Commit b39df028836 for woocommerce
commit b39df0288368ad662488446e9a26790d9cbab0f4
Author: Brandon Kraft <public@brandonkraft.com>
Date: Fri Jul 10 12:00:21 2026 -0500
Harden HPOS order/meta cache reads against corrupt object cache entries (#66131)
* Harden HPOS order/meta cache reads against corrupt object cache entries
A corrupt or cross-contaminated persistent object cache entry (e.g. a
string or stdClass where an array of meta rows is expected) fatals the
HPOS Orders screen in WC_Data_Store_WP::filter_raw_meta_data().
Validate entries read from the orders_data and orders_meta cache groups:
discard, invalidate, and log non-conforming entries so they are re-read
from the database and self-heal. Add a last-resort guard in
OrdersTableDataStore::filter_raw_meta_data() that treats a non-array
argument as empty meta instead of fataling.
* fix: use PHP 7.4-safe gettype() and satisfy phpcs in cache hardening
* fix: validate full shape of cached HPOS order and meta entries
Code review (ce-code-review + codex adversarial) surfaced that the
top-level shape guards were too loose:
- Order data: a stdClass with a missing or mismatched id passed the
is_object check, so read_multiple() could fatal on $order_data->id or
hydrate the wrong order (the cross-bleed class). Now require a stdClass
whose id matches the cache key, else discard, invalidate and re-read.
- Meta: validating only is_array let an array of non-meta-row elements
(or rows missing meta_id/meta_value) through, reproducing the original
filter_raw_meta_data() TypeError or loading real keys with null values.
Validate the full row shape at the cache boundary so corrupt entries are
re-read from the database in the same request.
filter_raw_meta_data() keeps a last-resort guard for paths that bypass the
cache boundary. Adds tests for element-level corruption, incomplete rows,
foreign objects, and id mismatch.
* fix: reject truncated HPOS order cache entries missing mapped columns
Per CodeRabbit review: the orders_data guard verified only stdClass +
matching id, so a truncated record (right id, missing mapped columns)
still passed and set_order_props_from_data() would default the missing
properties, hydrating stale/wrong order state instead of forcing a DB
reread. Validate the full cached shape against
get_all_order_column_mappings_for_cache() (the same source
get_order_data_for_ids_from_db() populates), so truncated or stale
pre-complete-column-set entries are invalidated and re-read. Adds a
regression test for a matching-id record missing a mapped column.
* style: use arrow function for meta-row validity closure
Addresses review nitpick.
diff --git a/plugins/woocommerce/changelog/fix-hpos-data-cache-corrupt-entry-fatal b/plugins/woocommerce/changelog/fix-hpos-data-cache-corrupt-entry-fatal
new file mode 100644
index 00000000000..69f0613e6fc
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-hpos-data-cache-corrupt-entry-fatal
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent a fatal TypeError on the HPOS Orders screen when a persistent object cache returns a corrupt order or meta cache entry: invalid entries are now discarded and re-read from the database instead of breaking the page.
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index a55f564b2a0..f70e1081fcb 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -1483,6 +1483,40 @@ WHERE
* @return array Filtered meta data.
*/
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.
+ */
+ $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' )
+ );
+ $is_corrupt = count( $valid_meta_data ) !== count( $raw_meta_data );
+ $raw_meta_data = $valid_meta_data;
+ } else {
+ $is_corrupt = true;
+ $raw_meta_data = array();
+ }
+
+ if ( $is_corrupt ) {
+ $this->error_logger->warning(
+ sprintf(
+ 'Discarded malformed meta data for order %1$d while reading; invalidating the cached entry so it is re-read from the database.',
+ (int) $object->get_id()
+ ),
+ array( 'source' => 'hpos-data-cache' )
+ );
+ $this->data_store_meta->clear_cached_data( array( $object->get_id() ) );
+ }
+
$filtered_meta_data = parent::filter_raw_meta_data( $object, $raw_meta_data );
$allowed_keys = array(
'_billing_address_index',
@@ -1943,8 +1977,71 @@ WHERE
*/
private function get_order_data_for_ids_from_cache( array $ids ): array {
$cache_engine = wc_get_container()->get( WPCacheEngine::class );
+ $order_data = $cache_engine->get_cached_objects( $ids, $this->get_cache_group() );
+
+ foreach ( $order_data as $id => $datum ) {
+ /*
+ * Cached order data is always a complete plain stdClass whose id matches the cache key
+ * and which carries every mapped column property (see get_order_data_for_ids_from_db()).
+ * Accept only that shape. Anything else - a scalar, an array, a foreign/incomplete
+ * object such as __PHP_Incomplete_Class, a record whose id is missing or belongs to a
+ * different order (cross-contamination), or a truncated record missing mapped columns -
+ * would fatal or silently hydrate the wrong/default order state downstream
+ * (read_multiple() dereferences $order_data->id and indexes $orders[$order_id];
+ * set_order_props_from_data() defaults any missing property). This can happen when a
+ * third-party persistent object cache returns a corrupt or cross-contaminated value, or
+ * when a stale entry written by an older version predates the complete-column-set fix.
+ * Invalidate the entry so it is re-read from the database, and surface it for diagnosis.
+ */
+ if ( $this->is_valid_cached_order_data( $datum, (int) $id ) ) {
+ continue;
+ }
+
+ if ( null !== $datum ) {
+ $cache_engine->delete_cached_object( $id, $this->get_cache_group() );
+ $this->error_logger->warning(
+ sprintf(
+ 'Discarded a corrupt HPOS order cache entry for order %1$d (unexpected shape, mismatched id, or missing mapped columns); it will be re-read from the database.',
+ (int) $id
+ ),
+ array( 'source' => 'hpos-data-cache' )
+ );
+ }
- return array_filter( $cache_engine->get_cached_objects( $ids, $this->get_cache_group() ) );
+ unset( $order_data[ $id ] );
+ }
+
+ return $order_data;
+ }
+
+ /**
+ * Determine whether a cached order data record is complete and belongs to the requested order.
+ *
+ * A valid entry is a plain stdClass whose id matches the cache key and which carries every
+ * column property that get_order_data_for_ids_from_db() populates from
+ * get_all_order_column_mappings_for_cache(). Rejecting truncated records forces a fresh database
+ * read instead of letting set_order_props_from_data() silently default the missing properties.
+ *
+ * @param mixed $datum The cached value to validate.
+ * @param int $id The order id the entry is cached under.
+ *
+ * @return bool True when the record is a complete order data object for the given id.
+ */
+ private function is_valid_cached_order_data( $datum, int $id ): bool {
+ if ( ! $datum instanceof \stdClass || ! property_exists( $datum, 'id' ) || (int) $datum->id !== $id ) {
+ return false;
+ }
+
+ foreach ( $this->get_all_order_column_mappings_for_cache() as $table_name => $column_mappings ) {
+ foreach ( $column_mappings as $field => $map ) {
+ $field_name = $map['name'] ?? "{$table_name}_$field";
+ if ( ! property_exists( $datum, $field_name ) ) {
+ return false;
+ }
+ }
+ }
+
+ return true;
}
/**
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStoreMeta.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStoreMeta.php
index 07c8930a9b1..85f309ed5bb 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStoreMeta.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStoreMeta.php
@@ -132,7 +132,66 @@ class OrdersTableDataStoreMeta extends CustomMetaDataStore {
$cache_engine = wc_get_container()->get( WPCacheEngine::class );
$meta_data = $cache_engine->get_cached_objects( $object_ids, $this->get_cache_group() );
- return array_filter( $meta_data );
+ foreach ( $meta_data as $object_id => $object_meta ) {
+ if ( null === $object_meta ) {
+ unset( $meta_data[ $object_id ] );
+ continue;
+ }
+
+ if ( $this->is_valid_cached_meta( $object_meta ) ) {
+ continue;
+ }
+
+ /*
+ * A malformed cache entry - not an array, or an array whose elements are not complete
+ * meta rows - would fatal or silently load wrong values downstream
+ * (WC_Data_Store_WP::filter_raw_meta_data() reads $meta->meta_key; WC_Data::init_meta_data()
+ * reads meta_id/meta_key/meta_value). This can happen when a third-party persistent
+ * object cache returns a corrupt or cross-contaminated value. Invalidate the entry and
+ * exclude it from the cache hits so it is re-read from the database in this same
+ * request, and surface it for diagnosis.
+ */
+ $cache_engine->delete_cached_object( $object_id, $this->get_cache_group() );
+ wc_get_logger()->warning(
+ sprintf(
+ 'Discarded a corrupt HPOS meta cache entry for order %1$d; it will be re-read from the database.',
+ (int) $object_id
+ ),
+ array( 'source' => 'hpos-data-cache' )
+ );
+ unset( $meta_data[ $object_id ] );
+ }
+
+ return $meta_data;
+ }
+
+ /**
+ * Determine whether a cached meta entry is a well-formed array of meta rows.
+ *
+ * An empty array is valid: it represents an order with no meta. Each non-empty element must be
+ * an object carrying the meta_id, meta_key and meta_value properties that database-backed rows
+ * always have (see CustomMetaDataStore::get_meta_data_for_object_ids()) and that downstream
+ * consumers read.
+ *
+ * @param mixed $object_meta The cached value to validate.
+ *
+ * @return bool True when the value is a usable array of meta rows.
+ */
+ private function is_valid_cached_meta( $object_meta ): bool {
+ if ( ! is_array( $object_meta ) ) {
+ return false;
+ }
+
+ foreach ( $object_meta as $meta_row ) {
+ if ( ! is_object( $meta_row )
+ || ! property_exists( $meta_row, 'meta_id' )
+ || ! property_exists( $meta_row, 'meta_key' )
+ || ! property_exists( $meta_row, 'meta_value' ) ) {
+ return false;
+ }
+ }
+
+ return true;
}
/**
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 0980a5235c6..01a3a9b9ad7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreCacheCrossBleedTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreCacheCrossBleedTest.php
@@ -301,6 +301,356 @@ class OrdersTableDataStoreCacheCrossBleedTest extends \HposTestCase {
remove_all_filters( 'woocommerce_logging_class' );
}
+ /**
+ * @testdox A corrupt (non-object) order data cache entry is discarded, re-read from the database, and logged.
+ */
+ public function test_corrupt_order_data_cache_entry_is_discarded_and_reread(): void {
+ $fake_logger = $this->create_fake_logger();
+ add_filter(
+ 'woocommerce_logging_class',
+ function () use ( $fake_logger ) {
+ return $fake_logger;
+ }
+ );
+
+ $container = wc_get_container();
+ $container->reset_all_resolved();
+ $sut = $container->get( OrdersTableDataStore::class );
+
+ $order = new WC_Order();
+ $order->set_status( 'completed' );
+ $order->set_total( '100.00' );
+ $order->save();
+ $order_id = $order->get_id();
+
+ $sut->clear_cached_data( array( $order_id ) );
+ wp_cache_flush();
+
+ // Poison the order data cache with a non-object value, simulating a corrupt object cache entry.
+ $cache_engine = $container->get( WPCacheEngine::class );
+ $cache_engine->cache_objects( array( $order_id => 'corrupt-cache-entry' ), 0, 'orders_data' );
+
+ $call_get_data = function ( $ids ) {
+ return $this->get_order_data_for_ids( $ids );
+ };
+ $order_data = $call_get_data->call( $sut, array( $order_id ) );
+
+ $this->assertArrayHasKey( $order_id, $order_data, 'Order data should still be returned after the corrupt cache entry is discarded.' );
+ $this->assertInstanceOf( \stdClass::class, $order_data[ $order_id ], 'The corrupt cache entry should be replaced by a fresh object read from the database.' );
+ $this->assertSame( $order_id, (int) $order_data[ $order_id ]->id );
+
+ // The corrupt entry should have been invalidated and re-cached with a valid object (self-heal).
+ $recached = $cache_engine->get_cached_objects( array( $order_id ), 'orders_data' );
+ $this->assertInstanceOf( \stdClass::class, $recached[ $order_id ], 'The corrupt entry should be replaced in cache by a valid object.' );
+
+ $this->assert_hpos_cache_warning_logged( $fake_logger, 'corrupt HPOS order cache entry' );
+
+ remove_all_filters( 'woocommerce_logging_class' );
+ }
+
+ /**
+ * @testdox A non-stdClass object order data cache entry is discarded, re-read from the database, and logged.
+ */
+ public function test_corrupt_object_order_data_cache_entry_is_discarded_and_reread(): void {
+ $fake_logger = $this->create_fake_logger();
+ add_filter(
+ 'woocommerce_logging_class',
+ function () use ( $fake_logger ) {
+ return $fake_logger;
+ }
+ );
+
+ $container = wc_get_container();
+ $container->reset_all_resolved();
+ $sut = $container->get( OrdersTableDataStore::class );
+
+ $order = new WC_Order();
+ $order->set_status( 'completed' );
+ $order->save();
+ $order_id = $order->get_id();
+
+ $sut->clear_cached_data( array( $order_id ) );
+ wp_cache_flush();
+
+ // Poison the order data cache with a foreign object (not a plain stdClass), simulating a
+ // cross-contaminated or unserialized-incomplete cache entry.
+ $cache_engine = $container->get( WPCacheEngine::class );
+ $cache_engine->cache_objects( array( $order_id => new WC_Order() ), 0, 'orders_data' );
+
+ $call_get_data = function ( $ids ) {
+ return $this->get_order_data_for_ids( $ids );
+ };
+ $order_data = $call_get_data->call( $sut, array( $order_id ) );
+
+ $this->assertArrayHasKey( $order_id, $order_data, 'Order data should still be returned after the corrupt object entry is discarded.' );
+ $this->assertInstanceOf( \stdClass::class, $order_data[ $order_id ], 'The foreign-object cache entry should be replaced by a fresh stdClass read from the database.' );
+ $this->assertSame( $order_id, (int) $order_data[ $order_id ]->id );
+
+ $this->assert_hpos_cache_warning_logged( $fake_logger, 'corrupt HPOS order cache entry' );
+
+ remove_all_filters( 'woocommerce_logging_class' );
+ }
+
+ /**
+ * @testdox A corrupt (non-array) meta cache entry is discarded, re-read from the database, and logged, without fataling the order read.
+ */
+ public function test_corrupt_meta_cache_entry_is_discarded_and_reread(): void {
+ $fake_logger = $this->create_fake_logger();
+ add_filter(
+ 'woocommerce_logging_class',
+ function () use ( $fake_logger ) {
+ return $fake_logger;
+ }
+ );
+
+ $container = wc_get_container();
+ $container->reset_all_resolved();
+ $sut = $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();
+
+ $sut->clear_cached_data( array( $order_id ) );
+ wp_cache_flush();
+
+ // Poison the meta cache with a non-array value, simulating a corrupt object cache entry.
+ $cache_engine = $container->get( WPCacheEngine::class );
+ $cache_engine->cache_objects( array( $order_id => 'corrupt-meta-entry' ), 0, 'orders_meta' );
+
+ // Reading the order must not fatal in filter_raw_meta_data().
+ $reloaded_order = wc_get_order( $order_id );
+
+ $this->assertInstanceOf( WC_Order::class, $reloaded_order, 'The order should load instead of fataling on the corrupt meta cache entry.' );
+ $this->assertSame( 'custom_value', $reloaded_order->get_meta( 'custom_meta_key' ), 'Meta should be re-read from the database after the corrupt cache entry is discarded.' );
+
+ $this->assert_hpos_cache_warning_logged( $fake_logger, 'corrupt HPOS meta cache entry' );
+
+ remove_all_filters( 'woocommerce_logging_class' );
+ }
+
+ /**
+ * @testdox A well-formed meta cache array whose elements are not meta rows does not fatal; the order self-heals on the next read.
+ */
+ public function test_corrupt_meta_element_cache_entry_does_not_fatal_and_self_heals(): void {
+ $fake_logger = $this->create_fake_logger();
+ add_filter(
+ 'woocommerce_logging_class',
+ function () use ( $fake_logger ) {
+ return $fake_logger;
+ }
+ );
+
+ $container = wc_get_container();
+ $container->reset_all_resolved();
+ $sut = $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();
+
+ $sut->clear_cached_data( array( $order_id ) );
+ wp_cache_flush();
+
+ /*
+ * Poison the meta cache with a well-formed array whose elements are scalars instead of
+ * meta-row objects. This passes a naive top-level is_array() check but is rejected by the
+ * full row-shape validation at the cache boundary, so it is re-read from the database in
+ * the same request rather than fataling in filter_raw_meta_data() ($meta->meta_key).
+ */
+ $cache_engine = $container->get( WPCacheEngine::class );
+ $cache_engine->cache_objects( array( $order_id => array( 'not-a-meta-row', 'another-string' ) ), 0, 'orders_meta' );
+
+ // The read must not fatal and must self-heal in the same request from the database.
+ $reloaded_order = wc_get_order( $order_id );
+ $this->assertInstanceOf( WC_Order::class, $reloaded_order, 'The order should load instead of fataling on the corrupt meta elements.' );
+ $this->assertSame( 'custom_value', $reloaded_order->get_meta( 'custom_meta_key' ), 'Meta should be re-read correctly from the database, not the corrupt scalar elements.' );
+ $this->assert_hpos_cache_warning_logged( $fake_logger, 'corrupt HPOS meta cache entry' );
+
+ remove_all_filters( 'woocommerce_logging_class' );
+ }
+
+ /**
+ * @testdox A meta row missing required fields (e.g. only meta_key) is treated as corrupt and re-read from the database.
+ */
+ public function test_incomplete_meta_row_cache_entry_is_rejected_and_reread(): void {
+ $fake_logger = $this->create_fake_logger();
+ add_filter(
+ 'woocommerce_logging_class',
+ function () use ( $fake_logger ) {
+ return $fake_logger;
+ }
+ );
+
+ $container = wc_get_container();
+ $container->reset_all_resolved();
+ $sut = $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();
+
+ $sut->clear_cached_data( array( $order_id ) );
+ wp_cache_flush();
+
+ // A row carrying only meta_key (no meta_id/meta_value) would otherwise load the real key
+ // with a null value instead of the database value.
+ $cache_engine = $container->get( WPCacheEngine::class );
+ $cache_engine->cache_objects( array( $order_id => array( (object) array( 'meta_key' => 'custom_meta_key' ) ) ), 0, 'orders_meta' ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+
+ $reloaded_order = wc_get_order( $order_id );
+ $this->assertInstanceOf( WC_Order::class, $reloaded_order, 'The order should load instead of using the incomplete cached row.' );
+ $this->assertSame( 'custom_value', $reloaded_order->get_meta( 'custom_meta_key' ), 'The incomplete row should be discarded and the real value re-read from the database.' );
+ $this->assert_hpos_cache_warning_logged( $fake_logger, 'corrupt HPOS meta cache entry' );
+
+ remove_all_filters( 'woocommerce_logging_class' );
+ }
+
+ /**
+ * @testdox An order data cache entry whose id does not match the requested key (cross-bleed) is discarded and re-read.
+ */
+ public function test_order_data_cache_entry_with_mismatched_id_is_discarded_and_reread(): void {
+ $fake_logger = $this->create_fake_logger();
+ add_filter(
+ 'woocommerce_logging_class',
+ function () use ( $fake_logger ) {
+ return $fake_logger;
+ }
+ );
+
+ $container = wc_get_container();
+ $container->reset_all_resolved();
+ $sut = $container->get( OrdersTableDataStore::class );
+
+ $order = new WC_Order();
+ $order->set_status( 'completed' );
+ $order->save();
+ $order_id = $order->get_id();
+
+ $sut->clear_cached_data( array( $order_id ) );
+ wp_cache_flush();
+
+ // Poison the cache with a stdClass whose id belongs to a different order (cross-bleed).
+ $cross_bled = new \stdClass();
+ $cross_bled->id = $order_id + 999;
+ $cache_engine = $container->get( WPCacheEngine::class );
+ $cache_engine->cache_objects( array( $order_id => $cross_bled ), 0, 'orders_data' );
+
+ $call_get_data = function ( $ids ) {
+ return $this->get_order_data_for_ids( $ids );
+ };
+ $order_data = $call_get_data->call( $sut, array( $order_id ) );
+
+ $this->assertArrayHasKey( $order_id, $order_data, 'Order data should still be returned after the cross-bled entry is discarded.' );
+ $this->assertSame( $order_id, (int) $order_data[ $order_id ]->id, 'The mismatched-id entry should be replaced by the correct order read from the database.' );
+ $this->assert_hpos_cache_warning_logged( $fake_logger, 'corrupt HPOS order cache entry' );
+
+ remove_all_filters( 'woocommerce_logging_class' );
+ }
+
+ /**
+ * @testdox A cached order data record with the correct id but a missing mapped column is rejected and re-read.
+ */
+ public function test_truncated_order_data_cache_entry_is_rejected_and_reread(): void {
+ $fake_logger = $this->create_fake_logger();
+ add_filter(
+ 'woocommerce_logging_class',
+ function () use ( $fake_logger ) {
+ return $fake_logger;
+ }
+ );
+
+ $container = wc_get_container();
+ $container->reset_all_resolved();
+ $sut = $container->get( OrdersTableDataStore::class );
+
+ $order = new WC_Order();
+ $order->set_status( 'completed' );
+ $order->set_total( '60.00' );
+ $order->save();
+ $order_id = $order->get_id();
+
+ $sut->clear_cached_data( array( $order_id ) );
+ wp_cache_flush();
+
+ $call_get_data = function ( $ids ) {
+ return $this->get_order_data_for_ids( $ids );
+ };
+
+ // Prime the cache with a complete record, then read it back.
+ $call_get_data->call( $sut, array( $order_id ) );
+ $cache_engine = $container->get( WPCacheEngine::class );
+ $cached = $cache_engine->get_cached_objects( array( $order_id ), 'orders_data' );
+ $this->assertInstanceOf( \stdClass::class, $cached[ $order_id ] );
+ $this->assertTrue( property_exists( $cached[ $order_id ], 'status' ), 'Sanity: the primed cache record should carry the status column.' );
+
+ // Truncate the record: keep the correct id but drop a mapped column, then re-cache it.
+ $truncated = $cached[ $order_id ];
+ unset( $truncated->status );
+ $cache_engine->cache_objects( array( $order_id => $truncated ), 0, 'orders_data' );
+
+ $order_data = $call_get_data->call( $sut, array( $order_id ) );
+
+ $this->assertArrayHasKey( $order_id, $order_data, 'Order data should still be returned after the truncated entry is discarded.' );
+ $this->assertTrue( property_exists( $order_data[ $order_id ], 'status' ), 'The truncated entry should be replaced by a complete record read from the database.' );
+ $this->assertSame( $order_id, (int) $order_data[ $order_id ]->id );
+ $this->assert_hpos_cache_warning_logged( $fake_logger, 'corrupt HPOS order cache entry' );
+
+ remove_all_filters( 'woocommerce_logging_class' );
+ }
+
+ /**
+ * @testdox filter_raw_meta_data() treats a non-array argument as empty meta instead of fataling.
+ */
+ public function test_filter_raw_meta_data_tolerates_non_array_input(): void {
+ $fake_logger = $this->create_fake_logger();
+ add_filter(
+ 'woocommerce_logging_class',
+ function () use ( $fake_logger ) {
+ return $fake_logger;
+ }
+ );
+
+ $container = wc_get_container();
+ $container->reset_all_resolved();
+ $sut = $container->get( OrdersTableDataStore::class );
+
+ $order = new WC_Order();
+ $order->save();
+
+ $call_filter = function ( $order_object, $raw_meta_data ) {
+ return $this->filter_raw_meta_data( $order_object, $raw_meta_data );
+ };
+
+ $result = $call_filter->call( $sut, $order, 'not-an-array' );
+
+ $this->assertSame( array(), $result, 'A non-array meta argument should be treated as empty meta.' );
+ $this->assert_hpos_cache_warning_logged( $fake_logger, 'Discarded malformed meta data' );
+
+ remove_all_filters( 'woocommerce_logging_class' );
+ }
+
+ /**
+ * Assert that a warning with the "hpos-data-cache" source and the given message fragment was logged.
+ *
+ * @param object $fake_logger The fake logger capturing log calls.
+ * @param string $needle A substring expected in the warning message.
+ */
+ private function assert_hpos_cache_warning_logged( object $fake_logger, string $needle ): void {
+ $found = false;
+ foreach ( $fake_logger->warning_calls as $call ) {
+ if ( 'hpos-data-cache' === ( $call['context']['source'] ?? '' ) && false !== strpos( $call['message'], $needle ) ) {
+ $found = true;
+ break;
+ }
+ }
+
+ $this->assertTrue( $found, "Expected a 'hpos-data-cache' warning containing: $needle" );
+ }
+
/**
* Create a fake logger for testing.
*