Skip to content

fix(postgres): build a GSI added by UpdateTable correctly while writes continue - #401

Merged
robinnsc merged 1 commit into
mainfrom
fix/pg-online-gsi-backfill
Oct 26, 2026
Merged

robinnsc merged 1 commit into
mainfrom
fix/pg-online-gsi-backfill

Conversation

@robinnsc

@robinnsc robinnsc commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

What

Adding a GSI with UpdateTable on PostgreSQL now produces a correct index when writes keep going during the build. Before this change the index ended up wrong or the call failed. The same cached-definitions bug on SQLite is fixed alongside.

The new module crates/storage-postgres/src/data/gsi_build.rs documents the ordering argument. The build:

  • update_table.rs creates the GSI's data table before it commits the indexes row, and commits that row as CREATING instead of ACTIVE. Every write reads the catalog, so once the row commits, writes maintain the index exactly as they maintain an ACTIVE one, inline or through the propagation queue.
  • After the commit, UpdateTable waits 1 s. It then takes LOCK TABLE <base> IN SHARE MODE and releases it at once; the lock waits for writes already in flight. Writes arriving meanwhile queue behind the request, each holding a data pool connection, so while the request waits, writes to other tables can wait on the pool too. The request has a lock timeout (one second over deadlock_timeout, which is what lets it cancel an autovacuum) and is retried up to six times with growing pauses: with the default deadlock_timeout, about 12 s of write stalls spread over 20 s in the worst case before the build is left to recovery.
  • The backfill runs in keyset pages of 256 over the full base key, up to the last key present when it starts. Each page locks its rows FOR SHARE SKIP LOCKED and writes each item's current state into the index. Rows a writer held are then done one at a time with a waiting lock, so a page never holds row locks while it waits on another one. Pages are retried on 40P01 and 40001.
  • A last pass deletes index rows whose item no longer exists. It locks the rows first and then deletes those exact rows by ctid with a fresh snapshot, so a writer re-creating the item at that moment keeps its row.
  • When the backfill finishes, the index flips to ACTIVE. If the build fails for a transient reason (a conflict, a lock timeout, a lost connection or an empty pool, a shutdown, a server I/O or internal error), the index stays CREATING and the request still succeeds. A new background worker (recovery_worker, every 60 s, with backoff per index) finishes such indexes, and also those left behind by a crash. Any other error, including a row that does not decode, counts as one that would recur: the index is removed and the request fails, as before, and the recovery worker does the same when it meets one. The removal only touches a row still CREATING, so a second build of the same index (the recovery worker next to an UpdateTable that could not take build ownership) cannot remove an index the first one has published. The data table is dropped a second after the catalog row, and the synchronous write path treats a table that is gone as an index that is gone, so a write that read the catalog just before the removal does not fail.

Queue and write path:

  • gsi_queue.rs: a queued GSI update now applies the item's current state, read under its row lock (resync_base_key), instead of the image the row carried. A row applied late, twice, out of order by a second server process, or after the backfill already wrote a newer state can no longer leave the index behind the table.
  • index.rs: the inline (synchronous) path writes a GSI the way the backfill and the queue do (write_state): delete the item's rows by base key, then upsert the current state on the index primary key. The upsert is in insert_index_rows_multi, which the backup restore copy (fix(backup): restore sort keys, indexes, and throughput; bound memory; recover abandoned restores #384) also uses; a restore writes into a table nothing else touches, so for it the statement behaves as the insert it was. LSIs keep the old image-based delete.
  • delete_table.rs and worker_store.rs drop a table's storage through one helper (drop_table_storage; the control plane worker gives it a 3 s lock timeout, as main's inline drop had, so one table waiting behind a CreateBackup cannot stall a pass, and a timeout leaves the row DELETING for the next pass) that stops the propagation queue for the table first (a vector_index_holds row, which the claim query already honours), deletes the table's pending rows, which waits out the claims in flight, and only then drops the data tables. The queue worker holds a claimed row and takes FOR SHARE on the base row; a drop that took the tables first deadlocked with it, and as the victim left the data tables behind with the catalog rows already gone. With 16 writers and DeleteTable 1 s in, 8 rounds: main failed 7 of 8 with 14 orphan tables and 20 deadlocks; this branch 0 of 8, none, none. Main has this through the index table rather than the base table.
  • gsi_queue.rs: for a GSI row, the worker's transaction sets lock_timeout = 2s. When the base row of the item is held open by a client, the claim rolls back on the timeout and the item is passed over for 2 s: every queued row for that item is given back as soon as it is claimed (a claim and a rollback), without waiting on the lock, so the rows behind it in the partition apply meanwhile. The pass-over is by item, not by row, because a held item usually has several rows queued (one per asynchronous GSI for each write to it), and passing a single row over at a time did not move the partition at all: by the time the second row had timed out, the first was due again. The item is tried again when the pass-over ends, so about every 4 s (up to 5 s on an otherwise idle partition), each attempt stalling the partition for up to 2 s, and it applies once the hold ends. On main the worker has no lock timeout, so the partition waited for the whole hold; with a timeout alone, the timed-out row is the lowest due id and is claimed again at once, which comes to the same. Passing a GSI row over is safe because the apply writes the item's state at apply time; a later row for the same item writes the same state. Vector rows apply the images they carry, so they are not passed over and keep waiting for the base row as before.
  • update_table.rs (both backends): while a GSI on the table is CREATING, a GSI Create, or a Delete of any other GSI, is refused with the service's LimitExceededException sentence ("Only 1 online index can be created or deleted simultaneously per table"), now a shared constant; Lee measured both against the service. A Delete of the index that is building is left to the delete path, which accepts it in both phases (the service answers ResourceInUseException during allocation and accepts it during backfill; Lee was fine leaving this), and a throughput Update is not refused. On PostgreSQL the check runs under the tables row lock every UpdateTable takes. On SQLite a build runs under the engine write lock and keeps it until the index is ACTIVE, so the check also runs before the lock is taken, from a WAL read: a second change sent during a build is refused at once instead of waiting for the build and then succeeding. The read sees only a committed CREATING row: a build that holds the lock but is still in its catalog transaction is not seen, and a request that meets one waits for the build and then succeeds, as before. The SQLite test holds the write lock over a planted CREATING row; it shows the refusal does not wait on the lock, not that the window is closed.
  • ddl.rs (both backends): a Query or Scan naming a CREATING GSI resolves as IndexNotFound, which the engine already answers with ValidationException "The table does not have the specified index", as the service does. On SQLite this was a 500 from the partial or missing data table, on main too.
  • data_engine.rs (PostgreSQL): a PutItem, GetItem, DeleteItem, UpdateItem, Query, Scan, TransactWriteItems or TransactGetItems (and so BatchWriteItem and BatchGetItem, which go through PutItem, DeleteItem and GetItem) that resolved its table just before a DeleteTable committed and reached the data database after the drop got PostgreSQL's undefined_table as a 500. It is now TableNotFound, which the engine answers with ResourceNotFoundException; for a transaction, the table named is the one whose data table is missing. On a Query or Scan naming an index, a missing index table is IndexNotFound, answered with ValidationException "The table does not have the specified index"; that includes an index read racing a DeleteTable, since the drop takes the index tables too. Parallel Scan's segment check (scan_key_in_segment) does not touch a data table, so it has no such case. With Lee's DeleteTable scenario and SDK retries off (16 writers, DeleteTable 1 s in, 8 rounds), the previous head gave 23 writer 500s and this one gives 0.
  • update_item.rs (PostgreSQL): the post-update item's index keys are validated for type and emptiness, as PutItem, TransactWriteItems and SQLite already did. Setting a GSI key to the wrong type was written and left out of the index; it is now the service's ValidationException. Same on main. The check covers the whole updated item, so an UpdateItem that does not touch a GSI key attribute that already has the wrong type (an item written before the index existed) is refused too, as it is on SQLite and in TransactWriteItems.
  • Index attribute definitions now come from the write's catalog read, which now joins tables, instead of the cached TableKeyInfo. The engine drops that cache only when UpdateTable returns. Until then, writes built rows for a new GSI with a RANGE key on a new attribute without the sort key column, and skipped the type check on that attribute.

The remaining gap. The catalog and the data are separate databases, so a write that read the catalog just before the index row committed cannot learn about the index in its own transaction. Two things cover that write:

  • Each write stamps the time of its catalog read (after the catalog connection is acquired, so pool wait is not counted), on CLOCK_BOOTTIME so suspend time is counted.
  • If more than 500 ms has passed when it is about to commit, it re-reads the catalog and, in the same transaction, queues a repair row for any GSI it did not know about.

A write whose base-table statements all returned within 500 ms holds the table's ROW EXCLUSIVE lock from the first of them until it commits, and the barrier comes at least 1 s after the catalog commit, so the barrier waits for it and the backfill sees it. A slower write repairs itself. Every source of measurement error makes a write look slower or the wait longer, which is the safe direction.

A write with no transaction (a plain put or delete on a table the write believed had no index) takes the transaction path instead if it is already past 500 ms when it decides. One that becomes slow only after that queues its repair in a separate transaction after the write; if the process dies between the two, that item stays out of the index (or, for a delete, its old row stays in) until the item is next written. The module doc records this.

SQLite: SQLite took a new GSI's attribute definitions from the cached TableKeyInfo in the same way. A write landing after the build but before the cache was dropped wrote the index row without its sort key column, so a Query on the full index key missed the item. CI found this with the new pytest. SQLite now reads the definitions with its index read (fetch_indexes_for_table joins tables) and uses them to build rows, queue rows, and validate keys. SQLite already reads its index set under its write lock, so it does not have the cross-database window.

Other changes:

  • The OFFSET-based backfill_gsi in table_helpers.rs is removed.
  • Docs: docs/design/04-component-storage.md §6.2 describes the build as implemented.
  • CI: the two new storage-level test files are added to the PostgreSQL storage-level step in integration.yml.

Why

No issue filed. Found in the 1.0 readiness review (P0-4: adding a GSI under concurrent writes fails on PostgreSQL). The readiness document puts the cause in the index table's primary key. That is not it: the PK already includes the base key (ddl.rs, (pk, base_pk, base_sk...)). The causes on main:

  1. The indexes row committed ACTIVE before the data table existed. Writes in between failed with 42P01 (synchronous GSIs). Asynchronous ones queued rows that the worker skipped as "table dropped", so those rows were lost.
  2. backfill_gsi paged ORDER BY key LIMIT 500 OFFSET n while writes landed. Rows were read twice, which hit duplicate key value violates unique constraint and failed UpdateTable with InternalServerError, or were skipped.
  3. Queue rows applied their own old/new images, and deleted the old row only if the old image had the index keys. A row applied after the backfill wrote the same item collided (23505, retried forever, stalling its partition) or left a second row for the item.
  4. Writes that read the catalog before the index existed maintained nothing. The backfill could pass their item before they committed.

Measured with a script that loads 2,000 items, runs 4 writer threads (put, update of the key, REMOVE of the key, delete, TransactWriteItems), and adds a GSI mid-traffic. On main, 3 of 3 runs ended ACTIVE but wrong: 15 to 18 rows missing, 33 to 38 extra and 40 to 50 stale out of about 1,770. With a RANGE key on a new attribute, UpdateTable failed with InternalServerError. On this branch every run matches the table: hash-only and hash+range GSIs, asynchronous and synchronous propagation, 4 and 8 writers. SQLite is not affected, because it holds its write lock for the whole backfill, and it passes the same test on main.

Not closed here

  • DescribeTable does not send Backfilling on a CREATING GSI; the service sends false, then true.
  • UpdateTable still returns after the build rather than right away with CREATING, as before.
  • Vector indexes have the same cross-database window for writes that read the catalog just before an index was created, and their queue rows still apply the images they carry. They are left as they are.
  • On MongoDB, the new pytest is marked xfail. With these writers, concurrent UpdateItem and TransactWriteItems calls that create the same item return HTTP 500 (ImmutableField on _id). That also happens on a table with no index at all, so it is the MongoDB write-race issue that test_put_item_create_race.py already marks, not this change.
  • On Cassandra, the new pytest is skipped. In CI its writers got HTTP 500 from DeleteItem under contention on one item, and the online build had not finished after 10 minutes of them. Both are Cassandra backend issues worth their own issue.
  • On PostgreSQL, an index Query or Scan racing a DeleteTable gets ValidationException (no such index) rather than the ResourceNotFoundException the service gives once the table is gone.
  • On SQLite, a second index change that arrives while a build holds the engine write lock but has not yet committed its CREATING row waits for the build and succeeds (see the update_table.rs bullet).
  • A DeleteTable interrupted between its catalog commit and the storage drop (a client timeout, or the process killed while the drop waits behind an open client transaction) leaves the data tables with no catalog row and nothing that sweeps them. This is on main too; Lee is filing it with a reproduction.
  • On PostgreSQL, if a client disconnects mid-build, the build still finishes, because it runs in its own task (on SQLite the backfill runs in the request, and a dropped request leaves the CREATING row for the startup reconciler). The engine's cached TableKeyInfo on that node is not invalidated, because that happens after the storage call returns. This was already true on main.

Cost

  • No extra SQL on the write path unless a write takes more than 500 ms between its catalog read and its commit. Above that, it costs one catalog read, once per table for TransactWriteItems. A plain put or delete on a table with no index, which writes with a single autocommit statement, takes the transaction path instead when it is already past 500 ms before that statement: a row lock and four data round trips (BEGIN, SELECT FOR UPDATE, write, COMMIT) in place of one, plus the catalog read. What gets a write there is a catalog query that itself took 500 ms (time waiting for a catalog connection is not counted), so this extra read lands on a catalog that is already slow.
  • On tables with a synchronous GSI, each write does a DELETE by base key before its upsert (an overwrite already did; a new item did not), and the GSI maintenance runs inside one SAVEPOINT/RELEASE pair per write so that an index removed meanwhile does not fail the write (the pass is redone without that index). With N synchronous GSIs that is 2N + 2 data round trips per write where there were N to 2N. Asynchronous GSIs, the default, add nothing on the write path.
  • A queue apply of a GSI row now reads the base row FOR SHARE, and sets its lock timeout: the worker's transaction for a present item is 10 statements where it was 7 or 8. That lock means a queued update and a concurrent write to the same item now wait for each other, where before the worker only touched the index table; a foreground transaction holding a row costs its queue partition up to 2 s each time the held item is retried, about every 4 s, while the other rows in the partition apply in between. Vector rows are unchanged.
  • The backfill writes two statements per row (delete by base key, upsert) in one transaction per page, against main's one INSERT per row, and ends with an anti-join of the index against the table.
  • Write throughput and queue drain time showed no measurable difference with 8 client threads on one async-GSI table (about 610 writes/s on both, queue drained within 20 ms), on distinct and on 20 hot keys. The client was the limit there, so this is not a ceiling measurement. Propagation lag of a single write to an asynchronous GSI under that load (put returns, then Query on the index until the item shows; default delay; 200 probes, two runs each) is the same on both: p50 18 to 19 ms, p99 45 to 51 ms.
  • Each UpdateTable that creates a GSI takes at least 1 s longer.

Testing done

New tests/test_gsi_online_backfill.py: adds a GSI while four writer threads run puts, key updates, key removals, deletes and TransactWriteItems, then compares a Scan of the index with a Scan of the table (missing, extra, stale, and items indexed twice), for a hash-only GSI and a hash+range GSI on a new attribute. On main both fail on PostgreSQL. On this branch both pass on PostgreSQL and SQLite. On main, SQLite fails the hash+range case only intermittently (it did in CI); the deterministic test below covers that case. On MongoDB both are xfail and on Cassandra both are skipped (see above).

New crates/storage-postgres/tests/gsi_online_build.rs. The file does not compile on main (it uses ONLINE_INDEX_LIMIT and reconcile_creating_gsis, which this PR adds). The results "on main" below are from earlier runs of the tests that do not need those symbols, against main.

  • queue_rows_applied_out_of_order_converge_to_the_item: on main, the item is indexed under both its old and new key.

  • queue_row_for_a_row_the_backfill_wrote_is_applied: on main, 23505 and the queue never drains.

  • sync_write_over_an_existing_index_row_succeeds: on main, the put fails with 23505.

  • sync_write_survives_a_gsi_whose_table_is_gone: two synchronous GSIs, one whose data table is gone; the write succeeds and the other index is right.

  • a_creating_gsi_refuses_a_second_change_and_reads: with a GSI CREATING, a second Create and a Delete of another GSI get LimitExceeded (and the other GSI stays), a Delete of the building GSI is not refused that way, and the index resolves as not found while CREATING and resolves again once ACTIVE. The SQLite test of the same name holds the engine write lock, as a build does, and requires both refusals within 500 ms.

  • a_held_base_row_does_not_hold_up_its_queue_partition: one base row held for 10 s with three rows queued for its item and ten rows for other items in the same partition behind them; all ten reach the index within 6 s while the held item has not moved, and it applies once released. It fails on the previous head, where the pass-over was per row: the ten rows did not reach the index while the row was held.

  • a_write_to_a_dropped_data_table_is_table_not_found: PutItem, GetItem, TransactWriteItems and TransactGetItems against a dropped data table are TableNotFound, naming the table.

  • delete_table_waits_for_a_claimed_queue_row_then_drops_everything: a queue row for the table is claimed by hand (the pending row locked, the base row share-locked, as a worker mid-apply holds them). DeleteTable does not finish while the claim is held, has stopped the queue and dropped nothing; once the claim is released it finishes with no data table, hold or queue row left.

  • update_item_rejects_a_wrong_typed_gsi_key: SET of a GSI key to a number on a string index is a ValidationException.

  • concurrent_writes_during_a_build_leave_the_index_equal_to_the_table: 1,200 items and 4 writers, synchronous then asynchronous. On main, writes fail with 42P01.

  • recovery_finishes_a_gsi_left_creating: missing, stale and orphan rows are put right, and the index becomes ACTIVE.

  • write_with_key_info_older_than_a_new_gsi_indexes_its_sort_key: a put carrying key info from before the GSI was added. On main, the index row has no sort key. The same test is added for SQLite in crates/storage-sqlite/src/update_table.rs, where it also fails without this change.

New crates/storage-postgres/tests/gsi_stale_writer.rs: a hidden test hook (pause_writes_for_tests) makes writes pause 3 s at a chosen point, so they write after the build has finished. It covers a new item, a key change and a delete, three times: paused between the decision to write without a transaction and the statement, so the writes stay on the autocommit path and repair after the statement (fix_up_autocommit); paused after the catalog read, so the same unconditional writes are already slow when they decide and take the transaction path; and with conditions, which take a transaction regardless. With the autocommit repair turned off by hand, the first run fails: all three items are wrong in the index.

Full PostgreSQL pytest run (tests/, -n 6) against servers built from this branch and from main: no test fails on the branch that passes on main, apart from the GSI pagination tests in test_query_scan.py that Scan an asynchronous index straight after their puts with no wait; those fail now and then on both builds under -n 6 (one in each full run, a different one each time) and pass on both when the file runs alone without -n. The failures that are common to both come from the local environment (CLI lifecycle without a connection string, import/export paths not configured). The same comparison for the full SQLite pytest run: no new failures. The Rust integration suite (tests/rust, --test-threads=1) has the same 3 failures on both.

Eight rounds of independent review of the design and the implementation, and two rounds from Lee against PostgreSQL, SQLite and the live service. Their findings are fixed here or listed under "Not closed here".

cargo fmt --all -- --check
cargo +1.97.0 clippy --all-targets -- -D warnings
cargo +1.88.0 check --workspace --locked
EXTENDDB_TEST_PG_CONNECTION_STRING=... cargo test --workspace --exclude extenddb-storage-cassandra
EXTENDDB_TEST_PG_CONNECTION_STRING=... cargo test -p extenddb-storage-postgres --test gsi_online_build --test gsi_stale_writer

Checklist

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

ADR / RFC: n/a. There is no schema migration, no wire change and no trait change.

Breaking changes

None on the wire. DescribeTable reports a GSI that is being built as CREATING instead of ACTIVE (PostgreSQL; SQLite already did, for the duration of its shorter build). While it is CREATING, Query and Scan naming it get ValidationException, and a GSI Create or a Delete of another GSI gets LimitExceededException, as with the service. On PostgreSQL, an item read, write, transaction, Query or Scan (and the batch calls) that meets a table dropped under it gets ResourceNotFoundException instead of a 500 (ValidationException for a Query or Scan naming an index).


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.

@robinnsc
robinnsc force-pushed the fix/pg-online-gsi-backfill branch 3 times, most recently from a979c6f to e9e0736 Compare October 16, 2026 08:06

@LeeroyHannigan LeeroyHannigan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed e9e0736 on PostgreSQL and SQLite against main cc981e2 and against live DynamoDB.

The new pytest fails 2 of 2 on main and passes 2 of 2 here. I also ran six writers on five hot keys (3 of 3 equal), kill -9 mid-build then restart (recovery finished it, index equal to table), two server processes on one catalog (equal, zero failed writes), and two concurrent builds on one table (both equal after the queue drained, 2 of 2). Regression sweep on the item and index suites: 302 of 304 here, 300 of 304 on main, the two gains are the new test, the two shared failures are async timing tests that fail on both. SQLite 304 of 304. fmt, clippy 1.97.0, MSRV 1.88.0, all four notices checks, workspace tests green. UpdateTable +1.01 s, write throughput 843 vs 841 per second on an async GSI, barrier stall at most 57 ms on any write.

Six items to fix, inline below.

  1. DeleteTable deadlocks with the queue worker while writes are in flight and returns 500, leaving data tables with no catalog row. 16 writer threads, DeleteTable 1 s in, 8 rounds: 6 of 8 returned InternalServerError, 19 deadlocks, 12 orphan tables. Main does the same (8 of 8, 16 orphans) through the index table instead of the base table, so this is not new, but the comment at delete_table.rs:116 says the drop order closes it and it does not for the queue path.
  2. The queue apply's FOR SHARE on the base row has no lock_timeout. One client holding a row open stalls that worker's whole partition for the duration (23 s measured against a 25 s hold).
  3. A second UpdateTable Create during a build is accepted on both backends. The service returns LimitExceededException, "Subscriber limit exceeded: Only 1 online index can be created or deleted simultaneously per table".
  4. SQLite returns 500 on Query and Scan of a GSI for most of its build window (38 of 40 reads during a 40,000 item build). The service returns ValidationException 400, "The table does not have the specified index: gsi1". Same on main SQLite. The body describes this gap as "not refused". On SQLite it is a 500.
  5. PostgreSQL accepts UpdateItem SET of a GSI key attribute to the wrong type. The service and SQLite here reject it with "Type mismatch for Index Key gsik Expected: S Actual: N IndexName: gsi1". PutItem is rejected on all three. Same on main.
  6. The body says the CREATING status is a PostgreSQL change. SQLite shows CREATING to a concurrent DescribeTable during its build too. Neither backend sends Backfilling on the CREATING index; the service sends false then true.

Minor: the recovery worker logs a removed index by UUID where the inline path logs table and index name; backoff is in-memory and silent between retries; the sweep's ctid lock has no test (removing it passes all 8 tests, 3 runs). I could not make the lock matter with the writers in this PR since write_state deletes by base key before upserting, so no writer updates an index row in place.

// while the page waited on the dropped index table: a deadlock whose
// victim could be this transaction, after the catalog rows are gone.
// Dropped first, the base table waits for the page to finish.
Self::drop_data_table(&mut data_tx, &row.table_id).await?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This order still deadlocks with the queue worker. The worker holds its claimed gsi_pending row and waits for RowShareLock on the base table (the FOR SHARE resync); this transaction holds the drop and waits on that row at the DELETE FROM gsi_pending below. With 16 writers in flight, 6 of 8 DeleteTable calls returned 500 and the catalog rows were already gone, so 12 data tables were left behind. Main deadlocks the same way through the index table. A fix is to stop the queue for the table before the drop, using the hold the claim query already honours (vector_index_holds), then wait for claimed rows, delete the pending rows and drop.

let Some(key_item) = new_item.or(old_item) else {
return Ok(());
};
crate::data::gsi_build::resync_base_key(tx, table_id, idx, base_key_schema, attr_defs, key_item)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resync_base_key takes FOR SHARE with no lock_timeout. A client holding this row open (UpdateItem, conditional PutItem, DeleteItem, TransactWriteItems) blocks this worker's partition for the whole hold: 23 s measured against a 25 s hold. The barrier has a bounded lock_timeout and retry; this path needs the same.

(table_id, index_name, index_id, index_type, key_schema, projection,
index_status, provisioned_throughput)
VALUES ($1, $2, $3, 'GSI', $4, $5, 'ACTIVE', $6)",
VALUES ($1, $2, $3, 'GSI', $4, $5, 'CREATING', $6)",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing refuses a second Create while one GSI is CREATING. I added gsi2 1 s into the gsi1 build and both ran. The service returns LimitExceededException, "Subscriber limit exceeded: Only 1 online index can be created or deleted simultaneously per table". SQLite accepts it too.

// the read's stamp lets a write that was slow to commit queue a repair
// for any GSI it did not see (`gsi_build::fix_up_stale_write`), and a fast
// write is one the backfill sees. Vector indexes still have the window.
let (indexes, vector_metas, stamp) =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UpdateItem SET of a GSI key to the wrong type is written here. gsik is S in the index schema; SET gsik = {N: 9} returned ok and the item now carries N. The service and SQLite on this branch return ValidationException "Type mismatch for Index Key gsik Expected: S Actual: N IndexName: gsi1". PutItem rejects it on all three. Same on main.

.map(|(i, c)| format!("b.{c} = ${}", i + 1))
.collect();
let lock = format!(
"SELECT ctid::text FROM {idx_table} WHERE {} FOR UPDATE",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test pins this lock. With the lock removed and the delete done by base key with NOT EXISTS in one statement, all 8 tests in gsi_online_build and gsi_stale_writer pass, 3 runs. I could not make it matter either. write_state deletes by base key before upserting, so no writer in this PR updates an index row in place. A test that does would pin it, or a comment saying the guard is for a writer path that does not exist yet.

// The same answer UpdateTable gives: an error that would recur on
// every attempt removes the index rather than rebuilding it hourly.
tracing::error!(
index_id = %index_id,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline path logs the table and index name when it removes an index; this one logs the UUID. The operator has to join it to the catalog to learn what disappeared.

);
}

/// A write that carries the key info cached before an UpdateTable added a GSI

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SQLite shows IndexStatus CREATING to a concurrent DescribeTable during its build (0.5 s on 2,000 items), so the breaking-changes note is not PostgreSQL only. Query and Scan of the index during that window return 500 (38 of 40 reads on a 40,000 item build); the service returns ValidationException 400 "The table does not have the specified index". Same on main SQLite.

@robinnsc
robinnsc force-pushed the fix/pg-online-gsi-backfill branch from e9e0736 to 13e5f30 Compare October 20, 2026 00:35
@robinnsc

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough pass, and for measuring against the service. All six are addressed in 13e5f30 (still one commit); the branch description and the design doc are updated to match.

  1. DeleteTable deadlock. You were right that the drop order only moved the cycle. Both delete paths (delete_table.rs and the deferred one in worker_store.rs) now go through drop_table_storage, which does what you suggested: a vector_index_holds row for the table (committed on its own so the claim query sees it), then DELETE FROM gsi_pending for the table, which waits out the claims already in flight, then the drops, then the hold. Your scenario (16 writers, DeleteTable 1 s in, 8 rounds): main 7 of 8 failed, 14 orphan tables, 20 deadlocks; this branch 0, 0, 0. There is a storage test for it (delete_table_under_queue_traffic_leaves_no_data_tables, three rounds). The comment that claimed the order closed it is gone.
  2. Queue FOR SHARE with no timeout. The worker's transaction now sets lock_timeout = 2s; a 55P03 rolls the claim back and the row is retried after 200 ms, logged at debug rather than error. So a 25 s hold costs that partition 2 s per attempt, not 25.
  3. Second Create during a build. Both backends refuse a GSI Create or Delete while another GSI on the table is CREATING, with the service's LimitExceeded sentence (now ONLINE_INDEX_LIMIT, shared with the two-actions-in-one-request check). Tested on both.
  4. Query/Scan on a CREATING GSI. fetch_index_info_by_table_id on both backends treats a CREATING row as not found, which the engine already answers with ValidationException "The table does not have the specified index". That is the service's response, and it replaces the 500 on SQLite.
  5. UpdateItem wrong-typed GSI key. PostgreSQL's UpdateItem now runs the same post-update index key type and emptiness validation as SQLite and TransactWriteItems. Test added.
  6. CREATING status wording. The breaking-changes note now says SQLite already showed CREATING during its build, and Backfilling is listed as not sent, under "Not closed here".

Minor items: the recovery worker's log lines now carry table and index name. The ctid lock's doc comment now says plainly that no writer in this crate updates an index row in place today, so the single-statement form would not misbehave against them, and that the guard is for a writer that does; it costs one statement on a path taken only for an item that is gone. I left it in rather than remove a guard whose absence a future writer would discover the hard way. The backoff stays in-memory; it is per process and resets on restart, which for a 60 s to 1 h schedule seems acceptable, but say if you'd rather have it in the catalog.

Comment thread crates/storage-sqlite/src/update_table.rs Fixed
@robinnsc

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough pass, and for measuring against the service. All six are addressed in 13e5f30 (still one commit); the branch description and the design doc are updated to match.

  1. DeleteTable deadlock. You were right that the drop order only moved the cycle. Both delete paths (delete_table.rs and the deferred one in worker_store.rs) now go through drop_table_storage, which does what you suggested: a vector_index_holds row for the table (committed on its own so the claim query sees it), then DELETE FROM gsi_pending for the table, which waits out the claims already in flight, then the drops, then the hold. Your scenario (16 writers, DeleteTable 1 s in, 8 rounds): main 7 of 8 failed, 14 orphan tables, 20 deadlocks; this branch 0, 0, 0. There is a storage test for it (delete_table_under_queue_traffic_leaves_no_data_tables, three rounds). The comment that claimed the order closed it is gone.
  2. Queue FOR SHARE with no timeout. The worker's transaction now sets lock_timeout = 2s; a 55P03 rolls the claim back and the row is retried after 200 ms, logged at debug rather than error. So a 25 s hold costs that partition 2 s per attempt, not 25.
  3. Second Create during a build. Both backends refuse a GSI Create or Delete while another GSI on the table is CREATING, with the service's LimitExceeded sentence (now ONLINE_INDEX_LIMIT, shared with the two-actions-in-one-request check). Tested on both.
  4. Query/Scan on a CREATING GSI. fetch_index_info_by_table_id on both backends treats a CREATING row as not found, which the engine already answers with ValidationException "The table does not have the specified index". That is the service's response, and it replaces the 500 on SQLite.
  5. UpdateItem wrong-typed GSI key. PostgreSQL's UpdateItem now runs the same post-update index key type and emptiness validation as SQLite and TransactWriteItems. Test added.
  6. CREATING status wording. The breaking-changes note now says SQLite already showed CREATING during its build, and Backfilling is listed as not sent, under "Not closed here".

Minor items: the recovery worker's log lines now carry table and index name. The ctid lock's doc comment now says plainly that no writer in this crate updates an index row in place today, so the single-statement form would not misbehave against them, and that the guard is for a writer that does; it costs one statement on a path taken only for an item that is gone. I left it in rather than remove a guard whose absence a future writer would discover the hard way. The backoff stays in-memory; it is per process and resets on restart, which for a 60 s to 1 h schedule seems acceptable, but say if you'd rather have it in the catalog.

@robinnsc
robinnsc force-pushed the fix/pg-online-gsi-backfill branch 3 times, most recently from 542b6d9 to 8c31762 Compare October 20, 2026 03:57

@LeeroyHannigan LeeroyHannigan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-ran the six items on PostgreSQL and SQLite against e9e0736 and against the live service. The DeleteTable deadlock is gone (16 writers, DeleteTable 1 s in, 8 rounds: 200 every time, 0 deadlocks, 0 orphans; e9e0736 failed 8 of 8). Reads of a CREATING index are a 400 on SQLite now (0 of 217 were 500; e9e0736 211 of 221). UpdateItem refuses a wrong-typed or empty index key with the service's text on every shape I sent, and the item stays as it was. The body wording is in. Index equals table on every run, including two servers on one catalog through a 118 s build and a pool of 8 under 16 writers. Throughput and build time match e9e0736.

Three items from the last round are not finished, and the body says they are.

  1. Item 2. The lock_timeout is there and the 55P03 is handled, but after each timeout the worker claims the same row again (ORDER BY id), so the partition still waits for the whole hold. Held one row for 30 s: ten keys in the same partition reached the index at 29.9 s on this branch and at 29.9 s on e9e0736, with 13 retry lines 2.2 s apart. The body says the stall is two seconds per claim instead of the hold. Either skip the held id for the next claim, or say in the body that the worker releases its claim every 2 s and the partition still waits. Inline on gsi_queue.rs.

  2. Item 3. The service refuses a Delete of another GSI during a build with the same LimitExceededException it uses for a second Create; I measured it in both of its CREATING phases. This branch accepts the Delete on both backends and drops the index while the other keeps building. Your comment says Create or Delete are refused and the Breaking changes paragraph says a further GSI change gets LimitExceededException; the code gates on Create only. One line per backend. Inline on update_table.rs.

  3. Item 3 on SQLite. The check runs after write_lock.lock(), and the build holds that lock until the row is ACTIVE, so a second Create sent during the build waits for it and then succeeds (200 after 6.5 s; the service refuses in under 10 ms). It can only ever see a crash-left CREATING row, and the startup reconciler clears that before the server answers health. The code comment says this; the body says both backends. Either read indexes before taking the lock, or state it under Not closed here. Inline on the SQLite update_table.rs.

Two things I found on the DeleteTable path that the last round should have caught, since it asked you to rewrite that path. I am not making either a condition on this PR.

  • A PutItem that races a DeleteTable answers 500 (relation "_ddb_..." does not exist), about 7 per round of 16 writers. The pytest writers use botocore's default one retry, which lands after the catalog row is gone and gets a 400, so the run cannot see it. Mapping 42P01 on the data tables to ResourceNotFoundException, as the queue worker already does, closes it. Take it if you want it in this PR.
  • A DeleteTable interrupted between the catalog commit and the storage drop (client timeout or a kill while the drop waits behind an open client transaction) leaves both data tables with no catalog row and nothing to sweep them. The hold is cleared at 10 minutes; the tables are not. This is on main too. I'll file it as its own issue with the reproduction.

Once 1 to 3 are in and the body matches the code, this merges, and I will not add to the list on the next revision.

// about to resync. The claim rolled back and the row waits; the
// pause keeps this from spinning on the same held row.
tracing::debug!("GSI worker {worker_id}: row held by a writer, retrying: {e}");
tokio::time::sleep(LOCK_RETRY_PAUSE).await;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the pause the next claim runs ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED. The timed-out row is unlocked by the rollback and is still the lowest due id in the partition, so it is picked again, and the rows behind it wait. I held one base row for 30 s with eleven same-partition rows queued: the ten behind it reached the index at 29.9 s, as they did on e9e0736, with 13 of these debug lines 2.2 s apart. Rows in other partitions took 1 to 2 s. Remembering the timed-out id and claiming id <> $held on the next pass would let the partition move; or the body says the partition still waits behind the held row.

// index DDL builds its columns from the same set the catalog now holds.
let mut merged_attr_defs_for_ddl: Option<Vec<AttributeDefinition>> = None;
if let Some(updates) = &input.global_secondary_index_updates {
if updates.iter().any(|u| u.create.is_some()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The service refuses a Delete of another GSI here too. Measured with gsi1 CREATING and gsi0 ACTIVE: UpdateTable Delete gsi0 is 400 LimitExceededException Subscriber limit exceeded: Only 1 online index can be created or deleted simultaneously per table, in both of the service's CREATING phases. This branch returns 200 and gsi0 is gone 2 ms later while gsi1 keeps building. The Delete of the CREATING index itself is a different case: the service answers ResourceInUseException during allocation and 200 during backfill, so that one can stay as it is.

// columns from the same set the catalog now holds.
let mut merged_attr_defs_for_ddl: Option<Vec<AttributeDefinition>> = None;
if let Some(updates) = &input.global_secondary_index_updates {
if updates.iter().any(|u| u.create.is_some()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs after write_lock.lock() at line 67, and the build holds that lock until the row is ACTIVE, so a concurrent request never sees CREATING here. Create gsi2 sent 100 ms into gsi1's build, with DescribeTable at 50 ms showing CREATING: 200 after 6,461 ms, both indexes ACTIVE; e9e0736 gave 200 after 6,781 ms; the service 400 in under 10 ms. The only CREATING row this can see is one a crash left, and the startup reconciler rebuilds that before the server answers health. The comment above says so; the body says both backends.

@robinnsc

Copy link
Copy Markdown
Collaborator Author

You're right on all three. The last reply described what I intended rather than what the code did, and the body repeated it. a52d62b (one commit, rebased onto ba53851) fixes them, and before writing this I checked the body against the code sentence by sentence.

  1. The queue worker re-claimed the held row. After a lock timeout the worker now rolls the claim back and passes that row over for 2 s. The pass-over list is in memory per worker and applies to both the claim query and next_ready_wait, so the rows behind it in the partition apply in the meantime. The held row is tried again when its pass-over ends, which works out to about every 4 s (2 s lock wait plus 2 s parked, up to 5 s on an otherwise idle partition), and it applies once the hold ends. With one queued row for the held item, the partition is stalled about half the time instead of for the whole hold. Each further queued row for that item is passed over on its own and adds up to 2 s per cycle; the body now says this. Passing a row over is safe because the apply writes the item's state at apply time, and the process_batch doc no longer claims strict id order. New test: a_held_base_row_does_not_hold_up_its_queue_partition. It holds one base row for 10 s with ten rows for the same partition key behind its update, and requires all ten in the index within 6 s while the held item hasn't moved. It fails on 8c31762.

  2. Delete of another GSI during a build. Both backends now refuse a GSI Create, or a Delete of any GSI other than the one building, with ONLINE_INDEX_LIMIT. The predicate is shared (building_refuses in extenddb_storage::util, with unit tests). A Delete of the building index still goes to the delete path, which accepts it in both phases, as you said it could stay; the body states that rather than implying parity with the service. The PostgreSQL test checks that the Delete of another GSI is refused and leaves that index in place, and that the Delete of the building one isn't refused this way.

  3. SQLite check under the write lock. The check now also runs before write_lock is taken, using a WAL read joined on tables. The SQLite test holds write_lock over a planted CREATING row and requires both refusals within 500 ms. One limit remains: the pre-lock read only sees a committed CREATING row. A request that arrives while a build holds the lock but is still in its catalog transaction is not seen; it waits for that build and then succeeds, as before. The window is now the catalog transaction rather than the whole backfill. The body says this, both in the update_table bullet and under Not closed here, and notes that the test shows the refusal doesn't wait on the lock but doesn't close that window.

I took the 42P01 suggestion. data_engine.rs maps PostgreSQL's undefined_table on a data table to TableNotFound (ResourceNotFoundException) for PutItem, GetItem, DeleteItem, UpdateItem, Query, Scan, TransactWriteItems and TransactGetItems; BatchWriteItem and BatchGetItem go through the first three. The match covers the SQLSTATE and also the relation "_ddb_…" does not exist text, because several write paths render the error with to_string(), which drops the code. For a transaction, the table named is the one whose data table is missing. A Query or Scan naming an index gets IndexNotFound, and that includes an index read racing DeleteTable, since the drop takes the index tables too; the body lists this under Not closed here as a ValidationException where the service gives ResourceNotFoundException. With your scenario and SDK retries off (16 writers, DeleteTable 1 s in, 8 rounds), current main gave 34 writer 500s and 5 of 8 DeleteTable failures; 8c31762 gave 23 writer 500s and 0 failures; this commit gives 0 and 0, with no orphans. The interrupted-DeleteTable orphan case is under Not closed here, pointing at the issue you're filing.

Also fixed in the body: a sentence garbled by an earlier edit; "on main every test fails", which can't be literally true because the test file doesn't compile on main; and the client-disconnect note, which applies to PostgreSQL only.

Checks on a52d62b: fmt, clippy 1.97.0 for default and sqlite features, MSRV 1.88.0, and workspace tests are clean. The 12 PostgreSQL storage tests in gsi_online_build and the SQLite unit tests pass. Full pytest compared against ba53851 shows no new failures on PostgreSQL or SQLite; the only difference is the new online-backfill test, which fails on main. CI is green.

@robinnsc
robinnsc force-pushed the fix/pg-online-gsi-backfill branch from a52d62b to 7f29a19 Compare October 23, 2026 08:17
…s continue

Adding a GSI to a PostgreSQL table that was taking writes left the index
with missing, extra and stale rows, or failed UpdateTable with a duplicate
key. The index row was committed ACTIVE before its data table existed, the
backfill paged with OFFSET under concurrent writes, and queue rows applied
the image they carried, so a late row overwrote newer state.

UpdateTable now creates the data table first, publishes the index
CREATING, waits 1 s, takes a brief SHARE lock on the base table to wait out
in-flight writes, backfills in keyset pages under FOR SHARE SKIP LOCKED,
sweeps orphan rows, and flips the index ACTIVE. Writes maintain a CREATING
index like an ACTIVE one. The synchronous write path, the queue and the
backfill all write an item's current state, read under its row lock. A
write that took more than 500 ms from its catalog read re-reads the catalog
and queues a repair for any GSI it did not know about; that and the wait
cover writes that read the catalog just before the index was created, which
no transaction can see because the catalog and the data are separate
databases. A build that fails for a transient reason stays CREATING and a
background worker finishes it; one that would fail every time removes the
index.

On both PostgreSQL and SQLite the write path takes index attribute
definitions from its own catalog read instead of the cached key info, which
predated a new GSI's key attributes until UpdateTable returned, so a GSI
with a sort key on a new attribute got rows without that sort key.

While a GSI is CREATING, Query and Scan naming it answer as the service
does (no such index) rather than reading a partial table, and a second
GSI Create, or a Delete of another GSI, on the table is refused with
LimitExceededException; on SQLite that check also runs before the engine
write lock, which a build holds until it publishes. DeleteTable stops the
propagation queue for the table before dropping anything, which removes a
deadlock with the queue worker that left orphan data tables, and a request
(item call, transaction, Query or Scan) that meets a data table dropped
under it gets ResourceNotFoundException instead of a 500. The worker bounds
its wait on a held base row to 2 s and passes the item over for 2 s, every
queued row for it included, so the rest of its partition keeps moving.
UpdateItem on PostgreSQL validates GSI key types on the updated item as
PutItem does.

The new pytest is xfail on MongoDB (concurrent creates of one item return
HTTP 500 there regardless of indexes) and skipped on Cassandra (contended
deletes return HTTP 500 and the build did not finish in CI).

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
@robinnsc
robinnsc force-pushed the fix/pg-online-gsi-backfill branch from 7f29a19 to f721d74 Compare October 23, 2026 10:26

@LeeroyHannigan LeeroyHannigan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-ran the three items on PostgreSQL and SQLite against f721d74.

  1. Held one base row for 30 s with ten same-partition keys queued behind it: they reached the index at 3.6 s (29.9 s on 8c31762), other partitions at 1.7 to 2.0 s, the held item at 30.3 s once released, retries 4.0 s apart. Three writes to the held item during the hold left one index row with the last value. A 60 s hold changed only the retry count. The new test passes here and fails on 8c31762 at the assertion you describe.

  2. With gsi1 CREATING: Create gsi2 and Delete gsi0 are 400 LimitExceededException with the service's sentence in both halves of the build on both backends, gsi0 is still there afterwards, Delete gsi1 is accepted, a table throughput Update is not refused. Two Creates released at the same instant on PostgreSQL: one 200 and one refusal, 20 of 20.

  3. SQLite refuses in 1 to 5 ms against builds of 6.4 and 8.5 s (6,461 ms wait on 8c31762). The window you describe measured under 5 ms for a request sent after the build request; two requests released at the same instant on a 2,000-item table met it in 16 of 20 trials, with the wait-then-succeed outcome the body gives.

Body lines 18, 19, 61 and 125 match the code. fmt, clippy 1.97.0, the 1.88 check and 1,696 unit tests pass here; CI is green; the merge with main is clean.

@LeeroyHannigan LeeroyHannigan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-ran the three items on PostgreSQL and SQLite against f721d74, plus the 42P01 mapping, and read the body against the code.

Item 1. One base row held FOR UPDATE for 30 s, ten rows for other keys in the same partition queued behind its update, propagation delay 3 s. The ten reached the index at 4.8 to 4.9 s. On e9e0736 they reached it at 30.1 to 30.2 s, after the hold ended. The held row was retried 7 times at 4.0 to 4.05 s intervals and applied 0.8 s after the hold was released.

Item 2. With gsi1 CREATING, Create gsi2 and Delete gsi0 are 400 LimitExceededException with the service's sentence on both backends (PostgreSQL 46 ms and 3 ms, SQLite 6 ms and 5 to 93 ms over three runs), and gsi0 stays ACTIVE. Delete gsi1 went to the delete path: 200 in 9 ms on PostgreSQL while the build was in flight, 200 on SQLite once the build released the write lock.

Item 3. The SQLite refusals above landed 5 to 93 ms into builds that ran 1.0 to 3.0 s, so the check does not wait on the write lock. The catalog-transaction window is in the body, in the update_table bullet and under Not closed here.

42P01. 16 writers, DeleteTable 1 s in, 8 rounds, SDK retries off: 7,881 PutItem calls, 2,877 of them 200, 5,004 ResourceNotFoundException, 0 of them 500; DeleteTable 200 in 8 of 8; 0 orphan data tables. The same run on e9e0736 gave 71 writer 500s, with relation "_ddb_..." does not exist in the server log for each.

I read the body sentence by sentence against f721d74 and found nothing the code does not do. The throughput and lag numbers and the statement counts under Cost are yours; I did not re-measure them.

fmt, clippy 1.97.0, the 13 storage tests in gsi_online_build and gsi_stale_writer, and the new pytest on both backends pass here.

@robinnsc
robinnsc added this pull request to the merge queue Oct 26, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Oct 26, 2026
@robinnsc
robinnsc added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit abf8082 Oct 26, 2026
34 checks passed
@robinnsc
robinnsc deleted the fix/pg-online-gsi-backfill branch October 26, 2026 03:56
LeeroyHannigan added a commit that referenced this pull request Oct 26, 2026
Five behaviours at the 409,600-byte item limit, each measured against Amazon
DynamoDB (us-east-1, 2026-10-19) by an accept/refuse pair either side of the
limit, and each wrong on main.

Number byte cost. A number costs 1 + ceil(integer significant digits / 2) +
ceil(fraction significant digits / 2), plus one for a sign. The two halves are
rounded up separately. main counted all significant digits together, so every
number with a fraction was one byte short: 1.5 costs 3 (main 2), 3.14159 costs
5 (main 4), 100.5 costs 4 (main 3). Sixteen measured literals are pinned in a
unit test.

Item limit on updates. The PostgreSQL and SQLite storage factories hardcoded
400_000 as the bound on the finished item of an UpdateItem or a transacted
Update, while the engine applied the configured 409,600 to puts. The
configured limit now reaches those two factories through
ServerComponentsOptions, so on PostgreSQL and SQLite every write path enforces
the one configured figure. The Cassandra and MongoDB factories do not read the
option; Cassandra keeps its fixed 409,600 on the finished item and MongoDB
applies no limit on updates, as before.

UpdateItem is sized by the statement. The service sizes a standalone UpdateItem
by the statement rather than the finished item: a base of 3; 18 per SET or ADD
clause and nothing per REMOVE or DELETE; a skeleton item holding only the
written paths (each map entry its name plus one, each map or list 3, each list
element one more, untouched siblings left out), where a SET or ADD leaf costs
the clause's value placeholders and a REMOVE or DELETE leaf costs nothing; one
more for a clause through a list index; and, inside a SET operand, 26 plus the
name length per function call, 34 per plus, 37 per minus, and for a document
path operand 15 plus the name for its first element, 14 plus the name for a
later attribute and 15 for an index, with the attribute's stored value not
charged. The legacy AttributeUpdates form costs 3 plus the name and finished
value of each attribute it names that remains. The key and untouched attributes
are left out, so at a one-byte key a plain SET can produce a finished item of
at most 409,581 bytes, and a long key or an untouched attribute buys the
headroom back up to 409,600, which the finished item never exceeds. An
attribute reached through an alias is charged as the attribute. A transacted
Update is sized on the finished item alone: exactly 409,600 is stored and one
byte over is a cancellation. Both update paths word the refusal "Item size to
update has exceeded the maximum allowed size"; main used the put wording.
validate_update_item_size and validate_transact_update_item_size in core carry
the rules, called from the PostgreSQL and SQLite update paths; the engine marks
a desugared AttributeUpdates request on the expression maps so storage sizes
it as the legacy form.

Empty binary index key on UpdateItem (PostgreSQL). UpdateItem setting a GSI
key to an empty binary was written on PostgreSQL; SQLite and the service
refuse it with "The update expression attempted to update a secondary index
key to a value that is not supported. The AttributeValue for a key attribute
cannot contain an empty binary value." #401 has since added those index key
checks to the PostgreSQL standalone path, with the index attribute definitions
from the write's own catalog read; this change keeps that version and adds no
second copy.

tests/test_item_size_at_limit.py carries 58 dual-target cases, 32 of them the
largest accepted padding for one statement shape each (plain and multi-clause
SET, same-map clauses, nested paths, list indexes with siblings, if_not_exists,
list_append, nested functions, addition, subtraction, ADD, REMOVE, DELETE and
six legacy AttributeUpdates forms). 58 of 58 pass against the service and
against both backends with this change; 49 fail on main (PostgreSQL) and 48
(SQLite). The statement sizer also carries a direct unit test in core
(validation::update_size_tests): eight cases pinning the byte figure for the
common shapes, the over-limit refusal and its wording, the finished-item
refusal, and the legacy form, so the arithmetic runs under cargo test without a
server. docs/differences-from-dynamodb.md holds the sizing rules in one place
(item bytes, number cost, the statement rule) and records the two regions
(eu-south-2, me-central-1) that size an UpdateItem on the finished item instead;
docs/dynamodb-limits.md states the limit and points there.

The update-specific cases run on PostgreSQL and SQLite and against live
DynamoDB; they are skipped on Cassandra and MongoDB, whose storage layers
size updates differently as recorded in docs/differences-from-dynamodb.md.
devtools/run-tests exports EXTENDDB_TEST_BACKEND so the file can gate those
classes. Number sizing runs on every backend.
LeeroyHannigan added a commit that referenced this pull request Oct 26, 2026
…es the way the service does

On the Cassandra backend, UpdateItem and the Put and Update members of
TransactWriteItems did not check secondary-index key attributes on the item
they were about to write. An UpdateExpression that set a GSI or LSI key to an
empty string, an empty binary, or a value of another type was accepted with
HTTP 200 and stored, leaving an item whose index attribute no longer matched
the index's declared type; later writes to that item failed inside the index
row write with HTTP 500. Fixes #410.

Measuring the fix against the service showed that the other backends, which
did check the written item, did so in a different order and with different
precedence from the service, so the measured rules are now applied on every
backend from shared code. Every rule below was measured verbatim on live
DynamoDB; the probe scripts and their recorded output live under conf-run in
the working tree, not in the repository.

UpdateItem:

- A literal operand assigned to a secondary-index key (SET k = :v, ADD k :v,
  DELETE k :v, list_append of literals) is checked on the statement, ahead of
  the ConditionExpression and ahead of applying any action, so it is reported
  even when the condition would fail or another action could not be applied.
  Wording: "Type mismatch for Index Key <attr> Expected: <T> Actual: <U>
  IndexName: <index>". ADD of a number onto a number key passes; ADD or DELETE
  of a set, or ADD of a number onto a string or binary key, is a mismatch.
- A value computed from the existing item (an attribute reference, the
  fallback of if_not_exists when the attribute is absent) is checked after the
  condition passes and the statement is applied, with the wording "The update
  expression attempted to update the secondary index key to unsupported type".
  if_not_exists(k, :bad) with k present is accepted, since nothing bad is
  written.
- A key the statement leaves empty is refused after the condition passes, with
  the update-expression wording.
- A failed condition is reported ahead of a statement that cannot be applied.

TransactWriteItems:

- One member: a wrong-typed index key is a positional ValidationError
  cancellation reason; an empty index key is a top-level ValidationException
  with no CancellationReasons. On a Put member both checks run ahead of the
  member's condition. On an Update member the literal type check runs ahead of
  the condition, and the computed-value and empty checks after it. Within one
  statement the type mismatch wins.
- Across members: a wrong-typed key on any member, table key or secondary-index
  key, cancels the transaction with the first such member's ValidationError in
  position and None in every other position, whatever those members' own
  faults were. Otherwise an empty index key on any member is the top-level
  ValidationException, outranking failed conditions and unapplicable
  statements elsewhere. An empty index key on a member whose own condition
  fails is reported as that member's ConditionalCheckFailed.

Changes:

- core: validate_index_key_literal_operands (the statement-level pass),
  validate_index_key_types_after_update (the computed-value wording), and on
  CancellationReason two internal constructors plus resolve_key_faults, which
  applies the cross-member precedence and decides between a cancellation and a
  top-level ValidationException. The internal codes never reach a client.
- engine: UpdateItem runs the literal pass before dispatching to storage, so
  every backend reports it in the same position.
- storage-cassandra: update_item.rs checks the written item after apply;
  transactions.rs gains compute_transact_update, used by the first PREPARE
  pass and by the create-race retry so the two cannot drift, runs both checks
  ahead of the condition on a Put member, tags table-key failures in all four
  arms, and resolves the reasons after rollback. index_key_refs replaces the
  put path's inline copy.
- storage-sqlite, storage-postgres and storage-mongodb: every member's
  key checks (table key, Put item index keys, Update literal operands) run in
  a pre-pass before any row is read or locked; a fault there decides the
  request under the precedence without touching a row, so a request that is
  already decided never waits on a row another transaction holds (the
  contract crates/storage-postgres/tests/twi_conflict.rs pins). The Update
  arms then run the computed-value check after apply; the per-op Validation
  variant that aborted on the first empty key is gone, and the reasons are
  resolved after every member has run. Standalone UpdateItem on all three now
  reports a computed value with the service's wording.
- docs: the rules and the two known shared differences, in the Secondary
  Indexes table.

Depends on #409 (fix/item-size-parity); the branch is rebased on it. The
PostgreSQL standalone UpdateItem index-key checks this change reuses came to
main with #401. On PostgreSQL and SQLite the storage-side index key checks,
the transaction pre-pass included, take the index attribute definitions from
the write's own catalog read, as #401 does for the rest of the write path.
#401's storage-level test update_item_rejects_a_wrong_typed_gsi_key calls the
storage engine past the engine's statement pass, so it now expects the wording
of the check on the written item.

tests/test_index_key_validation_on_update.py adds 64 dual-target cases: each
path, each fault, each index kind, the positional reason, the no-write
guarantee, the ordering against a condition on Put and Update members, the
cross-member precedence including table keys, if_not_exists, and the literal
versus computed split. They pass against live DynamoDB and against servers
built from this commit on Cassandra, SQLite, PostgreSQL and MongoDB, with no
backend-specific skips. On main 26 of the first 31 fail on Cassandra. Workspace lib tests (1,232), fmt, clippy on 1.97.0
with -D warnings, and the 1.88 MSRV build are clean.

Measured and left as they are, all shared across backends and pre-existing:
an empty index key through the legacy AttributeUpdates parameter uses the
update-expression wording where the service names the index; a request whose
own value exceeds 400 KB is refused for size by the service before any
index-key check; a transacted Update whose Key has the wrong type answers the
Type mismatch wording where the service says the key element does not match
the schema; the UpdateItem reserved-keyword message lacks the service's
"1 validation error detected: " prefix; and the service's position of the
literal-empty check relative to the condition varied across tables when
measured repeatedly (one table of five answered empty-first), so the two tests
pinning empty-after-condition can disagree with live DynamoDB on a table that
lands the other way.
LeeroyHannigan added a commit that referenced this pull request Oct 26, 2026
…indexes

Since Terraform AWS provider 6.13.0 the aws_dynamodb_table resource waits
for WarmThroughput.Status to read ACTIVE after every table and index
create or update and treats a description without the member as not
found, so an apply against ExtendDB polled until it timed out (#462).

ExtendDB now accepts WarmThroughput on CreateTable, UpdateTable and
GlobalSecondaryIndexUpdates (Create and Update), stores what the client
sent, and reports the member with the shape, defaults, lifecycle and
validation measured on Amazon DynamoDB on 2026-10-23:

* Absent while the table or index is CREATING unless the request named
  it (then echoed as sent with Status UPDATING); 12000 read and 4000
  write units on an on-demand table and its indexes, the provisioned
  units on a provisioned table and index; present with Status on every
  CreateTable, DescribeTable, UpdateTable and DeleteTable description.
* An update reports the previous values with Status UPDATING for the
  control plane delay, then the new values ACTIVE; a decrease is
  validated against the reported values. A switch to PAY_PER_REQUEST
  raises a stored value to the on-demand minimum. A capacity change on a
  never-set provisioned table moves the reported default with the units.
* Refusals carry the service's sentences: below the on-demand minimum,
  below the provisioned units, below the current value, each naming
  "table" or "index <name>"; one action per GlobalSecondaryIndexUpdate
  element; one update per index; on an on-demand table an index Update
  may only carry OnDemandThroughput or WarmThroughput.
* UpdateTable with a table-level WarmThroughput refuses companions in
  the measured order: Server-Side Encryption, TableClass,
  DeletionProtection (each with its own sentence), OnDemandThroughput
  (needs BillingMode), a GlobalSecondaryIndexUpdates Delete, then
  BillingMode, ProvisionedThroughput, StreamSpecification or vector index
  updates ("WarmThroughput must be the only operation in the request").
  GlobalSecondaryIndexUpdates Update beside it is accepted. A request
  carrying only WarmThroughput is accepted. SSESpecification is now read
  on UpdateTableInput so it can be refused rather than dropped.
* A restored table starts with the defaults; a GlobalSecondaryIndexOverride
  carrying WarmThroughput is refused with the service's sentence.

Nothing acts on the values. There is no pre-warming and no scaling at the
database level, and the stored numbers have no effect on throughput,
throttling or storage; they exist so clients that read the member work.
The account maximum the service enforces is not enforced here.

Storage: one nullable warm_throughput column on tables and indexes on
SQLite (schema plus shape convergence), PostgreSQL (001_schema.sql plus
shape steps for existing catalogs) and Cassandra (migration V006, catalog
version 0.0.5 so extenddb migrate applies it); a document field on
MongoDB. Each backend stores one JSON record (the client's sides, the
previous sides, and the UPDATING deadline); the wire member is derived
in one place, TableDescription::prepare_for_response.

On PostgreSQL, UpdateTable builds a new GSI before it answers (#401), which
takes longer than the 0.25 s UPDATING window the index row is inserted
with. The window of each GSI the request created with WarmThroughput
starts again once the build has run, so the response reports it UPDATING
as the service does.

Tests: unit tests for the record, defaults and every rule; three
dual-target wire files (lifecycle and validation, update shapes and
billing changes, backup and restore) covering every measured row; a
Terraform apply of the configuration from the provider issue plus a
table with a GSI on every backend, with Terraform 1.17.0 and provider
6.70.0 pinned and checksum-asserted in CI; a Cassandra test for a
catalog that has not run V006.

Measured on live DynamoDB: the three wire files pass against the service
(12, 13 and 1 tests) and against SQLite, PostgreSQL, MongoDB and
Cassandra. Cassandra lib (61) and direct_integration (151) suites pass.
Negative controls: with the member removed the Terraform apply fails
with the provider's "couldn't find resource (21 retries)" after about
three and a half minutes, which is the hang from the issue; with the
validation removed the three refusal rows fail; five further built
mutants (table member, index member, UPDATING deadline, only-operation
rule, swapped defaults) each fail the wire file.

Documented in docs/differences-from-dynamodb.md (Capacity and Throttling)
and docs/getting-started.md.

Closes #462.
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.

3 participants