docs: propose the S3 versioning design (per-key version tree) - #49
Conversation
alanshaw
left a comment
There was a problem hiding this comment.
I think I need to understand properly how this all works before I read any more. I'm not sure if I'm missing something?
| - **`seq`** — a per-bucket monotonic ordinal. Its only job is *ordering* (recency). Internal; | ||
| never leaves the server. | ||
| - **`version_id`** — a string, the S3 client handle (`x-amz-version-id`). Its job is *identity*. | ||
| It is opaque, and for suspended/unversioned writes it is the literal string `"null"`. |
There was a problem hiding this comment.
Agreed, and it's AWS's rule: S3 fixes the null version's id to the literal string null, and the conformance suite asserts the exact echo (Versioning_PutObject_suspended_null_versionId_obj). The consolation in this design is containment: the null version costs one string compare plus one uint64 on the leaf (NullSeq), where the composite-key scheme let it leak into the key encoding.
| type VersionNode struct { | ||
| Seq uint64 `cborgen:"s"` // per-bucket ordinal; ordering only, never exposed | ||
| VersionID string `cborgen:"v"` // client handle: "null" or a ULID token (§3) | ||
| Manifest cid.Cid `cborgen:"m"` // ObjectManifest CID |
There was a problem hiding this comment.
Can we just inline this in the current node? Does it need to be separate?
There was a problem hiding this comment.
Inlining the manifest into the leaf would break invariant 5 (a version's identity never changes). The manifest CID is the version's stable handle: an overwrite pushes that same CID into the prev tree, promotion pulls it back, and nothing is rewritten. The reference index (§8) counts per-version claims on the strength of that stability. Inlined, a displaced manifest would need re-serializing as its own block, which mints a new CID, and the full blob list would sit on a block that every read and list fetches. §2.1 now spells this out (606460e).
| // its manifest. | ||
| type VersionNode struct { | ||
| Seq uint64 `cborgen:"s"` // per-bucket ordinal; ordering only, never exposed | ||
| VersionID string `cborgen:"v"` // client handle: "null" or a ULID token (§3) |
There was a problem hiding this comment.
Below we say that Version ID comes from the manifest - why is it then repeated here?
There was a problem hiding this comment.
Good catch. That sentence was meant to be scoped to prev-tree entries (their MST value is a bare manifest CID, so for them the id lives only in the manifest), and as written it contradicted this field. The copy here is deliberate: it lets the leaf answer questions without fetching a manifest. The write rule decides retain-or-discard from displaced.VersionID (§5.2), and resolution short-circuits on Current.VersionID / Current.Seq (§6.1). Manifests are immutable, so the copy can't drift. 606460e rescopes §2.2 and adds the rationale to §2.1.
| order-preservingly* — no composite-string escaping. Valid under `mst.IsValidKey` (hex is | ||
| UTF-8, NUL-free, short). | ||
| - **value** — the version's `ObjectManifest` CID. `Seq` is recoverable from the key; | ||
| `VersionID` comes from the manifest (§2.3), which every consumer of a prev entry fetches |
There was a problem hiding this comment.
Hmm, I'm not clear on how this works - how do you get a particular version if the version ID is not in the key?
There was a problem hiding this comment.
The token carries its own position: a numbered version id is a ULID whose low 64 entropy bits are the version's seq (§3). A version-scoped read parses the token, checks Current.Seq, and otherwise seeks prev.Get(revSeqKey(seq)) directly, O(log n), no scan. It then fetches the manifest and confirms the stored VersionID equals the full token; any well-formed ULID parses, so without that check a token this bucket never minted could land on a real seq and serve a version the caller never named (§6.1). versionId=null is the one token with no position inside it, which is what the leaf's NullSeq field is for. The doc buried all of this in §3 and §6: 606460e adds an end-to-end walkthrough at the top of §2 so the mechanism lands before the data structures.
|
|
||
| ```go | ||
| Seq uint64 `cborgen:"sq"` // the version's ordinal (== leaf/prev position) | ||
| VersionID string `cborgen:"vi"` // "null" or the ULID token; "" only in pre-versioning blocks |
There was a problem hiding this comment.
These are both repeated in VersionNode?
There was a problem hiding this comment.
Yes, and each side has a job. The manifest is the authoritative, self-contained record: prev entries are bare manifest CIDs (§2.2), so for a noncurrent version the manifest is the only place its id exists. List rendering reads it there, the §6.1 check verifies against it, and promotion (§7.2) rebuilds Current from it. The VersionNode copies exist so leaf-only decisions skip the manifest fetch (see the thread on line 51). Manifests are immutable, so the two can't drift: the copy is a cache, and the manifest stays the record. §2.1 and §2.3 now say this in the doc (606460e).
… front Review response for PR #49: add a §2.0 end-to-end lookup walkthrough (including what the token-verify guard protects against), document why VersionNode caches two manifest fields and why the manifest stays a separate block, and scope the §2.2 'id lives in the manifest' claim to prev-tree entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Fair, and the doc's ordering caused it: the data structures (§2) landed before the mechanism that motivates them (§3's seq-in-the-token, §6.1's resolution). 606460e restructures:
The short version: the top MST still maps plain object keys, now to a small per-key leaf. The leaf holds the current version inline, so plain reads and lists cost one extra block and never touch history. Old versions live in a per-key sub-MST keyed newest-first by seq. A version id is a ULID with the seq embedded, so version-scoped reads seek straight to their target. Happy to walk through it live if that's faster. |
… front Review response for PR #49: add a §2.0 end-to-end lookup walkthrough (including what the token-verify guard protects against), document why VersionNode caches two manifest fields and why the manifest stays a separate block, and scope the §2.2 'id lives in the manifest' claim to prev-tree entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8f088b9 to
dc8d099
Compare
| iterates newest-first because its keys are inverted seqs. | ||
|
|
||
| **Space** (§8). Each version claims its body digests in `blob_refs`. Removing a version | ||
| releases only that version's claims, and the physical bytes go only when the last claim goes, |
There was a problem hiding this comment.
Just checking this isn't refering to Piri and removing a blob only when it is removed from all spaces it is allocated in. This is Ingot tracking blobs and actually performing /blob/remove only when no objects (or versions of an object) in the bucket refer to it?
There was a problem hiding this comment.
Yup, that's right. There are two layers of refcounting:
- ingot's
blob_refscounts claims per (space,digest) across all objects and versions in the bucket, and only at zero does ingot invoke/blob/removethat releases the space's claim on Piri. - piri's own cross-space accounting before physically deleting bytes (here).
I'll add a clairifcation that RemoveBlob in ingot means: "release this space's claim via /blob/remove and not physically delete".
| tree and promotion pulls it back; nothing is rewritten, and the reference index (§8) counts | ||
| claims per version on the strength of that stability. An inlined manifest would have to be | ||
| re-serialized as a standalone block when displaced, which mints a new CID, and it would put the | ||
| full blob list on a block that every read and list fetches. |
There was a problem hiding this comment.
and it would put the full blob list on a block that every read and list fetches.
You need it anyway for those responses. You cannot list objects in a bucket with just the data that is defined in the ObjectLeaf.
There was a problem hiding this comment.
I think this design should obviate this? https://github.com/fil-forge/ingot/pull/49/changes#r3730655300
| version's stable identity (invariant 5, §2.4). Displacement pushes that same CID into the prev | ||
| tree and promotion pulls it back; nothing is rewritten, and the reference index (§8) counts | ||
| claims per version on the strength of that stability. An inlined manifest would have to be | ||
| re-serialized as a standalone block when displaced, which mints a new CID, and it would put the |
There was a problem hiding this comment.
An inlined manifest would have to be re-serialized as a standalone block when displaced, which mints a new CID
I'm just saying for every key read we've gone from 1 block lookup to 2.
i.e.key -> ObjectManifest to key -> ObjectLeaf -> ObjectManifest for ALL buckets and all keys versioned or not. Further, I imagine the majority will not be versioned.
"minting" a CID is deterministic, so unless we're going to mutate the manifest, does it matter if we store a manifest block when we "revise" (aka displace) an object?
There was a problem hiding this comment.
Yeah, fair and I think you've already solved this below via the discriminator suggestion. So we'll adopted both together:
- the top-MST value is now a union.
- A key holding exactly one version stores the bare
ObjectManifestCID so one block per read - It upgrades to an
ObjectLeafon its firstrevisionsupersession(?), keeping the leaf from then on. - Unversioned buckets (the majority case, as you say) never pay the indirection; only keys that actually accumulate versions do, and those need the leaf hop anyway to route the seq lookup.
The price is a two-case decode at each read/write site, which I think is worth it.
| in place and reset any persistent dev DB"), there is **no migration**: existing dev buckets are | ||
| reset. Readers must **not** type-sniff values: cbor-gen's map decoders skip unknown fields and | ||
| zero-fill missing ones, so decoding a manifest block as an `ObjectLeaf` *succeeds* and yields | ||
| garbage. The deployment declares the format; nothing detects it from bytes. |
There was a problem hiding this comment.
We should use a version discriminator on our key values to allow upgrades to this format in the future without duck typing or re-writing all data and continue to support older versions.
e.g.
type ObjectLeaf union {
| ObjectLeafV0 "/objectleaf/0"
| ObjectLeafV1 "/objectleaf/1"
} representation keyed
type ObjectLeafV0 struct {
current VersionNode
prev Link
nullSeq int
}
type ObjectLeafV1 struct {
// completely different to v0
}
There was a problem hiding this comment.
Adopted. Outline here: https://github.com/fil-forge/ingot/pull/49/changes#r3730655300
| the authoritative record, and the `VersionNode` fields are a two-field cache of it. | ||
|
|
||
| **Why `Manifest` is a pointer rather than an inlined manifest:** the manifest CID is the | ||
| version's stable identity (invariant 5, §2.4). Displacement pushes that same CID into the prev |
There was a problem hiding this comment.
Can we use a different word to displacement? "Revision" maybe?
There was a problem hiding this comment.
Renamed, though I went with "supersession"/"superseded" rather than "revision": "revised" reads ambiguously about which version it names (the new one or the pushed-down one), while "superseded" can only mean the old current — and architecture.md already uses "superseded" for exactly this. Happy to switch if you feel strongly about "revision".
There was a problem hiding this comment.
Gotcha, sounds fine - it's better than displacement.
|
|
||
| **Why `Manifest` is a pointer rather than an inlined manifest:** the manifest CID is the | ||
| version's stable identity (invariant 5, §2.4). Displacement pushes that same CID into the prev | ||
| tree and promotion pulls it back; nothing is rewritten, and the reference index (§8) counts |
There was a problem hiding this comment.
What does "promotion" mean? Is this just retrieving a version? We don't ever actually move previous versions back to current right?
There was a problem hiding this comment.
We do move one back: deleting the current version by versionId makes the newest remaining prev entry current again — S3 semantics; removing the latest version re-exposes the next-newest. "Promotion" is that move, nothing else. I'll update the doc to make this clearer.
| ### 5.2 Displacement | ||
|
|
||
| With `displaced` = the existing `leaf.Current` (if the key exists) and `new` = the incoming |
There was a problem hiding this comment.
| ### 5.2 Displacement | |
| With `displaced` = the existing `leaf.Current` (if the key exists) and `new` = the incoming | |
| ### 5.2 Revisioning | |
| With `revised` = the existing `leaf.Current` (if the key exists) and `new` = the incoming |
There was a problem hiding this comment.
Applied via the rename thread above, with "Supersession" in place of "Revisioning".
… front Review response for PR #49: add a §2.0 end-to-end lookup walkthrough (including what the token-verify guard protects against), document why VersionNode caches two manifest fields and why the manifest stays a separate block, and scope the §2.2 'id lives in the manifest' claim to prev-tree entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
b54b8f9 to
2bc7388
Compare
|
|
||
| - **A bare `ObjectManifest`** — the key holds exactly one version. One block serves the whole | ||
| read (the manifest carries `Seq`/`VersionID`, §2.3, so even version-scoped requests resolve | ||
| against it directly), and most keys never leave this form. |
There was a problem hiding this comment.
Personally I'd also envelope this as well like { "/objectmanifest/0": <ObjectManifest fields> } so you gain the ability to upgrade the format of a manifest over time as well.
You can also keep your manifest/leaf types separate like:
type Value struct {
Manifest *ObjectManifest `cborgen:"/objectmanifest/0"`
ManifestV1 *ObjectManifestV1 `cborgen:"/objectmanifest/1"`
Leaf *ObjectLeaf `cborgen:"/objectleaf/0"`
}...or, leave it as is and add it when we need it.
There was a problem hiding this comment.
Adopted in full via 43dfdf9. Every value block is now the keyed union, manifests under /objectmanifest/0 and leaves under /objectleaf/0, with the codec generated from essentially the struct you sketched.
…union Per review on #49: every catalog value block is now the keyed union — a manifest stores under "/objectmanifest/0", a leaf under "/objectleaf/0" — so both arms' formats can be revised independently (a new format takes a new key; old and new blocks coexist under one reader, no rewrite pass). Manifest blocks carry the union key everywhere they appear, as a value block and as a prev-tree entry alike, so a version's identity CID names one encoding. cbor-gen generates the union codec from omitempty pointer arms; because its decoder skips unknown map keys silently, thin strict wrappers supply the loud unknown-key failure the format requires. §10's compatibility story follows: pre-union bare blocks are rejected, dev buckets reset, and format revisions from here need no reset at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The implementable spec for S3 bucket versioning, built on the per-key version-tree leaf proposed in the PR #2 architecture review (r3440362078), which supersedes architecture.md §3's composite-key invertedVersionId encoding: seq (ordering, internal) and version_id (identity, "null" or a ULID token) are separate fields, so the null version's replace-in-place semantics fall out structurally instead of via sentinels. Covers the data model (ObjectLeaf / prev sub-MST keyed by inverted seq), the token grammar, the write rule per versioning state, read/delete/list semantics pinned against the versitygw conformance suite, reference-index interplay, and a file-by-file implementation map. Design doc only — the implementation lands in a follow-up PR once this is ratified. (The design has been validated end-to-end by a prototype in this worktree: unit suite plus the upstream versioning conformance categories against the live smelt stack.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… front Review response for PR #49: add a §2.0 end-to-end lookup walkthrough (including what the token-verify guard protects against), document why VersionNode caches two manifest fields and why the manifest stays a separate block, and scope the §2.2 'id lives in the manifest' claim to prev-tree entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retitle to 'S3 versioning in Ingot', replace the design-history section with a 'How versioning works' overview (structure, seq vs version_id, null version, writes, reads with the token-verify walkthrough, lists, reference counting), and remove version-relative language throughout so the doc describes the system as designed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Version-scoped DELETE of an unknown id is an idempotent success no-op (pinned by upstream conformance), token parsing is strict ULID, GC candidates cover manifests and leaves but not MST interior nodes, and the implementation map gains the listversions.go / DeleteBucket-guard / delete-preconditions rows. List sections note the delimiter manifest cost and version-id-marker validation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Point §3's versioned-storage section at docs/s3-versioning.md, replace the composite-key bullet and manifest diagram with the ObjectLeaf shape, update the schema comment, and drop the resolved open question and the versioning entry from the deferred list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopt the review's value-union suggestion: a single-version key stores its bare ObjectManifest CID (the pre-versioning one-block layout), and a key gains an ObjectLeaf, wrapped in a "/objectleaf/0" keyed envelope, at its first supersession. Unversioned keys never pay the leaf indirection, and the envelope makes the format self-describing, so future revisions take a new key instead of a data reset. Also from the review: drop the incorrect claim that re-serializing an inlined manifest mints a new CID (cbor-gen encoding is deterministic), restate the pointer-vs-inline tradeoff as leaf weight, rename displacement to supersession, clarify that RemoveBlob releases the space's claim while Piri owns physical deletion, and point promotion's first use at §7.2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…union Per review on #49: every catalog value block is now the keyed union — a manifest stores under "/objectmanifest/0", a leaf under "/objectleaf/0" — so both arms' formats can be revised independently (a new format takes a new key; old and new blocks coexist under one reader, no rewrite pass). Manifest blocks carry the union key everywhere they appear, as a value block and as a prev-tree entry alike, so a version's identity CID names one encoding. cbor-gen generates the union codec from omitempty pointer arms; because its decoder skips unknown map keys silently, thin strict wrappers supply the loud unknown-key failure the format requires. §10's compatibility story follows: pre-union bare blocks are rejected, dev buckets reset, and format revisions from here need no reset at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
43dfdf9 to
084567d
Compare
The implementable spec for S3 bucket versioning, built on the per-key version-tree leaf proposed in the PR #2 architecture review (@hannahhoward's design-comment), which supersedes architecture.md §3's composite-key invertedVersionId encoding.
Covers the data model (
ObjectLeaf/ prev sub-MST keyed by inverted seq), the token grammar, the write rule per versioning state, read/delete/list semantics pinned against theversitygwconformance suite, reference-index interplay, and a file-by-file implementation map.Design doc only — the implementation lands in a follow-up PR once this is ratified. (The design has been validated end-to-end by a prototype in this worktree: unit suite plus the upstream versioning conformance categories against the live smelt stack.)