Skip to content

fix(universaldb): report the last error when transaction retries are exhausted - #5695

Open
MasterPtato wants to merge 1 commit into
mainfrom
stack/fix-universaldb-report-the-last-error-when-transaction-retries-are-exhausted-otvmloun
Open

MasterPtato wants to merge 1 commit into
mainfrom
stack/fix-universaldb-report-the-last-error-when-transaction-retries-are-exhausted-otvmloun

Conversation

@MasterPtato

@MasterPtato MasterPtato commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@railway-app

railway-app Bot commented Sep 10, 2026

Copy link
Copy Markdown

🚅 Deployed to the actors-pr-5695 environment in rivet-frontend

Service Status Web Updated
frontend-cloud 😴 Sleeping (View Logs) Web Sep 18, 2026 at 3:24 pm UTC
website ❌ Build Failed (View Logs) Web Sep 14, 2026 at 6:36 pm UTC
kitchen-sink 😴 Sleeping (View Logs) Web Sep 14, 2026 at 11:13 am UTC
frontend-inspector 😴 Sleeping (View Logs) Web Sep 13, 2026 at 9:35 am UTC
mcp-hub ✅ Success (View Logs) Web Sep 10, 2026 at 7:47 pm UTC
ladle ✅ Success (View Logs) Web Sep 10, 2026 at 7:47 pm UTC

@the-company-company the-company-company Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 1 medium-severity finding

Reviewed commit 487b538.

MaxRetriesReached,
// Stores the last error. The alternate format prints the whole context chain, so the cause the
// context names is reported alongside the underlying variant.
#[error("max number of transaction retries reached, last error: {0:#}")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Medium · Preserve the last error in the error source chain

The tuple field is formatted into this error, but it is not marked as a thiserror source. Consequently anyhow::Error::chain() stops at MaxRetriesReached and callers cannot inspect or downcast the retry-exhausting TransactionTooOld, NotCommitted, or its contextual cause programmatically; only the rendered alternate display includes it.

Mark the wrapped anyhow::Error with #[source] (or use a named source field) so the reported cause remains part of the standard error chain.

@claude

claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Review: fix(universaldb): report the last error when transaction retries are exhausted

Reviewed the diff (gh pr diff 5695) and traced the retry-loop control flow change in both drivers by hand.

Findings

1. The refactor silently changes total attempt counts and backoff pacing for the database-wide default limit (worth confirming intentional)

To capture the last error, the retry-limit check moved from before running an attempt to after an attempt fails (postgres/database.rs, rocksdb/database.rs). That is the right move to fix the reported bug, but it also changes semantics for the un-overridden path:

  • Old: max_attempts = max_retries (when no per-txn override), checked before running, so exactly max_retries total attempts ran.
  • New: retry_budget = max_retries, checked after a failed attempt, so max_retries + 1 total attempts run (the loop always runs one more attempt than the budget before giving up).

For the per-txn override path (tx.retry_limit(N)) this washes out: old code did limit.saturating_add(1) as the pre-check bound, new code drops the +1 but the post-check adds it back, so both old and new give N + 1 attempts. But for the default max_retries path there is no such compensation, so the default effectively went from "N attempts" to "N+1 attempts."

As a side effect, calculate_tx_retry_backoff is now called with attempt after incrementing instead of before, so every real backoff step uses one exponent higher than before (roughly doubling each wait, until the 2^7 cap absorbs it). This is arguably more correct (old code also wasted one full backoff sleep after the terminal failed attempt, since it slept before discovering attempt >= max_attempts on the next loop iteration; new code no longer does that), but it is an unstated behavior change bundled into what is described as a pure error-reporting fix. Worth a one-line callout that default retry/backoff counts shifted by one, so it does not surprise someone tuning retry budgets later.

2. No test actually asserts the fix core claim (the last error is preserved)

Every updated test (conflict_parity.rs, the three depot/tests/*_txn_window.rs files) only checks that the error matches DatabaseError::MaxRetriesReached(_), and the wildcard ignores the wrapped error entirely. None of them assert that the inner anyhow::Error / formatted message actually reflects the last real failure (e.g. that a deliberately distinguishable error surfaces in format!("{:#}", max_retries_err)). Given the PR whole purpose is "report the last error," it would be worth one test that fails a closure with a recognizable, tagged error and asserts it is present in the final MaxRetriesReached message/chain. Otherwise a regression that silently drops the wrapped error back to a generic message would not be caught.

3. Minor: is_retryable _ => false fallthrough

error.rs is_retryable match was touched by this diff (MaxRetriesReached to MaxRetriesReached(_)), but it still ends in a _ => false catch-all over the remaining variants (UsedDuringCommit, RetryLimitUnsupported). Per CLAUDE.md ("Never use a _ => fall-through arm when matching on a Rust enum ... enumerate every variant explicitly"), since this arm was already being edited here, it would be a good opportunity to spell out UsedDuringCommit | RetryLimitUnsupported => false so a future new variant is a compile error instead of silently defaulting to non-retryable.

4. Note (not a blocker): MaxRetriesReached(anyhow::Error) is not chain-walkable

The wrapped anyhow::Error field has no #[source] annotation, so std::error::Error::source() will not expose it and err.chain().find_map::<DatabaseError>() from a caller cannot reach into it to recover the original terminal cause type (e.g. distinguishing "exhausted retries because of repeated conflicts" vs "exhausted retries because the last attempt aged out"). Given the {0:#} Display already prints the full nested chain for humans, this is probably fine for logging and diagnostics purposes, but flagging in case any caller wants to programmatically branch on the underlying cause of exhaustion rather than just reading the message string.

What looks good

  • The commit.rs changes correctly use .context(...) on anyhow::Error::from(DatabaseError::NotCommitted) rather than losing the DatabaseError type in the chain, so err.chain().find_map::<DatabaseError>() callers downstream still work.
  • All call sites of the now-tuple MaxRetriesReached variant were updated consistently (checked across the whole repo, not just the touched packages).
  • The comments explaining the re-read-every-iteration retry_limit logic are genuinely helpful given the closure-vs-database-wide precedence is non-obvious.

No security concerns: this is all internal engine retry and error-reporting logic within the trusted UniversalDB driver boundary.

🤖 Generated with Claude Code

@the-company-company the-company-company Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 1 medium-severity finding

Reviewed commit c040647.

MaxRetriesReached,
// Stores the last error. The alternate format prints the whole context chain, so the cause the
// context names is reported alongside the underlying variant.
#[error("max number of transaction retries reached, last error: {0:#}")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Medium · Preserve the last error in the error source chain

The tuple field is formatted into this error, but it is not marked as a thiserror source. Consequently anyhow::Error::chain() stops at MaxRetriesReached and callers cannot inspect or downcast the retry-exhausting TransactionTooOld, NotCommitted, or its contextual cause programmatically; only the rendered alternate display includes it.

Mark the wrapped anyhow::Error with #[source] (or use a named source field) so the reported cause remains part of the standard error chain.

@the-company-company the-company-company Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 1 medium-severity finding

Reviewed commit fb5d946.

MaxRetriesReached,
// Stores the last error. The alternate format prints the whole context chain, so the cause the
// context names is reported alongside the underlying variant.
#[error("max number of transaction retries reached, last error: {0:#}")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Medium · Preserve the last error in the error source chain

The tuple field is formatted into this error, but it is not marked as a thiserror source. Consequently anyhow::Error::chain() stops at MaxRetriesReached and callers cannot inspect or downcast the retry-exhausting TransactionTooOld, NotCommitted, or its contextual cause programmatically; only the rendered alternate display includes it.

Mark the wrapped anyhow::Error with #[source] (or use a named source field) so the reported cause remains part of the standard error chain.

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.

1 participant