Skip to content

fix(transact): write nothing when a removal-only update finds no item - #513

Open
LeeroyHannigan wants to merge 4 commits into
fix/update-remove-only-missing-itemfrom
fix/transact-update-remove-only
Open

LeeroyHannigan wants to merge 4 commits into
fix/update-remove-only-missing-itemfrom
fix/transact-update-remove-only

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #510. Stacked on #508, which now sits on main at f49c5f8 with #473 merged.

What ExtendDB did

A TransactWriteItems request with an Update whose actions are all REMOVE or DELETE,
on a key that holds no item, created an item holding only the key. REMOVE a,
REMOVE a, b, DELETE s :v and REMOVE a with attribute_not_exists(pk) each answered 200,
and GetItem then returned {"pk": ...}. In a transaction with a removal-only update on one
key and a SET on 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_only from #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.

  • Each of the four updates above answers 200 and GetItem afterwards returns no item.
  • REMOVE a.b[0] and REMOVE a DELETE s :v also write nothing.
  • A transaction holding a removal-only update and a SET, or a removal-only update and a
    Put, writes only the other item.
  • ReturnConsumedCapacity TOTAL reports 2.0 capacity units and 2.0 write capacity units for
    the removal-only update, the same as for a SET control. A replay under the same
    ClientRequestToken reports 2.0 capacity units and 2.0 read capacity units, and a replay of
    that token with a different body answers IdempotentParameterMismatchException.
  • ReturnConsumedCapacity INDEXES reports the table at 2.0 and 2.0 with no per-index member,
    where the SET control reports 4.0 with 1.0 on the GSI and 1.0 on the LSI.
  • ReturnItemCollectionMetrics SIZE reports the item collection key from the request key with
    a size estimate range of 0.0 to 1.0 GB.
  • No GSI or LSI entry appears, and no stream record is emitted, while the SET in the same
    transaction gives one index entry each and one INSERT record.
  • The update's own condition still runs: a condition that fails cancels the transaction with
    ConditionalCheckFailed and the message The conditional request failed, and
    ReturnValuesOnConditionCheckFailure ALL_OLD carries no Item for a missing key and the
    pre-update item for an existing one.
  • SET and ADD, alone or beside a removal, still create the item. A removal-only update and
    a ConditionCheck on one key is refused with Transaction request cannot include multiple operations on one item.

The change

Every backend applies removes_only 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.

  • SQLite returns before upsert_item_in_tx.
  • 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. When a
    create wins that race the reservation returns the winner and the normal write path runs
    against it.
  • MongoDB returns before replace_one in the transaction session.
  • Cassandra decides in PREPARE, from the read it makes before it plants the marker. PREPARE
    records no post-image for the update and leaves its created_to_prepare marker planted, so a
    concurrent create still collides as it does for a ConditionCheck on an absent item. COMMIT
    and recovery then delete the marker instead of writing it. Recovery needs the missing
    post-image to do that: the ledger op carries item_data and not the update's actions, so
    recovery cannot evaluate removes_only itself. When a server without this change recovers a
    transaction 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 server
    with this change recovers the transaction.

Rollback needed no change: the marker carries item_data, so the rollback fetch sees it and
deletes it on the created_to_prepare path.

Tests

tests/test_transact_update_remove_only.py, 48 cases. Every expected response traces to one
of the measurements above. The file covers the five removal-only shapes on both key shapes, the
condition that passes and the condition that fails, ReturnValuesOnConditionCheckFailure on a
missing and on an existing item, ReturnConsumedCapacity TOTAL and INDEXES,
ReturnItemCollectionMetrics SIZE, ClientRequestToken replay and reuse with another body, a
removal-only update beside a SET, a Put and a ConditionCheck, 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.py gains
test_removal_only_updates_of_missing_items_do_not_write_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. 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_update in the new file runs on every backend. It was
skipped on Cassandra, whose transaction path wrote no stream record before #576; on #508's
head it fails there with an INSERT record for the removed key.

Gates

The list below was measured on bea3d7f3, before this branch was rebased onto #508's head
at the time, f93859dc, which brought in #576. After the rebase, CI passed 30 of 30 on fdb73a0be,
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 on
06428af2e, where the Cassandra job ran all 48 tests of the new file, the stream case
included.

  • cargo +1.97.0 fmt --all -- --check: clean.
  • cargo +1.97.0 clippy --all-targets -- -D warnings: clean.
  • cargo test --workspace --exclude extenddb-storage-cassandra plus
    cargo test -p extenddb-storage-cassandra --lib on stable: 1,842 passed, 0 failed.
  • cargo +1.88.0 test over the same selection (MSRV): 1,842 passed, 0 failed.
  • Wire tests on all four backends, through devtools/run-tests --extenddb --pytest as
    .github/workflows/integration.yml runs it. The new file: 48 passed on SQLite, 48 on
    PostgreSQL 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.py propagation tests fail on
    this host on the base as well as on this branch.
  • Cargo.lock is unchanged, so the license notice scripts were not needed.

Related behaviour on main

  • A ClientRequestToken replay that adds ReturnConsumedCapacity to the first request's body
    is served as a replay; DynamoDB answers IdempotentParameterMismatchException. This happens on
    main for any update shape.
  • ReturnConsumedCapacity INDEXES on a transaction reports no per-index breakdown; the engine
    says so at crates/engine/src/transact_write_items.rs. The service reports 1.0 on each index
    for a SET control. For a removal-only update the service reports no per-index member
    either, 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..02b497ca1 reports all four commits unchanged.

Gates on the new head, 02b497c: cargo +1.97.0 fmt --all --check clean; cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings clean; cargo +1.97.0 check --locked --workspace --all-targets and cargo +1.88.0 check --locked --workspace clean; cargo test --lib on 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.py passed 48 of 48, tests/test_transact_absent_item_isolation.py 6 of 6, tests/test_update_remove_only_missing_item.py 64 of 64 and tests/python/test_transactions.py 16 of 16, each on SQLite and on PostgreSQL 16. The MongoDB live tests in data_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_skew passes 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.

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.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant