Skip to content

fix(update): write nothing for a removal-only UpdateItem on a missing key - #508

Open
LeeroyHannigan wants to merge 4 commits into
fix/mongodb-update-creates-keyfrom
fix/update-remove-only-missing-item
Open

LeeroyHannigan wants to merge 4 commits into
fix/mongodb-update-creates-keyfrom
fix/update-remove-only-missing-item

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #473 (fix/mongodb-update-creates-key): the branch starts at its head, 4e6ccc9, and the PR targets that branch, so MongoDB runs this change with #473's key fix underneath. GitHub retargets it to main when #473 merges.

Summary

UpdateItem on a key that holds no item created a key-only item whenever the update only removed attributes (REMOVE, DELETE, or the legacy AttributeUpdates form with only DELETE actions). Amazon DynamoDB writes nothing for such a request. This change makes all four backends write nothing in that case.

Fixes #478

What changed

  • extenddb_core::expression::removes_only(actions) is the shared decision: true when the action list is non-empty and every action is REMOVE or DELETE. An update with no actions at all is not covered, because the service does create a key-only item for it.
  • Every backend applies the actions to the key-only image as before, so validation errors are raised exactly as they were, then skips the write, the index maintenance and the stream record when the item was absent and removes_only holds, returning no old and no new image.
    • SQLite (crates/storage-sqlite/src/data/update_item.rs): return before upsert_item_in_tx.
    • PostgreSQL (crates/storage-postgres/src/data/update_item.rs): return before the INSERT ... ON CONFLICT DO NOTHING.
    • Cassandra (crates/storage-cassandra/src/data/update_item.rs): return before the TTL claim and the LWT write.
    • MongoDB (crates/storage-mongodb/src/data_engine.rs): the native fast path runs findAndModify with upsert: false for removal-only updates and answers from the result; the transaction path ends in a new NoWrite outcome that aborts the read-only transaction; the pushdown path returns before replace_one.
  • The DataEngine::update_item contract in crates/storage/src/lib.rs documents the exception.
  • The engine (crates/engine/src/update_item.rs) is unchanged: with no new image it already omits Attributes for every ReturnValues, charges one write unit, and derives ItemCollectionMetrics from the key, which is what the service returns.

Service measurements

us-east-1, client retries off. Tables: bb-r2-idx (pk S, a GSI on g1pk, no LSI, stream NEW_AND_OLD_IMAGES) and bb-arn-main (pk S, sk S, a GSI gsi1 on g1pk and an LSI lsi1 on l1sk, stream on). Every row below was measured on both tables with the same result, except the rows marked bb-arn-main: ItemCollectionMetrics needs an LSI, which only bb-arn-main has. Fresh key per case; "no item" means GetItem with ConsistentRead returned no Item afterwards.

UTC request on a missing key response item afterwards
2026-10-24 08:45:13 REMOVE a, ReturnValues NONE, ALL_OLD, UPDATED_OLD, ALL_NEW, UPDATED_NEW 200 {} (no Attributes for any of them) no item
2026-10-24 08:45:13 REMOVE a, b, ReturnValues NONE, ALL_NEW, UPDATED_NEW 200 {} no item
2026-10-24 08:45:13 REMOVE a.b[0] 200 {} no item
2026-10-24 08:45:13 DELETE s :v (SS), ReturnValues NONE and ALL_NEW 200 {} no item
2026-10-24 08:45:13 REMOVE a DELETE s :v, ALL_NEW 200 {} no item
2026-10-24 08:45:13 REMOVE a with attribute_not_exists(pk), ALL_NEW 200 {} no item
2026-10-24 08:45:13 REMOVE a with attribute_exists(pk) ConditionalCheckFailedException no item
2026-10-24 08:45:13 AttributeUpdates {"a": {"Action": "DELETE"}}, NONE and ALL_NEW 200 {} no item
2026-10-24 08:45:13 AttributeUpdates two DELETE actions without Value 200 {} no item
2026-10-24 08:45:13 AttributeUpdates {"s": {"Action": "DELETE", "Value": {"SS": ["x"]}}}, NONE and ALL_NEW 200 {} no item
2026-10-24 08:45:13 REMOVE a, ReturnConsumedCapacity TOTAL ConsumedCapacity: {CapacityUnits: 1.0, TableName} no item
2026-10-24 08:45:13 REMOVE a, ReturnConsumedCapacity INDEXES {CapacityUnits: 1.0, Table: {CapacityUnits: 1.0}} no item
2026-10-24 08:45:13 REMOVE a, ReturnItemCollectionMetrics SIZE (bb-arn-main) ItemCollectionMetrics with the ItemCollectionKey and [0.0, 1.0] no item
2026-10-24 11:14:27 REMOVE g1pk, l1sk, ALL_NEW (bb-arn-main) 200 {}; queries on gsi1 and lsi1 return nothing for the key no item
2026-10-24 11:14:27 REMOVE g1pk, ReturnConsumedCapacity INDEXES (bb-arn-main) {CapacityUnits: 1.0, Table: {CapacityUnits: 1.0}}, no GlobalSecondaryIndexes member; the SET g1pk = :v control charges 2.0 with gsi1 1.0 no item
2026-10-24 12:35:49 REMOVE a ADD n :v, DELETE s :s ADD n :n and ADD n :v REMOVE a, ALL_NEW (bb-arn-main, twice each) 200, Attributes {pk, sk, n: 1} item {pk, sk, n: 1} created
2026-10-24 08:45:13 to 08:45:34 REMOVE a on a stream-enabled table, LATEST iterators drained for 10 s 0 stream records for that key; the SET control on the same table produced one INSERT record no item
2026-10-24 08:45:13 controls: SET a = :v; ADD n :v; REMOVE a SET b = :v; AttributeUpdates PUT; AttributeUpdates DELETE plus PUT 200, ALL_NEW returns the created item item created
2026-10-24 08:45:13 AttributeUpdates {} (no actions) 200 {} key-only item created
2026-10-24 08:49:02 UpdateItem with no UpdateExpression and no AttributeUpdates 200 {} key-only item created
2026-10-24 08:45:13 existing item {pk, a, b}: REMOVE a then REMOVE b, ALL_NEW Attributes {pk, b} then {pk} key-only item remains

Tests

  • tests/test_update_remove_only_missing_item.py (new, dual-target): 64 tests over a hash table, a hash and range table, and a hash and range table with a GSI and an LSI.
    • Removal-only updates on a missing key under every ReturnValues, nested paths, set DELETE, conditions, the legacy DELETE forms and a stream-enabled table.
    • On the index table: no index entry, ItemCollectionMetrics from the key, and ReturnConsumedCapacity INDEXES charging the table only.
    • Two tests write a control item to the table before the removal-only update. On MongoDB, a table that has taken a write and has no GSI serves later updates through the native fast path, and a conditional one through the pushdown path; the earlier tests reach neither. With the removal-only branch disabled in either path, its two tests fail and the rest of the file passes.
    • Controls that still create the item: SET, ADD, REMOVE plus SET, REMOVE plus ADD, DELETE plus ADD, legacy DELETE plus PUT, AttributeUpdates {}, and REMOVE on an existing item.
  • At the head, the file passes on SQLite, PostgreSQL, MongoDB and Cassandra (64 of 64 on each). On main with SQLite, 46 fail and 18 pass; the 18 are the controls.
  • crates/core/src/expression/ast.rs: unit tests for removes_only.
  • crates/storage-sqlite/src/data/update_item_tests.rs: in-memory backend tests for the no-write decision, the SET, ADD, mixed and no-action controls, and REMOVE on an existing item.
  • crates/storage-mongodb/src/data_engine_live_tests.rs: fix(mongodb): store the key when UpdateItem creates an item #473's live test of an UpdateItem that creates an item had a REMOVE case that expected a key-only document. That case is now its own live test, live_remove_only_update_on_a_missing_key_writes_nothing: no image, no document, and a PutItem on the key still creates the item. The MongoDB live tests pass 8 of 8 against a replica set.
  • Wire tests at the first revision, every test file that exercises UpdateItem (26 files) plus the new file: SQLite 667 passed, 6 skipped, 1 xfailed; PostgreSQL 665 passed, 8 skipped, 1 xfailed; MongoDB with fix(mongodb): store the key when UpdateItem creates an item #473 merged in, 666 passed, 0 failed. The 26-file Cassandra run could not finish on this host: the single Cassandra node stopped answering partway through, while the new file had passed.
  • crates/storage-mongodb/src/data_engine.rs (4,475 lines) and crates/storage/src/lib.rs (1,028 lines) were over 600 lines before this change, which adds 44 and 4 lines to them; they are not split here.

Interactions with open PRs

Not changed here

  • TransactWriteItems with an Update whose actions are all REMOVE or DELETE on a missing key still creates a key-only item on every backend. The service writes nothing for it (measured 2026-10-24 08:49:02 UTC, REMOVE a, REMOVE a, b, DELETE s :v, and REMOVE a with attribute_not_exists(pk), both tables, ConsumedCapacity 2.0 write units like the SET control). The transaction update path is separate code in each backend, so it is a separate change. No issue is filed for it yet.
  • REMOVE a SET a = :v on the service is rejected with "Two document paths overlap with each other; must remove or rewrite one of these paths; path one: [a], path two: [a]" (measured 2026-10-24 08:49:02 UTC). Not checked against ExtendDB here.

UpdateItem on a key that holds no item used to create a key-only item
whenever the update expression consisted solely of REMOVE or DELETE
actions, and the legacy AttributeUpdates form with only DELETE actions
did the same. Amazon DynamoDB writes nothing for such a request: no item
is created, no stream record is emitted, and the response carries no
Attributes under any ReturnValues, while ConsumedCapacity still reports
one write unit. SET, ADD and an update with no actions at all still
create the item.

The decision lives in one place, extenddb_core::expression::removes_only,
and every backend applies it after the actions have run against the
key-only image, so validation errors are raised exactly as before. SQLite,
PostgreSQL and Cassandra return before their insert; MongoDB runs the
native fast path without upsert, ends the transaction path with a
NoWrite outcome, and returns from the pushdown path before replace_one.

Fixes #478
In-memory backend tests for the no-write decision: REMOVE, DELETE and
their combinations on a missing key return no image and create no item;
SET, ADD, a mixed expression and an update with no actions still create
the item; REMOVE on an existing item still removes the attribute.
The removal-only tests now also run on a table with a GSI and an LSI: no
item and no index entry appears, ItemCollectionMetrics comes from the key,
and ReturnConsumedCapacity INDEXES charges the table only, as the service
answers. Two tests write to the table before the removal-only update, so a
backend that serves later updates on a table through another path is
covered there too. REMOVE plus ADD and DELETE plus ADD on a missing key
still create the item with the added number, as measured on the service.
The live test of an UpdateItem that creates an item had a REMOVE case
that expected the update to create a key-only document. With removal-only
updates on a missing key writing nothing, that case is now its own live
test: no old or new image, no document, and a PutItem on the key still
creates the item. The test table records that it has no GSI, so the
update takes the native fast path.

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