Repository navigation
fix(storage): plan an LSI query as an index walk on PostgreSQL and SQLite - #432
Open
LeeroyHannigan wants to merge 3 commits into
Open
LeeroyHannigan wants to merge 3 commits into
LeeroyHannigan wants to merge 3 commits into
Conversation
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn,
robinnsc and
yesyayen
as code owners
October 22, 2026 01:06
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. |
LeeroyHannigan
force-pushed
the
fix/lsi-query-order-by
branch
from
October 30, 2026 11:10
736d171 to
e9d3045
Compare
…Lite DynamoDB returns a Query on a local secondary index ordered by the index sort key, with the items that share an index sort key value in one deterministic order, and a reverse Query as the exact mirror of the forward one (measured 2026-10-21; the order inside such a group differs from ExtendDB's byte order and is tracked in #425). ExtendDB orders an LSI partition by the index sort key, then the base table sort key, and pages through it with a cursor over the same columns. Every index table whose index has a sort key has an ordering index on (pk, sk, base_pk, base_sk) on the PostgreSQL and SQLite backends, with one sk column per RANGE attribute. A GSI Query orders by its RANGE columns in key-schema order, then base_pk and base_sk, which is that index without the constant pk, so both databases walk the index and stop at the page limit. An LSI Query ordered by (sk, base_sk) and its cursor compared the same two columns. Neither planner can tell that base_pk is constant inside an LSI partition, so the index served the index sort key only. PostgreSQL planned an Incremental Sort over the index scan when the key condition left several index sort key values and a Sort when an equality left one; SQLite used a temporary B-tree for the last term of the ORDER BY, and seeked a resumed page on the index sort key alone. On an LSI partition of 50,000 items with 50 items per index sort key value, a PostgreSQL page with Limit 100 read 151 rows and 154 buffers to return 101, and a page resumed inside a group read 130 rows and 133 buffers. The LSI ORDER BY and cursor now list base_pk between the index sort key and the base sort key, the same columns a GSI with one RANGE attribute uses, so the ORDER BY is the ordering index without pk. Every row of an LSI partition stores the queried partition key as base_pk. The cursor binds the queried partition key for it, not the start key's partition key, so base_pk compares equal on every row and the base sort key orders the rows as before. The rows, their order, the page boundaries and the LastEvaluatedKey and ExclusiveStartKey shapes do not change. On SQLite the statement builder moves out of query_impl into query_statement so the plan tests can explain the statement a Query sends. Measured on servers built from this branch and from main, on the same 52,000 items: the LSI pages plan as an Index Scan or Index Scan Backward on the ordering index with the cursor in the Index Cond and no sort node, in both directions, in the custom and the generic plan, reading 101 rows and 103 to 105 buffers. SQLite plans SEARCH USING INDEX idx_order_* with no temporary B-tree and seeks a resumed page on (sk_s, base_pk, base_sk_s). The GSI plans and buffer counts are unchanged. The items and LastEvaluatedKey of every measured page are identical before and after on both backends. crates/storage-postgres/tests/lsi_query_plan.rs reads the statement the engine sent from pg_stat_activity and asserts that its generic and custom plans have no Sort or Incremental Sort node and no Filter under the index scan, for a first page and a resumed page with the partition alone, with lsk = :l, and with lsk BETWEEN, in both directions. It also checks the page rows and that a start key naming another partition returns the same page. It skips below PostgreSQL 16, which added EXPLAIN (GENERIC_PLAN). CI runs it in the PostgreSQL storage-level step. The SQLite plan tests (crates/storage-sqlite/src/data/query_scan/plan_tests.rs) assert no temporary B-tree for the same LSI shapes and for GSI pages, and page through an LSI in both directions. The cursor unit tests on both backends assert the new LSI fragment and bind. A GSI with several RANGE attributes orders by every RANGE column (#429). crates/storage-postgres/tests/lsi_query_plan/multi_range_gsi.rs, a module of the lsi_query_plan test, and a fourth SQLite plan_tests case run such a GSI with RANGE attributes a, b and c for the partition alone, an equality on a, an equality on a with b BETWEEN, and equalities on all three, first and resumed pages in both directions, and assert the same index walk; both also check the rows of the pages. Their plan checks pass on main. With the ORDER BY cut to the first RANGE column, the SQLite case sorts in a temporary B-tree and the PostgreSQL case returns the first page out of order. With a condition applied to the first RANGE column instead of the one it names, the SQLite case returns no rows for a = :a AND b BETWEEN. With base_pk removed from the LSI ORDER BY, the PostgreSQL plan test and the SQLite LSI plan test fail on the sort node; the SQLite GSI plan test and paging test pass, as the rows do not change. With base_pk removed from the LSI cursor comparison, the PostgreSQL plan test fails on the Filter under the index scan and the SQLite cursor unit tests fail on the fragment; the paging tests pass. With the start key's partition key bound instead of the queried one, the PostgreSQL other-partition page check and the SQLite bind test fail. tests/test_query_scan.py and tests/test_multipart_gsi.py pass on both backends. docs/design/04-component-storage.md describes the LSI cursor and ORDER BY and the sort step they avoid. Closes #366
LeeroyHannigan
force-pushed
the
fix/lsi-query-order-by
branch
from
November 1, 2026 13:07
e9d3045 to
16a1b92
Compare
1 of 3 tasks
.github/workflows/integration.yml conflicted. Main runs every test target of extenddb-storage-postgres with --tests, so the step no longer lists the LSI query plan test by name. The comment keeps this branch's sentence on what that test reads. Main removed crate::schema::apply from extenddb-storage-sqlite. The SQLite plan tests now create their schema with crate::migrations::apply_for_tests, as main's other unit tests do.
LeeroyHannigan
requested review from
eliseong,
hitesh96db and
navareamol
as code owners
November 5, 2026 01:23
Main's migrations no longer seed the control_plane_delay_seconds row in settings, so the UPDATE that set it to zero in these tests changed no row. The engine kept its default delay, CreateTable left the table CREATING, and table_key_info then returned TableNotFound. The tests now insert the row or update it, as main's own tests do.
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 first, #428 meets the same hunk when it is rebased and needs the three edits below. 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.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, with oneskcolumn per RANGE attribute. A GSI Query orders by its RANGE columns in key-schema order, thenbase_pkandbase_sk((sk, base_pk, base_sk)with one RANGE attribute), 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 columns 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 incrates/storage-sqlite/src/data/query_scan/plan_tests.rs.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)).crates/storage-postgres/tests/lsi_query_plan/multi_range_gsi.rs: the same plan check for a GSI with RANGE attributesa,bandc, a module oflsi_query_planso the CI step runs it (see Rebase on fix(query): classify and order key conditions on a multi-attribute GSI the way the service does #429)..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 the pre-rebase head
e9d30451e, PostgreSQL 16.15 and SQLite in memory. Plans from the servers' own statements: PostgreSQLEXPLAIN (ANALYZE, BUFFERS) EXECUTEandEXPLAIN (GENERIC_PLAN); SQLiteEXPLAIN QUERY PLAN. The rebase changed no LSI statement, and these rows were not repeated on03d9c72e3.=,>,<,<=,>=,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,LastEvaluatedKeykeystest_item_operations.py,test_table_operations.py,test_put_item_create_race.pyThe rows below are for the rebased head. The servers (SQLite and PostgreSQL 16) and the PostgreSQL tests ran on
03d9c72e3, the rebased commit before the SQLite plan tests moved into their own file and the multi-RANGE case gained its row check; this head keeps that commit's non-test code and PostgreSQL tests. The SQLiteplan_testsand cursor unit test rows ran on16a1b92f1:tests/test_query_scan.py+tests/test_multipart_gsi.pytests/test_gsi_multi_range_query.py,tests/test_read_paging.py,tests/test_vector_index_query_scan.pytests/python/test_query_scan.pycrates/storage-postgres/tests/lsi_query_plan.rs(20 LSI plans, 32 multi-RANGE GSI plans)plan_tests(12 LSI statements, 4 GSI statements, 16 multi-RANGE GSI statements, the rows of the multi-RANGE GSI pages, LSI paging both directions)Negative controls
On the pre-rebase head
e9d30451e: each mutation applied to the committed tree, the tests run, the file restored withgit checkout,crates/clean afterwards. The cursor unit test counts are that head's; main has since added one test per backend.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"]On
03d9c72e3, with theORDER BYof an index with several RANGE attributes cut to its first RANGE column and the cursor untouched:plan_tests::multi_range_gsi_query_walks_the_ordering_index_without_a_sortfails withmulti-RANGE GSI h only forward=true resumed=false: the plan sorts in a temporary B-tree(USE TEMP B-TREE FOR LAST 2 TERMS OF ORDER BY), andmulti_range_gsi_query_plans_as_an_index_walk_in_both_directionsfails on the rows of the first page ((a0, b1, c1, ..)where(a0, b0, c0, ..)was expected); the other plan tests pass.On
16a1b92f1, with SQLite'squery_statementapplying the sort key condition to the first RANGE column instead of the one it names:plan_tests::multi_range_gsi_query_walks_the_ordering_index_without_a_sortfails on rows (multi-RANGE GSI a = :a AND b BETWEEN forward=true: rows, no items where 60 were expected); the other 167 SQLite--libtests pass.Gates
On
16a1b92f1:cargo +1.97.0 fmt --all --checkclean;cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warningsclean;cargo test -p extenddb-storage-sqlite --lib: 168 passed, 1 ignored. On03d9c72e3, whose non-test code and PostgreSQL tests this head keeps:cargo +1.97.0 check --locked --workspace --all-targetsandcargo +1.88.0 check --locked --workspacebuild;cargo test -p extenddb-storage-postgresagainst PostgreSQL 16.15: lib 63, backup_restore 30, catalog_converge 5, doc_version_literals 3, gsi_online_build 12, gsi_stale_writer 1, key_collation 3, lsi_query_plan 2, migrate_data_db_name 2, put_create_race 9, stream_records_page_order 1, stream_shard_ownership 6, stream_shard_retention 1, table_snapshot 81 (2 ignored), twi_conflict 8, vector_control_plane 42, all passed. No dependency change. CI passed all 35 checks on16a1b92f1.Related behaviour
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 (Query accepts an ExclusiveStartKey from another partition and returns an empty page #440).Limitlands on the last item of a partition, with no sort key condition: the service returns that page with aLastEvaluatedKeyon some calls and without one on others (Query on an index returns a LastEvaluatedKey on the page that holds the last item; the service does so for base tables only #434, measured 2026-10-23); ExtendDB returns the key and then an empty page.(sk, base_pk, base_sk)and resumes an LSI page with up to three statements over that order (sk = :s AND base_pk = :p AND base_sk > :b,sk = :s AND base_pk > :p,sk > :s), so its rows come back in clustering order.Rebase on #429
Rebased on main at
f49c5f82(#429) on 1 November. #429 orders a Query on a GSI with several RANGE attributes over every RANGE column: on PostgreSQLorder_by_columnsandbuild_pagination_where, and on SQLitequery_order_columnsandappend_query_pagination, take the queried key schema's RANGE attributes as a slice in place of the first one, and keepis_lsi. This change removesis_lsifrom the same functions, so bothquery_scan.rsfiles conflicted. The resolved functions take #429's slice and dropis_lsi: an index with a sort key orders by its RANGE columns in key-schema order, thenbase_pk, then the base sort key, which is the column order of its ordering index afterpk. An LSI cursor still binds the queried partition key asbase_pk. GSI columns are the same as on main, with one RANGE attribute or several; only the LSI columns differ from main, as they did before the rebase. On SQLite,query_statementnow holds #429's statement code, which applies each RANGE condition to the column of the attribute it names..github/workflows/integration.yml,query.rsand the design doc merged without a conflict.The plan tests now cover a GSI with RANGE attributes
a,bandc:crates/storage-postgres/tests/lsi_query_plan/multi_range_gsi.rs, a module of thelsi_query_plantest so the CI step runs it, and a fourthplan_testscase on SQLite. Each runs the partition alone, an equality ona, an equality onawithb BETWEEN, and equalities on all three, first and resumed pages in both directions, and asserts a walk of the ordering index with no sort step. Both also check the rows of the pages: the PostgreSQL test of every page it plans, the SQLite case of every page of each shape read withLimit7 in both directions. Their plan checks pass on main too, because main already orders such a GSI over every RANGE column. The SQLite plan tests are in their own file,crates/storage-sqlite/src/data/query_scan/plan_tests.rs, soquery_scan.rsis 971 lines here (883 on main).With the plan tests applied to main alone (on SQLite with the statement builder moved into
query_statement, which the tests call),lsi_query_plans_as_an_index_walk_in_both_directionsfails on the first page with anIncremental Sortover the index scan (presorted keysk_s), andlsi_query_walks_the_ordering_index_without_a_sortfails withUSE TEMP B-TREE FOR LAST TERM OF ORDER BY; the GSI cases and the LSI paging test pass there. The second Verification table, the Gates and the last two Negative controls paragraphs are the rebased head's, on the commits they name; the first Verification table and the mutation table are the pre-rebase head's.