Repository navigation
fix: match data relations by catalog id in catalog-check, so --fix keeps live tables - #690
Open
LeeroyHannigan wants to merge 13 commits into
Open
LeeroyHannigan wants to merge 13 commits into
LeeroyHannigan wants to merge 13 commits into
Conversation
Add DiagnosticsStore::check_storage_relations and drop_orphaned_relations with default implementations that report the backend does not support them. The PostgreSQL store implements both from the catalog's table_id, index_id and vector index ids through the crate's own naming functions, so the expected names match what the engine creates. Rows in every status count as live. The drop takes SHARE locks on tables, indexes and vector_indexes, reads the catalog again under them, and drops only the reported relations that no row names.
… backend
catalog-check expected relations named _ddb_{account}_{table} and
_ddb_{account}_{table}__gsi__{index}, while the storage layer names them
_ddb_{table_id}, _ddb_{index_id} and _ddb_vec_{index_id}. On PostgreSQL
every live relation was reported orphaned, every live table missing, and
--fix dropped the data tables of ACTIVE tables.
Checks 1 and 2 now call the backend's DiagnosticsStore, which derives the
expected names from the catalog ids with the engine's own naming
functions and re-checks each candidate under catalog locks before it
drops it. Missing relations are reported with the table or index that
owns them. Checks 3 and 4 are unchanged.
… engine creates On a deployment with a separate data database, a table with a GSI, an LSI and a vector index is neither orphaned nor missing; a planted relation no catalog row names is dropped alone while an unrelated table and a table created after the check are kept; a renamed index relation is reported missing with its owner; rows in CREATING, UPDATING and DELETING count as live. The binary is listed with the other PostgreSQL storage-level tests.
…atalog row The troubleshooting entry told operators not to use --fix because the command expected a different relation-naming scheme. It now describes what the check compares, how --fix re-checks each relation under catalog locks before dropping it, and keeps the manual comparison for inspection.
The drop locked tables, indexes and vector_indexes in SHARE mode in one statement and kept the locks until every drop had finished. An UpdateTable adding a GSI on a new attribute writes indexes and then tables, so the two could deadlock and the client got a 500. A drop waiting on a relation another session held kept every catalog write on the server waiting for up to 30 seconds. Under REPEATABLE READ the re-read after the lock wait used a snapshot taken before it, and the drop removed a live GSI relation. Only a GSI add creates a relation before its catalog row commits, and it holds ROW EXCLUSIVE on indexes from its insert to its end; every relation name comes from an id generated when its row is inserted. A SHARE lock on indexes alone therefore covers every path. It is taken in attempts of 200 ms, 50 ms apart, within the 30 second budget, in a READ COMMITTED transaction whose first statements take no snapshot, and it is released right after the catalog read. Each drop then runs in its own transaction with a 1 second lock timeout and without IF EXISTS: a relation another session dropped first is reported as already gone, one another session holds as failed, and no notice reaches the output. The report waits for the index writers the same way before the catalog read that decides orphans, and reports a relation orphaned only when a second listing still has it. It reads the catalog a second time after that listing and reports a relation missing only when both reads expect it and the listing lacks it. Rows in CREATING are no longer expected to have relations. A catalog without data_database_connection_string, or with an empty one, is refused again with the message the command gave before.
Split the catalog_check test binary into report, fix and support modules, keeping its name for CI. The new tests cover a GSI add whose relation exists and whose row has not committed, against the drop at the default isolation and at REPEATABLE READ and against the check, with the add's table write and another index write timed; an orphan another session holds, with a CreateTable sent while the drop waits; a relation another session dropped first; rows in CREATING or DELETING whose relations are gone; a catalog without the data database setting; and a DeleteTable, a dropped orphan and a new orphan landing between the check's first catalog read and its second listing, held there by a test-hooks pause.
The troubleshooting entry now says that the check reads the data database's current schema and refuses a catalog without the data database setting, that rows in CREATING are not reported missing, and which reports can still be wrong while a server on another host changes tables. The --fix paragraph describes the wait for GSI additions in flight, the one second lock wait of each drop, the Already gone outcome, and that every _ddb_ relation in the schema is taken as this deployment's. The manual query filters on the current schema, as the command does.
The holder session is released only after the drop returns, so a drop without a lock timeout of its own would keep the test waiting. A 10 second timeout around the drop makes that case fail instead.
…med relation The report took the SHARE lock on indexes on every run. While that request waits behind an open writer, every new write to indexes queues behind it for up to one attempt, the index rows of a CreateTable included. The check now reads the catalog first and waits for the index writers only when a listed relation is unnamed, which outside a GSI add in flight means a relation no row will name, so a check that finds every relation named holds up no write. An attempt now lasts 100 ms instead of 200 ms.
…ng else The drop test against a GSI add in flight now reads pg_locks while the drop waits for its SHARE lock on indexes, and asserts that the waiting session holds no lock on tables or vector_indexes. Before, a lock of all three tables passed the test: each attempt's 100 ms lock timeout ends the wait before PostgreSQL's deadlock check runs, so the add's table write still finished in time.
The drop test committed the add after a fixed 600 ms, which fell in the 50 ms pause between two lock attempts about a third of the time. The next attempt then started after the commit, and a snapshot taken before the lock still saw the row, so the REPEATABLE READ case passed without the isolation fix. The test now commits only once pg_locks shows a SHARE request on indexes that began less than 20 ms earlier, so the commit lands while that attempt waits.
The troubleshooting entry still said the check always waits for the GSI additions in flight and that each lock attempt lasts 200 ms. The check waits only when it finds a relation no row names, and an attempt lasts 100 ms. A write to indexes that queues behind the granted lock also waits for the one catalog read the lock is held for; the entry and the comment on the attempt length now say so.
The troubleshooting entry opened its list of the reports that can be wrong while a server runs with a count of them. It now states the condition and lists them.
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn,
robinnsc and
yesyayen
as code owners
October 31, 2026 11:48
7 of 8 tasks
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
The relation names belong to the storage backend, so the comparison moved there.
DiagnosticsStore(crateextenddb-storage) gainscheck_storage_relationsanddrop_orphaned_relations, with defaults that report the backend does not support them. Other backends are untouched.data/catalog_check.rs. Expected names come fromtable_storage_relations, the listDeleteTabledrops, for the rows oftables,indexesandvector_indexesin any status, through the crate's own naming functions. The data relations are listed frompg_tablesin the data database's current schema by the_ddb_prefix the naming functions now share.indexesto end and reads the catalog again before it compares (see the lock section below). A check that finds each listed relation named takes no lock.--fixwaits for theindexeswriters the same way, reads the catalog under that lock, and releases the lock before it drops anything. Each reported relation that the read does not name is then dropped in a transaction of its own with a lock timeout of 1 s, the boundSYNC_DROP_LOCK_TIMEOUTsets for the server's own synchronous drops, usingDROP TABLEwithoutIF EXISTS. The command printsDropped;Already gonewhen another session dropped the relation first (SQLSTATE 42P01);Keptwith the reason; orFAILEDwith the error. For a relation another session holds longer than 1 s, the error reads "another session held a lock on it for more than 1000 ms, so it was kept; run the command again". If the index writers do not finish within 30 s, the command reports that, drops nothing and says to run it again.data_database_connection_string, or with an empty value, is refused with the message main gives: "No data database connection string in settings. Runextenddb init." Both store methods refuse it, so the command does not look for data relations in the catalog's own database. The data pool connects with statement logging off, so a drop that waits out its lock timeout adds no slow-statement line to the report.cmd_catalog_check.rscalls the store for checks 1 and 2 and reports each missing relation with the table or index that owns it. Checks 3 and 4, the PID refusal, the counting and the exit code are unchanged.docs/troubleshooting.md: the entry from feat(storage): SnapshotEngine, a consistent table snapshot on every backend [backup/restore 4/4] #395 that told operators not to use--fixnow describes the check, what it waits for, the reports that can be wrong while a server runs (below) and the drop outcomes. It says that--fixtreats each_ddb_relation in the data database's current schema as this deployment's, and its manual query now filters oncurrent_schema().The lock on
indexesEach relation name comes from a UUID that the request inserting its catalog row generates for that row: in
create_table.rsfor a table and the GSIs, LSIs and vector indexes created with it, which restore also uses, and inupdate_table.rsfor a GSI or vector index added later. No path inserts a row for an id it did not generate, so a row inserted later cannot name a relation that exists already. The recovery paths ingsi_build.rsandvector_index.rscreate a relation only for a row that has committed.CreateTable and a vector index add commit the catalog rows first and create the relations after. A GSI add in
UpdateTableis the only path that commits its relation before its row: it inserts the index row, creates and commits the relation on the data database, and then commits the catalog transaction, which holds ROW EXCLUSIVE onindexesfrom the insert to its end. A SHARE lock onindexesis granted only after each such add whose relation exists has committed or rolled back, and a catalog read taken after the grant sees the rows of those that committed. No row names a relation that this read does not name, and no row inserted later can name it.The command requests the lock in attempts of 100 ms, 50 ms apart, for up to 30 s. While an attempt waits, new writes to
indexesqueue behind it, so a server write waits on the command for at most 100 ms plus the one catalog read the lock is held for. The command holds no other lock while it waits, so it cannot take part in a deadlock, and the GSI add's next statement, an update of itstablesrow, does not wait on it. The transaction runs at READ COMMITTED whateverdefault_transaction_isolationthe database, role or connection sets, and sets its lock timeout withSET LOCAL, which takes no snapshot, so the read after the grant sees rows committed during the wait.Servers do not hold
migrate's advisory lock, so taking it would not exclude a running CreateTable or UpdateTable.#708 (
fix/654-pg-async-gsi-build), the fix for #654, stopsUpdateTablefrom waiting for the GSI build and leaves the add's order of insert, create and commit as it is, so the lock covers a GSI add there too. Its cleanup of a failed build removes the CREATING row, waitsGRACE_PUBLISH(1 s) and then drops the relation, in the order main uses, so a check in that second reports the relation orphaned. The two branches add adjacent lines to the storage-level test list inintegration.ymland to thetest-hooksre-exports incrates/storage-postgres/src/lib.rs; merging both keeps the lines of each.Reports that can be wrong while a server runs
The command refuses to run while it finds the server's PID file, but it does not see a server started without
--write-pid-file, one on another host, or one using anotherrun_diror port. With a server running, the reports below can be wrong, and a later run does not repeat them:GRACE_PUBLISHsecond. A--fixthat drops such a relation first causes the server no error, because the server's drops useIF EXISTS.control_plane_delay_secondsis 0. Otherwise the row moves from CREATING to ACTIVE once that delay (0.25 s by default) has passed since its catalog transaction began, whether or not the relations have committed.--fixdrops only relations reported orphaned, so a wrong missing report leads to no drop.The command does not report a GSI or vector index stuck in CREATING: check 4 covers tables only, and check 2 does not count a CREATING index row as expected.
Why
extenddb catalog-checkexpected data relations named_ddb_{account}_{table}and_ddb_{account}_{table}__gsi__{index}. The storage layer names them from catalog ids:_ddb_{table_id},_ddb_{index_id}and_ddb_vec_{index_id}for vector indexes, which the command did not know. It also counted only tables in ACTIVE or CREATING as live.Measured on main
1c0523c90(2026-10-30, PostgreSQL 16 with pgvector, a separate data database asextenddb initcreates it, one table with a GSI, an LSI and a vector index): the report listed all four live relations as orphaned and three names that never existed as missing;--fixdropped the four relations; GetItem then returnedResourceNotFoundExceptionand Query on either index returnedValidationException: The table does not have the specified index, while DescribeTable still reported the table ACTIVE.On SQLite the command exits before any check with
Cannot connect to catalog: error with configuration: relative URL without a base, because it opens a PostgreSQL pool unconditionally (#177). It reports and drops nothing there, so no SQLite change is needed.Closes #652
Testing done
The runs in this section were made against PostgreSQL 16 with pgvector and a separate data database, on 2026-10-30 unless an item gives another date.
With a binary built from
4556ffcd4, on the deployment from the reproduction with the table recreated: the report is HEALTHY (exit 0). With a planted_ddb_00000000-0000-4000-8000-00000000deadand an unrelatedoperator_scratch, the report lists the planted relation only;--fixprintsDroppedfor it and nothing else; the four live relations andoperator_scratchremain; GetItem returns the item and Query on gsi1 and lsi1 returns 3 and 1 items.The loops below used a binary built from
90d429449, whose code matches the branch head (the later commits change tests, one comment and docs), with a server running on the same deployment. The command ran with a config for another port, so the PID refusal did not apply. Four traffic workers each created a table with a GSI, an LSI and a vector index, set its TTL, tagged it, wrote three items, added a second GSI, queried both indexes and deleted it, in a loop; a fifth wrote items to one table.fe791ce87on a busier host where 188 of 349 CreateTable calls took longer than 250 ms (p50 255 ms, max 808 ms): 3 of 300 runs exited 1, each in a case listed under "Reports that can be wrong while a server runs". Two runs reported missing relations for a table whose CreateTable took 558 ms in one run and 435 ms in the other. The third reported orphaned relations for two tables whose DeleteTable calls were between their catalog commit and their data commit. How many runs report a wrong result depends on how long CreateTable and DeleteTable take on the host. The server log has no deadlock and no error line.16c62f521), which requested the lock on each run in attempts of 200 ms, had 1 run of 300 exit 1, at 20:10:56Z: the four relations of a table whose CreateTable took 308 ms were reported missing. A waiting lock request can hold up a CreateTable's insert intoindexesfor one attempt, which counts against the 0.25 s delay. The check now requests the lock only when it finds an unnamed relation.--fixloop: 120 runs 0.3 s apart from 20:29:20Z to 20:31:20Z, each with a planted relation that no row names. Each run reported and dropped that relation and nothing else; no run kept or failed a drop or reported a relation missing. The traffic made 253 CreateTable, 252 UpdateTable and 7,457 PutItem calls, all answered 200, and the server log has no deadlock and no error line.psqlsession in ACCESS SHARE mode for 6 s:--fixprintedFAILED to drop ...: another session held a lock on it for more than 1000 ms, so it was kept; run the command again: error returned from database: canceling statement due to lock timeoutand exited 1 after 1.33 s. After the session ended,--fixprintedDropped.data_database_connection_stringsetting absent, the report and--fixboth printedError: No data database connection string in settings. Run `extenddb init`.and exited 1.Tests
crates/storage-postgres/tests/catalog_check/(main.rs,report.rs,fix.rs,support.rs), run as--test catalog_checkin the PostgreSQL storage-level step ofintegration.yml. Each test initialises a deployment throughPostgresBootstrapperwith a separate data database, creates a table with gsi1, lsi1 and vec1 (vec1 when pgvector is installed) and three items through the engine, and drivesPostgresCatalogStoreas theDiagnosticsStore. WithoutEXTENDDB_TEST_PG_CONNECTION_STRINGthe tests skip.The check:
index gsi1 of table 123456789012/ordersas its owner, and the renamed one orphaned;indexesleft open, a check that finds each relation named finishes in under 2 s;The drop:
--fixdrops a planted relation, keeps an unrelated table, keeps the relations of a table created after the check when they are passed as stale candidates, and leaves the items and index rows intact;tablesorvector_indexes; the add'stableswrite finishes in under 900 ms and anotherindexeswrite in under 1 s; the add commits while a lock attempt waits; the relation is kept. The test runs at the default isolation and again with the store's sessions defaulting to REPEATABLE READ;FAILEDwith the lock message within 5 s, a CreateTable sent meanwhile finishes within 5 s, and once the session ends the drop succeeds;Already gone.A unit test in
data/catalog_check.rschecks that a row in CREATING or DELETING, or one that is absent, does not count as settled.Negative controls
Each mutation was applied to a clean committed tree, the
catalog_checktests were run, and the file was restored withgit checkoutand the tree checked clean before the next.indexesbefore the readtables,indexesandvector_indexes, as beforetables)indexeswriteSELECT set_config(...)and no READ COMMITTEDDROP TABLE IF EXISTSagainfalseif false &&An earlier control, run when the test file had its first four tests, built the expected names from account and table names instead of ids; all four tests failed.
Gates
cargo +1.97.0 fmt --all --check: clean.cargo +1.97.0 clippy -p extenddb-storage -p extenddb-storage-postgres -p extenddb-app -p extenddb --all-targets -- -D warnings: no warnings.cargo test --locked -p extenddb-storage -p extenddb-app: 138 and 34 passed.cargo test --locked -p extenddb-storage-postgres --lib: 65 passed.integration.yml,cargo test --release -p extenddb-storage-postgreswith its 11 test binaries andEXTENDDB_TEST_PG_REQUIRED=1, against PostgreSQL 16 with pgvector: 204 passed, 2 ignored, none failed (catalog_check: 13 passed).cargo +1.88.0 check --locked -p extenddb-storage -p extenddb-storage-postgres -p extenddb-app --all-targets: clean.cargo check --locked --workspace --all-targets: clean.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. All of them passed. Locally I ran the crates listed under Gates. Clippy ran with
-D warnings, without the pedantic group. The change adds two methods with default bodies toDiagnosticsStore, so other backends compile unchanged, and adds no command, flag or on-disk format. There are no breaking changes.ADR / RFC: n/a
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.