Skip to content

lore-aws: Carry fragment metadata on the S3 object - #157

Open
mjansson wants to merge 3 commits into
EpicGames:mainfrom
mjansson:proto/s3-header-metadata
Open

lore-aws: Carry fragment metadata on the S3 object#157
mjansson wants to merge 3 commits into
EpicGames:mainfrom
mjansson:proto/s3-header-metadata

Conversation

@mjansson

@mjansson mjansson commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

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:

  1. Concurrent cross-partition writers — two writers upload different representations and publish different fragments. The interleaving leaving writer A's fragment beside writer B's payload is permanent and requires no failure.
  2. A lost metadata write — a writer replaces the object, then fails to publish. The stored fragment describes bytes that are gone, and the affected partition cannot repair it: its re-put sees a full match and does nothing.

Both yield a payload that cannot be decompressed, reported as an internal size mismatch, undetected until a read fails.

Separately, lookup short-circuits to MatchNone when MatchFull is requested, so put's
MatchPartition and MatchHash arms 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 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.

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 GetItem with 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_exists create guarding an obliteration mark.

Flows

Put — probe association ∥ state, in parallel:

Probe Action
state = Obliterating SLOWDOWN
state present, associated OK
state present, not associated, payload supplied associate only, no S3
state present, not associated, no payload Payload buffer required
no state, or Obliterated upload → conditional create → associate

Ordering 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 GetItemGetObject. 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 a MatchFull query; the AWS store overrides it with a HeadObject. 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

Operation main This branch
Put, new content 1–3 DDB reads, 1 S3 PUT, 2 DDB writes 2 DDB reads (parallel), 1 S3 PUT, 2 DDB writes
Put, dedup across partitions not supported — re-uploads 2 DDB reads, 1 DDB write, no S3
Get 2 DDB reads, 1 S3 GET 1 DDB read, 1 S3 GET (parallel)
Query 2 DDB reads 2 DDB reads (parallel), no S3
Copy 2 DDB reads, 1 DDB write 1 DDB read, 1 DDB write
get_metadata 2 DDB reads 2 DDB reads + 1 HeadObject

Production code in lore-aws grows ~460 lines; dynamodb.rs is untouched. This is not a
simplification 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_table is accepted as an alias for
dynamodb_fragment_state_table. The optional dynamodb_fragment_metadata_table gates 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 × 4
representations × 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.

@mjansson
mjansson force-pushed the proto/s3-header-metadata branch 12 times, most recently from f83be1e to 86bfd2d Compare August 4, 2026 08:05
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
mjansson force-pushed the proto/s3-header-metadata branch from 86bfd2d to 2f734cd Compare August 4, 2026 09:25
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>
@mjansson mjansson added the ready-to-import Approved by Epic staff for import into Lore label Aug 4, 2026
@epic-lore-bot epic-lore-bot Bot added imported Imported into Lore for internal review and removed ready-to-import Approved by Epic staff for import into Lore labels Aug 4, 2026
@epic-lore-bot

epic-lore-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

Imported as Lore CR-288.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

imported Imported into Lore for internal review

Development

Successfully merging this pull request may close these issues.

2 participants