Repository navigation
fix(transact): write nothing when a removal-only update finds no item - #513
Open
LeeroyHannigan wants to merge 4 commits into
Open
LeeroyHannigan wants to merge 4 commits into
LeeroyHannigan wants to merge 4 commits into
Conversation
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn,
robinnsc and
yesyayen
as code owners
October 24, 2026 17:31
LeeroyHannigan
force-pushed
the
fix/update-remove-only-missing-item
branch
from
October 30, 2026 09:51
86a6f46 to
f93859d
Compare
LeeroyHannigan
force-pushed
the
fix/transact-update-remove-only
branch
from
October 30, 2026 18:09
bea3d7f to
fdb73a0
Compare
A TransactWriteItems Update whose actions are all REMOVE or DELETE, on a key that holds no item, created an item holding only the key. Amazon DynamoDB writes nothing for such an update: no item, no index entry and no stream record, while the transaction's other items are written as before and ConsumedCapacity reports the same 2.0 capacity units as a SET. The update's own condition still runs, so a condition that fails cancels the transaction. Each backend has its own transaction update path, so the single-item decision in extenddb_core::expression::removes_only did not reach them. Every path now applies it after the actions have run against the key-only image, so validation errors are raised exactly as before, and returns no images, which keeps the stream record and the index work out of the operation. PostgreSQL reserves the key first, the way a delete of a missing item does, so a concurrent create cannot commit between the condition check and the end of the transaction. Cassandra decides in PREPARE, which is the phase that reads the pre-image: its marker row carries the key in item_data, so COMMIT cannot tell that row from an item that was already there. PREPARE records no post-image for the update, and COMMIT and recovery then delete the marker instead of writing it. The new wire tests cover the removal-only shapes on both key shapes, the conditions, ReturnValuesOnConditionCheckFailure, ConsumedCapacity TOTAL and INDEXES, ReturnItemCollectionMetrics, ClientRequestToken replay and reuse, index entries, streams, the shapes that must still create the item, and the existing-item paths. The stream case is skipped on the Cassandra backend, which writes no stream record for any transaction operation. Every expected response was measured against Amazon DynamoDB in us-east-1 on 2026-10-24. Fixes #510
…e skew Two transactions each run a conditional removal-only Update on a key the other one creates. The update writes nothing, but its condition read that the key was absent, so at most one of the two commits. Measured on Amazon DynamoDB on 2026-10-24: none of 40 rounds committed both. On PostgreSQL this is the case the key reservation on the removal-only path exists for.
The case was skipped on Cassandra because its transaction path wrote no stream record for any operation. #576 changed that, so the skip's reason no longer holds. On Cassandra the case passes, and without the removal-only fix it fails with an INSERT record for the removed key.
The doc comment on commit_single_operation said COMMIT cannot tell PREPARE's marker row from an item that was already there. Since #576 COMMIT re-reads the row together with created_to_prepare, which does tell them apart. The decision is made in PREPARE and recorded in the ledger because recovery replays from the ledger, which carries item_data and not the update's actions.
LeeroyHannigan
force-pushed
the
fix/update-remove-only-missing-item
branch
from
October 31, 2026 17:48
f93859d to
4f062df
Compare
LeeroyHannigan
force-pushed
the
fix/transact-update-remove-only
branch
from
October 31, 2026 17:48
06428af to
02b497c
Compare
This branch has not been deployed
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.
Fixes #510. Stacked on #508, which now sits on main at f49c5f8 with #473 merged.
What ExtendDB did
A
TransactWriteItemsrequest with anUpdatewhose actions are allREMOVEorDELETE,on a key that holds no item, created an item holding only the key.
REMOVE a,REMOVE a, b,DELETE s :vandREMOVE awithattribute_not_exists(pk)each answered 200,and
GetItemthen returned{"pk": ...}. In a transaction with a removal-only update on onekey and a
SETon another, both keys ended up holding an item.Each backend has its own transaction update path, so the single-item decision in
extenddb_core::expression::removes_onlyfrom #508 did not reach it.What DynamoDB does
Measured 2026-10-24 in us-east-1 with client retries off, on a hash table and a hash and
range table, and for the index, capacity and stream cases on a hash and range table with a
one-attribute GSI and an LSI, both streams set to NEW_AND_OLD_IMAGES.
GetItemafterwards returns no item.REMOVE a.b[0]andREMOVE a DELETE s :valso write nothing.SET, or a removal-only update and aPut, writes only the other item.ReturnConsumedCapacityTOTAL reports 2.0 capacity units and 2.0 write capacity units forthe removal-only update, the same as for a
SETcontrol. A replay under the sameClientRequestTokenreports 2.0 capacity units and 2.0 read capacity units, and a replay ofthat token with a different body answers
IdempotentParameterMismatchException.ReturnConsumedCapacityINDEXES reports the table at 2.0 and 2.0 with no per-index member,where the
SETcontrol reports 4.0 with 1.0 on the GSI and 1.0 on the LSI.ReturnItemCollectionMetricsSIZE reports the item collection key from the request key witha size estimate range of 0.0 to 1.0 GB.
SETin the sametransaction gives one index entry each and one INSERT record.
ConditionalCheckFailedand the messageThe conditional request failed, andReturnValuesOnConditionCheckFailureALL_OLD carries noItemfor a missing key and thepre-update item for an existing one.
SETandADD, alone or beside a removal, still create the item. A removal-only update anda
ConditionCheckon one key is refused withTransaction request cannot include multiple operations on one item.The change
Every backend applies
removes_onlyafter the actions have run against the key-only image, sovalidation errors are raised exactly as before, and returns no images, which keeps the stream
record and the index work out of the operation.
upsert_item_in_tx.create cannot commit between the condition check and the end of the transaction. When a
create wins that race the reservation returns the winner and the normal write path runs
against it.
replace_onein the transaction session.records no post-image for the update and leaves its
created_to_preparemarker planted, so aconcurrent create still collides as it does for a
ConditionCheckon an absent item. COMMITand recovery then delete the marker instead of writing it. Recovery needs the missing
post-image to do that: the ledger op carries
item_dataand not the update's actions, sorecovery cannot evaluate
removes_onlyitself. When a server without this change recovers atransaction that a server with this change started, as during a rolling upgrade or after a
downgrade, its recovery stops at the missing
item_data, and the marker stays until a serverwith this change recovers the transaction.
Rollback needed no change: the marker carries
item_data, so the rollback fetch sees it anddeletes it on the
created_to_preparepath.Tests
tests/test_transact_update_remove_only.py, 48 cases. Every expected response traces to oneof the measurements above. The file covers the five removal-only shapes on both key shapes, the
condition that passes and the condition that fails,
ReturnValuesOnConditionCheckFailureon amissing and on an existing item,
ReturnConsumedCapacityTOTAL and INDEXES,ReturnItemCollectionMetricsSIZE,ClientRequestTokenreplay and reuse with another body, aremoval-only update beside a
SET, aPutand aConditionCheck, GSI and LSI entries,streams, the shapes that must still create the item, and the existing-item paths. A later write
to the same key is checked as well, so nothing is left reserved behind.
On SQLite and on Cassandra, the same 24 of the 48 fail on a binary built from #508's head
at the time,
f93859dc, and all 48 pass with this change.tests/test_transact_absent_item_isolation.pygainstest_removal_only_updates_of_missing_items_do_not_write_skew: two transactions each run aconditional removal-only
Updateon a key the other one creates. The update writes nothing,but its condition read that the key was absent, so at most one of the two commits. On DynamoDB,
measured 2026-10-24, none of 40 rounds committed both. This is the case PostgreSQL's key
reservation covers: with the reservation disabled, 38 to 39 of 40 rounds committed both. The
test carries the file's existing MongoDB marker: MongoDB has the write skew the file's other
tests mark, and this test fails there by the same assertion.
test_no_stream_record_for_a_removal_only_updatein the new file runs on every backend. It wasskipped on Cassandra, whose transaction path wrote no stream record before #576; on #508's
head it fails there with an
INSERTrecord for the removed key.Gates
The list below was measured on
bea3d7f3, before this branch was rebased onto #508's headat the time,
f93859dc, which brought in #576. After the rebase, CI passed 30 of 30 onfdb73a0be,including the Cassandra, MongoDB and SQLite wire test jobs. A rerun of the new file on
Cassandra 4.1 on 2026-10-31, with a release build started directly rather than through
devtools/run-tests, passed 48 of 48, including the stream case. CI then passed 30 of 30 on06428af2e, where the Cassandra job ran all 48 tests of the new file, the stream caseincluded.
cargo +1.97.0 fmt --all -- --check: clean.cargo +1.97.0 clippy --all-targets -- -D warnings: clean.cargo test --workspace --exclude extenddb-storage-cassandrapluscargo test -p extenddb-storage-cassandra --libon stable: 1,842 passed, 0 failed.cargo +1.88.0 testover the same selection (MSRV): 1,842 passed, 0 failed.devtools/run-tests --extenddb --pytestas.github/workflows/integration.ymlruns it. The new file: 48 passed on SQLite, 48 onPostgreSQL 16, 48 on MongoDB 7, and 47 passed on Cassandra 4.1, where the stream case was
skipped. The transaction and update regression set: 436 passed and 3 skipped on SQLite, 478
passed and 3 skipped on PostgreSQL, 433 passed with 3 skipped and 4 xfailed on MongoDB, 263
passed and 3 skipped on Cassandra. Two
tests/test_gsi_async.pypropagation tests fail onthis host on the base as well as on this branch.
Cargo.lockis unchanged, so the license notice scripts were not needed.Related behaviour on main
ClientRequestTokenreplay that addsReturnConsumedCapacityto the first request's bodyis served as a replay; DynamoDB answers
IdempotentParameterMismatchException. This happens onmain for any update shape.
ReturnConsumedCapacityINDEXES on a transaction reports no per-index breakdown; the enginesays so at
crates/engine/src/transact_write_items.rs. The service reports 1.0 on each indexfor a
SETcontrol. For a removal-only update the service reports no per-index membereither, and that is the shape the new test asserts.
Rebase on main
#473 merged into main on 31 October as bf73446, and #508 was moved onto main at f49c5f8 (#473, then #429) as 4f062df. This branch now holds its own four commits on that head, so with #508 it carries eight commits over main. The rebase hit no conflict, and
git range-diff f93859dc5..06428af2e 4f062dfc5..02b497ca1reports all four commits unchanged.Gates on the new head, 02b497c:
cargo +1.97.0 fmt --all --checkclean;cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warningsclean;cargo +1.97.0 check --locked --workspace --all-targetsandcargo +1.88.0 check --locked --workspaceclean;cargo test --libon the six touched crates: 1,311 passed, 0 failed (extenddb-core 717, extenddb-storage 139, extenddb-storage-sqlite 166, extenddb-storage-postgres 63, extenddb-storage-mongodb 151 with the 45 live tests ignored, extenddb-storage-cassandra 75). Against servers built from this head,tests/test_transact_update_remove_only.pypassed 48 of 48,tests/test_transact_absent_item_isolation.py6 of 6,tests/test_update_remove_only_missing_item.py64 of 64 andtests/python/test_transactions.py16 of 16, each on SQLite and on PostgreSQL 16. The MongoDB live tests indata_engine::live_tests::passed 17 of 17 against a mongo:7 replica set.On servers built from #508's new head 4f062df, without this change, the same 24 of the 48 fail on SQLite and on PostgreSQL, with "the item was created" and an INSERT stream record for the removed key.
test_removal_only_updates_of_missing_items_do_not_write_skewpasses on that head on both backends: on PostgreSQL because the old path writes a key-only item and that write takes the row lock, on SQLite because the engine write lock serializes the two transactions whatever the update writes. On this head with the PostgreSQL key reservation removed, 25 of 40 rounds committed both. The Cassandra wire job was not rerun here; CI ran it on 06428af, and this head carries the same four commits with the same diffs.CI passed all 30 checks on
02b497ca1.