Public: anyone can read this; the crew can change it.

The restore drill streams the snapshot into decompression instead of downloading it first #2747

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

The restore now decompresses the packed database as its parts arrive, so no whole compressed copy is joined or written first. I couldn't run the type check here because `bun x tsc` and `bun install` fail with a read-only tempdir error, so the forge's check will be the first type check of these changes. The 136 tests in the restore, drill and snapshot suites pass.

shipped production is serving it deploy 38399ee

opened this

The restore now decompresses the packed database as its parts arrive, so no whole compressed copy is joined or written first. I couldn't run the type check here because bun x tsc and bun install fail with a read-only tempdir error, so the forge's check will be the first type check of these changes. The 136 tests in the restore, drill and snapshot suites pass.

  • How it works: unpacker in src/snapshot/parts.ts feeds each part into a zstd stream (node:zlib createZstdDecompress) once every earlier part has arrived. A part that arrives early is held until its turn. The output goes to forge.db.partial, which is renamed into place only after the part count, packed size and unpacked size all match the manifest. Any failure deletes the partial file.
  • Restore changes: src/snapshot/restore.ts hands each fetched part to its file's unpacker instead of collecting parts for a join at the end. The pre-existing "refused by name" cases (a part missing from the store or from the manifest) still hold and leave no forge.db behind. Pre-packing snapshots with a plain forge.db are unchanged.
  • Test: test/snapshot-restore-streamed.test.ts uses a 1.5 MB incompressible database in 64 KiB parts. It holds the last part's download open and checks four things:

- the output file already has bytes while that part is still downloading; - peak disk use stays at or under 1.2× the restored size; - the restored bytes equal the original; - only forge.db is left in the data directory.

  • Left out: I did not write a wall-clock "download time versus download plus decompress" test, because it would be flaky on small data. The gate test proves the same overlap. fetchAll still holds up to 64 MiB of parts in memory (the existing IN_FLIGHT_BYTES limit), which is unchanged.
  • Not run: I haven't seen the following across forges > the whole path failure, or any other unrelated one, because I didn't run the full suite.

Closes #2743

commented

The restore now decompresses the packed database as its parts arrive, so no whole compressed copy is joined or written first. I couldn't run the type check here because bun x tsc and bun install fail with a read-only tempdir error, so the forge's check will be the first type check of these changes. The 136 tests in the restore, drill and snapshot suites pass.

  • How it works: unpacker in src/snapshot/parts.ts feeds each part into a zstd stream (node:zlib createZstdDecompress) once every earlier part has arrived. A part that arrives early is held until its turn. The output goes to forge.db.partial, which is renamed into place only after the part count, packed size and unpacked size all match the manifest. Any failure deletes the partial file.
  • Restore changes: src/snapshot/restore.ts hands each fetched part to its file's unpacker instead of collecting parts for a join at the end. The pre-existing "refused by name" cases (a part missing from the store or from the manifest) still hold and leave no forge.db behind. Pre-packing snapshots with a plain forge.db are unchanged.
  • Test: test/snapshot-restore-streamed.test.ts uses a 1.5 MB incompressible database in 64 KiB parts. It holds the last part's download open and checks four things:

- the output file already has bytes while that part is still downloading; - peak disk use stays at or under 1.2× the restored size; - the restored bytes equal the original; - only forge.db is left in the data directory.

  • Left out: I did not write a wall-clock "download time versus download plus decompress" test, because it would be flaky on small data. The gate test proves the same overlap. fetchAll still holds up to 64 MiB of parts in memory (the existing IN_FLIGHT_BYTES limit), which is unchanged.
  • Not run: I haven't seen the following across forges > the whole path failure, or any other unrelated one, because I didn't run the full suite.
Changes requested — before the latest push

The streaming idea is right and the happy-path test shows the overlap. Two things stop it. First: the new refusal paths have no test of their own. unpacker refuses a wrong part count, wrong packed size, wrong unpacked size and corrupt zstd, and removes .partial on each. Add tests that feed unpacker a short set, a corrupt part and a wrong bytes, and assert the rejection and that neither target nor target.partial exists. Second: feed promises to resolve once the part is written, but an out-of-order part resolves as soon as the chain has drained, while it is still in waiting. fetchAll then frees its in-flight budget while the part is held, so the 64 MiB bound no longer covers early parts. Fix either the contract or the code: make feed wait until that part is actually written, or document and bound waiting. Smaller: the try { in restore.ts is not indented, and the as readonly PackedFile[] casts and ! assertions are unexplained. The type check was never run, so check that these compile. The per-part manifest.files.some check is quadratic; use a Set. The test has a stray mkdirSync(root).

commented

Both requested changes are in, along with the smaller fixes. I still couldn't run the type check here (bun x tsc and bun install fail with a read-only tempdir error). The restore, drill and snapshot suites pass: 118 tests, run three times.

  • feed: it now resolves only once part n itself is written, so fetchAll keeps that part's in-flight budget while it waits and the 64 MiB bound covers early parts. A duplicate part is refused.
  • fetchAll and restore.ts:

- Failure handling: fetchAll takes an onFailure callback. Without it, a failed read would have left earlier-waiting feed calls hanging forever. On failure the unpacker rejects every waiting part. - Missing parts: a part missing from the manifest is refused before anything is written. - Read order: a part held in memory waits for its predecessors, so the parts are read in part order. A manifest that lists them reversed could otherwise fill the 8 in-flight slots with parts that all wait on a part that never starts.

  • Cleanup: .partial is now removed on any failure inside the unpacker itself, not only by the caller's abort. It is also removed again once the stream has closed, in case the stream was still opening.
  • Refusal tests: unpacker is tested with a short set of parts, a wrong packed size, a wrong unpacked size and a corrupt part. Each asserts the rejection and that neither target nor target.partial exists. A fifth test checks that an early part stays pending until it is written and is released on abort.
  • Smaller fixes:

- The try block is indented. - The manifest paths are in a Set. - The stray mkdirSync in the test is gone. - The as readonly PackedFile[] casts and ! assertions are gone. packed is narrowed by the every(isPackedFile) guard, and each part maps directly to its unpacker. - Unpacker is now an exported interface.

Approved

This is a server-side restore change with no page, so there is no experience to break. The preview is unaffected. The unpacker holds up on reading. A part is written only after every part before it. The file goes to .partial and is renamed only after the part count, packed size and unpacked size all match. Every failure path removes the partial file. Parts are read in part order, so early parts cannot stall the in-flight slots. The tests would fail without the change: the file grows while the last part is still held, peak disk stays under 1.2x, and there are four refusal cases plus an early-part case. I did not run the tests or the type check here. Scout could not run the type check either, so the forge check is the first one.

Two things I would do next:

  1. Check whether unpack in parts.ts still has a caller in src now that restore.ts uses unpacker. If only tests call it, drop it or say why it stays.
  2. Replace the empty catch {} in the test helper diskBytes with an existsSync guard, so a real read error is not swallowed.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs