Commit 599ca7d753 for woocommerce
commit 599ca7d7537893ba5367601d9acbc9b802e5c8b7
Author: Michael Pretty <prettyboymp@users.noreply.github.com>
Date: Thu Feb 19 18:22:55 2026 -0500
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>
diff --git a/plugins/woocommerce/changelog/wooplug-6264-cache-products-last-modified b/plugins/woocommerce/changelog/wooplug-6264-cache-products-last-modified
new file mode 100644
index 0000000000..0f9f99d32f
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6264-cache-products-last-modified
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Cache Store API products last modified timestamp in the object cache to avoid a database query on every request.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/class-wc-post-data.php b/plugins/woocommerce/includes/class-wc-post-data.php
index 2b7635b007..ffe9bc552d 100644
--- a/plugins/woocommerce/includes/class-wc-post-data.php
+++ b/plugins/woocommerce/includes/class-wc-post-data.php
@@ -37,6 +37,7 @@ class WC_Post_Data {
* @return void
*/
public static function init() {
+ add_action( 'clean_post_cache', array( __CLASS__, 'invalidate_products_last_modified' ), 10, 2 );
add_filter( 'post_type_link', array( __CLASS__, 'variation_post_link' ), 10, 2 );
add_action( 'shutdown', array( __CLASS__, 'do_deferred_product_sync' ), 10 );
add_action( 'set_object_terms', array( __CLASS__, 'force_default_term' ), 10, 5 );
@@ -156,6 +157,28 @@ class WC_Post_Data {
WC_Cache_Helper::get_transient_version( 'product_query', true );
}
+ /**
+ * Invalidate the cached products last modified timestamp when a product post cache is cleaned.
+ *
+ * This does not use wp_cache_set_last_changed() because the cached value is exposed to
+ * clients via the Last-Modified HTTP header for collection cache invalidation. WordPress
+ * core's last_changed pattern auto-seeds with the current time on cache miss, which is
+ * acceptable for opaque cache-key salts but would force all clients to unnecessarily
+ * invalidate their local caches. Instead, invalidating the cache here allows the read side
+ * in ProductQuery::get_last_modified() to fall back to the DB and re-seed with the real
+ * last modification time.
+ *
+ * @since 10.6.0
+ *
+ * @param int $post_id Post ID.
+ * @param WP_Post $post Post object.
+ */
+ public static function invalidate_products_last_modified( $post_id, $post ): void {
+ if ( $post instanceof WP_Post && in_array( $post->post_type, array( 'product', 'product_variation' ), true ) ) {
+ wp_cache_delete( 'last_modified', 'wc_products' );
+ }
+ }
+
/**
* Handle type changes.
*
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 29a389d044..0918415e3a 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -74004,12 +74004,6 @@ parameters:
count: 1
path: src/StoreApi/Routes/V1/Products.php
- -
- message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int\<min, \-1\>\|int\<1, max\> given\.$#'
- identifier: argument.type
- count: 1
- path: src/StoreApi/Routes/V1/Products.php
-
-
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$#'
identifier: missingType.generics
@@ -75600,12 +75594,6 @@ parameters:
count: 1
path: src/StoreApi/Utilities/ProductQuery.php
- -
- message: '#^Method Automattic\\WooCommerce\\StoreApi\\Utilities\\ProductQuery\:\:get_last_modified\(\) should return int but returns int\|false\|null\.$#'
- identifier: return.type
- count: 1
- path: src/StoreApi/Utilities/ProductQuery.php
-
-
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$#'
identifier: missingType.generics
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
index 9677cecd3c..2db3c1136d 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
@@ -335,16 +335,41 @@ class ProductQuery implements QueryClausesGenerator {
}
/**
- * Get last modified date for all products.
+ * Get last modified date for all products as an HTTP-date (RFC 7232).
*
- * @return int timestamp.
+ * The result is cached in the 'wc_products' object cache group and invalidated via the
+ * clean_post_cache hook in WC_Post_Data::invalidate_products_last_modified().
+ *
+ * Note: This intentionally does NOT use WordPress core's wp_cache_get_last_changed() /
+ * wp_cache_set_last_changed() pattern. Those functions are designed for opaque cache-key
+ * salting where auto-seeding with the current time on a cache miss is acceptable (a wrong
+ * salt simply causes a cache miss and re-query). Here, the value is exposed to clients via
+ * the Last-Modified HTTP header for collection cache invalidation. Auto-seeding with "now"
+ * on a cache miss would force all clients to unnecessarily invalidate their local caches.
+ * Instead, on a cache miss we fall back to the database to get the real last modification
+ * time and cache that.
+ *
+ * @return string|null HTTP-date formatted string, or null if no products exist.
*/
public function get_last_modified() {
- global $wpdb;
+ $last_modified = wp_cache_get( 'last_modified', 'wc_products' );
- $last_modified = $wpdb->get_var( "SELECT MAX( post_modified_gmt ) FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' );" );
+ if ( false === $last_modified ) {
+ global $wpdb;
+
+ $last_modified_gmt = $wpdb->get_var(
+ "SELECT MAX( post_modified_gmt ) FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' )"
+ );
+
+ if ( ! $last_modified_gmt ) {
+ return null;
+ }
+
+ $last_modified = gmdate( 'D, d M Y H:i:s', strtotime( $last_modified_gmt ) ) . ' GMT';
+ wp_cache_set( 'last_modified', $last_modified, 'wc_products' );
+ }
- return $last_modified ? strtotime( $last_modified ) : null;
+ return $last_modified;
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryTest.php
new file mode 100644
index 0000000000..ed90509ccc
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryTest.php
@@ -0,0 +1,217 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Utilities;
+
+use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
+use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
+
+/**
+ * Unit tests for the ProductQuery::get_last_modified() caching behavior.
+ */
+class ProductQueryTest extends \WC_Unit_Test_Case {
+
+ /**
+ * @var ProductQuery
+ */
+ private ProductQuery $product_query;
+
+ /**
+ * Setup test data. Called before every test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->product_query = new ProductQuery();
+ wp_cache_delete( 'last_modified', 'wc_products' );
+ }
+
+ /**
+ * @testdox get_last_modified returns null when no products exist.
+ */
+ public function test_get_last_modified_returns_null_when_no_products(): void {
+ global $wpdb;
+
+ // Temporarily remove all product posts to test the null case.
+ $original_posts = $wpdb->get_results(
+ "SELECT ID, post_type, post_status FROM {$wpdb->posts} WHERE post_type IN ('product', 'product_variation')"
+ );
+ $wpdb->query(
+ "UPDATE {$wpdb->posts} SET post_type = '_tmp_hidden' WHERE post_type IN ('product', 'product_variation')"
+ );
+
+ $result = $this->product_query->get_last_modified();
+
+ // Restore original posts.
+ $wpdb->query(
+ "UPDATE {$wpdb->posts} SET post_type = REPLACE(post_type, '_tmp_hidden', '') WHERE post_type = '_tmp_hidden'"
+ );
+
+ // Restore correct post types from the saved data.
+ foreach ( $original_posts as $post ) {
+ $wpdb->update( $wpdb->posts, array( 'post_type' => $post->post_type ), array( 'ID' => $post->ID ) );
+ }
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @testdox get_last_modified returns an HTTP-date formatted string.
+ */
+ public function test_get_last_modified_returns_http_date_format(): void {
+ $fixtures = new FixtureData();
+ $fixtures->get_simple_product(
+ array(
+ 'name' => 'Test Product',
+ 'regular_price' => 10,
+ )
+ );
+
+ $result = $this->product_query->get_last_modified();
+
+ $this->assertNotNull( $result );
+ $this->assertStringEndsWith( 'GMT', $result );
+ // Verify it parses as a valid date.
+ $this->assertNotFalse( strtotime( $result ) );
+ }
+
+ /**
+ * @testdox get_last_modified caches the result in the object cache.
+ */
+ public function test_get_last_modified_caches_result(): void {
+ $fixtures = new FixtureData();
+ $fixtures->get_simple_product(
+ array(
+ 'name' => 'Test Product',
+ 'regular_price' => 10,
+ )
+ );
+
+ // First call seeds the cache.
+ $result = $this->product_query->get_last_modified();
+
+ // Verify cache is populated.
+ $cached = wp_cache_get( 'last_modified', 'wc_products' );
+ $this->assertNotFalse( $cached );
+ $this->assertSame( $result, $cached );
+ }
+
+ /**
+ * @testdox get_last_modified returns cached value without querying the database.
+ */
+ public function test_get_last_modified_uses_cached_value(): void {
+ $sentinel = 'Thu, 01 Jan 2099 00:00:00 GMT';
+ wp_cache_set( 'last_modified', $sentinel, 'wc_products' );
+
+ $result = $this->product_query->get_last_modified();
+
+ $this->assertSame( $sentinel, $result );
+ }
+
+ /**
+ * @testdox Cache is invalidated when a product post cache is cleaned.
+ */
+ public function test_cache_invalidated_on_product_change(): void {
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Test Product',
+ 'regular_price' => 10,
+ )
+ );
+
+ // Seed the cache.
+ $this->product_query->get_last_modified();
+ $this->assertNotFalse( wp_cache_get( 'last_modified', 'wc_products' ) );
+
+ // Simulate product change — clean_post_cache fires WC_Post_Data::invalidate_products_last_modified.
+ clean_post_cache( $product->get_id() );
+
+ $this->assertFalse( wp_cache_get( 'last_modified', 'wc_products' ) );
+ }
+
+ /**
+ * @testdox Cache is invalidated when a product variation post cache is cleaned.
+ */
+ public function test_cache_invalidated_on_variation_change(): void {
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Test Product',
+ 'regular_price' => 10,
+ )
+ );
+
+ // Create a variation post directly to avoid complex variable product setup.
+ $variation_id = wp_insert_post(
+ array(
+ 'post_type' => 'product_variation',
+ 'post_parent' => $product->get_id(),
+ 'post_status' => 'publish',
+ )
+ );
+
+ // Seed the cache.
+ $this->product_query->get_last_modified();
+ $this->assertNotFalse( wp_cache_get( 'last_modified', 'wc_products' ) );
+
+ // Clean variation post cache.
+ clean_post_cache( $variation_id );
+
+ $this->assertFalse( wp_cache_get( 'last_modified', 'wc_products' ) );
+ }
+
+ /**
+ * @testdox Cache is NOT invalidated when a non-product post cache is cleaned.
+ */
+ public function test_cache_not_invalidated_on_non_product_change(): void {
+ $fixtures = new FixtureData();
+ $fixtures->get_simple_product(
+ array(
+ 'name' => 'Test Product',
+ 'regular_price' => 10,
+ )
+ );
+
+ // Seed the cache.
+ $this->product_query->get_last_modified();
+ $cached_value = wp_cache_get( 'last_modified', 'wc_products' );
+ $this->assertNotFalse( $cached_value );
+
+ // Create and clean a regular post.
+ $post_id = wp_insert_post(
+ array(
+ 'post_title' => 'Regular Post',
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ )
+ );
+ clean_post_cache( $post_id );
+
+ $this->assertSame( $cached_value, wp_cache_get( 'last_modified', 'wc_products' ) );
+ }
+
+ /**
+ * @testdox get_last_modified re-seeds cache from DB after invalidation.
+ */
+ public function test_get_last_modified_reseeds_after_invalidation(): void {
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Test Product',
+ 'regular_price' => 10,
+ )
+ );
+
+ // Seed the cache.
+ $first_result = $this->product_query->get_last_modified();
+
+ // Invalidate.
+ clean_post_cache( $product->get_id() );
+
+ // Next call should re-query DB and re-seed.
+ $second_result = $this->product_query->get_last_modified();
+
+ $this->assertNotNull( $second_result );
+ $this->assertNotFalse( wp_cache_get( 'last_modified', 'wc_products' ) );
+ }
+}