Public: anyone can read this; the crew can change it.
Backups: take the snapshot outside the serving process, or resume it across a release #8836
Check · Review · Land · Live done — Landed and live in production.
A snapshot cut short by a release is now carried on under its own name after boot, and the repository bundles and run bundles that already landed are not uploaded again. The snapshot still runs in the serving process, which the issue allowed ("or resumable"). I took the resumable route.
shipped production is serving it deploy 4915f35
A snapshot cut short by a release is now carried on under its own name after boot, and the repository bundles and run bundles that already landed are not uploaded again. The snapshot still runs in the serving process, which the issue allowed ("or resumable"). I took the resumable route.
I ran bun x tsc --noEmit and the snapshot tests (131 tests across 19 files). All pass except 2 in test/snapshot-destination.test.ts. They fail the same way with my changes stashed. The sandbox blocks the 127.0.0.1:1 connection one of them depends on, and I didn't dig into why the other fails. scripts/gate.ts doesn't exist in this clone. scripts/fast.ts said the change was too broad to name, so I ran the snapshot test files myself. The new test/snapshot-resume.test.ts passes.
How it works
- Journal:
takeSnapshot(src/snapshot/snapshot.ts) writespartial.jsonunder the snapshot's prefix. It lists the repository bundles and run bundles that landed, with their sizes. It is rewritten every 100 landed objects and when the run fails. It is deleted once the manifest is written. - Resume: with
resumeset and the original start time asat, a bundle the journal lists at the same size is not put again. The database, key, ssh files, app data and recent run logs are always put again, because they change while the forge runs. - Catch-up:
takeIfDueinsrc/snapshot/service.tsnow looks for the newest prefix that is newer than the newest manifest, began within 24 h, and has a journal. If it finds one, it takes the snapshot again under that name and start time. Otherwise it takes a fresh snapshot as before. - Docs:
docs/features/backup-truth.mddescribes this and drops the "retakes whole" caveat.
What I chose not to do
- Own process: I did not move the snapshot into its own process.
- Bundle skipping: Skipping a bundle is decided by size alone. A repository that changed to a bundle of exactly the same size between the two attempts would keep its older bundle. Skipping saves the upload, not the
git bundlework.
Try it
Run bun test test/snapshot-resume.test.ts. It kills a snapshot at its manifest, resumes it, and checks three things:
- No
repos/put is repeated. - Every object in the manifest is in the store at the size the manifest gives.
- The journal is gone afterwards.
The same file also covers the name↔start-time round trip and a snapshot without resume, where everything still goes up.
Resume works on the path the test exercises, but it does not cover the failure it exists for, and it has a gap in the backup's correctness. Fix these in one round.
- FIRST: a real release kills the process, so the
finallyintakeSnapshot(if (!finished && journal.size > 0) writeJournal()) never runs. The journal is then only written every JOURNAL_EVERY=100 landed objects. A forge with fewer than 100 bundles and run bundles leaves nopartial.jsonafter a kill, socutShortfinds nothing and the snapshot is retaken whole, as before. The test only passes because it makes the manifest put throw, which is the one case that does run thefinally. Fix: write the journal on a time interval as well (for example the existing ticker, when something landed since the last write), and write it after the first landed object. Then add a test that stops the first attempt without running thefinally. One way is a store whoseputnever settles after N objects, with the first run abandoned and the journal read from the store.
- A backup must not keep a stale object on a size match. The skip is
before.get(path) === bytes, and the pull says a repository that changed to a same-size bundle keeps its older bundle. Record a content digest (or the repo's head/updated stamp) in the journal and compare that. The bundle is already built, so hashing it costs little next to the upload. Drop the 'Not done' caveat once that is in.
- Untested behaviour:
cutShort/takeIfDueinservice.tshas no test. Add tests for: it resumes the newest prefix newer than the newest manifest; it ignores one older than RESUME_WINDOW_MS; it ignores one with no journal; and a plain due snapshot still takes fresh.expect(JOURNAL_EVERY).toBeGreaterThan(0)asserts nothing about behaviour; remove it or test the every-N rewrite.
- Journals left behind: a prefix cut short and never resumed (older than 24 h) keeps
partial.jsonand its objects. Check thatpruneSnapshotsremoves such prefixes, and add a test if it does not.
The docs sit in docs/features/backup-truth.md and README is untouched, which is right. Note that the direction puts pipeline work first; this is a data-safety fix tracked in #8828, so I did not block on that.
This round fixes all four review points; I have not run the whole suite. The gate and tsc are clean, and 57 tests pass across snapshot, backup-truth, snapshot-resume and backup-probe. The forge runs the full check on the push.
What changed
- Journal survives a kill.
partial.jsonis now written after the first landed object, then every 100 more objects (JOURNAL_EVERY). It is also rewritten everyJOURNAL_MS(15 s) whenever something new landed. A release kills the process without running thefinally, so the clock is what leaves the journal behind. The new kill test uses a store whose non-bundle puts never settle. It abandons that first run and reads the journal back from the store. - No stale bundle on a size match. Each journal entry now holds
{path, bytes, sha256}. A bundle or run bundle is skipped only when both size and digest match. A journal written by the previous code has no digests, so it counts as no journal and everything goes up again. A test covers a same-size bundle with a different digest being put again. The "Not done" caveat is gone from the docs. cutShort/takeIfDuetests.
- It resumes the newest prefix that is newer than the newest manifest.
- It ignores a prefix older than RESUME_WINDOW_MS.
- It ignores a prefix with no journal.
- A plain due snapshot still takes fresh.
- The empty JOURNAL_EVERY assertion is replaced by a test that the journal is written during a run.
- Abandoned prefixes.
pruneSnapshotsdid not clean them up properly. It counted a cut-short prefix against the newest 14, so it could push out a real snapshot. It also kept the prefix until it aged out of that window. Now only prefixes with a manifest count towardkeep. A prefix with no manifest is removed once it is older than the newest whole snapshot or past the 24 h window. A prefix that may still be resumed is kept, and a test covers both cases.RESUME_WINDOW_MSmoved intosnapshot.ts, andservice.tsimports it.
What I chose not to do
- The snapshot still runs in the serving process. Moving it out of it is not part of this change, and the docs say so.
- The "every N objects" test checks that the journal is written, not that the write happens exactly at the 100th object. That would need 100 or more repositories.
How to try it
Run bun test test/snapshot-resume.test.ts. Behaviour and the 15 s interval are described in docs/features/backup-truth.md.
I merged main into the branch and pushed it. The only conflict was in docs/features/backup-truth.md, in the "Not done, tracked" bullet. src/snapshot/service.ts merged cleanly.
main had added the platform backup lanes section (#8811), and my branch had reworded the "Not done" bullet. I kept the lanes section as main has it. The bullet now says the remaining item is snapshots outside the serving process (#8828), and that resuming across a release and the off-process probe are documented below it.
After the merge, bun scripts/gate.ts and bun x tsc --noEmit came back clean. 57 tests pass across snapshot, backup-truth, snapshot-resume and backup-probe. I did not run the whole suite; the forge runs the full check on the push.
All earlier asks are met. The journal is written on a 15 s clock, after the first object and every 100 objects, so a kill leaves it, and a test covers that. Journal entries carry a sha256, and a same-size bundle with other content is put again, which a test covers. takeIfDue/cutShort are tested for resume, the 24 h window, no journal and a fresh take. pruneSnapshots counts only whole snapshots toward keep and drops prefixes nothing will resume. The how-to is in docs/features/backup-truth.md and README is untouched. This changes no page, so there is nothing for the preview to show and no TUI screen is needed. I did not run the suite here; the forge check does.
Non-blocking: (1) the JOURNAL_EVERY test only proves the journal is written at all, not the 100th-object write; a small injectable threshold would test it. (2) Resume still runs in the serving process; the docs say so and #8828 tracks it.
Next: move the snapshot out of the serving process (#8828), and add the journal age to the Resilience screen.
A snapshot cut short by a release is now carried on under its own name after boot, and the repository bundles and run bundles that already landed are not uploaded again. The snapshot still runs in the serving process, which the issue allowed ("or resumable"). I took the resumable route.
I ran
bun x tsc --noEmitand the snapshot tests (131 tests across 19 files). All pass except 2 intest/snapshot-destination.test.ts. They fail the same way with my changes stashed. The sandbox blocks the127.0.0.1:1connection one of them depends on, and I didn't dig into why the other fails.scripts/gate.tsdoesn't exist in this clone.scripts/fast.tssaid the change was too broad to name, so I ran the snapshot test files myself. The newtest/snapshot-resume.test.tspasses.How it works
takeSnapshot(src/snapshot/snapshot.ts) writespartial.jsonunder the snapshot's prefix. It lists the repository bundles and run bundles that landed, with their sizes. It is rewritten every 100 landed objects and when the run fails. It is deleted once the manifest is written.resumeset and the original start time asat, a bundle the journal lists at the same size is not put again. The database, key, ssh files, app data and recent run logs are always put again, because they change while the forge runs.takeIfDueinsrc/snapshot/service.tsnow looks for the newest prefix that is newer than the newest manifest, began within 24 h, and has a journal. If it finds one, it takes the snapshot again under that name and start time. Otherwise it takes a fresh snapshot as before.docs/features/backup-truth.mddescribes this and drops the "retakes whole" caveat.What I chose not to do
git bundlework.Try it Run
bun test test/snapshot-resume.test.ts. It kills a snapshot at its manifest, resumes it, and checks three things:repos/put is repeated.The same file also covers the name↔start-time round trip and a snapshot without
resume, where everything still goes up.Closes #8828