Repository navigation
fix(storage-postgres): return UpdateTable before the GSI backfill finishes - #708
Open
LeeroyHannigan wants to merge 1 commit into
Open
LeeroyHannigan wants to merge 1 commit into
LeeroyHannigan wants to merge 1 commit into
Conversation
…ishes The service answers UpdateTable at once with a new GSI CREATING and backfills in the background. On PostgreSQL, UpdateTable with a GSI Create spawned the index build and awaited it inside the request, so the response arrived only after every existing item was indexed, and a 20,000-item table exceeded boto3's default 60 s read timeout. The build task is now detached. UpdateTable returns once the CREATING index row is committed, and the task waits 1 s, takes a write barrier, backfills, and flips the row to ACTIVE. Writes already maintain a CREATING index, and the recovery worker already finishes a build that a restart left behind. A build that fails for a transient reason leaves the index CREATING for the recovery worker; one that would fail on every attempt is logged and its index removed. One server process runs at most GSI_BUILD_SLOTS (4) GSI builds at once, and the recovery worker's builds count toward them. UpdateTable never waits for a slot. A build that finds none waits in its task with no database session open, takes its ownership lock when a slot comes, and then builds only if the index is still CREATING, since the recovery worker of this or another server may have built it in the meantime. Without the limit, in a storage-level test, 30 UpdateTable calls in a row on 30 tables, each build held after its first page, raised the database's sessions from 11 to 42. A CREATING index answers as the service does in its backfilling phase. Query and Scan get ValidationException "Cannot read from backfilling global secondary index: <name>" where they got "The table does not have the specified index". DeleteTable gets ResourceInUseException "Attempt to change a resource which is still in use: Cannot delete table while indexes are being created, updated, or deleted.", also on a table with deletion protection on; before, it deleted an unprotected table under the build. On the service, measured on 30 October 2026, Backfilling turned true 16 to 39 s after UpdateTable, and the index went ACTIVE about 16 minutes after the call on 2,000-item tables. The new tests gsi_async_build, gsi_build_task and gsi_build_slots hold a table's backfill through a test-hooks hook and cover the response during the build, writes made during it, a restart partway through, both failure outcomes, the ownership lock that keeps the recovery sweep off a running build, DeleteTable on a protected table, the build limit, and a waiting build whose index another server's recovery sweep published. They are added to the storage-level PostgreSQL step of the integration workflow. Storage tests and two wire tests that used an index right after UpdateTable now wait for it to be ACTIVE. The design doc, the gsi_build module doc and differences-from-dynamodb.md describe the detached build and the differences from the service that remain.
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn,
robinnsc and
yesyayen
as code owners
October 31, 2026 22:54
This was referenced Oct 31, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
ExtendDB's backfill starts after a 1 s wait and a write barrier, so it has no stage that corresponds to the allocation phase. A CREATING index on PostgreSQL now answers as the service does in the backfilling phase:
GSI_BUILD_SLOTSincrates/storage-postgres/src/data/gsi_build.rs), and the recovery worker's builds count toward the four. UpdateTable never waits for a slot. A build that finds none waits in its task with no database session open and its index CREATING, and slots go to waiting builds in the order they asked. Without the limit, a client that added indexes to one table after another started every build at once, each with a database session of its own for its ownership lock. In a storage-level test against a local PostgreSQL 16 container, 30 UpdateTable calls in a row on 30 tables of 300 items, with each build held after its first page, left 42 sessions on the database 3 s after the last call (11 before) and 30 ownership locks held. With the limit, the same 30 calls left 16 sessions and 4 ownership locks at that point, the most seen at any sample was 18 sessions, and all 30 indexes were ACTIVE 12.8 s after the builds were released.Differences that remain, with the service's measured answers:
Backfillingturns true, 16 to 39 s after the call, then ACTIVE.Backfillingmember. The service reportsBackfillingfalse in the UpdateTable response and until that point, then true until the index is ACTIVE.Backfillingturns true, the service still answers a Query with "The table does not have the specified index" (0 and 2 s after, on one table), and later with the backfilling text (about 45 s after). ExtendDB answers the backfilling text for as long as the index is CREATING.The same four are rows in
docs/differences-from-dynamodb.md.These answers are unchanged and match the service's backfilling phase: a second index Create gets LimitExceededException, a Delete of the creating index is accepted, and PutItem and stream changes are accepted. A switch to PROVISIONED billing with table throughput only is accepted. The service was sent that switch with a GSI
Updatecarrying the index's throughput as well, and accepted it; ExtendDB was compared without the GSIUpdate, which PostgreSQL refuses (see "Not changed"). ExtendDB also answers LimitExceededException to a Delete of a different index; on the service only a Delete of an index that did not exist was measured, and it got LimitExceededException.Restart and failure handling uses the existing recovery worker and build ownership lock. When a build slot is free, UpdateTable takes the build's ownership lock (a session advisory lock on its own connection) before the catalog commit, and the build task holds it for its lifetime. A build that waits for a slot takes the lock when the slot comes. Until then its CREATING row has no owner, and the recovery worker on this or another server may take the index and build it instead. Once it holds the lock, the waiting build reads the index row again and goes ahead only if the index is still CREATING. If the server stops during a build, the lock is released with the connection, and the recovery worker, which runs a pass at startup and then every 60 s, takes ownership of each CREATING GSI whose lock is free and runs the build again over the partial index table. The backfill upserts, and the orphan sweep removes leftovers. A transient error leaves the index CREATING for the recovery worker. A deterministic error removes the index, as the recovery worker already did, so DescribeTable shows it gone and a later UpdateTable can create it again. The caller no longer receives that error in the UpdateTable response. The service answers UpdateTable before its backfill starts, so it cannot return a backfill failure in that response either.
I chose this over handing every build to the recovery worker, which would start a build up to 60 s late, and over a new persisted failed state, which the service does not report and which would need a catalog migration.
docs/design/04-component-storage.mdsection 6.2 and the module doc ofdata/gsi_build.rsnow describe the build task.Not changed
UpdateTable with a GSI
Updateaction is refused on PostgreSQL with ValidationException "UpdateGlobalSecondaryIndex is not yet supported", whether the index is CREATING or ACTIVE, as before this PR. The service accepted that action in the backfilling phase.UpdateTable holds a pool connection for its catalog transaction while it takes a second one to create a new GSI's data table, so on a server whose catalog and data share one pool, as many concurrent GSI Creates as the pool has connections can each hold one and time out waiting for another; this PR does not change that.
This PR does not change SQLite. SQLite builds the index inside UpdateTable and answers with it ACTIVE. On a 20,000-item table the call took 0.62 s and 0.67 s in two runs of a local release build. Its response reports the index ACTIVE where the service reports CREATING.
Overlap with open pull requests
#690 also adds test binaries to the PostgreSQL storage-level step of
.github/workflows/integration.ymland test hooks tocrates/storage-postgres/src/lib.rs. The two branches conflict in those two places only. Whichever merges second keeps both sides.Why
On PostgreSQL, UpdateTable with a GSI Create spawned the index build and awaited it inside the request (
crates/storage-postgres/src/update_table.rs), so the response came back only after every existing item had been indexed. The issue reports 23 s for a 3,000-item table under load, and a clientReadTimeoutErrorat boto3's default 60 s read timeout on a 20,000-item table while the build went on in the server.DynamoDB answers UpdateTable at once with the new index CREATING and backfills in the background.
On a 20,000-item table on a local PostgreSQL 16 container, the backfill on this branch took 5.6 s from the UpdateTable call to the index reporting ACTIVE. Before this change the UpdateTable response waited for the backfill to finish; the pre-change build was not timed separately on this host. With this change the call returned in 24 ms with the index CREATING. Both figures were measured before the build limit (item 4 under "What") was added. A build that finds a free slot starts the way it did before.
Service behaviour
Measured on Amazon DynamoDB in us-east-1 on 30 October 2026, on four on-demand tables: two of 2,000 items and two of 500 items, one of the 500-item tables created with deletion protection on. Each measurement added a GSI with UpdateTable and polled DescribeTable every 1 or 2 s, six index builds in all:
Backfillingfalse.Backfillingtrue. The service's own messages call the first stage the resource allocation phase and the second the backfilling phase.Answers while the index was CREATING:
Updatecarrying the index's throughputThe backfilling-phase Query and Scan answers on the two 2,000-item tables came about two minutes after UpdateTable. On the deletion-protected table, during the build of its second index, Queries 0 and 2 s after
Backfillingturned true still got "The table does not have the specified index", and Queries about 45 s after it got the backfilling text.Each allocation-phase answer was measured on two tables, except DeleteTable: once on an unprotected table, and twice on the deletion-protected one. In the backfilling phase, PutItem, the second Create, the stream change, the billing change and the Delete of the creating index were measured once each, Scan on two tables, Query on three, and DeleteTable five times on two tables.
Closes #654
Testing done
Tests
New
crates/storage-postgres/tests/gsi_async_build.rs, using a hook behind the crate'stest-hooksfeature (hold_gsi_backfill_for_tests) that holds one table's GSI build at its start or after a given number of backfill pages:update_table_returns_while_the_backfill_runs: with the build held, UpdateTable returns within 10 s with the index CREATING, and DescribeTable agrees. A read of the index gets the backfilling message. DeleteTable gets the ResourceInUse message and the table stays. After one page is released, the test writes behind and ahead of the scan (a key change, a delete, an insert, an item losing the key attribute). On release the index reaches ACTIVE and holds each base item that carries the key attribute once.a_restart_during_the_backfill_still_reaches_active: a first engine on its own runtime adds the GSI with the build held after one page. Once at least 256 rows are in the index, that runtime is shut down, and the index stays CREATING. After more writes,reconcile_creating_gsison a second engine finishes the build, and the index matches the base table with each item once.New
crates/storage-postgres/tests/gsi_build_task.rs, for what the build task does after UpdateTable has answered:a_build_that_fails_every_time_removes_the_index: one stored item is made undecodable. UpdateTable answers with the index CREATING, the build fails on that item, and the index and its data table are removed. Once the item is restored, the same Create builds the index.a_build_that_fails_for_a_passing_reason_stays_creating: an outside transaction holds the base table's lock, so every write barrier attempt times out. The index stays CREATING with its data table, the build's ownership lock is released, and the next recovery sweep publishes the index.the_recovery_sweep_leaves_a_running_build_alone: a recovery sweep during a held build returns at once with nothing built.delete_table_of_a_protected_table_during_a_build_is_in_use: on a deletion-protected table, DeleteTable gets the protection refusal before the build, the ResourceInUse message during it, and the protection refusal again once the index is ACTIVE.New
crates/storage-postgres/tests/gsi_build_slots.rs. Its first three tests run on an engine given one build slot, with a first table's build held after its first page:a_build_waits_for_a_free_slot_holding_no_session: UpdateTable on a second table returns within 10 s with the index CREATING. That index refuses a read with the backfilling message, has 0 rows after 3 s, and holds no ownership lock. Once the first index is ACTIVE, the second build starts under its own ownership lock, and both indexes end holding exactly the base items.the_recovery_sweep_waits_for_a_free_slot: a recovery sweep takes the waiting index and builds nothing while the slot is taken. When the slot frees, the index's own task finds the sweep owning it and leaves it, and the sweep publishes it.a_waiting_build_leaves_an_index_a_peer_published: a second engine on the same database stands in for another server. While the second build waits, that engine's recovery sweep takes the index and publishes it. The second index's build entry is then held, so a build that started there would keep its ownership lock. When the slot frees, the waiting task takes the lock, finds the index ACTIVE and releases the lock, and 3 s later no ownership lock is held.The fourth,
an_engine_runs_at_most_gsi_build_slots_builds, runs on an engine with its own four slots: UpdateTable on five tables, each build held after its first page, leaves four builds running with one ownership lock each, and the fifth index with 0 rows and no owner after 3 s. All five indexes then reach ACTIVE holding exactly the base items.The new files are listed in the PostgreSQL storage-level test step of
integration.yml.Tests that assumed the build had finished when UpdateTable returned now wait for the index to be ACTIVE:
add_gsiingsi_online_build.rsandgsi_stale_writer.rs, and in the wire suiteupdate_table_attribute_definitionsandgsi_on_hash_only_table, through a newtest_base::wait_for_index_active. Without that, two wire tests failed against a server built from this branch: one got the backfilling ValidationException and the other got LimitExceededException on its next index add.Negative controls
Each was run on the committed tree and restored with git:
update_table_returns_while_the_backfill_runsfails with "UpdateTable waited for the held backfill instead of returning".data/ddl.rsas before this change: the same test fails because the read answered IndexNotFound.delete_table.rsas before this change: the same test fails because DeleteTable answered Ok.delete_table_of_a_protected_table_during_a_build_is_in_usefails because DeleteTable answered the protection refusal during the build.a_build_that_fails_every_time_removes_the_indexfails because the index stayed CREATING.a_build_that_fails_for_a_passing_reason_stays_creatingfails because the index was removed.the_recovery_sweep_leaves_a_running_build_alonefails because the sweep started a second build and did not return.the_recovery_sweep_waits_for_a_free_slotfails the same way.a_waiting_build_leaves_an_index_a_peer_publishedfails because the task held the published index's ownership lock to build it a second time.Semaphore::MAX_PERMITSpermits):an_engine_runs_at_most_gsi_build_slots_buildsfails because the fifth build wrote its first page.Results
On this branch, against PostgreSQL 16 with pgvector in a local container:
cargo fmt --all -- --checkandcargo clippy -p extenddb-storage-postgres --all-targets -- -D warningson 1.97.0,cargo check --locked --workspace --all-targets, andcargo +1.88.0 check --locked -p extenddb-storage-postgres --all-targetspass with no warnings.On an earlier revision of this branch, based on
1c0523c90and without the waiting build's re-read of its index row or the last twogsi_build_slotstests:tests/rust) against a release server: 512 passed, 0 failed.test_gsi_async,test_gsi_attribute_definitions,test_gsi_online_backfill,test_multipart_gsi,test_table_operations) against the same server: 48 passed, 0 failed.Checklist
cargo test --workspace)cargo fmt --check)cargo clippy -- -W clippy::pedantic)Storagetrait, auth model, on-diskformat, or public CLI surface, an RFC has been accepted or is linked
below. Otherwise, an ADR captures the decision (link below).
CI ran the workspace tests on Rust 1.88.0 and on stable, and the integration suites on all four backends, including the wire suite and the Python suite (without its import and export tests) against a PostgreSQL server. All of them passed. Locally I ran the steps listed under Results. Clippy ran with
-D warnings, without the pedantic group. The change adds no command, flag, catalog migration or trait method. The build task's design is indocs/design/04-component-storage.mdsection 6.2.ADR / RFC: n/a
Breaking changes
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.