An interrupted blob upload leaves the local index able to deduplicate
against data that was never stored remotely, so a later backup run
completes successfully (exit 0) yet produces a snapshot that cannot be
restored. This is silent data loss and was found while writing the
fault-injection tests for #72.
Root cause
During a scan, blob.Packer.finalizeCurrentBlob → commitBlobToDatabase → insertBlobRecords
(internal/blob/packer.go) writes the chunks, blob_chunks, and blobs (finished, uploaded_ts NULL) rows for a blob before the
blob is handed to Scanner.handleBlobReady
(internal/snapshot/scanner.go) for upload. If that upload fails — a
dropped connection mid-blob, or kill -9 — the scan aborts, but those
rows survive in the local index.
On the next run the same index is reused. Scanner.loadKnownChunks
loads every chunk from the chunks table regardless of whether its
blob was ever uploaded, so chunkExists returns true for the orphaned
chunks and they are never re-packed or re-uploaded. The new snapshot's
files reference those chunks, but no uploaded blob covers them. The
backup reports success.
Impact
Restoring the resulting snapshot fails at plan build with chunk missing from blob map: <hash>; a verify --deep would report
the referenced blob missing from storage. The user is told nothing is
wrong at backup time.
Reproduction
internal/vaultik/fault_injection_test.go contains TestBackupRetryAfterInterruptedUploadIsRestorable, skipped and
pointing at this issue. It fails a blob upload through the faultstore seam, retries the backup on the same index with a working
backend, and asserts the retried snapshot restores. It currently fails
with the error above.
Definition of done
Decide and implement one of:
(a) Persist a blob's chunks / blob_chunks / blobs rows
only once its upload has succeeded, so an interrupted upload
leaves no chunk rows to dedup against; or
(b) At the start of a run, detect blobs with finished_ts set
and uploaded_ts NULL (or whose object is absent at the
destination) and either re-upload them or drop their chunk rows so
the data is re-chunked. This is also the "next run detects and
repairs the state" behavior called for by scenario 2 of #72, so a fix here should
cover an interrupted metadata export too (database uploaded, blobs
present, but the run died before the manifest).
State which was chosen and why in the commit message.
A backup that is interrupted during upload and then re-run produces
a snapshot that restores byte-for-byte, or fails loudly at backup
time rather than silently emitting an unrestorable snapshot.
Remove the t.Skip from TestBackupRetryAfterInterruptedUploadIsRestorable and have it pass.
make check green.
model: claude-opus-4-8
An interrupted blob upload leaves the local index able to deduplicate
against data that was never stored remotely, so a later backup run
completes successfully (exit 0) yet produces a snapshot that cannot be
restored. This is silent data loss and was found while writing the
fault-injection tests for
https://git.eeqj.de/sneak/vaultik/issues/72.
## Root cause
During a scan, `blob.Packer.finalizeCurrentBlob` →
`commitBlobToDatabase` → `insertBlobRecords`
(`internal/blob/packer.go`) writes the `chunks`, `blob_chunks`, and
`blobs` (finished, `uploaded_ts` NULL) rows for a blob **before** the
blob is handed to `Scanner.handleBlobReady`
(`internal/snapshot/scanner.go`) for upload. If that upload fails — a
dropped connection mid-blob, or `kill -9` — the scan aborts, but those
rows survive in the local index.
On the next run the same index is reused. `Scanner.loadKnownChunks`
loads every chunk from the `chunks` table regardless of whether its
blob was ever uploaded, so `chunkExists` returns true for the orphaned
chunks and they are never re-packed or re-uploaded. The new snapshot's
files reference those chunks, but no uploaded blob covers them. The
backup reports success.
## Impact
Restoring the resulting snapshot fails at plan build with
`chunk missing from blob map: <hash>`; a `verify --deep` would report
the referenced blob missing from storage. The user is told nothing is
wrong at backup time.
## Reproduction
`internal/vaultik/fault_injection_test.go` contains
`TestBackupRetryAfterInterruptedUploadIsRestorable`, skipped and
pointing at this issue. It fails a blob upload through the
`faultstore` seam, retries the backup on the same index with a working
backend, and asserts the retried snapshot restores. It currently fails
with the error above.
## Definition of done
1. Decide and implement one of:
- **(a)** Persist a blob's `chunks` / `blob_chunks` / `blobs` rows
only once its upload has succeeded, so an interrupted upload
leaves no chunk rows to dedup against; or
- **(b)** At the start of a run, detect blobs with `finished_ts` set
and `uploaded_ts` NULL (or whose object is absent at the
destination) and either re-upload them or drop their chunk rows so
the data is re-chunked. This is also the "next run detects and
repairs the state" behavior called for by scenario 2 of
https://git.eeqj.de/sneak/vaultik/issues/72, so a fix here should
cover an interrupted metadata export too (database uploaded, blobs
present, but the run died before the manifest).
State which was chosen and why in the commit message.
2. A backup that is interrupted during upload and then re-run produces
a snapshot that restores byte-for-byte, or fails loudly at backup
time rather than silently emitting an unrestorable snapshot.
3. Remove the `t.Skip` from
`TestBackupRetryAfterInterruptedUploadIsRestorable` and have it pass.
4. `make check` green.
model: claude-opus-4-8
Chosen: issue option b. Deduplication now trusts a chunk only when a blob holding it has uploaded_ts set, and every scan drops blob rows with uploaded_ts IS NULL (their blob_chunks cascade) plus any chunks left unreferenced, so an interrupted run's data is re-chunked and re-uploaded and the index self-repairs. The storage-less path marks its blobs uploaded so the invariant holds uniformly.
The skipped test named here lives in the unmerged #72 branch, so the PR adds its own reproduction: TestBackupRetryAfterInterruptedUploadIsRestorable fails a blob upload, retries on the same index with a working backend, then restores and byte-compares. It fails without the change at plan build (chunk missing from blob map) and passes with it.
model: claude-opus-4-8
Fixed in https://git.eeqj.de/sneak/vaultik/pulls/175 (base `next`).
Chosen: issue option b. Deduplication now trusts a chunk only when a blob holding it has `uploaded_ts` set, and every scan drops blob rows with `uploaded_ts IS NULL` (their `blob_chunks` cascade) plus any chunks left unreferenced, so an interrupted run's data is re-chunked and re-uploaded and the index self-repairs. The storage-less path marks its blobs uploaded so the invariant holds uniformly.
The skipped test named here lives in the unmerged #72 branch, so the PR adds its own reproduction: `TestBackupRetryAfterInterruptedUploadIsRestorable` fails a blob upload, retries on the same index with a working backend, then restores and byte-compares. It fails without the change at plan build (`chunk missing from blob map`) and passes with it.
model: claude-opus-4-8
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
An interrupted blob upload leaves the local index able to deduplicate
against data that was never stored remotely, so a later backup run
completes successfully (exit 0) yet produces a snapshot that cannot be
restored. This is silent data loss and was found while writing the
fault-injection tests for
#72.
Root cause
During a scan,
blob.Packer.finalizeCurrentBlob→commitBlobToDatabase→insertBlobRecords(
internal/blob/packer.go) writes thechunks,blob_chunks, andblobs(finished,uploaded_tsNULL) rows for a blob before theblob is handed to
Scanner.handleBlobReady(
internal/snapshot/scanner.go) for upload. If that upload fails — adropped connection mid-blob, or
kill -9— the scan aborts, but thoserows survive in the local index.
On the next run the same index is reused.
Scanner.loadKnownChunksloads every chunk from the
chunkstable regardless of whether itsblob was ever uploaded, so
chunkExistsreturns true for the orphanedchunks and they are never re-packed or re-uploaded. The new snapshot's
files reference those chunks, but no uploaded blob covers them. The
backup reports success.
Impact
Restoring the resulting snapshot fails at plan build with
chunk missing from blob map: <hash>; averify --deepwould reportthe referenced blob missing from storage. The user is told nothing is
wrong at backup time.
Reproduction
internal/vaultik/fault_injection_test.gocontainsTestBackupRetryAfterInterruptedUploadIsRestorable, skipped andpointing at this issue. It fails a blob upload through the
faultstoreseam, retries the backup on the same index with a workingbackend, and asserts the retried snapshot restores. It currently fails
with the error above.
Definition of done
Decide and implement one of:
chunks/blob_chunks/blobsrowsonly once its upload has succeeded, so an interrupted upload
leaves no chunk rows to dedup against; or
finished_tssetand
uploaded_tsNULL (or whose object is absent at thedestination) and either re-upload them or drop their chunk rows so
the data is re-chunked. This is also the "next run detects and
repairs the state" behavior called for by scenario 2 of
#72, so a fix here should
cover an interrupted metadata export too (database uploaded, blobs
present, but the run died before the manifest).
State which was chosen and why in the commit message.
A backup that is interrupted during upload and then re-run produces
a snapshot that restores byte-for-byte, or fails loudly at backup
time rather than silently emitting an unrestorable snapshot.
Remove the
t.SkipfromTestBackupRetryAfterInterruptedUploadIsRestorableand have it pass.make checkgreen.model: claude-opus-4-8
Fixed in #175 (base
next).Chosen: issue option b. Deduplication now trusts a chunk only when a blob holding it has
uploaded_tsset, and every scan drops blob rows withuploaded_ts IS NULL(theirblob_chunkscascade) plus any chunks left unreferenced, so an interrupted run's data is re-chunked and re-uploaded and the index self-repairs. The storage-less path marks its blobs uploaded so the invariant holds uniformly.The skipped test named here lives in the unmerged #72 branch, so the PR adds its own reproduction:
TestBackupRetryAfterInterruptedUploadIsRestorablefails a blob upload, retries on the same index with a working backend, then restores and byte-compares. It fails without the change at plan build (chunk missing from blob map) and passes with it.model: claude-opus-4-8