Commit 4cbd6e8c7f9 for woocommerce
commit 4cbd6e8c7f98c4199fb26a6cfac50f97ea394218
Author: Mike Jolley <mike.jolley@me.com>
Date: Fri Oct 23 15:34:14 2026 +0100
Save only changed session keys so overlapping requests don't lose cart items (#69559)
* Save only changed session keys when another request saved in between
* Add changelog entry for overlapping session saves fix
* Save the whole session when the stored row cannot be read during a merge
* Use an explicit flag for replacing the whole session on save
The loaded snapshot is now always an array, and a separate flag marks
saves that should overwrite the stored session. SessionDataWriter gets
separate replace() and merge() methods instead of branching on null.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Compare stored session data as binary before writing
The sessions table uses a case-insensitive collation, so the guarded
update matched rows where another request only changed a value's case,
then overwrote that change.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Cast the session key and expiry before saving the session
WC_Session_Handler uses strict types, so a subclass that stores the
customer ID as an int would fatal when passing it to SessionDataWriter.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/46483-merge-concurrent-session-saves b/plugins/woocommerce/changelog/46483-merge-concurrent-session-saves
new file mode 100644
index 00000000000..3f497df0b8e
--- /dev/null
+++ b/plugins/woocommerce/changelog/46483-merge-concurrent-session-saves
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep items added to the cart when another request from the same shopper finishes later, instead of losing them.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-session.php b/plugins/woocommerce/includes/abstracts/abstract-wc-session.php
index 4ab36834a77..531e8003eb1 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-session.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-session.php
@@ -39,6 +39,22 @@ abstract class WC_Session {
*/
protected $_dirty = false; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
+ /**
+ * Session data as loaded from storage, used to work out which keys this request changed.
+ *
+ * @var array
+ */
+ private $loaded_session_snapshot = array();
+
+ /**
+ * Whether saving should overwrite the stored session instead of merging this request's changes into it.
+ *
+ * True until a snapshot is recorded, so handlers that never record one keep overwriting the stored session.
+ *
+ * @var bool
+ */
+ private $replace_session_on_save = true;
+
/**
* Init hooks and session data. Extended by child classes.
*
@@ -131,6 +147,49 @@ abstract class WC_Session {
$this->_data[ $key ] = $serialized_value;
}
+ /**
+ * Record the session data as loaded from storage, so saving writes only the keys this request changed.
+ *
+ * @since 11.4.0
+ *
+ * @param array $data Session data as loaded from storage.
+ */
+ protected function set_loaded_session_snapshot( array $data ): void {
+ $this->loaded_session_snapshot = $data;
+ $this->replace_session_on_save = false;
+ }
+
+ /**
+ * Get the session data as loaded from storage.
+ *
+ * @since 11.4.0
+ *
+ * @return array
+ */
+ protected function get_loaded_session_snapshot(): array {
+ return $this->loaded_session_snapshot;
+ }
+
+ /**
+ * Make the next save overwrite the stored session, for requests that replace the whole session.
+ *
+ * @since 11.4.0
+ */
+ protected function replace_session_on_save(): void {
+ $this->replace_session_on_save = true;
+ }
+
+ /**
+ * Whether the next save should overwrite the stored session instead of merging into it.
+ *
+ * @since 11.4.0
+ *
+ * @return bool
+ */
+ protected function should_replace_session_on_save(): bool {
+ return $this->replace_session_on_save;
+ }
+
/**
* Get customer ID. If the session is not initialized, returns an empty string.
*
diff --git a/plugins/woocommerce/includes/class-wc-session-handler.php b/plugins/woocommerce/includes/class-wc-session-handler.php
index 73588e3b671..437e7c16815 100644
--- a/plugins/woocommerce/includes/class-wc-session-handler.php
+++ b/plugins/woocommerce/includes/class-wc-session-handler.php
@@ -13,6 +13,7 @@ declare(strict_types=1);
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\Session\SessionDataWriter;
use Automattic\WooCommerce\Utilities\StringUtil;
use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils;
@@ -168,6 +169,7 @@ class WC_Session_Handler extends WC_Session {
// If there is no cookie, generate a new session/customer ID.
$this->_customer_id = $this->generate_customer_id();
$this->_data = $this->get_session_data();
+ $this->set_loaded_session_snapshot( $this->_data );
return;
}
@@ -209,6 +211,7 @@ class WC_Session_Handler extends WC_Session {
$session_data = array_diff_key( $session_data, array( 'customer' => true ) );
$this->_data = $session_data;
$this->_dirty = true;
+ $this->replace_session_on_save();
$this->save_data();
}
@@ -222,6 +225,7 @@ class WC_Session_Handler extends WC_Session {
$this->_data = $this->get_session( $guest_session_id, array() );
$this->_dirty = true;
$this->_customer_id = $user_session_id;
+ $this->replace_session_on_save();
$this->save_data( $guest_session_id );
/**
@@ -269,6 +273,9 @@ class WC_Session_Handler extends WC_Session {
* @return array Modified session data to be used for initialization.
*/
$this->_data = (array) apply_filters( 'woocommerce_restored_session_data', $session_data );
+
+ // Snapshot the unfiltered data, so changes made by the filter are saved like any other change.
+ $this->set_loaded_session_snapshot( $session_data );
}
/**
@@ -551,19 +558,19 @@ class WC_Session_Handler extends WC_Session {
public function save_data( $old_session_key = '' ) {
// Dirty if something changed - prevents saving nothing new.
if ( $this->_dirty && $this->has_session() ) {
- global $wpdb;
+ $writer = wc_get_container()->get( SessionDataWriter::class );
+ $customer_id = (string) $this->get_customer_id();
+ $expiry = (int) $this->_session_expiration;
- $wpdb->query(
- $wpdb->prepare(
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
- "INSERT INTO {$this->_table} (`session_key`, `session_value`, `session_expiry`) VALUES (%s, %s, %d)
- ON DUPLICATE KEY UPDATE `session_value` = VALUES(`session_value`), `session_expiry` = VALUES(`session_expiry`)",
- $this->get_customer_id(),
- maybe_serialize( $this->_data ),
- $this->_session_expiration
- )
- );
- wp_cache_set( $this->get_cache_prefix() . $this->get_customer_id(), $this->_data, WC_SESSION_CACHE_GROUP, $this->_session_expiration - time() );
+ // Merging writes only the keys this request changed, so changes other requests saved since it loaded are kept.
+ $saved_data = $this->should_replace_session_on_save()
+ ? $writer->replace( $this->_table, $customer_id, $expiry, $this->_data )
+ : $writer->merge( $this->_table, $customer_id, $expiry, $this->_data, $this->get_loaded_session_snapshot() );
+
+ wp_cache_set( $this->get_cache_prefix() . $this->get_customer_id(), $saved_data, WC_SESSION_CACHE_GROUP, $this->_session_expiration - time() );
+
+ // Later saves in this request compare against this request's own data, so they merge again.
+ $this->set_loaded_session_snapshot( $this->_data );
$this->_dirty = false;
/**
@@ -601,6 +608,7 @@ class WC_Session_Handler extends WC_Session {
$this->_dirty = false;
$this->_customer_id = $this->generate_customer_id();
$this->_has_cookie = false;
+ $this->replace_session_on_save();
}
/**
diff --git a/plugins/woocommerce/src/Internal/Session/SessionDataWriter.php b/plugins/woocommerce/src/Internal/Session/SessionDataWriter.php
new file mode 100644
index 00000000000..09fcd6c58c5
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Session/SessionDataWriter.php
@@ -0,0 +1,149 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Session;
+
+/**
+ * Saves a session row without reverting changes another request saved after this one loaded the session.
+ *
+ * Each request loads the whole session when it starts. Writing all of it back on save reverts anything other
+ * requests saved in the meantime, so only the keys this request changed are applied to the stored row. When two
+ * requests change the same key, the last one to save wins.
+ *
+ * @since 11.4.0
+ */
+class SessionDataWriter {
+
+ /**
+ * Overwrite the stored session with this request's data.
+ *
+ * @param string $table Sessions table name.
+ * @param string $session_key Session key (customer ID).
+ * @param int $expiry Session expiry timestamp.
+ * @param array $current Session data in this request.
+ * @return array The session data that was saved.
+ */
+ public function replace( string $table, string $session_key, int $expiry, array $current ): array {
+ $this->upsert( $table, $session_key, $expiry, $current );
+ return $current;
+ }
+
+ /**
+ * Apply only the keys that changed since this request loaded the session to the stored session.
+ *
+ * @param string $table Sessions table name.
+ * @param string $session_key Session key (customer ID).
+ * @param int $expiry Session expiry timestamp.
+ * @param array $current Session data in this request.
+ * @param array $loaded Session data as this request loaded it.
+ * @return array The session data that was saved.
+ */
+ public function merge( string $table, string $session_key, int $expiry, array $current, array $loaded ): array {
+ // Usually no other request saved in the meantime, so the row still holds what this request loaded.
+ if ( $this->write_if_unchanged( $table, $session_key, $expiry, $current, $loaded ) ) {
+ return $current;
+ }
+
+ global $wpdb;
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+ $value = $wpdb->get_var( $wpdb->prepare( "SELECT session_value FROM {$table} WHERE session_key = %s", $session_key ) );
+
+ if ( '' !== $wpdb->last_error ) {
+ // Without the stored row there is nothing to apply the changes to, so save this request's whole session.
+ return $this->replace( $table, $session_key, $expiry, $current );
+ }
+
+ $stored = maybe_unserialize( $value );
+ $data = $this->apply_changes( is_array( $stored ) ? $stored : array(), $current, $loaded );
+
+ $this->upsert( $table, $session_key, $expiry, $data );
+ return $data;
+ }
+
+ /**
+ * Apply the keys this request added, changed, or removed to the stored session data.
+ *
+ * @param array $stored Session data in storage now.
+ * @param array $current Session data in this request.
+ * @param array $loaded Session data as this request loaded it.
+ * @return array
+ */
+ private function apply_changes( array $stored, array $current, array $loaded ): array {
+ foreach ( array_keys( $current + $loaded ) as $key ) {
+ $value = $current[ $key ] ?? null;
+
+ if ( ( $loaded[ $key ] ?? null ) === $value ) {
+ continue;
+ }
+
+ if ( null === $value ) {
+ unset( $stored[ $key ] );
+ } else {
+ $stored[ $key ] = $value;
+ }
+ }
+
+ return $stored;
+ }
+
+ /**
+ * Write the row only if it still holds the data this request loaded.
+ *
+ * @param string $table Sessions table name.
+ * @param string $session_key Session key (customer ID).
+ * @param int $expiry Session expiry timestamp.
+ * @param array $data Session data to write.
+ * @param array $loaded Session data as this request loaded it. Empty if the row should not exist yet.
+ * @return bool Whether the row was written.
+ */
+ private function write_if_unchanged( string $table, string $session_key, int $expiry, array $data, array $loaded ): bool {
+ global $wpdb;
+
+ if ( empty( $loaded ) ) {
+ return (bool) $wpdb->query(
+ $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+ "INSERT IGNORE INTO {$table} (`session_key`, `session_value`, `session_expiry`) VALUES (%s, %s, %d)",
+ $session_key,
+ maybe_serialize( $data ),
+ $expiry
+ )
+ );
+ }
+
+ // Compare as binary, since the column's case-insensitive collation would match a row whose values only changed case.
+ return (bool) $wpdb->query(
+ $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+ "UPDATE {$table} SET `session_value` = %s, `session_expiry` = %d WHERE `session_key` = %s AND CAST(`session_value` AS BINARY) = %s",
+ maybe_serialize( $data ),
+ $expiry,
+ $session_key,
+ maybe_serialize( $loaded )
+ )
+ );
+ }
+
+ /**
+ * Write the row, replacing whatever is stored.
+ *
+ * @param string $table Sessions table name.
+ * @param string $session_key Session key (customer ID).
+ * @param int $expiry Session expiry timestamp.
+ * @param array $data Session data to write.
+ */
+ private function upsert( string $table, string $session_key, int $expiry, array $data ): void {
+ global $wpdb;
+
+ $wpdb->query(
+ $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+ "INSERT INTO {$table} (`session_key`, `session_value`, `session_expiry`) VALUES (%s, %s, %d)
+ ON DUPLICATE KEY UPDATE `session_value` = VALUES(`session_value`), `session_expiry` = VALUES(`session_expiry`)",
+ $session_key,
+ maybe_serialize( $data ),
+ $expiry
+ )
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/SessionHandler.php b/plugins/woocommerce/src/StoreApi/SessionHandler.php
index 7c510a604ac..a376fbf67d6 100644
--- a/plugins/woocommerce/src/StoreApi/SessionHandler.php
+++ b/plugins/woocommerce/src/StoreApi/SessionHandler.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\StoreApi;
use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Internal\Session\SessionDataWriter;
use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils;
use WC_Session;
defined( 'ABSPATH' ) || exit;
@@ -66,6 +67,7 @@ final class SessionHandler extends WC_Session {
$this->_customer_id = $this->generate_customer_id();
$this->session_expiration = CartTokenUtils::get_cart_token_expiration();
$this->_data = array();
+ $this->set_loaded_session_snapshot( $this->_data );
return;
}
@@ -74,6 +76,7 @@ final class SessionHandler extends WC_Session {
$this->_customer_id = $payload['user_id'];
$this->session_expiration = $payload['exp'];
$this->_data = (array) $this->get_session( $this->get_customer_id(), array() );
+ $this->set_loaded_session_snapshot( $this->_data );
}
/**
@@ -168,6 +171,7 @@ final class SessionHandler extends WC_Session {
$this->_data = array();
$this->_dirty = false;
$this->_customer_id = null;
+ $this->replace_session_on_save();
}
/**
@@ -191,18 +195,16 @@ final class SessionHandler extends WC_Session {
public function save_data() {
// Dirty if something changed - prevents saving nothing new.
if ( $this->_dirty ) {
- global $wpdb;
-
- $wpdb->query(
- $wpdb->prepare(
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
- "INSERT INTO {$this->table} (`session_key`, `session_value`, `session_expiry`) VALUES (%s, %s, %d) ON DUPLICATE KEY UPDATE `session_value` = VALUES(`session_value`), `session_expiry` = VALUES(`session_expiry`)",
- $this->get_customer_id(),
- maybe_serialize( $this->_data ),
- $this->session_expiration
- )
- );
+ $writer = wc_get_container()->get( SessionDataWriter::class );
+ // Merging writes only the keys this request changed, so changes other requests saved since it loaded are kept.
+ if ( $this->should_replace_session_on_save() ) {
+ $writer->replace( $this->table, $this->get_customer_id(), (int) $this->session_expiration, $this->_data );
+ } else {
+ $writer->merge( $this->table, $this->get_customer_id(), (int) $this->session_expiration, $this->_data, $this->get_loaded_session_snapshot() );
+ }
+
+ $this->set_loaded_session_snapshot( $this->_data );
$this->_dirty = false;
}
}
diff --git a/plugins/woocommerce/tests/legacy/framework/class-wc-unit-test-case.php b/plugins/woocommerce/tests/legacy/framework/class-wc-unit-test-case.php
index e5bed3439b3..363045e2347 100644
--- a/plugins/woocommerce/tests/legacy/framework/class-wc-unit-test-case.php
+++ b/plugins/woocommerce/tests/legacy/framework/class-wc-unit-test-case.php
@@ -221,6 +221,14 @@ class WC_Unit_Test_Case extends WP_HTTP_TestCase {
wc_clear_notices();
}
+ if ( WC()->session instanceof WC_Session ) {
+ // The session row is rolled back but the session object keeps what it loaded, which no longer matches
+ // storage. Make its next save a full write instead of a merge against that stale snapshot.
+ ( function () {
+ $this->replace_session_on_save();
+ } )->call( WC()->session );
+ }
+
if ( isset( WC()->countries ) ) {
// The locale is cached on first read. A test that reads it while a
// woocommerce_get_country_locale filter is attached leaves the filtered
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-session-concurrency-test.php b/plugins/woocommerce/tests/php/includes/class-wc-session-concurrency-test.php
new file mode 100644
index 00000000000..c33182b0dc7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-session-concurrency-test.php
@@ -0,0 +1,206 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\StoreApi\SessionHandler as StoreApiSessionHandler;
+use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils;
+
+/**
+ * Tests for overlapping requests saving the same session.
+ *
+ * Each test loads the session in two "requests" before either saves, then saves request A and then request B, as
+ * happens when a slow request overlaps another one from the same shopper. Requests that change the same key
+ * still end with the value from the last save, so those cases are not covered here.
+ *
+ * @see https://github.com/woocommerce/woocommerce/issues/46483
+ */
+class WC_Session_Concurrency_Test extends WC_Unit_Test_Case {
+
+ private const GUEST_ID = 't_0123456789abcdef0123456789abcd';
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ unset( $_SERVER['HTTP_CART_TOKEN'] );
+ wp_set_current_user( 0 );
+ parent::tearDown();
+ }
+
+ /**
+ * Cases: initial session data, the keys each request sets (null unsets), and the expected end state.
+ *
+ * @return array<string, array{array<string, mixed>, array<string, mixed>, array<string, mixed>, array<string, mixed>}>
+ */
+ public function provide_overlapping_requests(): array {
+ $item = array(
+ 'product_id' => 10,
+ 'quantity' => 1,
+ );
+
+ return array(
+ 'add to cart vs notices write' => array(
+ array(),
+ array( 'cart' => array( 'x' => $item ) ),
+ array( 'wc_notices' => array( 'notice' => array( 'Hello' ) ) ),
+ array(
+ 'cart' => array( 'x' => $item ),
+ 'wc_notices' => array( 'notice' => array( 'Hello' ) ),
+ ),
+ ),
+ 'value update vs unrelated write' => array(
+ array( 'ext_consent' => 'granted' ),
+ array( 'ext_consent' => 'withdrawn' ),
+ array( 'chosen_shipping_methods' => array( 'free_shipping:1' ) ),
+ array(
+ 'ext_consent' => 'withdrawn',
+ 'chosen_shipping_methods' => array( 'free_shipping:1' ),
+ ),
+ ),
+ 'coupon removal vs unrelated write' => array(
+ array( 'applied_coupons' => array( 'dev10' ) ),
+ array( 'applied_coupons' => null ),
+ array( 'wc_notices' => array( 'notice' => array( 'Hello' ) ) ),
+ array( 'wc_notices' => array( 'notice' => array( 'Hello' ) ) ),
+ ),
+ );
+ }
+
+ /**
+ * @testdox WC_Session_Handler keeps both requests' changes when saves overlap: $_dataName.
+ * @dataProvider provide_overlapping_requests
+ *
+ * @param array $initial Session data before either request starts.
+ * @param array $changes_a Keys set by request A, which saves first.
+ * @param array $changes_b Keys set by request B, which saves last from older data.
+ * @param array $expected Expected stored session data.
+ */
+ public function test_cookie_session_handler_overlapping_saves( array $initial, array $changes_a, array $changes_b, array $expected ): void {
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'customer' ) ) );
+
+ $this->run_overlapping_requests( WC_Session_Handler::class, $initial, $changes_a, $changes_b );
+
+ $this->assertEquals( $expected, $this->get_stored_session( (string) get_current_user_id() ), 'Request B should not overwrite request A\'s changes' );
+ }
+
+ /**
+ * @testdox Store API SessionHandler keeps both requests' changes when saves overlap: $_dataName.
+ * @dataProvider provide_overlapping_requests
+ *
+ * @param array $initial Session data before either request starts.
+ * @param array $changes_a Keys set by request A, which saves first.
+ * @param array $changes_b Keys set by request B, which saves last from older data.
+ * @param array $expected Expected stored session data.
+ */
+ public function test_store_api_session_handler_overlapping_saves( array $initial, array $changes_a, array $changes_b, array $expected ): void {
+ $_SERVER['HTTP_CART_TOKEN'] = CartTokenUtils::get_cart_token( self::GUEST_ID );
+
+ $this->run_overlapping_requests( StoreApiSessionHandler::class, $initial, $changes_a, $changes_b );
+
+ $this->assertEquals( $expected, $this->get_stored_session( self::GUEST_ID ), 'Request B should not overwrite request A\'s changes' );
+ }
+
+ /**
+ * @testdox Keys removed by the woocommerce_restored_session_data filter are removed from storage on the next save.
+ */
+ public function test_filtered_restored_data_is_saved(): void {
+ $user_id = (string) $this->factory->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( (int) $user_id );
+ $this->apply_and_save( $this->start_request( WC_Session_Handler::class ), array( 'ext_tracking' => 'abc' ) );
+
+ add_filter(
+ 'woocommerce_restored_session_data',
+ function ( $data ) {
+ unset( $data['ext_tracking'] );
+ return $data;
+ }
+ );
+
+ // The filter only runs when the session is restored from a cookie.
+ $request = $this->getMockBuilder( WC_Session_Handler::class )->onlyMethods( array( 'get_session_cookie' ) )->getMock();
+ $request->method( 'get_session_cookie' )->willReturn( array( $user_id, time() + DAY_IN_SECONDS, time() + HOUR_IN_SECONDS, 'hash' ) );
+ $request->init();
+ $this->apply_and_save( $request, array( 'wc_notices' => array( 'notice' => array( 'Hello' ) ) ) );
+
+ $this->assertEquals( array( 'wc_notices' => array( 'notice' => array( 'Hello' ) ) ), $this->get_stored_session( $user_id ) );
+ }
+
+ /**
+ * @testdox A request that saves twice merges each save, so its second save does not undo another request's changes.
+ */
+ public function test_second_save_in_same_request_still_merges(): void {
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'customer' ) ) );
+
+ $request_a = $this->start_request( WC_Session_Handler::class );
+ $request_b = $this->start_request( WC_Session_Handler::class );
+
+ $this->apply_and_save( $request_b, array( 'first' => 'b' ) );
+ $this->apply_and_save( $request_a, array( 'other' => 'a' ) );
+ $this->apply_and_save( $request_b, array( 'second' => 'b' ) );
+
+ $this->assertEquals(
+ array(
+ 'first' => 'b',
+ 'other' => 'a',
+ 'second' => 'b',
+ ),
+ $this->get_stored_session( (string) get_current_user_id() )
+ );
+ }
+
+ /**
+ * Seeds the session, then interleaves two requests: load A, load B, save A, save B.
+ *
+ * @param string $handler_class Session handler class.
+ * @param array $initial Session data before either request starts.
+ * @param array $changes_a Keys set by request A.
+ * @param array $changes_b Keys set by request B.
+ */
+ private function run_overlapping_requests( string $handler_class, array $initial, array $changes_a, array $changes_b ): void {
+ $this->apply_and_save( $this->start_request( $handler_class ), $initial );
+
+ $request_a = $this->start_request( $handler_class );
+ $request_b = $this->start_request( $handler_class );
+
+ $this->apply_and_save( $request_a, $changes_a );
+ $this->apply_and_save( $request_b, $changes_b );
+ }
+
+ /**
+ * Starts a request: a new session handler that has loaded the session.
+ *
+ * @param string $handler_class Session handler class.
+ * @return WC_Session
+ */
+ private function start_request( string $handler_class ): WC_Session {
+ $handler = new $handler_class();
+ $handler->init();
+ return $handler;
+ }
+
+ /**
+ * Sets session keys (null unsets) and saves.
+ *
+ * @param WC_Session $handler Session handler.
+ * @param array $changes Keys to set.
+ */
+ private function apply_and_save( WC_Session $handler, array $changes ): void {
+ foreach ( $changes as $key => $value ) {
+ $handler->set( $key, $value );
+ }
+ $handler->save_data();
+ }
+
+ /**
+ * Reads the stored session straight from the database, bypassing the object cache.
+ *
+ * @param string $customer_id Session key.
+ * @return array
+ */
+ private function get_stored_session( string $customer_id ): array {
+ global $wpdb;
+
+ $value = $wpdb->get_var( $wpdb->prepare( "SELECT session_value FROM {$wpdb->prefix}woocommerce_sessions WHERE session_key = %s", $customer_id ) );
+
+ return array_map( 'maybe_unserialize', (array) maybe_unserialize( $value ) );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Session/SessionDataWriterTest.php b/plugins/woocommerce/tests/php/src/Internal/Session/SessionDataWriterTest.php
new file mode 100644
index 00000000000..144fca284e6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Session/SessionDataWriterTest.php
@@ -0,0 +1,209 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Session;
+
+use Automattic\WooCommerce\Internal\Session\SessionDataWriter;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the SessionDataWriter class.
+ */
+class SessionDataWriterTest extends WC_Unit_Test_Case {
+
+ private const SESSION_KEY = 't_session_writer_test';
+
+ /**
+ * The System Under Test.
+ *
+ * @var SessionDataWriter
+ */
+ private $sut;
+
+ /**
+ * Sessions table name.
+ *
+ * @var string
+ */
+ private $table;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new SessionDataWriter();
+ $this->table = $GLOBALS['wpdb']->prefix . 'woocommerce_sessions';
+ }
+
+ /**
+ * @testdox Inserts a new row when the session is not stored yet.
+ */
+ public function test_inserts_new_row(): void {
+ $saved = $this->sut->merge( $this->table, self::SESSION_KEY, time() + 100, array( 'a' => 'mine' ), array() );
+
+ $this->assertSame( array( 'a' => 'mine' ), $saved );
+ $this->assertSame( array( 'a' => 'mine' ), $this->get_stored() );
+ }
+
+ /**
+ * @testdox Replacing overwrites the stored row.
+ */
+ public function test_replace_overwrites_stored_row(): void {
+ $this->store( array( 'a' => 'theirs' ) );
+
+ $this->sut->replace( $this->table, self::SESSION_KEY, time() + 100, array( 'b' => 'mine' ) );
+
+ $this->assertSame( array( 'b' => 'mine' ), $this->get_stored() );
+ }
+
+ /**
+ * @testdox When another request saved since this one loaded, only this request's changes are applied to the stored row.
+ */
+ public function test_applies_only_changed_keys_to_newer_data(): void {
+ $this->store(
+ array(
+ 'kept' => 'theirs',
+ 'removed' => 'old',
+ 'added' => 'theirs',
+ )
+ );
+ $loaded = array(
+ 'kept' => 'old',
+ 'removed' => 'old',
+ 'changed' => 'old',
+ );
+ $current = array(
+ 'kept' => 'old',
+ 'changed' => 'mine',
+ );
+
+ $saved = $this->sut->merge( $this->table, self::SESSION_KEY, time() + 100, $current, $loaded );
+
+ $expected = array(
+ 'kept' => 'theirs',
+ 'added' => 'theirs',
+ 'changed' => 'mine',
+ );
+ $this->assertSame( $expected, $saved );
+ $this->assertSame( $expected, $this->get_stored() );
+ }
+
+ /**
+ * @testdox When both requests changed the same key, the last one to save wins.
+ */
+ public function test_same_key_changed_by_both_requests_uses_the_last_save(): void {
+ $this->store( array( 'a' => 'theirs' ) );
+
+ $this->sut->merge( $this->table, self::SESSION_KEY, time() + 100, array( 'a' => 'mine' ), array( 'a' => 'old' ) );
+
+ $this->assertSame( array( 'a' => 'mine' ), $this->get_stored() );
+ }
+
+ /**
+ * @testdox When another request only changed the case of a value, that change is kept.
+ */
+ public function test_keeps_case_only_change_saved_by_another_request(): void {
+ $this->store(
+ array(
+ 'coupon' => 'save10',
+ 'other' => 'old',
+ )
+ );
+ $loaded = array(
+ 'coupon' => 'SAVE10',
+ 'other' => 'old',
+ );
+
+ $this->sut->merge(
+ $this->table,
+ self::SESSION_KEY,
+ time() + 100,
+ array(
+ 'coupon' => 'SAVE10',
+ 'other' => 'mine',
+ ),
+ $loaded
+ );
+
+ $this->assertSame(
+ array(
+ 'coupon' => 'save10',
+ 'other' => 'mine',
+ ),
+ $this->get_stored()
+ );
+ }
+
+ /**
+ * @testdox When the stored row cannot be read, saves this request's whole session instead of only its changes.
+ */
+ public function test_saves_whole_session_when_stored_row_cannot_be_read(): void {
+ global $wpdb;
+ $this->store(
+ array(
+ 'a' => 'theirs',
+ 'keep' => 'kept',
+ )
+ );
+ $break_read = function ( $query ) {
+ return 0 === strpos( $query, 'SELECT session_value FROM' ) ? str_replace( 'FROM ', 'FROM missing_', $query ) : $query;
+ };
+ add_filter( 'query', $break_read );
+ $suppress = $wpdb->suppress_errors( true );
+
+ try {
+ $saved = $this->sut->merge(
+ $this->table,
+ self::SESSION_KEY,
+ time() + 100,
+ array(
+ 'a' => 'mine',
+ 'keep' => 'kept',
+ ),
+ array(
+ 'a' => 'old',
+ 'keep' => 'kept',
+ )
+ );
+ } finally {
+ $wpdb->suppress_errors( $suppress );
+ remove_filter( 'query', $break_read );
+ }
+
+ $expected = array(
+ 'a' => 'mine',
+ 'keep' => 'kept',
+ );
+ $this->assertSame( $expected, $saved );
+ $this->assertSame( $expected, $this->get_stored(), 'Keys this request did not change should not be dropped' );
+ }
+
+ /**
+ * Store a session row directly.
+ *
+ * @param array $data Session data.
+ */
+ private function store( array $data ): void {
+ global $wpdb;
+ $wpdb->replace(
+ $this->table,
+ array(
+ 'session_key' => self::SESSION_KEY,
+ 'session_value' => maybe_serialize( $data ),
+ 'session_expiry' => time() + 100,
+ )
+ );
+ }
+
+ /**
+ * Read the stored session row.
+ *
+ * @return array
+ */
+ private function get_stored(): array {
+ global $wpdb;
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+ return (array) maybe_unserialize( $wpdb->get_var( $wpdb->prepare( "SELECT session_value FROM {$this->table} WHERE session_key = %s", self::SESSION_KEY ) ) );
+ }
+}