From 51b495366a5bb28127f20ab9c6a06f4f36d1fa04 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Mon, 29 Dec 2025 14:57:53 +0000 Subject: [PATCH 01/27] add backoff parameter to db transaction method --- .../Database/Concerns/ManagesTransactions.php | 34 ++++++++- tests/Database/DatabaseTransactionsTest.php | 76 +++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index 9874727d26c9..56bf60d93504 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -4,6 +4,7 @@ use Closure; use Illuminate\Database\DeadlockException; +use Illuminate\Support\Sleep; use RuntimeException; use Throwable; @@ -19,11 +20,12 @@ trait ManagesTransactions * * @param (\Closure(static): TReturn) $callback * @param int $attempts + * @param callable|array|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 +42,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; @@ -81,11 +83,12 @@ public function transaction(Closure $callback, $attempts = 1) * @param \Throwable $e * @param int $currentAttempt * @param int $maxAttempts + * @param callable|array|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 @@ -108,12 +111,37 @@ protected function handleTransactionException(Throwable $e, $currentAttempt, $ma if ($this->causedByConcurrencyError($e) && $currentAttempt < $maxAttempts) { + $this->handleBackoff($backoff, $e, $currentAttempt, $maxAttempts); + return; } throw $e; } + /** + * Handle the backoff between transaction attempts. + * + * @param callable|array|int|null $backoff + * @param \Throwable $e + * @param int $currentAttempt + * @param int $maxAttempts + * @return void + */ + public function handleBackoff(callable|array|int|null $backoff = null, Throwable $e, $currentAttempt, $maxAttempts): void + { + $duration = (int) match (true) { + is_int($backoff) => $backoff, + is_array($backoff) => $backoff[$currentAttempt - 1] ?? end($backoff) ?? 0, + is_callable($backoff) => $backoff($e, $currentAttempt, $maxAttempts) ?? 0, + default => 0, + }; + + if ($duration > 0) { + Sleep::for($duration)->milliseconds(); + } + } + /** * Start a new database transaction. * diff --git a/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php index 3affe52a8a00..d96e093bd2ad 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,79 @@ public function testNestedTransactionsAreRolledBack() } } + #[DataProvider('transactionRollBackAndBackedOffProvider')] + 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('A deadlock occurred', 40001); + }, + attempts: 4, // Final attempt doesn't have any backoff + backoff: $backoff, + ); + } catch (PDOException) { + } + + if (count($expectedSleepSequence) > 0) { + Sleep::assertSequence($expectedSleepSequence); + } else { + Sleep::assertNeverSlept(); + } + } + + public static function transactionRollBackAndBackedOffProvider() + { + 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 '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. * From 552cf1b64b7c065e5fb29b5523c1e2fd43ca421e Mon Sep 17 00:00:00 2001 From: Jamie York Date: Mon, 29 Dec 2025 15:01:26 +0000 Subject: [PATCH 02/27] let (int) cast handle nulls --- src/Illuminate/Database/Concerns/ManagesTransactions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index 56bf60d93504..52b661931347 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -132,8 +132,8 @@ public function handleBackoff(callable|array|int|null $backoff = null, Throwable { $duration = (int) match (true) { is_int($backoff) => $backoff, - is_array($backoff) => $backoff[$currentAttempt - 1] ?? end($backoff) ?? 0, - is_callable($backoff) => $backoff($e, $currentAttempt, $maxAttempts) ?? 0, + is_array($backoff) => $backoff[$currentAttempt - 1] ?? end($backoff), + is_callable($backoff) => $backoff($e, $currentAttempt, $maxAttempts), default => 0, }; From 7b8422ade9c9f161ed4ce9abe34d36956ca58d05 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Mon, 29 Dec 2025 15:02:54 +0000 Subject: [PATCH 03/27] align types with elsewhere --- src/Illuminate/Database/Concerns/ManagesTransactions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index 52b661931347..912e6caac558 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -128,7 +128,7 @@ protected function handleTransactionException(Throwable $e, $currentAttempt, $ma * @param int $maxAttempts * @return void */ - public function handleBackoff(callable|array|int|null $backoff = null, Throwable $e, $currentAttempt, $maxAttempts): void + public function handleBackoff($backoff = null, Throwable $e, $currentAttempt, $maxAttempts): void { $duration = (int) match (true) { is_int($backoff) => $backoff, From b2f2d1f82f96c5449f8b18920f6da2f9f30db59d Mon Sep 17 00:00:00 2001 From: Jamie York Date: Mon, 29 Dec 2025 15:04:23 +0000 Subject: [PATCH 04/27] rename method --- src/Illuminate/Database/Concerns/ManagesTransactions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index 912e6caac558..b4562b4fd9db 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -111,7 +111,7 @@ protected function handleTransactionException(Throwable $e, $currentAttempt, $ma if ($this->causedByConcurrencyError($e) && $currentAttempt < $maxAttempts) { - $this->handleBackoff($backoff, $e, $currentAttempt, $maxAttempts); + $this->handleTransactionExceptionBackoff($backoff, $e, $currentAttempt, $maxAttempts); return; } @@ -128,7 +128,7 @@ protected function handleTransactionException(Throwable $e, $currentAttempt, $ma * @param int $maxAttempts * @return void */ - public function handleBackoff($backoff = null, Throwable $e, $currentAttempt, $maxAttempts): void + public function handleTransactionExceptionBackoff($backoff = null, Throwable $e, $currentAttempt, $maxAttempts): void { $duration = (int) match (true) { is_int($backoff) => $backoff, From 7a6f0a1234c109dd7f8aef34e52800db4ba3392b Mon Sep 17 00:00:00 2001 From: Jamie York Date: Mon, 29 Dec 2025 15:05:05 +0000 Subject: [PATCH 05/27] rename dp method --- tests/Database/DatabaseTransactionsTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php index d96e093bd2ad..a08ce289a206 100644 --- a/tests/Database/DatabaseTransactionsTest.php +++ b/tests/Database/DatabaseTransactionsTest.php @@ -244,7 +244,7 @@ public function testNestedTransactionsAreRolledBack() } } - #[DataProvider('transactionRollBackAndBackedOffProvider')] + #[DataProvider('transactionIsRolledBackAndBackedOffProvider')] public function testTransactionIsRolledBackAndBackedOff($backoff, $expectedSleepSequence) { $transactionManager = m::mock(new DatabaseTransactionsManager); @@ -282,7 +282,7 @@ public function testTransactionIsRolledBackAndBackedOff($backoff, $expectedSleep } } - public static function transactionRollBackAndBackedOffProvider() + public static function transactionIsRolledBackAndBackedOffProvider() { yield 'null backoff' => [ null, From bd57c266cc831c3eb33aac1cd46b70e4e533dff0 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Wed, 31 Dec 2025 15:13:58 +0000 Subject: [PATCH 06/27] tidy up types --- .../Database/Concerns/ManagesTransactions.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index b4562b4fd9db..f6fc8160e95e 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -20,7 +20,7 @@ trait ManagesTransactions * * @param (\Closure(static): TReturn) $callback * @param int $attempts - * @param callable|array|int|null $backoff + * @param \Closure(\Throwable, int, int): int|array|int|null $backoff * @return TReturn * * @throws \Throwable @@ -83,7 +83,7 @@ public function transaction(Closure $callback, $attempts = 1, $backoff = null) * @param \Throwable $e * @param int $currentAttempt * @param int $maxAttempts - * @param callable|array|int|null $backoff + * @param \Closure|array|int|null $backoff * @return void * * @throws \Throwable @@ -121,14 +121,14 @@ protected function handleTransactionException(Throwable $e, $currentAttempt, $ma /** * Handle the backoff between transaction attempts. - * - * @param callable|array|int|null $backoff + * + * @param \Closure|array|int|null $backoff * @param \Throwable $e * @param int $currentAttempt * @param int $maxAttempts - * @return void + * @return void */ - public function handleTransactionExceptionBackoff($backoff = null, Throwable $e, $currentAttempt, $maxAttempts): void + protected function handleTransactionExceptionBackoff($backoff = null, Throwable $e, $currentAttempt, $maxAttempts): void { $duration = (int) match (true) { is_int($backoff) => $backoff, From 4fe66989a68c9035c8a0ea4fb60fcda13cbe0105 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Fri, 2 Jan 2026 19:41:55 +0000 Subject: [PATCH 07/27] drop comment --- tests/Database/DatabaseTransactionsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php index a08ce289a206..b675b3ea89e3 100644 --- a/tests/Database/DatabaseTransactionsTest.php +++ b/tests/Database/DatabaseTransactionsTest.php @@ -269,7 +269,7 @@ public function testTransactionIsRolledBackAndBackedOff($backoff, $expectedSleep throw new PDOException('A deadlock occurred', 40001); }, - attempts: 4, // Final attempt doesn't have any backoff + attempts: 4, backoff: $backoff, ); } catch (PDOException) { From e34407d777332618a9b5256c6a75c5296acd29e8 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Fri, 2 Jan 2026 19:50:21 +0000 Subject: [PATCH 08/27] handle when committing --- .../Database/Concerns/ManagesTransactions.php | 10 ++++++---- tests/Database/DatabaseTransactionsTest.php | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index f6fc8160e95e..61f112bfd0e9 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -59,7 +59,7 @@ public function transaction(Closure $callback, $attempts = 1, $backoff = null) $this->transactions = max(0, $this->transactions - 1); } catch (Throwable $e) { $this->handleCommitTransactionException( - $e, $currentAttempt, $attempts + $e, $currentAttempt, $attempts, $backoff ); continue; @@ -109,8 +109,7 @@ 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; @@ -253,15 +252,18 @@ public function commit() * @param \Throwable $e * @param int $currentAttempt * @param int $maxAttempts + * @param \Closure|array|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/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php index b675b3ea89e3..e2887552bc43 100644 --- a/tests/Database/DatabaseTransactionsTest.php +++ b/tests/Database/DatabaseTransactionsTest.php @@ -267,7 +267,7 @@ public function testTransactionIsRolledBackAndBackedOff($backoff, $expectedSleep 'value' => 2, ]); - throw new PDOException('A deadlock occurred', 40001); + throw new PDOException('deadlock detected', 40001); }, attempts: 4, backoff: $backoff, From bf9875cc888a47a05a218f362851bec77bc52c5e Mon Sep 17 00:00:00 2001 From: Jamie York Date: Fri, 2 Jan 2026 21:20:35 +0000 Subject: [PATCH 09/27] test nested --- tests/Database/DatabaseTransactionsTest.php | 50 ++++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php index e2887552bc43..d119df0de2ac 100644 --- a/tests/Database/DatabaseTransactionsTest.php +++ b/tests/Database/DatabaseTransactionsTest.php @@ -244,7 +244,7 @@ public function testNestedTransactionsAreRolledBack() } } - #[DataProvider('transactionIsRolledBackAndBackedOffProvider')] + #[DataProvider('backOffProvider')] public function testTransactionIsRolledBackAndBackedOff($backoff, $expectedSleepSequence) { $transactionManager = m::mock(new DatabaseTransactionsManager); @@ -282,7 +282,53 @@ public function testTransactionIsRolledBackAndBackedOff($backoff, $expectedSleep } } - public static function transactionIsRolledBackAndBackedOffProvider() + #[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, From fa8efafd61fc434c6a7d09ddf7a0e324fd50f364 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Fri, 2 Jan 2026 21:40:36 +0000 Subject: [PATCH 10/27] fix cs --- tests/Database/DatabaseTransactionsTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php index d119df0de2ac..76a1963014df 100644 --- a/tests/Database/DatabaseTransactionsTest.php +++ b/tests/Database/DatabaseTransactionsTest.php @@ -268,12 +268,12 @@ public function testTransactionIsRolledBackAndBackedOff($backoff, $expectedSleep ]); throw new PDOException('deadlock detected', 40001); - }, + }, attempts: 4, backoff: $backoff, ); } catch (PDOException) { - } + } if (count($expectedSleepSequence) > 0) { Sleep::assertSequence($expectedSleepSequence); @@ -314,12 +314,12 @@ public function testNestedTransactionsAreRolledBackAndBackedOff($backoff, $expec throw new PDOException('deadlock detected', 40001); }); - }, + }, attempts: 4, backoff: $backoff, ); } catch (PDOException) { - } + } if (count($expectedSleepSequence) > 0) { Sleep::assertSequence($expectedSleepSequence); @@ -336,7 +336,7 @@ public static function backOffProvider() ]; yield 'integer backoff' => [ - 42, + 42, [ Sleep::for(42)->milliseconds(), Sleep::for(42)->milliseconds(), @@ -359,7 +359,7 @@ public static function backOffProvider() Sleep::for(2222)->milliseconds(), Sleep::for(4444)->milliseconds(), Sleep::for(6666)->milliseconds(), - ], + ], ]; } From 6b7cb93daf44d5f850ce2922ad093d4ff5ade27a Mon Sep 17 00:00:00 2001 From: Jamie York Date: Fri, 2 Jan 2026 21:45:04 +0000 Subject: [PATCH 11/27] non optional --- src/Illuminate/Database/Concerns/ManagesTransactions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index 61f112bfd0e9..908a06bc8a84 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -127,7 +127,7 @@ protected function handleTransactionException(Throwable $e, $currentAttempt, $ma * @param int $maxAttempts * @return void */ - protected function handleTransactionExceptionBackoff($backoff = null, Throwable $e, $currentAttempt, $maxAttempts): void + protected function handleTransactionExceptionBackoff($backoff, Throwable $e, $currentAttempt, $maxAttempts): void { $duration = (int) match (true) { is_int($backoff) => $backoff, From c4458f5441f1bcd1603c3b4858ba111785ea75be Mon Sep 17 00:00:00 2001 From: Jamie York Date: Fri, 2 Jan 2026 21:52:07 +0000 Subject: [PATCH 12/27] add backoff to sql server implementation --- src/Illuminate/Database/SqlServerConnection.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index 7b3d0c5f0183..03a99958bb3d 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -27,11 +27,12 @@ public function getDriverTitle() * * @param \Closure $callback * @param int $attempts + * @param \Closure(\Throwable, int, int): int|array|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') { @@ -55,6 +56,8 @@ public function transaction(Closure $callback, $attempts = 1) catch (Throwable $e) { $this->getPdo()->exec('ROLLBACK TRAN'); + $this->handleTransactionExceptionBackoff($backoff, $e, $a, $attempts); + throw $e; } From e2745a3d02182c05b0fb85006c3d50fa689a8ac5 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Fri, 2 Jan 2026 23:02:50 +0000 Subject: [PATCH 13/27] handle collections --- src/Illuminate/Database/Concerns/ManagesTransactions.php | 8 +++++--- tests/Database/DatabaseTransactionsTest.php | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index 908a06bc8a84..24f3939119da 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -4,6 +4,7 @@ use Closure; use Illuminate\Database\DeadlockException; +use Illuminate\Support\Collection; use Illuminate\Support\Sleep; use RuntimeException; use Throwable; @@ -20,7 +21,7 @@ trait ManagesTransactions * * @param (\Closure(static): TReturn) $callback * @param int $attempts - * @param \Closure(\Throwable, int, int): int|array|int|null $backoff + * @param \Closure(\Throwable, int, int): int|array|Collection|int|null $backoff * @return TReturn * * @throws \Throwable @@ -83,7 +84,7 @@ public function transaction(Closure $callback, $attempts = 1, $backoff = null) * @param \Throwable $e * @param int $currentAttempt * @param int $maxAttempts - * @param \Closure|array|int|null $backoff + * @param \Closure(\Throwable, int, int): int|array|Collection|int|null $backoff * @return void * * @throws \Throwable @@ -121,7 +122,7 @@ protected function handleTransactionException(Throwable $e, $currentAttempt, $ma /** * Handle the backoff between transaction attempts. * - * @param \Closure|array|int|null $backoff + * @param \Closure(\Throwable, int, int): int|array|Collection|int|null $backoff * @param \Throwable $e * @param int $currentAttempt * @param int $maxAttempts @@ -132,6 +133,7 @@ protected function handleTransactionExceptionBackoff($backoff, Throwable $e, $cu $duration = (int) match (true) { is_int($backoff) => $backoff, is_array($backoff) => $backoff[$currentAttempt - 1] ?? end($backoff), + $backoff instanceof Collection => $backoff->get($currentAttempt - 1) ?? $backoff->last(), is_callable($backoff) => $backoff($e, $currentAttempt, $maxAttempts), default => 0, }; diff --git a/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php index 76a1963014df..d9cf9b51d8f5 100644 --- a/tests/Database/DatabaseTransactionsTest.php +++ b/tests/Database/DatabaseTransactionsTest.php @@ -353,6 +353,15 @@ public static function backOffProvider() ], ]; + 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, [ From f19182147fff850f32759efae1ea0926fd7767b7 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Fri, 2 Jan 2026 23:07:02 +0000 Subject: [PATCH 14/27] array access --- src/Illuminate/Database/Concerns/ManagesTransactions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index 24f3939119da..d4fb633e02e2 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -133,7 +133,7 @@ protected function handleTransactionExceptionBackoff($backoff, Throwable $e, $cu $duration = (int) match (true) { is_int($backoff) => $backoff, is_array($backoff) => $backoff[$currentAttempt - 1] ?? end($backoff), - $backoff instanceof Collection => $backoff->get($currentAttempt - 1) ?? $backoff->last(), + $backoff instanceof Collection => $backoff[$currentAttempt - 1] ?? $backoff->last(), is_callable($backoff) => $backoff($e, $currentAttempt, $maxAttempts), default => 0, }; From 67d9edc2b9eecaa4472097da79d3406853043632 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Sun, 4 Jan 2026 22:26:00 +0000 Subject: [PATCH 15/27] update interface --- src/Illuminate/Database/ConnectionInterface.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. From eadcf63651b0ad85ac196ddf556a334fd1acf57c Mon Sep 17 00:00:00 2001 From: Jamie York Date: Mon, 5 Jan 2026 17:29:05 +0000 Subject: [PATCH 16/27] null do nothing --- src/Illuminate/Database/Concerns/ManagesTransactions.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index d4fb633e02e2..f597f09b5553 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -130,6 +130,10 @@ protected function handleTransactionException(Throwable $e, $currentAttempt, $ma */ 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), From af0aa1c06d2f277a17a6c5f7aa6e2ec42b007435 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Mon, 5 Jan 2026 21:49:54 +0000 Subject: [PATCH 17/27] empty ci commit From 9379fa2ac04dfaf05fc029d85bc44576c3cab476 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Mon, 5 Jan 2026 22:29:14 +0000 Subject: [PATCH 18/27] fix type --- src/Illuminate/Database/SqlServerConnection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index 03a99958bb3d..8e79a1b71ddb 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -27,7 +27,7 @@ public function getDriverTitle() * * @param \Closure $callback * @param int $attempts - * @param \Closure(\Throwable, int, int): int|array|int|null $backoff + * @param \Closure(\Throwable, int, int): int|array|Collection|int|null $backoff * @return mixed * * @throws \Throwable From 26b680dddcbb641d924e9edc3f454993bb24c689 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Mon, 5 Jan 2026 22:54:02 +0000 Subject: [PATCH 19/27] handle non-sqlsrv backoffs correctly --- src/Illuminate/Database/SqlServerConnection.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index 8e79a1b71ddb..dc9662ded9bd 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -5,6 +5,7 @@ use Closure; use Exception; use Illuminate\Database\Query\Grammars\SqlServerGrammar as QueryGrammar; +use LogicException; use Illuminate\Database\Query\Processors\SqlServerProcessor; use Illuminate\Database\Schema\Grammars\SqlServerGrammar as SchemaGrammar; use Illuminate\Database\Schema\SqlServerBuilder; @@ -36,7 +37,11 @@ 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 LogicException('Transaction backoffs are only supported for sqlsrv SQL Server driver connections.'); } $this->getPdo()->exec('BEGIN TRAN'); @@ -56,8 +61,6 @@ public function transaction(Closure $callback, $attempts = 1, $backoff = null) catch (Throwable $e) { $this->getPdo()->exec('ROLLBACK TRAN'); - $this->handleTransactionExceptionBackoff($backoff, $e, $a, $attempts); - throw $e; } From 0cd89fb463874457c09f3760e602792e07199334 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Mon, 5 Jan 2026 22:54:57 +0000 Subject: [PATCH 20/27] wording tweak --- src/Illuminate/Database/SqlServerConnection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index dc9662ded9bd..241a4372b241 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -41,7 +41,7 @@ public function transaction(Closure $callback, $attempts = 1, $backoff = null) } if ($backoff !== null) { - throw new LogicException('Transaction backoffs are only supported for sqlsrv SQL Server driver connections.'); + throw new LogicException('Transaction attempt backoffs are only supported for sqlsrv SQL Server driver connections.'); } $this->getPdo()->exec('BEGIN TRAN'); From ae030249158c219111b15e7b5e7f7209abe95eb0 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Mon, 5 Jan 2026 22:56:02 +0000 Subject: [PATCH 21/27] wording tweaks --- src/Illuminate/Database/SqlServerConnection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index 241a4372b241..691c11e1b565 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -41,7 +41,7 @@ public function transaction(Closure $callback, $attempts = 1, $backoff = null) } if ($backoff !== null) { - throw new LogicException('Transaction attempt backoffs are only supported for sqlsrv SQL Server driver connections.'); + throw new LogicException('Transaction attempt backoffs are only supported for "sqlsrv" driver connections.'); } $this->getPdo()->exec('BEGIN TRAN'); From b8dd36a3088018735c97c78068affa880f58ef5a Mon Sep 17 00:00:00 2001 From: Jamie York Date: Mon, 5 Jan 2026 22:56:38 +0000 Subject: [PATCH 22/27] fix cs --- src/Illuminate/Database/SqlServerConnection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index 691c11e1b565..87b92aef2c1c 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -5,11 +5,11 @@ use Closure; use Exception; use Illuminate\Database\Query\Grammars\SqlServerGrammar as QueryGrammar; -use LogicException; use Illuminate\Database\Query\Processors\SqlServerProcessor; use Illuminate\Database\Schema\Grammars\SqlServerGrammar as SchemaGrammar; use Illuminate\Database\Schema\SqlServerBuilder; use Illuminate\Filesystem\Filesystem; +use LogicException; use RuntimeException; use Throwable; From c52f7abe9325ceb73671ac3de70d2441742c10a0 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Fri, 9 Jan 2026 22:41:29 +0000 Subject: [PATCH 23/27] test non sqlserv --- .../Database/SqlServerConnection.php | 8 ++--- .../DatabaseSqlServerConnectionTest.php | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 tests/Database/DatabaseSqlServerConnectionTest.php diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index 87b92aef2c1c..0168ca4bb1c7 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -35,15 +35,15 @@ public function getDriverTitle() */ public function transaction(Closure $callback, $attempts = 1, $backoff = null) { + if ($backoff !== null) { + throw new LogicException('Transaction attempt backoffs are only supported for "sqlsrv" driver connections.'); + } + for ($a = 1; $a <= $attempts; $a++) { if ($this->getDriverName() === 'sqlsrv') { return parent::transaction($callback, $attempts, $backoff); } - if ($backoff !== null) { - throw new LogicException('Transaction attempt backoffs are only supported for "sqlsrv" driver connections.'); - } - $this->getPdo()->exec('BEGIN TRAN'); // We'll simply execute the given callback within a try / catch block diff --git a/tests/Database/DatabaseSqlServerConnectionTest.php b/tests/Database/DatabaseSqlServerConnectionTest.php new file mode 100644 index 000000000000..072e15a731e7 --- /dev/null +++ b/tests/Database/DatabaseSqlServerConnectionTest.php @@ -0,0 +1,32 @@ +shouldReceive('getAttribute')->with(PDO::ATTR_DRIVER_NAME)->andReturn('dblib'); + + $connection = new SqlServerConnection($pdo, 'testdb'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Transaction attempt backoffs are only supported for "sqlsrv" driver connections.'); + + $connection->transaction(function () { + return 'test'; + }, attempts: 1, backoff: 100); + } +} From fca33bd1dbaf2b2765c785f65e7ba7eac594dd9a Mon Sep 17 00:00:00 2001 From: Jamie York Date: Fri, 9 Jan 2026 23:15:47 +0000 Subject: [PATCH 24/27] inline function --- tests/Database/DatabaseSqlServerConnectionTest.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/Database/DatabaseSqlServerConnectionTest.php b/tests/Database/DatabaseSqlServerConnectionTest.php index 072e15a731e7..f3852d781caa 100644 --- a/tests/Database/DatabaseSqlServerConnectionTest.php +++ b/tests/Database/DatabaseSqlServerConnectionTest.php @@ -25,8 +25,6 @@ public function testTransactionBackoffThrowsExceptionForNonSqlsrvDrivers() $this->expectException(LogicException::class); $this->expectExceptionMessage('Transaction attempt backoffs are only supported for "sqlsrv" driver connections.'); - $connection->transaction(function () { - return 'test'; - }, attempts: 1, backoff: 100); + $connection->transaction(fn () => '', attempts: 1, backoff: 100); } } From 5345353333bc4fbf0124d9cc74c80dee01d0616d Mon Sep 17 00:00:00 2001 From: Jamie York Date: Fri, 9 Jan 2026 23:52:42 +0000 Subject: [PATCH 25/27] runtime exception in correct place --- src/Illuminate/Database/SqlServerConnection.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index 0168ca4bb1c7..37e0c1eafea2 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -9,7 +9,6 @@ use Illuminate\Database\Schema\Grammars\SqlServerGrammar as SchemaGrammar; use Illuminate\Database\Schema\SqlServerBuilder; use Illuminate\Filesystem\Filesystem; -use LogicException; use RuntimeException; use Throwable; @@ -35,15 +34,15 @@ public function getDriverTitle() */ public function transaction(Closure $callback, $attempts = 1, $backoff = null) { - if ($backoff !== null) { - throw new LogicException('Transaction attempt backoffs are only supported for "sqlsrv" driver connections.'); - } - for ($a = 1; $a <= $attempts; $a++) { if ($this->getDriverName() === 'sqlsrv') { 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'); // We'll simply execute the given callback within a try / catch block From f080edea489ee758582e46cfc3b09e016260aa8b Mon Sep 17 00:00:00 2001 From: Jamie York Date: Fri, 9 Jan 2026 23:53:04 +0000 Subject: [PATCH 26/27] clean up --- .../DatabaseSqlServerConnectionTest.php | 30 ------------------- 1 file changed, 30 deletions(-) delete mode 100644 tests/Database/DatabaseSqlServerConnectionTest.php diff --git a/tests/Database/DatabaseSqlServerConnectionTest.php b/tests/Database/DatabaseSqlServerConnectionTest.php deleted file mode 100644 index f3852d781caa..000000000000 --- a/tests/Database/DatabaseSqlServerConnectionTest.php +++ /dev/null @@ -1,30 +0,0 @@ -shouldReceive('getAttribute')->with(PDO::ATTR_DRIVER_NAME)->andReturn('dblib'); - - $connection = new SqlServerConnection($pdo, 'testdb'); - - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Transaction attempt backoffs are only supported for "sqlsrv" driver connections.'); - - $connection->transaction(fn () => '', attempts: 1, backoff: 100); - } -} From 06aa09237d630c2f376bdb201f0660af41711b83 Mon Sep 17 00:00:00 2001 From: Jamie York Date: Sat, 10 Jan 2026 00:06:19 +0000 Subject: [PATCH 27/27] fix docblock --- src/Illuminate/Database/Concerns/ManagesTransactions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index f597f09b5553..a0e68325d2b6 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -258,7 +258,7 @@ public function commit() * @param \Throwable $e * @param int $currentAttempt * @param int $maxAttempts - * @param \Closure|array|int|null $backoff + * @param \Closure(\Throwable, int, int): int|array|Collection|int|null $backoff * @return void * * @throws \Throwable