Skip to content

fix(mongodb): retry an unconditional PutItem that conflicts on a GSI table - #487

Open
LeeroyHannigan wants to merge 24 commits into
mainfrom
fix/mongodb-putitem-gsi-conflict
Open

LeeroyHannigan wants to merge 24 commits into
mainfrom
fix/mongodb-putitem-gsi-conflict

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #426. Stacked on #431 (fix/mongodb-upsert-duplicate-key): this branch merges #431's branch and should merge after it.

On MongoDB a put on a table with an index writes the item and its index entries
in one transaction. MongoDB aborts the loser of a same-document conflict at
once instead of queueing it, so of N writers on one key one commits per round
and the rest start over, and a writer needs on the order of N attempts, more
under the connection pool's queueing. A budget of 50 attempts ran out for a
share of the puts, and the put answered the 400 TransactionConflictException
that main reserves for a single-item write that could not serialize.

Two bounds now end a contended write, and both answer an error the SDKs retry.
The contention budget is 1,000 attempts, and a PutItem that spends it returns
StorageError::Transient, answered as HTTP 503 ServiceUnavailable. A request's
retry loop also stops after CONTENTION_DEADLINE, 10 seconds, because the
attempt budget bounds attempts and not the time a caller waits: at 256 writers
on one key, 89 of 5,120 calls were still waiting when the client's 120 s read
timeout fired. With the deadline the longest of 6,400 calls on that shape was
11.7 s.

The requests of one BatchWriteItem share one deadline, and a request that
reaches it is returned in UnprocessedItems while the other requests are
applied. See "BatchWriteItem" below.

Merged with main

This branch merges origin/main at 03baa32. After #543 added a MongoEngine struct literal in stream_records_live_tests.rs, the merged tree was conflict free and the test build failed with E0063 (missing field contention_deadline), as snapshot_engine.rs did after #395. MongoEngine::new now builds the engine in from_parts, and a test-only MongoEngine::for_test calls the same function, so the test literals set only the fields they change and a new field is set in one place. The contention tests moved into data_engine_live_tests_contention.rs and build their engine through the live test module's scratch_engine. They are #[ignore]d like the other live tests and run under devtools/run-mongodb-tests --rust-live, which CI runs.

What the service does

Measured against DynamoDB in us-east-1 with client retries off
(retries={"max_attempts": 0}), on existing bb- tables with a string hash
key, a GSI and a stream, on 2026-10-23 and 2026-10-24.

shape calls outcome
32 writers putting one key of a GSI table 2,016 2,016 answered 200, the index held the final item's entry and no other
16 threads x 30 PutItem on one key 480 480 answered 200, 0.6 s
64 threads x 30 PutItem on one key 1,920 1,920 answered 200, 2.5 s
16 threads x 30 PutItem each on its own key of the GSI table 480 480 answered 200, 0.6 s
64 threads x 30 PutItem each on its own key of the GSI table 1,920 1,920 answered 200, 2.3 s
16 writers x 30 rounds, 8 BatchWriteItem (hot key plus a side key) racing 8 PutItem with ALL_OLD on the hot key 240 batch and 240 put all 200, no UnprocessedItems, 0.6 s
16 threads x 20 UpdateItem ADD #c :one SET #g = :g with ALL_NEW on one key 320 320 answered 200, the stored counter equalled the 200s, each ALL_NEW value went to one caller
16 threads x 20 UpdateItem SET l = list_append(l, :m) on one key 320 320 answered 200, each marker stored once
16 writers x 10 TransactWriteItems (one Put) on one key 160 53 answered 200, 107 answered 400 TransactionCanceledException with reason TransactionConflict
8 TransactWriteItems x 10 racing 8 PutItem x 10 on one key 80 transact and 80 put transactions: 35 answered 200, 45 answered 400 TransactionCanceledException [TransactionConflict]. Puts: 47 answered 200, 33 answered 400 TransactionConflictException

The service sheds none of the single-item load at any writer count measured,
including 64 concurrent writers on one key. It answers a single-item write with
TransactionConflictException only while a TransactWriteItems holds the item,
which this backend cannot tell apart from a race against other puts. It does
shed concurrent transactions on one key, with the same
TransactionCanceledException and TransactionConflict reason this backend
answers when a transaction stops retrying.

What each bound answers

Measured on this branch with another session holding the contended item inside
a MongoDB transaction it never commits, so every attempt of the retry loop
conflicts. Four writes ran at once on that key, client retries off, four
rounds, host load 11 to 13:

round PutItem DeleteItem UpdateItem TransactWriteItems
1 503 in 9.65 s 400 TransactionConflictException in 9.98 s 503 in 10.02 s 400 TransactionCanceledException [TransactionConflict] in 10.01 s
2 503 in 9.59 s 400 TransactionConflictException in 9.94 s 503 in 10.02 s 400 TransactionCanceledException [TransactionConflict] in 10.02 s
3 503 in 10.03 s 503 in 10.02 s 503 in 10.02 s 400 TransactionCanceledException [TransactionConflict] in 10.01 s
4 503 in 9.64 s 503 in 10.02 s 503 in 10.03 s 400 TransactionCanceledException [TransactionConflict] in 10.02 s

PutItem answers 503 on either bound, and TransactWriteItems answers
TransactionCanceledException with a TransactionConflict reason per item on
either bound. DeleteItem and UpdateItem answer 503 on the deadline and 400
TransactionConflictException when their 1,000 attempts run out first, which
the delete did in 2 of the 4 rounds above: the two bounds take about the same
time on this shape, so which one the loop reaches decides which error class the
caller is told. Those two 400s are what main answers for every spent budget on
those paths, and #475, the fix for #437, routes them through the same transient
give-up. After #475 merges, every single-item write answers 503 on either
bound. The deadline moves the common case on those two paths to 503, so the
400 is reached less often than on main.

A client on an SDK retry mode retries the 503 and the retry commits once the
contention has passed. The message the give-up carries ("PutItem: the write
lost to concurrent writers for 10 seconds; retry the request") reaches the
server log; the client gets the fixed 503 body every transient error on this
backend answers with.

BatchWriteItem

BatchWriteItem calls the storage layer once per request. Before this change
each call started its own deadline, so a batch of contended requests could
keep a client waiting 10 s per request, and when a later request gave up the
handler failed the whole batch with 503 after the earlier requests had
committed. The SDKs retry a 503, so the retry applied those requests again,
after any write another client had made to the same items.

The handler now runs inside a request clock scope
(extenddb_storage::request_clock), and the MongoDB contention loops measure
the deadline from the start that scope carries, so the requests of one batch
share one deadline. The first attempt of every request still runs. A request
whose write fails with Transient or TransactionConflict is returned in
UnprocessedItems, and the requests after it are still tried. The batch answers
the error only when none of its requests was applied, so a client that retries
it resends nothing that took effect. ExtendDB answers 503 there, where the
service answered the single-request batch in the table below with 500
InternalServerError. The capacity of a request returned
unprocessed is still counted. On the other backends a batch put or delete that
returns one of those two errors is now returned unprocessed too, where it
failed the batch before.

Measured against DynamoDB in us-east-1 on 2026-11-03, client retries off, while
TransactWriteItems calls kept updating one item H on an on-demand table with a
string hash key:

batch calls outcome
put H, then put a free item S 80 46 answered 200 with the put of H in UnprocessedItems and S written; 34 answered 200 with both written. Every one reported 2.0 capacity units
put S, then put H 80 53 answered 200 with the put of H in UnprocessedItems and S written; 27 answered 200 with both written. Every one reported 2.0 capacity units
put H only 80 26 answered 200; 54 answered 500 InternalServerError; none answered 200 with the request unprocessed

The shape from the review, against a mongosh session that holds B in a
transaction for 15 s: [delete A, put B] sent by a client on the SDK's default
retry mode, while another client puts A=v2 5 s in. On this head the batch
answers 200 in 10.01 s with only the put of B in UnprocessedItems, and A holds
v2. On the build before this change it answered 200 after 15.02 s with nothing
unprocessed: the first send answered 503 at the deadline, the SDK resent the
batch, and the resend deleted A=v2 and put B once the hold ended.

ImportTable is not changed. It writes a table it created at the start of the import, so its
puts meet contention only when another client writes that table during the import,
and it runs outside any scope, so each of its puts keeps a deadline of its own.

#475 also changes this handler to return such requests in UnprocessedItems,
for a wider set of errors, so the one of the two PRs that merges second carries
one version of it.

What each status means here

  • 200: the write committed.
  • 200 with UnprocessedItems (BatchWriteItem): the requests listed reached
    the deadline or their attempt budget and were not applied; the others were.
  • 503 ServiceUnavailable: the write spent its attempt budget, or it reached the
    10 s deadline. The error-handling documentation lists ServiceUnavailable
    (HTTP 503) as "OK to retry? Yes", and the SDK retry modes retry it, whereas a
    400 TransactionConflictException is handed to the application.
  • 400 TransactionCanceledException with a TransactionConflict reason per
    operation: a TransactWriteItems reached either bound. This is what the
    service answers a transaction that loses to contention on one key, measured
    in 107 of 160 calls.
  • 500 on the create-race path: every attempt found no item and its insert hit
    an item another writer had created and removed again. That is fix(mongodb): retry an unconditional PutItem that loses the create race #431's answer,
    the same one the PostgreSQL backend gives when its own retries of this race
    run out.

One constant sized three loops, and which loops take the deadline

TRANSIENT_RETRY_ATTEMPTS sized three different loops, so raising it to 1,000
raised all three. Two of them are not contention loops:

  • TRANSIENT_RETRY_ATTEMPTS = 1000 now sizes the contention loops only: put,
    delete, update, transact, the GSI backfill, and the transaction of the
    startup key repair pass that main gained in fix(mongodb): store the key when UpdateItem creates an item #473. Set by the measurements in
    its doc comment: 50 attempts left 15 to 25 percent of the puts of 32 clients
    on one key without a commit, 400 left one put in 2,016 on a loaded host.
  • COMMIT_RETRY_ATTEMPTS = 50 sizes commit_write_transaction's in-place
    retry after an UnknownTransactionCommitResult. That label means the
    commit's outcome is unknown, the commit is idempotent and the body must not
    be re-run, so those attempts wait for one reply rather than racing other
    writers. No measurement here sizes a longer wait, and a longer one only
    delays the HTTP 500 the path answers when the outcome stays unknown.
  • CREATE_RACE_ATTEMPTS = 50 sizes the create-race give-up, on PutItem's
    sessionless path and transaction path and in UpdateItem's native upsert,
    which main gained in fix(mongodb): store the key when UpdateItem creates an item #473. fix(mongodb): retry an unconditional PutItem that loses the create race #431 measured that budget with
    this backoff: a put blocked by a document another writer removes 100 ms
    after the put starts commits well inside it, and fix(mongodb): retry an unconditional PutItem that loses the create race #431's storage tests fail
    with the cap put back to 5. The transaction path now gives up as soon as
    that budget is spent, rather than carrying lost races through the contention
    budget.

CONTENTION_DEADLINE = 10 s bounds the four loops that serve a request: put,
delete, update and transact. It is read before an attempt starts and never
before the first one, so a request answers within the deadline plus the attempt
already running, and no request answers 503 without having tried the write
once. The attempt already running has no bound of its own: under many writers
on one key it waits for a connection from the pool (50 by default) and for the
commit's own retries, and the longest call at 256 writers on one key was
11.7 s. A loop measures the deadline from the start of its request, which for
BatchWriteItem is the start of the batch. The TTL worker's conditional delete
runs the delete loop too, outside any request, so an expired item that stays
contended holds the sweep until the deadline or the attempt budget ends that
loop, where 50 attempts ended it before; the worker then logs the failure and
moves to the next item. The GSI backfill loop keeps the attempt budget and
takes no deadline: it runs in the background worker, where no caller is
waiting for a reply and giving up would leave the index CREATING. The
sessionless PutItem fast path, UpdateItem's native upsert and
commit_write_transaction take no deadline either, because none of them is a
contention loop. The key repair pass runs in a background task at startup and
takes no deadline, as the GSI backfill does.

The engine carries the deadline as a field set to CONTENTION_DEADLINE in
every production construction, which is what lets a test decide which bound it
exercises instead of racing 1,000 attempts against a wall clock.

How long each bound takes

backoff_sleep draws each sleep uniformly from 0 to the current base, capped
at 12.8 ms, so 1,000 attempts sleep about 6.4 s in total on average and 12.8 s
at worst, before the attempts' own round trips.

Measured on MongoDB 7.0 as a single-node replica set, against a transaction
another session holds open, where each attempt also waits out MongoDB's lock
timeout:

bound time
1,000 attempts, with the deadline raised out of the way 9.5 to 10.0 s over three runs, 11.3 to 12.7 s over three more at a host load average of about 15, and 10.08 s in the storage test that now sets a 600 s deadline
the 10 s deadline 10.005, 10.009 and 10.010 s over three runs of the storage test on the production deadline
a deadline a test sets to 2 s 2.003 s for PutItem, 2.007 s for DeleteItem, 2.008 s for UpdateItem, 2.003 s for TransactWriteItems

What the bounds buy and what they cost

MongoDB 7.0 single-node replica set, client retries off, a fresh GSI table per
run, all writers on one key, 20 puts each. The server was the only extenddb
process on the host; the host itself was shared. Host load average 5.6 to 22.3
for the budget-only columns and 5.0 to 15.8 for this head, so the two sets are
not from an idle host and are not from the same load. How many calls are shed
depends on that load: the 64-writer row below shed none, and the same shape
shed 106 and 149 of 1,280 on another host at load averages 6.8 and 10.5.

writers budget 50, no deadline budget 1,000, no deadline budget 1,000 and the deadline (this head)
64 337 of 1,280 answered 200, 943 answered 503; 6.5 commits/s; p50 1,691 ms, p99 7,070 ms, max 7,322 ms 1,280 of 1,280 answered 200; 25.9 commits/s; p50 1,112 ms, p99 18,397 ms, max 27,995 ms 1,280 of 1,280 answered 200, none shed; 38.9 commits/s; p50 1,022 ms, p99 7,864 ms, max 9,740 ms; host load 7.95 at the start
256 298 of 5,120 answered 200, 4,822 answered 503; 2.4 commits/s; p50 6,167 ms, p99 12,192 ms, max 12,427 ms 4,644 of 5,120 answered 200, 387 shed, 89 calls hit the client's 120 s read timeout; 5.4 commits/s; p50 29,460 ms, p99 120,098 ms, max 120,103 ms 1,262 of 5,120 answered 200, 3,858 shed, no call hit the client timeout; 6.9 commits/s; p50 10,079 ms, p99 10,662 ms, max 11,658 ms; host load 5.01 at the start

At 64 writers on one key the raised budget turns 943 shed calls into commits,
and on that run the deadline was never reached, so nothing was shed and the
longest call fell from 28.0 s to 9.7 s. At 256 writers the deadline turns
3,471 calls that used to commit after a long wait into 503s the SDK retry
modes retry, and the commit rate is 6.9 against 5.4 on a different host load.
In both runs the stored item was the last writer's and the index held one
entry for its indexed value.

The same 256-writer shape against this head's parent commit, which has the
1,000-attempt budget and no deadline, on the same host and container, 5 rounds
each: without the deadline p50 20,983 ms, p99 98,856 ms and the longest call
99,534 ms; with it p50 10,079 ms, p99 10,662 ms and the longest call 11,658 ms.
Those two runs sent 5 rounds and 20 rounds, so their commit counts measure
different amounts of work; the per-call latencies are measured on the same
shape and the same host. A client on botocore's default 60 s read timeout gets
an answer it can retry on this shape instead of a socket timeout.

The cost is CPU on the MongoDB server. The losers of a hot key retry with a
backoff capped at 12.8 ms for up to 10 s, so mongod stays busy committing and
aborting transactions on that key for as long as the contention lasts. With
64 and then 256 writers putting one key of a GSI table for 20 s, client retries
off, on a 16-core host at a load average of 18 to 21, mongod used a median of
132 and 128 percent CPU and ran 1,666 and 1,811 transactions a second, of which
98.7 and 99.6 percent aborted, while the puts committed 21.7 and 6.9 times a
second. On another host the same shapes measured about 2 cores and 1,800 to
1,900 transactions a second.

The GSI backfill keeps its attempt budget and no deadline

A table with 600 items, 48 writers putting one of those keys continuously with
client retries off, then UpdateTable adding a GSI on g: the index reached
ACTIVE 22.6 s after the UpdateTable, which is past the 10 s deadline, and
held 600 entries for the 600 base items. The 48 concurrent writers answered
3,038 times with 200 and twice with 503. So the request path's deadline does
not stop the background backfill, which is why that loop reads no clock.

What this PR does not change

The hot-key commit rate on this backend, measured above on a shared host, is
2.4 to 6.5 commits per second at budget 50, 5.4 to 25.9 at budget 1,000, and
6.9 to 38.9 at budget 1,000 with the deadline. The service answered 1,920 calls
from 64 writers on one key in 2.5 s, about 770 per second. That comes from
aborting the loser of a same-document conflict instead of queueing it, which
neither this PR nor #475 touches. Each changes which error a losing write
receives, not the rate at which writes commit.

On a table with a GSI and a stream, writes to different keys can conflict while
they write an index entry or draw a stream sequence number, and those errors
map to Internal in sync_indexes_in_session and the stream helpers, so they
answer 500 here as on main. That is #437. #475 classifies those conflicts as
transient so the retry loop sees them. The results in this description that
show no 400 and no 500 are for writers on one key of a GSI table without a
stream.

Tests

  • tests/test_put_item_gsi_contention.py (skipped on Cassandra, where some of
    these puts answer TransactionConflictException, Cassandra: unconditional PutItem on one key of a GSI table answers TransactionConflictException under contention #802): 32 writers putting
    one key of a table with a GSI, 2,016 puts, client retries off, each writer with a client
    of its own. Every call must answer 200 or 503 ServiceUnavailable, never 400
    TransactionConflictException and never 500, and at least half of the puts
    must commit; the stored item must be one writer's item, whole, carrying the
    gk that writer sent with that w; and the index must hold exactly one
    entry for it. How many calls are shed depends on the load the server is
    under, so the test reports that count and bounds it only loosely: it failed
    1 of 4 runs in the shape CI uses when an earlier revision asserted that every
    call answers 200, and 240 of 2,016 were shed when another hot-key load shared
    the server. The file also runs on the PostgreSQL and SQLite integration
    jobs, which shed nothing on this shape.
  • tests/test_mongodb_batch_write_contention.py, MongoDB only, holding items
    through mongosh in the runner's container: the review's [delete A, put B]
    shape answers 200 with only the put of B unprocessed and keeps the other
    client's A; and a batch whose first request is held for 6 s and whose second
    is held past the deadline answers within the batch's deadline with the second
    request unprocessed. Both fail against the build before the change: each
    batch answered 200 after about 15 s with nothing unprocessed, and in the
    first the other client's A was deleted. A third test checks that a batch
    whose only request is held past the deadline answers 503 and applies nothing.
  • docs/differences-from-dynamodb.md carries a row for the divergence under
    Capacity and Throttling: what each operation answers at each bound, that the
    shed count depends on the host's load, the 256-writer measurement, and how a
    BatchWriteItem answers a request that reaches the deadline.
  • Storage crate live tests against the replica set named by
    EXTENDDB_TEST_MONGODB_URI. 32 writers putting one key on the transaction
    path all commit. A contended PutItem, DeleteItem and UpdateItem that reach
    the deadline each answer the transient give-up naming the operation and the
    deadline. A contended TransactWriteItems that reaches the deadline answers
    a canceled transaction with a TransactionConflict reason per operation, and
    neither operation is applied. A put that spends its 1,000 attempts answers
    the give-up with the budget written out in the test, with the held item
    unchanged, and takes at least 5 s, which the sleeps of 50 attempts cannot
    reach. A put of a held item inside a request clock scope that started past
    the deadline gives up after one attempt, and a put of a free item in the same
    scope commits. Each of those tests sets the engine's deadline, 2 s to make the
    deadline the bound and 600 s to make the attempt budget the bound, so none of
    them races the two.
  • Unit tests: the deadline helpers (the first attempt of a request always
    runs, no attempt starts once the deadline has passed, a deadline a test sets
    decides the answer, the give-up carries the deadline that passed, with the
    fraction of a deadline below one second); the request clock (a call sees the
    start of its scope, an inner scope replaces it, and the MongoDB loops read
    the scope's start).
  • fix(mongodb): retry an unconditional PutItem that loses the create race #431's two create-race storage tests still pass with the create-race budget
    named separately, and still report the give-up after 50 attempts on both
    paths.

What fails without this branch

On MongoDB 7.0, 32 writers putting one key of a GSI table, 2,016 puts per run,
client retries off:

build result
main (9d13202), budget 50 303 puts answered 400 TransactionConflictException
c3f8bc8 (#431), budget 50 322 puts answered 400 TransactionConflictException
budget 400, an intermediate build no failures in four runs at a load average of 15; one put answered 503 at a load average of 30; with 64 writers, 20 of 1,984 answered 503
this branch no 400 and no 500 in any run; the shed count tracks the load, from 0 of 2,016 on an unloaded server to 240 of 2,016 with a second hot-key load on the same server

Each storage test of the two bounds was run against a broken version of the
path it covers, and each failed: with the loop reading the production constant
instead of the engine's deadline, all three deadline tests and the
attempt-budget test fail; with the deadline check removed from the transact
loop, the transaction test fails on its elapsed bound; with either give-up
returning TransactionConflict instead of Transient, the test of that
give-up fails on the error class. The tests added for the review were checked
the same way: with TRANSIENT_RETRY_ATTEMPTS set to 50, the attempt-budget
test fails on its message; with the loops measuring the deadline from their own
start instead of the request's, the request scope test fails because the held
put waits out a 2 s deadline of its own.

Gates

  • cargo +1.97.0 fmt --all -- --check: clean.
  • cargo +1.97.0 clippy -p extenddb-storage -p extenddb-storage-mongodb -p extenddb-engine --all-targets -- -D warnings, and the same for extenddb-storage-mongodb with its test-hooks feature enabled: no warnings.
  • cargo +1.88.0 test --locked -p extenddb-storage -p extenddb-engine -p extenddb-storage-mongodb, with EXTENDDB_TEST_MONGODB_URI set so the URI-gated tests ran: 675 passed, 0 failed, 53 ignored over 13 targets.
  • The storage-mongodb live tests, cargo +1.88.0 test --locked -p extenddb-storage-mongodb --lib -- --ignored live, against a MongoDB 7 single-node replica set: 48 passed, 0 failed.
  • MongoDB wire tests on a release build with only the mongodb-test-hooks feature enabled, as CI builds it: test_mongodb_batch_write_contention.py 2 passed, and test_put_item_gsi_contention.py passed with 0 of 2,016 puts shed at a host load average of about 14. The PostgreSQL and SQLite wire jobs were not run locally for this revision.
  • Cargo.lock unchanged, so the license notice checks do not apply.

Amazon DynamoDB applies concurrent PutItem calls on one key one after
another. A call without a condition succeeds, the last writer's item is
stored, and with ReturnValues ALL_OLD each call returns the item it
replaced. Measured on 2026-10-21 against the service in us-east-1 with
client retries off: 32 writers x 63 rounds of racing unconditional puts
on a new key per round answered 2,016 of 2,016 with 200 on hash, GSI and
stream tables; 32 writers on one key for 63 rounds answered 2,016 of
2,016 on GSI and stream tables; one key with half the writers sending
PutItem and half DeleteItem answered 22,400 puts and 22,400 deletes with
200 and no error (seven runs of 64 writers x 100 operations on a hash
table, three runs of 32 x 200 on a GSI table); 32 writers racing PutItem
against DeleteItem, UpdateItem and BatchWriteItem on one key answered
every call with 200; and 64 writers with one putter per three deleters on
one key answered 960 puts and 2,880 deletes with 200. In every cell the
ALL_OLD images, the final item, the index and the stream records formed
one serial history. tests/test_put_item_create_race.py runs against the
service as well as against ExtendDB and checks the race on 8 clients.

On MongoDB, two puts that create the same item could each find no
document and each insert it. The second insert fails with a duplicate-key
error (E11000), which findAndModify reports as a command error. The data
collection has two unique indexes: _id_, and one on the key fields (pk,
plus the sort key field on a table with a sort key). MongoDB retries such
an upsert itself only when the filter is an equality on the fields of the
index that raised the error. The put filters on pk and, on a table with a
sort key, the sort key field, and every measured duplicate-key error names
_id_, so the server does not retry it. The sessionless path (no condition,
no index, no stream) returned the error as an internal error (HTTP 500),
and the transaction path (a table with an index or a stream) classified it
as fatal, which also returned an internal error. On a 16-core host the
race test failed 2 or 3 of its 9 cases per run, each failure a racing put
answering InternalServerError; 32 writers creating a new key per round
got 3 InternalServerError responses in 2,016 puts; and one-key put and
delete churn from 8 and 32 threads got 66 and 45 in 2,016 puts.

Both paths now retry the put under the transient retry budget of 50
attempts with the same backoff the write-conflict retry uses. The next
attempt finds the item the other put created, replaces it and returns it
as the old image, so the ALL_OLD images form one chain; if a delete
removed that item first, the next attempt inserts. On the transaction
path the index update and the stream record take that item as the old
image too. A put whose 50 attempts all lost the create race returns the
internal error the PostgreSQL backend returns when its own retries of
this race run out, with the attempt count in the message.
is_duplicate_key now recognizes the command form as well as the write
form that insert_one returns.

The MongoDB xfail marker on the plain-table race tests is removed.
devtools/run-mongodb-tests exports EXTENDDB_TEST_MONGODB_URI, and two
storage crate tests use it, on the sessionless path and on the
transaction path each: one forces every attempt to lose the race with a
document that holds the item's _id under another pk and checks the
give-up error after 50 attempts and that the document is unchanged; the
other replaces or deletes that document 100 ms after the put starts and
checks that the put succeeds, returns the other put's item or no old
image, and stores its own item. Without the URI the tests print a SKIP
line; with EXTENDDB_TEST_MONGODB_CONTAINER set and no URI they fail. The
scratch databases are dropped when a test fails.

Verified against MongoDB 7.0 as a single-node replica set. The race test
passes 9 of 9 cases in each of 16 runs. The same cells as the service
measurement answered every unconditional PutItem, DeleteItem and
BatchWriteItem with 200 and no invariant violation; one-key put and
delete churn answered 44,800 puts and 44,800 deletes with 200 (ten runs
of 16 x 400 and four of 64 x 100) while 4,663 puts lost a create race
and succeeded on a later attempt. With the backoff removed from the
sessionless retry the lost-race-then-success test fails after 50 attempts;
with a cap of 5 lost races put back on either path, with the transaction
give-up error disabled, or with the transaction arm giving up at the first
lost race, the storage crate tests fail; with the sessionless retry
disabled the race test fails its 3 plain-table cases. One-key contention
on GSI and stream tables still answers TransactionConflictException for
some puts (issue 426), unchanged here.

Closes #356
…table

Amazon DynamoDB applies concurrent PutItem calls on one key one after
another and answers each with 200; with a global secondary index the index
holds the final item's entry only. Measured on 2026-10-23 against the
service in us-east-1 with client retries off: 32 writers putting one key of
a table with a GSI, 2,016 puts, 2,016 answered 200, and the index held the
entry of the final item and no other.

On MongoDB a put on a table with an index writes the item and its index
entries in one transaction. MongoDB aborts the loser of a same-document
conflict at once instead of queueing it, so of N writers on one key one
commits per round and the rest start over, and a writer needs on the order
of N attempts, more under the connection pool's queueing. The retry budget
of 50 attempts ran out for a share of the puts, and the put answered the
400 TransactionConflictException main reserves for a single-item write that
could not serialize against concurrent activity.

The budget is now 1,000 attempts for every write path in data_engine.rs.
The backoff is random, so a writer can lose many rounds in a row, and the
chance of losing every attempt falls off exponentially with the budget:
400 attempts left one put in 2,016 without a commit on a host at a load
average of 30 and 20 of 1,984 with 64 writers. backoff_sleep is capped at
12.8 ms, so 1,000 attempts bound a write at about 6.4 s of backoff plus
the attempts' own round trips before it gives up; measured against a
transaction another session holds open, a put returns after 10.5 s. A
PutItem that runs out of attempts returns StorageError::Transient, which
the engine answers as HTTP 503 ServiceUnavailable. The service answers a
PutItem with TransactionConflictException only while a TransactWriteItems
holds the item, which this backend cannot tell apart from a race against
other puts; the DynamoDB error-handling documentation lists
ServiceUnavailable (HTTP 503) as "OK to retry? Yes", and the SDK retry
modes retry a 503 on their own, whereas a 400 is handed to the application.

Measured on MongoDB 7.0 (single-node replica set, client never retries),
32 writers putting one key of a GSI table, 2,016 puts: main (9d13202)
answered 303 of 2,016 with 400 TransactionConflictException; c3f8bc8 (#431) answered 322 of 2,016 with 400 TransactionConflictException; this branch
answered 2,016 with 200 in each of 3 runs of the wire test, and the index held
the final item's entry only. tests/test_put_item_gsi_contention.py runs
this shape and fails on main and on c3f8bc8. Two storage crate tests run
against the replica set named by EXTENDDB_TEST_MONGODB_URI: 32 writers
putting one key on the transaction path all commit, and a put whose every
attempt conflicts with a transaction another session holds open gives up
with the transient error after 1,000 attempts, in 10.5 s, leaving the
held item unchanged.

Fixes #426
…gets

TRANSIENT_RETRY_ATTEMPTS sized three different loops, and raising it to
1,000 for hot-key contention raised all three. Two of them are not
contention loops and no measurement in this branch says anything about
running them 1,000 times:

commit_write_transaction retries commitTransaction in place after an
UnknownTransactionCommitResult. The label means the commit's outcome is
unknown, the commit is idempotent and the body must not be re-run, so
those attempts wait for one reply rather than racing other writers.
COMMIT_RETRY_ATTEMPTS keeps it at the 50 every write path used before.

An unconditional PutItem that keeps losing the create race gives up with
an internal error. #431 measured that path at 50 attempts with this
backoff: a put blocked by a document another writer removes 100 ms after
the put starts commits well inside it, and the storage tests fail with
the cap put back to 5. CREATE_RACE_ATTEMPTS keeps it at 50, on the
sessionless path and on the transaction path. The transaction path now
gives up as soon as that budget is spent instead of carrying lost races
through the contention budget.

TRANSIENT_RETRY_ATTEMPTS stays at 1,000 and now sizes the contention
loops only: put, delete, update, transact and the GSI backfill.

The constant's doc comment said 1,000 attempts "bound the wait at about
6.4 s of backoff". backoff_sleep draws each sleep uniformly from 0 to the
current base, capped at 12.8 ms, so 6.4 s is the mean of the sleeps and
12.8 s is the bound, before the attempts' own round trips. Measured on
MongoDB 7.0 as a single-node replica set, a put that loses all 1,000
attempts to a transaction another session holds open answered in 9.46 s,
9.85 s and 10.00 s over three runs, where each attempt also waits out
MongoDB's lock timeout. The comment now states the mean, the bound and
the measurement, and the test prints the elapsed time it already
measured.

The RFC's write-conflict paragraph said is_transient_write_conflict
returns true for the UnknownTransactionCommitResult label. It does not,
by design: under that label the commit may already have applied, so the
body must not be re-run, and classify_commit_error handles it alone. The
paragraph now also names the commit and create-race budgets and carries
the corrected mean and bound.

Issue #426
main added four fields to MongoEngine in #384. The with_scratch_engine test
helper builds the engine with a struct literal, so against a main that
carries #384 it failed to compile with E0063. It now sets the four fields the
way backup_live_tests.rs does.

The comments on the retry budget give the measured wall time of a put that
loses every attempt as a range from six runs on a shared host.
The 1,000-attempt budget bounds attempts, not the time a caller waits. At
256 writers putting one key of a GSI table, 89 of 5,120 calls were still
waiting when the client read timeout fired at 120 s, past the SDKs'
default read timeouts.

The contention loops of PutItem, DeleteItem, UpdateItem and
TransactWriteItems now read CONTENTION_DEADLINE (10 s) before each attempt
after the first. A single-item write that reaches it answers
ServiceUnavailable (HTTP 503), which the SDKs retry. A transaction that
reaches it answers TransactionCanceledException with a TransactionConflict
reason per item, as it does when its attempts run out and as the service
answers a transaction that loses to contention. The GSI backfill loop runs
in the background worker and takes no deadline.
The deadline and the 1,000-attempt budget take about the same time against a
contended key, so a test could not say which bound ended a write. The engine
now carries the deadline, set to CONTENTION_DEADLINE in every production
construction, and a storage test sets it: 2 s makes the deadline the bound,
600 s makes the attempt budget the bound.

Three paths that had no test now have one. A contended PutItem, DeleteItem and
UpdateItem that reach the deadline each answer the transient error the engine
answers as HTTP 503, with the operation and the deadline in the message. A
TransactWriteItems that reaches the deadline answers a canceled transaction
with a TransactionConflict reason per operation, which is what the service
answers a transaction that loses to contention. The test named for the attempt
budget reaches the attempt budget again and asserts the 1,000-attempt message
rather than either message. Each test is bounded to the deadline it sets, so a
loop that stopped reading the deadline fails it.

The doc comment on the deadline said both bounds answer the same HTTP 503,
which holds for PutItem and TransactWriteItems only. On this branch DeleteItem
and UpdateItem answer 503 on the deadline and 400 TransactionConflictException
when their attempt budget runs out first, measured in 2 of 4 rounds against a
document a transaction held. The comment and the RFC paragraph now say that,
and name the change that gives all three single-item writes one answer.
… has

tests/test_put_item_gsi_contention.py asserted that all 2,016 puts of 32
writers on one key answer 200. The retry deadline this PR adds makes that
false under load: a put that has been losing for ten seconds is shed with 503
ServiceUnavailable. The file failed 1 of 4 runs in the shape CI uses, two
xdist workers on one server, and failed with 240 of 2,016 shed when another
hot-key load shared the server.

The test now asserts the contract the retry loop gives. Every call answers 200
or 503 ServiceUnavailable, never 400 TransactionConflictException and never
500; the stored item is one writer's item, whole, carrying the gk that writer
sent with that w; and the index holds exactly one entry for it. The number
shed depends on the load the server is under, so the test reports it instead
of asserting it. The docstring keeps what the service answered to this shape,
200 to every call, and says where ExtendDB diverges.

docs/differences-from-dynamodb.md gets a row for that divergence under
Capacity and Throttling, with the measurements at 64 and 256 writers and what
each operation answers at each bound.

@robinnsc robinnsc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The single-item fix holds up. On a local MongoDB 7 with a GSI + stream table and 32, 64 and 256 writers on one key, PutItem only ever answered 200 or 503. The stored item, its single index entry and the stream (one record per commit, none for a shed put, an unbroken old-image chain) were consistent after every run. UpdateItem, DeleteItem and TransactWriteItems on the same key were consistent too, and conditional puts weren't retried into wrong answers. The longest single-item call was 11.8 s at 256 writers, against 28 s on main. Uncontended puts cost the same as before. The 9 new storage tests pass, and #431's create-race fix holds (2,880 of 2,880 racing creates answered 200).

Two things I'd look at before merging, both in the first two inline comments: BatchWriteItem, and the merge with current main not compiling in test builds.

Smaller notes:

  • On a GSI + stream table, writes to different keys that conflict while writing the index or the stream counter still answer 500, because those errors map to Internal in sync_indexes_in_session and the stream helpers. A mixed BatchWriteItem + PutItem run gave 68 of 240 batches and 9 of 240 puts as 500 on head (main is similar). If that's #437's scope, it might be worth saying so, since the description says no 400 or 500 in any run.
  • A hot key keeps mongod at about 2 cores for as long as the contention lasts (about 1,800-1,900 transactions/s at 64 and 256 writers), since the backoff cap is still 12.8 ms and losers now retry for up to 10 s. That seems like a fair trade-off, just worth a sentence.
  • The overshoot past 10 s (up to 12.4 s at 256 writers) comes from the attempt in flight waiting on the 50-connection pool and the commit retries. The doc comment's "deadline plus one attempt" covers it, but the attempt can be a couple of seconds on its own.
  • Small doc nits: the orphaned doc comment on fn hash_key in the test module; the RFC decision-table row at docs/rfcs/0000-mongodb-backend.md:238 still says the UnknownTransactionCommitResult label is detected, which the rewritten paragraph now contradicts; contention_deadline_exhausted prints as_secs(), so a sub-second test deadline reads "0 seconds".


let tx_options = self.write_transaction_options();
let mut lost_create_races = 0;
let contention_started = std::time::Instant::now();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clock starts per storage call, and batch_write_item.rs:127-138 / :181-191 call put_item / delete_item once per item, so a BatchWriteItem gets 10 s per item. With items held and released 7-9 s apart, a 10-item batch answered 200 after 70 s, and with botocore's default 60 s read timeout the client timed out with 8 of 10 written. Main answered 400 in 0.4 s.

The bigger effect is the 503 itself. Earlier items in the batch have already committed when a later one gives up, and since 503 is retried by the SDK (main's 400 wasn't), the whole batch is resent. Repro with [delete A, put B], B held for 14 s, and another client putting A=v2 at 5 s, which got 200: the batch's resend deleted A again, so the final state is A absent, with stream records REMOVE v0, INSERT v2, REMOVE v2. With [put A=v1, put B] the final state was A=v1 after the other client's v2. On main the same shapes answered 400 once, and v2 survived.

DynamoDB returns items it couldn't write in UnprocessedItems, so the SDK never resends the applied ones. Could consider carrying one deadline per request into the batch, and returning the item that gave up plus the rest as UnprocessedItems rather than failing the batch. ImportTable (import_export.rs:154) has the same per-item shape, though there it's a fresh table.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c36c14. The BatchWriteItem handler now runs inside a request clock scope (the new extenddb_storage::request_clock), and the MongoDB contention loops measure CONTENTION_DEADLINE from the start that scope carries, so the requests of one batch share one 10 s deadline. A request that gives up with the transient error, or with TransactionConflict (DeleteItem's spent budget on this branch), is returned in UnprocessedItems, and the requests after it are still tried, each with at least one attempt. The batch answers the error only when none of its requests was applied, so a client that retries that error resends nothing that took effect. The capacity of an unprocessed request is still counted.

DynamoDB does the same. With TransactWriteItems calls updating one item and client retries off, 46 of 80 [put held, put free] batches and 53 of 80 [put free, put held] batches answered 200 with the held request in UnprocessedItems and the free item written, each reporting the 2 capacity units of both requests. A batch whose only request is on the held item answered 500 InternalServerError in 54 of 80 calls and never 200 with its request unprocessed. ExtendDB answers that batch with 503.

tests/test_mongodb_batch_write_contention.py runs your repro: [delete A, put B], B held past the deadline by a mongosh transaction, another client putting A=v2 at 5 s, and the SDK's default retries on the batch client. On this head the batch answers 200 in 10.01 s with only the put of B in UnprocessedItems, and A holds v2. Against the build before this commit the same test fails: the batch answered 200 after 15.02 s with nothing unprocessed, because the first send answered 503 at the deadline and the SDK resent the batch, and the resend deleted A=v2 and wrote B=v1 once the hold ended. A second test holds B1 for 6 s and B2 past the deadline: on this head the batch answers in 10.02 s with B2 unprocessed. The build before this commit answered after 14.63 s with nothing unprocessed, because B2's own deadline started 6 s in and B2 committed when its hold ended.

ImportTable is not changed. It writes a table it created at the start of the import, so its puts meet contention only when another client writes that table during the import, and it runs outside any scope, so each of its puts keeps a deadline of its own.

/// construction uses. A test sets it short to make the deadline the bound
/// a contended write reaches, or long to make the attempt budget the
/// bound, instead of racing the two.
pub(crate) contention_deadline: std::time::Duration,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

git merge-tree against current main is clean, but cargo check -p extenddb-storage-mongodb --tests on the merged tree fails with E0063: missing field contention_deadline at stream_records_live_tests.rs:46. That literal came in with #543 after this branch merged main. It's the same kind of break as the #384 one the description mentions, and #431 on its own also fails against main (its with_scratch_engine literal). A single test constructor for MongoEngine might save the next round of these.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9212328, after merging main in ac1f3b4. MongoEngine::new now builds the engine in from_parts, and a test-only MongoEngine::for_test calls the same function. The literals in backup_live_tests.rs, stream_records_live_tests.rs, snapshot_engine.rs and the restore backfill test call for_test and set only the fields they change with struct update syntax, so a field added to MongoEngine is set in from_parts and nowhere else. The contention tests moved into data_engine_live_tests_contention.rs and build their engine through the live test module's scratch_engine, setting only contention_deadline. On the merged tree cargo clippy -p extenddb-storage-mongodb --all-targets -- -D warnings is clean and the crate's unit and live tests pass.

# the server is under, so it is reported rather than asserted; what is
# asserted is that a shed put is the only failure a caller can get.
print(f"{len(failures)} of {total} puts were shed, {total - len(failures)} committed")
assert set(failures) <= {SHED}, (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only checks the error class, so any build that answers 503 at the first conflict passes as long as one put commits. That includes the old budget of 50 with the new give-up. The storage tests do pin the budget (the deadline tests and the 256-writer test fail with 50), so it's not unguarded, but a loose floor here, say at least half committed, would give the wire test some teeth without the 0-shed flakiness the description mentions. Similarly, unconditional_put_that_spends_its_attempt_budget_gives_up_with_a_transient_error builds its expected message from TRANSIENT_RETRY_ATTEMPTS and only bounds time from above, so it can't tell which budget ran.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d9e3299. The wire test now also requires at least half of the 2,016 puts to commit, so a server that sheds most of them fails it. I kept the floor at half because the shed count follows the server's load (240 of 2,016 with a second hot-key load on the same server), and the storage tests pin the budget and the deadline. On this head the test passed with 0 of 2,016 puts shed, at a host load average of about 14.

The attempt-budget test now expects "PutItem: the write lost 1000 attempts to concurrent writers; retry the request" written out, and requires the give-up to take at least 5 s. The backoff sleeps of 1,000 attempts alone come to about 6.4 s, against about 0.3 s for 50 attempts. With TRANSIENT_RETRY_ATTEMPTS set to 50 the test fails on the message, which reads "lost 50 attempts", and the whole test finished in 0.56 s.

Comment thread tests/test_put_item_gsi_contention.py Outdated
kwargs["endpoint_url"] = endpoint_url
if endpoint_url.startswith("https://"):
kwargs["verify"] = False
return boto3.client(**kwargs)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this module-scoped client is shared by all 32 writer threads. A client per thread would avoid the occasional TLS error a shared botocore client gives under threads. Here that would fail the test rather than pass it, so it's a flake risk, not a false pass.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d9e3299. Each of the 32 writer threads now gets a client of its own from the raw_clients fixture, so no botocore client is shared between threads. The test passed on this head with 0 of 2,016 puts shed.

/// 503 whichever bound it reaches.
///
/// The deadline is read before an attempt starts, so a request answers within
/// the deadline plus one attempt. It governs the loops that serve a request:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TTL worker reaches one of these loops too: ttl_worker.rs:474 calls DataEngine::delete_item with a condition, so on a GSI or stream table it takes the transaction loop with the deadline. A contended expired item can now hold the sweep for up to 10 s (or 1,000 attempts) where it was 50 attempts before. That's probably fine, but the comment says only request loops take the deadline.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in efe8bc9. The comment now says that the TTL worker's conditional delete runs the delete loop and takes the deadline too, so an expired item that stays contended holds the sweep until the deadline or the 1,000 attempts end that loop, where 50 attempts ended it before. The worker then logs the failure and moves to the next item (ttl_worker.rs, the Err(e) arm after DataEngine::delete_item). The code is unchanged: the sweep runs outside any request clock scope, so each of its deletes measures its own deadline.

// this loop does. The error means another put's item is already in
// the collection, so the next attempt finds that item, replaces it
// and returns it as the old image; if a delete removed that item
// first, the next attempt inserts. Attempts share the transient

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: since 544103c the create race has its own CREATE_RACE_ATTEMPTS budget, so "Attempts share the transient retry budget" here and "uses the same retry budget and backoff as a write conflict" near line 563 are out of date.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in efe8bc9. The sessionless path's comment now says it makes at most CREATE_RACE_ATTEMPTS attempts, and the duplicate-key arm's comment says the transaction path counts each such attempt against CREATE_RACE_ATTEMPTS and gives up with the create race error once that budget is spent.

UpdateItem's native upsert, which came in with main's #473, now counts its create race against CREATE_RACE_ATTEMPTS too (88420dd).

Comment thread docs/differences-from-dynamodb.md Outdated
| Provisioned throughput | Token bucket per table/partition | Token bucket per table/partition, matching DynamoDB's burst and refill behavior |
| On-demand capacity | Automatic scaling | Fixed initial burst capacity (4000 WCU / 12000 RCU), no auto-scaling |
| Throttling | Always on; throttles requests that exceed provisioned/burst capacity. No setting to disable | Configurable via `throttling_enabled` runtime setting (default: `true`) |
| Sustained contention on one item (MongoDB backend) | Applies concurrent single-item writes on one key one after another and answers 200 to each. Measured to every call at 32 and 64 writers putting one key of a table with a GSI. A `PutItem` is answered 400 `TransactionConflictException` only while an in-flight `TransactWriteItems` holds the item (33 of 80 calls measured), and a `TransactWriteItems` that loses to other transactions on one key is answered 400 `TransactionCanceledException` with a `TransactionConflict` reason (107 of 160 calls measured) | A write on a table with a secondary index writes the item and its index entries in one transaction, so concurrent writes on one key abort each other and are retried. One request's retry loop stops after 10 seconds (`CONTENTION_DEADLINE`) or 1,000 attempts, whichever comes first, and the request is then shed instead of waiting out the queue of writers on that key: `PutItem` answers 503 `ServiceUnavailable` on either bound, which the SDK retry modes retry and which succeeds once the contention has passed. `DeleteItem` and `UpdateItem` answer 503 on the deadline and 400 `TransactionConflictException` when their 1,000 attempts run out first. `TransactWriteItems` answers `TransactionCanceledException` with a `TransactionConflict` reason per item on either bound, which is the answer the service gives. Measured with one GSI table and all writers on one key: at 64 writers all 1,280 puts committed and none were shed, with the longest call 9.7 s; at 256 writers 1,262 of 5,120 committed and 3,858 were shed, with the longest call 11.7 s. Without the deadline the same 256-writer shape left calls waiting 99.5 s, so a client sees a shed request where it used to see a request outlive its own read timeout. The other three backends keep writes on one key waiting rather than shedding them, and the deadline does not apply to them |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"at 64 writers all 1,280 puts committed and none were shed" didn't hold on a moderately loaded host here: 106 and 149 of 1,280 were shed at load averages 6.8 and 10.5. The description already says the shed count depends on load. Might be worth saying the same in this user-facing row, or keeping only the 256-writer bound.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in efe8bc9. The row now says that how many requests are shed depends on the load on the server's host, keeps only the 256-writer measurement, and drops the 64-writer claim. It also says how a BatchWriteItem answers a request that reaches the deadline.

@navareamol navareamol added the v1 Needed for the 1.0 release label Nov 3, 2026
@navareamol navareamol added this to the 1.0 milestone Nov 3, 2026
Main added a live test module for the data engine in #473: its tests are
#[ignore]d, read EXTENDDB_TEST_MONGODB_URI through one uri() helper, fail
rather than skip when a run passes --ignored without it, and run in the
mongodb-rust job through devtools/run-mongodb-tests --rust-live. Its
scratch_engine helper builds the engine with MongoEngine::new and struct
update syntax, so a field added to MongoEngine needs no change there.

The two create-race storage tests move into that module as
data_engine_live_tests_put_race.rs. They used their own URI gate, which
printed SKIP without the variable, and a with_scratch_engine helper that
built MongoEngine from a struct literal. Against main that literal missed
the four fields #384 added, and the test build failed with E0063. Both
helpers are gone, and the tests check what they checked before.

devtools/run-mongodb-tests exported EXTENDDB_TEST_MONGODB_URI a second
time for these tests. Main exports it since #473, so the line is removed.
The comment on the duplicate-key arm of the transaction path said the arm
handles a put that lost the create race to another put. It does not. On
MongoDB 7.0, two snapshot transactions that upsert the same _id fail the
second upsert with WriteConflict (code 112, "WiredTigerIdIndex::_insert:
index: _id_") and the TransientTransactionError label, both while the
first transaction is open and after it committed past the second one's
snapshot. TxErr::from already retried that error before this branch.

The comment now says the arm is a defence: it is reached only when a
document in the transaction's snapshot holds the item's _id without
matching the key filter, which is the document the storage tests insert.
test_put_item_create_race.py shared one boto3 client across its 8 writer
threads. A botocore client used from several threads at once can fail a
request with a TLS error, and with retries off and the MongoDB xfail marker
gone, that error fails the test. Each writer now has its own client.
Merged with main, the storage-mongodb test build failed with E0063: the
MongoEngine literals in stream_records_live_tests.rs (from #543) and in
snapshot_engine.rs (from #395) do not set contention_deadline. Each test
that needed an engine listed every field, so each field added to
MongoEngine broke every such literal.

MongoEngine::new now builds the engine in from_parts, and a test-only
MongoEngine::for_test calls the same function with the driver's default
pool of 10 connections. The test literals in backup_live_tests.rs,
stream_records_live_tests.rs, snapshot_engine.rs and the restore backfill
test now call for_test and set only the fields they change with struct
update syntax. A new field is set in from_parts and nowhere else.

The four contention storage tests move from the data_engine test module
into data_engine_live_tests_contention.rs, next to the create-race tests
#431 moved. They build the engine through the live test module's
scratch_engine, are #[ignore]d like the other live tests and run under
devtools/run-mongodb-tests --rust-live, and set the deadline with struct
update syntax. The with_scratch_engine helpers and the second URI gate go
with them. What each test checks is unchanged.
…deadline

BatchWriteItem calls put_item or delete_item once per request, and each
call started its own CONTENTION_DEADLINE, so a batch of contended requests
could keep the client waiting 10 s per request. When a later request gave
up with the transient error, the handler failed the whole batch with HTTP
503 after the earlier requests had committed. The SDKs retry a 503, so the
retry sent those requests again, and a put or delete that another client
had overwritten in between was applied a second time.

The new extenddb_storage::request_clock module carries the start of an API
request in a task-local value. The BatchWriteItem handler runs inside
request_clock::scope, and the MongoDB contention loops measure the deadline
from that start (contention_clock_start), so the requests of one batch
share one deadline. The first attempt of every request still runs, so a
request that comes after the deadline is tried once. Outside a scope a
loop measures from the start of its own call, as before.

A request whose write fails with StorageError::Transient or
StorageError::TransactionConflict is returned in UnprocessedItems, and the
requests after it are still tried. On MongoDB the first is the contention
give-up and the second a spent attempt budget of DeleteItem; neither
applied the write. The batch answers the error only when none of its
requests was applied, so a client that retries it resends nothing that
took effect. Every other error still fails the batch. The capacity of a
request returned unprocessed is still counted.

Amazon DynamoDB answers a batch this way. Measured in us-east-1 with client
retries off while TransactWriteItems calls kept updating one item: 46 of 80
batches [put held, put free] and 53 of 80 batches [put free, put held]
answered 200 with the held item's request in UnprocessedItems and the free
item written, each reporting 2 capacity units, the units of both requests.
54 of 80 batches with only the held request answered 500
InternalServerError, and none answered 200 with its request unprocessed.

The handler change applies to every backend: a batch put or delete that
returns one of these two errors on another backend is now returned in
UnprocessedItems where it failed the batch before.

Tests:
- request_clock: a call sees the start of the scope it runs in, and an
  inner scope replaces it until it ends.
- the_contention_clock_starts_with_the_request_scope: the MongoDB loops
  read the scope's start, and their own start outside a scope.
- live_a_write_in_a_request_scope_measures_the_deadline_from_the_scope_start:
  in a scope that started 5 s ago with a 2 s deadline, a put of a held item
  gives up after one attempt and a put of a free item commits.
- tests/test_mongodb_batch_write_contention.py: the shape from the review,
  [delete A, put B] with B held past the deadline and another client
  putting A at 5 s, now answers 200 with only the put of B unprocessed and
  the other client's A kept; and in a batch whose first request is held
  for 6 s and whose second is held past the deadline, the second request
  gives up when the batch's deadline passes and is returned unprocessed.
…empt budget

tests/test_put_item_gsi_contention.py checked only the error class of the
calls that failed, so a server that gives up at the first conflict passed
as long as one put committed. The test now also requires at least half of
the 2,016 puts to commit. The floor is loose on purpose: the shed count
depends on the server's load (240 of 2,016 with a second hot-key load on
the same server), and the storage tests pin the budget and the deadline.

Each writer thread now has a client of its own. A botocore client shared
by threads can fail a request with a TLS error, which with client retries
off fails the test.

live_unconditional_put_that_spends_its_attempt_budget_gives_up_with_a_transient_error
built its expected message from TRANSIENT_RETRY_ATTEMPTS and bounded the
time only from above, so it also passed with a budget of 50. It now
expects the message for 1,000 attempts written out, and requires the
give-up to take at least 5 s: backoff_sleep sleeps a uniform draw from 0
to 12.8 ms before all but the first eight attempts, so the sleeps of 1,000
attempts alone come to about 6.4 s, against about 0.3 s for 50 attempts.
- The sessionless PutItem path and the duplicate-key arm of the
  transaction path said their attempts share the transient retry budget.
  Both count against CREATE_RACE_ATTEMPTS since the create race got a
  budget of its own.
- CONTENTION_DEADLINE's comment said only the loops that serve a request
  take the deadline. The TTL worker's conditional delete runs the delete
  loop too, so an expired item that stays contended holds the sweep until
  the deadline or the attempt budget ends that loop; the worker then logs
  the failure and moves on. The comment now says so, says that the
  requests of one BatchWriteItem share one deadline, and says that the
  attempt already running when the deadline passes has no bound of its
  own: it waits for a connection from the pool and for the commit's own
  retries, and the longest call at 256 writers on one key measured 11.7 s.
  It also states the cost of retrying for up to 10 s, CPU on the MongoDB
  server for as long as a key stays hot.
- contention_deadline_exhausted printed the deadline with as_secs(), so a
  deadline a test sets below one second read as "0 seconds". It now
  prints as_secs_f64(): "10 seconds", "2 seconds", "0.25 seconds".
- The RFC's decision table still listed the UnknownTransactionCommitResult
  label as a conflict the body retry detects, which the write conflict
  section contradicts. The row now says that label retries
  commitTransaction in place and never re-runs the body.
- The RFC section and the code now name #475, the fix for #437, as the
  change that gives DeleteItem and UpdateItem the transient give-up.
- docs/differences-from-dynamodb.md said all 1,280 puts committed at 64
  writers. On a loaded host 106 and 149 of 1,280 were shed. The row now
  says the shed count depends on the host's load, keeps the 256-writer
  measurement, and says how a BatchWriteItem answers a request that
  reaches the deadline.
0d3f76d rewrote the comment on the transaction path's duplicate-key arm:
a put that loses the create race inside a snapshot transaction gets
WriteConflict 112, so the arm only sees a document that holds the item's
_id without matching the key filter. Two comments in the same function and
the doc of create_race_exhausted still described the arm as the create
race, and now say what it is.

The create race test's client fixture no longer explains why each writer
has its own client; the docstring states what the fixture gives.
…tem-gsi-conflict

Brings #431's b7c9258, which describes the transaction path's
duplicate-key arm as a document that holds the item's _id rather than as
the create race.

The one conflict is the PutItem transaction path's give-up after its loop.
This branch keeps its own version: the loop gives up with the create race
error once CREATE_RACE_ATTEMPTS attempts have met such a document, and a
spent contention budget answers the transient error. The comment on that
check and the doc of create_race_exhausted now use #431's wording, and the
doc says the transaction path gives up after CREATE_RACE_ATTEMPTS attempts
rather than after every attempt.
The comment on CONTENTION_DEADLINE gave the CPU cost of retrying a hot key
for up to 10 s without a measurement from this tree. Measured on this head
with 64 and then 256 writers putting one key of a GSI table for 20 s,
client retries off, on a 16-core host at a load average of 18 to 21:

- 64 writers: 457 puts committed and 12 were shed; mongod used a median of
  132 percent CPU and ran 35,063 transactions, 34,606 of them aborted
  (1,666 a second).
- 256 writers: 150 committed and 444 were shed; mongod used a median of
  128 percent CPU and ran 39,335 transactions, 39,185 of them aborted
  (1,811 a second).
…EATE_RACE_ATTEMPTS

Main's #473 added upsert_native_update, whose create race loop is sized by
TRANSIENT_RETRY_ATTEMPTS. That is 50 on main and 1,000 on this branch, and
the merge ac1f3b4 gave the loop this branch's value with no conflict, so
a lost create race on _id retried up to 1,000 times with backoff, about
6.4 s of backoff before it answered TransactionConflictException, where
main stops after 50. The loop now counts against CREATE_RACE_ATTEMPTS, the
50-attempt budget a lost create race has on the PutItem paths.

Also from the review of this branch before the push: the BatchWriteItem
doc comment says which writes answer TransactionConflict; the differences
row says which error a batch answers when none of its requests was
applied, and what the service answered; and a wire test checks that a
batch whose only request is held past the deadline answers 503 and applies
nothing.
@LeeroyHannigan

Copy link
Copy Markdown
Collaborator Author

The branch now merges main (31f7c6e, main at 03baa32; the first merge was ac1f3b4) and #431's current head (63df25e, #431 at b7c9258). Both merges of #431 conflicted in data_engine.rs. In 3727a0e (#431 at 08c4995) the resolution removes the in-module copies of the two create-race tests that #431 moved into the live test modules, and points #431's put race test at CREATE_RACE_ATTEMPTS. In 63df25e (#431 at b7c9258) the conflict was the PutItem transaction path's give-up after its loop, where this branch keeps its own version and takes #431's wording for the duplicate-key arm.

Both points you would hold the PR for are fixed:

  • BatchWriteItem (6c36c14): the requests of one batch share one deadline, measured from the start of the request through a new task-local request clock, and a request that reaches the deadline or its budget is returned in UnprocessedItems while the others are still tried. The batch answers the error only when none of its requests was applied. Your [delete A, put B] repro is now a MongoDB wire test; details and the DynamoDB measurement are in the inline reply.
  • The test build after merging main (9212328): every test engine is built through one constructor, so a new MongoEngine field is set in one place. The same commit moved the contention tests into the live test module, which removed the orphaned doc comment above hash_key.

The smaller notes:

  • 500s from index and stream counter conflicts on different keys: that is MongoDB: a write conflict on the stream sequence counter answers 500, and BatchWriteItem can answer 500 after one of its puts committed #437, and fix(mongodb): retry a write conflict on the stream sequence counter #475 classifies those conflicts as transient. The description now says so, and says that its "no 400 and no 500" results are for writers on one key of a GSI table without a stream.
  • CPU cost of a hot key: the comment on CONTENTION_DEADLINE and the description now state it. Measured on a release build of 63df25e with 64 and then 256 writers on one key of a GSI table for 20 s, on a 16-core host at a load average of 18 to 21: mongod used a median of 132 and 128 percent CPU and ran 1,666 and 1,811 transactions a second, 98.7 and 99.6 percent of them aborted.
  • Overshoot past 10 s: the comment (efe8bc9) and the description now say that the attempt already running when the deadline passes has no bound of its own, since it waits for a pool connection and the commit's own retries. The longest call measured here at 256 writers was 11.7 s.
  • The RFC decision-table row (efe8bc9) now says the UnknownTransactionCommitResult label retries commitTransaction in place and never re-runs the body.
  • contention_deadline_exhausted (efe8bc9) prints as_secs_f64(), so a 250 ms deadline reads "0.25 seconds"; the unit test covers 2 s, 250 ms and 1.5 s.

Tests run on the final head, 31f7c6e:

  • cargo +1.97.0 fmt --all -- --check and cargo +1.97.0 clippy --all-targets -- -D warnings on extenddb-storage, extenddb-storage-mongodb (also with --features test-hooks) and extenddb-engine: clean.
  • cargo +1.88.0 test --locked on the same three crates, with EXTENDDB_TEST_MONGODB_URI set: 675 passed, 0 failed, 53 ignored over 13 targets.
  • The storage-mongodb live tests (--ignored live) against a MongoDB 7 replica set: 48 passed, 0 failed.
  • MongoDB wire tests on a release build of this head: test_mongodb_batch_write_contention.py 2 passed (10.01 s and 10.02 s), test_put_item_gsi_contention.py passed with 0 of 2,016 puts shed, and main's new test_update_remove_only_missing_item.py passed (64 tests). Against the build before the BatchWriteItem change both batch tests fail; in the repro the batch answered 200 after 15.02 s with nothing unprocessed and the other client's A was deleted.
  • Negative controls: with TRANSIENT_RETRY_ATTEMPTS set to 50 the attempt-budget test fails on its message; with the loops measuring the deadline from their own start, the request scope test fails after 2.01 s.
  • DynamoDB in us-east-1, client retries off, TransactWriteItems calls updating one item H: 46 of 80 [put H, put S] and 53 of 80 [put S, put H] batches answered 200 with the put of H unprocessed and S written, each reporting 2.0 capacity units; 54 of 80 [put H] batches answered 500 InternalServerError.

88420dd came out of a check before this push: main's #473 loop in UpdateItem's native upsert had taken this branch's 1,000-attempt budget in the merge, and now counts against CREATE_RACE_ATTEMPTS (50), as main's loop stopped at 50. The same commit says which writes answer TransactionConflict in the BatchWriteItem doc comment, says in the differences row which error a batch answers when none of its requests was applied, and adds a wire test for that batch (503, nothing applied). At this head the wire tests pass (68), the unit tests pass (675) and the MongoDB live tests pass (48).

test_unconditional_puts_on_one_key_of_a_gsi_table_commit_or_are_shed holds
the backend to the MongoDB retry loop's contract: every contended put
answers 200 or a shed 503. The Cassandra backend answers some of these puts
with 400 TransactionConflictException (17 of 2,016 in this branch's
cassandra-pytest job on 2026-10-24, 31 of 2,016 on 2026-11-03), where the
service answers 200 to all of them. That is a Cassandra write path problem
outside this change, filed as #802, so the test is skipped on Cassandra
until it is fixed. SQLite, PostgreSQL and MongoDB still run it.

This branch has not been deployed

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

Labels

v1 Needed for the 1.0 release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MongoDB: unconditional PutItem on a table with a GSI returns TransactionConflictException under contention

3 participants