feat(sync): gzip the sync wire, without touching what a hash means (BEA-144) - #160
Merged
ssowonny merged 3 commits intoAug 13, 2026
Conversation
ssowonny
force-pushed
the
bea-144-transport-compression-on-the-sync-wire-follow-up-to-delta
branch
from
August 13, 2026 15:00
6991dd4 to
cff484a
Compare
Nothing on the /store/* wire was compressed, while the corpus it carries is markdown and source. Compression is added as a pure transport concern: content addressing, the storage layout and the journal format all stay over the uncompressed bytes. The two legs are not symmetric. Pull needs no negotiation at all — net/http already sends Accept-Encoding: gzip and inflates transparently — so devices built before this get it the day the hub ships. Push cannot be unilateral, because an old hub would store gzip bytes under the sha256 of the plaintext, so the client compresses only when sign() advertised accept_encoding. The hub inflates ABOVE spool, since the sha a key promises, the ops a journal carries and the size that gets billed are all properties of the plaintext — and the inflate is bounded, because Content-Encoding severs the one-wire-byte-one-disk-byte relationship that made spool safe unbounded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Delta sync landed first, so the harnesses this feature's acceptance criteria named now exist: buildOldBinary (pinned at 33ca0ca, which is the commit before compression as well as before chunking) and the real CLI e2e environment. Two things the argument-by-construction could not show: The old binary's pull really is compressed with no client change, and its push really does stay raw. Watched per-client through its own proxy rather than by marking a window on a shared counter — `bdrive init` starts a daemon, so a mark taken after it measures whatever the daemon had not already done, which is how the first draft reported a 282x pull ratio and proved nothing. It now reports 7.4x over the client's ENTIRE session, sign-in and listings included, against 0.87x with compression off. And the two key classes chunking added ride the same wire. The manifest is the one that matters: it is never presigned, so it always takes the relay path that compresses, and its write-once compare and chunks-exist gate read the spooled plaintext below the inflate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Not reachable through any hub fixture in the tree: they all run on file:// storage, which implements no PutSigner, so every plan comes back mode:"server" and putDirect is never exercised with a hub that advertises gzip. Managed hubs are S3/GCS-backed, which makes that the production path. A stray compression there is the one failure in this change that storage would keep forever rather than reject: the object lands under the sha256 of the plaintext with no hub in the path to inflate it. Verified the assertion is live by making putDirect compress — it fails. The fake presign target has to share the hub's origin. directTargetOK refuses a target that is neither https nor the hub's own origin, so a second httptest server is declined and silently relayed instead — which passes such a test while proving nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ssowonny
force-pushed
the
bea-144-transport-compression-on-the-sync-wire-follow-up-to-delta
branch
from
August 13, 2026 19:13
29955a1 to
5fe056e
Compare
ssowonny
deleted the
bea-144-transport-compression-on-the-sync-wire-follow-up-to-delta
branch
August 13, 2026 19:20
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.
TL;DR
net/httphas always sentAccept-Encoding: gzipand inflated the answer itself, so deploying the hub alone wins it, no client update.sign()now answersaccept_encoding), because a gzip body posted to an old hub would be stored under the sha256 of its plaintext.spooland bounded at 256 MiB — otherwise a 1 MB body becomes an arbitrary hub-side disk write before any quota check can run.chunks/andmanifests/ride the same compressed wire, and the old-binary claim is now proven against the real pre-compression binary rather than argued.flowchart TB Wire["<b>one gzip switch on /store/*</b>"] Pull["<div style='text-align:left'><b>PULL</b> hub -> device<br/><br/>hub gzips the response<br/>net/http ALREADY sends Accept-Encoding: gzip<br/>and inflates transparently<br/><br/><b>no client change - old binaries win on deploy</b></div>"] Push["<div style='text-align:left'><b>PUSH</b> device -> hub<br/><br/>sign() must advertise accept_encoding<br/>hub inflates BEFORE spool -> hash -> quota<br/>inflate is bounded (256 MiB)<br/><br/><b>old hub advertises nothing -> client sends raw</b></div>"] Wire --> Pull Wire --> Push classDef free fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef work fill:#f59e0b22,stroke:#f59e0b,stroke-width:2px class Pull free class Push workEverything is a transport concern. Hashes, the storage layout and the journal format are all still over the uncompressed bytes — the hub stores plaintext, bills plaintext, and inflates before it hashes.
What can't break
spool, so the sha it checks is the plaintext's. A gzip body decoding to the wrong bytes still getscontent does not hash to its key.ownJournal,journalOps,opsNameTheirAuthorandjournalKeepsItsOpsall run below the inflate, on the plaintext, untouched. A gzipped truncating journal push still 409s.syncer.push; nothing here moves it.Opfield, nojournal.Less, noReplay.putDirectstays raw — there is no hub in that path to inflate, so compressing it would corrupt content addressing at rest.What I'm accepting
ContentLengthis cleared and the PUT goes out chunked.spoolalready handles that (it measures the body rather than believing a header), but a self-hosted deployment with an intermediary proxy now sees chunked bodies where it saw sized ones.maxInflatedPut = 256 MiBis a precedent, not a measurement — same caveatmaxImportBlobcarries. It applies only whenContent-Encodingis present, so no honest raw push that works today can start failing.httpBackend.domust never setAccept-Encoding. If it ever does, Go stops inflating, the hub keeps answeringContent-Encoding: gzip, and every blob fails its sha check while looking like a corrupt hub. There's a comment atdosaying so.The old binary, for real
buildOldBinaryis pinned at33ca0ca— the commit before delta sync, which is also the commit before this. So it is a genuine pre-compression client, andTestCompressionE2E_OldBinaryPullsCompressedAndPushesRawruns it against this hub:TestCompressionE2E_ChunksAndManifestsOverGzipcovers the two key classes chunking added. The manifest is the one that matters: it is never presigned, so it always takes the relay path that compresses, and its write-once compare and chunks-exist gate read the spooled plaintext below the inflate.Deviations from the reviewed plan
countingHubis duplicated, not shared. The plan (correctly) rejectedcountingBackendfor the ratio test — it wraps aremote.BackendabovehttpBackendand counts plaintext by construction.internal/syncer/compress_e2e_test.gohas its own hub-level counter because it measures a realSession.Cycle(), one package over fromwebapp's; sharing it would mean exporting a test helper across packages.sign()answer with noaccept_encoding, whichTestPushCompressesOnlyWhenTheHubAdvertisesItdrives in four variants (absent, empty, a codec we don't speak, gzip). Building a whole binary to assert one absent JSON field would be slower and prove less. The old-client half is the real thing — see below.What was run
origin/main(delta sync, Delta sync: large files move as content-defined chunks #161). Two conflicts, both additive-adjacent: thestore.goimport block, and the inflate bound landing beside the new chunk/manifest key checks. The bound goes first — it must refuse before anything reads the body.go build ./... && go vet ./... && go test ./...— green, including delta sync's own e2e rows now running over the compressed wire.npm run e2e(Playwright,internal/webapp/frontend) — 170 passed, 1 skipped.false, the syncer ratio test reports 0.95×, the old-binary e2e drops to 0.87×, and the chunked push sends nothing compressed.npm run buildand no screenshots — the browser never touches/store/*.Architecture changes
architecture/webapp-server.md: one new type,wireCodec(internal/remote/compress.go) — the probe both legs share, plus theputPlan.AcceptEncodingfield that carries the negotiation — and two new dependency edges onto it, fromServer(the hub's GET/list/PUT handlers) and fromBackend(httpBackend's relayed push). Nothing was removed. ThejournalDoorandcountingWriternotes gained sentences about inflate-above-spool and counter-outside-gzip; those are behavioral, not structural.flowchart TB Server["<div style='text-align:left'><b>Server</b><br/>/api/p/<id>/store/*</div>"] RemoteSource["<div style='text-align:left'><b>RemoteSource</b><br/>+Backend remote.Backend</div>"] Backend["<div style='text-align:left'><b>Backend</b> <<interface>><br/>+Put +Get +List +Exists +Close<br/>impls: local, s3, gcs, httpBackend</div>"] PutSigner["<div style='text-align:left'><b>PutSigner</b> <<interface>><br/>+SignPut(ctx, key, size, ttl)</div>"] journalDoor["<div style='text-align:left'><b>journalDoor</b> <<store.go>><br/>ownJournal(key)<br/>journalOps(key, spooled)<br/>opsNameTheirAuthor(ops)<br/>journalKeepsItsOps(ctx, be, key, ops)</div>"] countingWriter["<div style='text-align:left'><b>countingWriter</b> <<quota.go>><br/>+Write(p) n<br/>+n int64</div>"] wireCodec["<div style='text-align:left'><b>wireCodec</b> <<internal/remote, compress.go>><br/>+Compressible(r) rejoined, worth, err<br/>+AcceptsGzip(req) bool<br/>putPlan.AcceptEncoding []string</div>"] Legs["Pull needs no negotiation:<br/>net/http sends Accept-Encoding: gzip<br/>and inflates transparently.<br/>Push compresses only when sign()<br/>advertised accept_encoding.<br/>putDirect stays raw - no hub in the path."] RemoteSource -- "Prefixed(Root, projectID)" --> Backend Backend -. "optional capability" .-> PutSigner Server -- "/store/* is the only way a device writes" --> journalDoor Server -- "every bytes-out route that bills" --> countingWriter Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ gzip on /store GET+list, inflate above spool on PUT</span>" --> wireCodec Backend -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ httpBackend gzips a relayed PUT when sign() allows</span>" --> wireCodec wireCodec -.- Legs classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class wireCodec added class Legs noteBox linkStyle 4 stroke:#22c55e,stroke-width:2px linkStyle 5 stroke:#22c55e,stroke-width:2pxCloses BEA-144.
Build session
(only works on this machine)