Commit 87cd67638ac for woocommerce

commit 87cd67638acb6c193e12835a17da0800ac91ab97
Author: Christopher Hertel <mail@christopher-hertel.de>
Date:   Tue Nov 10 10:33:17 2026 +0100

    Fix ability writes reporting success after a deadlock rollback (#70702)

    * Fail ability writes the database rolled back on a deadlock

    When two writes deadlock, InnoDB rolls back the whole transaction of one
    of them. DatabaseTransaction::run() did not notice: the call's remaining
    queries ran in autocommit, its COMMIT was a no-op, and the ability
    reported success for a write that was gone.

    run() now sets a savepoint at the start of the transaction and releases
    it before committing. MySQL drops every savepoint on a rollback, so a
    failed release means the transaction was lost; run() then rolls back and
    returns a 409 WP_Error. While run() owns the transaction it also turns
    autocommit off, so the queries after a deadlock land in an implicit
    transaction that the rollback discards instead of committing one by one.

    * Detect lost ability writes from the query error, not a savepoint

    The sentinel savepoint could not tell a deadlock rollback from an
    implicit commit: DDL, LOCK TABLES or a raw COMMIT inside a hook also
    drop every savepoint, so the call rolled back the half after the commit
    and asked for a retry that re-applied the other half. It also missed a
    lock wait timeout, which rolls back only the one statement and leaves
    the savepoints in place.

    run() now watches the queries of the callback through the query filter,
    which WordPress applies before it clears the previous query's error, and
    treats a deadlock (1213) or lock wait timeout (1205) as a lost write:
    roll back, return the 409 WP_Error. Without one, an implicit commit
    inside the callback commits as before. A caller that commits after a
    deadlock now gets a rollback too, and run() restores autocommit only
    when the connection had it on.

    The changelog moves to dev: the transaction code is unreleased.

    * Treat a MariaDB snapshot conflict as a lost ability write

    On MariaDB 11.6+ innodb_snapshot_isolation is on by default, so two
    product saves that race on the product_query transient version fail
    with ER_CHECKREAD (1020, "Record has changed since last read") instead
    of a deadlock. MariaDB rolls back the whole transaction, as on a
    deadlock, but run() only looked for 1205 and 1213, so the parallel
    product-delete repro still reported a trash that never happened.

    Add 1020 to the watched error numbers, and match the "try restarting
    transaction" suffix that the server puts on all three messages for
    drivers other than mysqli.

    * Fail an ability write when its final COMMIT hits a conflict

    On a Galera cluster, a certification conflict surfaces as a deadlock on
    COMMIT and the server rolls the transaction back. run() only checked
    errors before closing, so it reported success for the lost write. Check
    the error of the closing statement too, so run() returns the 409.

    * Reset the transaction depth when a rollback in run() throws

    * Test that a lost-write error number alone fails the transaction

    * Use the ProductStatus constant in the deadlock test

    * Reword the COMMIT conflict note in DatabaseTransaction::end()

    * Say a conflicted ability write might not be saved, and match the suffix without a regex

diff --git a/plugins/woocommerce/changelog/wooai-1656-parallel-product-writes b/plugins/woocommerce/changelog/wooai-1656-parallel-product-writes
new file mode 100644
index 00000000000..16d114baf3b
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1656-parallel-product-writes
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Fail ability writes that hit a deadlock in unreleased transaction code; nothing shipped is affected.
+
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 932dd33c2cc..05f2ce7b3b6 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -45,7 +45,9 @@ defined( 'ABSPATH' ) || exit;
  * adds write what the saved objects imply elsewhere, such as the price range a
  * variable parent derives from its variations, or a row in a table of the
  * extension. A sync also sees the objects the call deleted and the input of
- * the call. A refusal there rolls the call back too. A rollback drops the
+ * the call. A refusal there rolls the call back too. When a query hits a
+ * deadlock, a lock wait timeout or a snapshot conflict with a parallel call, the call rolls
+ * back and fails with a WP_Error instead of reporting the write. A rollback drops the
  * object caches of the objects the call saved or deleted. It does not undo what a sync
  * did outside the database, such as an HTTP request, and it does nothing on a
  * table engine without transactions. The other steps must not save, send email
diff --git a/plugins/woocommerce/src/Internal/Utilities/DatabaseTransaction.php b/plugins/woocommerce/src/Internal/Utilities/DatabaseTransaction.php
index fd2024d27a7..262d2c4ee6b 100644
--- a/plugins/woocommerce/src/Internal/Utilities/DatabaseTransaction.php
+++ b/plugins/woocommerce/src/Internal/Utilities/DatabaseTransaction.php
@@ -8,6 +8,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Internal\Utilities;

 use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Utilities\StringUtil;

 defined( 'ABSPATH' ) || exit;

@@ -22,14 +23,24 @@ defined( 'ABSPATH' ) || exit;
  * When a caller started a transaction with wc_transaction_query() before run(), run() works in a savepoint
  * inside it, so the caller's commit or rollback decides the outcome, as on code without run().
  *
+ * When a query inside run() hits a deadlock, a lock wait timeout or a MariaDB snapshot conflict, the database
+ * rolls back the transaction or that one query, so run() rolls back the rest and returns a WP_Error. When run() owns the transaction, autocommit
+ * is off while it runs, so the queries after a deadlock roll back too. Inside a transaction that a caller
+ * started, they commit one by one, as on code without run().
+ *
  * Raw START TRANSACTION, COMMIT or ROLLBACK queries and DDL statements still end the transaction, because
- * MySQL commits before them.
+ * MySQL commits before them. The queries after them commit or roll back when run() ends.
  *
  * @internal
  * @since 11.4.0
  */
 final class DatabaseTransaction {

+	/**
+	 * Error numbers of a snapshot conflict (MariaDB with innodb_snapshot_isolation), a lock wait timeout and a deadlock.
+	 */
+	private const LOST_WRITE_ERRNOS = array( 1020, 1205, 1213 );
+
 	/**
 	 * Number of open levels: the transaction, then one per savepoint. 0 when no transaction is owned.
 	 *
@@ -44,13 +55,27 @@ final class DatabaseTransaction {
 	 */
 	private static $caller_open = false;

+	/**
+	 * Whether the outermost run() watches its queries for an error that lost the write.
+	 *
+	 * @var bool
+	 */
+	private static $watching = false;
+
+	/**
+	 * Whether a query inside the outermost run() hit an error that lost the write.
+	 *
+	 * @var bool
+	 */
+	private static $lost = false;
+
 	/**
 	 * Run the callback in a transaction. Commit when it returns, roll back when it returns a WP_Error or
 	 * throws. Inside another run(), or inside a transaction that wc_transaction_query() started, the callback
 	 * runs in a savepoint.
 	 *
 	 * @param callable $callback Callback to run.
-	 * @return mixed What the callback returns.
+	 * @return mixed What the callback returns, or a WP_Error when a query hit an error that lost the write.
 	 * @throws \Throwable What the callback throws, after the rollback.
 	 */
 	public static function run( callable $callback ) {
@@ -61,17 +86,44 @@ final class DatabaseTransaction {

 		$joined      = 0 === self::$depth && self::$caller_open;
 		$level       = $joined ? 1 : self::$depth;
+		$outermost   = ! self::$watching;
+		$autocommit  = 0 === $level && self::is_autocommit_on();
 		self::$depth = $level;
+		if ( $autocommit ) {
+			self::query( 'SET autocommit = 0' );
+		}
 		self::start();
+		if ( $outermost ) {
+			self::$lost     = false;
+			self::$watching = true;
+			add_filter( 'query', array( self::class, 'watch' ) );
+		}
 		try {
 			$result = $callback();
+			self::end( $level, ! is_wp_error( $result ) );
+			$lost = self::$lost;
 		} catch ( \Throwable $e ) {
 			self::end( $level, false );
-			self::$depth = $joined ? 0 : self::$depth;
 			throw $e;
+		} finally {
+			if ( $outermost ) {
+				remove_filter( 'query', array( self::class, 'watch' ) );
+				self::$watching = false;
+				self::$lost     = false;
+			}
+			if ( $autocommit ) {
+				self::query( 'SET autocommit = 1' );
+			}
+			self::$depth = $joined ? 0 : $level;
+		}
+
+		if ( $lost && ! is_wp_error( $result ) ) {
+			return new \WP_Error(
+				'woocommerce_transaction_rolled_back',
+				__( 'The change might not have been saved because another change ran at the same time. Check it and try again.', 'woocommerce' ),
+				array( 'status' => 409 )
+			);
 		}
-		self::end( $level, ! is_wp_error( $result ) );
-		self::$depth = $joined ? 0 : self::$depth;

 		return $result;
 	}
@@ -94,6 +146,18 @@ final class DatabaseTransaction {
 		self::$caller_open = 'commit' !== $type && 'rollback' !== $type;
 	}

+	/**
+	 * Note an error of the previous query that lost the write. WordPress applies the query filter before it
+	 * clears the error of the previous query.
+	 *
+	 * @param mixed $query Query, returned unchanged.
+	 * @return mixed
+	 */
+	public static function watch( $query ) {
+		self::check_last_error();
+		return $query;
+	}
+
 	/**
 	 * Start the transaction, or set a savepoint inside the open one. Other code uses run(). wc_transaction_query()
 	 * calls this method inside an open transaction, and the test bootstrap calls it one time, because the test
@@ -115,29 +179,67 @@ final class DatabaseTransaction {
 	}

 	/**
-	 * Close the given level and every level that a caller left open inside it.
+	 * Close the given level and every level that a caller left open inside it. After an error that lost the
+	 * write, roll back instead of committing. A COMMIT can fail too, when Galera finds a certification conflict.
 	 *
 	 * @param int  $level  Level to close. 0 is the transaction itself.
 	 * @param bool $commit Whether to keep the changes.
 	 */
 	private static function end( int $level, bool $commit ): void {
-		$level = max( 0, $level );
+		self::check_last_error();
+		$commit = $commit && ! self::$lost;
+		$level  = max( 0, $level );
 		if ( 0 === $level ) {
 			self::query( $commit ? 'COMMIT' : 'ROLLBACK' );
 		} else {
-			self::query( ( $commit ? 'RELEASE SAVEPOINT wc_' : 'ROLLBACK TO SAVEPOINT wc_' ) . $level );
+			// A deadlock already dropped the savepoint, so rolling back to it is expected to fail.
+			self::query( ( $commit ? 'RELEASE SAVEPOINT wc_' : 'ROLLBACK TO SAVEPOINT wc_' ) . $level, self::$lost );
 		}
+		self::check_last_error();
 		self::$depth = $level;
 	}

+	/**
+	 * Mark the transaction lost when the previous query hit one of LOST_WRITE_ERRNOS. The server ends the
+	 * message of each with "try restarting transaction", which covers database drivers other than mysqli.
+	 */
+	private static function check_last_error(): void {
+		global $wpdb;
+
+		if ( ! self::$watching || self::$lost || empty( $wpdb->last_error ) ) {
+			return;
+		}
+
+		$dbh        = $wpdb instanceof \wpdb ? $wpdb->__get( 'dbh' ) : null;
+		$errno      = $dbh instanceof \mysqli ? mysqli_errno( $dbh ) : 0; // phpcs:ignore WordPress.DB.RestrictedFunctions.mysql_mysqli_errno -- wpdb does not expose the error number.
+		self::$lost = in_array( $errno, self::LOST_WRITE_ERRNOS, true )
+			|| StringUtil::ends_with( rtrim( (string) $wpdb->last_error ), 'try restarting transaction' );
+	}
+
+	/**
+	 * Whether the connection commits each query on its own, which MySQL does by default.
+	 *
+	 * @return bool
+	 */
+	private static function is_autocommit_on(): bool {
+		global $wpdb;
+
+		return '1' === (string) $wpdb->get_var( 'SELECT @@autocommit' );
+	}
+
 	/**
 	 * Send a transaction statement.
 	 *
-	 * @param string $sql Statement. The only variable part is an integer level.
+	 * @param string $sql   Statement. The only variable part is an integer level.
+	 * @param bool   $quiet Whether to keep an expected failure out of the output.
 	 */
-	private static function query( string $sql ): void {
+	private static function query( string $sql, bool $quiet = false ): void {
 		global $wpdb;

+		$suppress = $quiet ? $wpdb->suppress_errors() : null;
 		$wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The only variable part is an integer level.
+		if ( null !== $suppress ) {
+			$wpdb->suppress_errors( $suppress );
+		}
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 46f913164f7..f63b4f64157 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -9,6 +9,7 @@ namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;

 use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
 use Automattic\WooCommerce\Abilities\ActionableAbility;
+use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
 use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
@@ -2929,6 +2930,26 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		$this->assertContains( $product->get_id(), $cleaned );
 	}

+	/**
+	 * @testdox Should fail a trash, and keep the product, when a query of the call hits a deadlock.
+	 */
+	public function test_a_deadlock_fails_the_call_and_keeps_the_product(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$this->add_test_action(
+			'trashed_post',
+			static function () {
+				global $wpdb;
+				$wpdb->last_error = 'Deadlock found when trying to get lock; try restarting transaction';
+			}
+		);
+
+		$response = $this->run_ability_response( 'woocommerce/product-delete', array( 'id' => $product->get_id() ) );
+
+		$this->assertSame( 409, $response->get_status() );
+		$this->assertSame( 'woocommerce_transaction_rolled_back', $response->get_data()['code'] );
+		$this->assertSame( ProductStatus::PUBLISH, get_post_status( $product->get_id() ) );
+	}
+
 	/**
 	 * @testdox Should drop the cache of an order when a sync refuses the change.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseTransactionTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseTransactionTest.php
index 1d7b8ab2974..a63082e3e1a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseTransactionTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseTransactionTest.php
@@ -11,7 +11,7 @@ use WC_Unit_Test_Case;
  *
  * The test bootstrap owns the transaction that every test runs in. These tests reset the owned depth to 0,
  * and replace each transaction statement with SELECT 1, so they can read the statements of a request that
- * owns no transaction without ending the test transaction.
+ * owns no transaction without ending the test transaction. The autocommit check reads $autocommit instead.
  */
 class DatabaseTransactionTest extends WC_Unit_Test_Case {

@@ -22,6 +22,13 @@ class DatabaseTransactionTest extends WC_Unit_Test_Case {
 	 */
 	private $statements = array();

+	/**
+	 * The autocommit value that the connection reports.
+	 *
+	 * @var int
+	 */
+	private $autocommit = 1;
+
 	/**
 	 * Owned depth before the test.
 	 *
@@ -63,7 +70,11 @@ class DatabaseTransactionTest extends WC_Unit_Test_Case {
 	 * @return string
 	 */
 	public function record_statement( $query ) {
-		foreach ( array( 'START TRANSACTION', 'COMMIT', 'ROLLBACK', 'SAVEPOINT', 'RELEASE SAVEPOINT' ) as $prefix ) {
+		if ( 'SELECT @@autocommit' === $query ) {
+			$this->statements[] = $query;
+			return 'SELECT ' . $this->autocommit;
+		}
+		foreach ( array( 'START TRANSACTION', 'COMMIT', 'ROLLBACK', 'SAVEPOINT', 'RELEASE SAVEPOINT', 'SET autocommit' ) as $prefix ) {
 			if ( 0 === strpos( $query, $prefix ) ) {
 				$this->statements[] = $query;
 				return 'SELECT 1';
@@ -101,7 +112,7 @@ class DatabaseTransactionTest extends WC_Unit_Test_Case {

 		$this->assertSame( 'done', $result );
 		$this->assertSame(
-			array( 'START TRANSACTION', 'SAVEPOINT wc_1', 'SAVEPOINT wc_2', 'ROLLBACK TO SAVEPOINT wc_2', 'RELEASE SAVEPOINT wc_1', 'COMMIT' ),
+			array( 'SELECT @@autocommit', 'SET autocommit = 0', 'START TRANSACTION', 'SAVEPOINT wc_1', 'SAVEPOINT wc_2', 'ROLLBACK TO SAVEPOINT wc_2', 'RELEASE SAVEPOINT wc_1', 'COMMIT', 'SET autocommit = 1' ),
 			$this->statements
 		);
 		$this->assertFalse( DatabaseTransaction::is_open() );
@@ -119,7 +130,10 @@ class DatabaseTransactionTest extends WC_Unit_Test_Case {
 		);
 		wc_transaction_query( 'start' );

-		$this->assertSame( array( 'START TRANSACTION', 'SAVEPOINT wc_1', 'COMMIT', 'START TRANSACTION' ), $this->statements );
+		$this->assertSame(
+			array( 'SELECT @@autocommit', 'SET autocommit = 0', 'START TRANSACTION', 'SAVEPOINT wc_1', 'COMMIT', 'SET autocommit = 1', 'START TRANSACTION' ),
+			$this->statements
+		);
 	}

 	/**
@@ -140,7 +154,7 @@ class DatabaseTransactionTest extends WC_Unit_Test_Case {
 		DatabaseTransaction::run( static fn() => null );

 		$this->assertSame(
-			array( 'START TRANSACTION', 'SAVEPOINT wc_1', 'SAVEPOINT wc_2', 'RELEASE SAVEPOINT wc_2', 'RELEASE SAVEPOINT wc_1', 'SAVEPOINT wc_1', 'ROLLBACK TO SAVEPOINT wc_1', 'ROLLBACK', 'START TRANSACTION', 'COMMIT' ),
+			array( 'START TRANSACTION', 'SAVEPOINT wc_1', 'SAVEPOINT wc_2', 'RELEASE SAVEPOINT wc_2', 'RELEASE SAVEPOINT wc_1', 'SAVEPOINT wc_1', 'ROLLBACK TO SAVEPOINT wc_1', 'ROLLBACK', 'SELECT @@autocommit', 'SET autocommit = 0', 'START TRANSACTION', 'COMMIT', 'SET autocommit = 1' ),
 			$this->statements
 		);
 		$this->assertFalse( DatabaseTransaction::is_open() );
@@ -164,8 +178,237 @@ class DatabaseTransactionTest extends WC_Unit_Test_Case {
 			$this->assertSame( 'Disk full.', $e->getMessage() );
 		}

-		$this->assertSame( array( 'START TRANSACTION', 'ROLLBACK', 'START TRANSACTION', 'ROLLBACK' ), $this->statements );
+		$this->assertSame(
+			array( 'SELECT @@autocommit', 'SET autocommit = 0', 'START TRANSACTION', 'ROLLBACK', 'SET autocommit = 1', 'SELECT @@autocommit', 'SET autocommit = 0', 'START TRANSACTION', 'ROLLBACK', 'SET autocommit = 1' ),
+			$this->statements
+		);
+		$this->assertFalse( DatabaseTransaction::is_open() );
+	}
+
+	/**
+	 * @testdox run() should roll back and return a WP_Error when a query inside it hits a deadlock, a lock wait timeout or a snapshot conflict.
+	 * @testWith ["Deadlock found when trying to get lock; try restarting transaction"]
+	 *           ["Lock wait timeout exceeded; try restarting transaction"]
+	 *           ["Record has changed since last read in table 'wp_options'; try restarting transaction"]
+	 *
+	 * @param string $error Database error of the failed query.
+	 */
+	public function test_run_rolls_back_after_a_lost_write( string $error ): void {
+		$result = DatabaseTransaction::run(
+			static function () use ( $error ) {
+				global $wpdb;
+				$wpdb->last_error = $error;
+				return 'done';
+			}
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_transaction_rolled_back', $result->get_error_code() );
+		$this->assertSame( array( 'SELECT @@autocommit', 'SET autocommit = 0', 'START TRANSACTION', 'ROLLBACK', 'SET autocommit = 1' ), $this->statements );
+		$this->assertFalse( DatabaseTransaction::is_open() );
+	}
+
+	/**
+	 * @testdox run() should roll back and return a WP_Error when a query fails with a lost-write error number and a message in another wording.
+	 */
+	public function test_run_rolls_back_after_a_lost_write_error_number(): void {
+		$result = DatabaseTransaction::run(
+			static function () {
+				global $wpdb;
+				$suppress = $wpdb->suppress_errors();
+				$wpdb->query( "SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1020, MESSAGE_TEXT = 'Snapshot conflict'" );
+				$wpdb->suppress_errors( $suppress );
+				return 'done';
+			}
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_transaction_rolled_back', $result->get_error_code() );
+		$this->assertSame( array( 'SELECT @@autocommit', 'SET autocommit = 0', 'START TRANSACTION', 'ROLLBACK', 'SET autocommit = 1' ), $this->statements );
+	}
+
+	/**
+	 * @testdox run() should notice a deadlock that later queries inside it would clear.
+	 */
+	public function test_run_notices_a_deadlock_before_later_queries(): void {
+		$result = DatabaseTransaction::run(
+			static function () {
+				global $wpdb;
+				$wpdb->last_error = 'Deadlock found when trying to get lock; try restarting transaction';
+				$wpdb->get_var( 'SELECT 2' );
+				return 'done';
+			}
+		);
+
+		$this->assertWPError( $result );
+		$this->assertContains( 'ROLLBACK', $this->statements );
+		$this->assertNotContains( 'COMMIT', $this->statements );
+	}
+
+	/**
+	 * @testdox run() should roll back its savepoint and return a WP_Error after a deadlock in a transaction that a caller started.
+	 */
+	public function test_run_rolls_back_a_caller_transaction_after_a_deadlock(): void {
+		wc_transaction_query( 'start' );
+
+		$result = DatabaseTransaction::run(
+			static function () {
+				global $wpdb;
+				$wpdb->last_error = 'Deadlock found when trying to get lock; try restarting transaction';
+				return 'done';
+			}
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( array( 'START TRANSACTION', 'SAVEPOINT wc_1', 'ROLLBACK TO SAVEPOINT wc_1' ), $this->statements );
+		$this->assertFalse( DatabaseTransaction::is_open() );
+	}
+
+	/**
+	 * @testdox run() should roll back when a caller inside it commits after a deadlock.
+	 */
+	public function test_run_rolls_back_a_caller_commit_after_a_deadlock(): void {
+		$result = DatabaseTransaction::run(
+			static function () {
+				global $wpdb;
+				$wpdb->last_error = 'Deadlock found when trying to get lock; try restarting transaction';
+				wc_transaction_query( 'commit' );
+				return 'done';
+			}
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( array( 'SELECT @@autocommit', 'SET autocommit = 0', 'START TRANSACTION', 'ROLLBACK', 'ROLLBACK', 'SET autocommit = 1' ), $this->statements );
+	}
+
+	/**
+	 * @testdox run() should return a WP_Error when its COMMIT hits a conflict, as Galera reports a certification failure.
+	 */
+	public function test_run_fails_when_the_commit_hits_a_deadlock(): void {
+		global $wpdb;
+		$real = $wpdb;
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- A real COMMIT cannot be made to fail here.
+		$wpdb = new class() {
+			/**
+			 * Error of the latest query.
+			 *
+			 * @var string
+			 */
+			public $last_error = '';
+
+			/**
+			 * Run the query filter first, as wpdb does, and fail COMMIT the way Galera does.
+			 *
+			 * @param string $query Query.
+			 * @return bool
+			 */
+			public function query( $query ) {
+				apply_filters( 'query', $query );
+				$this->last_error = 'COMMIT' === $query ? 'Deadlock found when trying to get lock; try restarting transaction' : '';
+				return false;
+			}
+
+			/**
+			 * Report autocommit on.
+			 *
+			 * @param string $query Query.
+			 * @return string
+			 */
+			public function get_var( $query ) {
+				$this->query( $query );
+				return '1';
+			}
+
+			/**
+			 * Errors are never suppressed.
+			 *
+			 * @return bool
+			 */
+			public function suppress_errors() {
+				return false;
+			}
+		};
+
+		try {
+			$result = DatabaseTransaction::run( static fn() => 'done' );
+		} finally {
+			$wpdb = $real; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the real connection.
+		}
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_transaction_rolled_back', $result->get_error_code() );
+		$this->assertSame( array( 'SELECT @@autocommit', 'SET autocommit = 0', 'START TRANSACTION', 'COMMIT', 'SET autocommit = 1' ), $this->statements );
+	}
+
+	/**
+	 * @testdox run() should commit, and return what the callback returned, when a caller inside it already committed.
+	 */
+	public function test_run_commits_after_a_caller_committed(): void {
+		$result = DatabaseTransaction::run(
+			static function () {
+				wc_transaction_query( 'commit' );
+				return 'done';
+			}
+		);
+
+		$this->assertSame( 'done', $result );
+		$this->assertSame( array( 'SELECT @@autocommit', 'SET autocommit = 0', 'START TRANSACTION', 'COMMIT', 'COMMIT', 'SET autocommit = 1' ), $this->statements );
+	}
+
+	/**
+	 * @testdox run() should ignore a deadlock that happened before it started.
+	 */
+	public function test_run_ignores_a_deadlock_before_it(): void {
+		global $wpdb;
+		$wpdb->last_error = 'Deadlock found when trying to get lock; try restarting transaction';
+
+		$result = DatabaseTransaction::run( static fn() => 'done' );
+
+		$this->assertSame( 'done', $result );
+		$this->assertContains( 'COMMIT', $this->statements );
+	}
+
+	/**
+	 * @testdox run() should own no transaction after a query filter throws on its rollback, so the next run() starts one.
+	 */
+	public function test_run_owns_no_transaction_after_its_rollback_throws(): void {
+		$throw_on_rollback = static function ( $query ) {
+			if ( 'ROLLBACK' === $query ) {
+				throw new \RuntimeException( 'Rollback refused.' );
+			}
+			return $query;
+		};
+		add_filter( 'query', $throw_on_rollback, 0 );
+		try {
+			DatabaseTransaction::run(
+				static function () {
+					throw new \RuntimeException( 'Disk full.' );
+				}
+			);
+			$this->fail( 'The exception should reach the caller.' );
+		} catch ( \RuntimeException $e ) {
+			$this->assertSame( 'Rollback refused.', $e->getMessage() );
+		} finally {
+			remove_filter( 'query', $throw_on_rollback, 0 );
+		}
+
 		$this->assertFalse( DatabaseTransaction::is_open() );
+
+		$this->statements = array();
+		DatabaseTransaction::run( static fn() => null );
+
+		$this->assertSame( array( 'SELECT @@autocommit', 'SET autocommit = 0', 'START TRANSACTION', 'COMMIT', 'SET autocommit = 1' ), $this->statements );
+	}
+
+	/**
+	 * @testdox run() should leave autocommit alone when the connection already has it off.
+	 */
+	public function test_run_keeps_autocommit_off(): void {
+		$this->autocommit = 0;
+
+		DatabaseTransaction::run( static fn() => null );
+
+		$this->assertSame( array( 'SELECT @@autocommit', 'START TRANSACTION', 'COMMIT' ), $this->statements );
 	}

 	/**