Skip to content

feat(storage): add the pending drops quarantine ledger - #1064

Draft
aparajon wants to merge 1 commit into
mainfrom
armand/pending-drops-ledger-store
Draft

feat(storage): add the pending drops quarantine ledger#1064
aparajon wants to merge 1 commit into
mainfrom
armand/pending-drops-ledger-store

Conversation

@aparajon

@aparajon aparajon commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Why this matters

RENAME TABLE is atomic but not idempotent. When an apply is interrupted partway through its DROP phase and resumes, the phase replays from its first statement, and the rename it already performed now finds no source table. The change actually landed, but the re-run fails.

The direct-drop path can fix this by looking at the target: the table is absent, the plan wanted it gone, so it converges. The quarantine path cannot. It renamed the table to a timestamped name in _pending_drops, and quarantined names differ from each other only by their timestamp prefix, so "a quarantined copy of orders exists" does not tell you whether this apply made it, another apply made it, or something outside SchemaBot did. Guessing wrong either fails a completed change or claims another apply's data.

What is missing is proof of authorship, and the target cannot hold it: pendingdrops.TableName discards the schema name and truncates to MySQL's 64-character limit, so the origin is destroyed at rename time and no later scan can recover it.

What it does

Adds a pending_drops ledger in the deployment's own storage, plus the store that reads and writes it. Each row records which run quarantined which table, under which name. That turns the re-run question into a lookup with three distinguishable answers:

DROP phase replays after an interrupted apply
                    │
                    ▼
      ledger row for (target, environment, database, table)?
                    │
      ┌─────────────┼──────────────────────┐
     none      run_id ≠ this run      run_id = this run
      │             │                      │
      ▼             ▼                      ▼
 nothing         another apply's        this apply's own
 recorded        quarantined copy       earlier rename
      │             │                      │
      ▼             ▼                      ▼
 FAIL CLOSED    FAIL CLOSED           converge: the change
 (external      (never claim           already landed, skip
  drift)         another run's          the statement
                 data)

The two failing branches fail for different reasons and must stay distinguishable, which is why LatestForTable deliberately does not filter by run id: the caller compares it, so "no record at all" cannot collapse into "an earlier apply's copy."

Design decisions worth review attention:

  • The ledger is a derived index, never an authority. Reaping still reads the target, so a lost or absent row degrades discovery rather than correctness. Whether the feature is on is expressed by whether rows exist, not by config.
  • Rows are keyed on (target, environment). A target alone is not a server, because the endpoint lookup is scoped by environment. The endpoint itself is re-resolved every pass and never stored: it churns on failover, and a stale stored endpoint on a DROP path is exactly the wrong thing to be confident about.
  • Terminal rows are kept. Prune only touches rows that are no longer quarantined, so a reaped row stays as evidence until it ages out. Deleting at reap time would make "reclaimed" indistinguishable from "no record."
  • original_table is varchar(64), the real table-identifier limit. At 255 the origin index exceeded MySQL's 3072-byte key limit.
  • Identity keys fold, table identifiers do not. environment and database_name are folded at both the read and write boundaries, because PostgreSQL compares bytes and an unfolded row would be invisible to a folded lookup. original_table and quarantined_name keep the target server's own spelling, since they name real objects there rather than SchemaBot rows.

Tests live in the cross-dialect parity suite and are registered as a parity family, so the completeness test over storage.Storage fails if a backend ever stops implementing it. All 15 subtests run against both MySQL and PostgreSQL.

Opened by Claude (Opus 5).

Copilot AI lite review requested due to automatic review settings August 17, 2026 04:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds a durable “pending drops” quarantine ledger to storage so a deployment can record which tables it quarantined (including the apply run_id) and later distinguish “we renamed it earlier” from “it disappeared for some other reason.” This fits into SchemaBot’s storage layer and schema bootstrap/parity testing, providing the foundation for convergent re-runs of the DROP phase on the quarantine path.

Changes:

  • Introduces pending_drops storage model/types and a PendingDropStore interface, exposed via Storage.PendingDrops().
  • Implements the SQL-backed store (Record, LatestForTable, ListExpired, ListQuarantined, SetState, Prune) in the shared sqlstore.
  • Adds MySQL/Postgres schema DDL plus cross-dialect parity tests for the new store.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.

Show a summary per file
File Description
pkg/storage/types.go Adds PendingDrop and PendingDropState types for the quarantine ledger.
pkg/storage/storage.go Extends the public storage API with PendingDropStore and Storage.PendingDrops().
pkg/storage/internal/sqlstore/storage.go Wires the new pendingDropStore into the sqlstore Storage implementation.
pkg/storage/internal/sqlstore/pending_drops.go Implements the SQL store for pending-drops ledger operations.
pkg/schema/mysql/pending_drops.sql Adds MySQL DDL for the pending_drops table and indexes.
pkg/schema/postgres/pending_drops.sql Adds Postgres DDL for the pending_drops table and indexes.
pkg/storage/storagetest/storagetest.go Registers the new parity suite in the storage test harness.
pkg/storage/storagetest/pending_drops.go Adds cross-dialect behavioral tests for PendingDropStore.
pkg/api/handlers_test.go Updates the storage mock to satisfy the expanded storage.Storage interface.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Records the tables a deployment moved into an engine's pending-drops
quarantine, in that deployment's own storage. Discovery of servers holding
expired quarantines becomes an index lookup rather than a scan, so cleanup
cost scales with drops instead of with the number of registered databases,
and a deployment that never quarantines writes no rows and no-ops by
construction.

Rows also carry the run identifier that performed the move, which is what
lets an interrupted DROP phase tell its own completed rename apart from
drift or from an earlier apply's quarantined copy.

No caller yet: the quarantine path and the reaper are wired in follow-ups.
@aparajon
aparajon force-pushed the armand/pending-drops-ledger-store branch from b597ec7 to 78fb147 Compare September 5, 2026 18:33
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.

2 participants