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