Repository navigation
fix(scan): check AttributesToGet, ScanFilter, ExclusiveStartKey and ExpressionAttributeValues in the service's order - #854
Open
LeeroyHannigan wants to merge 6 commits into
Conversation
…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.
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn,
robinnsc and
yesyayen
as code owners
November 5, 2026 08:25
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 aScanFilterlist, 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 changehandle_scanincrates/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
LorM. The places read are theExpressionAttributeValuesentries, theExclusiveStartKeyvalues and theScanFilterlists. 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.rscomputes it once per request (CheckOrder), and its module doc lists both orders.Order R:
AttributesToGetlength and theLimit,SegmentandTotalSegmentsranges, in one message, the length clause first. Then theReturnConsumedCapacityenumScanFiltercounts as present whether or not it is emptyExpressionAttributeValues, the entries in the member order below ({":n": {"N": "fred"}, ":a": {"N": "bob"}}answersfred, 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 theScanFilterlists are fix(query): check legacy conditions against the key schema #507's checkExpressionAttributeValues, 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 ofExpressionAttributeValues, withMlevels counted asLlevelsExpressionAttributeNameswith no expression,ExpressionAttributeValueswith no expression,ConditionalOperatorwith noScanFilter, aScanFiltercondition's value countSelectenumSelectrules, the unused values,COUNTwith aProjectionExpression,COUNTwithAttributesToGet, theSegmentpairingOrder U:
ReturnConsumedCapacityenum, then theSelectenumExpressionAttributeNamesandExpressionAttributeValueswith no expression, then theScanFilterlist values. A list value that is not one AttributeValue answersSupplied 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. ThenConditionalOperatorwith noScanFilterand aScanFiltercondition's value countThe provided starting key is invalid:. A number carries the value check's text. A set fault carries the texts of order U (below)ExpressionAttributeValuesentries, in the member order below, the first faulty one named, each fault in the formExpressionAttributeValues 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 itsLorNS. Its text isThe 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 orderThe handler in
crates/engine/src/scan.rsholds back the values each order reports late, so that the typed deserialization does not report them first. They are the key (StartKeyBody), the faultyExpressionAttributeValuesentries (ExpressionValuesBody), in order U the faultyScanFilterlist values (ScanFilterBody), and in order R an invalidSelect(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 ofExpressionAttributeValuesin order U, is the iteration order of a JavaHashMapover the names. It takesString.hashCode()of each name, spreads it ash ^ (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 bucketsskfalls in bucket 0,ain 1,bin 2,pk,gpk,idandtsin 3. With eight,gpkandpkfall in 3 andgskin 6. Names outside ASCII hash on their UTF-16 code units, a name outside the Basic Multilingual Plane (U+1D11E) on its two. Java'sHashMapalso 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 atfitted_member_orderstates the rows. The parsed body sorts object members on every build but the MongoDB one.dispatchnow passes the request text to the handler. The handler reads the request's order of the key members, the map's entries and theScanFilterconditions from it.The set texts follow the order. Order R quotes the members as JSON:
Input collection ["a", "a"] contains duplicates.andInput collection ["YQ==","YQ=="]of type BS contains duplicates.. Order U does not:Input collection [a, a] contains duplicates.. For anNSin order U the text isInput collection contains duplicates, with no prefix and no full stop. An emptySSand an emptyBSin the key carry the key prefix in order U. An emptyNSdoes 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
ScanFilterlist checks and the entry order and invalid-value form ofExpressionAttributeValuesin order U. It adds the table's doubling for four members and theBSprefix. Those were measured on 2026-11-05 at 05:31 UTC. The comment at the top ofhandle_scanlists the stages.One row of item 5 stays open. From 63
Llevels the JSON parser refuses the body (128 JSON levels, two perLlevel) before any check reads it. ExtendDB answersSerializationExceptionwhere 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.
ScanFiltercounts as present for the parameter mix.ScanFilter: {}with aFilterExpressionanswersCan not use both expression and non-expression parameters in the same request: Non-expression parameters: {ScanFilter} Expression parameters: {FilterExpression}. The service acceptsScanFilter: {}alone.AttributesToGet: []answers1 validation error detected: Value at 'AttributesToGet' failed to satisfy constraint: Member must have length greater than or equal to 1: alone, with aFilterExpression, with aFilterExpressionand a malformed number, and withScanFilter: {}and aFilterExpression. The length constraint joins the range constraints.Limit: 0withAttributesToGet: []answers2 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.NinExclusiveStartKeyanswersThe 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 anNSmember, anLmember, and a number in theLof an attribute that is not a key attribute. On anNhash key," 1"and""answer the same text with the value echoed. 39 digits answerAttempting to store more than 38 significant digits in a Number.{"pk": {"N": "bob"}}alone, with nosk, answersbobahead of the key schema check (2026-11-05).Value at 'Limit' ...), in order U the older form (Value '0' at 'limit' ...).{"pk": {}, "sk": {"S": "x"}}withSelect: "BAD"answers theSelectclause. A value nested 32 levels withSelect: "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.ReturnConsumedCapacity: "BAD"beside the parameter mix and besideSelect: "BAD"answers its own clause.ExpressionAttributeNameswith no expression answers its text ahead ofExpressionAttributeValueswith no expression and ofConditionalOperator.ConditionalOperatoranswers its text ahead of aScanFiltercondition with no value.{":e": {"NS": []}}with no expression answersOne or more parameter values were invalid: An number set may not be emptyahead of the no-expression check.ExpressionAttributeValueswith no expression andSelect: "BAD"answers the no-expression text. The mix besideSelect: "BAD"answers the mix text.ExpressionAttributeValuesvalue nested 32 to 62Llevels deep answersNesting 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 theSelectenum (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 emptyNSin 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 anNor anSleaf.{"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. AnSSwith 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 anMat 32). From 63 levels (63, 70, 80, 99 and 100 measured) the text isExpressionAttributeValues contains invalid value: Nesting Levels have exceeded supported limits: Attributes in the item have nested levels beyond supported limit for key :d.SPECIFIC_ATTRIBUTES,ALL_PROJECTED_ATTRIBUTESand theSegmentpairing. An undefined value ahead of the unused values, theSegmentpairing andSPECIFIC_ATTRIBUTES. An operand type ahead of the unused values and theSegmentpairing. The unused values ahead ofCOUNTwith aProjectionExpressionand of theSegmentpairing.COUNTwithAttributesToGetahead of theSegmentpairing.{"pk": {"N": "bob"}, "sk": {"S": "x"}}, each expression check answers its own text. A missing index,ConsistentReadon a GSI and a missing table answer the number text. The set texts areOne or more parameter values were invalid: An number set may not be empty,... An string set may not be emptyand... Binary sets should not be empty. A set with duplicates answers... Input collection contains duplicates.for anNS(["1", "1"]and["1", "1.0"]alike),... Input collection ["a", "a"] contains duplicates.for anSS(["a\"b", "a\"b"]for members with a quote) and... Input collection ["YQ==","YQ=="]of type BS contains duplicates.for aBS. In anNS,["bob", "1", "1"]and["1", "1", "bob"]answerbob. A number inExpressionAttributeValuesis reported ahead of a number in the key. A malformed number in aScanFilterlist answersThe 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 emptyNSin another entry, a malformed number inExpressionAttributeValuesanswered the empty-set text in one send and the number text in the other. An emptyNSanswers its text ahead ofExpressionAttributeNameswith no expression and ahead ofSelect: "BAD", and afterReturnConsumedCapacity: "BAD".ConditionalOperatorand aScanFiltercondition with no value answer their texts ahead ofSelect: "BAD".{":n": {"N": "fred"}, ":a": {"N": "bob"}}answersfred, the entry first in the member order below. Sorted order would givebob.{":n": {"NS": []}, ":a": {"SS": []}}answeredOne or more parameter values were invalid: An string set may not be emptyin one send and... An number set may not be emptyin the other. An emptyNSbeside the parameter mix answers the mix text.The provided starting key is invalid: Supplied AttributeValue is empty, must contain exactly one of the supported datatypes. A key value with two descriptors answersThe 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 anLorM, 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 areExpressionAttributeValueswith no expression,ExpressionAttributeNameswith no expression,ConditionalOperatorwith noScanFilter, aScanFiltercondition 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 threeSelectrules and the unused value. TheSegmentpairing does too, with each of its three faults. The three table checks (a missing index,ConsistentReadon a GSI and a missing table) do too. A nested value the filter reads does too, with anSor anNleaf. So does a nested value nothing reads.ExpressionAttributeValuesentries (2026-10-31 B4-7 and 2026-11-05). An empty entry the filter reads answersExpressionAttributeValues contains invalid value: Supplied AttributeValue is empty, must contain exactly one of the supported datatypes for key :awith 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. BesideSelect: "BAD"orReturnConsumedCapacity: "BAD"the enum clause comes first, and beside the parameter mix andSelect: "BAD"theSelectclause does. Beside a key fault the key's text comes first. A malformed number in the map answersExpressionAttributeValues 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:nnames:n.{":p": {"S": "x"}, ":b": {}, ":a": {"X": "1"}}names:b. An empty value at the bottom of 32Llevels 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": {}}answersExpressionAttributeValues contains invalid value: One or more parameter values were invalid: An number set may not be empty for key :s, and anSSwith duplicates there... Input collection [a, a] contains duplicates. for key :s. A malformed number inside anLor anNSanswers 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::aand:eshare 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.ScanFilterlist values (2026-11-05 at 05:31 UTC).[{}]and[{"X": "1"}]answerSupplied AttributeValue is empty, must contain exactly one of the supported datatypes, with no prefix.ExpressionAttributeNameswith no expression answers its text ahead of the list value. The list value answers its text ahead ofConditionalOperator, 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 answerSupplied AttributeValue has more than one datatypes set, must contain exactly one of the supported datatypes, and an empty value inside anLthe empty-value text (2026-11-05 at 07:38 UTC). With two conditions the one first in the member order below is named:pkahead ofsk, andtsahead ofidwhentscomes first. In one list the first faulty value is named.ExpressionAttributeValueswith no expression answers its text ahead of the list value. An invalidComparisonOperatoranswers its constraint clause ahead of the list value, a legacy-condition check this branch does not make (below). In order R an emptyNSin a list answersOne or more parameter values were invalid: An number set may not be empty, the deserializer's text here.ExpressionAttributeValuesentry the filter reads, a keyNSwith duplicates answersInput collection contains duplicatesand a keySSwith duplicatesOne or more parameter values were invalid: Input collection [a, a] contains duplicates.. An empty keyNSanswersOne or more parameter values were invalid: An number set may not be emptythere and beside an unreadable key member. An empty keySSbeside an unreadable key member answersThe provided starting key is invalid: One or more parameter values were invalid: An string set may not be empty. Beside an emptyExpressionAttributeValuesentry the filter reads it answers the same. An empty keyBSbeside an unreadable key member answersThe provided starting key is invalid: One or more parameter values were invalid: Binary sets should not be empty. A keyBSwith duplicates there answersOne or more parameter values were invalid: Input collection [YQ==, YQ==]of type BS contains duplicates.. The empty keyBSkeeps the prefix beside an emptyExpressionAttributeValuesentry the filter reads (2026-11-05 at 07:38 UTC). It keeps it inside anLand in a member that is not a key attribute.pk,sktable the service namesskin both request orders. On HASHa, RANGEbit namesa. On HASHpk, RANGEait namesain both request orders. Onts,idand onid,tsit 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"}}namesgpk. On a GSI with a range key the key has four members. There{"gpk": {}, "gsk": {"N": "bob"}, "pk": ..., "sk": ...}namesgpkin both request orders, and{"gpk": ..., "gsk": {}, "pk": {"N": "bob"}, "sk": ...}namespk. 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"}}answeredbobon 2026-10-31 andfredon 2026-11-04. The tests for those rows accept either member's fault.main (
25ee109bf, a SQLite server) acceptsScanFilter: {}with aFilterExpression. It scans and compares for{"N": "bob"}in aScanFilterlist (200, no item), as every commit of this branch does. The check is #507's. It answersInvalid ProjectionExpression: The expression can not be empty;forAttributesToGet: []alone, with aFilterExpressionand withLimit: 0. The #562 base answers theLimitclause alone for that last request, in the newer form. For a malformedExclusiveStartKeynumber on anSkey, main answers the key schema text (The provided starting key is invalid: The provided key element does not match the schema). On anNsort key it answers500 InternalServerError, and on anNhash key it scans and returns no items. For" 1",""and 39 digits on anNhash 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 aSerializationException. 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 answersSerializationException: Start of structure or map found where not expected: recursion limit exceeded.Known differences (main differs from the service on each of them too):
ScanFilterlist. The service answersThe parameter cannot be converted to a numeric value: bob, ahead of a number in the key (measured on 5 November). ExtendDB scans and compares. The check is Scan request validation gaps: empty ScanFilter in the parameter mix, empty AttributesToGet, legacy and ExclusiveStartKey numbers, ExpressionAttributeValues nesting depth #564 item 3, which fix(query): check legacy conditions against the key schema #507 makesComparisonOperatorin aScanFiltercondition beside an unreadable list value. The service answers1 validation error detected: Value 'BAD' at 'scanFilter.a.member.comparisonOperator' failed to satisfy constraint: Member must satisfy enum value set: [IN, NULL, BETWEEN, LT, NOT_CONTAINS, EQ, GT, NOT_NULL, NE, LE, BEGINS_WITH, GE, CONTAINS](measured on 5 November). ExtendDB answers the list value's text. TheComparisonOperatorchecks are legacy-condition checks, which fix(query): check legacy conditions against the key schema #507 makes with the textInvalid comparison operator. The clause form of order U is in neither1 validation error detected:prefix on the threeSelectrules (Scan Select rules and the Query ALL_PROJECTED_ATTRIBUTES rule answer without the "1 validation error detected: " prefix #832): Segment: 2 is not less than TotalSegments: 2on theSegmenttext (Scan: the Segment, ScanFilter value count and COUNT with AttributesToGet checks answer texts that differ from the service #838)Invalid number of attribute values for comparisonfor aScanFiltercondition with no value, where the service answersOne or more parameter values were invalid: Invalid number of argument(s) for the EQ ComparisonOperator(Scan: the Segment, ScanFilter value count and COUNT with AttributesToGet checks answer texts that differ from the service #838)Cannot specify the ProjectionExpression when Select is COUNTforAttributesToGetwithSelect: COUNT, where the service answers1 validation error detected: Cannot specify the AttributesToGet when choosing to get only the Count(Scan: the Segment, ScanFilter value count and COUNT with AttributesToGet checks answer texts that differ from the service #838)Invalid FilterExpression: expected attribute path or value placeholderfora = = :pandInvalid ProjectionExpression: expected attribute namefora,,b, where the service answersSyntax error; token: "=", near: "= = :p"andSyntax error; token: ",", near: ",,b"(FilterExpression and ProjectionExpression syntax errors answer the parser's text, not the service's token and position #839): FilterExpression is nullonExpressionAttributeValues can only be specified when using expressions, which the service answers in order U and drops in order R (Scan: ExpressionAttributeValues without an expression answers with a ": FilterExpression is null" suffix the service does not send #722)SerializationExceptiontexts for a field of the wrong JSON type. The service answersSTRING_VALUE cannot be converted to Integerfor"Limit": "5",NUMBER_VALUE cannot be converted to Stringfor{"N": 5}in the key and for{"SS": ["a", 1]}, andUnexpected field typefor"ExclusiveStartKey": "x". It answersBase64 encoded length is expected a multiple of 4 bytes but found: 3for{"B": "!!!"}. It answersDuplicate key found in payloadfor a member name written twice inExclusiveStartKeyor an entry name written twice inExpressionAttributeValues(A repeated type descriptor in an attribute value is accepted last-wins where the service refuses it #630). The parser here keeps the last value. It answersStart of structure or map found where not expectedfor an object whereExpressionAttributeNamesneeds a string. The deserializer's texts are unchanged here"ConsistentRead": "yes"beside a key shape fault: the service answers the key's text, ExtendDB reads the whole body first and answers theSerializationExceptionExclusiveStartKeytexts.{"pk": {}, ...}and{"pk": {"X": "1"}, ...}answer the prefixed empty-value text on the service. Query answers the bare text and theSerializationExceptionhere, as beforeTesting 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 withboband expects the check's text, and theSelectenum beside an unreadable key. One holds the combinations measured on 2026-10-31. One sends the two-member rows of the five tables throughStartKeyBodywith the request's member order. One sends theExpressionAttributeValuesrows of order U throughExpressionValuesBodywith the request's entry order. One sends theScanFilterlist 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 throughExpressionValuesBody. 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 whatStartKeyBodyandSelectBodyhold back. The rows with two value faults in two members accept either member's fault.tests/python/test_scan_request_validation.pysends 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 besideSelect: "BAD"accepts this branch's text and #507's. The twoScanFilternumber rows are markedxfailfor #507 and are not counted below.Where the rows fail, on SQLite servers of the base commits:
ReturnConsumedCapacitybeside the mix. TheSelectenum after the no-expression check. An undefined value and an operand type ahead of the unused values and theSegmentpairing.COUNTwith aProjectionExpressionafter 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{}ingpk. The more are the key names outside ASCII and outside the Basic Multilingual Plane. The keyBSwith duplicates and the empty keySSbeside an empty entry. The emptyNSbeside a number, besideExpressionAttributeNames, besideSelect: "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.ConditionalOperatorand a condition with no value besideSelect: "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.ExpressionAttributeValuesnumber in order U, alone with:aand:n, beside a key shape fault, inside anLand anNS. 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 aScanFilterlist,ExpressionAttributeNamesandExpressionAttributeValueswith no expression beside an empty list value, and which of two conditions is named. An emptyBSbeside an unreadable key member, beside an unreadable expression value, inside anLand 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.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
BSwithout the prefix fails 4 wire tests and 1 unit test. TheScanFilterlist step afterConditionalOperatorfails 2 wire tests. TheScanFilterlist values left to the deserializer fails 5 wire tests and 1 unit test. TheExpressionAttributeValuesentries ordered from the parsed body instead of the request text fails 1 wire test. TheExpressionAttributeValuesentries 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 theSelectenum 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, andcargo +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 intests/python/test_scan_range_constraints.py,test_scan_request_validation.py,test_scan_edge_cases.pyandtest_query_scan.py, and 185 intests/test_query_scan.pyandtests/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
+1key on anNtable, six Query rows, and aScanFilterlist nested 32 levels. 12 are rows of the last four known differences and of the twoSerializationExceptiontexts named there. 2 are theScanFilternumber rows, #507's check. On PostgreSQL the same rows differ, and one more.Segment: -0.5of 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
AttributesToGetlength clause beside aSegmentorTotalSegmentsfault alone. Its older form (Value '[]' at 'attributesToGet' ..., listed last). The length against the enums.Mlevels in the nesting count.ScanFilter: {}andAttributesToGet: []with aProjectionExpression. In order R, the nesting check againstExpressionAttributeNameswith no expression,ConditionalOperatorand theScanFiltervalue count. The projection syntax against an undefined value, an operand type and the unused values. A set fault inExpressionAttributeValuesagainst a malformed number in another entry. The number is checked first here. The service's two sends disagreed. Which of two set faults inExpressionAttributeValuesis reported (the first in sorted name order). The service answered each in one of its two sends. A set fault in aScanFilterlist value in order U (at the list's step). In order R the deserializer's text is measured. In order U, theSelectenum against the mix (the enum first). The mix besideSelect: "BAD"and an empty entry answers theSelectclause. An empty value ahead of a malformed number inside oneExpressionAttributeValuesentry (the empty value, the element order). The number ahead of the empty value is measured. A set fault inExpressionAttributeValuesagainst the key's faults (the key first, the set fault with the map's entries). ABSwith 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'sHashMapdoubles a table under 64 buckets early and the code does not. Two value faults inside oneMorNS(the map's and the request's order). Query does not change here. It has no nesting check, and itsAttributesToGetandQueryFilterforms were not measured. 63 to 100 levels: the open row above.Checklist
cargo test --workspace)cargo fmt --check)cargo clippy -- -W clippy::pedantic)Storagetrait, auth model, on-diskformat, 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.