Skip to content

fix(#52): opt-in retry ceiling for transact/readTransact/watch loops - #79

Merged
s2x merged 2 commits into
masterfrom
fix/issue-52-retry-ceiling
Aug 21, 2026
Merged

fix(#52): opt-in retry ceiling for transact/readTransact/watch loops#79
s2x merged 2 commits into
masterfrom
fix/issue-52-retry-ceiling

Conversation

@s2x

@s2x s2x commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes #52

Problem

Database::transact(), readTransact() and the four watch* helpers spun unbounded
while (true) loops relying entirely on fdb_transaction_on_error() to eventually surface a
non-retryable error. A persistently conflicting workload could spin indefinitely — there was no
library-level ceiling and no convenient default RETRY_LIMIT/TIMEOUT.

Change

Opt-in, process-wide ceilings (default behaviour unchanged):

Knob Default On violation
FoundationDB::defaultTransactionRetryLimit(int) 0 = unbounded < 0InvalidArgumentException; otherwise loop throws after N retries
FoundationDB::defaultTransactionTimeoutSeconds(float) 0.0 = unbounded < 0.0InvalidArgumentException; otherwise loop throws when budget exhausted

All six retry sites route through a shared Database::runWithRetry(); when a ceiling is hit it
throws the new TransactionRetryLimitExceededException carrying the actual retry count and
elapsed wall-clock seconds. The pure predicate Database::checkRetryLimit() is public static for
FFI-free unit testing. Ceilings are cleared by FoundationDB::reset().

This revives the work described in the issue comment, which had been left on an abandoned stacked branch; it is cherry-picked onto current master.

Integration tests rewritten (important)

The rescued integration tests injected a synthetic FDBException(1007) into the callback and
relied on the native on_error() future to resolve — a fabricated error describes an error the
transaction never experienced, and in practice the suite hung indefinitely against a live
cluster (no output within 10 minutes). They are rewritten around deterministic real conflicts
(read → interferer commits → write+commit ⇒ genuine not_committed retries): ceiling exhaustion,
default-unbounded recovery, wall-clock termination, plus readTransact/watch smoke coverage.
Against master the file errors on the missing API; with the fix all five cases pass in ~0.3s.

Verification

Check Result
composer lint (PHPCS + Rector + PHPStan L9)
composer test:unit ✅ 461 tests
Integration suite (Docker 5-node cluster) ✅ 209 tests

Piotr Hałas added 2 commits August 21, 2026 08:55
…retry loops

The convenience retry loops in Database::transact(),
readTransact(), and the four watch() helpers were unbounded
'while (true)' cycles that relied entirely on FoundationDB's
fdb_transaction_on_error() to eventually surface a
non-retryable error. A persistently conflicting workload could
therefore spin indefinitely under the default.

Fix introduces an opt-in, process-wide ceiling (FoundationDB::
defaultTransactionRetryLimit(int) and ::defaultTransaction
TimeoutSeconds(float)) and routes all six retry sites through
a shared Database::runWithRetry() helper, which throws a new
TransactionRetryLimitExceededException carrying the actual
attempt count and elapsed wall-clock seconds when either
ceiling is hit. The pure Database::checkRetryLimit() predicate
is exposed as static so the bounded-retry decision can be
unit-tested without FFI.

Both ceilings default to 0 / 0.0 (unbounded), preserving the
historical 'FDB decides when to stop retrying' semantics for
users who do not opt in. Negative values throw
\\InvalidArgumentException at configuration time so a typo
cannot silently disable the ceiling.

Coverage:
- tests/Unit/TransactionRetryLimitTest.php: 28 cases (predicate,
  configuration validation, exception fields, message).
- tests/Integration/TransactionRetryLimitTest.php: 6 cases
  (transact/readTransact/watch honour limits, default-unbounded
  preserved, wall-clock ceiling terminates, reset()).

Docs: docs/transactions.md adds 'Bounded retry' section;
CHANGELOG entry at the top of [Unreleased].
The integration tests rescued from the abandoned branch injected a
synthetic FDBException(1007) into the callback and relied on the
native fdb_transaction_on_error() to resolve. A fabricated error code
describes an error the transaction never experienced, so the native
future may never resolve - in practice the suite hung indefinitely
against a live cluster (first observed: no output within 10 minutes).

Rewrite the scenarios around deterministic REAL conflicts: the
callback reads a key, an interferer transaction commits a change to
it, then the callback writes and commits - producing genuine
not_committed (1020) retries through the real on_error() path.

- attempt ceiling: interferer fires every round; limit=2 aborts with
  TransactionRetryLimitExceededException after exactly 3 retries
- default unbounded ceiling: interference stops after 3 rounds; loop
  recovers and commits without any library exception
- wall-clock ceiling: unbounded attempts + 50ms budget terminates the
  loop while conflicts keep coming
- readTransact/watch: smoke coverage that both paths work with a
  ceiling configured (snapshot reads cannot conflict)

Against master (no fix) the file errors on the missing configuration
API; with the fix all five cases pass in ~0.3s.
@s2x
s2x merged commit aa7eac1 into master Aug 21, 2026
6 checks passed
@s2x
s2x deleted the fix/issue-52-retry-ceiling branch August 21, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Reliability] transact/readTransact retry unbounded with no default ceiling

1 participant