Delta sync: large files move as content-defined chunks - #161
Merged
Conversation
Files over 4 MiB push as chunks/<sha256> pieces plus a manifests/<sha256> chunk list keyed by the whole file's hash, so Op.Blob alone locates it and the journal format is byte-identical. A 1-byte edit to a 20 MiB file now transfers ~2 MB instead of ~21 MB, both directions; chunk boundaries come from a rolling hash (restic/chunker), so front insertions stay cheap. The hub reassembles whole blobs on demand (spool, verify, backfill, serve), which is the entire backward-compatibility story: old clients ask for blobs/<sha> and never learn anything changed. Proven by e2e tests that build the real pre-change binary from the pinned merge-base commit. The push skip-proof is one Exists per chunk — three cheaper proxies (local basis, manifest existence, stored manifest content) each proved false or forgeable across four CTO review rounds and are recorded in the code comment. Hub-side, manifests are write-once and must name only chunks the store holds; reassembly is bounded at 256 MiB against amplified manifests. Also: per-file sync ceiling 32 -> 100 MiB; import refuses archives whose journals reference content they do not hold (--allow-incomplete overrides). Deploy hubs before clients: old hubs refuse chunk keys (push degrades to offline-retry), and old clients cap reads at 32 MiB so 32-100 MiB files report "blob corrupt on remote" until the client upgrades. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6nqxi5a9qcENmJvrgBJF7
buildOldBinary archives the pinned merge-base sha, which a fetch-depth-1 actions/checkout does not have — all three old-binary e2e tests failed in CI with exit 128 while passing on any full local clone. On archive failure, fetch just that commit (--depth=1, one object; actions/checkout persists credentials so the in-job fetch works) and retry. Verified against a real GitHub shallow clone: archive fails, the single-sha fetch succeeds, archive then yields the pre-delta tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6nqxi5a9qcENmJvrgBJF7
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
What this is
Files over 4 MiB push as
chunks/<sha256>pieces plus amanifests/<sha256>chunk list keyed by the whole file's hash — soOp.Blobalone locates it and the journal format is byte-identical (journal.LessandReplayuntouched; a test diffs a chunked op's JSON fields against a small file's). Chunk boundaries come from a rolling hash (restic/chunker, fixed polynomial, 256K/1M/4M), so an insertion at the front of a 20 MiB file moves ~527 KB, not the file. Local volume stores keep files whole — chunking exists only on the wire and in the hub's store.Measured (byte-counting tests in
internal/syncer/delta_test.go, real-HTTP variants ininternal/webapp/delta_e2e_test.go):Backward compatibility
RemoteSource.OpenBlobis the single hub-side content read (viewer, history, shares, downloads,/store/*); whenblobs/<sha>is absent it reassembles from the manifest — spool, hash-verify, backfill, serve — so a client that has never heard of a manifest keeps working with no change. The e2e suite builds the actual pre-change binary from the pinned merge-base commit (git archive, no worktree state) and drives it against a live hub: chunked-storage reads, old-writes/new-reads round trips, concurrent old↔new conflicts with conflict-copy preservation, and delete propagation.Sequencing (also in the CHANGELOG): hub first, then clients. An old hub 400s chunk keys — a new client's large-file push degrades to offline-retry, no data loss. An old client caps reads at its 32 MiB bound, so 32–100 MiB files report "blob corrupt on remote" (the hub is healthy) until that client upgrades.
Hostile-member hardening
Four rounds of adversarial review (
beardrive-ctoagent; scoreboard and round log in.claude/delta-sync-goal.md) drove the design to its final shape:Existsper chunk. Three cheaper proxies — local basis possession, manifest existence, stored manifest content — each proved false or forgeable (a member who can read a file can publish its true chunk hashes without uploading a byte). The dead proxies are recorded in the code comment so the next round-trip optimization doesn't resurrect one.bdrive importrefuses archives whose journals reference content they don't hold — the silent-loss shape an oldexportproduces against a new hub;--allow-incompleteoverrides with per-path warnings.Also in this PR
maxPullBytes32 → 100 MiB (hostile-hub sec-test ceilings now derive from the constant).web/docsreference pages (cli, project-files), CHANGELOG Unreleased entry with the deploy-order note.Architecture changes
architecture/cli-sync.md—SessiongainedpushChunked/fetchChunked/chunkSpans; a newManifesttype (chunks.go) carries the chunk list, with a new Session → Manifest relationship for the over-4-MiB paths.✅ added · ❌ removed (strikethrough) · unmarked = unchanged
flowchart TB Session["<div style='text-align:left'><b>Session</b><br/>+Cycle(ctx) Result<br/>+Restore(ctx, path, sha)<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -pushChunked(ctx, blob) bytes</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -fetchChunked(ctx, op, basis)</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -chunkSpans(blob) []span</span></div>"] Manifest["<div style='text-align:left'><b>Manifest</b><br/>+V int<br/>+Size int64<br/>+Chunks []chunkRef h, n</div>"] Backend["<b>remote.Backend</b><br/>Put · Get · List · Exists"] Note["chunks.go — files > 4 MiB push as<br/>chunks/<sha256> + manifests/<file-sha><br/>keyed by Op.Blob: journal format untouched.<br/>Skip-proof = one Exists per chunk;<br/>manifest refusal → whole-blob fallback"] Session -- "pull and push" --> Backend Session -- "✅ <span style='background:#22c55e55;padding:0 5px;border-radius:3px'>chunked push/pull, files > 4 MiB</span>" --> Manifest Manifest -.- Note classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class Manifest added class Note noteBox linkStyle 1 stroke:#22c55e,stroke-width:2pxarchitecture/webapp-server.md—RemoteSourcegainedreassemble(the chunked-storage fallback behindOpenBlob), and the/store/*door's key space grew the two new classes with their ingest gates.flowchart TB RemoteSource["<div style='text-align:left'><b>RemoteSource</b><br/>+OpenBlob(ctx, sha)<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -reassemble(ctx, sha) chunked fallback</span><br/>-verify(ctx, sha) re-hash until sealed</div>"] journalDoor["<div style='text-align:left'><b>journalDoor</b> /api/p/id/store/*<br/>ownJournal · journalOps<br/>opsNameTheirAuthor</div>"] Keys["✅ new key classes<br/>chunks/<sha256> — content-addressed,<br/>presigned like blobs, PUT must hash to key<br/>manifests/<sha256> — never presigned,<br/>WRITE-ONCE, every named chunk must exist"] Reassembly["✅ reassemble: manifest → concat chunks →<br/>hash-verify → backfill blobs/<sha> → serve.<br/>Old clients never learn anything changed.<br/>Bounded: maxReassembleBytes 256 MiB"] RemoteSource -.- Reassembly journalDoor -.- Keys classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class Keys added class Reassembly added linkStyle 0 stroke:#22c55e,stroke-width:2px linkStyle 1 stroke:#22c55e,stroke-width:2px🤖 Generated with Claude Code
https://claude.ai/code/session_01R6nqxi5a9qcENmJvrgBJF7