Skip to content

Commit b33f062

Browse files
committed
Connection, Explorer: transaction() supports retry on deadlock, added RetryableException
Added optional $attempts parameter. When greater than 1, any exception implementing RetryableException on the outermost transaction triggers a retry of the whole callback. The callback must be idempotent. A new RetryableException marker interface is introduced; DeadlockException and LockTimeoutException all implement it. Applications can mark their own transient errors with the interface (e.g. optimistic lock conflicts) to opt into automatic retries. Nested transactions never retry on their own — the exception bubbles up to the outermost transaction, which honors its own $attempts setting.
1 parent fc986dc commit b33f062

8 files changed

Lines changed: 380 additions & 44 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,7 @@ aren't up yet, not a broken test.
6969
(`?and`/`?set`/`?values`/`?order`/`?list`), so the same array expands differently
7070
after `WHERE` vs `SET` vs `INSERT`. See `docs/internals/sql-preprocessor.md`.
7171
- **Nested transactions use a depth counter, not savepoints** - only the outermost
72-
`transaction()` issues a real BEGIN/COMMIT/ROLLBACK; there is no partial rollback,
73-
and no retry mechanism (no `$attempts`, no `RetryableException`, no `onRetry`).
72+
`transaction()` issues a real BEGIN/COMMIT/ROLLBACK; there is no partial rollback.
7473
There is no `TypeConverter` class either (DB->PHP conversion is
7574
`Helpers::normalizeRow`). Don't document designed-but-absent features as present.
7675
- **Array expansion is a mass-assignment surface.** Passing raw user input as the

docs/internals/connection-drivers.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,15 @@ interval).
3838
├── ConnectionException → ConnectionLostException
3939
├── ConstraintViolationException
4040
│ ├── ForeignKey / NotNull / Unique / CheckConstraintViolation
41-
├── DeadlockException
42-
└── LockTimeoutException
41+
├── DeadlockException (Retryable)
42+
└── LockTimeoutException (Retryable)
4343
```
4444

4545
Note `Deadlock`/`LockTimeout` extend `DriverException` **directly**, not the
46-
constraint hierarchy. There is **no** `RetryableException` marker and `transaction()`
47-
performs no retries — retrying on deadlock/lock-timeout/connection-lost is the
48-
caller's job. The mapping is **per driver** in
46+
constraint hierarchy, and both implement the `RetryableException` marker (used by
47+
`transaction()` retries). `ConnectionLostException` deliberately does not — a
48+
connection lost during `COMMIT` has an unknown outcome, so an automatic retry could
49+
apply the transaction twice. The mapping is **per driver** in
4950
`convertException`: MySQL keys on the numeric error code, PgSql on the SQLSTATE; an
5051
unrecognized error falls back to a bare `DriverException::from()`. `DriverException::from`
5152
parses `errorInfo`, or the `SQLSTATE[..] [..] ..` pattern from the message when

docs/internals/readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ worlds — the low-level core and the Explorer (ActiveRow) layer — so split by
1212
`Driver` dialect abstraction, and exception mapping.
1313
- **[results-and-types.md](results-and-types.md)**`ResultSet` and the DB→PHP
1414
value normalization.
15-
- **[transactions.md](transactions.md)**`transaction()` and depth-counter nesting.
15+
- **[transactions.md](transactions.md)**`transaction()`, nesting, and retries.

docs/internals/transactions.md

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,25 @@ Nesting is **counter-based only** — there are **no savepoints**.
44

55
## `transaction()`
66

7-
`transaction(callable $callback): mixed` runs the callback between `BEGIN` and
8-
`COMMIT`/`ROLLBACK`. Nesting is tracked purely by `$transactionDepth`:
7+
`transaction(callable $callback, int $attempts = 1)` runs a phase machine
8+
(`begin`/`body`/`commit`) inside a retry loop. Nesting is tracked purely by
9+
`$transactionDepth`:
910

1011
- a real `BEGIN` is issued only when `transactionDepth === 0`; a nested call merely
1112
increments the counter and **emits no SQL**;
1213
- `COMMIT` likewise only at depth 0; on an exception, `ROLLBACK` only when it unwinds
13-
back to depth 0, then the exception is rethrown. Any failure of the rollback itself
14-
(e.g. when the server already rolled back after a deadlock, or an `onQuery` handler
15-
throws) is swallowed so it cannot mask the original exception.
14+
back to depth 0 (wrapped in try/catch, since the server may have rolled back
15+
already);
16+
- a **retry** happens only at the outermost level, when `$attempt < $attempts` and the
17+
exception implements `RetryableException` (deadlock / lock timeout; a lost
18+
connection is deliberately not retryable — the outcome of an in-flight `COMMIT`
19+
is unknown) — firing `onRetry` between attempts.
1620

1721
**The consequence to internalize:** a nested `transaction()` gives **no partial
1822
rollback**. Only the outermost transaction issues real `BEGIN`/`COMMIT`/`ROLLBACK`, so
1923
an inner failure tears down the *entire* outer transaction. The idea of savepoints is
2024
**not implemented** — there is no `SAVEPOINT`/`RELEASE` anywhere in the code.
2125

22-
**No retries either (so don't document them as present):** there is no `$attempts`
23-
parameter, no retry loop, no `RetryableException` marker, no `onRetry` event.
24-
`DeadlockException`, `LockTimeoutException` and `ConnectionLostException` exist, but
25-
nothing in `transaction()` catches and retries them — retrying is the caller's job.
26-
2726
## Manual control is fenced off inside a callback
2827

2928
`beginTransaction()`/`commit()`/`rollBack()` each **throw** if called while

src/Database/Connection.php

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class Connection
2525

2626
/** @var array<callable(static, ResultSet|DriverException): void> Occurs after query is executed */
2727
public array $onQuery = [];
28+
29+
/** @var array<callable(static, int, RetryableException): void> Occurs before a transaction() retry */
30+
public array $onRetry = [];
2831
private Driver $driver;
2932
private SqlPreprocessor $preprocessor;
3033
private ?PDO $pdo = null;
@@ -235,36 +238,60 @@ public function isInTransaction(): bool
235238

236239
/**
237240
* Executes callback inside a transaction. Supports nesting.
241+
* When $attempts > 1, a RetryableException raised during begin, commit
242+
* or inside the callback on the outermost transaction triggers a retry
243+
* of the whole callback. Callbacks must be idempotent. The $onRetry
244+
* event fires before each retry and is the place to apply backoff.
238245
* @param callable(static): mixed $callback
239246
*/
240-
public function transaction(callable $callback): mixed
247+
public function transaction(callable $callback, int $attempts = 1): mixed
241248
{
242-
if ($this->transactionDepth === 0) {
243-
$this->beginTransaction();
249+
if ($attempts < 1) {
250+
throw new Nette\InvalidArgumentException('Number of attempts must be at least 1.');
244251
}
245252

246-
$this->transactionDepth++;
247-
try {
248-
$res = $callback($this);
249-
} catch (\Throwable $e) {
250-
$this->transactionDepth--;
251-
if ($this->transactionDepth === 0) {
252-
try {
253-
$this->rollBack();
254-
} catch (\Throwable) {
255-
// e.g. after a deadlock the server has already rolled back; the original exception matters more
253+
for ($attempt = 1; ; $attempt++) {
254+
$phase = 'begin';
255+
try {
256+
if ($this->transactionDepth === 0) {
257+
$this->beginTransaction();
256258
}
257-
}
258259

259-
throw $e;
260-
}
260+
$this->transactionDepth++;
261+
$phase = 'body';
262+
$res = $callback($this);
263+
$this->transactionDepth--;
264+
$phase = 'commit';
265+
if ($this->transactionDepth === 0) {
266+
$this->commit();
267+
}
261268

262-
$this->transactionDepth--;
263-
if ($this->transactionDepth === 0) {
264-
$this->commit();
265-
}
269+
return $res;
270+
} catch (\Throwable $e) {
271+
if ($phase === 'body') {
272+
$this->transactionDepth--;
273+
}
274+
275+
if ($this->transactionDepth === 0 && $phase !== 'begin') {
276+
try {
277+
$this->rollBack();
278+
} catch (\Throwable) {
279+
// server may have already rolled back (deadlock) or the
280+
// connection may be gone; the original $e is what matters
281+
}
282+
}
266283

267-
return $res;
284+
if ($this->transactionDepth === 0
285+
&& $attempt < $attempts
286+
&& $e instanceof RetryableException
287+
) {
288+
Arrays::invoke($this->onRetry, $this, $attempt, $e);
289+
continue;
290+
}
291+
292+
throw $e;
293+
}
294+
}
268295
}
269296

270297

src/Database/Explorer.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,16 @@ public function isInTransaction(): bool
5858

5959

6060
/**
61-
* Executes callback inside a transaction.
61+
* Executes callback inside a transaction. Supports nesting.
62+
* When $attempts > 1, a RetryableException raised during begin, commit
63+
* or inside the callback on the outermost transaction triggers a retry
64+
* of the whole callback. Callbacks must be idempotent. Subscribe to
65+
* Connection::$onRetry to plug in backoff between attempts.
6266
* @param callable(static): mixed $callback
6367
*/
64-
public function transaction(callable $callback): mixed
68+
public function transaction(callable $callback, int $attempts = 1): mixed
6569
{
66-
return $this->connection->transaction(fn() => $callback($this));
70+
return $this->connection->transaction(fn() => $callback($this), $attempts);
6771
}
6872

6973

src/Database/exceptions.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@
88
namespace Nette\Database;
99

1010

11+
/**
12+
* Marks transient exceptions that are safe to retry because the server
13+
* guarantees the transaction was rolled back, such as deadlocks or lock
14+
* timeouts. Connection::transaction() retries the callback automatically
15+
* when $attempts > 1.
16+
*/
17+
interface RetryableException
18+
{
19+
}
20+
21+
1122
/**
1223
* Failed to connect to the database server.
1324
*/
@@ -70,7 +81,7 @@ class CheckConstraintViolationException extends ConstraintViolationException
7081
* Deadlock or serialization failure detected by the server; the transaction
7182
* was rolled back and can be retried.
7283
*/
73-
class DeadlockException extends DriverException
84+
class DeadlockException extends DriverException implements RetryableException
7485
{
7586
}
7687

@@ -79,6 +90,6 @@ class DeadlockException extends DriverException
7990
* A lock wait exceeded the configured timeout. The statement was aborted,
8091
* typically leaving the surrounding transaction alive.
8192
*/
82-
class LockTimeoutException extends DriverException
93+
class LockTimeoutException extends DriverException implements RetryableException
8394
{
8495
}

0 commit comments

Comments
 (0)