diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index 9874727d26c9..a0e68325d2b6 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -4,6 +4,8 @@ use Closure; use Illuminate\Database\DeadlockException; +use Illuminate\Support\Collection; +use Illuminate\Support\Sleep; use RuntimeException; use Throwable; @@ -19,11 +21,12 @@ trait ManagesTransactions * * @param (\Closure(static): TReturn) $callback * @param int $attempts + * @param \Closure(\Throwable, int, int): int|array|Collection|int|null $backoff * @return TReturn * * @throws \Throwable */ - public function transaction(Closure $callback, $attempts = 1) + public function transaction(Closure $callback, $attempts = 1, $backoff = null) { for ($currentAttempt = 1; $currentAttempt <= $attempts; $currentAttempt++) { $this->beginTransaction(); @@ -40,7 +43,7 @@ public function transaction(Closure $callback, $attempts = 1) // exception back out, and let the developer handle an uncaught exception. catch (Throwable $e) { $this->handleTransactionException( - $e, $currentAttempt, $attempts + $e, $currentAttempt, $attempts, $backoff ); continue; @@ -57,7 +60,7 @@ public function transaction(Closure $callback, $attempts = 1) $this->transactions = max(0, $this->transactions - 1); } catch (Throwable $e) { $this->handleCommitTransactionException( - $e, $currentAttempt, $attempts + $e, $currentAttempt, $attempts, $backoff ); continue; @@ -81,11 +84,12 @@ public function transaction(Closure $callback, $attempts = 1) * @param \Throwable $e * @param int $currentAttempt * @param int $maxAttempts + * @param \Closure(\Throwable, int, int): int|array|Collection|int|null $backoff * @return void * * @throws \Throwable */ - protected function handleTransactionException(Throwable $e, $currentAttempt, $maxAttempts) + protected function handleTransactionException(Throwable $e, $currentAttempt, $maxAttempts, $backoff) { // On a deadlock, MySQL rolls back the entire transaction so we can't just // retry the query. We have to throw this exception all the way out and @@ -106,14 +110,43 @@ protected function handleTransactionException(Throwable $e, $currentAttempt, $ma // if we haven't we will return and try this query again in our loop. $this->rollBack(); - if ($this->causedByConcurrencyError($e) && - $currentAttempt < $maxAttempts) { + if ($this->causedByConcurrencyError($e) && $currentAttempt < $maxAttempts) { + $this->handleTransactionExceptionBackoff($backoff, $e, $currentAttempt, $maxAttempts); + return; } throw $e; } + /** + * Handle the backoff between transaction attempts. + * + * @param \Closure(\Throwable, int, int): int|array|Collection|int|null $backoff + * @param \Throwable $e + * @param int $currentAttempt + * @param int $maxAttempts + * @return void + */ + protected function handleTransactionExceptionBackoff($backoff, Throwable $e, $currentAttempt, $maxAttempts): void + { + if (is_null($backoff)) { + return; + } + + $duration = (int) match (true) { + is_int($backoff) => $backoff, + is_array($backoff) => $backoff[$currentAttempt - 1] ?? end($backoff), + $backoff instanceof Collection => $backoff[$currentAttempt - 1] ?? $backoff->last(), + is_callable($backoff) => $backoff($e, $currentAttempt, $maxAttempts), + default => 0, + }; + + if ($duration > 0) { + Sleep::for($duration)->milliseconds(); + } + } + /** * Start a new database transaction. * @@ -225,15 +258,18 @@ public function commit() * @param \Throwable $e * @param int $currentAttempt * @param int $maxAttempts + * @param \Closure(\Throwable, int, int): int|array|Collection|int|null $backoff * @return void * * @throws \Throwable */ - protected function handleCommitTransactionException(Throwable $e, $currentAttempt, $maxAttempts) + protected function handleCommitTransactionException(Throwable $e, $currentAttempt, $maxAttempts, $backoff) { $this->transactions = max(0, $this->transactions - 1); if ($this->causedByConcurrencyError($e) && $currentAttempt < $maxAttempts) { + $this->handleTransactionExceptionBackoff($backoff, $e, $currentAttempt, $maxAttempts); + return; } diff --git a/src/Illuminate/Database/ConnectionInterface.php b/src/Illuminate/Database/ConnectionInterface.php index 69e1fafa389c..f9ccbbd3e805 100755 --- a/src/Illuminate/Database/ConnectionInterface.php +++ b/src/Illuminate/Database/ConnectionInterface.php @@ -133,11 +133,12 @@ public function prepareBindings(array $bindings); * * @param \Closure $callback * @param int $attempts + * @param \Closure(\Throwable, int, int): int|array|Collection|int|null $backoff * @return mixed * * @throws \Throwable */ - public function transaction(Closure $callback, $attempts = 1); + public function transaction(Closure $callback, $attempts = 1, $backoff = null); /** * Start a new database transaction. diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index 7b3d0c5f0183..37e0c1eafea2 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -27,15 +27,20 @@ public function getDriverTitle() * * @param \Closure $callback * @param int $attempts + * @param \Closure(\Throwable, int, int): int|array|Collection|int|null $backoff * @return mixed * * @throws \Throwable */ - public function transaction(Closure $callback, $attempts = 1) + public function transaction(Closure $callback, $attempts = 1, $backoff = null) { for ($a = 1; $a <= $attempts; $a++) { if ($this->getDriverName() === 'sqlsrv') { - return parent::transaction($callback, $attempts); + return parent::transaction($callback, $attempts, $backoff); + } + + if ($backoff !== null) { + throw new RuntimeException('Transaction attempt backoffs are only supported for "sqlsrv" driver connections.'); } $this->getPdo()->exec('BEGIN TRAN'); diff --git a/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php index 3affe52a8a00..d9cf9b51d8f5 100644 --- a/tests/Database/DatabaseTransactionsTest.php +++ b/tests/Database/DatabaseTransactionsTest.php @@ -5,7 +5,10 @@ use Exception; use Illuminate\Database\Capsule\Manager as DB; use Illuminate\Database\DatabaseTransactionsManager; +use Illuminate\Support\Sleep; use Mockery as m; +use PDOException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Throwable; @@ -241,6 +244,134 @@ public function testNestedTransactionsAreRolledBack() } } + #[DataProvider('backOffProvider')] + public function testTransactionIsRolledBackAndBackedOff($backoff, $expectedSleepSequence) + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->times(4)->with('default', 1); + $transactionManager->shouldReceive('rollback')->times(4)->with('default', 0); + $transactionManager->shouldNotReceive('commit'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + Sleep::fake(); + + try { + $this->connection()->transaction( + callback: function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + throw new PDOException('deadlock detected', 40001); + }, + attempts: 4, + backoff: $backoff, + ); + } catch (PDOException) { + } + + if (count($expectedSleepSequence) > 0) { + Sleep::assertSequence($expectedSleepSequence); + } else { + Sleep::assertNeverSlept(); + } + } + + #[DataProvider('backOffProvider')] + public function testNestedTransactionsAreRolledBackAndBackedOff($backoff, $expectedSleepSequence) + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->times(4)->with('default', 1); + $transactionManager->shouldReceive('begin')->times(4)->with('default', 2); + $transactionManager->shouldReceive('rollback')->times(4)->with('default', 1); + $transactionManager->shouldReceive('rollback')->times(4)->with('default', 0); + $transactionManager->shouldNotReceive('commit'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + Sleep::fake(); + + try { + $this->connection()->transaction( + callback: function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 3, + ]); + + throw new PDOException('deadlock detected', 40001); + }); + }, + attempts: 4, + backoff: $backoff, + ); + } catch (PDOException) { + } + + if (count($expectedSleepSequence) > 0) { + Sleep::assertSequence($expectedSleepSequence); + } else { + Sleep::assertNeverSlept(); + } + } + + public static function backOffProvider() + { + yield 'null backoff' => [ + null, + [], + ]; + + yield 'integer backoff' => [ + 42, + [ + Sleep::for(42)->milliseconds(), + Sleep::for(42)->milliseconds(), + Sleep::for(42)->milliseconds(), + ], + ]; + + yield 'array backoff' => [ + [1111, 2222], + [ + Sleep::for(1111)->milliseconds(), + Sleep::for(2222)->milliseconds(), + Sleep::for(2222)->milliseconds(), + ], + ]; + + yield 'collection backoff' => [ + collect([1111, 2222]), + [ + Sleep::for(1111)->milliseconds(), + Sleep::for(2222)->milliseconds(), + Sleep::for(2222)->milliseconds(), + ], + ]; + + yield 'callable backoff' => [ + fn (Throwable $e, int $currentAttempt, int $maxAttempts): int => $currentAttempt * 2222, + [ + Sleep::for(2222)->milliseconds(), + Sleep::for(4444)->milliseconds(), + Sleep::for(6666)->milliseconds(), + ], + ]; + } + /** * Get a schema builder instance. *