Commit 0b83f2d3f7b for woocommerce
commit 0b83f2d3f7bde7c225722d0007ef51dd4a4c4418
Author: Brandon Kraft <public@brandonkraft.com>
Date: Mon Jul 13 08:14:24 2026 -0500
Invalidate the legacy meta cache in the HPOS order meta corruption guard (#66510)
diff --git a/plugins/woocommerce/changelog/66508-fix-hpos-legacy-meta-cache-self-heal b/plugins/woocommerce/changelog/66508-fix-hpos-legacy-meta-cache-self-heal
new file mode 100644
index 00000000000..0ecebea3a0d
--- /dev/null
+++ b/plugins/woocommerce/changelog/66508-fix-hpos-legacy-meta-cache-self-heal
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Invalidate the correct meta cache group when the HPOS order meta corruption guard fires, so a corrupt legacy object-cache entry is re-read from the database instead of persisting across reads.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-data.php b/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
index df82b590034..d4099640a4c 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
@@ -668,6 +668,26 @@ abstract class WC_Data {
}
}
+ /**
+ * Delete this object's cached raw meta data entry, if a cache group is set.
+ *
+ * Counterpart to prime_raw_meta_data_cache(): it removes the entry stored under this object's
+ * own cache group (for example 'orders' for WC_Abstract_Order), so the next read_meta_data()
+ * misses the cache and re-reads from the database. Used to recover from a corrupt persistent
+ * object cache entry.
+ *
+ * @since 11.0.0
+ *
+ * @return void
+ */
+ public function delete_meta_cache(): void {
+ if ( empty( $this->cache_group ) || ! $this->get_id() ) {
+ return;
+ }
+ $cache_key = self::generate_meta_cache_key( $this->get_id(), $this->cache_group );
+ wp_cache_delete( $cache_key, $this->cache_group );
+ }
+
/**
* Read Meta Data from the database. Ignore any internal properties.
* Uses it's own caches because get_metadata does not provide meta_ids.
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index f70e1081fcb..233f084f08b 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -1485,19 +1485,25 @@ WHERE
public function filter_raw_meta_data( &$object, $raw_meta_data ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.objectFound
/*
- * Defensive last-resort guard. The meta data should always arrive as an array of meta-row
- * objects, but a corrupt persistent object cache entry can surface as a scalar, an object,
- * or a well-formed array whose elements are not meta rows. Any of these would otherwise
- * fatal downstream (array_filter()/array_diff() on a non-array, or $meta->meta_key on a
- * non-object element). Drop anything that is not a usable meta row so the order still
- * loads, and when corruption is detected invalidate the cached entry so the next read
- * self-heals from the database.
+ * Defensive last-resort guard. The meta data should always arrive as an array of complete
+ * meta-row objects, but a corrupt persistent object cache entry can surface as a scalar, an
+ * object, a well-formed array whose elements are not meta rows, or an array of objects that
+ * are missing required columns. Any of these would otherwise fatal or silently load wrong
+ * values downstream (array_filter()/array_diff() on a non-array, $meta->meta_key on a
+ * non-object element, or a real key hydrated with a null value from a partial row). A row is
+ * only usable when it carries meta_id, meta_key and meta_value - the same completeness check
+ * OrdersTableDataStoreMeta::is_valid_cached_meta() applies at the HPOS cache boundary. Drop
+ * anything that is not a usable meta row so the order still loads, and when corruption is
+ * detected invalidate the cached entries so the next read self-heals from the database.
*/
$is_corrupt = false;
if ( is_array( $raw_meta_data ) ) {
$valid_meta_data = array_filter(
$raw_meta_data,
- static fn( $meta ) => is_object( $meta ) && property_exists( $meta, 'meta_key' )
+ static fn( $meta ) => is_object( $meta )
+ && property_exists( $meta, 'meta_id' )
+ && property_exists( $meta, 'meta_key' )
+ && property_exists( $meta, 'meta_value' )
);
$is_corrupt = count( $valid_meta_data ) !== count( $raw_meta_data );
$raw_meta_data = $valid_meta_data;
@@ -1514,7 +1520,25 @@ WHERE
),
array( 'source' => 'hpos-data-cache' )
);
+
+ /*
+ * Invalidate every cache the corrupt value could have come from so the next read
+ * self-heals from the database. The HPOS meta cache (orders_meta group) is primed by
+ * init_order_record(), while WC_Data::read_meta_data() reads the object's own legacy
+ * meta cache (the 'orders' group for orders) and, on a cache hit, skips re-caching -
+ * so without clearing that group the corrupt entry would persist across reads.
+ */
$this->data_store_meta->clear_cached_data( array( $object->get_id() ) );
+
+ /*
+ * delete_meta_cache() is defined on WC_Data, so $object always has it in a consistent
+ * deploy. The guard only covers the brief window during a plugin upgrade where this
+ * (newer) class can be loaded before an opcode-cached, older abstract-wc-data.php gains
+ * the method, which would otherwise fatal on an undefined method.
+ */
+ if ( is_callable( array( $object, 'delete_meta_cache' ) ) ) {
+ $object->delete_meta_cache();
+ }
}
$filtered_meta_data = parent::filter_raw_meta_data( $object, $raw_meta_data );
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreCacheCrossBleedTest.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreCacheCrossBleedTest.php
index 01a3a9b9ad7..c36a8204755 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreCacheCrossBleedTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreCacheCrossBleedTest.php
@@ -633,6 +633,145 @@ class OrdersTableDataStoreCacheCrossBleedTest extends \HposTestCase {
remove_all_filters( 'woocommerce_logging_class' );
}
+ /**
+ * @testdox A corrupt legacy ('orders' group) meta cache entry is invalidated so the next read_meta_data() self-heals from the database.
+ */
+ public function test_corrupt_legacy_meta_cache_entry_is_invalidated_and_self_heals(): void {
+ $fake_logger = $this->create_fake_logger();
+ add_filter(
+ 'woocommerce_logging_class',
+ function () use ( $fake_logger ) {
+ return $fake_logger;
+ }
+ );
+
+ // Rebuild the data store so its injected logger (captured in init() via wc_get_logger())
+ // is the fake logger added above, and orders created below use this instance.
+ $container = wc_get_container();
+ $container->reset_all_resolved();
+ $container->get( OrdersTableDataStore::class );
+
+ $order = new WC_Order();
+ $order->add_meta_data( 'custom_meta_key', 'custom_value', true );
+ $order->save();
+ $order_id = $order->get_id();
+
+ // Reload a fresh order object so its meta cache key reflects current cache prefixes.
+ $order = wc_get_order( $order_id );
+
+ /*
+ * Poison the legacy meta cache group that WC_Data::read_meta_data() reads
+ * (WC_Abstract_Order::$cache_group = 'orders') with a well-formed array whose elements are
+ * not meta rows. This passes the top-level is_array() check in read_meta_data(), so the
+ * corruption guard in filter_raw_meta_data() - not the HPOS 'orders_meta' boundary - is the
+ * layer that must invalidate it.
+ */
+ $cache_key = $order->get_meta_cache_key();
+ wp_cache_set( $cache_key, array( 'not-a-meta-row' ), 'orders' );
+ $this->assertIsArray( wp_cache_get( $cache_key, 'orders' ), 'Sanity: the legacy meta cache entry should be primed.' );
+
+ // Force the legacy read path directly (init_order_record() primes meta_data, so the normal
+ // wc_get_order() flow does not re-enter read_meta_data()).
+ $order->read_meta_data();
+
+ $this->assert_hpos_cache_warning_logged( $fake_logger, 'Discarded malformed meta data' );
+
+ // The corrupt legacy entry must have been invalidated - otherwise read_meta_data() would keep
+ // loading it (it skips re-caching on a cache hit) and re-log on every read.
+ $this->assertFalse(
+ wp_cache_get( $cache_key, 'orders' ),
+ 'The corrupt legacy meta cache entry should be invalidated so the next read re-reads from the database.'
+ );
+
+ // The next read misses the cache, re-reads from the database, and recovers the real value.
+ $order->read_meta_data();
+ $this->assertSame(
+ 'custom_value',
+ $order->get_meta( 'custom_meta_key' ),
+ 'Meta should self-heal from the database after the corrupt legacy cache entry is invalidated.'
+ );
+
+ // The self-healed read must not re-log: the warning fires once, not on every read.
+ $this->assertSame(
+ 1,
+ $this->count_hpos_cache_warnings( $fake_logger, 'Discarded malformed meta data' ),
+ 'The corruption warning should fire once and stop once the cache self-heals.'
+ );
+
+ remove_all_filters( 'woocommerce_logging_class' );
+ }
+
+ /**
+ * @testdox A legacy meta cache array containing an incomplete meta row (missing meta_value) is treated as corrupt, invalidated, and self-heals.
+ */
+ public function test_incomplete_legacy_meta_row_is_invalidated_and_self_heals(): void {
+ $fake_logger = $this->create_fake_logger();
+ add_filter(
+ 'woocommerce_logging_class',
+ function () use ( $fake_logger ) {
+ return $fake_logger;
+ }
+ );
+
+ // Rebuild the data store so its injected logger (captured in init() via wc_get_logger())
+ // is the fake logger added above, and orders created below use this instance.
+ $container = wc_get_container();
+ $container->reset_all_resolved();
+ $container->get( OrdersTableDataStore::class );
+
+ $order = new WC_Order();
+ $order->add_meta_data( 'custom_meta_key', 'custom_value', true );
+ $order->save();
+ $order_id = $order->get_id();
+
+ $order = wc_get_order( $order_id );
+
+ /*
+ * Poison the legacy 'orders' meta cache with an object row that has meta_key but is missing
+ * meta_id and meta_value. This is a valid array of objects with meta_key, so a meta_key-only
+ * shape check would accept it and hydrate the real key with a null value. The completeness
+ * check (meta_id + meta_key + meta_value) must treat it as corrupt and self-heal instead.
+ */
+ $cache_key = $order->get_meta_cache_key();
+ wp_cache_set( $cache_key, array( (object) array( 'meta_key' => 'custom_meta_key' ) ), 'orders' ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+
+ $order->read_meta_data();
+
+ $this->assert_hpos_cache_warning_logged( $fake_logger, 'Discarded malformed meta data' );
+ $this->assertFalse(
+ wp_cache_get( $cache_key, 'orders' ),
+ 'The incomplete legacy meta cache entry should be invalidated so the next read re-reads from the database.'
+ );
+
+ $order->read_meta_data();
+ $this->assertSame(
+ 'custom_value',
+ $order->get_meta( 'custom_meta_key' ),
+ 'The incomplete row should be discarded and the real value re-read from the database, not loaded as null.'
+ );
+
+ remove_all_filters( 'woocommerce_logging_class' );
+ }
+
+ /**
+ * Count warnings with the "hpos-data-cache" source containing the given message fragment.
+ *
+ * @param object $fake_logger The fake logger capturing log calls.
+ * @param string $needle A substring expected in the warning message.
+ *
+ * @return int Number of matching warnings.
+ */
+ private function count_hpos_cache_warnings( object $fake_logger, string $needle ): int {
+ $count = 0;
+ foreach ( $fake_logger->warning_calls as $call ) {
+ if ( 'hpos-data-cache' === ( $call['context']['source'] ?? '' ) && false !== strpos( $call['message'], $needle ) ) {
+ ++$count;
+ }
+ }
+
+ return $count;
+ }
+
/**
* Assert that a warning with the "hpos-data-cache" source and the given message fragment was logged.
*