Skip to content

fix(query): bound begins_with on a string sort key by the next prefix - #588

Open
LeeroyHannigan wants to merge 2 commits into
fix/440-start-key-partitionfrom
fix/cassandra-begins-with-bound
Open

LeeroyHannigan wants to merge 2 commits into
fix/440-start-key-partitionfrom
fix/cassandra-begins-with-bound

Conversation

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

Part of #489 (the Cassandra begins_with row). Stacked on #483, which moves the Cassandra sort key range into sk_range.rs.

A Query with begins_with(sk, :p) on a string sort key left out every key that starts with the prefix followed by U+10FFFF, on Cassandra, SQLite and PostgreSQL. It now returns them, as DynamoDB does. The #489 row says SQLite and PostgreSQL return the item; on the base of this branch both excluded it as Cassandra did, so all three change.

What happened

The three backends read begins_with(sk, p) as the range sk >= p AND sk < p + U+10FFFF. A key such as p + U+10FFFF or p + U+10FFFF + "b" starts with p and sorts at or above that bound, so it was not returned:

  • Cassandra: string_upper_bound in crates/storage-cassandra/src/data/query.rs appended \u{10FFFF}.
  • SQLite: the fragment was sk < (? || char(1114111)).
  • PostgreSQL: the fragment was sk COLLATE "C" < ($p || chr(1114111)).

MongoDB already computes the next prefix in next_string_prefix and is not changed.

What the service answers

Measured on 2026-10-25 at 22:31, 22:35 and 23:20 UTC in us-east-1, each request sent twice with the same answer. On sort keys a, ab, a + U+D7FF, a + U+E000, a + U+FFFF, a + U+10000, a + U+10FFFF, a + U+10FFFF + b, a + U+10FFFF + U+10FFFF, b, U+10FFFF, U+10FFFF + x, U+10FFFF + U+10FFFF and z, begins_with with the prefixes a, a + U+10FFFF, a + U+10FFFF + U+10FFFF, U+10FFFF, U+10FFFF + U+10FFFF, a + U+D7FF and a + U+FFFF returns exactly the keys that start with the prefix, in UTF-8 byte order, forward and reverse. Limit 1 pages over the prefixes a, a + U+10FFFF and U+10FFFF return the same keys in the same order. BETWEEN with a bound next to U+10FFFF includes both bounds. A GSI and an LSI with a string sort key answer the same way, including keys written twice.

The change

extenddb_storage::util::string_prefix_upper_bound returns the prefix with its last character replaced by the next scalar value. Trailing U+10FFFF characters are dropped first, and U+D7FF is followed by U+E000. When the prefix is empty or all U+10FFFF there is no upper bound, and the backends read from the prefix up. Cassandra and SQLite already did that for an all-0xFF binary prefix; PostgreSQL's binary bound is unchanged.

  • Cassandra: SkRange::from_condition uses it; the old string_upper_bound is removed from query.rs.
  • SQLite: build_sk_sql_and_binds binds the bound as a second value, or emits the lower bound alone.
  • PostgreSQL: the bound is a bound parameter. build_sk_sql now takes the expression maps, so it knows whether the upper bound placeholder exists, and returns a Result; bind_sk_condition binds the bound computed by the same function. A numeric prefix on a numeric sort key would pass parse_sk and bind one value for two placeholders, so build_sk_sql refuses it before building any SQL. That request answered InternalServerError on the base and still does; the service refuses it with a ValidationException, which is fix(query): classify and order key conditions on a multi-attribute GSI the way the service does #429's area.

Tests

  • crates/storage/src/util/prefix.rs: the bound for ten prefixes, and a check that the range holds every one of the measured keys that starts with the prefix and no other.
  • crates/storage-cassandra/src/data/sk_range.rs: the same check through SkRange::contains, and fix(query): refuse a Query ExclusiveStartKey the key condition cannot reach #483's pinned begins_with bound updated to ab < ac plus the unbounded U+10FFFF prefix.
  • crates/storage-postgres/src/data/query.rs: the fragment and placeholder count for a bounded and an unbounded string prefix, and the refusal of a numeric prefix on a numeric column.
  • tests/test_query_begins_with_max_character.py: 19 dual-target wire tests from the measurements.

Each of these altered versions of string_prefix_upper_bound fails tests in both crates: appending U+10FFFF again, treating a trailing U+10FFFF as unbounded, and mapping U+D7FF to U+10FFFF. Dropping the numeric refusal fails the PostgreSQL unit test, and a SQLite build that binds a second value for an unbounded prefix fails test_begins_with_pages_through_keys_after_the_prefix[max], which stops after 15 pages instead of looping.

Run locally on 2026-10-25:

Backend Head Base (#483 at ec0aab6)
Cassandra 4.1 the new file 19 passed the new file 7 of the first 17 failed
SQLite the new file 19 passed; the new file with tests/test_query_scan.py and tests/test_fgac_leading_keys.py, 162 passed at the first revision of the new file the new file 9 failed, 10 passed
PostgreSQL 16 the new file with tests/test_query_scan.py and tests/test_fgac_leading_keys.py, 164 passed the new file 7 of the first 17 failed

On SQLite the 9 that fail on the base are begins_with with a, a + U+10FFFF and U+10FFFF in both directions and the Limit 1 walks over those three prefixes; each prefix matches a key that continues with U+10FFFF. The 10 that pass are the other four prefixes in both directions, each matching only the key equal to the prefix, and the two BETWEEN tests.

On Cassandra, 16 GSI pagination tests in tests/test_query_scan.py and one LeadingKeys test failed locally at the head and on the base alike. devtools/run-tests applies index_propagation_delay_ms = 0 and two other test settings only when ./target/release/extenddb exists, and these runs used binaries from another path; with those settings applied all of them pass at the head.

Also run: cargo +1.97.0 fmt --all -- --check, cargo +1.97.0 clippy --all-targets -- -D warnings, and cargo test --workspace --exclude extenddb-storage-cassandra on stable and on 1.88.0 (1,808 passed each), plus cargo test -p extenddb-storage-cassandra --lib (71 passed) and cargo test -p extenddb-storage-postgres --lib (55 passed). The Cassandra integration tests were not run locally; their helper connects to 127.0.0.1:9042. CI runs the new file in run-integration, run-integration-sqlite, mongodb-pytest and cassandra-pytest.

Files

crates/storage-postgres/src/data/query_scan.rs was 761 lines and grows by five, because rustfmt splits the build_sk_sql call over several lines once it takes four arguments. No other file over 600 lines grows; crates/storage-cassandra/src/data/query.rs loses six.

Merging

This is based on #483's branch. After #483 merges, retarget it to main.

git merge-tree --write-tree against the open PRs that touch these files adds five conflicts. #429 calls build_sk_sql with three arguments, so whichever lands second passes maps and adds ?. The other four are adjacent lines in crates/storage/src/util/mod.rs (#401, #474, #543) and the use line in crates/storage-postgres/src/data/query.rs (#368); keep both sides.

Query with begins_with(sk, :p) on a string sort key read the range
sk >= p AND sk < p + U+10FFFF on Cassandra, SQLite and PostgreSQL. A key that
starts with p followed by U+10FFFF (p + U+10FFFF, p + U+10FFFF + "b") sorts
at or above that bound and was left out. Amazon DynamoDB returns those keys
on base tables, GSIs and LSIs (measured 2026-10-25 at 22:31, 22:35 and 23:20
UTC, each request twice), ordered by their UTF-8 bytes, which is how the
three backends compare string sort keys.

The new extenddb_storage::util::string_prefix_upper_bound returns the prefix
with its last character replaced by the next scalar value: trailing U+10FFFF
characters are dropped first, and U+D7FF is followed by U+E000. A prefix that
is empty or all U+10FFFF has no upper bound, and the three backends then read
from the prefix up. MongoDB already computed the same bound in its own
next_string_prefix and is unchanged.

On PostgreSQL the bound is now a bound parameter instead of `$p || chr(1114111)`,
so build_sk_sql takes the expression maps to know whether the placeholder
exists, and bind_sk_condition binds the bound it computes from the same
function. A numeric prefix on a numeric sort key is refused in build_sk_sql
before any SQL is built, so the placeholder and bind counts cannot disagree;
it answered InternalServerError before and still does. On Cassandra the old
string_upper_bound leaves query.rs.
Dual-target wire tests from measurements on 2026-10-25 at 22:31 and 22:35
UTC: begins_with with prefixes ending in a, U+10FFFF, U+D7FF and U+FFFF,
forward and reverse; Limit 1 pages over the prefixes a, a + U+10FFFF and
U+10FFFF; and two BETWEEN ranges whose bounds sit next to U+10FFFF. The
service returns every key that starts with the prefix, in UTF-8 byte order.

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