The emit upload path and NWCompressedBuffer round-trip #60
Notifications
Due Date
No due date set.
Depends on
#55 Bunny zone: how a blob is uploaded and how existence is checked without mounting
ShadowsOverWestgate/sow-tools
Reference: ShadowsOverWestgate/sow-tools#60
Reference in New Issue
Block a user
Question
What does
emit's upload path look like, and does the zstd framing round-trip against upstream?Part of #54. The decision is already made that
emituploads directly with no local blob tree — the working set is one resource, since a blob name is the SHA-1 of the uncompressed bytes and no resource exceeds 15 MiB. This ticket makes that concrete enough to build.Prototype far enough to answer:
--outtree still has to exist for conformance comparison against upstream. One interface, two implementations — or something simpler.x/text,yaml.v3) and stdlib has no zstd. Confirm the choice and that it can produce the exact NWCompressedBuffer framing:uint32magicNSYC, version 3, algorithm 2, uncompressed size, zstd header version 1, dictionary 0, then a raw zstd frame.quit(1).Inherited from #55 and #57 — settled, build against these
#55 (Bunny API) and #57 (restypes) both landed and pushed work into this ticket rather than leaving it open. Folding it in here so it is not re-decided:
Reuse
internal/depot'shttpBackend, extended — do not write a new client. It already has IPv4-pinned transport, retry, a tri-state probe whereUnconfirmedis distinct fromAbsent, and skip-if-present upload. Three sha256 assumptions become per-instance fields: the key layout (BlobKey(),manifest.go:26-28), the verify hash inGet()(remote.go:209-221), and theChecksumheader (remote.go:171) — which Bunny defines as SHA-256 and validates, so sending an uppercase SHA-1 there will make the upload fail. Compute both hashes in oneio.MultiWriterpass and send the SHA-256, keeping server-side integrity checking.PutReader(ctx, key string, r io.Reader, size int64)is the new method this needs.Puttakes a filesystem path, and NWSync blobs come from inside a hak and must never be staged to disk. ReimplementPuton top of it.Concurrency ceiling stays at
ProbeJobs(16) with the same discipline. The edge throttles with428/000under load; a throttled probe must never be read as "missing, re-upload" or as "present, skip".Mirror upstream's restype table, not Crucible's. Crucible registers six extensions upstream does not have (
lyt,vis,mdx,wlk,xml,gr2—erf.go:128-133); upstream wouldquit(1)on them. Matching the reference implementation is the point of the conformance exercise, and the corpus contains none of the six. Same for the hard-error rule: an unresolvable restype stops the run, and thenss/ndb/gicskip list is applied after resolution, not instead of it.Lowercase the resref in
emit, and rejectlen > 16.erf.Readpreserves original case (erf.go:347, noToLoweron the read path) and does not length-check. Upstream lowercases on both read and write (nwsync.nim:93-99). Crucible-built haks are already lowercase, foreign ones are not.No table lookup is needed on the emit path at all —
erf.Readkeeps the rawuint16from the ERF key list inResource.Type(erf.go:58-63,339-356), which is exactly what the manifest entry wants.Verification sweep — half of this ticket is already answered by PR #71
Landed in
a131b25, so do not re-decide:github.com/klauspost/compressv1.19.1 ingo.mod.internal/nwsync/compressedbuf.gowrites the exact framing (NSYC, version 3, algorithm 2, uncompressed size, zstd header version 1, dictionary 0, raw frame);TestBlobFramingRoundTripsproves round-trip.emit.go:22fileSizeLimit,TestEmitFailsClosedOnOversizeResource.emit.go:144; unresolvable restype is a hard error (emit.go:135).--outtree exists and is the only sink today.What is left in this ticket:
PutReader(ctx, key, r io.Reader, size int64)does not exist oninternal/depot'shttpBackendyet — build it per the inherited #55 note above (per-instance key layout / verify hash / SHA-256Checksumheader,ProbeJobs=16 ceiling, probe-then-skip).emit <artifact> --out DIRwith--outrequired, andassemble --order NAMES --entries DIR. #56/#62/#65 settled positional artifact keys, upload as the default sink, and NSYM keys derived by Crucible from the artifact key. Detail in the sweep comment on #53. Doing this with the upload sink is one change, not two.The blob-name-is-unchanged-by-compressed-bytes point is settled by construction (sha1 over uncompressed bytes) but still unproven against upstream — that proof is #59, not this ticket.
From #59: while reshaping the CLI, fix the flag order too —
emit hak/x.hak --out DIRfails with "exactly one artifact is required" because Go'sflagstops parsing at the first non-flag argument. With positionals becoming the norm (#56), this bites every caller.Prototype landed — PR #73
The upload path is concrete now, so the parts of this ticket that were design questions are answered by working code rather than argument.
Sink shape. One
sinkinterface, two implementations —zoneSink(production) anddirSink(--out DIR, the conformance path). Nothing simpler works, because both verbs need blob put, index put and index get, and the local tree has to stay diffable against upstream.Reuse, per #55, with one refinement.
depot.KeyStoreaddsProbeKey/PutReader/GetKeyto the existinghttpBackend, and the sha-addressedBackendis now rewritten on top of it — same transport, same retry, same tri-state probe. #55 called for three sha256 assumptions becoming per-instance fields; passing the key and the checksum as arguments turned out simpler and leftPuta four-line wrapper. Bunny'sChecksumis sha256 of the uploaded body, verified by the fake zone in the tests.Compression is now paid only for blobs that are actually uploaded — the body is a thunk behind the probe. On a re-run or a backfill pass that is the whole cost of the run.
Round-trip and framing were already proven by #59 against upstream (byte-identical manifest, identical blob headers, identical payloads after decompression). Re-checked through the reshaped CLI in this PR: still byte-identical.
Mid-hak failure — the mechanism. Blobs go up as produced, the index lands last, so a half-emitted artifact is real blobs plus no index, and that state is unambiguous. Blob names are content hashes, so a re-run skips whatever landed and costs one probe per resource.
assemblefails closed on an artifact with no index rather than publishing a manifest missing a hak.What still needs a human, and it is the last thing on this ticket
The policy above the mechanism: when
emitfails inside a producer repo's release job — say hak 7 of 11 — what happens to that release?assembleand fails closed.My recommendation is (1). A hak release whose blobs are only partly published is not a release anyone should be able to pin, and under (2) the failure surfaces in a different repo, days later, pointing at a hak whose emit failed rather than at the run that failed — the worst place to learn it. (3) is (1) plus a retry, and a re-run is already cheap and safe; CI re-run is the retry.
Not settled by me: (1) means a Bunny hiccup can fail an otherwise good hak build, and the blobs already uploaded stay in the zone as orphans until the next successful run adopts them. That is the cost.
Resolved — failure posture is (1): a failed emit fails the producing release
The mechanism shipped in PR #73; the policy above it is now settled.
When
emitfails inside a producer repo's release job, that release fails. No catalog entry, nothing downstream can pin it, and the operator fixes it in the run that broke. Not "publish and let sow-module'sassemblecatch it later": that surfaces the failure in a different repo days later, pointing at a hak rather than at the run that failed. Not a retry job either — a re-run is already cheap and safe, because blob names are content hashes and a re-run costs one probe per resource, so CI re-run is the retry.Accepted costs, stated so nobody re-litigates them on the day it happens:
assemble's fail-closed check on a missing index stays as the second line of defence, not the first.Everything else on this ticket, for the record
sinkinterface, two implementations:zoneSink(production) anddirSink(--out DIR, the conformance path).depot.KeyStore(ProbeKey/PutReader/GetKey) extendsinternal/depot'shttpBackend; the sha-addressedBackendnow rides on it. #55's "three sha256 assumptions become per-instance fields" became "the caller passes key and checksum", which is smaller.github.com/klauspost/compress, framing proven byte-identical against upstream in #59. Compression is now paid only for blobs that are actually uploaded.quit(1).Live upload against the real zone is still unproven: the zone and its credential are #61.