Skip to content

fix(query): classify and order key conditions on a multi-attribute GSI the way the service does - #429

Merged
LeeroyHannigan merged 5 commits into
mainfrom
fix/gsi-multi-range-query
Oct 31, 2026
Merged

LeeroyHannigan merged 5 commits into
mainfrom
fix/gsi-multi-range-query

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #381.

What the service does

Measured on 2026-10-21 against live DynamoDB with the issue's schema (GSI with HASH h and RANGE a, b, c, d) and a two-HASH index:

  • Results are ordered over every RANGE attribute in key-schema order, then the base table key; ScanIndexForward = false returns the exact mirror. LastEvaluatedKey carries every key attribute.
  • A key condition is served when every HASH attribute has an equality and the RANGE conditions cover a leading run of the RANGE attributes, with an equality on every one before the last; the last may use <, <=, >, >=, BETWEEN or begins_with. Clause order is free.
  • Refusals, in the order the service applies them: a second condition on one key attribute (KeyConditionExpressions must only contain one condition per key); more conditions than key attributes (The number of query conditions (6) exceeds the number of key attributes defined in the schema (5)); a HASH attribute without an equality (HASH key attributes h1, h2 must have equality conditions specified in the query); a RANGE gap (RANGE key attributes a must have equality conditions specified in the query because a condition is present on key attribute b); a value whose type differs from the attribute definition (One or more parameter values were invalid: Condition parameter type does not match schema type); a condition on an attribute outside the key schema, named by the first RANGE attribute without a condition (Query condition missed key schema element: a). begins_with on a number attribute is Incorrect operand type for operator or function; operator or function: begins_with, operand type: N.
  • dynamodb:LeadingKeys on a Query of a GSI with two HASH attributes is the first HASH attribute's value (measured 2026-10-20). g1 = :a on HASH g1, g2 answers ValidationException to every principal, including one fenced away from the value (measured 2026-10-21).
  • A clause on an attribute outside the queried key schema is refused by the key schema rule whatever its value's type. Measured 2026-10-30 on a GSI with HASH GSI2PK (S) and RANGE GSI2SK (S) of a table keyed on PK (S): GSI2PK = :h AND PK = :k answers Query condition missed key schema element: GSI2SK with :k an S, the type the table defines for PK, and with :k an N, two samples each.

What ExtendDB did

KeyCondition::resolve_multipart returned early for one HASH attribute, so the parser's assignment stood: results were ordered by the first RANGE attribute then the table key, every key condition with three or more clauses was refused as Query condition missed key schema element: h, and h = :h AND b = :b was accepted and returned nothing.

Changes

  • crates/core/src/expression/key_condition.rs: resolve_multipart classifies every clause by name and applies the rules above in the service's order. resolve_multipart_with takes a per-clause value check, which the engine supplies with the attribute definitions, so a wrong-typed value is reported after the structural rules and before the non-key rule, as measured. resolve_key_schema_with chooses between the classifier and resolve_pk_sk for the key schema the query reads, and both the engine and authorization call it.
  • crates/engine/src/query.rs: the key condition goes through resolve_key_schema_with with the type check. On a multi-attribute key schema the type check reads the queried key schema's attribute definitions only, so a clause on another attribute gets the non-key rule, as measured. The single-attribute path runs the type check, against the table's definitions, ahead of its missing-key check (a wrong-typed sort key value answered 500 before). That path has no non-key rule yet (see the known divergences below), so a clause on another attribute reaches storage, and the table's definitions are what refuse a value of the wrong type for it. begins_with on a number attribute is refused with the measured wording.
  • crates/server/src/leading_keys.rs: query_leading_keys assigns the clauses with resolve_key_schema, the engine's classification. With resolve_pk_sk it found no partition key in three-clause orders such as g2 = :g AND g1 = :v AND gs = :x or b > :lo AND a = :a AND h = :v, which the engine now serves, so a ForAllValues:StringEquals LeadingKeys fence let the query read any partition of the index.
  • crates/engine/src/legacy_filter.rs: KeyConditions on a multi-attribute schema go through the same classifier.
  • crates/storage-sqlite and crates/storage-postgres: every RANGE condition is applied to its column; ORDER BY and the row-comparison cursor cover all RANGE columns.
  • crates/storage/src/util/key.rs, crates/storage-mongodb, crates/storage-cassandra: these two backends filter the first RANGE attribute of such an index only. A condition on a later RANGE attribute is refused with a ValidationException naming the attribute and the backend, instead of being applied to the wrong column.
  • crates/storage-cassandra/src/data/query.rs: on main, every Query on a Cassandra index with two or more RANGE attributes answers 500 (measured on ec81e9d4: hash-only, first-RANGE equality and first-RANGE range conditions all return Order by currently only supports the ordering of columns following their declared order in the PRIMARY KEY from Cassandra). The index table clusters on every RANGE column ahead of base_pk, so the ORDER BY now names every index RANGE column in declared order; those queries answer 200 with the page in clustering order. A Query with an ExclusiveStartKey on such an index also answered 500 and is now refused with a ValidationException (ExclusiveStartKey on index gsi: the Cassandra backend pages a Query over an index with one RANGE attribute only), because the cursor compares the first RANGE attribute only. docs/differences-from-dynamodb.md records both backends.

Changes from the review of 30 October

  • fix(auth): bind LeadingKeys through the engine's key condition classification: authorization reads a multi-attribute key condition the way the engine does, so the three-clause orders from the review are denied to a principal fenced away from the first HASH attribute's value, and g1 = :a on HASH g1, g2 answers ValidationException to every principal, as the service does.
  • test(core): pin the condition count refusal ahead of the HASH and RANGE checks: unit cases with the HASH attribute missing or a RANGE gap and more conditions than key attributes.
  • test(query): take the first-RANGE-only branch from the backend under test: tests/test_gsi_multi_range_query.py reads the backend from EXTENDDB_TEST_BACKEND or the server's config file and fails when the server's reply does not match it, instead of skipping the served-result cases.
  • fix(query): type-check only the queried key schema on a multi-attribute index: a value of another type on an attribute the table defines for its base key or another index gets Query condition missed key schema element in place of the type sentence, which is the service's answer.

Verification

tests/test_gsi_multi_range_query.py, 64 cases: order, pages and LastEvaluatedKey, each accepted shape, each refusal and its text, legacy KeyConditions, the value type rule, and the MongoDB and Cassandra refusal. Every expectation in the first 61 cases was measured on the service. On 2026-10-30 the whole file at 3893bf63 ran against the service and passed, 63 passed and 1 skipped (the MongoDB and Cassandra refusal, which runs only on a target that refuses), so the three cases for a value on an attribute outside the queried key schema are measured on indexes with several key attributes too.

target result
live DynamoDB 63 passed, 1 skipped (the MongoDB and Cassandra refusal case), at 3893bf63 on 2026-10-30
SQLite 63 passed, 1 skipped
PostgreSQL 16 63 passed, 1 skipped
MongoDB 7 42 passed, 22 skipped (served-result cases), at 46a66b53
Cassandra 4.1 42 passed, 22 skipped (served-result cases), at 46a66b53. The first push of this branch errored at the module fixture (61 errors, the hash-only Query answered 500 as on main); the ORDER BY change fixed that
main at ec81e9d4, SQLite 30 of the 30 then-existing cases failed
2b05987b (before the review fixes), SQLite and PostgreSQL the three cases for an attribute outside the queried key schema fail, answering the type sentence

tests/test_fgac_leading_keys.py on servers built from this branch: 29 passed on SQLite, PostgreSQL and MongoDB, and 28 passed on Cassandra, where test_oversized_requests_fail_validation_before_authorization was not run. On servers built from 2b05987b the Query shape test fails on both: each of the eleven three-clause orders returns rows to the principal fenced away from the partition, and g1 = :a on the two-HASH index answers AccessDeniedException to that principal where the service answers ValidationException. The new orders combine two measured rules, LeadingKeys is the first HASH attribute's value and clause order is free; the shapes themselves were run against ExtendDB only.

Regression on the branch servers: test_multipart_gsi.py, test_query_scan.py, test_conditional_operator.py, test_expression_param_usage.py and test_item_operations.py, 309 passed on SQLite and 309 passed on PostgreSQL. A 67-request probe comparing the branch with the service case by case leaves 13 differences, all present on main and outside this change: the parser's wording for two non-equality clauses (#427), the 2048-byte hash key and 1024-byte aggregate range key limits on Query, and an extra page with a LastEvaluatedKey when Limit lands on the last item.

Negative controls on the committed tree, each restored with git checkout: the eight classifier mutations (missing-HASH order, RANGE duplicate detection, non-equality duplicate detection, RANGE gap window, non-equality excluded from the gap, early return widened, non-key refusal disabled, count refusal disabled) each fail their unit test; the count check moved after the missing-HASH and RANGE-gap checks fails the new count ordering test and passes the other 39 key_condition tests; the type check disabled fails 6 wire cases; the begins_with rule disabled fails 1; the MongoDB refusal disabled fails 18 wire cases on wrong items or order. With EXTENDDB_TEST_BACKEND=mongodb against a SQLite server, tests/test_gsi_multi_range_query.py from 2b05987b passes (60 passed, 1 skipped) and the new file fails with 23 errors.

Gates: cargo fmt --check; cargo +1.97.0 clippy --all-targets -D warnings on core, engine, storage and the four storage crates, and on core, engine and server for the review changes; cargo +1.88.0 build on the same, and cargo +1.88.0 check on core, engine and server; unit tests core 537, engine 178 and server 204 on this revision, and storage 72, storage-sqlite 101, storage-postgres 46, storage-mongodb 96 and storage-cassandra 52 on the earlier push (the review changes do not touch the storage crates). No dependency change.

Not in this change

Pre-existing, filed separately: #427 (the parser refuses a lone non-equality, or two non-equalities, before the key schema is known, so the wording differs from the service); Query does not enforce the key value size limits the service applies (Size of hashkey has exceeded the maximum size limit of2048 bytes, Aggregated size of all range keys has exceeded the size limit of 1024 bytes); a Limit that lands on the last item returns a LastEvaluatedKey and one more empty page where the service returns none. Full support for several RANGE attributes on the MongoDB and Cassandra backends (typed fields and clustering for every RANGE attribute) is a separate change.

Known divergences found in review, present on main, filed as #673 (Cassandra) and #674 (single-attribute key schema):

  • Cassandra: paging a Query on a GSI with HASH h1, h2 and RANGE r reads h2 as the cursor's sort key (crates/storage-cassandra/src/data/query.rs, query_key_schema[1]). Page 2 of h1 = :x AND h2 = :y repeats page 1 when h2 is N and answers 500 when it is S. On any GSI with a RANGE attribute, a key condition on that attribute together with an ExclusiveStartKey answers 500 on page 2. The new row in docs/differences-from-dynamodb.md covers indexes with several RANGE attributes only.
  • SQLite and PostgreSQL, single-attribute key schema: a key condition clause on a non-key attribute is applied to the first RANGE column. pk = :p AND zz = :v returns the item whose sort key equals :v, and a value of another type than the sort key answers 500. The service refuses such a clause with Query condition missed key schema element: <RANGE attribute> whatever the value's type (measured 2026-10-30, above), and the multi-attribute path here does the same. Until the single-attribute path has that rule, its type check keeps reading the table's definitions, so ExtendDB answers the measured GSI2PK = :h AND PK = :k with the type sentence when :k is an N, and with 200 and the condition applied to GSI2SK when :k is an S.

Also not changed here: the type check runs before the unused-value check, so pk = :p AND sk > :s with a wrong-typed :s and an unused :u gets the type sentence, where main reports the unused value; the #544 paragraph below covers where the service reports it. Legacy KeyConditions with two or more non-EQ conditions report the RANGE-gap message, or Query key condition not supported, before the count, HASH and non-key rules; only the wording differs, and the service is measured for one shape in that class.

Overlap with other open PRs

Rebase on #395

Rebased on main at 3051e500 (#395) on 30 October, after the review commits. Main's tests/conftest.py already has configured_backend(), so the LeadingKeys commit now reads the backend through it in tests/test_fgac_leading_keys.py and tests/test_ttl.py instead of adding its own copy. That was the only conflict in the review commits; the first commit had one more, a row in docs/differences-from-dynamodb.md. 46a66b53 is the head before the rebase, with the same changes, and the MongoDB and Cassandra rows above are from it.

On 3893bf63: workspace clippy with -D warnings; --lib tests core 713, engine 183 and server 222; tests/test_fgac_leading_keys.py, tests/test_gsi_multi_range_query.py and tests/test_ttl.py on a SQLite server, 103 passed and 1 skipped; and tests/test_gsi_multi_range_query.py against the service, 63 passed and 1 skipped.

Rebase on #511

Rebased on main at 1c0523c9 (#511) on 30 October. #511 binds LeadingKeys to the partition operand of a key condition the engine refuses, and it changed query_leading_keys and key_condition.rs as well, so the two conflicted. #511's path through the clauses stays. When a clause compares the partition attribute by equality, equality_pk_value reads the condition through resolve_key_schema with the queried key schema in place of resolve_pk_sk, and a single clause under an operator the engine rejects binds its operand. The legacy KeyConditions path keeps #511's operator list. multi hash gsi missing g2 now answers ValidationException to every principal, as the service does, so QUERY_REJECTED_CODE_DIVERGES, which held only that entry, is removed from tests/test_fgac_leading_keys.py. In key_condition.rs both sides added tests at the end of the test module, and both are kept.

On a7e14d8e: cargo fmt --check; workspace clippy with -D warnings; --lib tests core 715, engine 183 and server 237; tests/test_fgac_leading_keys.py, tests/test_gsi_multi_range_query.py and tests/test_ttl.py on a SQLite server, 105 passed and 1 skipped, and on a PostgreSQL 16 server, 105 passed and 1 skipped. The two extra passes are #511's tests. The run against the service is the one at 3893bf63 and was not repeated on this head.

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator Author

Measured on the service on 2026-10-23 while working #427 (each row twice, identical answers), for the placement of the type check this PR adds:

  • Multi-attribute index (multi-idx, HASH h, RANGE a b c d), h > :n with :n = {"N": "1"}: HASH key attributes h must have equality conditions specified in the query. The HASH wording precedes the type check on a multi-attribute index.
  • Single-attribute schema, pk > :n with :n = {"N": "1"} on an S key: One or more parameter values were invalid: Condition parameter type does not match schema type. The type check precedes the no-HASH-equality wording there.
  • pk > :n with an unused :y: Value provided in ExpressionAttributeValues unused in expressions: keys: {:y}; pk > :n with FilterExpression="nope(": Invalid FilterExpression: Syntax error; token: "<EOF>", near: "(". Both precede the type check.
  • sk >= :v with :v = {"N": "1"} on an S sort key, with or without an ExclusiveStartKey: the type sentence. ExtendDB answers InternalServerError for the sort key bound today; worth confirming the check covers the sort key clause and not only the partition value.
  • begins_with(pk, :n) with :n = {"N": "1"} on an N key: Invalid KeyConditionExpression: Incorrect operand type for operator or function; operator or function: begins_with, operand type: N.

#479 conflicts with this branch in crates/engine/src/query.rs (the partition key block); whichever merges second gets rebased, and the resolution keeps both orders above.

LeeroyHannigan added a commit that referenced this pull request Oct 29, 2026
… reach

Closes #440.

A Query ExclusiveStartKey from another partition, or outside the sort key
condition, was accepted and answered an empty page. The service refuses
it, with a sentence chosen by where the key falls. Measured on 2026-10-23
and 2026-10-24, every row repeated with identical answers, page-end rows
ten times each.

After the shape check, the service first asks whether a later key in the
scan direction can still satisfy the sort key condition, for either
partition:

- On a base table a later key must differ from the start key's sort
  value: ">" and "<" at the bound are refused, ">=" at the bound is
  accepted, "<=" at the bound refused, BETWEEN at the upper bound refused
  and at the lower bound accepted, begins_with with a key that does not
  carry the prefix refused; the reverse direction mirrors each.
- On an LSI or GSI a repeat of the start key's sort value counts as a
  later key, because index keys repeat: "<=", BETWEEN at either bound,
  ">=" in reverse, begins_with in reverse at the prefix and "=" with the
  key itself are all accepted; ">" and "<" at the bound are refused.
- The sentence is "The provided starting key does not match the range
  key predicate", with two base-table exceptions in the same partition:
  "=" with the key's own value: "The provided exclusive start key is
  invalid"; begins_with in reverse with the key exactly the prefix: "The
  provided starting key is outside query boundaries based on provided
  conditions".

Then the partition, when the key's partition differs from the condition's
(table, LSI, GSI, legacy KeyConditions, either direction):

- no sort key condition: "The provided starting key is outside query
  boundaries based on provided conditions";
- any sort key condition: "The query can return at most one row and
  cannot be restarted".

A hash-only base table refuses every ExclusiveStartKey with "The provided
exclusive start key is invalid", the queried partition included, and its
pages never carry a key. Partition values compare by value for numbers
(1, 1.0, 1e0, 01, +1 are equal); strings by UTF-8 byte order; binary by
byte order.

Page end: when a base table page ends on the last key that can satisfy
the sort key condition in the scan direction, the service returns no
LastEvaluatedKey (ten of ten for "=", BETWEEN at the upper bound, "<=" at
the bound, reverse ">=" at the bound). ExtendDB now drops the key there,
so a client that feeds every returned key back is never refused. Index
pages are unchanged.

Ordering, measured: filter and projection faults, unused names and
values, ConsistentRead on a GSI, Limit, a missing table or index, the
key size check and the start key shape check (missing or extra
attributes, wrong types) are reported first; the Select checks after.

The checks live in crates/engine/src/start_key.rs, called from
handle_query after the shape check. The sort key predicate is evaluated
with the expression evaluator on the start key's sort value, so every
operator and type follows the same comparison as the condition itself.

Tests: engine unit tests in start_key_tests.rs and start_key_range_tests.rs;
tests/python/test_query_start_key_partition.py, test_query_start_key_range.py
and test_query_start_key_index.py (162 rows, the hash-only page row
ExtendDB only) pass against the service and on SQLite and PostgreSQL, with
test_read_paging.py and the Query wire files. Pagination walks with Limit
1 to 3 in both directions feed every returned key back without a refusal.

Pre-existing, measured, not changed here: a sort key bound of another
type than the sort key answers InternalServerError (the type check in
#429); the service omits the trailing key when the next row exists but
fails the condition ("sk < z" with Limit equal to the matches), which
needs a storage look-ahead and is filed separately.

@robinnsc robinnsc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The classification and ordering look right. On local SQLite and PostgreSQL, a reference model built from the rules in the description matched every page over a few thousand checks: all RANGE attributes, both directions, Limit 1/2/3/7 across full-key ties, LastEvaluatedKey, COUNT, filters, legacy KeyConditions, and random clause orders and aliases. Main failed nearly all of the same checks. MongoDB and Cassandra behaved as documented. Performance is better: SQLite and PostgreSQL now read the ordering index with no sort, and hash-only reverse queries with Limit 10 on a 20k-item partition went from about 17 ms to about 3 ms. Base-table and single-attribute queries are unchanged.

The one thing I'd hold this for is the LeadingKeys interaction in the first inline comment. Authorization still picks the partition clause the old way, and this PR makes the engine serve clause orders it used to refuse.

Smaller notes, not blocking:

  • Cassandra, pre-existing: on a two-HASH index with one RANGE attribute (h1, h2; r), data/query.rs:264 reads the cursor's sort key as query_key_schema[1], which is h2. Paging h1 = :x AND h2 = :y repeats rows after the first page, and with a condition on r the second page answers 500 (Column "sk_n" cannot be restricted by both an equality and an inequality relation). Probably worth its own issue. The new differences row reads as if single-RANGE indexes page fine.
  • Legacy KeyConditions with two or more non-EQ conditions return the RANGE-gap or Query key condition not supported before the count, HASH and non-key rules, e.g. h LT, d GE gets the RANGE message rather than the HASH one. Only the wording differs, and the service is measured for just one shape in that class.
  • Nothing pins the count refusal ahead of the missing-HASH and RANGE-gap refusals. Moving the count check after both still passes all 37 key_condition tests, because every count case has every key attribute. a = :a AND b = :b AND c = :c AND z1 = :z AND z2 = :y (h missing) would pin it.
  • Description: the regression file set gave 309 passed on PostgreSQL here rather than 180.

Comment thread crates/engine/src/query.rs Outdated
// A wrong-typed value is reported after the structural rules and
// before the non-key rule (measured 2026-10-21), so the type check
// runs inside the classifier.
key_condition.resolve_multipart_with(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs a matching change in authorization before it goes in. query_leading_keys (crates/server/src/leading_keys.rs:253-262) still finds the LeadingKeys value with parse_key_condition + resolve_pk_sk and then checks pk_path == first HASH attribute. On main that was safe, because the engine ran the same check and refused anything authorization couldn't read. Here multi-attribute schemas skip it and classify by name, so for some 3+ clause orders authorization finds no key while the engine serves the query. Under ForAllValues:StringEquals dynamodb:LeadingKeys an absent key passes.

Reproduced on SQLite and PostgreSQL head, with a principal fenced to ["alice"] on the table and /index/* and every query naming bob:

  • GSI (g1, g2 HASH; gs RANGE): g2 = :g AND g1 = :b AND gs = :x, gs = :x AND g1 = :b AND g2 = :g, g2 = :g AND g1 = :b AND gs > :lo, and the #name variants return bob's items.
  • GSI (h HASH; a, b RANGE): a = :a AND h = :b AND b = :c, b = :c AND h = :b AND a = :a, a = :a AND h = :b AND b > :lo, b > :lo AND a = :a AND h = :b, and begins_with(b, :p) AND a = :a AND h = :b return bob's items.
  • On main all of these are ValidationException. Two-clause orders, the base table, LSIs, single-RANGE GSIs and legacy KeyConditions still bind correctly, and ForAnyValue or a Null guard denies.

Possible fix: when is_multipart_key_schema holds, have query_leading_keys call resolve_multipart(&hash_attrs, &range_attrs, &names) (no type check) instead of resolve_pk_sk. It leaves the first HASH attribute in pk_path, so the existing check and value_of bind the right value. Sharing one "classify for this key schema" function between the engine and authorization would keep them from drifting again. QUERY_ACCEPTED in tests/test_fgac_leading_keys.py only has two-clause orders on a HASH-only index, so adding the shapes above there should fail today and pass with the fix.

.position(|(n, _)| *n == name)
.map(|pos| (pos, all_sks[pos].1))
};
if let Some(sk_cond) = &key_condition.sk_condition

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing, but since these lines are rewritten: on a single-attribute schema a condition on a non-key attribute falls through to the first RANGE column. On pk S / sk N, pk = :p AND zz = :v with :v N 4 returns the item whose sk is 4. On a GSI (h; a), h = :h AND pk = :v filters on a. With mismatched types it's a 500. PostgreSQL does the same at query_scan.rs:190. The multi-attribute path now refuses these with Query condition missed key schema element, so the two paths disagree. Could consider applying the same count and non-key rules on the single-attribute path in query.rs too.

use extenddb_core::expression::Expr;
use extenddb_core::types::{AttributeValue, ScalarAttributeType};

let Some(attr) = resolve_path_attr_name(path, &maps.names) else {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

query_key_info.attribute_definitions is the table-wide list for an index query (query.rs:158), so a clause on an attribute that is defined but isn't in the queried index's key schema also gets type-checked here. On GSI (h; a), h = :h AND sk = :s (the base table's N sort key, S value) answers the type sentence, where the stated rules would give Query condition missed key schema element: a. The service isn't measured for that case, so this might be fine, but filtering the definitions to the queried key schema would line it up with the doc comment.

Comment thread crates/engine/src/query.rs Outdated
// value ahead of the missing-key check (measured 2026-10-21: `pk = :n`
// on an S key with a second clause on a non-key attribute reports the
// type, not the missing key).
for_each_key_clause(&key_condition, |path, value| {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the type check now runs before the unused-value check (query.rs:430) and filter parsing. So pk = :p AND sk > :s (S value on an N sort key) plus an unused :u answers the type sentence where main answered Value provided in ExpressionAttributeValues unused, and the same goes for a bad FilterExpression. The #544 paragraph in the description covers where the service puts it, so this is probably known. Just noting it shows up on this branch alone too.

FIRST_RANGE_ONLY_MARK = "applies a key condition to the first RANGE attribute"


@pytest.fixture(scope="module")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

first_range_only decides from the server's reply whether to skip the 22 served-result tests. If SQLite or PostgreSQL ever started answering with the MongoDB refusal, the module would report 39 passed / 22 skipped and stay green. Might be worth taking the branch from EXTENDDB_TEST_BACKEND like tests/test_ttl.py does, and failing when the reply doesn't match the backend.

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Pushed four commits:

  • LeadingKeys (baa5ea9): authorization now reads the key condition through the engine's classification, KeyCondition::resolve_key_schema_with, which picks the multi-attribute classifier or resolve_pk_sk and which the engine and query_leading_keys both call. Your g1, g2; gs and h; a, b shapes and the #name variants are in QUERY_ACCEPTED. On 2b05987 each returned rows to the principal fenced away from the partition, now each is denied on SQLite and PostgreSQL. g1 = :a on the two-HASH index also answers ValidationException to every principal now, as the service does.
  • Count ordering (aab50ad): your case has five conditions on the five key attributes of h; a, b, c, d, so the missing-HASH rule answers it. The test uses six conditions instead, one set with h missing and one with a RANGE gap, plus your five clauses on h1, h2; r. With the count check moved after both checks it fails, and the other 39 key_condition tests pass.
  • first_range_only (b31bd1d): the backend comes from EXTENDDB_TEST_BACKEND or the config file, as in tests/test_ttl.py, and the fixture fails when the reply doesn't match. With EXTENDDB_TEST_BACKEND=mongodb against SQLite the old file passes and the new one errors on 23 tests.
  • Index type check (46a66b5): on a multi-attribute key schema the type check reads the queried key schema's definitions only. That matches the service: on a GSI with HASH GSI2PK and RANGE GSI2SK, GSI2PK = :h AND PK = :k answers Query condition missed key schema element: GSI2SK with :k an S and with :k an N, and the three new cases in tests/test_gsi_multi_range_query.py pass against the service, with the rest of the file. Your h; a case is on the single-attribute path, which has no non-key rule yet (your query_scan.rs:90 comment). I left the table's definitions there, since without that rule an N value would reach storage and answer 500.
  • Description: 309 passed on both SQLite and PostgreSQL in this run, and the file list names the five files.

Filed separately: the Cassandra two-HASH cursor as #673 and the single-attribute non-key fall-through as #674, with the measurement above in the second. On main page 2 of the cursor repeats page 1 when h2 is N and answers 500 when it's S. A key condition on r plus an ExclusiveStartKey also answered 500 on page 2 of a one-HASH Cassandra GSI, so that part isn't about two HASH attributes.

Not changed: the type check running before the unused-value check, which the #544 paragraph covers, and the legacy KeyConditions order for two or more non-EQ conditions, where only the wording differs and the service is measured for one shape.

@LeeroyHannigan
LeeroyHannigan force-pushed the fix/gsi-multi-range-query branch from 46a66b5 to 3893bf6 Compare October 30, 2026 12:34
@LeeroyHannigan

Copy link
Copy Markdown
Collaborator Author

Rebased onto main to clear a conflict in tests/conftest.py: main already has configured_backend(), so the LeadingKeys commit now uses it instead of adding its own copy. The four review commits are now 4732501 (LeadingKeys), dce457a (count order), 7cfc727 (first_range_only) and 3893bf6 (index type check).

…I the way the service does

On a global secondary index with one HASH attribute and several RANGE attributes, Query ordered results by the first RANGE attribute and then the table key, refused every key condition with three or more clauses as "Query condition missed key schema element: h", and accepted a condition that skipped the first RANGE attribute while returning no items. KeyCondition::resolve_multipart returned early when the schema had one HASH attribute, so the parser's assignment stood: the first equality became the partition key and the rest sat where the engine's HASH-count check refused them.

Measured against the service on 2026-10-21, with the issue's schema (HASH h, RANGE a, b, c, d) and with two HASH attributes:

- Results are ordered over every RANGE attribute in key-schema order, then the base table key; ScanIndexForward = false is the exact mirror. LastEvaluatedKey carries every key attribute.
- A key condition is served when the HASH attributes all have an equality and the RANGE conditions cover a leading run of the RANGE attributes with an equality on every one before the last; the last may use <, <=, >, >=, BETWEEN or begins_with. Clause order in the expression is free.
- Refusals, in the order the service applies them: a second condition on one key attribute ("KeyConditionExpressions must only contain one condition per key"); more conditions than key attributes ("The number of query conditions (6) exceeds the number of key attributes defined in the schema (5)"); a HASH attribute without an equality ("HASH key attributes h1, h2 must have equality conditions specified in the query", every missing one named); a RANGE gap ("RANGE key attributes a must have equality conditions specified in the query because a condition is present on key attribute b"); a value whose type differs from the attribute definition ("One or more parameter values were invalid: Condition parameter type does not match schema type"); a condition on an attribute outside the key schema, named by the first RANGE attribute without a condition ("Query condition missed key schema element: a"). begins_with on a number attribute is "Incorrect operand type for operator or function; operator or function: begins_with, operand type: N". The single-attribute wording "Query condition missed key schema element" stays on single-attribute schemas.

resolve_multipart now classifies every clause by name against the HASH and RANGE attribute lists and applies those rules in that order; the value type check runs inside it through resolve_multipart_with, with the engine supplying the attribute definitions. The engine's single-attribute path gets the same type check ahead of its missing-key check, which turns a 500 on a wrong-typed sort key value into the service's refusal. Legacy KeyConditions on a multi-attribute schema go through the same classifier. The SQLite and PostgreSQL backends apply every RANGE condition to its column and order and page over all RANGE columns; the cursor keeps its shape. The MongoDB and Cassandra backends filter the first RANGE attribute only and now refuse a condition on a later one instead of applying it to the wrong column. On Cassandra every Query on an index with several RANGE attributes answered 500 before this change: the index table clusters on every RANGE column and CQL rejects an ORDER BY that skips one, so the ORDER BY now names every index RANGE column in declared order, and a Query with an ExclusiveStartKey on such an index is refused instead of answering 500, because the cursor compares the first RANGE attribute only. docs/differences-from-dynamodb.md records both backends.

tests/test_gsi_multi_range_query.py pins 60 cases, all measured on the service: order, pages and LastEvaluatedKey, every accepted shape, every refusal and its text, legacy KeyConditions, and the MongoDB and Cassandra refusal. 60 pass on the service and on SQLite and PostgreSQL; on MongoDB 39 pass and the 22 served-result cases skip; on Cassandra the same 22 skip. On main 30 of the 30 then-existing cases failed. Unit tests in core cover the classifier rules; the storage plan and cursor tests cover the ORDER BY and cursor columns.

Closes #381
…fication

On a key schema with several HASH or several RANGE attributes the engine
assigns every key condition clause to its key attribute by name, so it serves
clause orders such as `g2 = :g AND g1 = :v AND gs = :x` and
`b > :lo AND a = :a AND h = :v`. Authorization still read the partition
equality with `resolve_pk_sk`, found no partition key in those orders and left
dynamodb:LeadingKeys absent, so a ForAllValues:StringEquals fence let the
query read any partition of the index.

`KeyCondition::resolve_key_schema_with` holds the choice between the
multi-attribute classifier and `resolve_pk_sk`. The engine calls it, and so
does `equality_pk_value`, which `query_leading_keys` calls for a condition
with an equality on the partition attribute. When a clause compares the first
HASH attribute by equality, authorization binds that attribute's value for
every clause order the engine serves, and a condition the classifier refuses
leaves the key absent. For `g1 = :a` on HASH `g1`, `g2` that gives the
answer the service gives every principal, ValidationException (measured
2026-10-21). A single clause on the partition attribute under an operator
the engine rejects still binds that clause's operand, as it does on main
since #511.

tests/test_fgac_leading_keys.py adds the three-clause orders from the review on
a GSI with HASH `g1`, `g2` and RANGE `gs` and a GSI with HASH `h` and RANGE
`a`, `b`, and expects ValidationException for the missing-g2 shape from the
principal fenced away from the value. That shape was the only entry in the
list of codes that still differed from the service, so the list is removed.
It and tests/test_ttl.py read the backend through `configured_backend()` in
tests/conftest.py, so this file can expect the MongoDB and Cassandra refusal
of a condition on `b`.
…GE checks

The service reports more conditions than key attributes ahead of which key
attribute is missing (measured 2026-10-21), and the classifier checks the
count before the missing-HASH and RANGE-gap rules. Every count case in the
tests had every key attribute, so moving the count check after both rules
still passed them. The new cases have six conditions with `h` missing and six
with a RANGE gap on HASH `h` and RANGE `a`, `b`, `c`, `d`, and five conditions
on HASH `h1`, `h2` and RANGE `r`. The same five clauses on the five key
attributes of `h` and `a` to `d` are not more than the key attribute count,
so the missing HASH attribute answers them.
…test

`first_range_only` decided from the server's reply whether to skip the
served-result tests, so a SQLite or PostgreSQL server that answered a
condition on `b` with the MongoDB and Cassandra refusal would have reported 39
passed and 22 skipped and stayed green. The fixture now reads the backend from
`EXTENDDB_TEST_BACKEND` or the server's config file, as tests/test_ttl.py
does, expects the refusal on MongoDB and Cassandra only, and fails when the
reply does not match the backend.
…te index

For an index query the attribute definitions are the table's whole list, which
also holds the base table key and the other indexes' key attributes. A clause
on one of those attributes with a value of another type got `Condition
parameter type does not match schema type`, where the classifier's non-key
rule gives `Query condition missed key schema element: <attribute>`, the
first RANGE attribute without a condition. On a multi-attribute key schema the
type check now reads the definitions of the queried key schema only, as its
doc comment says.

Measured on the service on 2026-10-30, on a GSI with HASH `GSI2PK` and RANGE
`GSI2SK` of a table keyed on `PK` (S): `GSI2PK = :h AND PK = :k` answers
`Query condition missed key schema element: GSI2SK` with `:k` an S and with
`:k` an N, two samples each. The service's type check does not see an
attribute outside the queried key schema.

A single-attribute key schema keeps the table's definitions. Its path has no
non-key rule: a clause on another attribute reaches storage, which applies it
to the first RANGE attribute, and the type check is what refuses a value of
the wrong type there. Reading only the key schema's definitions on that path
would turn the N case above into a 500, and the S case would still be applied
to the first RANGE attribute. That path's divergence from the measurement is
older than this change and is tracked separately.
@LeeroyHannigan
LeeroyHannigan force-pushed the fix/gsi-multi-range-query branch from 3893bf6 to a7e14d8 Compare October 30, 2026 15:25
@LeeroyHannigan

Copy link
Copy Markdown
Collaborator Author

Rebased onto main to clear the conflict with #511. The resolution is the one the overlap note in the description gave, and the new "Rebase on #511" section has the details and the runs. The review commits are now 5f7452a (LeadingKeys), 853d20b (count order), 2269f00 (first_range_only) and a7e14d8 (index type check).

@LeeroyHannigan
LeeroyHannigan added this pull request to the merge queue Oct 31, 2026
Merged via the queue into main with commit f49c5f8 Oct 31, 2026
35 checks passed
@LeeroyHannigan
LeeroyHannigan deleted the fix/gsi-multi-range-query branch October 31, 2026 12:46
LeeroyHannigan added a commit that referenced this pull request Nov 1, 2026
…service does

Closes #427.

A Query whose KeyConditionExpression has no equality clause on the HASH
attribute was refused with a parser sentence of ExtendDB's own. The service
chooses the sentence from the key schema. Measured on 2026-10-23, every row
repeated with identical answers:

- Single HASH attribute, any clause names it (so the clause on it is a
  non-equality): "Query key condition not supported". Rows: pk > :x,
  pk BETWEEN :x AND :y, begins_with(pk, :x), pk > :x AND sk = :y,
  sk = :x AND pk > :y, zz = :x AND pk > :y, and the GSI forms.
- Single HASH attribute, no clause names it: "Query condition missed key
  schema element: <HASH>". Rows: sk > :x, zz > :x, sk > :x AND zz > :y,
  zz = :x AND yy = :y, and the GSI forms naming the index HASH attribute.
- Several key attributes (two HASH attributes, or one HASH with several
  RANGE attributes): "HASH key attributes h1, h2 must have equality
  conditions specified in the query", naming every HASH attribute, for
  every operator and for an N value on the HASH attribute.
- Two conditions on one attribute: "Invalid KeyConditionExpression:
  KeyConditionExpressions must only contain one condition per key".
- An undefined #name or :value in the key condition is refused first, with
  the "Invalid KeyConditionExpression: " prefix. The rows include pk = :x
  with no values.
- Single HASH attribute, a value of the wrong type: pk > :n with an N value
  on an S key answers "One or more parameter values were invalid: Condition
  parameter type does not match schema type", and begins_with(pk, :n) on an
  N key answers "Invalid KeyConditionExpression: Incorrect operand type for
  operator or function; operator or function: begins_with, operand type: N".
- Legacy KeyConditions is desugared with every reference defined and an
  equality clause, or refused there; no change.

Measured on 2026-11-01, two samples each, on a single HASH attribute
schema: two conditions on one attribute answer the one condition per key
sentence whatever their value types (pk > :x AND pk < :n). A wrong-typed
value is reported only in the clause on the HASH attribute:
pk > :n AND sk > :y, sk = :x AND pk > :n and pk BETWEEN :n AND :m answer
the type sentence, and pk > :x AND sk > :n answers "Query key condition not
supported". With no clause on the HASH attribute no value is checked:
sk > :n answers "Query condition missed key schema element: pk", and on an
index with HASH gsi1_pk and RANGE SK, PK > :n (PK the table's HASH
attribute) and SK > :n answer "Query condition missed key schema element:
gsi1_pk".

Ordering, measured: unused values, filter syntax, projection syntax, the
Select conflicts and undefined key references are reported before this
refusal; an ExclusiveStartKey fault and an oversized key value after it.
An unused value and an invalid FilterExpression are also reported before
the type sentence for pk > :n (measured again on 2026-10-31).

The refusal is in crates/core/src/expression/key_condition/no_hash_equality.rs,
with KeyCondition::lacks_hash_equality, refuse_two_conditions_on_one_attribute,
operands_naming and refuse_without_hash_equality, and
KeyCondition::validate_references is in key_condition/references.rs. The
parser now records one or two clauses with no equality instead of refusing
them, so the engine can choose the sentence.

handle_query skips the classification from #429 for a key condition with no
equality clause, and on a single HASH attribute schema for an equality on
another attribute paired with a non-equality on the HASH attribute
(sk = :x AND pk > :y). Every other key condition goes through that
classification as before. After validate_select_projection, handle_query
calls the engine's refuse_without_hash_equality. It checks the references
of every key condition and refuses the ones the classification skipped. It
refuses two conditions on one attribute first. Then, on a single HASH
attribute schema and only when a clause names the HASH attribute, it runs
the type check and the begins_with check from #429 on that clause, against
that attribute's definition. Then it refuses with the key schema's wording.
On a multi-attribute key schema no value is checked, because the service
answers h > :n with the HASH sentence.

LeadingKeys keeps the binding from #511.
query_kce_without_a_partition_key_equality_binds_only_a_partition_operand
asserts the values it binds for the shapes the parser now records, and
the_engine_still_refuses_every_shape_whose_operand_is_now_bound asserts that
the core refusal answers "Query key condition not supported" for each shape
it binds.

Tests: unit tests in no_hash_equality/tests.rs, references.rs and
crates/engine/src/query/no_hash_equality_tests.rs. The wire file
tests/python/test_query_lone_non_equality.py has 81 rows. 67 of them passed
against the service as a file; the other 14 repeat requests measured on
2026-10-23 (427-8c, row 6, and rows 5a and 5b with the N value) and on
2026-11-01 (the ten rows of TestValueTypes, two of them on the file's
single-idx in place of the measured index). All 81 pass on SQLite and
PostgreSQL 16 builds of this commit, and a SQLite build of main at f49c5f8
fails 74 of them.

Related behaviour, measured: "<>", NOT, OR, IN and contains in
a key condition get "Invalid operator used in KeyConditionExpression" on the
service; pk = :x AND z > :x gets "missed key schema element: sk" on the
service and 200 here; the Select ALL_PROJECTED_ATTRIBUTES text lacks the
"1 validation error detected: " prefix here. A key condition with an
equality clause and a HASH attribute without one (sk = :x on a table,
g1 = :x on an index with HASH g1, g2) is refused by the key schema checks
handle_query runs before it parses the FilterExpression. The service
reports an unused value or an invalid FilterExpression first (measured
2026-10-31).
LeeroyHannigan added a commit that referenced this pull request Nov 1, 2026
…service does

Closes #427.

A Query whose KeyConditionExpression has no equality clause on the HASH
attribute was refused with a parser sentence of ExtendDB's own. The service
chooses the sentence from the key schema. Measured on 2026-10-23, every row
repeated with identical answers:

- Single HASH attribute, any clause names it (so the clause on it is a
  non-equality): "Query key condition not supported". Rows: pk > :x,
  pk BETWEEN :x AND :y, begins_with(pk, :x), pk > :x AND sk = :y,
  sk = :x AND pk > :y, zz = :x AND pk > :y, and the GSI forms.
- Single HASH attribute, no clause names it: "Query condition missed key
  schema element: <HASH>". Rows: sk > :x, zz > :x, sk > :x AND zz > :y,
  zz = :x AND yy = :y, and the GSI forms naming the index HASH attribute.
- Several key attributes (two HASH attributes, or one HASH with several
  RANGE attributes): "HASH key attributes h1, h2 must have equality
  conditions specified in the query", naming every HASH attribute, for
  every operator and for an N value on the HASH attribute.
- Two conditions on one attribute: "Invalid KeyConditionExpression:
  KeyConditionExpressions must only contain one condition per key".
- An undefined #name or :value in the key condition is refused first, with
  the "Invalid KeyConditionExpression: " prefix. The rows include pk = :x
  with no values.
- Single HASH attribute, a value of the wrong type: pk > :n with an N value
  on an S key answers "One or more parameter values were invalid: Condition
  parameter type does not match schema type", and begins_with(pk, :n) on an
  N key answers "Invalid KeyConditionExpression: Incorrect operand type for
  operator or function; operator or function: begins_with, operand type: N".
- Legacy KeyConditions is desugared with every reference defined and an
  equality clause, or refused there; no change.

Measured on 2026-11-01, two samples each, on a single HASH attribute
schema: two conditions on one attribute answer the one condition per key
sentence whatever their value types (pk > :x AND pk < :n). A wrong-typed
value is reported only in the clause on the HASH attribute:
pk > :n AND sk > :y, sk = :x AND pk > :n and pk BETWEEN :n AND :m answer
the type sentence, and pk > :x AND sk > :n answers "Query key condition not
supported". With no clause on the HASH attribute no value is checked:
sk > :n answers "Query condition missed key schema element: pk", and on an
index with HASH gsi1_pk and RANGE SK, PK > :n (PK the table's HASH
attribute) and SK > :n answer "Query condition missed key schema element:
gsi1_pk". On a table with HASH pk (N) and RANGE sk (S),
begins_with(pk, :n) AND sk > :x and sk > :x AND begins_with(pk, :n) answer
the begins_with operand type sentence, as begins_with(pk, :n) alone does.

Ordering, measured: unused values, filter syntax, projection syntax, the
Select conflicts and undefined key references are reported before this
refusal; an ExclusiveStartKey fault and an oversized key value after it.
An unused value and an invalid FilterExpression are also reported before
the type sentence for pk > :n (measured again on 2026-10-31).

The refusal is in crates/core/src/expression/key_condition/no_hash_equality.rs,
with KeyCondition::lacks_hash_equality, refuse_two_conditions_on_one_attribute,
operands_naming and refuse_without_hash_equality, and
KeyCondition::validate_references is in key_condition/references.rs. The
parser now records one or two clauses with no equality instead of refusing
them, so the engine can choose the sentence. When only the second of two
such clauses is a begins_with, the parser keeps that clause whole as the
sort key condition, which the begins_with check reads.

handle_query skips the classification from #429 for a key condition with no
equality clause, and on a single HASH attribute schema for an equality on
another attribute paired with a non-equality on the HASH attribute
(sk = :x AND pk > :y). Every other key condition goes through that
classification as before. After validate_select_projection, handle_query
calls the engine's refuse_without_hash_equality. It checks the references
of every key condition and refuses the ones the classification skipped. It
refuses two conditions on one attribute first. Then, on a single HASH
attribute schema and only when a clause names the HASH attribute, it runs
the type check and the begins_with check from #429 on that clause, against
that attribute's definition. Then it refuses with the key schema's wording.
On a multi-attribute key schema no value is checked, because the service
answers h > :n with the HASH sentence.

LeadingKeys keeps the binding from #511.
query_kce_without_a_partition_key_equality_binds_only_a_partition_operand
asserts the values it binds for the shapes the parser now records, and
the_engine_still_refuses_every_shape_whose_operand_is_now_bound asserts that
the core refusal answers "Query key condition not supported" for each shape
it binds.

Tests: unit tests in no_hash_equality/tests.rs, references.rs and
crates/engine/src/query/no_hash_equality_tests.rs. The wire file
tests/python/test_query_lone_non_equality.py has 83 rows. 67 of them passed
against the service as a file; the other 16 repeat requests measured on
2026-10-23 (427-8c, row 6, and rows 5a and 5b with the N value) and on
2026-11-01 (the ten rows of TestValueTypes, two of them on the file's
single-idx in place of the measured index, and the two begins_with rows on
a table with HASH pk (N) and RANGE sk). All 83 pass on SQLite and
PostgreSQL 16 builds of this commit, and a SQLite build of main at f49c5f8
fails 76 of them.

Related behaviour, measured: "<>", NOT, OR, IN and contains in
a key condition get "Invalid operator used in KeyConditionExpression" on the
service; pk = :x AND z > :x gets "missed key schema element: sk" on the
service and 200 here; the Select ALL_PROJECTED_ATTRIBUTES text lacks the
"1 validation error detected: " prefix here. A key condition with an
equality clause and a HASH attribute without one (sk = :x on a table,
g1 = :x on an index with HASH g1, g2) is refused by the key schema checks
handle_query runs before it parses the FilterExpression. The service
reports an unused value or an invalid FilterExpression first (measured
2026-10-31).
LeeroyHannigan added a commit that referenced this pull request Nov 1, 2026
…Lite

DynamoDB returns a Query on a local secondary index ordered by the index
sort key, with the items that share an index sort key value in one
deterministic order, and a reverse Query as the exact mirror of the forward
one (measured 2026-10-21; the order inside such a group differs from
ExtendDB's byte order and is tracked in #425). ExtendDB orders an LSI
partition by the index sort key, then the base table sort key, and pages
through it with a cursor over the same columns.

Every index table whose index has a sort key has an ordering index on
(pk, sk, base_pk, base_sk) on the PostgreSQL and SQLite backends, with one
sk column per RANGE attribute. A GSI Query orders by its RANGE columns in
key-schema order, then base_pk and base_sk, which is that index without the
constant pk, so both databases walk the index and stop at the page limit.
An LSI Query ordered by (sk, base_sk) and its cursor compared the same two
columns. Neither planner can tell that base_pk is constant inside an LSI
partition, so the index served the index sort key only. PostgreSQL planned
an Incremental Sort over the index scan when the key condition left several
index sort key values and a Sort when an equality left one; SQLite used a
temporary B-tree for the last term of the ORDER BY, and seeked a resumed
page on the index sort key alone. On an LSI partition of 50,000 items with
50 items per index sort key value, a PostgreSQL page with Limit 100 read
151 rows and 154 buffers to return 101, and a page resumed inside a group
read 130 rows and 133 buffers.

The LSI ORDER BY and cursor now list base_pk between the index sort key
and the base sort key, the same columns a GSI with one RANGE attribute
uses, so the ORDER BY is the ordering index without pk. Every row of an LSI
partition stores the queried partition key as base_pk. The cursor binds the
queried partition key for it, not the start key's partition key, so base_pk
compares equal on every row and the base sort key orders the rows as
before. The rows, their order, the page boundaries and the LastEvaluatedKey
and ExclusiveStartKey shapes do not change. On SQLite the statement builder
moves out of query_impl into query_statement so the plan tests can explain
the statement a Query sends.

Measured on servers built from this branch and from main, on the same
52,000 items: the LSI pages plan as an Index Scan or Index Scan Backward on
the ordering index with the cursor in the Index Cond and no sort node, in
both directions, in the custom and the generic plan, reading 101 rows and
103 to 105 buffers. SQLite plans SEARCH USING INDEX idx_order_* with no
temporary B-tree and seeks a resumed page on (sk_s, base_pk, base_sk_s).
The GSI plans and buffer counts are unchanged. The items and
LastEvaluatedKey of every measured page are identical before and after on
both backends.

crates/storage-postgres/tests/lsi_query_plan.rs reads the statement the
engine sent from pg_stat_activity and asserts that its generic and custom
plans have no Sort or Incremental Sort node and no Filter under the index
scan, for a first page and a resumed page with the partition alone, with
lsk = :l, and with lsk BETWEEN, in both directions. It also checks the page
rows and that a start key naming another partition returns the same page.
It skips below PostgreSQL 16, which added EXPLAIN (GENERIC_PLAN). CI runs
it in the PostgreSQL storage-level step. The SQLite plan tests
(crates/storage-sqlite/src/data/query_scan/plan_tests.rs) assert no
temporary B-tree for the same LSI shapes and for GSI pages, and page
through an LSI in both directions. The cursor unit tests on both backends
assert the new LSI fragment and bind.

A GSI with several RANGE attributes orders by every RANGE column (#429).
crates/storage-postgres/tests/lsi_query_plan/multi_range_gsi.rs, a module of
the lsi_query_plan test, and a fourth SQLite plan_tests case run such a GSI
with RANGE attributes a, b and c for the partition alone, an equality on a,
an equality on a with b BETWEEN, and equalities on all three, first and
resumed pages in both directions, and assert the same index walk; both
also check the rows of the pages. Their plan checks pass on main. With the
ORDER BY cut to the first RANGE column, the SQLite case sorts in a
temporary B-tree and the PostgreSQL case returns the first page out of
order. With a condition applied to the first RANGE column instead of the
one it names, the SQLite case returns no rows for a = :a AND b BETWEEN.

With base_pk removed from the LSI ORDER BY, the PostgreSQL plan test and
the SQLite LSI plan test fail on the sort node; the SQLite GSI plan test
and paging test pass, as the rows do not change. With base_pk removed from
the LSI cursor comparison, the PostgreSQL plan test fails on the Filter
under the index scan and the SQLite cursor unit tests fail on the
fragment; the paging tests pass. With the start key's partition key bound
instead of the queried one, the PostgreSQL other-partition page check and
the SQLite bind test fail. tests/test_query_scan.py and
tests/test_multipart_gsi.py pass on both backends.
docs/design/04-component-storage.md describes the LSI cursor and ORDER BY
and the sort step they avoid.

Closes #366
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.

[Bug] Query on a GSI with one HASH and several RANGE attributes: wrong sort order, and three or more key conditions are rejected

2 participants