Commit acfa4fa6e1 for woocommerce
commit acfa4fa6e1fbbde8447346160bcee049c34d1e8f
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date: Fri Dec 19 09:57:57 2025 +0000
Fix handling of order modified date during HPOS sync-on-read (#62514)
diff --git a/plugins/woocommerce/changelog/fix-62492 b/plugins/woocommerce/changelog/fix-62492
new file mode 100644
index 0000000000..58667ae2f5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-62492
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Improve handling of order modified date during HPOS sync on read to prevent infinite loops.
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index 47a3113f4d..cf4ec799b7 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -44,6 +44,14 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
*/
private static $backfilling_order_ids = array();
+ /**
+ * Keep track of order IDs (as keys) that are being synced on read. This is used to prevent backfilling to posts of an order being updated
+ * from posts.
+ *
+ * @var array
+ */
+ private static $sync_on_read_order_ids = array();
+
/**
* Data stored in meta keys, but not considered "meta" for an order.
*
@@ -1356,6 +1364,10 @@ WHERE
$data_sync_enabled = $data_synchronizer->data_sync_is_enabled();
if ( $data_sync_enabled ) {
+ // We prefer not syncing-on-read if we are inside a webhook delivery or importing orders, as those events are likely triggered after the order is written
+ // and we don't want to possibly create loops of sync-on-read.
+ $should_sync_on_read = ! doing_action( 'woocommerce_deliver_webhook_async' ) && ! doing_action( 'wc-admin_import_orders' );
+
/**
* Allow opportunity to disable sync on read, while keeping sync on write enabled. This adds another step as a large shop progresses from full sync to no sync with HPOS authoritative.
* This filter is only executed if data sync is enabled from settings in the first place as it's meant to be a step between full sync -> no sync, rather than be a control for enabling just the sync on read. Sync on read without sync on write is problematic as any update will reset on the next read, but sync on write without sync on read is fine.
@@ -1364,7 +1376,7 @@ WHERE
*
* @since 8.1.0
*/
- $data_sync_enabled = apply_filters( 'woocommerce_hpos_enable_sync_on_read', $data_sync_enabled );
+ $data_sync_enabled = apply_filters( 'woocommerce_hpos_enable_sync_on_read', $should_sync_on_read );
}
$load_posts_for = array_diff( $order_ids, array_merge( self::$reading_order_ids, self::$backfilling_order_ids ) );
@@ -1710,6 +1722,8 @@ WHERE
* @return void
*/
private function migrate_post_record( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ): void {
+ self::$sync_on_read_order_ids[ $order->get_id() ] = true;
+
$diff = $this->migrate_meta_data_from_post_order( $order, $post_order );
$post_order_base_data = $post_order->get_base_data();
foreach ( $post_order_base_data as $key => $value ) {
@@ -1725,6 +1739,8 @@ WHERE
}
$this->persist_updates( $order, false );
+ unset( self::$sync_on_read_order_ids[ $order->get_id() ] );
+
/**
* Fired when an HPOS order is updated from its corresponding post record on read due to a difference in the data.
*
@@ -2877,7 +2893,11 @@ FROM $order_meta_table
// Fetch changes.
$changes = $order->get_changes();
- $this->persist_updates( $order );
+
+ // Does not make much sense to backfill to posts an order being sync-on-read from posts.
+ $should_backfill = ! isset( self::$sync_on_read_order_ids[ $order->get_id() ] );
+
+ $this->persist_updates( $order, $should_backfill );
// Update download permissions if necessary.
if ( array_key_exists( 'billing_email', $changes ) || array_key_exists( 'customer_id', $changes ) ) {
@@ -3421,6 +3441,7 @@ CREATE TABLE $meta_table (
$should_save =
$order->get_id() > 0
+ && ! isset( self::$sync_on_read_order_ids[ $order->get_id() ] )
&& $order->get_date_modified() < $current_date_time && empty( $order->get_changes() )
&& ( ! is_object( $meta ) || ! in_array( $meta->key, $this->ephemeral_meta_keys, true ) );
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
index cce4e759ff..4d0d3e4ef4 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
@@ -1483,6 +1483,76 @@ class OrdersTableDataStoreTests extends \HposTestCase {
$this->assertFalse( $post_order_comparison_closure->call( $this->sut ) );
}
+ /**
+ * @testdox Test sync-on-read with date and metadata differences.
+ *
+ * @testWith [true]
+ * [false]
+ *
+ * @param bool $with_metadata Whether to add a new metadata to the post version of the order.
+ */
+ public function test_sync_on_read_with_date_differences( $with_metadata = false ) {
+ global $wpdb;
+
+ $this->toggle_cot_authoritative( true );
+ $this->enable_cot_sync();
+
+ $now = time() - ( 10 * MINUTE_IN_SECONDS );
+ $before = $now - ( 10 * MINUTE_IN_SECONDS );
+
+ $order = new \WC_Order();
+ $order->set_status( OrderStatus::PROCESSING );
+ $order->set_billing_first_name( 'Duke' );
+ $order->save();
+
+ $order->set_date_modified( $before );
+ $order->save();
+
+ // Refresh order from HPOS datastore.
+ $order = wc_get_order( $order->get_id() );
+
+ // Confirm post version exists and that both have the same modified date.
+ $this->assertSame( 'shop_order', get_post_type( $order->get_id() ) );
+ $this->assertEquals( $order->get_date_modified( 'edit' )->getTimestamp(), $before );
+ $this->assertEquals( get_post_modified_time( 'U', true, $order->get_id() ), $before );
+
+ // Update the posts modified date and confirm the change was made.
+ $wpdb->update(
+ $wpdb->posts,
+ array(
+ 'post_modified_gmt' => gmdate( 'Y-m-d H:i:s', $now ),
+ ),
+ array(
+ 'ID' => $order->get_id(),
+ )
+ );
+ clean_post_cache( $order->get_id() );
+ $this->assertEquals( get_post_modified_time( 'U', true, $order->get_id() ), $now );
+
+ // Add a new metadata so sync-on-read does something.
+ if ( $with_metadata ) {
+ add_post_meta( $order->get_id(), 'foo', 'bar' );
+ }
+
+ // Trigger sync-on-read by re-reading the order and compare dates again.
+ $sync_on_read_triggered = false;
+ add_action(
+ 'woocommerce_hpos_post_record_migrated_on_read',
+ function ( $o ) use ( &$sync_on_read_triggered, $order ) {
+ $sync_on_read_triggered = $o->get_id() === $order->get_id();
+ }
+ );
+
+ $this->reset_order_data_store_state( $this->sut );
+ $order = wc_get_order( $order->get_id() );
+ $this->assertTrue( $sync_on_read_triggered );
+ remove_all_actions( 'woocommerce_hpos_post_record_migrated_on_read' );
+
+ // Compare dates again.
+ $this->assertEquals( $order->get_date_modified( 'edit' )->getTimestamp(), $now );
+ $this->assertEquals( get_post_modified_time( 'U', true, $order->get_id() ), $now );
+ }
+
/**
* @testDox Meta data should be migrated from post order to cot order.
*
@@ -3018,8 +3088,9 @@ class OrdersTableDataStoreTests extends \HposTestCase {
*/
private function reset_order_data_store_state( $sut ) {
$reset_state = function () use ( $sut ) {
- self::$backfilling_order_ids = array();
- self::$reading_order_ids = array();
+ self::$backfilling_order_ids = array();
+ self::$reading_order_ids = array();
+ self::$sync_on_read_order_ids = array();
};
$reset_state->call( $sut );
wc_get_container()->get( \Automattic\WooCommerce\Caches\OrderCache::class )->flush();