feat(s3): implement bucket versioning (per-key version tree) - #66
Conversation
0257c1b to
ef47138
Compare
ef47138 to
783e138
Compare
783e138 to
f7a9029
Compare
There was a problem hiding this comment.
Pull request overview
Implements S3 bucket versioning using the per-key version-tree (“ObjectLeaf”) data model: object keys map either directly to a single manifest CID (bare form) or to an enveloped leaf that tracks current + prior versions via a per-key MST. This wires version-id handling (ULID tokens + "null"), version-scoped reads/deletes, delete markers, ListObjectVersions, and the registry/state + persistence needed to allocate per-bucket version ordinals.
Changes:
- Add versioning core logic in
s3frontend(version-id grammar, commit write rule, version resolution, version-scoped delete, andListObjectVersions). - Extend registry + migrations to persist bucket versioning state and allocate per-bucket version sequences.
- Add unit + integration conformance coverage for versioning behaviors and pagination/teardown semantics.
Reviewed changes
Copilot reviewed 24 out of 26 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| s3frontend/version.go | Core versioning helpers: version-id parsing, commit write rule, resolveVersion, version-scoped delete helpers. |
| s3frontend/version_test.go | In-process unit tests covering versioning write rule, resolution, delete markers, deletes, and listing semantics. |
| s3frontend/object.go | Route PUT/GET/HEAD/DELETE (and listWalk) through version resolution/write rule; version-aware blob_refs reconciliation. |
| s3frontend/multipart.go | CompleteMultipartUpload commits through versioning write rule and returns version id when configured. |
| s3frontend/listversions.go | Implements ListObjectVersions over bare/leaf forms with combined-count pagination and delimiter grouping. |
| s3frontend/copy.go | CopyObject supports copy-source ?versionId, marker handling, and version-id response shaping. |
| s3frontend/conditions.go | Precondition helper updated to treat delete-marker current as “no object” and to use version resolution. |
| s3frontend/bucket.go | Implements Get/PutBucketVersioning and versioned DeleteBucket not-empty error mapping. |
| s3frontend/backend.go | Updates unsupported-ops comment to reflect newly implemented versioning. |
| registry/stores.go | Clarifies meaning of NullVersionID now that per-version ids exist. |
| registry/registry.go | Adds bucket VersioningState, persisted in State, plus Registry APIs SetVersioning/AllocVersionSeq. |
| registry/postgres.go | Persists bucket versioning state + sequence allocation in Postgres implementation. |
| migrations/sql/00003_stores.sql | Documents schema semantics for version_id, buckets.versioning, and next_version_seq. |
| itest/versity_versioning_test.go | Adds curated upstream conformance tables for versioning categories (pass + XFail). |
| itest/versity_test.go | Runs versioning categories with a versioned S3Conf so upstream teardown deletes per-version. |
| itest/stack_test.go | Adds forgeS3ConfVersioned to enable upstream versioned teardown mode. |
| itest/README.md | Updates docs to include versioning conformance file. |
| inmem/store.go | Adds in-memory tracking of bucket versioning state and per-bucket version seq allocation. |
| go.mod | Adds github.com/oklog/ulid/v2 dependency explicitly. |
| go.sum | Updates ULID v2 sums and related entries. |
| gen/main.go | Adds bucket types to cborgen list to keep generated codecs in sync. |
| CLAUDE.md | Updates testing documentation to include versioning conformance file. |
| bucket/manifest.go | Adds Seq/VersionID fields and updates delete-marker semantics documentation. |
| bucket/leaf.go | Introduces VersionNode/ObjectLeaf, envelope codec, and exact discriminated decoding via ObjectValue. |
| bucket/leaf_test.go | Tests envelope round-trip, bare manifest decode, and unknown-envelope rejection behavior. |
| bucket/cbor_gen.go | Updates generated CBOR codecs for new/changed bucket types and fields. |
Files not reviewed (1)
- bucket/cbor_gen.go: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| seqBelow := uint64(math.MaxUint64) | ||
| if k == keyMarker && versionIDMarker != "" { | ||
| switch kind, seq := classifyVersionID(versionIDMarker); kind { | ||
| case versionKindNull: | ||
| if current.VersionID == registry.NullVersionID { | ||
| seqBelow = current.Seq | ||
| } else if leaf != nil && leaf.NullSeq != 0 { | ||
| seqBelow = leaf.NullSeq | ||
| } | ||
| case versionKindToken: | ||
| seqBelow = seq | ||
| } | ||
| } |
f7a9029 to
b998a2e
Compare
|
|
||
| t := mst.LoadMST(b.read, st.Space, st.Root) | ||
| seenPrefix := map[string]struct{}{} | ||
| walkErr := t.WalkLeavesFromNocache(ctx, from, func(k string, valCid cid.Cid) error { |
There was a problem hiding this comment.
Why is this "Nocache" but when walking the versions below we do not use "Nocache"?
There was a problem hiding this comment.
The outer walk is over the whole bucket, and it can touch way more keys than it returns. With a delimiter, a prefix hiding a million keys counts as just one result — but the walk still steps over all million leaves to get past it. If it kept every node it loaded, one List request could hold a huge slice of the bucket's tree in memory. So: Nocache.
The inner walk is over one key's versions (sub MST), and every leaf it touches counts toward MaxKeys. So it can never touch more than ~1000 leaves before stopping. On top of that, the little tree object (pt) is created inside the callback and garbage-collected the moment that key is done. It can't accumulate anything, so Nocache wouldn't change anything.
Implements docs/s3-versioning.md: the ObjectLeaf/VersionNode data model with a per-key prev MST (newest-first by inverted seq), per-bucket seq allocation under the commit lock, strict ULID version ids carrying the seq in the low 64 entropy bits, the §5 write rule behind PUT / Copy / CompleteMultipartUpload / delete-marker insertion, version resolution for reads with marker 404/405 semantics and x-amz-delete-marker emission, version-scoped deletes with promotion, DeleteObjects dispatch, per-version blob_refs claims, ListObjectVersions with pagination, resumption, and delimiter grouping, Put/GetBucketVersioning, and the versioned DeleteBucket guard. Response version ids gate on the versioning state read under the commit lock, so a racing PutBucketVersioning cannot mint an id the response then omits. Tests: unit coverage for token classification, every displacement row, prev-null eviction, promotion, per-version claim add/release, list pagination and delimiter grouping, and the bare-leaf invariant; itest gains the versioning conformance categories (pass/xfail-ratcheted, run with the versioned teardown conf) plus the never-versioned VD listing group. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With the full release chain in place — ingot /blob/remove (FIL-588), hilt#37's blob.Remove write-set grant, and smelt#19's piri blob/release + blob/reject seed delegations — every body-storing versioning row passes its bucket teardown. The unexpected-pass ratchet flagged all 40: the 7 ListObjectVersions rows, the VD listing row, and 32 Versioning rows move to the pass tables. The one remaining XFail (DeleteObject_non_existing_objects) needs object-lock-enabled bucket creation, which ingot does not model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…union Adopt the revised design of docs/s3-versioning.md: the top-MST value is a keyed union. A key holding one version stores its ObjectManifest CID directly — the pre-versioning one-block layout — and gains an ObjectLeaf, wrapped in a "/objectleaf/0" envelope, at its first retained supersession, keeping it from then on (invariant 6). Unversioned keys never pay the leaf indirection, and readers dispatch on the envelope exactly: an unknown envelope key is an error, never a zero-filled cross-type decode. bucket gains the hand-written envelope codec (EnvelopedLeaf) and the read-side dispatch (ObjectValue); commitVersion, resolveVersion, the scoped and unversioned deletes, listWalk, and ListObjectVersions all handle both value forms; displaced/displacement becomes superseded/ supersession to match the spec. The rewritten bare-key structural test now asserts the value form through ObjectValue — the old direct ObjectLeaf decode would zero-fill a manifest and pass vacuously. Verified: unit suite, go vet, make gen no-op, and the full itest conformance partition against the smelt stack. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopt the revised §2.1: every value block is the keyed union — manifests under "/objectmanifest/0", leaves under "/objectleaf/0" — replacing the hand-written envelope codec and the bare-manifest fallback. ValueUnion is cborgen-generated from omitempty pointer arms; the strict ObjectValue / EnvelopedManifest / EnvelopedLeaf wrappers require exactly one arm, so a block carrying no known union key (a newer format, or a pre-union bare manifest) fails loudly instead of zero-fill decoding. Every manifest block read/write goes through the enveloped form, keeping a version's identity CID one encoding everywhere it appears. The ListObjectVersions resumption comment now spells out why token markers need no existence check (their seq is a position; only positionless null markers fall back to re-emission). Verified: unit suite (union round-trip, unknown-key and pre-union-block rejection), go vet, stable make gen, and the full itest conformance partition against the smelt stack. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
b998a2e to
d106ce7
Compare
Implements the S3 versioning design of
docs/s3-versioning.md— this PR is stacked on the designbranch of #49: review the spec (and the
architecture.mdalignment) there, the code here.CompleteMultipartUploadcommits through the versioning write rule on top of the deferred-acceptflow (#40), with the conditional-write preconditions re-checked under the commit lock.
What's in it
(
bucket.ValueUnion): a key holding one version stores its manifest under"/objectmanifest/0"— one block, one fetch — and gains abucket.ObjectLeafunder"/objectleaf/0"(current version inline + per-key prev MST keyed newest-first by invertedseq) at its first retained supersession, keeping it from then on. Every manifest block carries
its union key — as a value and as a prev-tree entry — so a version's identity CID names one
encoding. The strict
ObjectValue/EnvelopedManifest/EnvelopedLeafwrappers requireexactly one union arm, so a block with no known key (a newer format, or a pre-union bare
block) is an error, never a zero-filled cross-type decode; either arm's format can be revised
under a new key with old and new blocks coexisting.
ObjectManifestcarriesSeq/VersionID.(
registry.AllocVersionSeq); client ids are strict ULID tokens carrying the seq in the low 64entropy bits,
"null"for unversioned/Suspended writes.PutBucketVersioning/GetBucketVersioningoverbuckets.versioning.commitVersion) behind PUT / Copy / CompleteMPU /marker insertion — supersession per state, new keys and null-over-null writes staying bare,
the single-null invariant with prev-null eviction, GC candidates for discarded manifests and
superseded leaves. Response version ids gate on the state read under the lock, so a racing
PutBucketVersioningcan't mint an id the response omits.resolveVersion— on a bare key every versionId class resolves against thesingle manifest; on a leaf key, current / null / token seek with the token-verify guard.
Marker semantics 404/405 with
x-amz-delete-marker+Last-Modifiedon the error response;NoSuchVersionerrors carry<Key>/<VersionId>.drops the key; on a leaf, promotion of the newest survivor — a leaf is never downgraded,
invariant 6),
DeleteObjectsdispatch (explicitDeleteMarker: falsefor non-marker versiondeletes), versioned
DeleteBucketguard.blob_refsclaims; releases only on discard;null-replacing-null goes through the set-diff so unchanged digests never churn.
listWalkmarker skip (one manifest fetch per bare key, leaf + manifest persuperseded key);
ListObjectVersionswith combined-count pagination, marker-pair resumption,and delimiter grouping.
itest/versity_versioning_test.go—PutBucketVersioning,GetBucketVersioning,ListObjectVersions,Versioning, and the never-versionedListObjectVersionsVDlisting group, curated into the pass/XFail ratchet. The versionedcategories run with
WithVersioningEnabledso upstream teardown empties buckets per-version.Verification
make build,go vet ./...andgo vet -tags itest ./itest/,gofmt -lclean,go mod tidy -diffclean, andmake genis a no-op on the committedcbor_gen.go(theunion codec is generated; only the thin strict wrappers are hand-written).
GOWORK=off go test ./... -count=1green — 18 versioning tests covering tokenclassification, every §5.2 supersession row, first-supersession leaf creation, prev-null
eviction, promotion (and that the leaf survives it), per-version claim add/release on the
refindex harness, pagination, delimiter grouping, and the manifest-arm form of unversioned
keys asserted through
ObjectValue; 3 codec tests pinning both arms' round-trips and therejection of unknown union keys and pre-union bare blocks.
chain in place (ingot
/blob/removefrom feat: deferred-accept multipart (FIL-520) + network blob removal on DeleteObject (FIL-588) #40, hilt#37'sblob.Removewrite-set grant,smelt#19's piri
blob/release+blob/rejectseed delegations), the versioning partitionis 52 pass rows with a single XFail (
DeleteObject_non_existing_objects, which needsobject-lock-enabled bucket creation that ingot does not model).
Follow-ups (noted, out of scope)
gc_candidates, matching existingtop-MST practice (spec §5.3 documents the policy).
ListObjectVersionsshares V1listWalk's exact-fillIsTruncatedquirk.supersede()helper fromcommitVersion; haveprevHeadreturn the tree key so promotion uses the key-derived seq instead of the manifest copy.
Closes (together with #49) https://linear.app/filecoin-foundation/issue/FIL-585
🤖 Generated with Claude Code