Repository navigation
fix(storage): plan an LSI query as an index walk on PostgreSQL and SQLite - #432
Open
LeeroyHannigan wants to merge 1 commit into
Open
LeeroyHannigan wants to merge 1 commit into
LeeroyHannigan wants to merge 1 commit into
Conversation
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn,
robinnsc and
yesyayen
as code owners
October 22, 2026 01:06
…Lite DynamoDB returns a Query on a local secondary index ordered by the index sort key, with the items that share an index sort key value in one deterministic order, and a reverse Query as the exact mirror of the forward one (measured 2026-10-21; the order inside such a group differs from ExtendDB's byte order and is tracked in #425). ExtendDB orders an LSI partition by the index sort key, then the base table sort key, and pages through it with a cursor over the same columns. Every index table whose index has a sort key has an ordering index on (pk, sk, base_pk, base_sk) on the PostgreSQL and SQLite backends. A GSI Query orders by (sk, base_pk, base_sk), which is that index without the constant pk, so both databases walk the index and stop at the page limit. An LSI Query ordered by (sk, base_sk) and its cursor compared the same two columns. Neither planner can tell that base_pk is constant inside an LSI partition, so the index served the index sort key only. PostgreSQL planned an Incremental Sort over the index scan when the key condition left several index sort key values and a Sort when an equality left one; SQLite used a temporary B-tree for the last term of the ORDER BY, and seeked a resumed page on the index sort key alone. On an LSI partition of 50,000 items with 50 items per index sort key value, a PostgreSQL page with Limit 100 read 151 rows and 154 buffers to return 101, and a page resumed inside a group read 130 rows and 133 buffers. The LSI ORDER BY and cursor now list base_pk between the index sort key and the base sort key, the same columns a GSI uses, so the ORDER BY is the ordering index without pk. Every row of an LSI partition stores the queried partition key as base_pk. The cursor binds the queried partition key for it, not the start key's partition key, so base_pk compares equal on every row and the base sort key orders the rows as before. The rows, their order, the page boundaries and the LastEvaluatedKey and ExclusiveStartKey shapes do not change. On SQLite the statement builder moves out of query_impl into query_statement so the plan tests can explain the statement a Query sends. Measured on servers built from this branch and from main, on the same 52,000 items: the LSI pages plan as an Index Scan or Index Scan Backward on the ordering index with the cursor in the Index Cond and no sort node, in both directions, in the custom and the generic plan, reading 101 rows and 103 to 105 buffers. SQLite plans SEARCH USING INDEX idx_order_* with no temporary B-tree and seeks a resumed page on (sk_s, base_pk, base_sk_s). The GSI plans and buffer counts are unchanged. The items and LastEvaluatedKey of every measured page are identical before and after on both backends. crates/storage-postgres/tests/lsi_query_plan.rs reads the statement the engine sent from pg_stat_activity and asserts that its generic and custom plans have no Sort or Incremental Sort node and no Filter under the index scan, for a first page and a resumed page with the partition alone, with lsk = :l, and with lsk BETWEEN, in both directions. It also checks the page rows and that a start key naming another partition returns the same page. It skips below PostgreSQL 16, which added EXPLAIN (GENERIC_PLAN). CI runs it in the PostgreSQL storage-level step. The SQLite plan tests assert no temporary B-tree for the same LSI shapes and for GSI pages, and page through an LSI in both directions. The cursor unit tests on both backends assert the new LSI fragment and bind. With base_pk removed from the LSI ORDER BY, the PostgreSQL plan test and the SQLite LSI plan test fail on the sort node; the SQLite GSI plan test and paging test pass, as the rows do not change. With base_pk removed from the LSI cursor comparison, the PostgreSQL plan test fails on the Filter under the index scan and the SQLite cursor unit tests fail on the fragment; the paging tests pass. With the start key's partition key bound instead of the queried one, the PostgreSQL other-partition page check and the SQLite bind test fail. tests/test_query_scan.py and tests/test_multipart_gsi.py pass on both backends. docs/design/04-component-storage.md describes the LSI cursor and ORDER BY and the sort step they avoid. Closes #366
LeeroyHannigan
force-pushed
the
fix/lsi-query-order-by
branch
from
October 26, 2026 13:09
8a6404d to
736d171
Compare
Collaborator
Author
|
Rebased on main at abf8082; conflicts in .github/workflows/integration.yml, resolved by keeping both test entries: the PostgreSQL storage-level step runs gsi_online_build, gsi_stale_writer and lsi_query_plan, and its comment describes both. The earlier merge of main is folded into the one commit. crates/storage-postgres (lsi_query_plan included, on PostgreSQL 16) and crates/storage-sqlite tests pass. |
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.
Closes #366. Merge after #428: the LSI paragraph in
docs/design/04-component-storage.mdis written against the section 7 text #428 introduces (the**... Query:**headings, explicitASCandDESC, the hash-only and no-base-sort-key paragraphs). Merged on top of #428 it replaces #428's LSI paragraph; merged before it, section 7 would describe two different LSI cursors until #428 is rebased. The conflict is one hunk, plus two #428 sentences that merge cleanly but describe the LSI cursor this change removes:LSI Query:paragraph and SQL block in place of docs(design): describe the query cursor as the row comparison the code emits #428'sLSI Query:paragraph; keep docs(design): describe the query cursor as the row comparison the code emits #428'sHash-only index:paragraph and the "On a base table without a sort key" paragraph after it unchanged.What the service does
Measured 2026-10-21 on DynamoDB with a (pk, sk) table and an LSI on (pk, lsk), 9 items in three groups of equal
lsk: a Query on the LSI returns the items ordered bylsk, the items inside a group in one deterministic order (the same across two tables), and a reverse Query is the exact mirror of the forward one. Page boundaries andLastEvaluatedKey({lsk, pk, sk}) follow that order. The order inside a group is not byte order of the base sort key; that difference is #425 and is unchanged here.What ExtendDB did
Every index table whose index has a sort key has an ordering index on
(pk, sk, base_pk, base_sk)on PostgreSQL and SQLite. A GSI Query orders by(sk, base_pk, base_sk), the index without the constantpk, so the database walks the index and stops at the Limit. An LSI Query ordered by(sk, base_sk)with a cursor over the same columns. The planners cannot tell thatbase_pkis constant inside an LSI partition, so the index servedskonly. PostgreSQL added an Incremental Sort over the index scan (a Sort whenlsk = :lleft one value); SQLite added a temporary B-tree for the last ORDER BY term and seeked a resumed page onskalone. On a 50,000-item partition with 50 items perlskvalue, a Limit 100 page read 151 rows and 154 buffers on PostgreSQL to return 101; a page resumed inside a group read 130 rows and 133 buffers.Changes
crates/storage-postgres/src/data/query_scan.rs:order_by_columnsputsbase_pkbetween the index sort key and the base sort key for every index with a sort key (theis_lsiparameter is gone from it and frombuild_pagination_where); an LSI cursor binds the queried partition key asbase_pk; cursor unit tests updated.crates/storage-postgres/src/data/query.rs:PaginationBinds::BaseSkOnlyremoved (no remaining user).crates/storage-sqlite/src/data/query_scan.rs: the same column change inquery_order_columns;append_query_paginationtakes the queried partition key and binds it for an LSI; the statement builder moves fromquery_implintoquery_statementwith no behaviour change, so tests can runEXPLAIN QUERY PLANon the statement a Query sends; cursor unit tests updated plus a bind test; newplan_testsmodule.crates/storage-postgres/tests/lsi_query_plan.rs: new storage-level plan test. It reads the statement the engine sent frompg_stat_activityand asserts that the generic plan and the custom plan are an index walk: no Sort or Incremental Sort node, no Filter under the index scan, for a first page and a resumed page with the partition alone, withlsk = :land withlsk BETWEEN, in both directions. It checks the page rows and that a start key naming another partition returns the same page. It skips below PostgreSQL 16 (EXPLAIN (GENERIC_PLAN))..github/workflows/integration.yml:--test lsi_query_planadded to the PostgreSQL storage-level step (pgvector/pgvector:pg16).docs/design/04-component-storage.md: the LSI Query paragraph in section 7, with the statement, the bind, and the sort step the ordering index avoids.The cursor token format and the
LastEvaluatedKeyshape do not change.Verification
Servers built from this branch, PostgreSQL 16.15 and SQLite in memory. Plans from the servers' own statements: PostgreSQL
EXPLAIN (ANALYZE, BUFFERS) EXECUTEandEXPLAIN (GENERIC_PLAN); SQLiteEXPLAIN QUERY PLAN.=,>,<,<=,>=,begins_with,BETWEEN, engine chunks), both directionsSEARCH ... USING INDEX idx_order_*, no temporary B-tree, resumed seek on(sk_s, base_pk, base_sk_s)(wassk_sonly plus a temporary B-tree); 1,196 VM opcodes per LIMIT 98 page, was 2,892LastEvaluatedKey)Count,LastEvaluatedKeykeystests/test_query_scan.py+tests/test_multipart_gsi.pytest_item_operations.py,test_table_operations.py,test_put_item_create_race.pycrates/storage-postgres/tests/lsi_query_plan.rs(20 plans)plan_tests(12 LSI statements, 4 GSI statements, paging both directions)Negative controls
Each mutation applied to the committed tree, the tests run, the file restored with
git checkout,crates/clean afterwards.base_pkfiltered out of the LSI ORDER BY (all shapes)lsi_query_plans_as_an_index_walk_in_both_directions:first page, forward=true, generic plan: the plan sorts the rows the index scan reads(Incremental Sort)base_pkfiltered out of the LSI ORDER BY only when the key condition has a sort key conditionlsk = :l, first page, forward=true, generic plan: the plan sorts the rows the index scan reads(Sort, Sort Key: base_sk_s)base_pkreplaced by its bound value in the LSI cursor comparisonresumed page, forward=true, generic plan: the plan filters rows after the index scan instead of seeking on them(Index Condsk_s >= $2, Filter on the row comparison)resumed page, forward=true: a start key naming partition q moved the page boundarybase_pkfiltered out of the LSI ORDER BY (all shapes)plan_tests::lsi_query_walks_the_ordering_index_without_a_sort:LSI pk only forward=true resumed=false: the plan sorts in a temporary B-treebase_pkfiltered out of the LSI ORDER BY only with a sort key conditionLSI lsk = :l forward=true resumed=false: the plan sorts in a temporary B-treebase_pkremoved from the LSI cursor columns and bindlsi_cursor_compares_the_ordering_index_columns,order_by_columns_match_the_cursor_columns_for_every_shape,lsi_cursor_binds_the_queried_partition_key_as_base_pklsi_cursor_binds_the_queried_partition_key_as_base_pk: left["l", "other", "s"], right["l", "p", "s"]Gates
cargo fmt --all -- --checkclean.cargo +1.97.0 clippy -p extenddb-storage-postgres -p extenddb-storage-sqlite --all-targets -- -D warningsclean.cargo +1.88.0 test --no-runon both crates builds.cargo test -p extenddb-storage-sqlite: 104 passed, 1 ignored.cargo test -p extenddb-storage-postgresagainst PostgreSQL 16.15: lib 45, doc_version_literals 3, key_collation 3, lsi_query_plan 1, put_create_race 9, stream_shard_retention 1, twi_conflict 8, vector_control_plane 42, all passed. No dependency change.Not in this change
ExclusiveStartKeywhose partition key differs from the queried partition. The service returns HTTP 400ValidationException,The provided starting key is outside query boundaries based on provided conditions. ExtendDB accepts it and resumes inside the queried partition, on main and on this branch; the storage layer does not read the start key's partition key, so the refusal belongs in request validation. Filed separately.Limitequal to the number of remaining items: the service returns the last page without aLastEvaluatedKey; ExtendDB returns one and then an empty page. Pre-existing, filed separately.(index sk, base sk); its clustering order is a different mechanism.