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.
 	 *