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' ) );
+	}
+}