lore-aws: Avoid rewriting S3 on fragment deduplication and fix data corruption from torn metadata/blob representation - #155
Conversation
68f26de to
3f19575
Compare
There was a problem hiding this comment.
LGTM - I agree with the jist of it. Lets chat about this to make sure.
Some nits commented below (and after a few I stopped commenting). I think all traces should use fields - especially if we ever have to enable debug then filtering them out will be much easier as we debug something at scale.
| // reclaimed and every put for this hash backs off indefinitely. Real S3 always reports | ||
| // this; an implementation that does not turns a recoverable orphan into an unwritable | ||
| // hash, which is worth saying out loud rather than leaving to look like contention. | ||
| error!( |
There was a problem hiding this comment.
We should instrument and alert on this. The hash doesn't need to be part of the label but draw our attention to the logs
There was a problem hiding this comment.
It will never fire on real S3, not worth the effort imo
| self.instruments.operation_latency_histogram.clone(), | ||
| self.instruments | ||
| .instrument_provider | ||
| .get_labels_for_operation_context("put_object_if_absent"), |
There was a problem hiding this comment.
Probably a good idea to cache the labels like what the DDB impl does
There was a problem hiding this comment.
I think that's a change to the S3Impl signature and a separate change - no S3 code currently does that.
953a693 to
04c087a
Compare
Writing a fragment uploaded its payload whenever the exact (partition, context, hash) row was absent, even when the payload for that hash was already stored. The S3 object key is the bare content hash, so that upload replaced content other partitions reference, and the object and the metadata describing it were written as two independent, unordered operations. That permitted two corruption cases. Two writers storing the same content in different representations on different partitions at once are written to S3 and DynamoDB independently, so the writer that wins S3 need not be the writer that wins DynamoDB. Every write succeeds and the stored blob ends up described by the other writer's fragment. More likely, a second partition storing content that is already stored has no exact row, so it uploads and replaces the object other partitions reference. Losing the metadata write that follows — DynamoDB throttling, or termination between the two writes, which the code already anticipated — leaves the blob and the published metadata no longer describing each other. The affected partition cannot repair that: its own re-put matches on the association and returns without writing. A put now probes the association and the payload's durability together: two strongly consistent single-item reads against different tables, one round trip. The metadata table is keyed by hash alone, so the probe does not grow with the number of partitions referencing the content, and S3 is not consulted. Content that is already stored is referenced rather than uploaded again, which removes the second case entirely — there is no upload to replace the blob and no metadata write to lose. Deduplication adopts the stored fragment, never the incoming one, and still requires the payload, so presenting bytes remains proof of possession. Content that is genuinely absent is uploaded conditionally on the key being free. That makes the object write-once, so exactly one writer can create the bytes for a hash and S3 itself arbitrates between competing writers with no external lock. Metadata is published only after that writer's own upload created the object, so the S3 winner and the metadata winner are the same writer by construction, which removes the first case. A key that is already taken means the object belongs to a writer that has not published. Its age decides what to do: younger than the abandonment threshold the caller backs off, older it is reclaimed conditionally on the entity tag just observed, making reclaim single-winner. Only a writer that displaced bytes may overwrite another's metadata after a rejected publish; a writer whose upload was conditional stands down, since its bytes may since have been reclaimed. Order obliteration so associations need no transaction Obliteration marks the metadata row, removes the reference it was asked to remove, waits for puts that read the row beforehand to finish writing, and counts what remains — clearing the mark if anything does, and otherwise deleting the payload and leaving a tombstone. A put that reads the mark backs off, so no reference can appear while the payload is being removed, and the mark precedes the removal so the partition being obliterated cannot write its own reference back. An association is therefore written plainly rather than inside a transaction conditioned on the metadata row, and no new DynamoDB permission is required. Association reads are strongly consistent, so the wait is not for consistency: it is for in-flight writes to land, and is sized above the DynamoDB request timeout. Removing the reference is what obliteration has to do; the payload and the metadata are cleanup that only applies once nothing references the content. Ordering it that way also stops sub-fragments being obliterated before the parent is known to be going away. An obliteration finding the mark already set removes its own reference and leaves the payload and the metadata to whichever obliteration holds the mark, rather than reporting success without doing anything. Only a tombstone refuses a read: a row that is merely marked still has its payload, so refusing reads there would hide live content from every partition holding it. Published metadata is authoritative and deliberately not verified on the read or deduplication paths, since checking would cost a request per put and give back what deduplication buys. A read that finds published metadata with no object behind it is counted and logged rather than surfacing as an ordinary not-found, because that state is an operational failure the store cannot repair on its own. The fragment metadata table is unchanged: no new attributes, no migration. Requires an S3 endpoint honouring If-None-Match, If-Match and Last-Modified. Both corruption cases are reproduced as failing tests on main in repro/fragment-dedup-corruption, and their counterparts here run the same scenarios with the same fault injected. Signed-off-by: Mattias Jansson <mjansson@gmail.com>
04c087a to
452a67a
Compare
|
Imported as Lore CR-280. |
peter-lockhart-pub
left a comment
There was a problem hiding this comment.
We chatted on a call and LGTM. Pre-approving re the tracing fields
Deduplicate S3 payloads across partitions, and fix two fragment write corruption cases
Summary
The AWS immutable store uploads a fragment payload whenever the exact
(partition, context, hash)row is absent, even when the payload for that hash isalready stored. The S3 object key is the bare content hash, so that upload replaces
content other partitions reference. The object and the metadata describing it are
written as two independent, unordered operations, which permits two corruption cases.
This change makes a put that finds the content already stored record a reference to it
and perform no S3 write, and makes the object and its metadata a matched pair under
concurrency and partial failure.
No transactions, no new DynamoDB permissions, and no change to the fragment metadata
table.
Root causes
Current write path (
AwsImmutableStore::put→write_payload):do_query(MatchFull)— resolves toMatchFullorMatchNoneonly.MatchFull→ return.MatchNone+ payload →PutObject(unconditional) →PutItemmetadata(unconditional) →
PutItemassociation.1. Parallel interleaved puts — low likelihood, permanent, no failure required
Two writers store the same content in different representations (for example different
compression) on different partitions concurrently. S3 and DynamoDB are written
independently, so the writer that wins S3 need not be the writer that wins DynamoDB.
The stored blob ends up described by the other writer's fragment. All writes succeed;
the result is permanently inconsistent.
2. Overwrite where only the S3 put succeeds — higher likelihood
A second partition storing content that is already stored has no exact row, so it
uploads, replacing the object other partitions reference. If the subsequent metadata
write is lost — DynamoDB throttling, or process termination between the two writes, a
case the existing code comments already anticipate — the blob and the published
metadata no longer describe each other.
The affected partition cannot repair this: its own re-put matches on the association and
returns without writing. Only a put from a third partition rewrites both sides.
Improvement
A put for content already stored writes an association only. No S3 request is issued on
the deduplicating path.
The fix
Probe
exists_exact(fragments table) andmetadata_lookup(metadata table) are issuedconcurrently: two strongly consistent single-item reads against different tables, one
round trip. The metadata table is keyed by hash alone, so probe cost does not scale with
the number of partitions referencing the content. S3 is not consulted.
Resolution
PutItemassociation onlySlowDown); the mark is transientPayloadObliteratedtombstonePayload buffer required)Deduplication adopts the stored fragment, never the incoming one. A payload is still
required, so presenting bytes remains proof of possession; ingress verifies the payload
hashes to the address.
Upload
PutObjectwithIf-None-Match: *. The object becomes write-once: exactly one writercan create the bytes for a hash. S3 provides the mutual exclusion; no external lock is
used.
object. The published fragment therefore always describes the stored bytes. A rejected
publish re-reads the row and re-conditions rather than re-uploading.
PutItemassociation.Key already present (HTTP 412)
HeadObject. Younger than the abandonment threshold → returnSlowDown;the caller retries. Older → the writer that stored it is gone.
If-Matchon the observed ETag, making reclaim single-winner, thenpublish.
Only a writer that displaced bytes (reclaim or
force_write) may overwrite anotherwriter's metadata after a rejected publish. A writer whose upload was conditional stands
down, since its bytes may since have been reclaimed.
Obliteration
Reordered so that association writes need no transaction:
partition's association and return, leaving the payload and metadata to the mark
holder.
sub-fragments, delete the payload, write the tombstone.
A put that reads the mark backs off, so no reference can appear while the payload is
being removed, and the mark is set before the association is removed so the partition
being obliterated cannot write its own reference back. Association reads are strongly
consistent, so the wait is not for consistency — it is for in-flight writes to land, and
is sized above the DynamoDB request timeout.
Removing the reference is what obliteration must do; the payload and metadata are cleanup
that applies only once nothing references the content. Sub-fragments are therefore visited
only once the parent is known to be going away.
Only a tombstone refuses a read. A row that is merely marked still has its payload, so
refusing reads there would hide live content from every partition holding it.
How this resolves the root causes
metadata is published only by that writer. The S3 winner and the metadata winner are
the same writer by construction.
no upload to replace the blob and no metadata write to lose.
Related changes
force_writerespects obliteration flags, replaces the object in place, and failsvisibly if it cannot publish.
store.immutable.missing_payload, incremented with anerror!log when aread finds published metadata with no object behind it. Published metadata is
authoritative and deliberately not verified on the read or deduplication paths; this
condition is an operational failure and is now reported rather than surfacing as an
ordinary not-found.
Flow and call comparison against
mainPut
mainGetItem, sequentialGetItem, concurrent — one round tripGetItem+PutObject+ 2 ×PutItemGetItem+ 1 ×PutItem, no S3GetItem+PutObject+ 2 ×PutItemGetItem+PutObject(If-None-Match) + 1 × conditionalPutItem+ 1 ×PutItemGetItem+HeadObject, thenSlowDownGetItem+HeadObject+PutObject(If-Match)DynamoDB write units, items under 1 KB:
mainDeduplication drops an
PutObjectand one write unit. New content is unchanged onwrites and costs one additional single-item read. Re-puts are unchanged in cost and take
one round trip instead of two.
Obliterate
mainGetItemGetItemPutItemPutItemDeleteItemDeleteItemQuery(strongly consistent)Query(strongly consistent)ListObjectVersions+DeleteObject+ conditionalPutItemSame call set plus the drain. Obliteration is rare and not latency sensitive.
Requirements on S3 and DynamoDB
S3
PutObjectwithIf-None-Match: *PutObjectwithIf-Match: <etag>HeadObjectreturningETagandLast-ModifiedBoth conditional write headers are supported by S3. An endpoint that ignores either
header, or omits
Last-Modified, degrades silently:If-None-Matchremoves the mutual exclusion and restores case 1;If-Matchremoves single-winner reclaim;Last-Modifiedprevents an abandoned object from ever being reclaimed, soputs for that hash back off indefinitely (logged at
error!).S3-compatible endpoints must be confirmed to honour all three before use. No integration
test covers this; unit tests use mocks.
No bucket configuration change is required. Object keys are unchanged.
DynamoDB
GetItem,PutItem,DeleteItem,Query,BatchGetItem,DescribeTablePutItemNo new IAM permissions. Association writes are plain puts, so
dynamodb:ConditionCheckItemis not required andTransactWriteItemsis not used bythis path.
Schema and compatibility
No table change and no migration. The fragment metadata table keeps its exact attribute
set (
hash,flags,size_payload,size_content); no attributes are added, and rowswritten by this change are byte-identical to existing rows. Asserted by
published_rows_keep_their_original_shape.Tests
Reproduction on
mainBranch
repro/fragment-dedup-corruption, one commit offmain:Both tests fail. All 71 pre-existing tests are unaffected.
concurrent_cross_partition_writers_tear_blob_and_metadata— six writers, distinctpartitions and contexts, same content hash, six distinct representations, 512
iterations on a multi-threaded runtime against an in-memory S3 and DynamoDB in which
each operation is individually atomic. No injected failures. Fails, for example:
published size_payload 69 does not match the stored blob length 66.The interleaving comes from the scheduler, so this samples a real race; reproduced on
6/6 full-suite runs at 512 iterations. A single green run does not disprove it.
a_failed_cross_partition_write_corrupts_the_first_partition— deterministic, noconcurrency. Partition 1 stores content and reads it back. Partition 2 stores the same
content in a different representation; its upload lands and its metadata write is
throttled. Partition 1, which never wrote again, can no longer read what it stored:
Failed to load from immutable store, size mismatch (load 65, expected 64).This is a real
getthrough the read path. That the read breaks is itself evidence theblob was replaced.
Verification on this branch
96 tests pass. Counterparts run the same scenarios with the same fault injected:
concurrent_writers_with_different_representations_stay_cohesive— the case 1 race;asserts the stored blob and published metadata agree and that exactly one upload occurs.
a_failed_cross_partition_write_cannot_corrupt_the_first_partition— the case 2sequence with the metadata-write fault armed. Asserts the fault is still armed
afterwards (no metadata write occurred), that exactly one upload occurred, and that
partition 1's read succeeds.
Deduplication:
test_put_immutable_deduplicates_across_partitions,test_put_immutable_full_match_writes_nothing,cross_partition_put_of_another_representation_leaves_the_blob_alone,deduplicating_writers_adopt_the_stored_representation,test_put_immutable_payload_required_to_deduplicate.Reclaim and abandonment:
test_put_immutable_reclaims_abandoned_object,test_put_immutable_backs_off_from_an_unpublished_upload,test_put_immutable_losing_the_reclaim_publishes_nothing,test_put_immutable_conditional_writer_does_not_publish_over_a_reclaimer,test_put_immutable_publish_reconditions_without_reuploading,test_put_immutable_force_write_publish_exhaustion_is_loud,abandonment_is_decided_conservatively,stored_time_keeps_sub_second_precision.Obliteration:
test_obliterate_marks_before_removing_the_association,test_obliterate_already_obliterating,test_put_immutable_force_write_respects_obliteration,test_put_immutable_discards_object_left_by_obliteration,test_put_immutable_discards_reclaimed_bytes_when_obliteration_wins,test_get_immutable_marked_for_obliteration_is_still_readable,test_get_immutable_reports_published_metadata_with_no_object.Negative controls, asserting the checks are not vacuous:
legacy_write_order_tears_blob_and_metadata,legacy_cross_partition_overwrite_tears_blob_and_metadata,without_metadata_conditions_writers_upload_over_each_other.Each behavioural guard was verified by reverting it and confirming a specific test fails.
Known limitations
and nothing clears it. Such a hash remains readable but cannot be written until an
operator intervenes. Intended to be resolved separately by a durable obliteration work
table that servers pick up at startup and at intervals; that resume path will need to
take over the mark explicitly rather than deferring to it.
while its row survives, reads of that hash fail for every partition referencing it and
writing the content again does not repair it. Reported via
store.immutable.missing_payload.