Skip to content

Cassandra: a single-item write inside a transaction's PREPARE window is accepted undetected, and a deleted item is recreated by the transaction #578

Open

Description

@LeeroyHannigan

Summary

On the Cassandra backend a single-item write that commits inside a transaction's PREPARE window is neither serialised nor detected, and an item deleted in that window is recreated by the transaction. Read from crates/storage-cassandra/src/data/transactions.rs at main 744ea29 and measured against a cassandra:4.1 node on 2026-10-25.

Mechanism

Each PREPARE arm reads the row (fetch_item_for_transaction), evaluates the condition, then plants its marker with an LWT: UPDATE ... SET prepared_txn_id = ? ... IF prepared_txn_id = null on an existing row, INSERT ... IF NOT EXISTS with created_to_prepare = true on an absent one. Single-item writes are fenced out of the commit phase (occ_write's LWT is IF version = ? AND prepared_txn_id = null, and the single-item delete refuses when prepared_txn_id is set), but not out of the window between the transaction's read and its marker: a single-item write committing there leaves prepared_txn_id null, so the marker's own LWT applies and the transaction proceeds with a pre-image one write stale.

Measured: the marker statement UPDATE ... SET prepared_txn_id = ? WHERE pk = <absent> IF prepared_txn_id = null applies on a row that does not exist and creates it with item_data null. So when a single-item DeleteItem lands in the window, the transaction's Update arm finds a marker row with no item, computes its post-image from the bare key plus the actions, and commits it: the deleted item comes back. A transacted Delete in the same race applies its DELETE ... IF prepared_txn_id = ? against the recreated marker.

With #576 the stream records what the row held at commit time, so the record no longer claims a transition that did not happen; the write itself is still accepted and the resurrection still occurs.

Shapes

Single-item write in the PREPARE window Transaction op Result on main
PutItem A to B Put or Delete the transaction's condition was evaluated against A; the op applies
PutItem A to B Update post-image is f(B), condition evaluated against A
DeleteItem of A Update the row is recreated as key plus actions
DeleteItem of A Delete the marker row is created and then deleted; two deletes for one item

The service's TransactWriteItems serialises against concurrent single-item writes on the same item (a conflicting write cancels the transaction with TransactionConflict, or the transaction waits), and never recreates a deleted item.

Fix shape

Make the marker LWT carry the read's version: IF prepared_txn_id = null AND version = ? for an existing row (the single-item path already fences on version), and for an absent row keep INSERT ... IF NOT EXISTS, so a single-item write between the read and the marker fails the marker's LWT and the PREPARE phase re-reads or cancels with TransactionConflict. A storage test that interleaves a single-item write between a transaction's read and its marker (a hook or a split PREPARE in a test build) and asserts the transaction cancels or re-reads; a wire test with a tight loop of PutItem and TransactWriteItems on one key asserting every record's OldImage equals the previous record's NewImage.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    backend:cassandraRelated to the Cassandra backend specificallybugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions