Skip to content

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

Open

Description

@LeeroyHannigan

Summary

On PostgreSQL, DeleteTable deletes and commits the catalog rows first and drops the data tables afterwards, inside the request future. If the request is interrupted between the two, the base and index data tables (_ddb_<id>) are left in the data database with no catalog row, and nothing removes them later.

Reproduction

PostgreSQL backend, a table with one GSI and 2,000 items.

  1. In a psql session on the data database: BEGIN; LOCK TABLE "_ddb_<table_id>" IN ACCESS SHARE MODE; and leave it open. Any client transaction that has read the table holds the same lock, so this stands in for a slow reader.
  2. From a boto3 client with a 5 s read timeout: DeleteTable.
  3. The client gets ReadTimeoutError. In the catalog, the tables and indexes rows are gone. In the data database, both _ddb_ tables are present and pg_stat_activity shows the DROP TABLE waiting.
  4. Release the lock. The DROP TABLE backend disappears from pg_stat_activity and the tables are still there: the client's disconnect dropped the handler future and with it the sqlx transaction, so the drop was rolled back when the lock was granted.

The same end state with a server kill instead of a client timeout: kill -9 the server while the drop is waiting, release the lock, restart. After 75 s both tables are still present. The restarted server's startup pass (process_control_plane_transitions, worker_store.rs) acts on DELETING rows only, and this row was already deleted. The deferred delete path in worker_store.rs has the same commit-then-drop order.

Measured on cc981e2d (main) and on the #401 branch at 8c317624. On 8c317624 the vector_index_holds row the drop inserts is cleared by the sweep after 10 minutes; the tables are not.

A client cannot see the leak: DescribeTable and PutItem on the name answer ResourceNotFoundException, and CreateTable with the same name succeeds (a new table id). The orphan tables hold the deleted table's items and index rows in the data database until an operator finds them.

What would close it

Two changes, each of which covers one of the two interruptions:

  • Run the storage drop in a spawned task, as UpdateTable runs the GSI build (update_table.rs, so a client disconnect cannot cancel it part way), so a client timeout no longer rolls the drop back.
  • Keep the table row as DELETING until the drop has committed, so the control-plane poller finishes a drop a crash interrupted; or have the startup pass drop any _ddb_ table whose id has no catalog row.

Where it came up

Found while reviewing #401, which rewrites the drop order to avoid a deadlock with the GSI queue worker. #401 keeps the order in which the catalog delete and the storage drop commit, so the leak is the same on main and on that branch.

Activity

  1. added a commit that references this issue on Oct 26, 2026
    0585b92
  2. added this to the 1.0 milestone on Oct 27, 2026
  3. added 2 commits that reference this issue on Oct 29, 2026
    e7f5649
    1db38b3
  4. added
    v1Needed for the 1.0 release
    release-blockerMust ship for its milestone's release
    on Nov 3, 2026
  5. hitesh96db commented on Nov 9, 2026

    @hitesh96db
    Contributor

    Status check against main at 35edb77. I read the code; I did not re-run the reproduction.

    Already fixed on main (mostly by #395)

    • Client timeout or disconnect (the reproduction above). On one shared database the catalog delete and the data drops now commit in one transaction, behind a savepoint, so an interrupted request rolls back whole. On a separate data database the drops are run and hold their locks before anything commits, and both commits run in a tokio::spawned task, so a dropped request no longer rolls the drop back. This is the first change proposed above.
    • A reader holding ACCESS SHARE. An in-use probe and the 1 s SYNC_DROP_LOCK_TIMEOUT send DeleteTable down the asynchronous path. The row stays DELETING and the poller drops the tables later.
    • The deferred delete in worker_store.rs. It now drops the data tables first and deletes the row only after the drop succeeds, so the commit-then-drop order is gone there.

    Still open on main: separate data database, which is what extenddb init creates

    bootstrapper.rs always records data_database_connection_string, so a default deployment takes this path. delete_table.rs still deletes and commits the catalog row before the data commit. Two cases still leave _ddb_<id> tables with no catalog row:

    1. The process dies between the catalog commit and the data commit (or its retries).
    2. The data database is unreachable for longer than DATA_DROP_RETRY_BOUND (60 s).

    Main documents this instead of fixing it. The comment on DATA_DROP_RETRY_BOUND says "Nothing in the server drops such relations later", and docs/troubleshooting.md has a manual cleanup section ("Data tables without a catalog row").

    #504 closes the remaining cases

    On the separate data database path, #504 marks the row DELETING and commits it, drops the data tables, and removes the row only after that drop commits. After a process death or a data database outage past the 60 s window, the row stays DELETING with its transition due. The startup pass, or a later poller pass, then drops the tables and removes the row. A row names the data tables until they are dropped, so none can be orphaned. #504 also updates the DATA_DROP_RETRY_BOUND comment and the troubleshooting section. It shows 35/35 checks passing on 47e95b1.

    Before closing this issue on #504's merge:

    I have not run #504's tests myself.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

backend:postgresqlRelated to the PostgreSQL backend specifically.bugSomething isn't workingrelease-blockerMust ship for its milestone's releasev1Needed for the 1.0 release

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions