Skip to content

fix(postgres): keep a deleted table DELETING until its data tables drop - #504

Open
LeeroyHannigan wants to merge 2 commits into
mainfrom
fix/449-pg-delete-table-orphans
Open

LeeroyHannigan wants to merge 2 commits into
mainfrom
fix/449-pg-delete-table-orphans

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

On PostgreSQL, DeleteTable deleted and committed the catalog row first and dropped the _ddb_ data tables afterwards, inside the request future. A client that gave up while the drop waited on a lock dropped the future and rolled the drop back; a server killed between the two commits left the data tables with no catalog row. Nothing removed them later, because the startup pass acts on DELETING rows only. Both interruptions reproduced on main with the issue's recipe (an ACCESS SHARE lock held on the base data table): two orphan _ddb_ tables each time, still present 75 s after a restart.

The row is now marked DELETING and committed on both control-plane paths, and the drop runs in the control-plane pass, which deletes the catalog row only after the data transaction has committed. With the default delay the request runs that pass for its own table in a spawned task and awaits it, so a disconnect cannot cancel the drop. A crash at any point leaves a DELETING row the startup pass finishes (Recovered table 't': DELETING → deleted on restart in the reproduction).

In the common case the name is free when the response arrives, as before. When the drop waits past the pass's 3 s lock_timeout, or is the victim of a deadlock with the GSI propagation worker, the response is DELETING, the task wakes the poller, and the poller finishes the drop on its next tick. The scoped pass waits for a poller pass that holds the row instead of skipping it, so the request answers only after that pass has committed.

What a client sees in the window

Measured on the service 2026-10-24 (two repeats, identical) and matched here: DescribeTable reports TableStatus DELETING; CreateTable with the name answers ResourceInUseException Table already exists: <name>.

Tests

  • crates/storage-postgres/tests/delete_table_interrupt.rs, four tests on a scratch database: the request future dropped while the drop waits (row DELETING, both tables present, DescribeTable and CreateTable as above, then everything gone after the lock is released); the process killed while the drop waits (a second runtime dropped mid-request; the startup pass on a fresh engine finishes it); the poller woken after a lock timeout; the inline pass removing only its own table. 0 of 2 of the first two pass with the three fixed source files from main. Mutants on the committed tree: catalog row deleted before the data commit, task not awaited, DELETING committed after the spawn, wake removed, scope ignored; each fails at least one test.
  • tests/python/test_delete_table_deleting_window.py: DeleteTable and DescribeTable report DELETING; CreateTable with the name answers the measured sentence. On ExtendDB the window is held open with an outside ACCESS SHARE lock through EXTENDDB_TEST_PG_ADMIN_CONNECTION_STRING (the PostgreSQL integration job exports it; the other jobs skip). Against the service the tests run on its own window.
  • The storage-level CI step now lists delete_table_interrupt.
  • Crate: 160 tests pass against a PostgreSQL 16 container. tests/python/ 333 passed on a server built from the branch. cargo fmt, clippy 1.97.0 with -D warnings, build on 1.88.0: clean.
  • git merge-tree: clean with Fix PostgreSQL global secondary index creation under concurrent writers #454. One conflict with fix(postgres): build a GSI added by UpdateTable correctly while writes continue #401 in delete_table.rs (its inline fast path calls drop_table_storage; this branch removes the inline drop): resolution is this branch's file, verified by a trial merge on which the crate builds and both branches' tests pass. On the merged tree fix(postgres): build a GSI added by UpdateTable correctly while writes continue #401's drop_table_storage is called only from the pass, so its deadlock-safe drop order covers the request path too.

Not in this PR

Pre-existing on main, measured during this work and filed separately: a DeleteTable during an UpdateTable GSI backfill orphans the new index data table (same statement order on main; #401's base-table-first order closes it); the drop can deadlock with the GSI propagation worker (8 of 50 deletes under write load; #401 closes it); a second DeleteTable or an UpdateTable during the window answers a different sentence from the service; PutItem and GetItem on a DELETING table answer ResourceNotFoundException where the service serves them. This change does not sweep orphan tables an earlier release left behind.

Closes #449

Rebase on #401

delete_table.rs is this branch's file, as the trial merge above describes; the doc comment on #401's drop_table_storage said "DeleteTable itself waits" and now says that DeleteTable's pass for its own table (finish_table_delete) uses it with the pass's lock timeout.

LeeroyHannigan added a commit that referenced this pull request Oct 26, 2026
…does

A second DeleteTable and an UpdateTable during a table's DELETING window
answer 400 ResourceInUseException with "Attempt to change a resource which
is still in use: Table is being deleted: <name>" on all four backends.
PutItem, GetItem, Query and Scan are served during the window on PostgreSQL
and SQLite and answer ResourceNotFoundException once the table is gone; a
write made in the window goes away with the table. Two concurrent
DeleteTable calls on one ACTIVE table get one description and one
ResourceInUseException on PostgreSQL, SQLite and MongoDB. All measured
against the service on 2026-10-24 and 2026-10-25.

DeleteTable on PostgreSQL and SQLite looked the name up with a status filter
that excluded DELETING rows and answered ResourceNotFoundException. On
MongoDB and Cassandra it had no status filter and ran the delete again.
UpdateTable on every backend answered "Table <name> is not in ACTIVE state",
the sentence for the other non-ACTIVE statuses. The data plane on PostgreSQL
and SQLite answered ResourceNotFoundException for the whole window, because
the key-info read that authorization and the handlers share mapped a
DELETING row to not found. On SQLite the DeleteTable status lookup ran
outside the write transaction and on MongoDB the DELETING mark was
unconditioned, so two concurrent calls both proceeded.

New StorageError::TableBeingDeleted, worded once in the engine's message
table and mapped to ResourceInUseException by the DeleteTable, UpdateTable
and TTL error mappers. The key-info read on PostgreSQL and SQLite reads a
DELETING row as found; a statement or transaction that reaches a data table
the control plane has dropped is answered as not found instead of as an
internal error. SQLite's DeleteTable looks the row up inside its write
transaction and fences the DELETING mark on the table id; MongoDB's mark is
an update_one conditioned on the table id and status, and its final
delete_one is keyed on both.

On PostgreSQL and SQLite the DELETING row exists only on the asynchronous
DeleteTable path (control_plane_delay_seconds of 1 or more; the default is
0.25), so on a default deployment the new answers are reachable after #504,
which marks the row on both paths. On Cassandra DeleteTable never marks the
row, so the check is reachable only for an abandoned restore target, and the
data plane there is unchanged. MongoDB drops the collections inside the
DeleteTable call a few milliseconds after the mark, so its data plane keeps
answering not found during that window. The differences document carries
the per-backend shape.

Tests: engine unit tests for the three mappers; storage-level tests on each
backend for both control-plane requests and for a CREATING table keeping
its answer; on PostgreSQL and SQLite, for the four data-plane requests
during the window, for a put, a get and the two transaction calls after the
data table is dropped under a DELETING row, and for ten rounds of two
concurrent DeleteTable calls (also on MongoDB); a dual-target wire test for
the PostgreSQL case that holds the data table locked from a second
connection, with the data-plane rows, which skips on a server whose delay is
under 1 s until #504 lands. The PostgreSQL storage-level CI step runs the
new binary.

Closes #505
…e dropped

DeleteTable on PostgreSQL deleted and committed the catalog row first and
dropped the data tables afterwards, inside the request future. A client
that gave up while the drop waited on a lock dropped the future and rolled
the drop back, and a server killed between the two commits left the _ddb_
tables with no catalog row. Nothing removed them later: the startup pass
acts on DELETING rows only.

The row is now marked DELETING and committed on both control-plane paths,
and the drop is done by the control-plane pass, which deletes the catalog
row only after the data transaction has committed. The immediate path runs
that pass for the one table in a spawned task and awaits it before
answering, so a disconnect cannot cancel the drop. In the common case the
name is free when the response arrives; when the drop waits past the pass's
3 s lock timeout, or is the victim of a deadlock with the GSI propagation
worker, the response is DELETING, the task wakes the poller, and the poller
finishes the drop on its next tick. A crash at any point leaves a DELETING
row the startup pass finishes. The scoped pass waits for a poller pass that
holds the row instead of skipping it, so the request answers only after that
pass has committed.

During the window DescribeTable reports TableStatus DELETING and
CreateTable with the name answers ResourceInUseException with the service's
sentence. Four storage tests on a scratch database cover the client
disconnect, the process kill, the poller wake after a lock timeout and the
scope of the inline pass; a wire test pins the two responses, holding the
window open on ExtendDB with an outside lock.

Closes #449
@LeeroyHannigan
LeeroyHannigan force-pushed the fix/449-pg-delete-table-orphans branch from 9b0fd87 to 0585b92 Compare October 26, 2026 08:05
@jcshepherd jcshepherd self-assigned this Oct 26, 2026
AND ($1::TEXT IS NULL OR table_id = $1) \
{lock_clause}"
);
let candidates: Vec<(String, String, String)> = sqlx::query_as(&candidate_sql)

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.

Where is the 3s timeout being applied here (FOR UPDATE will block indefinitely)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You're right, it wasn't. The 3 s was set inside drop_table_storage on the data-pool transaction, so it bounded the drops and not this select. The select waited on whichever transaction held the row. After DELETING commits the only thing that holds the row past one statement is a poller pass, which takes due DELETING rows with SKIP LOCKED and keeps the locks until it commits, so the wait was bounded by that pass's progress rather than by a timeout, and the comment overstated what the 3 s covered.

5f437d5 sets lock_timeout to the same 3 s on this transaction (one constant now, shared with the drop) before the select. On SQLSTATE 55P03 the pass rolls back and returns no transitions, so finish_table_delete gives Ok(false), DeleteTable answers with the table still DELETING and wakes the poller, which is the existing path for a drop that times out. The poller pass doesn't set it, since SKIP LOCKED doesn't wait.

New test delete_table_lock_wait: holds the row from a second connection, expects the pass to give up at about 3 s with the row still DELETING, then releases the row, runs the pass to completion and reads lock_timeout on every pooled connection, expecting 0. It fails with the set_config removed, with the value changed to 30 s, and with the setting made session-level (a committed pass then leaves 3 s on the pooled connection). The interrupt tests, fmt, clippy and the 1.88 build pass. Comments in worker_store.rs and delete_table.rs now say what bounds the wait.

Two related waits are on main and not in this PR: DeleteTable's first FOR UPDATE select has no lock timeout either and can wait behind CreateBackup's FOR SHARE, and with one database the scoped pass holds a catalog connection while the drop acquires a second one from the same pool, so pool_size concurrent DeleteTable calls all wait out the 30 s acquire timeout (measured: 24 calls at pool size 10, all answered after 30.07 s, rows finished by the poller afterwards). I'll file both.

@jcshepherd jcshepherd 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.

Catalog delete appears to be lacking a time-out on a pessimistic lock, but I could be wrong. Please verify.

The pass that DeleteTable runs for its own table selects the table's
DELETING catalog row FOR UPDATE on a catalog transaction with no lock
timeout. After DELETING commits, a control-plane poller pass can hold
that row. The poller pass takes due DELETING rows with SKIP LOCKED and
keeps the locks until it commits, after dropping the data tables of the
rows it took. The 3 s lock timeout that the data drop sets applies to
the data transaction only, so the select waited until the poller pass
committed, and the spawned DeleteTable task held a catalog pool
connection for that time.

The scoped pass now sets lock_timeout to 3 s on its catalog transaction,
the value the data drop uses, kept in one constant. When the select fails
with lock_not_available (SQLSTATE 55P03), the pass rolls back and reports
the table as not removed. DeleteTable then answers with the table still
DELETING and wakes the poller, which finishes the delete. The timeout
also bounds the later lock waits in that transaction; a timeout there is
returned as an error and leaves the row DELETING for the poller. Other
errors propagate as before. The poller pass does not set the timeout,
since SKIP LOCKED does not wait for a row.

finish_table_delete is public and hidden from the docs so that the new
test delete_table_lock_wait can call it. The test holds the catalog row
from a second connection and checks that the pass gives up after about
3 s with the row still DELETING; it then releases the row, runs the pass
to completion, and reads lock_timeout on every pooled connection, which
must be 0. The setting is transaction-local (set_config with is_local
true); a session-level setting survives a committed transaction on that
pooled connection and the read catches it. The integration workflow runs
the new test in its PostgreSQL storage-level step.

This branch has not been deployed

No deployments
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.

PostgreSQL: DeleteTable interrupted between the catalog commit and the storage drop leaves orphan data tables

2 participants