Repository navigation
DynamoDB relay: take the seq inside the write transaction (nxf 6j6v.r3jf) - #59
Merged
Merged
Conversation
…3jf) The DynamoDB relay allocated a stream's seq with an UpdateItem ADD before the TransactWriteItems that stores the op and its claim. With two overlapping appends (A gets 10, B gets 11, B commits first) a puller at 9 was handed [11], stored cursor 11, and never received A: a permanent miss on ordinary replicas, and a fold watermark that passes an unfolded op. Each append round now reads the counter (strongly consistent Query) and sends ONE TransactWriteItems: SET next_seq = n conditioned on the value read, the tm4k claim, and the op at n. Seq n+1 cannot commit before n, so commit order is seq order, as on SQLite and Postgres. A lost round retries from the counter the failed check returns (ReturnValuesOnConditionCheck- Failure = ALL_OLD), after a capped random pause; contention rounds are bounded by CONTENTION_ROUNDS = 256, all other failed rounds by APPEND_ATTEMPTS = 32, each with a clear error. A taken claim outranks a moved counter, so a re-push still answers with its stored seq and now consumes no seq. Three items per transaction. Wire protocol, table contract and IAM actions are unchanged. Tests: a deterministic reproduction on DynamoDB Local (a before-write hook holds append A between choosing its seq and writing while B commits and a reader polls; it failed before this change), N writers with concurrent pullers seeing every op once in gap-free seq order, and the tm4k idempotence script now asserting density on DynamoDB as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016q5uMUkZJQW4fckJoA3FME
…nxf 6j6v.r3jf) Mitigates the independent review of PR #59. - Every append past a stream's first op now carries a ConditionCheck on the op at seq-1 (attribute_exists). Transactions are serializable against each other, but a Query is only read-committed: without the check, T(n+1) could commit while T(n)'s op was still invisible. A failed check with the counter condition holding marks a legacy gap; the next round, for that seq only, checks attribute_not_exists instead, so the item stays in the transaction and a writer of it still conflicts. Needs dynamodb:ConditionCheckItem (IAM CHANGED, required before deploy). - Per-push deadline (PUSH_DEADLINE 20 s, NXF_RELAY_PUSH_DEADLINE_SECS), checked between ops, between rounds and against the round in flight; answered 503 (OpStore::append_batch, default method). - A push carries the seq it wrote forward as the next expected counter; SeqTaken finds the top occupied seq with one reverse Query and keeps the counter known; contention budget counts losses in a row; checked_add against overflow; TxLayout decodes reason indexes. - with_before_write moved behind a test-seams feature (dev-dependency of the package on itself). - Tests: predecessor check blocks a commit over a missing op, mixed ADD-relay upgrade, re-push that lost the counter race, run of occupied seqs, carried counter, push deadline (store and HTTP 503), dense o3, floor pinned, ALL_OLD hint, reason mapping, jitter spread. - Module doc separates what AWS documents from what the design builds on; spec section 4.1 and section 2 row 1, E4, relay guide (en/de) and the changelog fragment (with a migration note) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016q5uMUkZJQW4fckJoA3FME
Member
Author
Mitigation of the review (
|
| Finding (source · severity) | Disposition | Notes |
|---|---|---|
CI red: clippy type_complexity in scripted() (Code #1 · High) |
✅ Fixed | Rounds/Answer type aliases; diff scanned for other clippy-prone patterns (redundant closure, lazy then, complex test-helper returns → aliases/boxed hook). Clippy not run locally; CI decides. |
| No gate evidence on this head (Test #1 · High) | ✅ Fixed (pending CI) | Full DynamoDB Local suite 37/37 locally on 3.3.1 (4 runs); CI runs amazon/dynamodb-local:latest on 82d97b2. |
| Visibility relies on undocumented ordering (Integrity #1 · High) | ✅ Fixed | Owner decision: ConditionCheck attribute_exists on op s−1 (s>1); on a proven legacy gap the next round, same seq only, checks attribute_not_exists (item stays in the tx). Module doc separates AWS-documented facts from design. IAM +dynamodb:ConditionCheckItem, required before deploy. |
| Module doc states premise as fact (Code #7 · Info) | ✅ Fixed | Rewritten ("What AWS documents" / "What the design builds on"). |
| Spec §4.1 / §2 row 1 / test comment stale (Code #2 · Medium) | ✅ Fixed | §4.1 rewritten to what is built (Query, :expected, carry, predecessor check, IAM, budgets, deadline, rejected alternatives); row 1 cites symbols at 130c048 and is marked reproduced; dynamodb_local.rs legacy-test comment fixed; E4 updated. |
| Contention budget cumulative but called "in a row"; per-append ceiling undocumented (Code #3 · Medium) | ✅ Fixed | lost resets on any non-Lost outcome (test the_contention_budget_counts_lost_rounds_in_a_row); ceiling documented on CONTENTION_ROUNDS; "one winner per round" claim removed. |
| No bound on push time (Integrity #2 · Medium) | ✅ Fixed | PUSH_DEADLINE 20 s (NXF_RELAY_PUSH_DEADLINE_SECS), checked between ops/rounds and against the round in flight → 503. |
| Contention unmeasured on AWS, cost understated (Integrity #3 · Medium) | ⏭️ Filed 6j6v.wv9e + partly fixed |
No AWS access here. Cost model (cancelled tx consume capacity, ~N× per op, ~30–60/s ceiling) in module doc, spec, PR. Carry-forward implemented (one read per push). |
| "Failed on old code" not repeatable (Test #2 · Medium) | ✅ Fixed | Backport re-run during mitigation (fails left: [] right: ["a"]); exact recipe + patch in the PR body. |
| Concurrency test catches bug only by chance (Test #3 · Medium) | ✅ Fixed | Renamed soak_…, comment says soak, not proof. |
| Rolling upgrade untested; counter condition unpinned (Test #4 · Medium) | ✅ Fixed | an_older_relay_allocating_with_add_meanwhile_… (raw ADD ×2 in the hook). Mutation: unconditional SET fails it. |
| Re-push that lost the counter race only unit-tested (Test #5 · Medium) | ✅ Fixed | a_re_push_that_lost_the_counter_race_… (held re-push, density asserted). |
dense proves only first re-push (Test #6 · Medium) |
✅ Fixed | Final fresh o3 == o2 + 1 in support/idempotence.rs (all backends). |
| Floor steps one seq per stall, wedges at 32+; needless re-read (Code #4 / Integrity #4 · Low) | ✅ Fixed | One reverse Query Limit 1 for the top occupied seq; counter carried as Known; 40-op test; checked_add. |
| Test seam on production type (Code #5 / Test #10 · Low) | ✅ Fixed | test-seams feature via self dev-dependency; WriteAttempt private without it. |
| Duplicated counter decoding; brittle reason index (Code #6 · Low) | ✅ Fixed | counter_value/counter_hint; TxLayout. |
ALL_OLD hint never asserted (Test #7 · Low) |
✅ Fixed | Unit test (3 branches + malformed); reproduction asserts counter_from == "hint". |
| Floor weakly checked end to end (Test #8 · Low) | ✅ Fixed | == Cursor(2), then fresh == Cursor(3). |
| Jitter test passes for zero jitter; reason→outcome mapping untested (Test #9 · Low) | ✅ Fixed | the_jitter_actually_spreads_its_draws; settle_cancellation/settle_claim/layout tests. |
| Test name overclaims (Test #11 · Info) | ✅ Fixed | Doc comment: held before sending, not during commit; section note on what DynamoDB Local cannot show. |
| Rolling upgrade: old relays 500 more (Integrity #5 · Low) | ✅ Fixed | Sentence in module doc, spec, changelog; plus the IAM step first. |
| tm4k idempotence holds (Integrity #6 · Info) | — Context | Unchanged; claim still outranks counter and the new predecessor reason. |
| IAM unchanged claim (Integrity #7 · Info) | ✅ Updated | No longer true: +ConditionCheckItem, documented everywhere it is listed. |
| fold-ddb Streams order across items (Integrity #1, consequence) | ⏭️ Filed 6j6v.ynav |
Out of scope for the relay write path. |
Gates (local): store_ddb unit 40/40 · lib 73/73 · dynamodb_local 37/37 (DynamoDB Local 3.3.1) · idempotent_append 1/1 · relay_http 17/17 · bin 5/5 · cargo check --features dynamodb --tests clean · cargo fmt --check clean · changelog check origin/main ok. Clippy: CI only.
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 (r3jf, P1)
The DynamoDB relay allocated a stream's seq with an
UpdateItem ADDbefore theTransactWriteItemsthat stores the op and its tm4k claim. When two appends overlap (A gets 10, B gets 11, B commits first), a puller at cursor 9 gets[11], stores cursor 11 (crates/sync/src/engine.rs), and never asks below 11 again. A, committed at 10 a moment later, is missing for good on that replica, and the fold watermark (fold_batch) can move past an unfolded op. SQLite and Postgres were never affected (a mutex, and an advisory lock held to commit). Spec:docs/specs/streams-without-lifetime-growth.md§2 item 1 and §4.1 (PR #58, merged into this branch).Fix
Each append round knows a counter value
c(one strongly consistentQueryofseq = 0for the first op of a push; afterwards learned without a read) and sends oneTransactWriteItems:Updatecounter:SET next_seq = :seq(s = c + 1), conditionnext_seq = :expected(orattribute_not_exists(next_seq) OR next_seq = :expectedfor 0),ReturnValuesOnConditionCheckFailure = ALL_OLDPutclaim atclaim_seq(op_id),attribute_not_exists(unchanged tm4k item)s > 1:ConditionCheck attribute_exists(stream_id)on the op ats − 1(the predecessor check, new after review)Putop ats,attribute_not_existsWhat AWS documents vs. what the design builds on (now spelled out in the module doc). AWS documents transactions as serializable against each other and a
Queryonly as read-committed against a transaction — a transaction's items need not become visible at the same instant. The counter condition alone orders the transactions but not visibility: T_n can show its counter before its op, T_{n+1} writes op n+1 (a different item) and commits, and a reader in that window gets[n+1](review, Integrity #1). The predecessor check puts op n's item into T_{n+1}: while T_n is still writing op n, T_{n+1} is cancelled withTransactionConflict(handled as a lost round); once op n exists, T_{n+1} may commit. That is transaction against transaction — the case AWS documents.When the check is relaxed, and why that is safe. The check never disappears from the transaction for
s > 1; it flips:attribute_existswhile the counter condition held (counter reasonNone) means: the counter stands atc, no transaction is writing opc(that would be a conflict), and opcis absent. No transaction of this build can still write opc— only the one that moved the counter toccould, and it writes counter and op atomically. Socis a seq anADD-era relay burned (or still holds, rolling upgrade).sonly (with the counter now known, no read), checksattribute_not_existson opcinstead. The item is still in the transaction, so any writer of it still conflicts; if opcappears meanwhile, that check fails and the round after goes back toattribute_exists. Any other outcome (a lost round → a new slot) clears the flip.top + 1wheretopis an occupied seq, so its check finds an op.Also after review:
PUSH_DEADLINE = 20 s(configurable asNXF_RELAY_PUSH_DEADLINE_SECS, read inmain.rs), below the ~30 s of API Gateway / CloudFront in front of the hosted relay (E4 §9a.3) and about 2× an uncontended 500-op page. Checked between ops (every backend,OpStore::append_batchdefault method), between rounds and against the round in flight (DynamoDB races the round withtimeout_at). A failure after the deadline answers 503. The client treats every push status except 413 the same way (pass fails, nothing marked pushed, next pass re-pushes), and the claims make the re-push safe. A round cut short may still commit; the re-push then finds its claim.ntransactions and one read pern-op push. A wrong guess costs one lost round, which hands back the real counter.SeqTakennow finds the top occupied seq with one reverseQuery(Limit 1) and keeps the counter known (no re-read); a run of any length costs one extra round (Integrity memory: the release runbook names the npm approval step (nxf 6j6v.c7dd) #4).checked_addmakes a counter ati64::MAXa loud error instead of a wrap into the claim range.with_before_writeis behind atest-seamscargo feature, enabled only by the package's dev-dependency on itself; the shipped binary and embedders never carry it.Contracts
seq = 0/next_seq, claim and op items; no new attribute, index or table).+dynamodb:ConditionCheckItemon the op table, REQUIRED before deploy. DynamoDB authorises each action insideTransactWriteItemsseparately; without it every push past a stream's first op fails withAccessDeniedException(a 500) while reads keep working. The op table's role needsQuery,PutItem,UpdateItem,ConditionCheckItem; the registry tableGetItem,PutItem,Query. Documented in thestore_ddb.rsmodule doc ("IAM"), the relay guide (en/de), spec §4.1, E4, and the changelog fragment's migration note.ConditionCheckItemfirst. Mixed with anADD-era relay nothing is overwritten and the counter never moves backwards (pinned by a test); the ordering guarantee holds once every relay writing a table runs this build. Meanwhile old relays answer more pushes with a 500 (theirADDis cancelled byTransactionConflictwhenever a new relay's transaction holds the counter).Tests
DynamoDB Local (deterministic, via the test seam):
a_reader_is_never_handed_a_seq_above_an_append_still_in_flight— the reproduction; A is held after choosing its seq and before sending its write (not during the commit, which no test against DynamoDB Local can do). Now also asserts A's second round took theALL_OLDhint and checked B's op.an_append_does_not_commit_over_a_missing_op_below_it_and_crosses_a_legacy_gap_once— counter at 4, op 4 absent: the checked round stores nothing and leaves the counter (asserted between rounds), the flipped round lands at 5, the next op at 6 in one round.an_older_relay_allocating_with_add_meanwhile_overwrites_nothing_and_never_moves_the_counter_back— rawUpdateItem ADD×2 inside the hook; new op lands at 6, counter never below 5, old relay's conditional puts at 4 and 5 succeed, stream readable in order.a_re_push_that_lost_the_counter_race_answers_its_own_seq_and_burns_none— held re-push of o1, o2 commits, re-push answers 1 in one round, o3 lands at 3.a_run_of_occupied_seqs_above_the_counter_is_stepped_over_in_one_round(40 legacy ops → 41 in two rounds),a_push_carries_the_seq_it_wrote_forward_and_reads_the_counter_once,a_push_stops_at_its_deadline_and_keeps_what_it_stored.== Cursor(2), then fresh== Cursor(3));denseinsupport/idempotence.rsadds a final fresh append== o2 + 1(all three backends).soak_concurrent_writers_and_pullers_see_every_op_once_in_gap_free_seq_order— renamed and documented as a soak test: it catches an ordering bug only when the scheduler produces it.Unit (no endpoint): layout → reason index mapping (with/without claim, with/without check), ranking incl. the predecessor check, cancellation → next step (
Conflict → Lost(None), hint carried, flip direction), claim read-back (Existing/ClaimForeign/ClaimVanished),ALL_OLDhint parsing (3 branches + malformed), flip only for its seq, in-a-row budget, deadline before a round and cutting a round in flight, overflow, jitter spread (fails for an always-zero jitter).relay_http: push past its deadline → 503 with partial storage; a store fault before it → 500.main.rs: deadline env parsing.Mutation check (run locally): dropping the predecessor check fails 2 tests (
an_append_does_not_commit…, the mixed-relay test); an unconditional counterSETfails 2 (mixed-relay, reproduction).Run locally against DynamoDB Local 3.3.1:
store_ddbunit tests 40/40 (73/73 lib),dynamodb_local37/37 (four full runs),idempotent_append1/1,relay_http17/17,nxf-relaybin 5/5;cargo check -p nxs-server --features dynamodb --testsclean;cargo fmt --all --checkclean;cargo xtask changelog check origin/mainok. Clippy not run locally (CI'sdynamodb-localjob runs it with-D warnings; thetype_complexityinscripted()is fixed with type aliases and the diff was scanned for other clippy-prone patterns).Reproducing the red run on main's code
The seam does not exist on main, so the red state is a backport. It was re-run during mitigation and fails as claimed:
git worktree add --detach /tmp/r3jf-red 130c048(v0.210.0, main before this PR).crates/server/src/store_ddb.rs— the hook goes right afterallocate_seqand beforetry_append_opinOpStore::append.crates/server/tests/dynamodb_local.rs, from this branch's copy: the helpersAttempts,recording,Seen,attempts_of,ids,Hook,gate, and the testa_reader_is_never_handed_a_seq_above_an_append_still_in_flight; importWriteAttemptfromnxs_server::store_ddb.NXF_RELAY_DDB_ENDPOINT=http://127.0.0.1:8000 cargo test -p nxs-server --features dynamodb --test dynamodb_local a_reader_is_never_handedassertion left == right failed: the reader's next pull, from the cursor it stored, still gets A: nothing was skipped — left: [] right: ["a"].Backport patch (store_ddb.rs at 130c048)
Performance and cost
Uncontended: one transaction per op (up to four items: counter, claim, predecessor check, op) plus one counter
Queryper push. A transaction is billed at twice the plain write per item; the predecessor check is a transactional read of ops − 1. Contended on one stream: appends take turns on the counter, like on the other two backends. Cancelled transactions consume capacity like committed ones, so N writers racing for one stream cost up to about N× capacity per stored op; the per-stream ceiling is about one append per counter round-trip (≈30–60 appends/s by latency arithmetic). Unmeasured on real AWS — dynamodb-local serialises transactions and we had no AWS access; filed as6j6v.wv9e. Appends to different streams never contend.Follow-ups filed
6j6v.wv9e(P3 chore): measure contended throughput and capacity per stored op on real AWS.6j6v.ynav(P3 chore): fold-ddbfolded_throughrelies on DynamoDB Streams record order across items — verify or make the watermark gap-aware.6j6v.5vxp: truncation must keep the op the counter points at.🤖 Generated with Claude Code
https://claude.ai/code/session_016q5uMUkZJQW4fckJoA3FME