Repository navigation
fix(cassandra): a Scan reads on past rows without an item - #612
Open
LeeroyHannigan wants to merge 3 commits into
Open
LeeroyHannigan wants to merge 3 commits into
LeeroyHannigan wants to merge 3 commits into
Conversation
Scan skipped rows without item_data after Cassandra had applied the LIMIT, and decided "more pages" from the items it kept. Such rows are the static-only row of a partition whose items have all been deleted, on a table with a sort key; the row a TTL claim on an absent key writes; and the row a transaction prepare leaves on an item deleted after the transaction read it. A read that met enough of them came back short with no LastEvaluatedKey, so the Scan ended early, and so did CreateBackup, export, and the TTL backfill and audit, which page through the same function. Scan now decides "more pages" from the rows Cassandra returned, so a short page carries a LastEvaluatedKey. When every row of a full read holds no item, Scan reads on after the last row it read: a base-table read also selects pk and the sort key column, and the next read goes on within the partition after a row with a sort key and from the next token after a row without one. Each further read takes up to twice as many rows and at most max(1000, LIMIT + 1), and no read goes back over rows already read. Cassandra counts a tombstone for each deleted row in the partitions such a read passes, so a continuation read that Cassandra refuses with a read failure is read again with half the rows, down to one, and later reads in the same call stay at that size. The row handling, the continuation and the two-query read that resumes after an ExclusiveStartKey are in the new data/scan_rows.rs; scan.rs and scan_paginated use it.
Thirty live partitions among 300 emptied ones and 20 claim rows, on a table with a sort key and on a hash-only table, paged with no Limit and with Limit 1, 2, 7 and 50, and as two parallel segments; a run of claim rows inside one partition ahead of its item; a run of 5,000 emptied partitions between items, after which Cassandra's tombstones_per_read histogram for the table must show no read of 1,500 tombstones or more; a table with no item left; and a table of 101,000 emptied partitions, more than Cassandra's tombstone failure threshold. Claim rows among items with number, binary and string sort keys, read back and bound again by the continuation; and 2,100 emptied partitions that each held 110 rows, with one item after them in token order, which a read of 1,000 rows cannot pass without a read failure.
Dual-target wire tests from the service measurements on 2026-10-26. At 15:29 UTC: every live item is returned and only live items are counted in ScannedCount, with no Limit, with Limit 1, 7 and 50, across two parallel segments, with Select COUNT and with a FilterExpression, on a table with a sort key and on a hash-only table. At 16:39 UTC: a table whose items have all been deleted answers Count 0, ScannedCount 0 and no LastEvaluatedKey.
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn,
robinnsc and
yesyayen
as code owners
October 26, 2026 19:49
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.
On Cassandra, a Scan now returns every item of a table that holds partitions whose items have all been deleted. Before, the Scan stopped early with no LastEvaluatedKey, and CreateBackup, export, and the TTL backfill and audit stopped early the same way, because they page through the same storage function.
This PR fixes no open issue. #611 fixes the same defect in Query.
What happened
crates/storage-cassandra/src/data/scan.rsasks Cassandra forLIMIT limit + 1rows and skips each row withoutitem_data. Such rows are:partition_max_delete_timestamp), on a table with a sort key;prepared_txn_idandprepared_txn_timestampset, on any table;UPDATE ... IF prepared_txn_id = nulllands on an item deleted after the transaction read it.Scan then decided "more pages" from the number of items it kept. A read that met such rows came back with fewer items than its LIMIT and no LastEvaluatedKey, so every caller took it as the end of the table.
What the service answers
Measured on 2026-10-26 in us-east-1, each case twice with the same answer.
SelectCOUNT and with a FilterExpression. Every case read withConsistentRead, and the Scan with no Limit was also read eventually consistent.The change
scan.rsdecides "more pages" from the rows Cassandra returned, as #611 does for Query. A page that skipped rows can be short but carries a LastEvaluatedKey, and the caller reads on from its last item.A full read in which no row holds an item has no item to continue from. Scan then reads on after the last row it read: a base-table read now also selects
pkand the sort key column, and the next read ispk = ? AND sk > ?for a row with a sort key (a claim row) andtoken(pk) > token(?)for a row without one (a static-only row, or any row of a hash-only table). Each further read takes up to twice as many rows as the one before and at mostmax(1000, LIMIT + 1). No read goes back over rows already read.Cassandra counts a tombstone for each deleted row in the partitions such a read passes, so when those partitions held many deleted rows, a read of 1,000 rows can still reach
tombstone_failure_threshold(100,000 by default). A continuation read that Cassandra refuses with a read failure is read again with half as many rows, down to one, and the later reads of the same storage call stay at that size. The first read of a page is not retried: it reads the same rows as the readmainmakes, and fails wheremainfails.Index tables never hold a row without
item_data, and their reads are unchanged. The row handling (skipping rows, the position of the last row, the two-query read, the retry) is in the newcrates/storage-cassandra/src/data/scan_rows.rs; the two-query read that resumes after an ExclusiveStartKey moved there fromscan_paginated, and both paths use it.Tests
crates/storage-cassandra/tests/direct/scan_rows_without_item.rs: 6 tests. Thirty live partitions among 300 emptied ones and 20 claim rows, on a table with a sort key and on a hash-only table, paged with no Limit and with Limit 1, 2, 7 and 50, and as two parallel segments; a run of 40 claim rows inside one partition ahead of its item; a run of 5,000 emptied partitions between 5 items, after which the test reads Cassandra'ssystem_views.tombstones_per_readhistogram for the table and requires that no read counted 1,500 tombstones or more (a read of 1,001 rows shows as about 1,109); a table with no item left; and a table of 101,000 emptied partitions, more than Cassandra's defaulttombstone_failure_thresholdof 100,000, Scanned with Limit 1,000 and 128.crates/storage-cassandra/tests/direct/scan_sort_key_types.rs: 4 tests. Claim rows among items in one partition, with number (38-digit, negative and exponent values), binary (up to 1,024 bytes) and string (multi-byte UTF-8) sort keys, plus 40 emptied partitions, paged with Limit 1, 2, 3, 7 and no Limit; and 2,100 emptied partitions that each held 110 rows, with one item after them in token order, Scanned with Limit 1 and 128.tests/test_scan_emptied_partitions.py: 16 dual-target wire tests built from the measurements.Run locally against Cassandra 4.1 (one node, PasswordAuthenticator) on 2026-10-26:
direct/scan.rspass. Withmain'sscan.rs, 8 of the new tests fail (Limit 1 read 0 of 30 items on the table with a sort key, 3 of 30 on the hash-only table, 0 of 5 after the 5,000-partition run, no item after the 40 claim rows, none of the typed items, and not the item after the 2,100 heavy partitions); the no-item-left and 101,000-partition tests pass onmaintoo.READ_TOO_MANY_TOMBSTONES(ReadFailure, answered as 500), and the tombstone check in the 5,000-partition test failed (one read counted about 2,299 tombstones). With a copy of this change whose read size has no cap, the tombstone check failed the same way.READ_TOO_MANY_TOMBSTONESon a continuation read; with it, the Scan reads through to the item.devtools/run-testsapplies in CI: 16 passed at the head on Cassandra and on SQLite. Withmain's binary on Cassandra, the 5 paging tests on the table with a sort key fail; the hash-only ones pass, since a deleted item of a hash-only table leaves no row.main, 112 and 44. 1,100 live items and 20 emptied partitions, CreateBackup and RestoreTableFromBackup: the restored table holds 1,100 items at the head and 984 onmain.cargo +1.97.0 fmt --all -- --checkandcargo +1.97.0 clippy --all-targets -- -D warnings: clean.cargo test --workspace --exclude extenddb-storage-cassandra: 1,794 passed on stable and on 1.88.0.cargo test -p extenddb-storage-cassandra --lib: 61 passed.Not changed here
These answer the same on
main:token(pk) > token(last pk), so a partition larger than its 500-row page loses its tail.READ_TOO_MANY_TOMBSTONES: at 110 deleted rows per partition, a first read of 1,001 rows (the LIMIT of CreateBackup, export and the TTL work) fails onmain, and the head makes the same first read without a retry.Merging
git merge-tree --write-treeagainst the 14 open PRs that changescan.rs,data/mod.rsortests/direct_integration.rs:scan.rs. It adds aconsistent_readargument toscan_paginatedand runs its two queries throughquery_rows_at. This PR moves those two queries intoscan_rows.rs(rows_after_position, which runs each statement throughrows_ofso the retry can see the driver's error type), so whichever merges second passesconsistent_readdown torows_ofand sets the read consistency there, which covers the continuation reads as well.mainalready, in.github/workflows/integration.yml; this PR adds no conflict to it.crates/storage-cassandra/src/data/mod.rs: each adds its ownmodline next tomod scan;, as this PR does. Keep both lines. fix(query): bound begins_with on a string sort key by the next prefix #588, stacked on fix(query): refuse a Query ExclusiveStartKey the key condition cannot reach #483, inherits the same conflict.