Files
0dd474baab Delta sync: large files move as content-defined chunks (#161)
* feat(sync): delta sync — large files move as content-defined chunks

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

* fix(test): fetch the pinned pre-delta commit on shallow CI clones

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 07:18:37 -07:00

262 lines
8.1 KiB
Go

package syncer
// Delta sync (docs/delta-sync-prd.md): files larger than chunkThreshold move
// as content-defined chunks plus a manifest instead of one whole blob. The
// journal format is untouched — a manifest is keyed by the FILE's sha256, so
// Op.Blob alone locates it. Chunk boundaries are chosen by content (rolling
// hash), so an insertion shifts only the chunk it lands in and every device
// derives the same chunks from the same bytes with no negotiation: the
// remote's "does this key exist" is the whole handshake.
//
// The local volume store is deliberately untouched: blobs stay whole on disk,
// chunking exists only on the wire and in the remote store.
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"github.com/restic/chunker"
"github.com/runbear-io/beardrive/internal/journal"
)
const (
// chunkThreshold: files at or below this size take the whole-blob path —
// below it the extra round trips cost more than the bytes they save.
chunkThreshold = 4 << 20
chunkMin = 256 << 10
chunkMax = 4 << 20
// manifestBound caps how much of a manifest is read: it is remote content
// a peer wrote. 8 MiB of JSON is ~100k chunks — far past maxPullBytes'
// worth of file.
manifestBound = 8 << 20
)
// chunkPol is the Rabin polynomial every device uses. It must be one fixed
// value for the whole world: two devices chunking the same bytes must produce
// the same chunks, or dedup silently degrades to nothing.
const chunkPol = chunker.Pol(0x3DA3358B4DC173)
type chunkRef struct {
H string `json:"h"`
N int64 `json:"n"`
}
type manifest struct {
V int `json:"v"`
Size int64 `json:"size"`
Chunks []chunkRef `json:"chunks"`
}
// span is one chunk of a local blob: its content hash and where it lives in
// the whole file, so its bytes can be re-read without holding them all.
type span struct {
sha string
off int64
n int64
}
// chunkSpans splits a local blob into content-defined chunks.
func (s *Session) chunkSpans(sum string) ([]span, error) {
f, err := s.Store.OpenBlob(sum)
if err != nil {
return nil, err
}
defer f.Close()
ch := chunker.NewWithBoundaries(f, chunkPol, chunkMin, chunkMax)
buf := make([]byte, chunkMax)
var spans []span
var off int64
for {
c, err := ch.Next(buf)
if err == io.EOF {
return spans, nil
}
if err != nil {
return nil, err
}
h := sha256.Sum256(c.Data)
spans = append(spans, span{sha: hex.EncodeToString(h[:]), off: off, n: int64(len(c.Data))})
off += int64(len(c.Data))
}
}
// pushChunked uploads one large blob as chunks + manifest, skipping only the
// chunks the remote CONFIRMS it holds. Returns bytes actually uploaded.
// Order is chunks → manifest, so a manifest's presence implies its chunks
// exist (the delta-sync content-before-journal invariant).
//
// The skip proof is one Exists per chunk, nothing cleverer. Three cheaper
// proxies were tried and every one was false or forgeable: "I hold the
// previous version locally" (false for a basis that went up whole — grown
// across the threshold, or pushed by a pre-delta binary); "a manifest exists
// for the basis" (its first write is unverifiable, so a member could plant an
// empty one); "the stored manifest's own hash list" (a member who can read
// the file can publish its true hashes without uploading a byte). There is no
// client-side proof that a chunk is on the remote except asking the remote —
// so ask. ~1 HEAD per unchanged chunk per push of a changed file, still
// three orders of magnitude cheaper than uploading it.
func (s *Session) pushChunked(ctx context.Context, blob string) (int64, error) {
spans, err := s.chunkSpans(blob)
if err != nil {
return 0, err
}
f, err := s.Store.OpenBlob(blob)
if err != nil {
return 0, err
}
defer f.Close()
var uploaded int64
man := manifest{V: 1}
for _, sp := range spans {
man.Size += sp.n
man.Chunks = append(man.Chunks, chunkRef{H: sp.sha, N: sp.n})
if ok, err := s.Backend.Exists(ctx, "chunks/"+sp.sha); err == nil && ok {
continue
}
sec := io.NewSectionReader(f, sp.off, sp.n)
if err := s.Backend.Put(ctx, "chunks/"+sp.sha, sec, sp.n); err != nil {
return uploaded, err
}
uploaded += sp.n
}
mb, err := json.Marshal(man)
if err != nil {
return uploaded, err
}
if err := s.Backend.Put(ctx, "manifests/"+blob, bytes.NewReader(mb), int64(len(mb))); err != nil {
// The manifest key is write-once on the hub, so a DIFFERENT body
// already under it (a squatter, or a client with other chunker
// parameters) is refused forever — and a push error here would have
// wedged this device's entire push leg on every future cycle, since
// the journal never goes up and the same job is rebuilt each time.
// The whole blob is content-addressed and always accepted: push it
// and move on. Costs one full upload for that file; costs nothing in
// correctness, and turns a future chunker-parameter change into a
// non-event.
f, ferr := s.Store.OpenBlob(blob)
if ferr != nil {
return uploaded, err
}
fi, serr := f.Stat()
if serr != nil {
f.Close()
return uploaded, err
}
perr := s.Backend.Put(ctx, "blobs/"+blob, f, fi.Size())
f.Close()
if perr != nil {
return uploaded, err
}
return uploaded + fi.Size(), nil
}
return uploaded + int64(len(mb)), nil
}
// errNoManifest tells pull's fetch loop to take the whole-blob path: the
// remote has no manifest for this blob (an old writer pushed it whole, or the
// hub will reassemble on Get).
var errNoManifest = errors.New("no manifest")
// fetchChunked pulls a large blob via its manifest, sourcing unchanged chunks
// from the basis version already in the local store and fetching only the
// rest. The assembled bytes go through PutBlobReader, which files them under
// their COMPUTED hash — so a manifest that does not reassemble to op.Blob
// leaves HasBlob(op.Blob) false and materialize keeps skipping the path,
// exactly like a corrupt whole blob. Returns errNoManifest when the remote
// has no manifest, so the caller can fall back to the whole-blob path.
func (s *Session) fetchChunked(ctx context.Context, op journal.Op, basis string) error {
mrc, err := s.Backend.Get(ctx, "manifests/"+op.Blob)
if err != nil {
return errNoManifest
}
var man manifest
derr := json.NewDecoder(io.LimitReader(mrc, manifestBound)).Decode(&man)
mrc.Close()
if derr != nil {
return fmt.Errorf("manifest for %s: %w", shortSha(op.Blob), derr)
}
var total int64
for _, c := range man.Chunks {
if !hexSha(c.H) || c.N < 0 {
return fmt.Errorf("manifest for %s names an invalid chunk", shortSha(op.Blob))
}
total += c.N
}
if total > maxPullBytes {
return errNoManifest // too big to assemble; the whole-blob path owns the ceiling
}
local := map[string]span{}
var bf *os.File
if basis != "" && s.Store.HasBlob(basis) {
if bspans, err := s.chunkSpans(basis); err == nil {
if bf, err = s.Store.OpenBlob(basis); err == nil {
defer bf.Close()
for _, sp := range bspans {
local[sp.sha] = sp
}
}
}
}
tmp, err := os.CreateTemp(s.Store.Dir(), ".bdrive-tmp-assemble-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
defer tmp.Close()
for _, c := range man.Chunks {
if sp, ok := local[c.H]; ok && bf != nil {
if _, err := io.Copy(tmp, io.NewSectionReader(bf, sp.off, sp.n)); err != nil {
return err
}
continue
}
crc, err := s.Backend.Get(ctx, "chunks/"+c.H)
if err != nil {
return err
}
h := sha256.New()
n, cerr := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(crc, c.N+1))
crc.Close()
if cerr != nil {
return cerr
}
if n != c.N || hex.EncodeToString(h.Sum(nil)) != c.H {
return fmt.Errorf("chunk %s of %s does not hash to its key", shortSha(c.H), shortSha(op.Blob))
}
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
return err
}
sum, _, err := s.Store.PutBlobReader(tmp)
if err != nil {
return err
}
if sum != op.Blob {
return fmt.Errorf("%w: manifest for %s assembles to %s", errBlobContent, shortSha(op.Blob), shortSha(sum))
}
return nil
}
func hexSha(s string) bool {
if len(s) != 64 {
return false
}
for _, r := range s {
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
return false
}
}
return true
}