Skip to content

fix(scan): check AttributesToGet, ScanFilter, ExclusiveStartKey and ExpressionAttributeValues in the service's order - #854

Open
LeeroyHannigan wants to merge 6 commits into
fix/563-scan-range-constraintsfrom
fix/564-scan-request-validation
Open

LeeroyHannigan wants to merge 6 commits into
fix/563-scan-range-constraintsfrom
fix/564-scan-request-validation

Conversation

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

What

Stacked on #850 (fix/563-scan-range-constraints). This branch adds six commits on top of it. They cover items 1, 2, 4 and 5 of #564, with one row of item 5 left open (below). Item 3, a malformed number in a ScanFilter list, is also item 3 of #477, and #507 covers it (the comment of 25 October on #564). This branch does not check the lists' numbers. This branch and #507 both change handle_scan in crates/engine/src/scan.rs, so the one that merges second must redo its changes there.

The service checks a Scan request in one of two orders. The order follows one property of the request: whether it holds a value that is not one AttributeValue. Such a value is empty, carries an unknown or lower-case type descriptor, or carries two descriptors, at the top or inside an L or M. The places read are the ExpressionAttributeValues entries, the ExclusiveStartKey values and the ScanFilter lists. A request with such a value follows order U. Every other request follows order R. The same property picks the older form of the constraint clauses (#563). crates/engine/src/scan_request.rs computes it once per request (CheckOrder), and its module doc lists both orders.

Order R:

  1. the aggregated constraints in the newer form: the AttributesToGet length and the Limit, Segment and TotalSegments ranges, in one message, the length clause first. Then the ReturnConsumedCapacity enum
  2. the parameter mix, in which an object ScanFilter counts as present whether or not it is empty
  3. every number in ExpressionAttributeValues, the entries in the member order below ({":n": {"N": "fred"}, ":a": {"N": "bob"}} answers fred, which sorted order would not). An entry that holds a set fault is skipped here. Its set fault is reported at step 4. A malformed number inside it is reported after step 4 and the typed deserialization: {"NS": ["bob", "1", "1"]} beside a value nested 32 levels answers the nesting text. The other entries are checked. The numbers of the ScanFilter lists are fix(query): check legacy conditions against the key schema #507's check
  4. an empty or duplicate set in ExpressionAttributeValues, the entries in sorted name order on every backend. With two set faults the service answered each in one of its two sends. Then the nesting depth of ExpressionAttributeValues, with M levels counted as L levels
  5. the request parameter checks: ExpressionAttributeNames with no expression, ExpressionAttributeValues with no expression, ConditionalOperator with no ScanFilter, a ScanFilter condition's value count
  6. the Select enum
  7. the expression checks: the filter syntax, the projection syntax, an undefined value, the operand types, the Select rules, the unused values, COUNT with a ProjectionExpression, COUNT with AttributesToGet, the Segment pairing
  8. the key's number and set faults, in the member order below
  9. the table lookup, the index resolution, the index rules, the key schema

Order U:

  1. the aggregated constraints in the older form, then the ReturnConsumedCapacity enum, then the Select enum
  2. the parameter mix, then ExpressionAttributeNames and ExpressionAttributeValues with no expression, then the ScanFilter list values. A list value that is not one AttributeValue answers Supplied AttributeValue is empty, must contain exactly one of the supported datatypes, with no prefix, for {} and for an unknown descriptor alike, and two descriptors the more-than-one text. The conditions are visited in the member order below and each list in its order. Then ConditionalOperator with no ScanFilter and a ScanFilter condition's value count
  3. the key's faults, shape and value together: the first faulty member in the member order below. A shape fault carries the prefix The provided starting key is invalid: . A number carries the value check's text. A set fault carries the texts of order U (below)
  4. the ExpressionAttributeValues entries, in the member order below, the first faulty one named, each fault in the form ExpressionAttributeValues contains invalid value: <text> for key :a. The faults are an entry that is not one AttributeValue, whether an expression reads it or not, a malformed number and a set fault. The number may sit at the top of the entry or inside its L or NS. Its text is The parameter cannot be converted to a numeric value: bob, or the 38-digit text. The set fault takes the set texts of order U. Inside one entry the first fault in the value's element order is named. The numbers and the set faults of this map are not checked at request time in this order
  5. the nesting depth, then the expression checks and the table

The handler in crates/engine/src/scan.rs holds back the values each order reports late, so that the typed deserialization does not report them first. They are the key (StartKeyBody), the faulty ExpressionAttributeValues entries (ExpressionValuesBody), in order U the faulty ScanFilter list values (ScanFilterBody), and in order R an invalid Select (SelectBody). A readable stand-in replaces each entry and list value taken out. A field of the wrong JSON type stays in the body, and the typed deserialization reports it first, as before.

The member order inside ExclusiveStartKey, and the entry order of ExpressionAttributeValues in order U, is the iteration order of a Java HashMap over the names. It takes String.hashCode() of each name, spreads it as h ^ (h >>> 16), and masks it to the table's buckets. The table has four buckets for up to three names and doubles when it holds more than three quarters of its size. Four to six names get eight buckets, seven to twelve get sixteen. The buckets come in ascending order, and the request's order holds inside a bucket. Keys of five and seven members and maps of four and seven entries were measured on 2026-11-05 at 07:38 UTC and follow the rule. So does the request's order inside a bucket for the map's entries. With four buckets sk falls in bucket 0, a in 1, b in 2, pk, gpk, id and ts in 3. With eight, gpk and pk fall in 3 and gsk in 6. Names outside ASCII hash on their UTF-16 code units, a name outside the Basic Multilingual Plane (U+1D11E) on its two. Java's HashMap also doubles a table under 64 buckets when one bucket reaches nine names. The code sizes the table by the count of names alone and departs from the JDK there (not measured). The rule is fitted to the measured rows. The service does not document it. The code comment at fitted_member_order states the rows. The parsed body sorts object members on every build but the MongoDB one. dispatch now passes the request text to the handler. The handler reads the request's order of the key members, the map's entries and the ScanFilter conditions from it.

The set texts follow the order. Order R quotes the members as JSON: Input collection ["a", "a"] contains duplicates. and Input collection ["YQ==","YQ=="]of type BS contains duplicates.. Order U does not: Input collection [a, a] contains duplicates.. For an NS in order U the text is Input collection contains duplicates, with no prefix and no full stop. An empty SS and an empty BS in the key carry the key prefix in order U. An empty NS does not.

The first commit adds the four checks and reports the length constraint alone, after the ranges. The second commit folds the length constraint into the aggregated message, ahead of the ranges, and cites the orders measured on 2026-10-30. The third commit moves the key's number check out of the request-time checks, as measured on 2026-10-31. The fourth commit puts the key's faults at two stages and moves four checks ahead of them, as measured on 2026-11-05. The fifth commit replaces the single order with the two orders above, as measured on 2026-11-05 at 03:18 UTC. It adds the member order and the set texts by order. The sixth commit adds the ScanFilter list checks and the entry order and invalid-value form of ExpressionAttributeValues in order U. It adds the table's doubling for four members and the BS prefix. Those were measured on 2026-11-05 at 05:31 UTC. The comment at the top of handle_scan lists the stages.

One row of item 5 stays open. From 63 L levels the JSON parser refuses the body (128 JSON levels, two per L level) before any check reads it. ExtendDB answers SerializationException where the service answers the prefixed nesting text. #807 changes request parsing, which every operation shares, and covers these depths.

Why

Refs #564.

Measured against DynamoDB (us-east-1) on 2026-10-25 at 10:38 and 10:55 UTC, on 2026-10-30 at 12:40 UTC and on 2026-10-31 between 23:54 and 23:56 UTC. Measured again on 2026-11-05 between 00:54 and 01:05 UTC, at 03:18 UTC, at 05:31 UTC, at 06:20 UTC and at 07:38 UTC. The probe sent each request twice, as a raw signed Scan. The key with two number faults was sent again on 2026-11-04 at 23:45 UTC.

  • An empty ScanFilter counts as present for the parameter mix. ScanFilter: {} with a FilterExpression answers Can not use both expression and non-expression parameters in the same request: Non-expression parameters: {ScanFilter} Expression parameters: {FilterExpression}. The service accepts ScanFilter: {} alone.
  • AttributesToGet: [] answers 1 validation error detected: Value at 'AttributesToGet' failed to satisfy constraint: Member must have length greater than or equal to 1: alone, with a FilterExpression, with a FilterExpression and a malformed number, and with ScanFilter: {} and a FilterExpression. The length constraint joins the range constraints. Limit: 0 with AttributesToGet: [] answers 2 validation errors detected: Value at 'AttributesToGet' failed to satisfy constraint: Member must have length greater than or equal to 1; Value at 'Limit' failed to satisfy constraint: Member must have value greater than or equal to 1.
  • A malformed N in ExclusiveStartKey answers The parameter cannot be converted to a numeric value: bob. That holds with a valid filter, with no filter, and in the index key of a GSI scan and of an LSI scan. It holds for an NS member, an L member, and a number in the L of an attribute that is not a key attribute. On an N hash key, " 1" and "" answer the same text with the value echoed. 39 digits answer Attempting to store more than 38 significant digits in a Number. {"pk": {"N": "bob"}} alone, with no sk, answers bob ahead of the key schema check (2026-11-05).
  • The constraint clauses come ahead of every key fault. In order R they take the newer form (Value at 'Limit' ...), in order U the older form (Value '0' at 'limit' ...).
  • No single order fits the measured rows. {"pk": {}, "sk": {"S": "x"}} with Select: "BAD" answers the Select clause. A value nested 32 levels with Select: "BAD" answers the nesting text. The same nested value beside that key answers the key's text. The three rows fit the two orders above, and so does every other measured row.
  • Order R, the enums and the request parameter checks (2026-11-05): ReturnConsumedCapacity: "BAD" beside the parameter mix and beside Select: "BAD" answers its own clause. ExpressionAttributeNames with no expression answers its text ahead of ExpressionAttributeValues with no expression and of ConditionalOperator. ConditionalOperator answers its text ahead of a ScanFilter condition with no value. {":e": {"NS": []}} with no expression answers One or more parameter values were invalid: An number set may not be empty ahead of the no-expression check. ExpressionAttributeValues with no expression and Select: "BAD" answers the no-expression text. The mix beside Select: "BAD" answers the mix text.
  • Order R, the nesting check: an ExpressionAttributeValues value nested 32 to 62 L levels deep answers Nesting Levels have exceeded supported limits: Attributes in the item have nested levels beyond supported limit. 31 levels answer the next check's text. The nesting text comes ahead of the Select enum (2026-10-30) and of the no-expression check (2026-10-31, B4-5a: a nested value with no expression at all). It comes ahead of the filter syntax and of a number in the key (2026-11-05). An empty NS in another entry answers its set text ahead of the nesting text (2026-11-05 at 06:2x UTC, {":p": {"S": "x"}, ":d": <32 levels>, ":e": {"NS": []}}). It holds for a value the filter reads and for one nothing reads, with an N or an S leaf. {"NS": ["bob", "1", "1"]} in another entry answers the nesting text: the number inside an entry with a set fault comes after it. {"L": [{"N": "bob"}, {"NS": []}]} in another entry answered the number in one send and the nesting text in the other. An SS with duplicates in another entry answered its duplicates text in one send and the nesting text in the other (2026-11-05 at 07:38 UTC). The service reports a malformed number at the bottom first, at every depth measured (31 to 60, and an M at 32). From 63 levels (63, 70, 80, 99 and 100 measured) the text is ExpressionAttributeValues contains invalid value: Nesting Levels have exceeded supported limits: Attributes in the item have nested levels beyond supported limit for key :d.
  • Order R, the expression checks among themselves (2026-11-05, each pair sent together): the filter syntax ahead of the projection syntax, SPECIFIC_ATTRIBUTES, ALL_PROJECTED_ATTRIBUTES and the Segment pairing. An undefined value ahead of the unused values, the Segment pairing and SPECIFIC_ATTRIBUTES. An operand type ahead of the unused values and the Segment pairing. The unused values ahead of COUNT with a ProjectionExpression and of the Segment pairing. COUNT with AttributesToGet ahead of the Segment pairing.
  • Order R, the key's value faults, after the expression checks and ahead of the table lookup, with no prefix. Beside {"pk": {"N": "bob"}, "sk": {"S": "x"}}, each expression check answers its own text. A missing index, ConsistentRead on a GSI and a missing table answer the number text. The set texts are One or more parameter values were invalid: An number set may not be empty, ... An string set may not be empty and ... Binary sets should not be empty. A set with duplicates answers ... Input collection contains duplicates. for an NS (["1", "1"] and ["1", "1.0"] alike), ... Input collection ["a", "a"] contains duplicates. for an SS (["a\"b", "a\"b"] for members with a quote) and ... Input collection ["YQ==","YQ=="]of type BS contains duplicates. for a BS. In an NS, ["bob", "1", "1"] and ["1", "1", "bob"] answer bob. A number in ExpressionAttributeValues is reported ahead of a number in the key. A malformed number in a ScanFilter list answers The parameter cannot be converted to a numeric value: bob (2026-11-05 at 05:31 UTC). It does so alone and beside {"pk": {"N": "fred"}, "sk": {"S": "x"}} in the key. That check is fix(query): check legacy conditions against the key schema #507's and is not made here. Beside an empty NS in another entry, a malformed number in ExpressionAttributeValues answered the empty-set text in one send and the number text in the other. An empty NS answers its text ahead of ExpressionAttributeNames with no expression and ahead of Select: "BAD", and after ReturnConsumedCapacity: "BAD". ConditionalOperator and a ScanFilter condition with no value answer their texts ahead of Select: "BAD".
  • Order R, two faulty entries (2026-11-05 at 07:38 UTC). {":n": {"N": "fred"}, ":a": {"N": "bob"}} answers fred, the entry first in the member order below. Sorted order would give bob. {":n": {"NS": []}, ":a": {"SS": []}} answered One or more parameter values were invalid: An string set may not be empty in one send and ... An number set may not be empty in the other. An empty NS beside the parameter mix answers the mix text.
  • Order U, the key's faults (2026-11-05). A key value that is empty or carries an unknown or lower-case type descriptor answers The provided starting key is invalid: Supplied AttributeValue is empty, must contain exactly one of the supported datatypes. A key value with two descriptors answers The provided starting key is invalid: Supplied AttributeValue has more than one datatypes set, must contain exactly one of the supported datatypes. An empty value inside an L or M, or in a member that is not a key attribute, answers the first text too. Five checks answer their own text beside such a key. They are ExpressionAttributeValues with no expression, ExpressionAttributeNames with no expression, ConditionalOperator with no ScanFilter, a ScanFilter condition with no value, and the parameter mix. Nine checks answer the key's text beside it. They are the filter syntax, an undefined value, the operand types, the projection syntax, the three Select rules and the unused value. The Segment pairing does too, with each of its three faults. The three table checks (a missing index, ConsistentRead on a GSI and a missing table) do too. A nested value the filter reads does too, with an S or an N leaf. So does a nested value nothing reads.
  • Order U, the ExpressionAttributeValues entries (2026-10-31 B4-7 and 2026-11-05). An empty entry the filter reads answers ExpressionAttributeValues contains invalid value: Supplied AttributeValue is empty, must contain exactly one of the supported datatypes for key :a with no key, with a valid key and on a missing table. An entry nothing reads answers the same text for its key, ahead of the nesting check (B4-7) and of the unused values. {"X": "1"} answers the empty-value text in that position. Two descriptors answer the more-than-one text with the same wrapper. The entry's text comes ahead of the filter syntax, an undefined value and the projection syntax. Beside Select: "BAD" or ReturnConsumedCapacity: "BAD" the enum clause comes first, and beside the parameter mix and Select: "BAD" the Select clause does. Beside a key fault the key's text comes first. A malformed number in the map answers ExpressionAttributeValues contains invalid value: The parameter cannot be converted to a numeric value: bob for key :a. It comes after the key's faults: {":n": {"N": "bob"}} beside {"pk": {}, ...} answers the key's text, with and without an empty entry. The entries are visited in the member order below. {":p": {"S": "x"}, ":a": {"N": "bob"}, ":e": {}} names :a, and the same with :n names :n. {":p": {"S": "x"}, ":b": {}, ":a": {"X": "1"}} names :b. An empty value at the bottom of 32 L levels is named too: {":d": <32 levels around {}>} answers the empty-value text for :d (2026-11-05 at 06:20 UTC). A set fault in an entry takes the same form (2026-11-05 at 07:38 UTC): {":p": {"S": "x"}, ":s": {"NS": []}, ":e": {}} answers ExpressionAttributeValues contains invalid value: One or more parameter values were invalid: An number set may not be empty for key :s, and an SS with duplicates there ... Input collection [a, a] contains duplicates. for key :s. A malformed number inside an L or an NS answers the number text for its key, and 39 digits ... Attempting to store more than 38 significant digits in a Number for key :a. {":p": {"S": "x"}, ":e": {}, ":a": {"N": "bob"}} names :e: :a and :e share a bucket and the request's order decides. With four entries and with seven the entry the eight and sixteen buckets put first is named. Inside one entry {"L": [{"N": "bob"}, {}]} answers the number.
  • Order U, the ScanFilter list values (2026-11-05 at 05:31 UTC). [{}] and [{"X": "1"}] answer Supplied AttributeValue is empty, must contain exactly one of the supported datatypes, with no prefix. ExpressionAttributeNames with no expression answers its text ahead of the list value. The list value answers its text ahead of ConditionalOperator, a condition with two values, a key shape or number fault and a missing table. Select: "BAD" answers its clause ahead of the list value. Two descriptors in a list value answer Supplied AttributeValue has more than one datatypes set, must contain exactly one of the supported datatypes, and an empty value inside an L the empty-value text (2026-11-05 at 07:38 UTC). With two conditions the one first in the member order below is named: pk ahead of sk, and ts ahead of id when ts comes first. In one list the first faulty value is named. ExpressionAttributeValues with no expression answers its text ahead of the list value. An invalid ComparisonOperator answers its constraint clause ahead of the list value, a legacy-condition check this branch does not make (below). In order R an empty NS in a list answers One or more parameter values were invalid: An number set may not be empty, the deserializer's text here.
  • Order U, the key's set texts (2026-11-05). Beside an empty ExpressionAttributeValues entry the filter reads, a key NS with duplicates answers Input collection contains duplicates and a key SS with duplicates One or more parameter values were invalid: Input collection [a, a] contains duplicates.. An empty key NS answers One or more parameter values were invalid: An number set may not be empty there and beside an unreadable key member. An empty key SS beside an unreadable key member answers The provided starting key is invalid: One or more parameter values were invalid: An string set may not be empty. Beside an empty ExpressionAttributeValues entry the filter reads it answers the same. An empty key BS beside an unreadable key member answers The provided starting key is invalid: One or more parameter values were invalid: Binary sets should not be empty. A key BS with duplicates there answers One or more parameter values were invalid: Input collection [YQ==, YQ==]of type BS contains duplicates.. The empty key BS keeps the prefix beside an empty ExpressionAttributeValues entry the filter reads (2026-11-05 at 07:38 UTC). It keeps it inside an L and in a member that is not a key attribute.
  • The member order (2026-10-31, 2026-11-04 and 2026-11-05). The rows are 112 with a shape fault among two faulty members on five tables, and five rows on two more. On the pk, sk table the service names sk in both request orders. On HASH a, RANGE b it names a. On HASH pk, RANGE a it names a in both request orders. On ts, id and on id, ts it names the member first in the request. On HASH ä, RANGE é it names ä in both request orders. On the GSI, {"gpk": {}, "pk": {"N": "bob"}, "sk": {"S": "x"}} names gpk. On a GSI with a range key the key has four members. There {"gpk": {}, "gsk": {"N": "bob"}, "pk": ..., "sk": ...} names gpk in both request orders, and {"gpk": ..., "gsk": {}, "pk": {"N": "bob"}, "sk": ...} names pk. Four buckets would reverse both. Eight buckets give them. Every row fits the hash order above. With two value faults and no shape fault the service's two sends disagreed on 19 of the 40 matrix cells (20 pairs, both request orders). {"pk": {"N": "bob"}, "sk": {"N": "fred"}} answered bob on 2026-10-31 and fred on 2026-11-04. The tests for those rows accept either member's fault.

main (25ee109bf, a SQLite server) accepts ScanFilter: {} with a FilterExpression. It scans and compares for {"N": "bob"} in a ScanFilter list (200, no item), as every commit of this branch does. The check is #507's. It answers Invalid ProjectionExpression: The expression can not be empty; for AttributesToGet: [] alone, with a FilterExpression and with Limit: 0. The #562 base answers the Limit clause alone for that last request, in the newer form. For a malformed ExclusiveStartKey number on an S key, main answers the key schema text (The provided starting key is invalid: The provided key element does not match the schema). On an N sort key it answers 500 InternalServerError, and on an N hash key it scans and returns no items. For " 1", "" and 39 digits on an N hash key it scans the table. main reports an empty key value, two descriptors and the set faults with the deserializer's texts, ahead of every handler check. It reports an unknown descriptor as a SerializationException. main has no nesting check on Scan: 32 levels answer the unused-value text or scan, and from 63 levels the JSON parser's recursion limit answers SerializationException: Start of structure or map found where not expected: recursion limit exceeded.

Known differences (main differs from the service on each of them too):

Testing done

Unit tests in crates/engine/src/scan_request/request_tests.rs: twenty-three tests. Eighteen hold measured rows. One sends each request-time check of order R beside a key with bob and expects the check's text, and the Select enum beside an unreadable key. One holds the combinations measured on 2026-10-31. One sends the two-member rows of the five tables through StartKeyBody with the request's member order. One sends the ExpressionAttributeValues rows of order U through ExpressionValuesBody with the request's entry order. One sends the ScanFilter list rows of order U, and checks that a malformed number in a list is left alone. One sends the two faulty entries of order R through the request-time checks. It checks the numbers in the member order and the set faults in sorted order. One sends the order-U set faults and the element order inside one entry through ExpressionValuesBody. The other five hold no service text. They check the order flag's classes, the fitted order's buckets and masks, and the member order read from the request text. They check what StartKeyBody and SelectBody hold back. The rows with two value faults in two members accept either member's fault. tests/python/test_scan_request_validation.py sends each measured row as raw signed JSON and compares the message byte for byte: 230 tests. Among them are B4-7, the 2026-11-05 rows of each order and the member-order rows on seven tables. Among them are the 69 rows of 05:31, 06:20 and 07:38 UTC. Nineteen rows assert only the check's place, where the text predates this branch (#722, #832, #838, #839). Four rows accept either of the service's two answers, and the row of a condition with no value beside Select: "BAD" accepts this branch's text and #507's. The two ScanFilter number rows are marked xfail for #507 and are not counted below.

Where the rows fail, on SQLite servers of the base commits:

Rows Fail on
153 of the 228 tests counted the #563 head
127 of the 228 tests counted the second commit of this branch
94 of the 228 tests counted the third commit of this branch
88 of the 228 tests counted. B4-7 and the other unreadable expression values an expression reads or not. The nested value beside a key shape fault. The two-member rows the fitted order settles. ReturnConsumedCapacity beside the mix. The Select enum after the no-expression check. An undefined value and an operand type ahead of the unused values and the Segment pairing. COUNT with a ProjectionExpression after the unused values. The set texts by order. Of the 2026-11-05 rows of 05:31, 06:20 and 07:38 UTC, 27 of the 29 the fifth commit fails, and 20 more. The ones the fourth commit passes are the four-member keys with {} in gpk. The more are the key names outside ASCII and outside the Basic Multilingual Plane. The key BS with duplicates and the empty key SS beside an empty entry. The empty NS beside a number, beside ExpressionAttributeNames, beside Select: "BAD" and beside a nested value. The empty leaf of a nested entry in order U. The unreadable entry beside the filter syntax, an undefined value, the projection syntax and the mix. ConditionalOperator and a condition with no value beside Select: "BAD". In order R, the set faults beside a nested value and two set faults. The number ahead of a set fault in one entry beside a nested value. the fourth commit of this branch
29 of the 228 tests counted. A malformed ExpressionAttributeValues number in order U, alone with :a and :n, beside a key shape fault, inside an L and an NS. The same with 39 digits, and among four and seven entries. Two faulty entries, and the request's order inside a bucket. The order-U set faults and the number ahead of an empty value in one entry. The three four-member keys and the keys of five and seven members. An unknown descriptor in a ScanFilter list, ExpressionAttributeNames and ExpressionAttributeValues with no expression beside an empty list value, and which of two conditions is named. An empty BS beside an unreadable key member, beside an unreadable expression value, inside an L and in a member that is not a key attribute. The two numbers of order R, and the number inside an entry with a set fault beside a nested value. the fifth commit of this branch
The other rows pass on the commit before the one that adds them, whose answers they pin

Mutants of the head, one at a time. The order-R set pass in the parsed body's order fails no test. The service answered each of the two set faults in one of its two sends, so that row accepts both. On SQLite the parsed order is the sorted order. Each other mutant is caught by the wire file. The order-R number check in sorted name order fails 1 wire test and 1 unit test. The order-U set fault in the bare form fails 2 wire tests and 1 unit test. The shape pass ahead of the number pass inside one entry fails 1 wire test and 1 unit test. The table doubled at more than four names fails 6 wire tests and 1 unit test. The order-U number ahead of the key's faults fails 2 wire tests. The order-R set pass after the nesting check fails 1 wire test and 1 unit test. The empty key BS without the prefix fails 4 wire tests and 1 unit test. The ScanFilter list step after ConditionalOperator fails 2 wire tests. The ScanFilter list values left to the deserializer fails 5 wire tests and 1 unit test. The ExpressionAttributeValues entries ordered from the parsed body instead of the request text fails 1 wire test. The ExpressionAttributeValues entries visited in sorted order in order U fails 10 wire tests and 5 unit tests. Four buckets kept for four members fails 7 wire tests and 1 unit test. The order-U number fault in the plain form fails 7 wire tests and 2 unit tests. The order flag forced to R fails 71 wire tests and 14 unit tests. The flag forced to U fails 75 wire tests and 26 unit tests. The key's member order replaced by sorted order fails 31 wire tests and 8 unit tests. The unreadable expression values moved after the unused check in order U fails 20 wire tests. The nesting check moved after the Select enum in order R fails 5 wire tests and 4 unit tests. The parameter mix moved after the key's faults fails 2 wire tests and 5 unit tests. The unreadable expression values moved ahead of the key's faults in order U fails 9 wire tests. The table lookup moved ahead of the key's value faults in order R fails 1 wire test. A lower-case descriptor left to the deserializer fails 1 wire test and 1 unit test.

Gates on the head: cargo +1.97.0 fmt --all --check, cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings, cargo +1.97.0 check --locked --workspace --all-targets, cargo +1.88.0 check --locked --workspace, and cargo +1.97.0 test --locked -p extenddb-core -p extenddb-engine --lib (521 and 239 tests). Wire tests on SQLite and PostgreSQL (pgvector/pgvector:pg16) servers of this head, on each backend: 338 in tests/python/test_scan_range_constraints.py, test_scan_request_validation.py, test_scan_edge_cases.py and test_query_scan.py, and 185 in tests/test_query_scan.py and tests/test_scan_number_values.py. The two new files also pass on MongoDB and Cassandra servers of this head: 286 on each.

The 936 measured requests went to SQLite and PostgreSQL servers of this head. They are the 806 rows of the 2026-11-05 measurements and the 130 rows of 2026-10-31 and 2026-11-04. On SQLite 146 rows differ. 111 of them are the texts of #722, #832, #838 and #839 in the known differences above. 11 are pairs of faulty members with two value faults. 10 differ on main and on every commit of this branch. They are the item count of a scan from a Unicode-digit or +1 key on an N table, six Query rows, and a ScanFilter list nested 32 levels. 12 are rows of the last four known differences and of the two SerializationException texts named there. 2 are the ScanFilter number rows, #507's check. On PostgreSQL the same rows differ, and one more. Segment: -0.5 of 2 (read as segment 0) on a table of three items returns 2 items there, where the service returned 1. The segment an item falls in is the backend's partitioning. The fifth commit differs on 175 of the 936 rows, the fourth on 233 of the first 863.

Not measured, and kept in the code's order. The AttributesToGet length clause beside a Segment or TotalSegments fault alone. Its older form (Value '[]' at 'attributesToGet' ..., listed last). The length against the enums. M levels in the nesting count. ScanFilter: {} and AttributesToGet: [] with a ProjectionExpression. In order R, the nesting check against ExpressionAttributeNames with no expression, ConditionalOperator and the ScanFilter value count. The projection syntax against an undefined value, an operand type and the unused values. A set fault in ExpressionAttributeValues against a malformed number in another entry. The number is checked first here. The service's two sends disagreed. Which of two set faults in ExpressionAttributeValues is reported (the first in sorted name order). The service answered each in one of its two sends. A set fault in a ScanFilter list value in order U (at the list's step). In order R the deserializer's text is measured. In order U, the Select enum against the mix (the enum first). The mix beside Select: "BAD" and an empty entry answers the Select clause. An empty value ahead of a malformed number inside one ExpressionAttributeValues entry (the empty value, the element order). The number ahead of the empty value is measured. A set fault in ExpressionAttributeValues against the key's faults (the key first, the set fault with the map's entries). A BS with duplicates in the key beside an unreadable expression value (no key prefix, the members unquoted). Six names in a map (eight buckets, measured for four, five and seven). Nine or more names in one bucket, where Java's HashMap doubles a table under 64 buckets early and the code does not. Two value faults inside one M or NS (the map's and the request's order). Query does not change here. It has no nesting check, and its AttributesToGet and QueryFilter forms were not measured. 63 to 100 levels: the open row above.

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic)
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk
    format, or public CLI surface, an RFC has been accepted or is linked
    below. Otherwise, an ADR captures the decision (link below).

ADR / RFC: n/a


By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.

…key numbers and nesting

Measured against the service on 2026-10-25 at 10:38 and 10:55 UTC, each
request sent twice (#564, items 1, 2, 4 and 5):

- An empty ScanFilter counts as present for the expression and
  non-expression parameter mix. ScanFilter {} with a FilterExpression
  answers the mix text; ScanFilter {} alone is accepted.
- AttributesToGet [] answers "1 validation error detected: Value at
  'AttributesToGet' failed to satisfy constraint: Member must have length
  greater than or equal to 1", alone, with a FilterExpression, and with a
  FilterExpression and a malformed number, so it comes ahead of the
  number check.
- A malformed N in ExclusiveStartKey answers "The parameter cannot be
  converted to a numeric value: <text>".
- An ExpressionAttributeValues value nested 32 or more L levels deep
  answers "Nesting Levels have exceeded supported limits: Attributes in
  the item have nested levels beyond supported limit". 31 levels pass.

ExtendDB accepted the first two, answered the starting-key schema text
for the third, and had no nesting check on Scan. The request-time checks
in crates/engine/src/scan_request.rs now make the four checks in the
order the module doc lists. M levels count the same as L levels; only L
was measured with a valid number at the bottom.

Unit tests are in crates/engine/src/scan_request/request_tests.rs.
tests/python/test_scan_request_validation.py has 18 rows; it passes on
SQLite and PostgreSQL servers built from this branch, and 9 of the 18
fail on a server built from the #563 branch, which this one is stacked on.

One row of item 5 stays open. From 63 L levels the service answers
"ExpressionAttributeValues contains invalid value: Nesting Levels have
exceeded supported limits: Attributes in the item have nested levels
beyond supported limit for key :d" (measured 2026-10-30 at 63, 70, 80,
99 and 100 levels; 32 to 62 levels answer the bare text), and ExtendDB
answers SerializationException from the JSON parser, whose recursion
limit is 128 levels, 62 of them L wrappers. That is a change to request
parsing for every operation, not to the Scan checks, and is filed on its
own. Item 3, the legacy AttributeValueList numbers, is #507's.

Refs #564
Measured against the service on 2026-10-30 at 12:40 UTC, each request
sent twice, as a raw signed Scan:

- Limit 0 with AttributesToGet [] answers "2 validation errors detected:
  Value at 'AttributesToGet' failed to satisfy constraint: Member must
  have length greater than or equal to 1; Value at 'Limit' failed to
  satisfy constraint: Member must have value greater than or equal to
  1". The length constraint is one of the constraints the service
  aggregates into one message, listed ahead of Limit. The check reported
  the Limit fault alone and never reached the length check.
- AttributesToGet [] with ScanFilter {} and a FilterExpression answers
  the length clause alone, so the aggregated constraints come ahead of
  the parameter mix.
- A malformed N in ExclusiveStartKey with no filter, and in the index
  key of a GSI scan, answers "The parameter cannot be converted to a
  numeric value: bob"; with ReturnConsumedCapacity BAD as well, the enum
  text alone.
- An L nested 32 levels with Select BAD answers the nesting text, so the
  nesting check comes ahead of the Select enum.

The length check joins the range check, and the module doc, the comment
at the top of handle_scan and the tests cite the measured orders. Where
the length clause sits beside a Segment or TotalSegments fault alone,
and its older form, are not measured: the code lists it first in the
newer form, as beside Limit, and last in the older form with the empty
list echoed, as the older form reverses the newer one. Both are in the
unit test of requests the service was not sent.

tests/python/test_scan_request_validation.py has 24 rows: the six new
ones send the measured requests above, and the table gains a GSI for
the index-scan row.

Refs #564
Measured against the service on 2026-10-31 between 23:54 and 23:56 UTC,
each request sent twice, as a raw signed Scan with {"pk": {"N": "bob"},
"sk": {"S": "x"}} as the ExclusiveStartKey:

- Every other check comes ahead of the key's number: the Limit, Segment
  and AttributesToGet constraint clauses, the ReturnConsumedCapacity and
  Select enum clauses, the Select rule for SPECIFIC_ATTRIBUTES, the
  parameter mix, the nesting text, the FilterExpression syntax error and
  the unused-value text each answer their own text, and a malformed number
  in ExpressionAttributeValues is reported ahead of one in the key. The
  check ran at request time, between the value numbers and the nesting
  check, so the Select enum, the nesting text, the SPECIFIC_ATTRIBUTES
  rule, the filter syntax and the unused value lost to the key's number.
- Inside the key a number fault comes ahead of an empty value or an
  unknown type descriptor, in either member order, and a number nested
  in the L of an attribute that is not a key attribute counts: {"pk": {},
  "sk": {"N": "bob"}} answers The parameter cannot be converted to a
  numeric value: bob. The typed deserialization of the body reported the
  empty value first, with the deserializer's text.
- A key whose only fault is an empty value answers The provided starting
  key is invalid: Supplied AttributeValue is empty, must contain exactly
  one of the supported datatypes. The deserializer's text had no prefix.

The handler now takes the key out of the body before the typed
deserialization when its values do not deserialize for a reason other
than a shape fault, and checks the key at its own stage, right before the
key schema: every number in it, in the sorted order of its members, with
the value check's rules and text, then the deserializer's fault with the
service's prefix. A key of the wrong JSON shape stays in the body and is
reported first, as before. Not measured: the key against the table
lookup, the index resolution, the index rules and the Segment pairing,
which the handler checks ahead of it; an unreadable-only key against the
filter syntax and unused-value checks; the prefix on the deserializer's
texts for two type descriptors and for an empty or duplicate set; and
which of two number faults is named when the request lists the members
the other way round (sorted order names bob either way).

The unit test that applied the checks' order to rows the service had not
been sent is renamed: all seven of its rows were sent on 2026-10-31 and
answered as asserted.

request_tests.rs has the measured rows, and
tests/python/test_scan_request_validation.py sends them as raw signed
requests: seven rows for the other checks ahead of the key, the nesting
text, the SPECIFIC_ATTRIBUTES rule and the filter syntax ahead of it,
nine rows for the faults inside the key, and three number texts on an N
hash key. The parser here words the filter syntax error differently from
the service, and the Scan text for the SPECIFIC_ATTRIBUTES rule has no
"1 validation error detected" prefix here, so those two tests assert the
check's place and not the text.
Measured against the service on 2026-11-05, each request sent twice, as a
raw signed Scan: a check of each stage beside each class of key fault, each
key alone, and pairs of faulty members on three tables.

- A key value that is empty, carries an unknown or lower-case type
  descriptor or carries two descriptors, at its top or inside an L or M, in
  a key member or in another member, is reported after the request
  parameter checks (ExpressionAttributeValues or ExpressionAttributeNames
  with no expression, ConditionalOperator with no ScanFilter, a ScanFilter
  condition's value count, the parameter mix) and ahead of the expression
  checks, with the prefix The provided starting key is invalid: on the
  empty-value or more-than-one-datatypes text. An unknown descriptor
  answered the deserializer's SerializationException; the other shape
  faults answered after the expression checks.
- A number that does not parse and the set faults are reported after the
  expression and request checks (the filter syntax, an undefined value, the
  operand types, the projection syntax, the Select rules, the unused
  values, the Segment pairing) and ahead of the table lookup, with no
  prefix: The parameter cannot be converted to a numeric value: bob; One or
  more parameter values were invalid: An number set  may not be empty, An
  string set  may not be empty, Binary sets should not be empty; Input
  collection contains duplicates. for an NS, also for ["1", "1.0"]; Input
  collection ["a", "a"] contains duplicates. for an SS. The set texts
  carried the key prefix, the NS duplicate text listed the members, and
  the key's number came after the table lookup and the index checks.
- A value of ExpressionAttributeValues the deserializer refuses (an empty
  value read by the filter) is reported after the key's value faults and
  ahead of the table lookup. It was reported ahead of every handler check.
- With faults in two members the service names one of them by the
  attribute names and, on some tables, the request's order: {"pk": {},
  "sk": {"N": "bob"}} answered the number on 2026-10-31 and {"a": {}, "b":
  {"N": "bob"}} the empty value. The code reports a shape fault in any
  member ahead of a value fault in any member, then the members in sorted
  order, and no test pins which member.

The handler holds back a key or an ExpressionAttributeValues value the
deserializer refuses, checks the key's shape faults between the request
parameter checks and the filter parse, and checks its numbers and sets,
then the held-back value, right before the table lookup. The Segment
pairing, the filter's placeholder and operand checks and the Select COUNT
rule for AttributesToGet move ahead of the key's value stage from after the
table lookup, as the measured rows beside a number in the key need. The
nesting check keeps its request-time place: the service answered the key's
text for a nested value the filter reads beside a key shape fault, and the
nesting text ahead of the Select enum for an unused value, which one order
cannot both give.

Not measured: a BS with duplicates, a number and a duplicate in one NS, a
set member that is not a string, an ExpressionAttributeValues value refused
for a reason other than an empty value, and the nesting check beside a key
shape fault for an unused value.

request_tests.rs has the measured rows, and
tests/python/test_scan_request_validation.py sends them as raw signed
requests: each stage beside each class of key fault, each key alone, the
pairs of faulty members (either member's fault accepted), the Segment
pairing, an undefined filter value, a missing table and an empty
expression value beside the key. The SPECIFIC_ATTRIBUTES rule and the
filter syntax tests assert only the check's place, as the texts differ
from the service's on main.
The service checks a Scan request in one of two orders. A value that is
not one AttributeValue is empty, has an unknown or lower-case type
descriptor, or has two descriptors. The fault can sit at the top or
inside an L or M. A request with such a value in ExpressionAttributeValues,
ExclusiveStartKey or a ScanFilter list follows order U. Every other
request follows order R. The same test
picks the older form of the constraint clauses. Measured against
DynamoDB (us-east-1) on 2026-10-31 and 2026-11-05, each request twice.

Order R:

- The constraint clauses in the newer form, then the ReturnConsumedCapacity
  enum, which comes ahead of the parameter mix. Then the mix, the numbers
  in ExpressionAttributeValues and their nesting depth. The nesting check
  comes ahead of the no-expression check (B4-7, 2026-10-31).
- An empty or duplicate set in ExpressionAttributeValues, ahead of the
  no-expression check.
- The request parameter checks: ExpressionAttributeNames with no
  expression, ExpressionAttributeValues with no expression,
  ConditionalOperator with no ScanFilter, a ScanFilter condition's value
  count. Then the Select enum.
- The expression checks: the filter syntax, the projection syntax, an
  undefined value, the operand types and the Select rules. Then the
  unused values, COUNT with a ProjectionExpression, COUNT with
  AttributesToGet and the Segment pairing.
- The key's number and set faults, then the table and its indexes.

Order U:

- The constraint clauses in the older form, then the ReturnConsumedCapacity
  and Select enums. Then the mix and the other request parameter checks.
- The key's faults, shape and value together: the first faulty member in
  the member order below. A shape fault carries the prefix The provided
  starting key is invalid: on the empty-value or more-than-one text. A
  number carries the value check's text.
- The ExpressionAttributeValues entries the deserializer refuses, read or
  not, with the text ExpressionAttributeValues contains invalid value: ...
  for key :e. An unknown descriptor answers the empty-value text there.
- The nesting depth, then the expression checks and the table.

The member order inside ExclusiveStartKey is fitted to 112 measured rows
on five tables. It is the order of a Java HashMap of capacity 4 over the
member names. String.hashCode() of the name is spread as h ^ (h >>> 16)
and masked to four buckets. The buckets come in ascending order, and the
request's order holds inside a bucket. sk falls in bucket 0, a in 1, b in
2, pk, gpk, id and ts in 3. The parsed body sorts object members on every
build but the MongoDB one. dispatch now passes the request text on, and
the handler reads the request's member order from it. Two value faults in two members vary
between sends on the service. The tests for those accept either answer.

The set texts follow the order. Order R quotes the members: Input
collection ["a", "a"] contains duplicates. and Input collection
["YQ==","YQ=="]of type BS contains duplicates. Order U does not: Input
collection [a, a] contains duplicates. For an NS in order U the text is
Input collection contains duplicates, with no prefix and no full stop.
An empty SS in the key carries the key prefix in order U.

Before this change the key's shape faults came at one stage and its value
faults at another in every request. The unreadable
ExpressionAttributeValues entries came after the key's value faults
whether or not an expression read them. The Select enum was a
request-time check in every request.
Row B4-7 of 2026-10-31 (an unused empty value beside a nested value)
answered the unused-value text instead of the empty value's.

Not measured, and kept in the code's order. In order R, the nesting check
against ExpressionAttributeNames with no expression, ConditionalOperator
and the ScanFilter value count. A set fault in ExpressionAttributeValues
against the nesting check. In order U, the Select enum against the mix. A
malformed ExpressionAttributeValues number against the key's faults. An
empty BS or a BS with duplicates in the key. An empty SS in the key beside
an unreadable expression value. Which entry is named when several
ExpressionAttributeValues entries are faulty: the first in sorted order.
Keys of four members follow the same member order.

The texts of #722 (the no-expression suffix), #832, #838 and #839 do not
change here.

Tests: request_tests.rs covers the order flag, the texts of each order,
the member order and its buckets, and what the handler holds back.
tests/python/test_scan_request_validation.py sends 161 rows as raw signed
JSON, among them B4-7, the R5 rows of 2026-11-05 and the member-order rows
on four tables. 41 of them fail on the previous commit.
…he service does

Measured against DynamoDB (us-east-1) on 2026-11-05 at 05:31, 06:20 and
07:38 UTC, each request twice.

- In order U a ScanFilter list value that is not one AttributeValue
  answers Supplied AttributeValue is empty, must contain exactly one of
  the supported datatypes. The text has no prefix. {} and an unknown
  descriptor answer alike. Two descriptors answer the more-than-one
  text. The step comes after the no-expression checks. It comes
  ahead of ConditionalOperator, the lists' value counts, the key's
  faults and the table. The conditions are visited in the order of the
  key's members and each list in its order. The handler holds such a
  value back from the typed deserialization, which reported it with
  the deserializer's text.
- In order U the ExpressionAttributeValues entries are visited in the
  order the key's members are visited. A malformed number, a set fault
  and a value that is not one AttributeValue are reported at the map's
  step, after the key's faults. Each takes the form
  ExpressionAttributeValues contains invalid value: <text> for key :a.
  The number text is The parameter cannot be converted to a numeric
  value: bob. It applies at the top of the entry and inside its L or
  NS. The set texts are those of order U. Inside one entry the first fault in the
  value's element order is named. A number ahead of an empty value
  answers the number.
- In order R the request-time number check visits the entries in the
  same order as the key's members. Two malformed numbers answer the one
  in the lower bucket, not the lower name. The set faults are checked
  at request time too, after the numbers and ahead of the nesting
  depth, in sorted name order on every backend. The service answered
  each of two set faults in one of its two sends.
- The visiting order of the key's members and of the map's entries is
  the iteration order of a Java HashMap. The map starts with four buckets
  and doubles when it holds more than three quarters of its size. Up to
  three names get four buckets, four to six names get eight, seven to
  twelve get sixteen. A key of four members on a GSI with a range key
  names gpk and pk ahead of gsk, which four buckets would reverse. Keys
  of five and seven members and maps of four and seven entries follow
  the same rule. Names outside ASCII hash on their UTF-16 code units,
  a supplementary-plane name on its two. Two names in one bucket keep
  the request's order. The handler reads the request's order of the key
  members, the map's entries and the ScanFilter conditions. It reads
  them from the request text.
- An empty BS in the key carries the key prefix in order U, as the empty
  SS does. It does so beside an unreadable key member or expression
  value, inside an L, and in a member that is not a key attribute. The
  empty NS does not.
- In order R the request-time number and nesting checks read the entries
  that deserialize one by one. A set fault in one entry no longer skips
  the numbers of the others. A malformed number inside an entry the
  deserializer refuses is reported after the nesting depth of another
  entry.

Left to PR #507 (#564 item 3): a malformed number in a ScanFilter list.
The service answers The parameter cannot be converted to a numeric
value: bob, ahead of a number in the key (2026-11-05). This commit does
not check the lists' numbers. The ComparisonOperator enum clause the
service answers in order U is a legacy-condition check too and is not
made here.

Not measured, and kept in the code's order: an empty value ahead of a
malformed number inside one ExpressionAttributeValues entry. The code
names the empty value, the element order. A set fault in a ScanFilter
list value in order U. A set fault beside a malformed number in another entry of
ExpressionAttributeValues in order R, where the service's two sends
disagreed. Nine or more names in one hash bucket, where Java's HashMap
doubles a table under 64 buckets early and the code does not.

Tests: request_tests.rs covers the ScanFilter step of order U and the
map's order and invalid-value form in order U. It covers the two orders
of the request-time number and set checks and the element order inside
one entry. It covers the table's doubling and the request order read from
the text. The wire file sends 69 of the 73 rows of 2026-11-05, two of
them with the nested value written out. It has 230 tests. The two
ScanFilter number rows are marked xfail for #507. Three rows accept
either of two service answers. Thirty-nine of the others fail on the
previous commit.

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