Lokasi ngalangkungan proxy:   [ UP ]  
[Ngawartoskeun bug]   [Panyetelan cookie]                
Skip to content

Commit 599ca7d

Browse files
prettyboympclaude
andauthored
Cache Store API products last modified timestamp (#63228)
* Cache Store API products last modified timestamp to avoid DB query per request The ProductQuery::get_last_modified() method ran a MAX() query against the posts table on every Store API /products request. This caches the result in the object cache and invalidates it via the clean_post_cache hook in WC_Post_Data, eliminating the query on subsequent requests. The cached value uses a dedicated key/group rather than WordPress core's wp_cache_*_last_changed() pattern because the value is exposed to clients via the Last-Modified header. Core's pattern auto-seeds with the current time on cache miss, which would force unnecessary client-side cache invalidation. Instead, on a miss we fall back to the DB for the real last modification time. Also normalizes the Last-Modified header to RFC 7232 HTTP-date format. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add changelog entry for products last modified caching Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3021d72 commit 599ca7d

5 files changed

Lines changed: 274 additions & 17 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: performance
3+
4+
Cache Store API products last modified timestamp in the object cache to avoid a database query on every request.

plugins/woocommerce/includes/class-wc-post-data.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class WC_Post_Data {
3737
* @return void
3838
*/
3939
public static function init() {
40+
add_action( 'clean_post_cache', array( __CLASS__, 'invalidate_products_last_modified' ), 10, 2 );
4041
add_filter( 'post_type_link', array( __CLASS__, 'variation_post_link' ), 10, 2 );
4142
add_action( 'shutdown', array( __CLASS__, 'do_deferred_product_sync' ), 10 );
4243
add_action( 'set_object_terms', array( __CLASS__, 'force_default_term' ), 10, 5 );
@@ -156,6 +157,28 @@ public static function delete_product_query_transients() {
156157
WC_Cache_Helper::get_transient_version( 'product_query', true );
157158
}
158159

160+
/**
161+
* Invalidate the cached products last modified timestamp when a product post cache is cleaned.
162+
*
163+
* This does not use wp_cache_set_last_changed() because the cached value is exposed to
164+
* clients via the Last-Modified HTTP header for collection cache invalidation. WordPress
165+
* core's last_changed pattern auto-seeds with the current time on cache miss, which is
166+
* acceptable for opaque cache-key salts but would force all clients to unnecessarily
167+
* invalidate their local caches. Instead, invalidating the cache here allows the read side
168+
* in ProductQuery::get_last_modified() to fall back to the DB and re-seed with the real
169+
* last modification time.
170+
*
171+
* @since 10.6.0
172+
*
173+
* @param int $post_id Post ID.
174+
* @param WP_Post $post Post object.
175+
*/
176+
public static function invalidate_products_last_modified( $post_id, $post ): void {
177+
if ( $post instanceof WP_Post && in_array( $post->post_type, array( 'product', 'product_variation' ), true ) ) {
178+
wp_cache_delete( 'last_modified', 'wc_products' );
179+
}
180+
}
181+
159182
/**
160183
* Handle type changes.
161184
*

plugins/woocommerce/phpstan-baseline.neon

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -74004,12 +74004,6 @@ parameters:
7400474004
count: 1
7400574005
path: src/StoreApi/Routes/V1/Products.php
7400674006

74007-
-
74008-
message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int\<min, \-1\>\|int\<1, max\> given\.$#'
74009-
identifier: argument.type
74010-
count: 1
74011-
path: src/StoreApi/Routes/V1/Products.php
74012-
7401374007
-
7401474008
message: '#^Method Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\ProductsById\:\:get_route_response\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
7401574009
identifier: missingType.generics
@@ -75600,12 +75594,6 @@ parameters:
7560075594
count: 1
7560175595
path: src/StoreApi/Utilities/ProductQuery.php
7560275596

75603-
-
75604-
message: '#^Method Automattic\\WooCommerce\\StoreApi\\Utilities\\ProductQuery\:\:get_last_modified\(\) should return int but returns int\|false\|null\.$#'
75605-
identifier: return.type
75606-
count: 1
75607-
path: src/StoreApi/Utilities/ProductQuery.php
75608-
7560975597
-
7561075598
message: '#^Method Automattic\\WooCommerce\\StoreApi\\Utilities\\ProductQuery\:\:get_objects\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
7561175599
identifier: missingType.generics

plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -335,16 +335,41 @@ public function get_objects( $request ) {
335335
}
336336

337337
/**
338-
* Get last modified date for all products.
338+
* Get last modified date for all products as an HTTP-date (RFC 7232).
339339
*
340-
* @return int timestamp.
340+
* The result is cached in the 'wc_products' object cache group and invalidated via the
341+
* clean_post_cache hook in WC_Post_Data::invalidate_products_last_modified().
342+
*
343+
* Note: This intentionally does NOT use WordPress core's wp_cache_get_last_changed() /
344+
* wp_cache_set_last_changed() pattern. Those functions are designed for opaque cache-key
345+
* salting where auto-seeding with the current time on a cache miss is acceptable (a wrong
346+
* salt simply causes a cache miss and re-query). Here, the value is exposed to clients via
347+
* the Last-Modified HTTP header for collection cache invalidation. Auto-seeding with "now"
348+
* on a cache miss would force all clients to unnecessarily invalidate their local caches.
349+
* Instead, on a cache miss we fall back to the database to get the real last modification
350+
* time and cache that.
351+
*
352+
* @return string|null HTTP-date formatted string, or null if no products exist.
341353
*/
342354
public function get_last_modified() {
343-
global $wpdb;
355+
$last_modified = wp_cache_get( 'last_modified', 'wc_products' );
344356

345-
$last_modified = $wpdb->get_var( "SELECT MAX( post_modified_gmt ) FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' );" );
357+
if ( false === $last_modified ) {
358+
global $wpdb;
359+
360+
$last_modified_gmt = $wpdb->get_var(
361+
"SELECT MAX( post_modified_gmt ) FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' )"
362+
);
363+
364+
if ( ! $last_modified_gmt ) {
365+
return null;
366+
}
367+
368+
$last_modified = gmdate( 'D, d M Y H:i:s', strtotime( $last_modified_gmt ) ) . ' GMT';
369+
wp_cache_set( 'last_modified', $last_modified, 'wc_products' );
370+
}
346371

347-
return $last_modified ? strtotime( $last_modified ) : null;
372+
return $last_modified;
348373
}
349374

350375
/**
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
<?php
2+
declare( strict_types=1 );
3+
4+
namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Utilities;
5+
6+
use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
7+
use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
8+
9+
/**
10+
* Unit tests for the ProductQuery::get_last_modified() caching behavior.
11+
*/
12+
class ProductQueryTest extends \WC_Unit_Test_Case {
13+
14+
/**
15+
* @var ProductQuery
16+
*/
17+
private ProductQuery $product_query;
18+
19+
/**
20+
* Setup test data. Called before every test.
21+
*/
22+
public function setUp(): void {
23+
parent::setUp();
24+
$this->product_query = new ProductQuery();
25+
wp_cache_delete( 'last_modified', 'wc_products' );
26+
}
27+
28+
/**
29+
* @testdox get_last_modified returns null when no products exist.
30+
*/
31+
public function test_get_last_modified_returns_null_when_no_products(): void {
32+
global $wpdb;
33+
34+
// Temporarily remove all product posts to test the null case.
35+
$original_posts = $wpdb->get_results(
36+
"SELECT ID, post_type, post_status FROM {$wpdb->posts} WHERE post_type IN ('product', 'product_variation')"
37+
);
38+
$wpdb->query(
39+
"UPDATE {$wpdb->posts} SET post_type = '_tmp_hidden' WHERE post_type IN ('product', 'product_variation')"
40+
);
41+
42+
$result = $this->product_query->get_last_modified();
43+
44+
// Restore original posts.
45+
$wpdb->query(
46+
"UPDATE {$wpdb->posts} SET post_type = REPLACE(post_type, '_tmp_hidden', '') WHERE post_type = '_tmp_hidden'"
47+
);
48+
49+
// Restore correct post types from the saved data.
50+
foreach ( $original_posts as $post ) {
51+
$wpdb->update( $wpdb->posts, array( 'post_type' => $post->post_type ), array( 'ID' => $post->ID ) );
52+
}
53+
54+
$this->assertNull( $result );
55+
}
56+
57+
/**
58+
* @testdox get_last_modified returns an HTTP-date formatted string.
59+
*/
60+
public function test_get_last_modified_returns_http_date_format(): void {
61+
$fixtures = new FixtureData();
62+
$fixtures->get_simple_product(
63+
array(
64+
'name' => 'Test Product',
65+
'regular_price' => 10,
66+
)
67+
);
68+
69+
$result = $this->product_query->get_last_modified();
70+
71+
$this->assertNotNull( $result );
72+
$this->assertStringEndsWith( 'GMT', $result );
73+
// Verify it parses as a valid date.
74+
$this->assertNotFalse( strtotime( $result ) );
75+
}
76+
77+
/**
78+
* @testdox get_last_modified caches the result in the object cache.
79+
*/
80+
public function test_get_last_modified_caches_result(): void {
81+
$fixtures = new FixtureData();
82+
$fixtures->get_simple_product(
83+
array(
84+
'name' => 'Test Product',
85+
'regular_price' => 10,
86+
)
87+
);
88+
89+
// First call seeds the cache.
90+
$result = $this->product_query->get_last_modified();
91+
92+
// Verify cache is populated.
93+
$cached = wp_cache_get( 'last_modified', 'wc_products' );
94+
$this->assertNotFalse( $cached );
95+
$this->assertSame( $result, $cached );
96+
}
97+
98+
/**
99+
* @testdox get_last_modified returns cached value without querying the database.
100+
*/
101+
public function test_get_last_modified_uses_cached_value(): void {
102+
$sentinel = 'Thu, 01 Jan 2099 00:00:00 GMT';
103+
wp_cache_set( 'last_modified', $sentinel, 'wc_products' );
104+
105+
$result = $this->product_query->get_last_modified();
106+
107+
$this->assertSame( $sentinel, $result );
108+
}
109+
110+
/**
111+
* @testdox Cache is invalidated when a product post cache is cleaned.
112+
*/
113+
public function test_cache_invalidated_on_product_change(): void {
114+
$fixtures = new FixtureData();
115+
$product = $fixtures->get_simple_product(
116+
array(
117+
'name' => 'Test Product',
118+
'regular_price' => 10,
119+
)
120+
);
121+
122+
// Seed the cache.
123+
$this->product_query->get_last_modified();
124+
$this->assertNotFalse( wp_cache_get( 'last_modified', 'wc_products' ) );
125+
126+
// Simulate product change — clean_post_cache fires WC_Post_Data::invalidate_products_last_modified.
127+
clean_post_cache( $product->get_id() );
128+
129+
$this->assertFalse( wp_cache_get( 'last_modified', 'wc_products' ) );
130+
}
131+
132+
/**
133+
* @testdox Cache is invalidated when a product variation post cache is cleaned.
134+
*/
135+
public function test_cache_invalidated_on_variation_change(): void {
136+
$fixtures = new FixtureData();
137+
$product = $fixtures->get_simple_product(
138+
array(
139+
'name' => 'Test Product',
140+
'regular_price' => 10,
141+
)
142+
);
143+
144+
// Create a variation post directly to avoid complex variable product setup.
145+
$variation_id = wp_insert_post(
146+
array(
147+
'post_type' => 'product_variation',
148+
'post_parent' => $product->get_id(),
149+
'post_status' => 'publish',
150+
)
151+
);
152+
153+
// Seed the cache.
154+
$this->product_query->get_last_modified();
155+
$this->assertNotFalse( wp_cache_get( 'last_modified', 'wc_products' ) );
156+
157+
// Clean variation post cache.
158+
clean_post_cache( $variation_id );
159+
160+
$this->assertFalse( wp_cache_get( 'last_modified', 'wc_products' ) );
161+
}
162+
163+
/**
164+
* @testdox Cache is NOT invalidated when a non-product post cache is cleaned.
165+
*/
166+
public function test_cache_not_invalidated_on_non_product_change(): void {
167+
$fixtures = new FixtureData();
168+
$fixtures->get_simple_product(
169+
array(
170+
'name' => 'Test Product',
171+
'regular_price' => 10,
172+
)
173+
);
174+
175+
// Seed the cache.
176+
$this->product_query->get_last_modified();
177+
$cached_value = wp_cache_get( 'last_modified', 'wc_products' );
178+
$this->assertNotFalse( $cached_value );
179+
180+
// Create and clean a regular post.
181+
$post_id = wp_insert_post(
182+
array(
183+
'post_title' => 'Regular Post',
184+
'post_type' => 'post',
185+
'post_status' => 'publish',
186+
)
187+
);
188+
clean_post_cache( $post_id );
189+
190+
$this->assertSame( $cached_value, wp_cache_get( 'last_modified', 'wc_products' ) );
191+
}
192+
193+
/**
194+
* @testdox get_last_modified re-seeds cache from DB after invalidation.
195+
*/
196+
public function test_get_last_modified_reseeds_after_invalidation(): void {
197+
$fixtures = new FixtureData();
198+
$product = $fixtures->get_simple_product(
199+
array(
200+
'name' => 'Test Product',
201+
'regular_price' => 10,
202+
)
203+
);
204+
205+
// Seed the cache.
206+
$first_result = $this->product_query->get_last_modified();
207+
208+
// Invalidate.
209+
clean_post_cache( $product->get_id() );
210+
211+
// Next call should re-query DB and re-seed.
212+
$second_result = $this->product_query->get_last_modified();
213+
214+
$this->assertNotNull( $second_result );
215+
$this->assertNotFalse( wp_cache_get( 'last_modified', 'wc_products' ) );
216+
}
217+
}

0 commit comments

Comments
 (0)