bottomless: fix snapshot upload pipeline silently dropping generation snapshots#2260
Draft
LeMikaelF wants to merge 1 commit into
Draft
bottomless: fix snapshot upload pipeline silently dropping generation snapshots#2260LeMikaelF wants to merge 1 commit into
LeMikaelF wants to merge 1 commit into
Conversation
… snapshots The continuous-backup pipeline could run for days while every generation it created was missing its base snapshot in object storage, degrading or breaking restore, and in one failure mode blocking checkpoints entirely so the WAL grew without bound. Five interacting defects: - A failed snapshot upload was never retried and blocked checkpoints until a process restart: the snapshot task attempted its PutObject exactly once and nothing ever re-ran it for the current generation, so the checkpoint gate refused every subsequent checkpoint with SQLITE_BUSY. The upload is now retried with backoff inside the snapshot task (rebuilding the body stream per attempt, so a retry never sends a stale Content-Length), and the checkpoint gate re-triggers the snapshot of the current generation when it failed or never ran. Re-uploading at any later point is correct because the main database file only changes on a TRUNCATE checkpoint, and checkpoints stay refused until the upload succeeds. - A checkpoint attempt that found an empty WAL unconditionally marked the current generation as snapshotted, laundering failed or still-in-flight uploads into success markers and letting the next checkpoint rotate the generation while leaving no snapshot of it behind. The empty-WAL branch now re-triggers a missing snapshot instead of overwriting the failure state. - Concurrent snapshot tasks compressed the database into the same fixed db.gz/db.zstd path with truncate(true), shrinking the file under the other task's in-flight upload stream (surfacing as hyper BodyWriteAborted/NotEof dispatch failures) or silently uploading a torn object. Compression now targets a per-generation artifact path, an in-flight flag guarantees at most one snapshot task at a time, and stale artifacts (including legacy fixed-name ones) are swept only while no upload is running. S3 object keys are unchanged. - The AWS SDK's stalled-stream protection aborted slow snapshot uploads: a whole-database PutObject can run for minutes and legitimately stalls beyond the ~5s grace period whenever the process is busy, and the watchdog abort surfaces as the same BodyWriteAborted signature. The watchdog is now disabled on the bottomless client; bottomless owns its retry policy. - Restarting on top of a non-empty database file created a generation with no .dep parent link, severing the chain that restore walks when a generation has no snapshot: compare_with_local returned early before storing the generation it compared against. The store now happens before the early return. Snapshot upload log lines now include the database name and generation. Tests: new snapshot_pipeline regression tests in libsql-server drive the bottomless WAL wrapper against a mock S3 server with failure injection, covering upload retry, checkpoint recovery without restart (including the empty-WAL laundering case), the snapshot overlap guard, and the .dep link on restart. Existing bottomless tests pass, including the ignored backup_restore test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Q5tV6oA1zXfrWhchz2Cqk
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The bottomless continuous-backup pipeline could run for days while every generation it created was missing its base snapshot in object storage — degrading or breaking restore/fork/point-in-time recovery — and, in one failure mode, blocking checkpoints entirely so the WAL grew without bound. The production signature was recurring
DispatchFailure(... BodyWriteAborted, NotEof(_))snapshot upload errors, 20+ consecutive generations with nodb.*snapshot object while checkpoints kept "succeeding" on the configured interval, and post-restart generations with no.depparent link.This PR fixes the five interacting defects behind that signature.
Bugs and fixes
A failed snapshot upload was never retried and wedged checkpoints until process restart. The snapshot task attempted its
PutObjectexactly once; on failure the checkpoint gate refused every subsequent checkpoint withSQLITE_BUSY, and with autocheckpoint disabled the WAL grew without bound. The upload is now retried with backoff inside the snapshot task (rebuilding theByteStreamper attempt so a retry never sends a stale Content-Length), and the checkpoint gate re-triggers the snapshot of the current generation when it failed or never ran. Re-uploading at any later point is correct because the main database file only changes on a TRUNCATE checkpoint, and checkpoints stay refused until the upload succeeds.The empty-WAL checkpoint path falsely marked generations as snapshotted.
skip_snapshot_for_current_generation()unconditionally stamped the snapshot waiter with success, laundering failed or still-in-flight uploads — this is why production showed days of on-schedule checkpoints with zero snapshots. The function is removed; the empty-WAL branch now re-triggers a missing snapshot instead of overwriting the failure state.Concurrent snapshot tasks truncated each other's shared temp file. Compression wrote to a fixed
db.gz/db.zstdpath withtruncate(true), shrinking the file under another task's in-flight upload stream (hyper'sBodyWriteAborted/NotEof) or silently uploading a torn object. Compression now targets a per-generation artifact path, an in-flight flag (cleared by a panic-safe drop guard) guarantees at most one snapshot task at a time, and stale artifacts — including legacy fixed-name ones — are swept only while no upload runs.The AWS SDK's stalled-stream protection killed slow snapshot uploads. A whole-database
PutObjectcan run for minutes and legitimately stalls beyond the ~5s grace period whenever the process is busy; the watchdog abort surfaces as the sameBodyWriteAbortedsignature and the SDK cannot retry a partially consumed stream. Stalled-stream protection is now disabled on the bottomless S3 client; bottomless owns its retry policy.Restarts created orphan generations with no
.deplink.compare_with_localreturned early for any database with change counter > 1 — before storing the generation it compared against — sonew_generation()saw no parent and wrote no.dep, severing the chain restore walks when a generation has no snapshot. The store now happens before the early return.Snapshot upload log lines now include the database name and generation to make multi-tenant incidents debuggable.
Compatibility
S3 object keys are unchanged (
{db}-{generation}/db.{gz,zstd,raw},.changecounter,.dep,.meta) — only the local temp artifact path changed — so existing buckets restore unchanged, and old/new versions interoperate.Testing
snapshot_pipelineregression tests inlibsql-server/src/test/bottomless.rsdrive the real bottomless WAL wrapper through a raw libsql connection against a per-test mock S3 server with failure injection, covering: upload retry after a transient failure, checkpoint recovery without a process restart (including the empty-WAL laundering case, which fails on the old code), the snapshot overlap guard, and the.deplink on restart.cargo test -p libsql-server bottomlesspasses, including the#[ignore]dbackup_restoretest (restore across generations missing snapshots).cargo test -p bottomless,cargo fmt --check, andcargo checkon bottomless, bottomless-cli, and libsql-server are clean.🤖 Generated with Claude Code
https://claude.ai/code/session_013Q5tV6oA1zXfrWhchz2Cqk