Skip to content

fix: PartiQL read correctness, the two missing batch member fields, and the measurement-gated perf pair - #190

Merged
hicksy merged 24 commits into
mainfrom
fix/partiql-read-correctness
Aug 18, 2026
Merged

fix: PartiQL read correctness, the two missing batch member fields, and the measurement-gated perf pair#190
hicksy merged 24 commits into
mainfrom
fix/partiql-read-correctness

Conversation

@hicksy

@hicksy hicksy commented Aug 17, 2026

Copy link
Copy Markdown
Member

What this changes

SELECT * FROM "table"."index" threw the index away and scanned the base table, so it returned rows the index does not hold. Fixing that turned up a run of problems in the same code: predicates that never matched on sets, lists, maps, binary or null; a WHERE clause that could not parse the grouping and NOT the docs already promised; two batch member fields dropped on the floor; and reads charged on what they returned rather than what they read.

A statement could also take the process down, three ways. Around 250 nested parentheses overflowed the stack, and the release profile aborts on panic. Flattening a wide clause was unbounded in two more: 32KB of text burned about half a minute of CPU, and 925KB peaked at gigabytes before the cap rejected it. All three are bounded now.

Every figure here is captured against eu-west-2.

Two performance items land with it, both measured before they were taken. Index writes are sized only when someone asked for the figure, which halves the projections an overwrite does against a two-index table. And a batch resolves each table once instead of twice per statement, taking a 25-statement batch from 51 metadata loads to one. That second one matters most on wasm, where every load crosses the bridge to a JS worker.

Breaking changes

Eight, all in the changelog. Two behavioural: a batch or transaction may no longer mix reads and writes or name one item twice, and the PartiQL surfaces now reject a ReturnConsumedCapacity outside the enum. Six are Rust API only, around the parser's public types, WhereClause, WhereCondition and execute_page. The wire API is untouched, so this is a minor bump.

Closes #179, #186, #183, #184, #185, #182.

Checklist

  • Tests added or updated

  • cargo fmt --check and cargo clippy -- -D warnings pass locally

  • CHANGELOG.md updated if this is a user-visible change

  • Linked issue, discussion, or a short note explaining the motivation

  • I agree my contribution is licensed under the project's terms (MIT License and Apache License, Version 2.0)

  • DynamoDB compatibility note - nothing here introduces a divergence; every change moves towards DynamoDB.

hicksy added 21 commits August 15, 2026 16:11
`"table"."index"` tokenises as three tokens and the table name parser took
one, leaving the `.` and the index name for the next clause to parse against.
A SELECT lost its WHERE, an UPDATE its SET, and a DELETE reported requiring a
WHERE clause it plainly had.

The qualifier is now parsed and carried. UPDATE and DELETE reject it during
execution and INSERT at parse, reporting the table name's position the way
DynamoDB does. Every Statement variant is now non_exhaustive, so a caller
matching one exhaustively needs a `..`.
A qualified SELECT scanned the base table, so it returned items the index
does not contain and charged the read to the wrong arm. It now resolves the
index, reads through it, and reports capacity against that index with the
table arm at zero, the same shape Query and Scan already produce.

An unknown index name is rejected, without the index name in the message:
Query and Scan append it and this surface does not. A strongly consistent
read of a GSI is rejected too, again with its own wording. The continuation
token carries the base table key, so rows sharing an index key are no longer
skipped, and it is bound to the index that minted it.

Measured against eu-west-2 on 2026-08-15.
…rapper

DynamoDB wraps a malformed statement in "Statement wasn't well formed, can't
be processed: " and reports a handful of rejections on its own terms. Every
parse failure went through the wrapper here, so a three-part name, an empty
path component and a qualifier on an INSERT all came back double-enveloped.

The parser now says which envelope its rejection takes, rather than leaving
each of the three call sites to guess from the message text.
A GSI rejects a projection naming an attribute it does not project, and an
LSI rejects a filter on one. The two sides really are asymmetric on AWS: a
GSI filter on an unprojected attribute matches nothing rather than failing,
because the attribute is absent from every entry.

An LSI serves a projection naming an unprojected attribute by reading the
base table. That reach-back is not implemented here, so such a projection
still comes back empty, and it is the one case in this area still diverging.
The rejection was applied to LSIs and not GSIs, which fitted the two cases
measured at the time and was the wrong reading of them. Both kinds reject a
filter on an attribute the index does not carry, and only when the read is
keyed on the index partition key; an unkeyed read is a scan and matches
nothing instead.

Four further cases settle it. An unkeyed LSI filter is accepted, a keyed GSI
filter is rejected, and an unkeyed filter with two conditions is accepted,
which rules out the index kind and the condition count in turn. The message
says "Secondary index" on both kinds.

The projection rule is a genuine GSI/LSI split and is unchanged.
PartiQL had its own comparison handling strings, numbers and booleans, with a
catch-all answering every other type false for = and true for <>. So a
predicate on a set, list, map, binary or null never matched whatever the
values were, and since the same code gates UPDATE and DELETE, a write
conditioned on one of those types could never fire.

The condition-expression engine already compares all of them, including sets
without regard to order. A capture found the two surfaces agree on every type
on real DynamoDB, so PartiQL now shares that implementation rather than
growing a second copy of it, and the two operator enums become one.
Four changes, all from review of the preceding commits.

An unterminated quoted name panicked the parser: `SELECT * FROM "` sliced a
one-character string from index 1, and the release profile aborts on panic, so
one malformed statement took the process down. The tokeniser now rejects it as
the syntax error it is. That fault predates the index qualifier, which added a
second way to reach it.

A continuation token stripped of its base table key but carrying a valid
fingerprint ended an index walk after one row rather than being rejected. The
two halves are now checked separately.

Token positions are computed when the one message that reports them fires,
rather than building a line and column for every character of every statement
to serve a case that almost never happens.

The projection rule lived twice, once in `build_index_item` and once in the
PartiQL read path. It now lives in `IndexDef` and both call it, which is the
same fault the predicate work was fixing a few commits earlier. `ParseError`
derives `Error` like `DynoxideError` rather than only `Display`.
The changelog's Notes section still said a SELECT against an index scans the
base table and drops its WHERE clause, and the compatibility summary still
called the index arm a known read-side gap. Both were true when written and
are the opposite of what the code now does.

The remaining read-side gap is restated as what it actually is: a PartiQL read
with no key condition is charged on the rows it returns where DynamoDB charges
a flat figure for the scan, and that applies to a base table read as much as an
index one.
A second capture round, prompted by review, settled five behaviours the first
one had not measured. Three of them the code already had right; two it did not.

An IN on the index partition key counts as keyed, so an unprojected filter
beside it is rejected. The read still scans, because an IN cannot be pushed
down as a single key, but the rejection follows the shape of the key condition
rather than what the read does with it. An index key reached through OR does
not count, and AWS accepts an unprojected filter there.

An index-qualified SELECT inside a transaction is rejected outright rather than
served and charged to the wrong arm.

Confirmed unchanged: the empty-component rejection belongs on the table half as
well as the index half; the unprojected-filter rejection covers IS MISSING and
BEGINS_WITH, not just equality; and ordering on binary compares the bytes.

Two divergences are now pinned by tests rather than left implicit. Ordering
between mismatched types answers false where DynamoDB rejects the statement,
and a parenthesised WHERE clause is a parse error here and valid on DynamoDB.
Both predate this work.
Both were listed as supported and neither was. The clause parser was a flat
OR of ANDs with no notion of parentheses, so even `WHERE (a='1')` was a parse
error, and `NOT` was recognised only as part of `NOT EXISTS` and
`NOT BEGINS_WITH`. DynamoDB parses all of it.

The clause is now read into a tree, `NOT` is driven down to the leaves by De
Morgan, and the result is flattened back into the OR of ANDs the executor and
the key pushdown already read, so nothing below the parser changes. `NOT` over
`BETWEEN` becomes a disjunction and over `IN` a conjunction, neither of which
fits a single condition, and `NOT CONTAINS` gains the variant it needed.
Flattening a clause distributes AND over OR, so an alternation nested deeply
enough is rejected as too complex rather than expanded.

Also rejects an ordering comparison whose operand has no ordering. DynamoDB
orders S, N and B, rejects `<`, `<=`, `>`, `>=` and `BETWEEN` against anything
else, and does it before resolving the table. dynoxide answered no rows. An
earlier note called this a type mismatch; it is not, and `S < N` is accepted on
both sides.

Captured eu-west-2 2026-08-15.
Three things found while working in this code and previously left standing.

A batch SELECT must name a single item. One that does not resolve to a primary
key, or that names an index, is now rejected against itself while the rest of
the batch runs. Both shapes carry the same message on DynamoDB, so an
index-qualified batch read is unreachable even when it does name the key.

An LSI serves a projection naming an attribute it does not carry by reading the
base item, which a GSI cannot do and rejects instead. dynoxide returned rows of
empty objects. The base reads land on the table arm at read granularity apiece,
leaving the index arm to cover the index read: three rows served this way report
total 2, table 1.5, lsi 0.5.

A batch member whose statement ran and failed echoes its table, where one
rejected before it ran does not. ConditionalCheckFailed and DuplicateItem carry
it; a ValidationError does not, which is what an invalid RETURNING variant is.

Captured eu-west-2 2026-08-15. The batch round now matches on 13 of 15, with
both remaining cases waiting on a member's ConsistentRead.
…ilure

BatchStatementRequest carried Statement and Parameters alone, so a member
setting either of DynamoDB's other two fields was parsed as though it had not.

ConsistentRead is per member and sets the rate that member's read is charged
at. A keyed batch SELECT costs 0.5 without it and 1 with it, and a batch mixing
the two sums both rates rather than taking one mode. It does not change which
rows come back, because every read against SQLite is already consistent.

ReturnValuesOnConditionCheckFailure is accepted and inert, which is what
DynamoDB does with it: a member whose condition fails returns the same response
whatever the option says and never the item. The same option on a
TransactWriteItems ConditionCheck does return it, which is what rules out a bad
measurement rather than a real inertness. Deserialising it means a client
setting it meets a field dynoxide knows rather than one it drops.

The batch round now matches on all fifteen cases.
The fan-out builds the projected entry to store it, and the capacity
calculation built the same projection again to measure it. On a table with two
indexes an overwrite made six projections where four would do, and an insert
four where two would do, whether or not anyone asked for the figures.

The fan-out now builds it once and hands it on. Measured with iai-callgrind,
which is deterministic where the wall-clock suite is not: an overwrite against
two indexes drops 2.6%, an insert against one drops 3.7%, both on instruction
counts.

Adds the benchmark that shows it. The suite could not: it only inserted fresh
keys against a single-index table, which is the one shape with no old image to
rebuild and half as much of it. Adds counters behind a feature for the
questions in this area that are about how many times something happens rather
than how long it takes; they compile to nothing when the feature is off.
…urned

DynamoDB sizes a read before the WHERE clause and before the projection, so
a SELECT matching one row costs what a SELECT matching every row costs, and
asking for one attribute costs what asking for all of them costs. Scan and
Query already did this; the PartiQL executor summed the rows it was about to
hand back, which under-reported any filtered or projected read. The window
now carries the bytes it read alongside the rows that matched.

Confirmed against eu-west-2: the filter cases agree on ten fixtures and the
projection cases on six. Scan's own behaviour was correct and had no test
holding it there, so this covers both.
The counters are process-wide and the harness runs tests in parallel, so one
test's fixture setup landed inside another's measurement. An insert into a
two-index table read as 8 entries built against a true figure of 2, which puts
an insert above an overwrite and inverts the answer. Each test now holds a lock
for its whole body, setup included, because building a fixture table reads
metadata too.

Three parallel runs now agree with each other and with a single-threaded run.
The index fan-out called index_write_units for every index on every write, and
sizing needs the old image projected on top of the new one the write already
built. ReturnConsumedCapacity defaults to NONE, where the response builders
throw those maps away, so the default write paid for a figure nobody could see.

The maintenance helpers now take the caller's ReturnConsumedCapacity and skip
the sizing when it asks for nothing. They take the mode itself rather than a
boolean, so a call site is right by forwarding a field it already holds. TTL
sweeps pass None, having no caller to report to.

On an overwrite against a table with two indexes, entries built goes from four
to two, which is what an insert costs. Asking for INDEXES still builds four,
so nothing is lost where the figures are wanted.

The gate sits in the same per-index loop as the write operations, so a version
placed one line out would stop maintaining indexes rather than stop measuring
them, and no capacity test would catch it because they all run in a mode that
does the work. The new tests read the indexes back through a query instead;
four of the six fail against that mistake.
…tement

The duplicate-target check loaded a table's metadata and parsed its key schema
to build a (table, pk, sk) tuple, then threw both away; the executor resolved
the same table again a moment later, and sizing a failed member resolved it a
third time. A 25-statement batch performed 50 metadata loads, or 75 when the
members failed.

The preparation pass now keeps what it resolved and hands it to the executor.
It keys that by table rather than by statement, because the metadata and the
key schema are per table and only the item key is per statement, so a batch
against one table resolves it once however many statements it carries. The
failure path reuses the target the preparation pass already holds. The same
change applies to ExecuteTransaction, which had the identical shape.

A 25-statement batch goes from 51 metadata loads and 51 key schema parses to
one of each. That matters most on the wasm backend, where every load crosses
the bridge to a JS worker and nothing caches the result, but the key schema is
a JSON parse and was paid on both backends.

Keying by table is the part that could go quietly wrong: a lookup returning
another table's entry would run a statement against the wrong key schema, and
the existing multi-table coverage would not catch it because those tables are
keyed alike. The new tests mix a hash-only table with a composite one.
A statement nested deeper than the parser can walk aborted the process. Around
250 nested parentheses, or a few thousand leading NOTs, overflowed the stack,
and the release profile aborts on panic, so one statement of about a kilobyte
took the host down. Nested list and map literals reached the same end by a
second route that predates this branch. Both descents now carry a depth budget
and reject past it, the way an over-complex clause was already rejected.

A negated comparison dropped rows the attribute is missing from. NOT a='x'
answered on rows holding an a and skipped the rest, because negation flipped
the operator and <> is false on a missing path, while NOT CONTAINS wrapped
instead and kept them. The two disagreed with each other on the same data.
Negation now wraps in every form, so a row the comparison cannot answer for
is a row the negation matches.

A batch SELECT naming a table that does not exist reported that it must
specify a primary key in the where clause, which it had. The check that reads
a member's target cannot tell a missing key from a missing table, and only the
reads went through it, so the same batch reported the missing table correctly
for a write and misleadingly for a read.

An LSI read that reaches back to the base table is charged on the bytes each
base read moved rather than a flat half unit apiece. The flat figure came from
one capture of three small rows and holds up to 4KB. Captured again in
eu-west-2 on 17 August across item sizes: the same three rows at 9KB apiece
cost 4.5 rather than 1.5, and 9 under ConsistentRead.

Also drops statement_target and the prepared-statement type it fed, which lost
their last caller when the batch pass moved to resolving per table.
…s in CI

The compatibility summary gained NOT, parenthesised grouping and the index
qualifier when they landed; the MCP tool descriptions did not, and that schema
is what an agent reads at the moment it writes a statement. They now say the
same thing, along with the two batch member fields. ExecutePartiql also gains
ConsistentRead, which was harmless to omit until it started deciding both the
rate a read is charged at and whether a GSI-qualified select is rejected.

The counter harness had no CI leg, so the evidence for the two performance
changes on this branch would have rotted without anything noticing. Two counter
tests printed their figures and asserted nothing, and two index tests asserted
only that a row was absent, which a write that never populated the index also
satisfies.
…rstand

GetItem, PutItem, UpdateItem, DeleteItem, Query and Scan each check the value
against the enum during their own deserialisation. The three PartiQL surfaces
derive their requests, so they had nowhere to put the check and took anything.
A typo read as NONE and reported nothing, which was easy to miss when that was
all it decided. It now also decides whether a write sizes its indexes, so the
same typo quietly skips that too. The check moves into one place rather than
being spelled out a seventh time.

Also records on ResolvedTable that a batch resolves a table before any of its
statements run, so require_table no longer runs per statement and a table
altered part way through a batch is served from the snapshot taken at the
start of it.
@github-actions

Copy link
Copy Markdown
Contributor

Criterion Benchmark Results

Baseline is the per-benchmark median of the last 5 stored runs, so one unusually fast or slow runner cannot skew the comparison. The range column is the spread across those runs.

Benchmark Baseline (ns/iter) Range Current Change
batch_execute_statement_25 762,143 new
batch_get_item_100 1,389,601 814,524 - 1,451,464 1,469,006 +5.7%
batch_write_item_25 1,119,577 533,898 - 1,182,554 1,118,570 -0.1%
delete_item 51,384 26,334 - 53,068 53,618 +4.3%
get_item 14,455 8,000 - 15,127 15,200 +5.2%
put_item/put_item/large 269,725 115,520 - 280,567 267,247 -0.9%
put_item/put_item/medium 44,503 19,445 - 46,845 45,414 +2.0%
put_item/put_item/small 25,143 10,759 - 26,885 26,310 +4.6%
query_base_table 1,093,125 666,833 - 1,164,732 1,112,147 +1.7%
query_gsi 25,592 12,359 - 26,517 26,678 +4.2%
scan_with_filter 8,217,608 5,182,664 - 8,785,317 8,548,890 +4.0%
transact_write_items_4 257,350 112,673 - 267,776 268,066 +4.2%
update_item 163,483 85,461 - 185,342 186,667 +14.2%

All benchmarks within 50% of the 5-run median.

Runs in the baseline
  • runs/2026-06-26-e066fc0
  • runs/2026-07-05-7d5fd8a
  • runs/2026-07-24-f7e7d96
  • runs/2026-07-30-bc2a16c
  • runs/2026-07-30T220642Z-be8bfbc

hicksy added 2 commits August 17, 2026 23:17
…e on the clause it flattens to

A clause could be made expensive in two ways the depth budget does not reach.
Flattening an alternation built the whole intermediate before testing the
256-group cap, so around 925KB of statement peaked at gigabytes. Flattening a
conjunction cloned conditions with only the group count bounding it, and a
4000-element negated IN crossed with eight negated pairs took about 27 seconds
of CPU from 32KB of text. The alternation now tests as it goes, the conjunction
skips the clone when a term is a single group, and a budget bounds the
conditions a clause can materialise as well as the groups.

The single-item requirement on UPDATE and DELETE went back to reading the
flattened clause. Keying it on whether the text contained an OR was wrong both
ways round: NOT (NOT pk='a' AND NOT pk='b') names two items and was accepted,
updating one of them, while pk='a' AND NOT (v='x' OR v='y') names one and was
refused for an OR it flattens away. Every group must now pin the same key, and
the clause as written only chooses the wording so an OR still reads as one.

An LSI reach-back happens before the filter rather than after it, so the table
arm covers every row walked. Captured eu-west-2 2026-08-17: three rows of 9KB
cost 4.5 whether the filter keeps all of them or none.

A replayed transaction is checked for an unrecognised ReturnConsumedCapacity
before the idempotency cache answers it, rather than only on the first call.
…rser

Six operations each spelled out the ReturnConsumedCapacity enum and its
rejection message, and the three PartiQL surfaces had a ninth copy in a shared
helper. All nine now ask the helper, and the ones that collect several failures
still report the envelope they did before.

The parser caps nesting at 64 levels, and three places still advertised
parenthesised grouping to any depth, including the tool schema an agent reads
before it writes a statement. AGENTS.md listed four CI feature configurations
where there are now five.
@github-actions

Copy link
Copy Markdown
Contributor

Criterion Benchmark Results

❌ The comparison could not be made (exit 1), so this
run says nothing about performance either way.

no criterion results parsed, nothing to compare

@github-actions

Copy link
Copy Markdown
Contributor

Criterion Benchmark Results

❌ The comparison could not be made (exit 1), so this
run says nothing about performance either way.

no criterion results parsed, nothing to compare

@hicksy
hicksy merged commit 810ab04 into main Aug 18, 2026
20 of 21 checks passed
@hicksy
hicksy deleted the fix/partiql-read-correctness branch August 18, 2026 12:25
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.

PartiQL SELECT against an index scans the base table and drops the WHERE clause

1 participant