Commit 7513d9dbb72 for woocommerce
commit 7513d9dbb7295cc08b98cf0882ad3b67d21fd9e6
Author: SH Sajal Chowdhury <72102985+shsajalchowdhury@users.noreply.github.com>
Date: Tue May 5 01:01:04 2026 +0600
Fix report transient cache key instability caused by DateTime microsecond serialization (#64430)
* Fix transient cache key instability caused by DateTime microsecond serialization
When get_cache_key() serialized DateTime objects via wp_json_encode,
the resulting cache key included microsecond precision from WC_DateTime.
This caused a different transient key on every request, defeating caching.
Normalize DateTimeInterface objects to ISO 8601 strings (DATE_ATOM format)
before hashing, which strips microseconds and produces stable cache keys.
Fixes #64177
* Improve test: verify DateTime timestamp and timezone are not mutated after get_cache_key
Address CodeRabbit feedback: use clone and compare getTimestamp() and
getTimezone()->getName() instead of only asserting instanceof.
* Disable report caching in customers test class
The test_user_creation test exercises query semantics, not cache
behaviour. Stable cache keys (introduced by the DateTime normalization
fix) surfaced a latent cache-invalidation timing bug tracked in #64557.
Bypass the cache via the existing woocommerce_analytics_report_should_use_cache
filter so the tests are not affected by the separate caching issue.
Per review feedback from @prettyboymp on #64430.
---------
Co-authored-by: Michael Pretty <prettyboymp@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/fix-64177-transient-cache-key-datetime b/plugins/woocommerce/changelog/fix-64177-transient-cache-key-datetime
new file mode 100644
index 00000000000..560519fce44
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-64177-transient-cache-key-datetime
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix report transient cache key instability caused by DateTime serialization with microseconds in get_cache_key
diff --git a/plugins/woocommerce/src/Admin/API/Reports/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
index 6c65c93cc1b..925a523ac46 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
@@ -334,6 +334,18 @@ class DataStore extends SqlQuery implements DataStoreInterface {
return ! empty( $param );
}
);
+
+ // Normalize DateTime objects to ISO 8601 strings to avoid cache key
+ // instability caused by microsecond-level differences in serialization.
+ array_walk(
+ $params,
+ function ( &$value ) {
+ if ( $value instanceof \DateTimeInterface ) {
+ $value = $value->format( DATE_ATOM );
+ }
+ }
+ );
+
ksort( $params );
return implode(
'_',
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
index a1358ea66af..ed0c99d2617 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
@@ -40,6 +40,20 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
'role' => 'administrator',
)
);
+
+ // Disable report caching for this test class — these tests exercise
+ // query semantics, not cache behaviour. A separate issue (#64557)
+ // tracks the cache-invalidation timing bug that stable cache keys
+ // have surfaced.
+ add_filter( 'woocommerce_analytics_report_should_use_cache', '__return_false' );
+ }
+
+ /**
+ * Clean up after tests.
+ */
+ public function tearDown(): void {
+ remove_filter( 'woocommerce_analytics_report_should_use_cache', '__return_false' );
+ parent::tearDown();
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/DataStore/CacheKeyTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/DataStore/CacheKeyTest.php
new file mode 100644
index 00000000000..6cb517b39e1
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/DataStore/CacheKeyTest.php
@@ -0,0 +1,94 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\API\Reports\DataStore;
+
+use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the base Reports DataStore cache key generation.
+ */
+class CacheKeyTest extends WC_Unit_Test_Case {
+
+ /**
+ * @testdox get_cache_key produces identical keys for DateTime objects representing the same second.
+ */
+ public function test_cache_key_is_stable_across_datetime_objects_with_same_second(): void {
+ $store = new CustomersDataStore();
+
+ // Two DateTime objects with the same timestamp but different microseconds.
+ $dt1 = new \DateTime( '2026-04-27 12:00:00.123456' );
+ $dt2 = new \DateTime( '2026-04-27 12:00:00.654321' );
+
+ $key1 = $this->invoke_get_cache_key( $store, array( 'before' => $dt1 ) );
+ $key2 = $this->invoke_get_cache_key( $store, array( 'before' => $dt2 ) );
+
+ $this->assertSame( $key1, $key2, 'Cache keys should be identical when DateTime objects differ only in microseconds.' );
+ }
+
+ /**
+ * @testdox get_cache_key produces different keys for DateTime objects with different seconds.
+ */
+ public function test_cache_key_differs_for_different_dates(): void {
+ $store = new CustomersDataStore();
+
+ $dt1 = new \DateTime( '2026-04-27 12:00:00' );
+ $dt2 = new \DateTime( '2026-04-27 12:00:01' );
+
+ $key1 = $this->invoke_get_cache_key( $store, array( 'before' => $dt1 ) );
+ $key2 = $this->invoke_get_cache_key( $store, array( 'before' => $dt2 ) );
+
+ $this->assertNotSame( $key1, $key2, 'Cache keys should differ when DateTime objects represent different moments.' );
+ }
+
+ /**
+ * @testdox get_cache_key produces identical keys for both before and after DateTime params in the same second.
+ */
+ public function test_cache_key_is_stable_with_before_and_after(): void {
+ $store = new CustomersDataStore();
+
+ $params1 = array(
+ 'before' => new \DateTime( '2026-04-27 12:00:00.111111' ),
+ 'after' => new \DateTime( '2026-04-20 12:00:00.222222' ),
+ );
+ $params2 = array(
+ 'before' => new \DateTime( '2026-04-27 12:00:00.999999' ),
+ 'after' => new \DateTime( '2026-04-20 12:00:00.888888' ),
+ );
+
+ $key1 = $this->invoke_get_cache_key( $store, $params1 );
+ $key2 = $this->invoke_get_cache_key( $store, $params2 );
+
+ $this->assertSame( $key1, $key2, 'Cache keys should be identical when both before/after differ only in microseconds.' );
+ }
+
+ /**
+ * @testdox get_cache_key does not modify the original params array's DateTime objects.
+ */
+ public function test_get_cache_key_does_not_modify_original_params(): void {
+ $store = new CustomersDataStore();
+
+ $original = new \DateTime( '2026-04-27 12:00:00.123456', new \DateTimeZone( 'UTC' ) );
+ $params = array( 'before' => clone $original );
+
+ $this->invoke_get_cache_key( $store, $params );
+
+ $this->assertInstanceOf( \DateTime::class, $params['before'], 'DateTime object in original params should remain a DateTime.' );
+ $this->assertSame( $original->getTimestamp(), $params['before']->getTimestamp(), 'DateTime timestamp should not be mutated.' );
+ $this->assertSame( $original->getTimezone()->getName(), $params['before']->getTimezone()->getName(), 'DateTime timezone should not be mutated.' );
+ }
+
+ /**
+ * Call the protected get_cache_key method via reflection.
+ *
+ * @param CustomersDataStore $store DataStore instance.
+ * @param array $params Parameters.
+ * @return string Cache key.
+ */
+ private function invoke_get_cache_key( CustomersDataStore $store, array $params ): string {
+ $method = new \ReflectionMethod( $store, 'get_cache_key' );
+ $method->setAccessible( true );
+ return $method->invoke( $store, $params );
+ }
+}