Skip to content

store/s3: sparse-aware, chunked, content-addressed checkpoint transfer - #364

Merged
CMGS merged 21 commits into
mainfrom
feat/362-s3-chunks
Oct 18, 2026
Merged

CMGS merged 21 commits into
mainfrom
feat/362-s3-chunks

Conversation

@CMGS

@CMGS CMGS commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Closes #362. Refs #183 (sandboxd no longer reclaims S3 at all; the reclaim contract is documented for the operator instead, see below).

Bottom of a stack: #366 builds on this branch, and #365 on #366. Merge in that order.

What

An S3 export file is now a manifest of fixed 16 MiB chunks (the existing DigestChunkSize), each stored under its SHA-256.

  • Layout.
    • <prefix><id>/meta.json (and labels.json) is the live record. meta.json is uploaded last as the commit marker, and the record prefix exists only while the record is live.
    • <prefix>manifests/<id>/<generation>.json lists each file's path, size and present chunks, plus the lineage.
    • Chunks live at <prefix>chunks/<lineage>/<sha256>.
    • A publish into a shared lineage writes a marker <prefix>lineages/<lineage>/<id>.
  • Upload. ScanFile walks data segments with SEEK_DATA/SEEK_HOLE, so a chunk lying fully in a hole is never read, and a chunk read as all zero is skipped. The chunk SHA-256 is both the object key and the record-digest chunk entry, so there is no second hash pass; the record digest (PublishDigested) is unchanged for the same content. A publish runs under a 30-minute budget, the same as a fetch.
  • Download. The file is created and truncated to its size, and only present chunks are streamed in (bounded concurrency), each verified against its manifest SHA-256. Holes stay holes. A missing chunk fails loudly and is deliberately not ErrNotFound.
  • Lineage dedup. Publish/PublishDigested take a lineage ("" means the record id). The pool passes the sandbox id, so every checkpoint, archive and replica of one claim shares chunks. A publish reads the current manifests of the records its lineage markers list and uploads only the chunks they lack.
  • Delete is soft. It removes meta.json, the labels and the lineage markers, and re-stamps the record's manifests. It never deletes a chunk or a manifest, so a fetch that already selected the record still completes.
  • No reclaim in sandboxd (owner decision). S3 SweepGenerations stays a no-op, as on main. Reclaiming chunks and manifests is an offline operator job: docs/deploy.md → Reclaiming S3 storage documents the key layout and the procedure. Stop every node on the prefix, build one inventory, keep the manifests live metas select, delete the rest and unreferenced chunks. Online reclamation is not supported. The dir backend keeps its own local generation sweep.
  • Peer heal. The tar stream carries each file as a size entry plus one entry per non-zero chunk, with index and SHA-256 in PAX records; the receiver writes chunks at their offsets and verifies each one.
  • transfermanager is gone: chunks are at most one part (go.mod −1).

Hot-path cost

None on the claim path or the data plane.

  • Publish into a shared lineage: 1 LIST of the lineage's markers, 2 GETs per listed record, 1 PUT per new chunk, then the manifest, the marker and the meta.
  • Fetch: 1 meta GET, 1 manifest GET and 1 GET per present chunk.
  • Delete: 1 LIST, a GET and PUT per manifest, and 2 batched deletes.
  • Metas (checkpoint listing): one GET per live record. A deleted record leaves no prefix to list.

Owner decision: no compatibility, upgrade step

Records in the old S3 layout are not readable after this change, and there is no fallback reader. Before starting the new binary on an S3 checkpoint_store, do one of these:

  • wake or drop archived claims;
  • delete checkpoints;
  • re-promote templates;
  • or point prefix at an empty location.

docs/deploy.md describes this and warns against an expiry lifecycle rule on the prefix, which would delete chunks a live record still names.

Tests

  • Sparse round trip, all-zero chunk skipped, checksum mismatch and missing chunk fail the fetch.
  • The digest of a sparse file equals the digest from a dense read.
  • Lineage dedup counts PUTs.
  • Peer heal stays sparse and rejects a damaged chunk.
  • The publish budget stays below the reuse grace.
  • A deleted record leaves no prefix to list: Metas sends no meta GET for it, and its chunks survive.
  • Delete removes the lineage marker.
  • SweepGenerations leaves the bucket untouched.
  • Every guard was mutation-checked.

Hardware acceptance

Two bare-metal nodes (one cell, PostgreSQL registry, S3 via SeaweedFS 4.48, cocoon v0.7.3, rt:24.04, 10 GiB disk). The stack head contains this branch's final code; later commits on this branch are docs only.

main this branch
bytes put by one archive (50 MiB of random data, 1 GiB memory) 11.27 GB 839 MB (57 PUTs: 54 chunks, manifest, marker, meta)
cross-node wake from that archive, first exec on the adopting node 7.1–7.3 s 0.71 s
second user checkpoint of the same claim after a 4 MiB rewrite full 403 MB (48 %; memory changes 21 of 31 chunks, disk 3 of 20)
bare-metal e2e on the S3 store 73.9 s 21.5–23.0 s
  • Delete: the record prefix, labels and lineage marker are gone. The manifest is re-stamped, the lineage's 80 chunk keys are unchanged (key, size, mtime), and both nodes stop listing the checkpoint.
  • No reclaim: over the whole run (2,757 S3 requests through a logging proxy), every DeleteObjects key was a meta.json, a labels.json or a lineage marker, with 0 under chunks/ or manifests/. All 1,739 chunk and manifest keys ever seen were still present at the end.
  • Listing: with replication on top (Running claims survive node loss: periodic snapshot replication behind a node lease #366), the record prefixes stayed equal to the live registry rows across 11 rounds, and GET /v1/checkpoints stayed at 0.15–0.20 s.

Review

CMGS added 20 commits October 18, 2026 02:00
An export file is now a manifest of 16 MiB chunks keyed by SHA-256. Upload walks the data segments with SEEK_DATA/SEEK_HOLE, skips holes and all-zero chunks, and hashes each chunk once for both its key and the record digest. Fetch sizes the file and writes only the present chunks, so a mostly-hole cow.raw stays sparse on the fetching node and no longer travels at its logical size. The record digest is unchanged for the same content. Records written in the old layout are not readable.
Chunks live under chunks/<lineage>/<sha256>. A publish lists its lineage once and uploads only chunks the lineage lacks; the lineage defaults to the record id and a claim's captures pass the sandbox id so consecutive snapshots share their unchanged chunks. Delete removes a record's manifests and meta. SweepGenerations, a no-op until now, drops superseded manifests idle past the grace and then chunks that no manifest names and that are older than the grace, so an in-flight publish's fresh chunks survive. Publish gains a PublishOption and SweepGenerations a context; the dir backend ignores the option.
A record pulled for peer heal no longer streams holes. Each export file is a size entry followed by one tar entry per non-zero chunk carrying its index and SHA-256; the receiver sizes the file, writes the chunks at their offsets and fails the pull on a checksum mismatch. Endpoint and auth are unchanged.
Mutation checks showed the sparse tests passing without a trailing hole, a repeated extent in one chunk or a young manifest beside an old meta. Each now has a case that fails when its guard is removed.
A publish used to trust the lineage chunk listing, so it could skip uploading an unreferenced chunk that a concurrent sweep was about to delete. It now reads the committed manifests of the lineage and reuses only the chunks they name; any other chunk is uploaded again. Accepted residual: a sweeper that listed an old unreferenced chunk can still delete the fresh re-upload of the same key between its list and its delete, which needs a content repeat and a concurrent sweep.
A record deleted between a publish's reference read and its manifest upload used to lose the chunks the new record dedups. Delete now removes only meta.json and the labels and re-stamps the generation manifests; the sweep counts a manifest without a meta as a reference until it has been idle for the grace and frees its chunks only afterwards. A fetch that already selected the record also survives its deletion.
A re-publish between the sweep's listing and its meta GET left the listing's stale mtime in charge, so the sweep took the new generation for an old one and deleted chunks it dedups. The age now comes from the GET response that also names the current generation.
A publish into a shared lineage listed every record of the namespace and read two objects per record. It now writes a zero-byte marker lineages/<lineage>/<id> before the commit and reads only the records the markers name: one LIST plus two GETs per record of the lineage. The sweep removes a marker once its record has neither meta nor manifests and the marker is idle for the grace.
The checkpoint and template views of an s3 store share one prefix and the s3 sweep already reads both id namespaces, so sweeping the template view repeated the whole listing and widened every race window. The template view is skipped when the checkpoint store is shared.
Publish and PublishDigested take a lineage string instead of a functional-options set. s3.go places its type blocks directly under the Store methods and every utility after them, and no local shadows the path package. ScanFile reads with ReadAt, the zero-length manifest slice literal and a wrong comment tail are gone, and the WriteChunk cases carry an index field instead of branching on the case name.
When a record's meta disappeared between the sweep's listing and its meta GET, the sweep skipped the record's manifests, so a chunk another record of the lineage had just reused from them was deleted. The listed manifests now still count as references and are not removed, leaving reclamation to a later sweep.
Dedup safety relies on a publish finishing within store.GenerationGrace
after the record it reused chunks from is soft-deleted. Without a bound, a
slow publish could commit a manifest naming a chunk the sweep already
deleted, and the hole would spread to every later capture through the
manifests the dedup set is built from.

Run publish under the budget fetch already uses, renamed transferBudget:
a record that cannot be uploaded in that budget could not be fetched within
it either, and the budget stays below the grace so the reuse window always
covers a publish.
… Delete

Delete removes only meta.json and the labels, so a soft-deleted record kept
its <prefix><id>/ prefix (its manifests) for the grace plus up to one sweep
period. The delimiter listing returned every such prefix and Metas paid a 404
meta GET for each; with replication publishing a checkpoint every interval
and soft-deleting the previous one, GET /v1/checkpoints, the TTL sweep and
restart reclaim scaled with (grace / interval) x replicated claims.

Manifests move to <prefix>manifests/<id>/<gen>.json, beside chunks/ and
lineages/, so the record prefix exists exactly while the record is live and
the id pattern never matches the manifests/ prefix. Sweep semantics are
unchanged: manifests of a live record are references, manifests of a vanished
record stay references until idle past the grace and are then doomed, and
chunks are freed only when no non-doomed manifest names them.

Delete also removes the record's lineage markers at once, reading the lineage
from the manifests it already refreshes. Markers only feed the dedup set,
which resolves each marker through the record's meta, and the sweep's marker
reclaim; chunk references come from manifests alone. The sweep's stale-marker
pass stays for crash residue.

The first capture after a wake uploads every chunk again because the wake
deletes the record it restored from; deploy.md says so.
sandboxd no longer garbage-collects the S3 store: SweepGenerations is a no-op
again and the sweep, its inventory and the stale-marker pass are gone.
Delete keeps its soft semantics (drop meta, labels and the lineage markers,
re-stamp the manifests so their LastModified is the delete time), which is what
makes an external reclaim safe against in-flight publishes together with the
30 minute publish bound.

deploy.md documents the key layout, what sandboxd never deletes, the safe
reclaim rule, the invariant behind it and the cost of not running one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

store/s3: sparse-aware, chunked, content-addressed checkpoint transfer

1 participant