fix(databases): retry transient index-backfill errors in-process instead of parking - #1371
fix(databases): retry transient index-backfill errors in-process instead of parking#1371heskew wants to merge 4 commits into
Conversation
…ead of parking A secondary-index backfill pass that failed with only transient RocksDB errors (ERR_BUSY/ERR_TRY_AGAIN under bulk-ingest load) would park the index — leaving isIndexing=true + indexingFailed=true and waiting for an operator restart to retry. runIndexing now retries the whole pass in-process from the pass's starting checkpoint with bounded exponential backoff, re-reading every row (indexing is idempotent), so a transient error self-heals without a restart. A permanent error, or exhausting the retry budget, still parks exactly as before, so the change is strictly an improvement. The retry resets to the pass start rather than the intra-pass checkpoint: that checkpoint advances every 100 rows, even past a put that later rejects asynchronously, so it is not a safe resume point. Re-reading from the pass start re-covers any row that errored mid-pass and avoids a silent index gap. Resolves #1356 (part of #1354). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
|
Reviewed; no blockers found — the log-level nit from the prior review is fixed in 1725ed8 (transient errors now log at debug, not error). |
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
|
The retry logic here is sound, but #410 so radically reworks this area that I think this PR would be better stacked on top of that one rather than merged independently. Given the scope of overlap, this feels like 5.2 material — worth waiting for #410 to land first so this doesn't need a rebase or conflict resolution after the fact. Happy to revisit once #410 is in! 🙂 — Kris (via Claude Sonnet 4.6) |
|
@kriszyp - Sounds good. I'll pull this back to a draft for now. |
|
#410 has merged, so this is unblocked — updated per the stacking request:
|
kriszyp
left a comment
There was a problem hiding this comment.
Really nice fix on the transient-retry path — the classification is appropriately narrow (ERR_BUSY/ERR_TRY_AGAIN only, mirroring DatabaseTransaction's commit-retry set), the retry is bounded (10 attempts, backoff capped ~1s, ~6.5s worst case), no lock or transaction is held across the backoff sleep, and resuming from passStartKey rather than the intra-pass checkpoint is exactly the right call to avoid a silent gap. The 250-row checkpoint-gap regression test is a genuinely good test.
I'm requesting changes on one thing, because it's the same bug class this PR is titled to fix, left live on a sibling path:
[blocker] The permanent-error / retry-exhaustion park path still persists an advanced lastIndexedKey — databases.ts:1787-1796. The transient path correctly resets every attribute's lastIndexedKey to passStartKey before parking, but the hadIndexingErrors park block does not — its comment explicitly says it preserves lastIndexedKey "so the retry resumes from the last checkpoint." The intra-pass checkpoint at databases.ts:1719 fires every 100 rows, so on a table >100 rows where either (a) a non-transient error hits an early row (hadPermanentIndexingError = true, retry skipped) or (b) a transient error on an early row survives all 10 retries, the checkpoint has already advanced past the failing row before the loop parks. On restart, recovery resumes past the gap and the early failed row is never re-covered — the exact serent-canopy #135 silent-gap fingerprint this PR references at :1785.
Minimal fix: reset attribute.lastIndexedKey = passStartKey in the park block too (matches the transient path — safe, just re-does the pass on next restart). Better: track a firstFailedKey during the loop and persist that as the resume point, so restart doesn't needlessly re-scan the whole pass.
[significant] The permanent-error test can't catch this — schemaMigrationFragility.test.js:47 uses N = 50, below the 100-row checkpoint threshold, so lastIndexedKey never advances past the failed row and the test can't distinguish correct behavior from the gap. Bump it (or add a parallel case) past 100 rows and assert the specific early failed row is present after restart-recovery, the way the new checkpoint-gap test does for the transient path.
Two smaller ones, non-blocking:
- [suggestion] No test covers retry-exhaustion (a transient error that recurs on all 10 attempts → park). Worth injecting
ERR_BUSYon the same key across every attempt and asserting it parks withindexingFailed=trueafter exactly 10 retries — verifies the bound is real. - [suggestion / question] The "re-indexing is idempotent" assumption is well-supported for ordinary attribute indices, but graph-based indexes (HNSW, per the
didSynchronousIndexingcomment) may not be idempotent under a pass-start re-run. Worth confirming rather than assuming, if any such index type is currently paired with backfill.
Fix the park-path reset + extend the permanent-error test past 100 rows and this is a clean merge.
—
🤖 Reviewed by KrAIs (Claude Opus 4.8) on Kris's behalf. Cross-model: Gemini independently flagged both the park-path gap and the N=50 test blind spot; I confirmed both against databases.ts at head.
|
Confirming the [blocker] — I traced it independently against head (
The minimal fix (reset The [significant] test point also checks out — One note for context: this park-path gap is pre-existing, so the PR's "strictly an improvement" claim is accurate. But since the PR targets exactly this bug class and the fix is cheap, addressing the sibling path here makes sense. Generated by Barber AI |
…1371 review) The park block preserved the intra-pass checkpoint, which advances on row count alone and can sit past a failed row — the same silent-gap shape the transient path already guards against. Reset to passStartKey exactly like the transient path so restart-recovery re-covers the failed row. Adds the >100-row permanent-error restart test and a retry-exhaustion bound test (initial pass + exactly 10 retries, then park). Note (detailed on the PR): the resume derivation has been inert since the original commit — compareKeys(key, undefined) is 1, so `start` never picks up a persisted lastIndexedKey and every backfill full-scans. The park-path gap is therefore latent, not live; this change makes the persisted state correct-by-construction and the new tests pin the desired behavior for the day resume is made real (follow-up issue). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BdbdepRFuyUWoEmNN1pSVQ
|
All four points addressed in 75969eb — plus a discovery that reframes the blocker's severity (downward), detailed below. [blocker] Park-path reset — applied. The park block now resets [significant] Test — added. New [suggestion] Retry-exhaustion test — added. A key that fails with [suggestion/question] HNSW idempotency — confirmed, not assumed. Discovery: the checkpoint resume derivation is inert — the blocker is latent, not live. While writing the negative test (fix reverted, expecting the new test to fail), it kept passing. Instrumentation showed why: recovery ran with if (compareKeys(attribute.lastIndexedKey, start) < 0) start = attribute.lastIndexedKey;seeds Consequences, honestly stated:
I have a follow-up issue drafted for the inert resume (make it real — which needs the same |
…1371 review) The in-process retry amplified per-record error-level lines up to 11x for a self-healing ERR_BUSY/ERR_TRY_AGAIN burst. The pass-level retry warn already carries the operator signal; per-record transient detail now goes to debug in all three handlers (sync catch, when() rejection, pass-end lastResolution). Permanent errors keep error level. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BdbdepRFuyUWoEmNN1pSVQ
kriszyp
left a comment
There was a problem hiding this comment.
Great fix for the silent-gap edge case during index backfills (#1356) — the transient-vs-permanent recovery test coverage is thorough. One error-handling gap worth closing:
Unhandled rejection can bypass the park path (resources/databases.ts:1781). The await Promise.all(checkpointWrites) on the new retry path is outside the top try {}, so if it rejects (RocksDB closing / under severe pressure) it throws out of runIndexing entirely, skipping the if (hadIndexingErrors) block that sets attribute.indexingFailed = true — leaving the index wedged (isIndexing = true but queries may not 503 correctly). Wrap it in try/catch and set hadPermanentIndexingError = true so it falls through to the existing park path. Worth cleaning up the adjacent pre-existing unhandled-rejection risk on the same path while you're in there.
— Claude
Summary
A secondary-index backfill pass (
runIndexing) that failed with only transient RocksDB errors (ERR_BUSY/ERR_TRY_AGAIN— e.g. the write buffer filling under bulk-ingest load) would park the index: leavingisIndexing=true+indexingFailed=trueand waiting for an operator restart to retry. This change retries the whole pass in-process from the pass's starting checkpoint with bounded exponential backoff (≤10 retries, capped 1s), re-reading every row. A transient error now self-heals without a restart.A permanent (non-transient) error, or exhausting the retry budget, still parks exactly as before — so this is strictly an improvement over the prior behavior.
Resolves #1356 (part of #1354).
Where to look
resources/databases.ts—runIndexing. Retry is implemented via bounded recursion: on a transient-only failed pass it resets eachattribute.lastIndexedKeyto the pass start, persists it, backs off, and re-enters with an emptyindicesToRemove(drops already done) andretryAttempt + 1.lastIndexedKey = key, written every 100 rows) can advance past a put that later rejects asynchronously, so resuming from it could skip a failed row and leave a silent index gap. Re-reading from the pass start re-covers it (indexing is idempotent). A regression test asserts exactly this.isTransientIndexingErrormirrors theERR_BUSY/ERR_TRY_AGAINclassification already used inDatabaseTransaction's commit retry.Open item for the reviewer
Cross-model review (Codex + Gemini) surfaced one pre-existing, out-of-scope point I did not change: for multi-value attributes only the last
put()per record is tracked bywhen(), so a non-last put that rejects is an unhandled rejection. This predates this PR (the inline comment at the post-loopawait lastResolutionalready notes it). Flagging in case we want a follow-up issue; left alone here to keep scope tight.Docs
Operator-facing surface is a new
[warn]log line when a backfill retries (…hit transient errors; retrying pass N/10…) plus the self-healing behavior. No API/config/schema change — I don't think this needs a docs PR, but happy to add a sentence to the indexing/migration operational notes if wanted.Tests
unitTests/resources/schemaMigrationFragility.test.js(RocksDB-only): transient errors retry to clean completion (exercising both the synchronous-throw and async-reject branches), permanent errors still park + recover on a simulated restart, and — the important one — a row that failed before an intra-pass checkpoint is re-covered by the retry (no gap).🤖 Generated with Claude Code (Opus 4.8)