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