Commit 649f854c3fb for woocommerce

commit 649f854c3fbd2fdfc5d44853b0fd431bd7969409
Author: Brandon Kraft <public@brandonkraft.com>
Date:   Tue Jul 21 15:03:08 2026 -0600

    Fix HPOS meta guard flagging extension virtual meta as corrupt (#66806)

    * Fix HPOS meta guard flagging filter-injected virtual meta as corrupt

    * Add changelog entry for HPOS virtual meta guard fix

    * Use correct post read-meta filter hook in guard test and docblock

diff --git a/plugins/woocommerce/changelog/66551-fix-hpos-virtual-meta-corruption-guard b/plugins/woocommerce/changelog/66551-fix-hpos-virtual-meta-corruption-guard
new file mode 100644
index 00000000000..24f8dc4dba5
--- /dev/null
+++ b/plugins/woocommerce/changelog/66551-fix-hpos-virtual-meta-corruption-guard
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop the HPOS order meta corruption guard from treating extension-injected virtual meta rows (a meta_key and meta_value but no meta_id) as corrupt, which purged the meta cache and logged a warning on every read.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-data.php b/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
index d4099640a4c..0fcfdc8ab39 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
@@ -740,7 +740,8 @@ abstract class WC_Data {
 		foreach ( $filtered_meta_data as $meta ) {
 			$this->meta_data[] = new WC_Meta_Data(
 				array(
-					'id'    => (int) $meta->meta_id,
+					// Virtual meta injected by data-store read filters can omit meta_id; default it to 0 rather than emitting an undefined-property warning.
+					'id'    => (int) ( $meta->meta_id ?? 0 ),
 					'key'   => $meta->meta_key,
 					'value' => maybe_unserialize( $meta->meta_value ),
 				)
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index 233f084f08b..5be6a1596c8 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -1485,14 +1485,24 @@ 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 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
+		 * 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, 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 usable when it carries meta_key and meta_value - the fields init_meta_data() reads
+		 * as data. meta_id is intentionally NOT required here: unlike OrdersTableDataStoreMeta::
+		 * is_valid_cached_meta(), which validates raw database rows at the HPOS 'orders_meta'
+		 * boundary (those always have a meta_id), this guard also runs against the post-filter output
+		 * that WC_Data::read_meta_data() caches and re-validates on a cache hit. That output can
+		 * legitimately include virtual rows injected by extensions via the
+		 * woocommerce_data_store_wp_post_read_meta filter (orders use the 'post' meta type), which
+		 * carry meta_key and meta_value but no meta_id (init_meta_data() reads such a row's id as 0).
+		 * Requiring meta_id would reclassify those legitimate rows as corrupt and churn the cache -
+		 * purging it and re-logging on every read - so we only treat a missing meta_key or meta_value
+		 * as corruption. 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.
 		 */
@@ -1501,7 +1511,6 @@ WHERE
 			$valid_meta_data = array_filter(
 				$raw_meta_data,
 				static fn( $meta ) => is_object( $meta )
-					&& property_exists( $meta, 'meta_id' )
 					&& property_exists( $meta, 'meta_key' )
 					&& property_exists( $meta, 'meta_value' )
 			);
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 c36a8204755..5b94151ea04 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreCacheCrossBleedTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreCacheCrossBleedTest.php
@@ -728,9 +728,9 @@ class OrdersTableDataStoreCacheCrossBleedTest extends \HposTestCase {

 		/*
 		 * 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.
+		 * 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_key + meta_value) must treat the missing value 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
@@ -753,6 +753,75 @@ class OrdersTableDataStoreCacheCrossBleedTest extends \HposTestCase {
 		remove_all_filters( 'woocommerce_logging_class' );
 	}

+	/**
+	 * @testdox A virtual meta row injected via the read-meta filter (meta_key/meta_value, no meta_id) is not treated as corrupt and does not churn the legacy meta cache.
+	 */
+	public function test_filter_injected_virtual_meta_is_not_treated_as_corrupt(): 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 );
+
+		/*
+		 * Simulate an extension that appends a virtual meta row on read. The row carries meta_key and
+		 * meta_value but no meta_id - a shape the guard must accept, not flag as corrupt.
+		 */
+		$read_meta_filter = function ( $meta_data ) {
+			$virtual             = new \stdClass();
+			$virtual->meta_key   = '_virtual_meta';
+			$virtual->meta_value = 'virtual_value';
+			$meta_data[]         = $virtual;
+			return $meta_data;
+		};
+		add_filter( 'woocommerce_data_store_wp_post_read_meta', $read_meta_filter );
+
+		// Make sure the legacy meta cache starts empty so the first read is a cache miss.
+		$cache_key = $order->get_meta_cache_key();
+		wp_cache_delete( $cache_key, 'orders' );
+
+		// First read: cache miss. Caches the post-filter output (including the injected virtual row)
+		// in the legacy 'orders' group and hydrates the object's meta data.
+		$order->read_meta_data();
+		$this->assertSame( 'virtual_value', $order->get_meta( '_virtual_meta' ), 'The injected virtual meta should load without a meta_id.' );
+		$this->assertIsArray( wp_cache_get( $cache_key, 'orders' ), 'The legacy meta cache should be primed after the first read.' );
+
+		// Second read: cache hit. The guard re-validates the cached post-filter data, which now
+		// includes the injected virtual row. It must not be reclassified as corrupt.
+		$order->read_meta_data();
+
+		$this->assertSame(
+			0,
+			$this->count_hpos_cache_warnings( $fake_logger, 'Discarded malformed meta data' ),
+			'A filter-injected virtual meta row must not trigger the corruption guard.'
+		);
+		$this->assertIsArray(
+			wp_cache_get( $cache_key, 'orders' ),
+			'The legacy meta cache must stay warm - the guard should not purge it for a filter-injected virtual row.'
+		);
+		$this->assertSame( 'virtual_value', $order->get_meta( '_virtual_meta' ), 'The injected virtual meta should still resolve after a cache hit.' );
+		$this->assertSame( 'custom_value', $order->get_meta( 'custom_meta_key' ), 'The real database meta should be unaffected.' );
+
+		remove_filter( 'woocommerce_data_store_wp_post_read_meta', $read_meta_filter );
+		remove_all_filters( 'woocommerce_logging_class' );
+	}
+
 	/**
 	 * Count warnings with the "hpos-data-cache" source containing the given message fragment.
 	 *