lore-aws: Carry fragment metadata on the S3 object - #157
Open
mjansson wants to merge 3 commits into
Open
Conversation
mjansson
force-pushed
the
proto/s3-header-metadata
branch
12 times, most recently
from
August 4, 2026 08:05
f83be1e to
86bfd2d
Compare
The AWS store keys S3 objects by the content hash while the object holds one representation of that content. Two writers may hold different valid representations of the same content and both address the same key, and the fragment describing which one is stored lives in a separate DynamoDB record. Two independently written records are required to agree, and they can fail to: concurrently, when two writers publish different fragments for the same key, or after a lost metadata write. Either way the payload cannot be decompressed, no retry fixes it, and nothing notices until a read fails. Store the fragment as S3 object metadata on the object holding the payload, as x-amz-meta-lore-fragment: <flags hex>:<size_payload>:<size_content>. Object metadata is part of the object version, so a GetObject returns headers and body from the same version and a full-object PUT is atomic. Last-writer-wins becomes safe: the disagreement is removed rather than policed, and no S3 or DynamoDB feature beyond the plainest ones is needed -- no conditional S3 write, no entity tag compare-and-set, no object-age heuristic, no transaction. The DynamoDB fragment metadata table becomes a fragment state table: row presence means the hash exists, plus obliteration state. That keeps the existence probe a single GetItem with no S3 request, which is what makes cross-partition deduplication possible at no additional cost -- previously lookup short-circuited so put resolved to a full triplet match or an upload, and content already durable in one partition was uploaded again for another. Consequences worth knowing. Query is answered from DynamoDB alone, because it runs once per fragment stored on the ingress path; it therefore reports whether a payload is durable rather than what representation is stored, and reading a representation moves to a new ImmutableStore::get_metadata. Get drops a DynamoDB read. A payload that is lost while still referenced is now counted, logged, and made recoverable by clearing its state row, which is only safe because that row carries no representation. Obliteration discharges its compliance obligation in one atomic DeleteItem and drains before counting so an in-flight put is counted rather than lost. Rollout is a full stop followed by a full start, with no mixed fleet and no clean rollback once writes are served. dynamodb_fragment_state_table is required; the older dynamodb_metadata_table spelling carries over as an alias for dynamodb_fragment_metadata_table, which gates the fallback read for objects predating the change. ImmutableStore::get_metadata is required rather than defaulted. A default delegating to query is right for a store whose query reports the representation, and silently wrong for a wrapper that forwards query alone -- the wrapper answers, the inner override never runs, and the caller gets a well-formed fragment with no sizes and no error. Requiring it makes that a compile error instead. Composite resolves local then durable, without the replica fan-out query does, since a representation is the same wherever it is read from. StoreResult stops returning a fragment for the same reason. A dispatched write reports before its leader compresses, so the only representation it could name is the one the caller passed in, and that is what it handed back on every path but the inline one. It now reports what the caller cannot know: the content size, and whether the payload is stored locally and durably. Write wrappers down to store_raw_local return an address alone; write_from_file also returns the content size, taken from the read that fed the hash so it agrees with the address, which stating the file again after the write does not. TrackedResult carries nothing, since no one read the fragment a leader or follower yielded. The write observer still takes a fragment -- the FragmentWrite event classifies on the payload flags and reports the payload size -- but assembles it from the caller's input rather than from the result. The state serialization trace loses its byte figures, the one place a stored representation reached a reader. Integration tests cover the migration read path against a real MinIO and DynamoDB: an object stored the way that era stored it -- bare bytes, fragment in a row -- reads back intact when the fragment metadata table is configured, and is reported as damaged when it is not. Both fail if the fallback is removed. Design and migration: docs/proposals/2026-08-03-fragment-metadata-on-the-s3-object.md Decision and alternatives: ADR-00018, which supersedes ADR-00006. Signed-off-by: Mattias Jansson <mjansson@gmail.com>
mjansson
force-pushed
the
proto/s3-header-metadata
branch
from
August 4, 2026 09:25
86bfd2d to
2f734cd
Compare
The comment saying the remote's metadata operation was not yet wired through survived the change that wired it through, and now sits directly above the comment describing what the method actually does. Signed-off-by: Mattias Jansson <mjansson@gmail.com>
FibHeap
approved these changes
Aug 4, 2026
|
Imported as Lore CR-288. |
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.
Problem
The AWS store keys S3 objects by the content hash while the object holds one representation of that content. Two writers may hold different valid representations of the same content (LZ4 and Zstd of the same bytes) and both address the same key. The fragment describing which representation is stored lives in a separate DynamoDB record, so two independently written records are required to agree. They can fail to:
Both yield a payload that cannot be decompressed, reported as an internal size mismatch, undetected until a read fails.
Separately,
lookupshort-circuits toMatchNonewhenMatchFullis requested, soput'sMatchPartitionandMatchHasharms are unreachable — content already durable in one partition is uploaded again for another.Change
The fragment travels on the object as
x-amz-meta-lore-fragment: <flags hex>:<size_payload>:<size_content>. Object metadata is part of the object version, so aGetObjectreturns headers and body from the same version, and a full-object PUT is atomic. Last-writer-wins becomes safe; the disagreement is removed rather than policed.The DynamoDB fragment metadata table becomes a fragment state table: row presence means the hash exists, plus obliteration state. The existence probe stays a single
GetItemwith no S3 request.No S3 or DynamoDB feature beyond the plainest ones — no conditional S3 write, no ETag
compare-and-set, no object-age heuristic, no transaction. The one conditional DynamoDB write is an
attribute_not_existscreate guarding an obliteration mark.Flows
Put — probe association ∥ state, in parallel:
SLOWDOWNOKPayload buffer requiredOrdering is load-bearing: object, then state row, then association. The reverse leaves a hash claiming to exist with no bytes, unrepairable by retry because the next put takes the
already-stored branch. Every step is idempotent, so a retry converges from any interruption.
Get — association
GetItem∥GetObject. The fragment arrives on the response carrying the bytes; the size check is self-consistency on one object.Query — association ∥ state, no S3. It is called once per fragment stored on the ingress path (
query_match_full), which ADR-00008 identifies as the busiest path in the server.ImmutableStore::get_metadata— new, defaulting to aMatchFullquery; the AWS store overrides it with aHeadObject. The only path spending an S3 request purely on metadata.Obliterate — mark → delete association (compliance discharged) → drain → count → release mark, or recurse sub-fragments, delete payload, tombstone.
Costs
HeadObjectProduction code in
lore-awsgrows ~460 lines;dynamodb.rsis untouched. This is not asimplification measured in lines.
Rollout
Full stop, then full start — no mixed fleet. That removes the dual-write phase: nothing writes the old shape, and the guarantee holds from the first write. There is no clean rollback once writes are served — content written after cut-over is described only on its object.
Configuration is non-breaking:
dynamodb_metadata_tableis accepted as an alias fordynamodb_fragment_state_table. The optionaldynamodb_fragment_metadata_tablegates the fallback read for pre-cut-over objects; leaving it unset declares no such object exists, so an object with no metadata is reported as damaged rather than described from a row that cannot be about it.Testing
94 tests in
lore-aws. The fake supports fault injection across nine operations, which makes error paths reachable. Every behavioural guard was verified by removing it and confirming the test fails.Notable:
concurrent_writers_cannot_tear_the_fragment_from_its_payload(4 writers × 4representations × 64 randomized rounds), sub-fragment obliteration and its failure aggregation, the drain window, the post-upload obliteration race, and lost-payload detection and repair.
Known limitations
A lost payload (object gone, reference remains) is detected on both reads and repaired by clearing the state row; reconciling the whole population is left to the planned obliteration work table. A crashed obliteration leaves a mark that nothing clears. The obliteration drain narrows but does not close its race.
Design and migration:
docs/proposals/2026-08-03-fragment-metadata-on-the-s3-object.md. Decision and alternatives: ADR-00018, which supersedes ADR-00006.