Skip to content

feat(storage): SnapshotEngine, a consistent table snapshot on every backend [backup/restore 4/4] - #395

Merged
robinnsc merged 1 commit into
mainfrom
feat/table-snapshot
Oct 30, 2026
Merged

robinnsc merged 1 commit into
mainfrom
feat/table-snapshot

Conversation

@robinnsc

@robinnsc robinnsc commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Backup/restore series (design: #396): #384 restore fidelity (merged) · #393 store · #394 format · #395 SnapshotEngine · next: PITR surface (#335 rebased), catalog index, backup job, restore job.
Closes #628 (part of #624).

Stacked on #394 (base branch feat/backup-format-streaming): SnapshotInfo is #394's SnapshotDescriptor and the orderability test is its KeyOrder::resolve. The diff shows this PR's single commit on top of #394; GitHub retargets it to main when #394 merges.

What

The one new obligation the engine-owned backup path places on a backend: open a consistent view of one table and stream its items. The trait returns the table definition, a snapshot marker, an ordering flag, and the item stream together, from one operation, so a backup's manifest and data files always describe the same instant. The in-catalog backup code in #384 keeps its own copy of this logic; the engine-side backup job (next PR) is the first caller.

Trait (crates/storage/src/snapshot.rs):

pub struct TableSnapshot {
    pub description: TableDescription,   // the storage-level description at the snapshot instant (not the API response: restore_summary may be set, table_throughput_mode_summary is not, and the counts are the stats pass's)
    pub info: SnapshotInfo,              // = extenddb_core::backup_format::SnapshotDescriptor { kind, marker }
    pub key_ordered: bool,               // items arrive in base-table key order
    pub items: BoxStream<'static, Result<Item, StorageError>>,  // owns the transaction/session
}
pub trait SnapshotEngine: Send + Sync {
    fn open_table_snapshot(&self, account_id: &str, table_name: &str)
        -> BoxFuture<'_, Result<TableSnapshot, StorageError>>;
}

Dropping the stream releases the backend transaction or session. A stream that has not reached its end (including one never polled) must be dropped inside a Tokio runtime: the sqlx backends return its connection through the runtime and panic outside one, MongoDB does not, and the trait doc says so. The first Err ends the stream on every backend. StorageEngine now requires SnapshotEngine; all four in-tree backends implement it. key_schema_is_orderable is KeyOrder::resolve(..).is_ok(), so a stream a backend marks ordered is one the manifest parser accepts, and a table whose range key has no attribute definition streams unordered and complete instead of paging by the hash key alone. The trait doc lists each backend's errors (Cassandra answers Unsupported before it resolves the table). StorageError::SnapshotExpired is new, for a snapshot the server discarded before the stream drained; the engine maps it to a retryable 503 like Transient. snapshot.rs's module doc gains one sentence (the design overview states the rules the trait's callers honour; this file's and each backend's snapshot_engine.rs module docs are the record of what the snapshots do); DEFAULT_FETCH_BATCH's doc now says why the size is a constant (over a 1 ms link the larger fetch would save about a second per million rows); the PostgreSQL module doc's pre-#361 paragraph carries the sort's cost (7 MB on disk for 100k rows at work_mem 4 MB).

Indexes. The description keeps every index in whatever build state it had and drops only one being deleted (drop_deleting_indexes, for GSIs and vector indexes; no in-tree backend reports a DELETING index today, and unit tests pin the rule); on PostgreSQL a GSI row whose data table is missing is also left out, with the attribute definitions only it used, as the in-catalog backup does (since #401 UpdateTable creates the data table first, so this is a guard). A restore recreates the indexes in the description and builds them from the items, so a CREATING index comes back complete; an index left out would not come back at all.

PostgreSQL. On one shared database the open uses one connection and one REPEATABLE READ READ ONLY transaction whose first statement reads the tables row, so the definition and the items come from one MVCC snapshot and no row lock is taken; a DeleteTable that drops the data table between that snapshot and the open's LOCK TABLE makes the open TableNotFound. With a separate data database the tables row is held FOR SHARE on a catalog connection from the definition read until the data transaction (REPEATABLE READ READ ONLY, ACCESS SHARE on the relation) has pinned its snapshot with SELECT pg_current_snapshot() and the GSI data-table probe has run; the definition is read on that barrier connection (build_table_description_on), so an open holds one catalog connection and the stream one data connection. UpdateTable and DeleteTable take the row FOR UPDATE, so neither commits in the gap; index-status workers do not, so an index may be reported in a slightly older status than the items. The marker is pg_current_snapshot() text on both layouts. An engine-wide permit is held per snapshot from the open until the stream's transaction ends, half the pool on either layout, and an open over the bound is LimitExceeded at once, so open snapshots leave half of the pool their items come from to requests (with a separate data database and the whole pool allowed, ten open snapshots made GetItem wait out the pool timeout); restores' own half-pool slots are separate, so snapshots and restores together can hold the whole pool, and the field doc says so. Items stream through a server-side cursor, FETCH 1000 per round trip, ordered by the primary key; a fetch is bounded by rows, not bytes (up to 27.7 MB of text per row at the default item limit), and the byte budget is the backup job PR's. A table created before #361 declared the key columns COLLATE "C" keeps the database collation; ordering it bytewise would sort the whole table before the first row while the lock, connection and permit are held, so such a table is streamed unordered with a warning. Only a collation named "C" or "POSIX" counts as bytewise; "default" on a C database streams unordered, which is complete. SQLSTATE 40001 (a recovery conflict on a standby) and 72000 (snapshot_too_old on a primary, PostgreSQL 16 and earlier) map to SnapshotExpired. The stream holds ACCESS SHARE until its last fetch, so a DROP TABLE waits behind it; DeleteTable takes the asynchronous path (row DELETING, worker drop under a lock timeout) when another session holds ACCESS SHARE on any relation it would drop (the base, GSI, LSI and vector-index data tables: data::table_storage_relations, the one list drop_table_storage also drops), probing only when its answer is used (delay < 1 s) and, on the shared layout, on its own catalog transaction rather than a second connection from the same pool (twenty concurrent DeleteTables on a ten-connection shared pool timed out without that). On both layouts the synchronous path takes every drop's lock before the catalog removal commits. It deletes the table's pending queue rows first, waiting for in-flight claims with no timeout, as #401 intends. It then drops the base, GSI, LSI and vector-index data tables, base first, within one 1 s budget for the group (SYNC_DROP_LOCK_TIMEOUT; before each DROP, lock_timeout is the time left). On the shared layout that runs in the catalog transaction behind a savepoint. With a separate data database it runs in a data transaction opened first, and a task the request awaits commits the catalog and then that transaction, so a cancelled caller cannot stop between the two. A lock wait that fails (55P03, a deadlock victim 40P01, or a statement_timeout's 57014) rolls the drops back and takes the asynchronous path with the row still held. A write transaction, a SHARE holder, an index build or a reader that starts after the probe therefore costs the request at most the 1 s, and no data table is left without its row. With a separate data database a data commit that fails after the catalog commit has the drop retried on a fresh connection for up to 60 s, so a failover, a dropped connection or a network blip no longer orphans the data tables; only a process that dies, or a data database unreachable for that whole window, between the catalog commit and the last retry leaves them without a row, and then they stay until an operator drops the relations the error log names (docs/troubleshooting.md, "Data tables without a catalog row"; catalog-check --fix cannot reclaim them on this schema). The base table goes first because a write holds it before it writes an LSI or a synchronous GSI: with the index tables first, a write in progress waited about 1 s on the drop, and the drop went asynchronous. On the shared layout a DeleteTable probing between an open's snapshot and its LOCK does not see the open; if the LOCK is granted first, the drop times out after 1 s, is rolled back with the removal, and DeleteTable returns DELETING (in 80 unaided open-and-delete pairs, the delete first in half of them, 0 left a data table without a row on either layout; 39 to 40 deletes ran synchronously and 40 to 41 asynchronously). The data transaction lives in a guard from BEGIN on, so every path that ends it after it may have taken ACCESS SHARE wakes the control-plane poller: the last page (before its commit is awaited), a fetch error, a stream dropped before its end, and an open that fails or is cancelled; the in-catalog backup's data transaction is held in the same guard and wakes it too. The poller stays active while a DELETING row's drop is deferred, also after a restart: startup recovery runs the same pass and wakes the poller when it deferred a drop. A drop is deferred when the probe finds another session holding any lock on the table's relations, or when the drop fails in a lock wait (55P03, 40P01 or 57014); any other failure is logged and left to the normal window and the 60 s idle pass. Each pass probes a held table and skips it without waiting, and drops a free one within the same 1 s budget, so every held table is retried about once a second however many are held, and a CreateTable that is due is activated on the same cadence (1.1 s with four drops deferred). A drop that DeleteTable deferred is due at once, not after control_plane_delay_seconds. The active loop waits on the wake as well as on its timer, so a snapshot's or an in-catalog backup's release has the drop retried at once, and any holder's release is followed within about a second (the maintainer measured 51.8 s before for a snapshot stream, and round 6 57 to 60 s for the in-catalog backup and a plain reader; now 108 to 166 ms after a snapshot or backup release or a plain reader's, 0.94 to 1.08 s after a release that spans a restart (the reader is released only once the poller is past its window), and 108 to 164 ms for the first of four held tables, in the tests). The admin guide's control_plane_delay_seconds row and the troubleshooting page's DELETING entry state the default (0.25 on every backend), the bound and the retry cadence, the same-name CreateTable refusal until then, and a two-step query, over every relation of the table, that finds any session holding a lock on them and its lock mode. A DeleteTable of a table that is already DELETING returns its description and changes nothing.

SQLite. One deferred read transaction covers catalog and data; its first read pins the WAL snapshot and no lock is taken, so writers never wait on a snapshot being opened. A TRUNCATE checkpoint meanwhile waits out the busy timeout and reports busy = 1; the module doc gives the measured reply. build_table_description_on reads the definition, including #384's restore_summary, on that transaction. The stream holds a slot from #384's backup_server_slots until its read transaction ends (the short page that shows the end, or the first error) or it is dropped (the pool arithmetic that keeps half the pool free counts snapshots); with none free the open returns LimitExceeded at once, and a CreateBackup that waited out its 30 s for a slot says that backups or table snapshots hold them; the pool_size doc gives the arithmetic. Items page through row-value keyset pagination on the primary key index; a page is bounded by rows, not bytes, as on PostgreSQL. An in-memory database has one connection, so the open reads every page in its transaction and returns a stream over the copy, holding no connection once it returns; the copy's stream keeps its backup slot until it has yielded its last item or is dropped, so the slots bound how many decoded copies exist. This is the shape #384's create_backup_single_connection uses, so CreateBackup keeps working on sqlite-memory. SQLite DeleteTable of a DELETING table (control_plane_delay_seconds of 1 or more) returns its description too.

MongoDB. A session started with snapshot: true reads the tables document, the indexes documents and the base collection at one cluster time, which is the marker. Every read sets readConcern: snapshot explicitly, because the driver only fills it in when neither the operation nor its database carries one, and the production catalog handle carries readConcern: local (as any handle does under a URI with readConcernLevel=); on that handle a read without the level fails: the first reports no snapshot time, which the open refuses naming the read concern, and later ones are refused by the server with code 72 (InvalidOptions). A server that is not a replica set member refuses the first read with NotAReplicaSet (123), mapped to Unsupported; that refusal and the no-snapshot-time refusal each name their own cause. The index read in the session maps 239/246 like the others. Error messages carry the server's code name and message, not the driver's dump of the reply. A document the stream cannot decode ends it with that error. SnapshotTooOld (239) and SnapshotUnavailable (246) map to SnapshotExpired; the server keeps the snapshot for at least minSnapshotHistoryWindowInSeconds and may discard it after, and an idle cursor times out after cursorTimeoutMillis (10 minutes; CursorNotFound maps to Internal). key_ordered is false: a composite key's _id is a netstring of the key text, not the format's order.

Cassandra. open_table_snapshot returns Unsupported. Cassandra has no read that fixes an instant across a table (a token-range scan observes each range at a different time; LWT serializes single partitions), and its in-catalog logical backup says so (ADR-0019). Returning that copy under this trait's contract would make the manifest's snapshot member false, so the backend refuses and the in-catalog path stays its backup path until the design settles it (#396 section 3).

Test hooks. A fetch-size knob, a round-trip counter and a pause at two points of the open (AfterDefinition, BeforeItems) live in extenddb_storage::snapshot::paging; every backend reads the fetch size there and calls the pause. PostgreSQL and SQLite record each FETCH or page query; for MongoDB, whose driver hides getMore, the test fixture records, through command monitoring, each find the server receives on the data database at readConcern snapshot (the stream's; the GSI backfill's find reads at majority) and each getMore on a cursor those finds returned, so the count is the snapshot stream's paging as the server saw it. They and the conformance check compile only with extenddb-storage's test-hooks feature, which each backend crate's tests enable through a dev-dependency on itself (on MongoDB through a new snapshot-test-hooks, so the crate's existing coordination test-hooks stays off in its unit tests, as on main); production builds carry none of them. PostgreSQL also gains test-hooks entries spawn_control_plane_poller_for_tests and spawn_control_plane_poller_with_active_poll_for_tests (the production poller, the second with its active retry interval as a parameter), recover_control_plane_at_startup_for_tests, control_plane_drop_attempts_for_tests (a per-engine count of drop attempts, a held table the probe skipped counted) and arm_delete_table_pause (holds DeleteTable between its probe and its drops), and re-exports, under test-hooks, the data relation names and apply_all_migrations (the three extenddb migrate runners), so tests/table_snapshot.rs binds the crate's lists instead of copying them. The two pauses (the open's and DeleteTable's) are one generic type, extenddb_storage::test_pause::Pause, and snapshot_conformance::PageSize is the one page-size guard the three backends' tests use. tests/table_snapshot.rs fails instead of skipping without a connection string when EXTENDDB_TEST_PG_REQUIRED is set, which integration.yml sets. The trait doc says why SnapshotEngine has no default body and StorageError is not #[non_exhaustive]: both breaks are meant. devtools/run-mongodb-tests publishes its container on 127.0.0.1 only.

Why

#396 section 3. An earlier draft of the trait returned items only and left the definition to a separate DescribeTable; a GSI added or the table deleted between the two calls produces a manifest that does not match its data files. Returning both from one operation is the only way the backup job can promise the manifest describes the data, and it has to be a backend obligation because the mechanism differs per backend.

Four adversarial review rounds on this PR found, and this revision fixes: the conformance check was inert at the production page size, then depended on the backend test setting the page size, then could not observe whether the knob had taken effect (it now sets the shared knob and asserts from the counter that a second fetch happened); the MongoDB engine inherited the client's read concern and the constant for SnapshotTooOld was SnapshotUnavailable's; an ACTIVE-only index filter dropped a GSI mid-backfill; the PostgreSQL ORDER BY lacked COLLATE "C", so a pre-#361 table streamed in locale order under a key_ordered flag and the design's restore would have refused a valid backup; the PostgreSQL open took two catalog connections and could deadlock a full pool; the MongoDB tests dropped fixed database names, so a URI in the environment could destroy another instance's data; the SQLite path held the writer lock across a pool acquire and bypassed the slot accounting; a missing sort-key attribute definition made the stream page by hash key alone and lose rows; SnapshotInfo and the orderability rule were second copies of #394's definitions. The fourth round: the MongoDB counter echoed the batch size the stream asked for (a cursor without batch_size passed); a synchronous DeleteTable during a stream could orphan the data table; a half-created GSI left an unused attribute definition CreateTable refuses; a shared catalog/data pool could deadlock concurrent opens; the Cassandra test created no table; the harness's key-order oracle was a third statement of the order. An earlier review by the maintainer then found: nothing checked that the definition and the items come from one instant or that the stream read after the writer committed (READ COMMITTED, a missing FOR SHARE, a MongoDB read outside the session, and a SQLite stream that read everything during the open all passed); only one key shape was tested; with a separate data database the bound was the whole pool, and ten snapshots made GetItem fail after 30 s; DeleteTable probed only the base data table, so a reader on a GSI table left the catalog row gone and the drop blocked; the collation clause turned a legacy table's read into a full sort; a MongoDB decode error did not end the stream and a standalone server reached the client as a 500; the SQLite refusal blamed backups when snapshots held the slots; and sqlite-memory lost a working CreateBackup. The fifth round found: on the shared layout DeleteTable took a second connection from the pool it already held one from, and twenty concurrent deletes on a ten-connection pool timed out; the open took two connections from one pool on the shared layout; four release paths (a drained stream, a fetch error, an open that failed or was cancelled) did not wake the poller; the one-instant scenario passed with the pauses outside the read window; the SQLite rowid page was untested and the MongoDB counter counted the GSI backfill's reads; a foreign test's open could take the armed pause; N range keys were not among the key shapes; in-memory SQLite copies were not bounded by the slots. Round 6 found that the one-second DeleteTable bound held only for readers that woke the poller (a snapshot stream), while a plain read, pg_dump or the in-catalog backup left the row DELETING until the 60 s idle pass; the poller now stays active while a drop is deferred, the backup wakes it too, and the conformance check asserts the branch it depends on, compares the description with DescribeTable, and bounds every drain. The maintainer's review of 2026-10-28 found: on one shared database an open's window between its snapshot and its LOCK let a synchronous DeleteTable commit the removal and wait in DROP TABLE with no timeout (6 of 80 unaided races left the data tables without a row); the release wake was inert because the active poller slept on its timer alone; startup recovery discarded the deferred count, so a reader spanning a restart left the row DELETING until the 60 s idle pass; every drop failure kept the poller in its one-second loop; the troubleshooting query matched only the base table; and the conformance check cleared restore_summary. Round 7 found: with a separate data database a DeleteTable that met a write transaction, a SHARE holder, an index build or a queue claim waited with no bound after the catalog commit, and a caller cancelled or timed out in that wait left the data tables without a row; a lock timeout per DROP let a table with n indexes stall its base table n seconds; the poller retried deferred drops one after another at 3 s each, delaying every other transition by 3 s per held table; the troubleshooting query found only readers; DeleteTable of a DELETING table returned ResourceNotFoundException; a deferred drop waited out control_plane_delay_seconds; a deadlock victim or a statement timeout in the drop was an InternalServerError; and SQLite kept a finished or failed stream's backup slot. Round 8 found: with a separate data database a data commit that failed after the catalog commit (a terminated backend, a failover) left the data tables orphaned with the row gone, the client told the delete had failed, and nothing to reclaim them.

Testing done

extenddb_storage::snapshot_conformance::run is one check every backend runs against a live database. It sets the shared fetch size to 50, opens a snapshot on a 700-item composite-key table with a GSI, reads 51 items and asserts the round-trip counter advanced by at least two since just before the open, then a writer deletes 100 items, inserts 100, and overwrites all 600 remaining items twice before the reader drains. It asserts 700 items, no duplicates, every deleted item present, no inserted item, every version at its pre-open value, that the counter advanced after the writer committed, key_ordered as the backend declares it and strictly ascending keys by KeyOrder::compare when ordered, and that a second snapshot after the writer stops shows the final round. Then, once per pause point and on a fresh table, it holds the open while it commits a write W1, an UpdateTable adding a GSI and a write W2 (in that order; on PostgreSQL with a separate data database the UpdateTable waits for the row lock and the open resumes after 2 s), and asserts what one instant implies: a description with the GSI has W1, items with W2 have the GSI in the description, and W2 is not in the items at all, since the pause is inside the open's read window (a backend whose pauses sat before both reads passed the two implications), and which of W2's commit and the 2 s timeout resumed the open, against the backend's declared Expectations::update_table_waits_at (PostgreSQL with a separate data database at AfterDefinition: the timeout; everywhere else: W2), so a slow UpdateTable cannot make the W2 assertion vacuous. Before the writer starts, the snapshot's description must equal the storage layer's describe_table with the running counts and table_throughput_mode_summary cleared; restore_summary is compared, and each backend's suite also compares a table given a restore record (assert_snapshot_describes_the_table). Every drain is under a 120 s deadline naming the stage and the items read, and a test waiting for the hooks lock gives up when one holder has kept it 600 s, naming the wait. Six more key shapes (hash-only S, N and B; S hash with S, N or B range) run at a page of 3, with keys whose byte, collation and base64 orders differ and range numbers whose text and numeric orders differ (negative, fractional, 1E-130, 9.9E125, and 38- and 34-digit pairs a last digit apart; the check's Expectations say how many digits a backend stores, and MongoDB's refusal of the 38-digit pair at the put, Decimal128's 34 digits, is asserted). Every test that opens a snapshot or touches the hooks holds snapshot_conformance::hooks_lock(), whose guard run and run_refusals take as an argument, so no other test in the binary can take the pause, reset the knob or feed the counter during a check. These mutants fail it: a fresh transaction per page or every page read during the open on SQLite, and >= in its hash-only or binary range keyset; OFFSET/LIMIT in READ COMMITTED, READ COMMITTED with the cursor, and no FOR SHARE on PostgreSQL; the data cursor or the index read outside the session on MongoDB; the pauses moved outside the read window on SQLite or MongoDB; the pauses moved outside the read window with a 2.5 s UpdateTable (the branch assertion); a description built from a second code path (the DescribeTable comparison); a stream that never ends (the drain deadline, within 120 s instead of hanging the binary). run_refusals covers a missing and a non-ACTIVE table on the three backends that implement snapshots. 27 hand mutants were built for round 4; 25 fail on the assertion they target, and the two that remove COLLATE "C" from the PostgreSQL clause pass because, with legacy tables streamed unordered, the clause is equivalent on every table that is ordered (kept as defence in depth and marked so). Round 5: 18 more mutants (DeleteTable's probe on a second pooled connection, the barrier shape on the shared layout, each wake site removed, the 42P01 mapping, the fetch-error path keeping its slot, no LOCK TABLE, the last page keeping its transaction, the barrier released before the pause, the pauses moved outside the read window on SQLite and MongoDB, rowid >=, the in-memory copy dropping its slot, the MongoDB counter counting every find); 17 fail on the assertion they target, and the barrier released between the pause and the pin passes, because no statement sits between them and UpdateTable takes over a second after the barrier goes. Round 6: the poller breaking at its deadline regardless fails the plain-reader test on both layouts (two earlier shapes of that test, an 8 s and a 12 s hold with the poller started before the DeleteTable, did not catch it: the window's last drop attempt was still waiting out its lock timeout, and the DeleteTable's own wake chained a second window); the SQLite pauses moved before the transaction with a 2.5 s UpdateTable fail the branch assertion; a SQLite open blanking the ARN, id and summaries fails the description comparison; a SQLite keyset stream without its end branch fails the drain deadline. For the maintainer's review of 2026-10-28: 9 mutants (the shared-layout drops after the commit; the notify removed from release and from finish; the active loop ignoring the notify; startup not waking the poller; every drop failure counted; the snapshot dropping restore_summary on PostgreSQL, SQLite and MongoDB), each failing on the assertion it targets. Round 7: 17 mutants (the separate layout's commit-then-drop order; a lock timeout on the pending-row delete; a timeout per DROP; index tables dropped first; the poller's probe skipped; activation after the drops; the DELETING filter on PostgreSQL and on SQLite; the deferred row scheduled after the delay; 40P01 or 57014 left out of the classifier; the poller idle at its window's end; DeleteTable's probe never finding a reader; the SQLite slot kept on the short page, on the error and in the in-memory copy; description_diff keeping commas). 16 fail on the assertion they target. Activation after the drops passes, since with the probe the drops no longer take time. Round 8: 2 mutants (the failed data commit returned instead of retried; the retry run inline instead of in the awaited task), each failing on the assertion it targets (the relations stay and the request errors; a cancelled caller strands the data tables).

Passes locally on PostgreSQL 16 (both pool layouts), SQLite (the shared check on a file-backed database; the in-memory copy has its own tests, since a stream that reads every page during the open fails the check's fetch-after-commit assertion by design), a MongoDB 7 replica set (devtools/run-mongodb-tests --mongo-only) and a standalone mongod, and Cassandra 4.1:

  • PostgreSQL: crates/storage-postgres/tests/table_snapshot.rs, 81 tests (and two ignored race measurements): 36 run on both layouts (on_both_layouts!): consistency; marker and key order; legacy-collation tables streaming unordered and complete on a scratch database checked not to be C-collated, beside an ordered one; DeleteTable with only a GSI or a vector-index data table locked taking the asynchronous path; an unparseable row ending the stream and freeing its permit; a GSI without its data table left out with its attribute; DeleteTable during a snapshot returning DELETING; twenty concurrent DeleteTables on a ten-connection pool; an open held while nine DeleteTables queue; an open completing with nine of ten connections held; the production poller at a 10 s active poll woken during its pause, and the table gone within 3 s, by a dropped stream, a drained stream, a fetch error, an open cancelled at BeforeItems and the in-catalog backup; a reader across a restart; a drop failing on a dependent view kept to the normal window; a restored table's restore_summary; a plain reader holding ACCESS SHARE for 10 s past the poller's window, sending no wake, with the table gone within 3 s of its release; a fetch error freeing the permit; DeleteTable at BeforeItems taking the asynchronous path; a drained stream out of its transaction before its end is polled; a ROW EXCLUSIVE and a SHARE holder sending DeleteTable down the asynchronous path within about 1 s with no data table left without its row; a DeleteTable cancelled during that wait leaving the table ACTIVE with its data; write holders on the base and three GSI tables released 0.8 s apart taking it there at about 1 s; a write holding the base and then writing the GSI table finishing first, with the table removed in the request; a claimed queue row holding DeleteTable until it ends; a probed reader sending it asynchronous in milliseconds; a statement timeout and a deadlock victim taking the asynchronous path; DeleteTable of a DELETING table returning its description and changing nothing; a deferred drop due at once; four held tables probed with no lock wait, the first released gone within 2 s and a CreateTable ACTIVE within 2 s. Plus five held snapshots on a ten-connection pool on each layout, with GetItem and DescribeTable unblocked, the sixth LimitExceeded and an open succeeding after one is dropped; the SQLSTATE mapping raised on the live server; and, on the shared layout, a snapshot open or a GSI-only reader locking after DeleteTable's probe sending it down the asynchronous path, and a table dropped before the open's LOCK being TableNotFound; and, on the separate layout, a data commit that fails after the catalog commit (its backend terminated at a pause between the two) retried until the drop goes through, with the request returning DELETING, the row gone and no relation left; a data database refusing connections past a bound shortened to 500 ms returning Internal naming every leaked relation, with the row gone; and a caller cancelled between the two commits, with the detached task completing the drop and no orphan at any step. The two ignored race measurements delay the delete or the open in alternate pairs and assert both outcomes occurred. Every DeleteTable runs under a caller timeout (300 ms in the cancellation test). Added to the direct-storage step in integration.yml. Scratch databases are dropped by a Drop guard on success or panic. The full storage-postgres suite (259 tests), fix(postgres): build a GSI added by UpdateTable correctly while writes continue #401's GSI build tests included, passes.
  • SQLite: 11 in-module tests (consistency; key order; the in-memory copy releasing its connection; in-memory copies holding their backup slot; a damaged attribute definition streaming unordered and complete at a page of seven with no duplicates and at least six fetches; slots shared with CreateBackup and its refusal text; the WAL checkpoint reply while a snapshot is held; a restored table compared on a file-backed and an in-memory engine; a drained stream and a stream that hit an error freeing their slot while still held; an in-memory copy freeing its slot with its last item), run by cargo test --workspace; scratch files under the test binary's target directory, removed by a Drop guard.
  • MongoDB: 11 in-module tests: live tests on per-test scratch databases (snap<uuid>_cat/_data, built the way fix(backup): restore sort keys, indexes, and throughput; bound memory; recover abandoned restores #384's live tests build theirs with the production catalog options, with the GSI backfill worker running and the command monitor feeding the counter, dropped by a guard on success or panic), gated on EXTENDDB_TEST_MONGODB_URI and failing rather than skipping when EXTENDDB_TEST_MONGODB_REQUIRED is set, holding the snapshot hooks lock (consistency; a restored table's restore fields compared; readConcernLevel=majority in the URI; an undecodable document ending the stream; reads without the snapshot level failing with code 72, with the error text checked; the round-trip counter counting only the snapshot stream, so a GSI backfill leaves it unchanged; a standalone server answering Unsupported, gated on EXTENDDB_TEST_MONGODB_STANDALONE_URI, which the CI job sets against a standalone mongod it starts, and failing rather than skipping when EXTENDDB_TEST_MONGODB_REQUIRED is set without it); and unit tests pinning the error-code mapping from reply documents. A new integration-mongodb.yml job (in the fan-in) starts a replica set through devtools/run-mongodb-tests --mongo-only and a standalone mongod, both on 127.0.0.1 only, and runs them (storage-postgres: UpdateTable awaits the GSI backfill inside the request, so large tables time out at the client #654).
  • Cassandra: a direct test asserting Unsupported naming the backend on an existing table and a missing one.
  • Storage: unit tests for drop_deleting_indexes (GSIs and vector indexes).
  • Engine: SnapshotExpired maps to ServiceUnavailable (storage_error_mapping_tests).
  • Wire: tests/test_table_operations.py's DeleteTable of a DELETING table returning its description, against PostgreSQL and SQLite servers (the state written into the catalog); it skips on MongoDB and Cassandra, which never leave a table DELETING.
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo clippy ... -- -W clippy::pedantic                                             # 0 findings on the lines this PR adds and in the files it adds (storage crates and extenddb-engine)
cargo check --workspace --all-targets                                                # with test-hooks via dev-deps
cargo check -p extenddb-storage -p extenddb-storage-postgres -p extenddb-storage-sqlite -p extenddb-storage-mongodb -p extenddb-storage-cassandra --lib   # without
CARGO_TARGET_DIR=target/t188 cargo +1.88.0 check --locked --workspace --all-targets
cargo test -p extenddb-storage -p extenddb-engine -p extenddb-server                 # 132, 179, 202 passed
cargo test -p extenddb-storage-sqlite --lib snapshot_engine                          # 11 passed
EXTENDDB_TEST_PG_REQUIRED=1 EXTENDDB_TEST_PG_CONNECTION_STRING=... cargo test -p extenddb-storage-postgres --test table_snapshot   # 81 passed, 2 ignored
EXTENDDB_TEST_PG_REQUIRED=1 EXTENDDB_TEST_PG_CONNECTION_STRING=... cargo test -p extenddb-storage-postgres                         # 259 passed
EXTENDDB_TEST_MONGODB_URI=... EXTENDDB_TEST_MONGODB_REQUIRED=1 EXTENDDB_TEST_MONGODB_STANDALONE_URI=... cargo test -p extenddb-storage-mongodb --lib snapshot_engine   # 11 passed
EXTENDDB_TEST_CASSANDRA=required cargo test -p extenddb-storage-cassandra --test direct_integration snapshot_engine   # 1 passed

Not changed here

  • The design's max_concurrent_jobs is the caller-side bound, in the catalog PR; this PR's per-server bounds (SQLite's slot, PostgreSQL's permit) only keep snapshots from exhausting a pool.
  • The in-catalog backup path in fix(backup): restore sort keys, indexes, and throughput; bound memory; recover abandoned restores #384 does not call this trait.
  • The PostgreSQL and SQLite fetches are bounded by rows, not bytes; the byte budget is the backup job PR's (design section 7).
  • DeleteTable's returned description omits VectorIndexes (both the ACTIVE and the new DELETING branch reuse build_table_description_from_row, which never populated them); this predates the PR (present on main) and VectorIndexes is an ExtendDB extension absent from DynamoDB, so no client answer disagrees with the service (storage-postgres: DeleteTable's response omits VectorIndexes that DescribeTable reports #656).
  • The drops take the base table before the index tables, not after: a write holds the base before it writes an LSI or a synchronous GSI, and the other order made such a write wait about 1 s on the drop.
  • Four issues round 7 found outside this diff are present on main (cf321f0) and out of scope here:
    • extenddb catalog-check --fix expects data relations named _ddb_{account}_{table} and _ddb_{account}_{table}__gsi__{index}, while main names them _ddb_{table_id} and _ddb_{index_id}, so it reports every live data table as orphaned and --fix drops it; it does not see _ddb_vec_ tables either.
    • On the shared layout the catalog migration's stream_shards.table_id references tables(table_id), and CreateTable inserts the shards on its own connection before its catalog transaction commits, so a CreateTable with a stream specification fails the foreign key with an InternalServerError (extenddb init does not produce that layout).
    • UpdateTable that creates a GSI awaits the backfill inside the request, so on a large table the client's read timeout expires first (boto3's 60 s against 20,000 items); the build itself continues server-side.
    • Query and Scan with IndexName resolve the table and then the index in separate statements, so a DeleteTable committing between them yields ValidationException ("The table does not have the specified index") instead of ResourceNotFoundException.
  • Attribute pruning for a left-out GSI is not shared between the snapshot and the in-catalog backup (they prune different types); backup_engine::abort_restore builds its own drop list in fix(backup): restore sort keys, indexes, and throughput; bound memory; recover abandoned restores #384's code.
  • cargo clippy on the local 1.98 toolchain did not fire the chunks_exact lint in storage-sqlite/src/vector_search.rs seen in an earlier round; CI runs stable and 1.88.0 (test.yml).
  • docs/design/04-component-storage.md (DeleteTable described as always asynchronous) and docs/differences-from-dynamodb.md (a 5 s default delay) are pre-existing inaccuracies not edited here.
  • The CodeQL alert on this PR (cleartext-storage-database, storage-sqlite/src/backup.rs) is in a fix(backup): restore sort keys, indexes, and throughput; bound memory; recover abandoned restores #384 test on main that this PR does not touch.

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic) for the files this PR adds
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk format, or public CLI surface, an RFC has been accepted or is linked below. Otherwise, an ADR captures the decision (link below).

ADR / RFC: #396, section 3. Storage trait: SnapshotEngine is added to the StorageEngine supertrait bound and implemented by all four in-tree backends; StorageError gains SnapshotExpired.

Breaking changes

  • Out-of-tree storage backends must implement SnapshotEngine and handle the new StorageError::SnapshotExpired variant in any exhaustive match.
  • PostgreSQL DeleteTable returns DELETING and finishes in the background, instead of synchronously, when another session holds a lock on the table's data or index tables that its drops cannot get within about 1 s, on both layouts: at once for a reader (a table snapshot, the in-catalog backup, a Scan or Query, pg_dump, a plain read), after the 1 s for an open write transaction, an index build or VACUUM, and also when the drop is a deadlock victim or meets a statement_timeout. The table is gone within about a second of that session's release (at once when a table snapshot or in-catalog backup ends; also when the server restarted in between). Until then DescribeTable reports DELETING, ListTables lists it, a CreateTable of the same name is refused with ResourceInUseException, and a DeleteTable of it returns its description, which on PostgreSQL and SQLite used to be ResourceNotFoundException. The response shape is unchanged. No wire, CLI or on-disk change.

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.

@robinnsc robinnsc changed the title feat(storage): SnapshotEngine, a consistent table snapshot on every backend feat(storage): SnapshotEngine, a consistent table snapshot on every backend [backup/restore 4/4] Oct 13, 2026
@robinnsc
robinnsc force-pushed the feat/table-snapshot branch from 3c2a30e to 12f5841 Compare October 13, 2026 06:50
robinnsc added a commit that referenced this pull request Oct 13, 2026
…t code

Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture, and two review rounds against the companion PRs #393,
#394 and #395. The revision log at the end of the document lists each
change; the ones that alter behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). The items-only signature in
  the draft allowed a GSI change or DeleteTable between the definition
  read and the data snapshot. Indexes are reported in whatever build
  state they had and only an index being deleted is dropped; an
  ACTIVE-only filter would have left a GSI mid-backfill out of the backup
  for good.
- The snapshot is opened in the CreateBackup request so the backup
  represents the request time, as DynamoDB documents;
  backup_creation_date_time is the instant the snapshot pinned.
- Per-job claims with fencing replace the single-instance lease the
  draft assumed; PostgreSQL and MongoDB have no such lease. The claim is
  an advisory lock on a dedicated catalog connection on PostgreSQL (the
  seed #384's restore lock already uses), the row itself on MongoDB, an
  in-process slot on SQLite; no row ever exists unowned. Heartbeats are a
  timer, staleness is measured on the database's clock, and a failed
  heartbeat aborts the job. The reconciler resolves a dead CREATING row
  at once under a fresh generation conditioned on status = CREATING and
  deletes its prefix only after the stale window.
- DeleteBackup tombstones the row as DELETING, deletes the manifest
  first, and marks DELETED only when the prefix is empty; backup sync is
  scoped to the configured store and never adopts a CREATING, DELETING,
  DELETED or FAILED prefix. Failed creations and deleted backups are
  BackupNotFound, as on the service, and share one retention.
- Restore returns CREATING from the request (table and restore_jobs row
  created atomically; ResourceInUseException on a name race,
  LimitExceededException over the job limit), spools and verifies every
  data file before writing any item, and records progress in a way a
  crash cannot split from the data: a data-database row per file on
  PostgreSQL (data migration 005 reserved), the same transaction per
  10k-item batch on SQLite, idempotent re-application of one file on
  MongoDB followed by a document-count check against the manifest.
  Duplicate detection for unordered backups is the target's primary key,
  not an in-memory key set. The ACTIVE flip is conditional; DeleteTable
  on a restore target fails the job.
- The manifest self-checksum is replaced by a sidecar over the stored
  bytes; the draft's canonical form depended on serde_json formatting.
  backup_arn is the backup's identity and source_table must agree with
  it. N key values are normalized before key text or key order is
  computed; the SQL ORDER BY recipe includes COLLATE "C" on PostgreSQL.
- Index population on restore stays per backend via bulk_load; the
  shared restore-backfill worker the draft described exists only on
  MongoDB.
- Format additions: item_order, per-file key_range, md5 and
  uncompressed_bytes, explicit nulls, three-state vector_indexes of
  creation specs, optional etag, dataFileS3Key as the full object key,
  partition-aware BackupArn as the single ARN implementation.
- Filesystem store: owner-only root re-checked by every operation,
  owner-only rule on every descended directory including list and
  delete_prefix, ancestor ownership rule, objects 0600 and directories
  0700 at creation, put returns object metadata; the consequence for
  backup pickup agents is stated. The S3 conformance suite is mandatory
  in CI against moto (MinIO no longer publishes images).
- ListBackups paging and filtering, the restore override contract,
  sqlite-memory behavior, extenddb destroy leaving the store intact, a
  restore spool outside the store root, max_concurrent_jobs,
  heartbeat_interval_secs and a concrete pool-size rule are specified.
- Phase 1 is rewritten as the PR series with acceptance criteria and
  GitHub numbers. Migration numbers are reserved up front so the PITR PR
  is not on the critical path.

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
@robinnsc
robinnsc force-pushed the feat/table-snapshot branch from 12f5841 to 4e5a9b5 Compare October 26, 2026 02:38
robinnsc added a commit that referenced this pull request Oct 26, 2026
…t code

Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture, two review rounds against the companion PRs #393, #394
and #395, and a reconciliation against main 744ea29 after #384 merged in
its final form and the Cassandra backend landed. The revision log at the
end of the document lists each change; the ones that alter behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). The items-only signature in
  the draft allowed a GSI change or DeleteTable between the definition
  read and the data snapshot. Indexes are reported in whatever build
  state they had and only an index being deleted is dropped; an
  ACTIVE-only filter would have left a GSI mid-backfill out of the backup
  for good.
- The snapshot is opened in the CreateBackup request so the backup
  represents the request time, as DynamoDB documents;
  backup_creation_date_time is the instant the snapshot pinned.
- Per-job claims with fencing replace the single-instance lease the
  draft assumed; PostgreSQL and MongoDB have no such lease. The claim is
  an advisory lock on a dedicated catalog connection on PostgreSQL (the
  seed #384's restore lock already uses), the row itself on MongoDB, an
  in-process slot on SQLite; no row ever exists unowned. Heartbeats are a
  timer, staleness is measured on the database's clock, and a failed
  heartbeat aborts the job. The reconciler resolves a dead CREATING row
  at once under a fresh generation conditioned on status = CREATING and
  deletes its prefix only after the stale window.
- DeleteBackup tombstones the row as DELETING, deletes the manifest
  first, and marks DELETED only when the prefix is empty; backup sync is
  scoped to the configured store and never adopts a CREATING, DELETING,
  DELETED or FAILED prefix. Failed creations and deleted backups are
  BackupNotFound, as on the service, and share one retention.
- Restore returns CREATING from the request (table and restore_jobs row
  created atomically; ResourceInUseException on a name race,
  LimitExceededException over the job limit), spools and verifies every
  data file before writing any item, and records progress in a way a
  crash cannot split from the data: a data-database row per file on
  PostgreSQL (data migration 005 reserved), the same transaction per
  10k-item batch on SQLite, idempotent re-application of one file on
  MongoDB followed by a document-count check against the manifest.
  Duplicate detection for unordered backups is the target's primary key,
  not an in-memory key set. The ACTIVE flip is conditional; DeleteTable
  on a restore target fails the job.
- The manifest self-checksum is replaced by a sidecar over the stored
  bytes; the draft's canonical form depended on serde_json formatting.
  backup_arn is the backup's identity and source_table must agree with
  it. N key values are normalized before key text or key order is
  computed; the SQL ORDER BY recipe includes COLLATE "C" on PostgreSQL.
- Index population on restore stays per backend via bulk_load; the
  shared restore-backfill worker the draft described exists only on
  MongoDB.
- Format additions: item_order, per-file key_range, md5 and
  uncompressed_bytes, explicit nulls, three-state vector_indexes of
  creation specs, optional etag, dataFileS3Key as the full object key,
  partition-aware BackupArn as the single ARN implementation.
- Filesystem store: owner-only root re-checked by every operation,
  owner-only rule on every descended directory including list and
  delete_prefix, ancestor ownership rule, objects 0600 and directories
  0700 at creation, put returns object metadata; the consequence for
  backup pickup agents is stated. The S3 conformance suite is mandatory
  in CI against moto (MinIO no longer publishes images).
- ListBackups paging and filtering, the restore override contract,
  sqlite-memory behavior, extenddb destroy leaving the store intact, a
  restore spool outside the store root, max_concurrent_jobs,
  heartbeat_interval_secs and a concrete pool-size rule are specified.
- Reconciled with #384 as merged: the restore job record extends #384's
  table_restores / provenance fields rather than adding a table; the
  backup id (14-digit, new_backup_id) and ARN parser #384 introduced are
  the one implementation, moved into extenddb-core by PR 4 and
  re-exported by storage; the claim names #384's RESTORE_LOCK_SEED, 15 s
  heartbeat and slot semaphores as what it replaces; defaults follow it
  (15 s heartbeat, 300 s stale window). Cassandra (#338) implements
  SnapshotEngine as Unsupported and keeps its logical backup until a
  token-range snapshot is accepted or the backend's backups are
  documented as unavailable, decided before PR 14.
- Phase 1 is rewritten as the PR series with acceptance criteria and
  GitHub numbers. Migration numbers are reserved up front so the PITR PR
  is not on the critical path.

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
@robinnsc
robinnsc force-pushed the feat/table-snapshot branch from 4e5a9b5 to 8a38f20 Compare October 26, 2026 06:49
robinnsc added a commit that referenced this pull request Oct 26, 2026
Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), three review rounds against
the companion PRs #393, #394 and #395, and a reconciliation against main
744ea29 after #384 merged in its final form and the Cassandra backend
landed. The revision log at the
end of the document lists each change; the ones that alter behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). The items-only signature in
  the draft allowed a GSI change or DeleteTable between the definition
  read and the data snapshot. Indexes are reported in whatever build
  state they had and only an index being deleted is dropped; an ACTIVE-
  only filter would have left a GSI mid-backfill out of the backup for
  good. - The snapshot is opened in the CreateBackup request so the
  backup represents the request time (stricter than the service's two-
  minute window). The backup id is minted from the instant the snapshot
  pinned, so backup_creation_date_time, the catalog's created_at and the
  ARN carry one time and the ListBackups cursor is a function of the
  ARN. - Per-job claims with fencing replace the single-instance lease
  the draft assumed; PostgreSQL and MongoDB have no such lease. The
  claim is an advisory lock on a dedicated catalog connection on
  PostgreSQL (the seed #384's restore lock already uses), the row itself
  on MongoDB, an in-process slot on SQLite, a lightweight-transaction
  row on Cassandra (restore targets only); no row ever exists unowned
  (the MongoDB row is born through an upsert pipeline so its first
  heartbeat is the server's clock). Heartbeats are a timer, staleness is
  measured on the database's clock, and any heartbeat that does not
  confirm the claim aborts the job. A job runs the failure path's
  delete_prefix only after its FAILED write matched a row; otherwise the
  claim holder owns the prefix. The reconciler resolves a dead CREATING
  row at once under a fresh generation conditioned on status = CREATING,
  leaves heartbeat_at untouched, and deletes the prefix only after the
  stale window; its duties are listed once, in section 4. A sink error
  fails the backup; there is no per-file restart, which the one-shot
  stream and the memory bound rule out. - DeleteBackup tombstones the
  row as DELETING, deletes the manifest first, and marks DELETED only
  when the prefix is empty; backup sync is scoped to the configured
  store and never adopts a CREATING, DELETING, DELETED or FAILED prefix.
  Failed creations and deleted backups are BackupNotFound, as on the
  service, and share one retention. - Restore returns CREATING from the
  request (table and restore_jobs row created atomically in a detached
  task; TableAlreadyExistsException or TableInUseException on a name
  race, as #384 already returns; LimitExceededException over the job
  limit), spools and verifies every data file before writing any item,
  and records progress in a way a crash cannot split from the data: a
  data-database row per file on PostgreSQL (data migration 005
  reserved), the same transaction per 10k-item batch on SQLite,
  idempotent re-application of one file on MongoDB followed by a
  document-count check against the manifest. Duplicate detection for
  unordered backups is the target's primary key, not an in-memory key
  set. The ACTIVE flip is conditional and precedes the deletion of the
  PostgreSQL restore_progress rows, which live in the data database and
  cannot share its transaction. DeleteTable on a restore target fails
  the job (a change from #384, which refuses it); multi-part-key tables
  are restored (#384 refuses them). The override paragraph now states
  what #384 ships: every override but VectorIndexOverride is applied,
  and the new path keeps that. - The manifest self-checksum is replaced
  by a sidecar over the stored bytes; the draft's canonical form
  depended on serde_json formatting. backup_arn is the backup's identity
  and source_table must agree with it. N key values are normalized
  before key text or key order is computed; #395's PostgreSQL ORDER BY
  carries COLLATE "C" so tables created before #361 stream in the
  format's order. - Index population on restore stays per backend via
  bulk_load; the shared restore-backfill worker the draft described
  exists only on MongoDB. - Format additions: item_order, per-file
  key_range, md5 and uncompressed_bytes, explicit nulls, three-state
  vector_indexes of creation specs, optional etag, dataFileS3Key as the
  full object key, partition-aware BackupArn as the single ARN
  implementation. - Filesystem store: owner-only root re-checked by
  every operation, owner-only rule on every descended directory
  including list and delete_prefix, ancestor ownership rule, objects
  0600 and directories 0700 at creation, put returns object metadata;
  the consequence for backup pickup agents is stated; list and
  delete_prefix resolve their prefix with the same no-follow walk as a
  key and tolerate entries a concurrent deleter removed. The S3
  conformance suite is mandatory in CI against moto, with the
  301/403/abort paths driven through an interceptor. Store settings live
  under [backup.store], matching #393. - ListBackups keeps #384's
  handler (full request already on main) over the catalog index merged
  with the legacy reader; sqlite-memory behavior, extenddb destroy
  leaving the store intact, a restore spool outside the store root,
  max_concurrent_jobs, heartbeat_interval_secs, a memory formula in
  bytes with the item-size term, and a pool-size rule that clamps and
  warns are specified. Restore and Describe never read the store without
  a catalog row; backup sync is the path in. - Reconciled with #384 as
  merged: the restore job record extends #384's table_restores /
  provenance fields rather than adding a table; the backup ARN parser
  and formatter #384 introduced are the one implementation, moved into
  extenddb-core by PR 4 and re-exported by storage (new_backup_id stays
  in storage and calls the shared formatter); the claim names #384's
  RESTORE_LOCK_SEED, lock key, 15 s heartbeat and slot semaphores as
  what it keeps or replaces; the 300 s stale window is new (#384 uses 60
  s). Cassandra (#338) implements SnapshotEngine as Unsupported and
  keeps its logical backup until a token-range snapshot is accepted or
  the backend's backups are documented as unavailable, decided before PR
  14. - Phase 1 is rewritten as the PR series with acceptance criteria
  and GitHub numbers. Migration numbers are reserved up front so the
  PITR PR is not on the critical path (Cassandra V006 to PR 1, V007 to
  PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill points
  for the crash tests are enumerated per backend.

Also adds the missing rows for designs 12 and 13 to
docs/design/README.md.

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
@robinnsc
robinnsc changed the base branch from main to feat/backup-format-streaming October 26, 2026 06:49
@robinnsc robinnsc closed this Oct 26, 2026
@robinnsc robinnsc reopened this Oct 26, 2026
@robinnsc
robinnsc force-pushed the feat/backup-format-streaming branch from d157769 to bab52ff Compare October 26, 2026 20:02
@robinnsc
robinnsc force-pushed the feat/table-snapshot branch from 8a38f20 to e6ec70a Compare October 26, 2026 20:02
LeeroyHannigan added a commit that referenced this pull request Oct 30, 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.

The tests #395 added for that call now expect the service's refusal
(12 of 12 runs on 2026-10-30).

Closes #505
LeeroyHannigan added a commit to yesyayen/extenddb that referenced this pull request Oct 30, 2026
…de (ExtendDB#396)

Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs ExtendDB#384, ExtendDB#335, ExtendDB#336, ExtendDB#337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), eight review rounds and the
maintainer's reviews against the companion PRs ExtendDB#393, ExtendDB#394 and ExtendDB#395, and
a reconciliation against main abf8082 (ExtendDB#384 in its final form, the
Cassandra backend ExtendDB#338, the PostgreSQL GSI build ExtendDB#401); then split so
that the document is the overview of the series and the companions' own
documentation is the record of what their code does (see the last
paragraph). The revision log at the end lists each change; the ones that
alter behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). Indexes are reported in
  whatever build state they had and only an index being deleted is
  dropped; a GSI without its data table is left out with the attribute
  definitions only it used. On PostgreSQL's shared layout the open is
  one connection and one REPEATABLE READ transaction; on the separate
  layout the catalog row is held FOR SHARE until the data snapshot pins,
  and an index that becomes ACTIVE between the definition read and the
  pin is reported CREATING. PostgreSQL bounds open snapshots at half the
  pool and refuses more with LimitExceeded, so open snapshots leave half
  the pool to requests; from PR 6 on max_concurrent_jobs (default 4)
  bounds backups and restores together under that permit, and ExtendDB#384's
  restore slots go. It streams a table whose key columns predate COLLATE
  "C" unordered rather than sort it, and on both layouts DeleteTable
  commits the catalog removal only after its drops succeed, base table
  first, under one 1 s budget, taking its asynchronous path with the row
  kept when its probe finds a reader or a drop fails in a lock wait
  (lock timeout, deadlock victim or statement timeout), so no DROP waits
  behind a reader with the row gone; on the separate layout a task the
  request awaits commits the catalog and then the data transaction, and
  a data commit that fails after the catalog commit is retried on a
  fresh connection for a bounded time before the request returns (an
  orphan survives only a process death or a data database down past that
  bound, and the troubleshooting page says how to drop it); the
  control-plane poller probes held tables and retries each about once a
  second, at once on a snapshot's or an in-catalog backup's release, a
  drop DeleteTable deferred is due at once, and DeleteTable of a
  DELETING table returns its description on PostgreSQL and SQLite. On
  sqlite-memory the snapshot copies the table on the one connection
  inside the request and keeps a backup slot until its stream ends, so
  CreateBackup keeps working there once [backup.store] is set. MongoDB
  reads without the explicit snapshot level fail (ExtendDB#395's MongoDB module
  doc gives the server's error).
- The snapshot is opened in the CreateBackup request (stricter than the
  service's two-minute window), and the backup id is minted from the
  instant the open returned, just after the pin, so the backup holds
  every write acknowledged before the request was received, and
  backup_creation_date_time, the catalog's created_at and the ARN carry
  one time and the ListBackups cursor is a function of the ARN.
- Per-job claims with fencing replace the single-instance lease the
  draft assumed: an advisory lock on a dedicated catalog connection on
  PostgreSQL (ExtendDB#384's seed and key), the row itself on MongoDB (born
  through an upsert pipeline with $literal), an in-process slot on
  SQLite, a lightweight-transaction row on Cassandra (restore targets).
  Heartbeats are a timer on the database's clock with a deadline; any
  heartbeat that does not confirm the claim aborts the job. Every job
  status write is conditioned on the generation and on the row's state,
  and an ambiguous AVAILABLE update is re-read. A job deletes a prefix,
  or a restore target, only after its FAILED write matched a row. The
  reconciler's duties are listed once, in PR 8's section, and include
  the prefixes of DELETED rows, orphan restore targets, stale spools
  and, on Cassandra, active_restores entries whose target is not a
  CREATING restore target of the backup. Every duty that reads or writes
  a backup row acts only on rows of the configured store (store_kind,
  store_root and the store id); a CREATING or DELETING row of another
  store is reported and left alone. The PostgreSQL staleness test also
  selects a NULL heartbeat. While a maintenance claim is held the
  reconciler only finishes DELETING rows, resolves rows whose owner is
  dead and runs its FAILED-target duty, which never removes a target
  left for reason target_not_empty; that one is the operator's, removed
  with extenddb backup remove-target. The PostgreSQL claim connection
  sets TCP keepalives, so the loss of the owner's host frees its lock in
  about a minute. While the configured store is unhealthy the reconciler
  acts on no row of the store; its legacy-row duty, which touches no
  store, still runs.
- DeleteBackup tombstones the row as DELETING, deletes the manifest
  first, and marks DELETED only when the prefix is empty. backup sync is
  scoped to the configured store; extenddb-store.json records the store
  id and the owning catalog's catalog_id, and a root another catalog
  owns, rows without the file, rows with two store ids or a missing
  newest manifest mark the store unhealthy (store operations refused,
  the reconciler idle on the store's rows) instead of stopping startup,
  so an unmounted volume, a recreated bucket or two catalogs on one root
  are caught; backup sync --adopt-store hands a root to another catalog,
  and sqlite-memory's startup sync takes ownership of a root a
  sqlite-memory catalog owned the same way, so one catalog owns a root
  at a time there too; instances that share a catalog must share one
  store root; it never adopts a CREATING, DELETING, DELETED or FAILED
  prefix, nor one whose listing lacks a data file the manifest lists at
  its recorded size, and marks a row whose manifest head is gone
  DELETING only with --prune-missing. --move-store resolves the old
  root's CREATING and DELETING rows, compares every data file, refuses
  to carry a tombstone over a verified manifest and offers
  --forget-backup; --forget-store deletes the rows of a root. purge,
  remove-target, --move-store, --forget-store, --prune-missing and
  --adopt-store take a catalog maintenance claim that every job checks;
  max_concurrent_jobs is per instance. DeleteBackup and Restore of
  another store's row return a ValidationException that names no root.
  Restore and Describe never read the store without a catalog row; an
  ARN with no row goes to the legacy reader. The store-id file is
  created with put_if_absent (If-None-Match on S3, O_EXCL and link on
  the filesystem). A backup is in use while a restore target registered
  from it is CREATING, as on main: DeleteBackup and a second restore
  return BackupInUseException. Purge deletes each row's prefix, never a
  whole account prefix. Every backup row is read by account and backup
  id and must match the request's account, table and ARN, and no row
  stores its key prefix. DeleteBackup's in-use check and DELETING write
  run under the backup row's lock, and a restore's registration
  re-checks AVAILABLE and in-use under the same lock, so the two
  serialize as on main (on Cassandra through main's active_restores
  lightweight transactions, now for new rows too); main's two PostgreSQL
  race tests stay for the legacy path and PR 6 adds the same two
  orderings over an off-host backup.
- Restore returns CREATING from the request (TableAlreadyExists or
  TableInUse on a name race, as ExtendDB#384 returns), spools and verifies every
  data file before writing any item, and records progress atomically
  with the data on PostgreSQL and SQLite and by idempotent
  re-application of the one unrecorded file or batch on MongoDB and
  Cassandra; on MongoDB the job writes DONE and the backfill flag in one
  transaction and the worker's ACTIVE flip is conditioned on the flag,
  not on the job's generation. A key-ordered restore runs ExtendDB#394's
  per-file key-range check and an unordered one ends with a count of the
  target, which is Cassandra's only duplicate check. A PostgreSQL
  restore is resumed, or its target deleted, only after the stale
  window; the manifest and sidecar are spooled for a resume; a legacy
  restore with a dead owner is failed, not resumed. The ACTIVE flip
  precedes the deletion of the PostgreSQL progress rows. DeleteTable on
  a restore target stays refused, as ExtendDB#384 and the service refuse it, and
  the job and the reconciler remove a failed target through the
  backend-internal remove_restore_target; the restore record is the
  table's child. Multi-part-key tables are restored (ExtendDB#384 refuses them;
  bulk_load keys rows with the backend's item-path functions, so the
  table is laid out as a natively created one). On PostgreSQL and SQLite
  bulk_load writes GSI, LSI and vector-index rows in the batch's
  transaction with the base rows, and the ACTIVE flip sets the table and
  every index ACTIVE in one catalog transaction; on Cassandra the index
  rows are created ACTIVE and the lightweight transaction flips the
  table alone. Table class is not restored, as on the service and ExtendDB#384;
  every override but VectorIndexOverride is applied, as ExtendDB#384 applies
  them. A second restore of a backup in use is refused before the permit
  and re-checked at registration. A restore stops on shutdown leaving
  its row RUNNING, download transport errors are retried for 15 minutes,
  and a takeover rewrites spool_host. Each data file is read through
  ExtenddbManifest::reader_prehashed, which applies the key-range check
  without hashing the spooled bytes again.
- The engine binds a BackupCatalog trait (PR 6) with a JobClaim for the
  per-backend claim, heartbeat and conditional status writes; new rows
  live in backup_index (a new table or collection; Cassandra reuses its
  tables with store_kind set and adds backup_index_by_status); MongoDB's
  catalog gets the versioned migration step it lacks; PR 6's migration
  backfills in-flight ExtendDB#384 restores with heartbeat_at at the epoch so
  the first reconciler pass fails them; every pre-PR-6 server stops
  before migrate. Vector tables are backed up and restored on the
  off-host path, while the legacy create keeps main's refusal (one ExtendDB#384
  engine test is rewritten in PR 9a and the wire test follows the
  configured path). source_table carries stream_specification and
  time_to_live for DescribeBackup. Both requests take the job permit
  last, after every refusal that needs no store access. The format's
  evolution rule is a version bump for any byte change, with v{N}
  fixtures beside v1. The trait gains listings for ListBackups, the
  store-id check and the root-matched commands, and backup_in_use.
  ListBackups accepts a table ARN in TableName from PR 6, as the service
  does. PR 6 adds the [backup] struct with the job keys. Row and
  restore-target methods take account-scoped keys, and the trait gains
  catalog_id and DeleteBackup's lock-holding start_backup_delete.
- The manifest self-checksum is replaced by a sidecar over the stored
  bytes, accepted in its sha256sum line form only; backup_arn is the
  backup's identity and source_table must agree with it; N key values
  are normalized before key text or key order is computed, and an
  un-normalized key_range endpoint is refused; the version gate accepts
  1 to the current version. The line cap is 32 MiB and is the floor of
  data_file_target_bytes; the format's other rules, the decoder's memory
  included, are ExtendDB#394's module docs, which section 1 points at. A reader
  refuses a manifest without stream_specification or time_to_live. The
  backup job validates every item with an item validator PR 8 adds to
  extenddb-core, which the restore binds, and fails the backup on a
  value ExtendDB#618 let in through any of the four write paths, a regression
  from main for tables that hold one; it validates the rest of the
  snapshot first and logs every refused item, and the FAILED reason
  gives the count and the first.
- Store (ExtendDB#393): section 2 points at the crate's README and module docs
  and keeps the three store facts the design's rules rest on (put and
  delete_prefix are unordered, so a caller never puts under a prefix it
  is deleting; runtime shutdown sends no abort, so the server awaits
  each job's failure path; listing has no delimiter form) and names
  put_if_absent as PR 7's addition. Section 8 points at the README's IAM
  example and config.rs's transport and proxy rules. The revision log
  (items 21 to 23) records the store facts the companion rounds settled:
  the endpoint is refused with user information, a query, a fragment or
  an IPv6 zone id, from any source; the S3 client for an http://
  endpoint is built with aws_smithy_http_client's Builder and no proxy,
  and the credential chain's clients get the same client when a
  credential endpoint setting, the IMDS endpoint setting or the
  container credentials URI is http://, so the chain's own http://
  requests do not use the proxy; without such a setting the chain uses
  the SDK's default client and NO_PROXY must list 169.254.169.254
  (fd00:ec2::254 in IPv6 mode) and 169.254.170.2; a denied
  AbortMultipartUpload in delete_prefix's sweep is logged and the
  deletion succeeds; a put dropped during the temporary's open leaves no
  temporary.
- Configuration: the default store root is per backend (beside the
  database file on file-backed SQLite, where a defaulted root that fails
  validation is a warning, not a startup error); without [backup.store],
  CreateBackup on every backend keeps the legacy in-catalog create with
  a deprecation warning until PR 14 removes it (Cassandra always, having
  no SnapshotEngine), after which it is a ValidationException naming
  [backup.store], and kind = "disabled" is the opt-out; PR 14's
  migration refuses while legacy backup rows remain unless forced. A
  store transport failure at startup degrades the store component
  instead of stopping the server. The memory formula is in item text
  bytes, and PR 8 bounds each PostgreSQL and SQLite snapshot page by
  SNAPSHOT_FETCH_BYTES (64 MiB) in the page's query, PostgreSQL's cursor
  replaced by a keyset page under the same snapshot, so a page holds at
  most the budget plus one row; the PostgreSQL page returns its key
  columns, orders the outer query and sizes its row limit from the
  previous page's average row, starting at 16; the PostgreSQL pool rule
  is max(MIN_POOL_SIZE, 2 * max_concurrent_jobs + 10), 18 at the default
  4; the spool default is per backend and spool_host is host plus spool
  path.
- Phase 1 is the PR series with acceptance criteria and GitHub numbers;
  migration numbers are reserved up front (Cassandra V006 to PR 1, V007
  to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill
  points for the crash tests are enumerated per backend and for
  DeleteBackup. The status line names the companion PRs' heads
  (aef17dd, 3f7107d, 069c4b1); the Phasing section refers to them by
  PR number. migrate-legacy re-reads and validates what it wrote and
  refuses, keeping the legacy rows, backups it cannot carry.

Also adds the missing rows for designs 12 and 13 to
docs/design/README.md; the row for 15 describes the document as the
overview of the series and names the crates whose documentation holds
the format, store and snapshot details.

The series after the four open PRs is ten PRs: the reconciler ships with
the CreateBackup job as PR 8 (a job without its reconciler leaves rows
no one resolves), and the CI changes and the documentation ship together
as PR 11, since both finalize after PR 9b. The revision log (item 24)
records the renaming; its earlier items keep the old labels.

The document is then split (revision log item 25): sections 1, 2 and 3
point at the companions' documentation (crates/core/src/backup_format
and its testdata README, ExtendDB#394; crates/backup-store/README.md and its
module docs, ExtendDB#393; crates/storage/src/snapshot.rs and the four
snapshot_engine.rs, ExtendDB#395) and keep only the rules later PRs build on;
sections 4 and 5 keep the invariants, the state machines and the
per-operation contracts; the schema, the per-backend mechanics, the
reconciler's duty list and the maintenance commands move into one
section per later PR under Phasing (PR 1, 6, 7, 8, 9a, 9b, 10, 11, 13,
14), each with its goal, the invariants it honours, the interfaces it
adds with their row keys, the transitions it owns and its acceptance
list kept whole; section 10 is a table of which document each PR
changes. The three companions gain the facts only this document stated
(the filesystem list's whole-root Vec and the empty-prefix IAM example
in ExtendDB#393's README; the fetch-size reason and the pre-ExtendDB#361 sort cost in
ExtendDB#395's module docs) and a sentence each saying this overview states the
rules their callers honour. 279,584 bytes become 187,722.

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
Co-authored-by: Lee Hannigan <lhnng@amazon.com>
robinnsc added a commit that referenced this pull request Oct 30, 2026
Shared traits (crates/storage). `BackupEngine` is replaced by two traits
every backend implements: `BackupCatalog`, the catalog index of backups
and restore jobs the engine binds (one row per backup addressed by
`BackupKey` (account, backup id, ARN), one restore job record per target
addressed by `RestoreTargetKey` (account, name, table id), the listings,
`start_backup_delete` under the row's lock, `remove_restore_target`,
`list_dead_restore_jobs`, `fail_restore_job`, `check_maintenance_claim`,
`catalog_id`) with its `JobClaim` (generation, heartbeat, conditional
`set_status`, `read`), and `LegacyBackupReader`, the frozen in-catalog
format's create, describe, list, delete, restore-source and restore,
whose `create` and `restore` now take the job's `JobPermit` and whose
`abandoned_creating_backups` reports a dead CREATING row. `BackupRow`
derives the API's source-table shapes from `source_table_details_json`
(reading only the table id and ARN, so a later build's required member
does not break a listing); `BackupStatus` maps DELETING to DELETED and
hides DELETED and FAILED. The in-use, not-found and LimitExceeded texts
every backend answers are one implementation in `backup_catalog`, as is
`JOB_HEARTBEAT_INTERVAL` (15 s), the period every backend's jobs
heartbeat at. `StorageError::AlreadyExists` is the claim's refusal;
`MigrationId::BackupCatalogIndex = 5` is the migration.
`util::require_served_catalog_version` is the rule `serve` applies on a
backend whose catalog carries a version string.

Config. A `[backup]` section (`deny_unknown_fields`, every key with a
default): `data_file_target_bytes`, `heartbeat_interval_secs`,
`stale_job_after_secs`, `failed_row_retention_secs`,
`reconcile_interval_secs`, `max_concurrent_jobs`, `spool_path`, with
`stale_job_after_secs` at least three times the period the jobs
heartbeat at (`max(heartbeat_interval_secs, JOB_HEARTBEAT_INTERVAL)`,
45 s at the least), `max_concurrent_jobs` at most 1024, and the floors
checked at load; `init` writes the commented block and the sample config
carries it. `ServerComponentsOptions.max_concurrent_jobs` lets the
PostgreSQL factory raise both pools to `2 * jobs + 10` with a warning.

Engine. `BackupRuntime` holds the job slots and the configured store
(none on this head); a request over `max_concurrent_jobs` is
`LimitExceededException` at once. Describe, List and Delete read the
catalog index first and fall back to the legacy reader; List merges
`list_account_backup_rows` with the reader's list under the existing
paging, filtering and cursor rules, and `TableName` accepts a table ARN
as CreateBackup does. CreateBackup runs the request order of the design
(ACTIVE probe, ARN fits, vector refusal, permit last, deprecation
warning, the legacy create); restore takes the permit last and hands it
to the backend. Delete over a row runs the in-use check before the
other-store check, as a restore does, and builds the description before
the DELETING write; a row whose recorded source table this build cannot
read is left out of ListBackups (logged) rather than failing the
listing. Both spellings of a backup ARN (as written and re-spelled in
this region) parse into `BackupArn` where the store-prefix rules allow;
a legacy backup of a name they refuse (dot-only, over 255 bytes) skips
the catalog index and describes, restores and deletes through the
legacy reader as on main.

Reconciler (crates/engine/src/backup/reconciler.rs). A cycle at startup
and every `reconcile_interval_secs`, spawned by `serve` beside the
backend workers and drained with them: checks the maintenance claim,
fails each dead legacy restore (`heartbeat_at` older than
`stale_job_after_secs`) and removes its CREATING target, removes the
CREATING target of a FAILED record whose removal did not land once the
record is that old, with no second FAILED write (the FAILED-target duty
of design 4(e), on every backend), logs a dead manifest restore once per
record and leaves it to a later release, deletes each abandoned
in-catalog CREATING backup through the reader, and records the worker
metrics (`WorkerLastSuccess`, `WorkerCycleLatency`, `WorkerErrorCount`
under `BackupReconciler`). It replaces the per-backend sweeps:
PostgreSQL's control-plane pass, SQLite's startup sweeps, MongoDB's
startup and worker sweep, Cassandra's 15 s poller.

PostgreSQL. Migration 5 (`0005_backup_catalog_index.sql`, breaking,
convergent, one transaction): `backup_index`, the twelve job columns on
`table_restores` with the backfill of existing registrations (DONE with
`completed_at = creation_date_time`, or RUNNING with the epoch heartbeat
and generation 0 where the table is CREATING), `backup_maintenance`,
`settings.catalog_id`. The claim is an advisory lock on a dedicated
connection (`ClaimSession`) that the backup job's heartbeats and status
writes travel through (the legacy restore heartbeats through it and
writes DONE and FAILED on pooled connections, each conditioned on
RUNNING and the generation); the reconciler's dead rule is the heartbeat
alone, the lock being the claim's exclusion, as on the other backends.
The legacy restore registers RUNNING with its generation, marks DONE in
the transaction that flips the table ACTIVE, and FAILED before it
removes a failed target. The restore slot semaphores (per account and
per server) and `sweep_abandoned_restores` are removed.
`RESTORE_LOCK_SEED` and `abort_restore` are `pub` and `#[doc(hidden)]`
so the tests bind them.

SQLite. Migration 5 as a code body with the same objects; the twelve
columns are nullable (ADD COLUMN on a populated table) and the
application writes every one. The claim is an in-process slot
(`JobSlots`, a drop guard), since one process serves a file; a restart
ages every RUNNING record to the epoch so the first cycle removes an
interrupted restore, and a CREATING `backups` row whose slot no task
holds is reported dead whatever `stale_after` says (`backups` has no
heartbeat column); the reader's `delete` removes such a row whole, with
no DELETED tombstone. The restore heartbeats every
`JOB_HEARTBEAT_INTERVAL` (15 s) under the engine write lock. Conditional
writes never have an unknown outcome here. `backup_server_slot_count`
stays until a later release folds it into `max_concurrent_jobs`; its
slots are shared by backups and table snapshots (#395), the formula is
stated once on `SqliteConfig::pool_size`, and the legacy create tries a
slot without waiting, as a snapshot open does, and answers the one
`LimitExceededException` text (`limit_exceeded_message`) for both.

MongoDB. `backup_index` and `backup_maintenance` collections, a
`restore_job` subdocument on a restore target's `tables` document, and
the versioned step `002_backup_catalog_index` recorded in
`schema_history`, which backfills the registrations, writes the catalog
id and moves `catalog_version` to `0.0.5` (one constant,
`CATALOG_VERSION`, where three copies of `0.0.2` were). The claim is the
row, born owned by an upsert with `heartbeat_at: "$$NOW"`; the lock for
DeleteBackup and a restore's registration is a session transaction that
increments `lock_seq` on the backup's document, which replaces the
`restore_in_progress`/`delete_in_progress` claim markers and the sweep
that cleared them. The legacy DeleteBackup writes DELETING before the
drop and DELETED after; describe and list hide DELETING. A restore
request that outlives the 60 s registration bound answers
InternalServerError while its task goes on; the target it leaves is
stated in the differences document's restore row. The restore slot
semaphores are removed. `serve` refuses a catalog whose version is not
`CATALOG_VERSION`, naming `extenddb migrate --yes`. The protocol model
(`backup_protocol_model.rs`) is rewritten for the new steps.

Cassandra. `V006__backup_catalog_index.cql`, after which the runner
writes `0.0.5` (once the backfill step and the file's `schema_history`
row are in, so a run that fails in the step leaves 0.0.4 and `migrate
--yes` runs the file again): the index columns (`store_kind`,
`store_root`, `store_id`, `backup_id`, `owner_generation`,
`heartbeat_at`, `completed_at`, `failure_reason`,
`source_table_details_json`) on the reused `backups_by_arn`, the
status-keyed `backup_index_by_status`, the `restore_*` job columns on
`tables`, `backup_maintenance`, and a Rust backfill step keyed on the
file. The file's statements are `ALTER TABLE ... ADD IF NOT EXISTS` and
`CREATE TABLE IF NOT EXISTS`, which the runner qualifies with the
keyspace (#638); the step and the version write name the keyspace in
their own statements, and no code or test issues `USE`. An index row is a `backups_by_arn` row with `store_kind` set, and
the two readers skip each other's rows. Claims are lightweight
transactions `IF owner_generation = ?`; the legacy create heartbeats its
CREATING row and polls its claim once per 64-item bucket, and
`abandoned_creating_backups` fences a stale one to DELETING under a
fresh `owner_generation` before reporting it, scanning DELETING rows
first so a row it fenced is reported once. A job stops after three
heartbeat errors in a row, as after a heartbeat that does not apply, and
touches neither its row nor its data again. An orphan
`backup_index_by_status` entry left by a crash between the row's write
and the batch is skipped by every reader; the sweep that removes it is
`backup sync`'s. `serve` refuses a catalog whose version is not
`CATALOG_VERSION`, as on MongoDB. `poll_abandoned_restores` and its
worker are removed. The crate gains a `test-hooks` feature (as
MongoDB's), compiled only for the `direct_integration` binary, which CI
runs with `--features test-hooks`.

Migration and versions. One change, one number: migration id 5 on
PostgreSQL and SQLite, `0.0.5` on MongoDB and Cassandra. The migration
is breaking (stop pre-upgrade servers first); a restore in flight at the
stop is a RUNNING record whose owner is dead, failed and removed on the
first reconciler pass.

Docs. The upgrade manual (migration 5 and its rollback, the Cassandra
and MongoDB `0.0.5` section with the Cassandra resume sentence, the
serve-time refusal, the removed 15 s sweep line), the admin guide
(`[backup]`, the job bound in place of the per-account and half-pool
restore slots, the pooler note), the deployment guide (the job bound in
place of the restore slots; the connection formulas gain
`+ max_concurrent_jobs`), the high-level design and getting-started
connection formulas, the limits document's concurrent-restores row, the
architecture guide, the MongoDB design document, the differences
document's restore rows (the MongoDB registration bound among them), the
sample config, `docs/schema/postgres.txt` and `sqlite.txt` regenerated,
and the backup/restore design's PR 6 section and section 4 (the
migration numbers as implemented, revision log items 26 and 27).

Tests. extenddb-storage 196 (13 new), extenddb-config 14 (6 new),
extenddb-engine 205 (22 new: catalog describe, list, delete and restore
paths, permit order, the ARN rules, reconciler cycle, duties, metrics
and worker). extenddb-storage-postgres: lib 69, tests/backup_restore 36
(28 kept, 1 rewritten, 2 removed, 8 new), every other binary green
(fresh 5, released 12, golden 5, crash 9, guards 6, runs 5, owners 3,
tag_blobs 1, vector_control_plane 42, doc_version_literals 3,
table_snapshot 81 with its restore record written as a DONE legacy job,
...).
extenddb-storage-sqlite 225 (3 rewritten, 11 new; 1 ignored benchmark).
extenddb-storage-mongodb: lib 141 plus 27 live tests against a replica
set (21 backup, 6 stream; 9 new or rewritten, 5 removed with the slots
and the sweep), interpreter_selftests 18, pushdown_parity 20.
extenddb-storage-cassandra: lib 78 (4 new), direct_integration 191 (11
new, 1 rewritten, 1 removed, 1 renamed; three of the new tests build
with `--features test-hooks`), ttl_integration 26 passed and the one
pre-existing failure below. Wire: tests/test_backup_restore_fidelity.py
(37) and tests/test_table_operations.py (27) against a `serve` of this
tree on PostgreSQL and on SQLite, 63 passed and 1 skipped each (the
DELETING test of #395 skips where a delete finishes in the request), and
on Cassandra, on the previous base bffa088, 62 passed and the vector
test skipped. `cargo fmt`, `clippy -D warnings`
on 1.97, `check --all-targets` on 1.88, `cargo doc` with no new
warnings.

Not changed here. The backup store (`[backup.store]`, the store identity
file) and the store-backed CreateBackup writer, worker and `backup`
commands; the manifest restore job; every `backup_index` row is "of
another store" for Delete and Restore until a store is configured. The
Cassandra `test_ttl_enable_rejects_asynchronous_gsi` failure predates
this change (it fails the same way without it, checked on a clean
bffa088: the test matches
"asynchronously propagated GSIs" and the refusal says "global secondary
index"). `heartbeat_interval_secs` reaches no job yet: every in-catalog
job heartbeats at `JOB_HEARTBEAT_INTERVAL`, which the stale floor is
computed from.

Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 31, 2026
Shared traits (crates/storage). `BackupEngine` is replaced by two traits
every backend implements: `BackupCatalog`, the catalog index of backups
and restore jobs the engine binds (one row per backup addressed by
`BackupKey` (account, backup id, ARN), one restore job record per target
addressed by `RestoreTargetKey` (account, name, table id), the listings,
`start_backup_delete` under the row's lock, `remove_restore_target`,
`list_dead_restore_jobs`, `fail_restore_job`, `check_maintenance_claim`,
`catalog_id`) with its `JobClaim` (generation, heartbeat, conditional
`set_status`, `read`), and `LegacyBackupReader`, the frozen in-catalog
format's create, describe, list, delete, restore-source and restore,
whose `create` and `restore` now take the job's `JobPermit` and whose
`abandoned_creating_backups` reports a dead CREATING row. `BackupRow`
derives the API's source-table shapes from `source_table_details_json`
(reading only the table id and ARN, so a later build's required member
does not break a listing); `BackupStatus` maps DELETING to DELETED and
hides DELETED and FAILED. The in-use, not-found and LimitExceeded texts
every backend answers are one implementation in `backup_catalog`
(`limit_exceeded_message(JobBound)`, the text naming the bound met:
`[backup] max_concurrent_jobs`, SQLite's snapshot slots, PostgreSQL's
connection slots), as is `JOB_HEARTBEAT_INTERVAL` (15 s), the period
every backend's jobs heartbeat at. `StorageError::AlreadyExists` is the
claim's refusal; `MigrationId::BackupCatalogIndex = 5` is the migration.
`util::require_served_catalog_version` is the rule `serve` applies on a
backend whose catalog carries a version string.

Config. A `[backup]` section (`deny_unknown_fields`, every key with a
default): `data_file_target_bytes`, `heartbeat_interval_secs`,
`stale_job_after_secs`, `failed_row_retention_secs`,
`reconcile_interval_secs`, `max_concurrent_jobs`, `spool_path`, with
`stale_job_after_secs` at least three times the period the jobs
heartbeat at (`max(heartbeat_interval_secs, JOB_HEARTBEAT_INTERVAL)`,
45 s at the least), `max_concurrent_jobs` at most 1024, and the floors
checked at load; `init` writes the commented block and the sample config
carries it. `ServerComponentsOptions.max_concurrent_jobs` lets the
PostgreSQL factory raise both pools to `2 * jobs + 10` with a warning.

Engine. `BackupRuntime` holds the job slots and the configured store
(none on this head); a request over `max_concurrent_jobs` is
`LimitExceededException` at once. Describe, List and Delete read the
catalog index first and fall back to the legacy reader; List merges
`list_account_backup_rows` with the reader's list under the existing
paging, filtering and cursor rules, and `TableName` accepts a table ARN
as CreateBackup does. CreateBackup runs the request order of the design
(ACTIVE probe; the region and the name fit a backup ARN, each refusal
naming its cause: `server.region` outside `[a-z0-9-]`, then a name over
255 bytes or made only of dots, checked before the ARN is formed so the
limit is named whatever the length; vector refusal; permit last;
deprecation warning; the legacy create); restore takes the permit last
and hands it to the backend. Delete over a row runs the in-use check
before the other-store check, as a restore does, and builds the
description before the DELETING write; a row whose recorded source table
this build cannot read is left out of ListBackups (logged) rather than
failing the listing. Both spellings of a backup ARN (as written and
re-spelled in this region) parse into `BackupArn` where the store-prefix
rules allow; a legacy backup of a name they refuse (dot-only, over 255
bytes) skips the catalog index and describes, restores and deletes
through the legacy reader as on main.

Reconciler (crates/engine/src/backup/reconciler.rs). A cycle at startup
and every `reconcile_interval_secs`, spawned by `serve` beside the
backend workers and drained with them: checks the maintenance claim,
fails each dead legacy restore (`heartbeat_at` older than
`stale_job_after_secs`) and removes its CREATING target (a target the
removal finds no longer CREATING is left to its owner and reported as
such), removes the target of a FAILED record whose removal did not land
once the record is that old, with no second FAILED write (the
FAILED-target duty of design 4(e), on every backend; on Cassandra, where
the removal is a claim to DELETING and then a drop, a target left
DELETING with its registration is listed on the same rule and its
removal resumed from the drop), logs a dead manifest restore once per
record and leaves it to a later release, deletes each abandoned
in-catalog CREATING backup through the reader, and records the worker
metrics (`WorkerLastSuccess`, `WorkerCycleLatency`, `WorkerErrorCount`
under `BackupReconciler`). It replaces the per-backend sweeps:
PostgreSQL's control-plane pass, SQLite's startup sweeps, MongoDB's
startup and worker sweep, Cassandra's 15 s poller.

PostgreSQL. Migration 5 (`0005_backup_catalog_index.sql`, breaking,
convergent, one transaction): `backup_index` (`backup_arn COLLATE "C"`,
so the listings' `(created_at, backup_arn)` order is byte order whatever
the database's collation), the twelve job columns on
`table_restores` with the backfill of existing registrations (DONE with
`completed_at = creation_date_time`, or RUNNING with the epoch heartbeat
and generation 0 where the table is CREATING), `backup_maintenance`,
`settings.catalog_id`. The claim is an advisory lock on a dedicated
connection (`ClaimSession`) that the backup job's heartbeats and status
writes travel through (the legacy restore heartbeats through it and
writes DONE and FAILED on pooled connections, each conditioned on
RUNNING and the generation); the reconciler's dead rule is the heartbeat
alone, the lock being the claim's exclusion, as on the other backends.
The legacy restore registers RUNNING with its generation, marks DONE in
the transaction that flips the table ACTIVE, and FAILED before it
removes a failed target. The restore slot semaphores (per account and
per server) and `sweep_abandoned_restores` are removed.
`RESTORE_LOCK_SEED` and `abort_restore` are `pub` and `#[doc(hidden)]`
so the tests bind them.

SQLite. Migration 5 as a code body with the same objects; the twelve
columns are nullable (ADD COLUMN on a populated table) and the
application writes every one. The claim is an in-process slot
(`JobSlots`, a drop guard), since one process serves a file; a restart
ages every RUNNING record to the epoch so the first cycle removes an
interrupted restore, and a CREATING `backups` row whose slot no task
holds is reported dead whatever `stale_after` says (`backups` has no
heartbeat column); the reader's `delete` removes such a row whole, with
no DELETED tombstone. The restore heartbeats every
`JOB_HEARTBEAT_INTERVAL` (15 s) under the engine write lock. Conditional
writes never have an unknown outcome here. `backup_server_slot_count`
stays until a later release folds it into `max_concurrent_jobs`; its
slots are shared by backups and table snapshots (#395), the formula is
stated once on `SqliteConfig::pool_size`, and the legacy create tries a
slot without waiting, as a snapshot open does, and answers the one
`LimitExceededException` text (`limit_exceeded_message`), naming the
snapshot slots as the bound, for both.

MongoDB. `backup_index` and `backup_maintenance` collections, a
`restore_job` subdocument on a restore target's `tables` document, and
the versioned step `002_backup_catalog_index` recorded in
`schema_history`, which backfills the registrations, writes the catalog
id and moves `catalog_version` to `0.0.5` (one constant,
`CATALOG_VERSION`, where three copies of `0.0.2` were). The claim is the
row, born owned by an upsert with `heartbeat_at: "$$NOW"`; the lock for
DeleteBackup and a restore's registration is a session transaction that
increments `lock_seq` on the backup's document, which replaces the
`restore_in_progress`/`delete_in_progress` claim markers and the sweep
that cleared them. Both in-use checks read at the transaction read
concern (majority-committed data) while DescribeTable reads at `local`,
so a RestoreTableFromBackup or DeleteBackup issued within the
majority-commit lag after DescribeTable first shows the target ACTIVE
can still answer BackupInUseException and succeeds when retried; the
differences document's concurrent-restores row states it with the
measured lag. The legacy DeleteBackup writes DELETING before the
drop and DELETED after; describe and list hide DELETING. A restore
request that outlives the 60 s registration bound answers
InternalServerError while its task goes on; the target it leaves is
stated in the differences document's restore row. The restore slot
semaphores are removed. `serve` refuses a catalog whose version is not
`CATALOG_VERSION`, naming `extenddb migrate --yes`. The protocol model
(`backup_protocol_model.rs`) is rewritten for the new steps.

Cassandra. `V006__backup_catalog_index.cql`, after which the runner
writes `0.0.5` (once the backfill step and the file's `schema_history`
row are in, so a run that fails in the step leaves 0.0.4 and `migrate
--yes` runs the file again): the index columns (`store_kind`,
`store_root`, `store_id`, `backup_id`, `owner_generation`,
`heartbeat_at`, `completed_at`, `failure_reason`,
`source_table_details_json`) on the reused `backups_by_arn`, the
status-keyed `backup_index_by_status`, the `restore_*` job columns on
`tables`, `backup_maintenance`, and a Rust backfill step keyed on the
file. The file's statements are `ALTER TABLE ... ADD IF NOT EXISTS` and
`CREATE TABLE IF NOT EXISTS`, which the runner qualifies with the
keyspace (#638); the step and the version write name the keyspace in
their own statements, and no code or test issues `USE`. An index row is
a `backups_by_arn` row with `store_kind` set, and the two readers skip
each other's rows. Claims are lightweight transactions `IF
owner_generation = ?`; the legacy create heartbeats its CREATING row and
polls its claim once per 64-item bucket, and
`abandoned_creating_backups` fences a stale one to DELETING under a
fresh `owner_generation` before reporting it, scanning DELETING rows
first so a row it fenced is reported once. A job stops after three
heartbeat errors in a row, as after a heartbeat that does not apply, and
touches neither its row nor its data again. An orphan
`backup_index_by_status` entry left by a crash between the row's write
and the batch is skipped by every reader; the sweep that removes it is
`backup sync`'s. `serve` refuses a catalog whose version is not
`CATALOG_VERSION`, as on MongoDB. `poll_abandoned_restores` and its
worker are removed. The crate gains a `test-hooks` feature (as
MongoDB's), compiled only for the `direct_integration` binary, which CI
runs with `--features test-hooks`.

Migration and versions. One change, one number: migration id 5 on
PostgreSQL and SQLite, `0.0.5` on MongoDB and Cassandra. The migration
is breaking (stop pre-upgrade servers first); a restore in flight at the
stop is a RUNNING record whose owner is dead, failed and removed on the
first reconciler pass.

Docs. The upgrade manual (migration 5 and its rollback, the Cassandra
and MongoDB `0.0.5` section with the Cassandra resume sentence, the
serve-time refusal, the removed 15 s sweep line), the admin guide
(`[backup]`, the job bound in place of the per-account and half-pool
restore slots, the pooler note), the deployment guide (the job bound in
place of the restore slots; the connection formulas gain
`+ max_concurrent_jobs`), the high-level design and getting-started
connection formulas, the limits document's concurrent-restores row, the
architecture guide, the MongoDB design document, the differences
document's restore rows (the MongoDB registration bound among them), the
admin guide's `region` row, the differences document's `BackupArn` and
backup creation rows, the limits document's `BackupName`/`TableName` row
and the upgrade manual's `server.region` paragraph (CreateBackup refused
under a region outside `[a-z0-9-]`; a name no backup ARN can carry
refused with the texts), `CONTRIBUTING.md`'s migration example
renumbered to 6, the sample config, `docs/schema/postgres.txt`
(regenerated for the collation) and `sqlite.txt` regenerated,
and the backup/restore design's PR 6 section and section 4 (the
migration numbers as implemented, revision log items 26 and 27).

Tests. extenddb-storage 195 (13 new; 196 beside a crate that enables its
`test-hooks` feature, as `cargo test --workspace` does), extenddb-config
14 (6 new), extenddb-engine 207 (24 new: catalog describe, list, delete
and restore paths, permit order, the ARN rules, reconciler cycle,
duties, metrics and worker). extenddb-storage-postgres: lib 69,
tests/backup_restore 37 (28 kept, 1 rewritten, 2 removed, 9 new), every
other binary green (fresh 5, released 12, golden 5, crash 9, guards 6,
runs 5, owners 3, tag_blobs 1, vector_control_plane 42,
doc_version_literals 3, table_snapshot 81 with its restore record
written as a DONE legacy job, ...). extenddb-storage-sqlite 225 (3
rewritten, 11 new; 1 ignored benchmark). extenddb-storage-mongodb: lib
141 plus 28 live tests against a replica set (22 backup, 6 stream; 10
new or rewritten, 5 removed with the slots and the sweep),
interpreter_selftests 18, pushdown_parity 20.
extenddb-storage-cassandra: lib 78 (4 new), direct_integration 192 (12
new, 1 rewritten, 1 removed, 1 renamed; three of the new tests build
with `--features test-hooks`), ttl_integration 26 passed and the one
pre-existing failure below. Wire: tests/test_backup_restore_fidelity.py
(37) and tests/test_table_operations.py (27) against a `serve` of this
tree: 64 passed on PostgreSQL with `EXTENDDB_TEST_BACKEND=postgres
EXTENDDB_CONFIG=<config>` (the DELETING test reads the catalog), 63 and
1 skipped without them; on SQLite 63 and the DELETING test skipped
without them (with them it fails on the host's sqlite3 3.7.17 module,
which cannot read a partial index); on Cassandra 62 passed and 2 skipped
(the vector test and the DELETING test). `cargo fmt`, `clippy -D
warnings` on 1.97, `check --all-targets` on 1.88, `cargo doc` with no
new warnings.

Not changed here. The backup store (`[backup.store]`, the store identity
file) and the store-backed CreateBackup writer, worker and `backup`
commands; the manifest restore job; every `backup_index` row is "of
another store" for Delete and Restore until a store is configured. The
Cassandra `test_ttl_enable_rejects_asynchronous_gsi` failure predates
this change (it fails the same way without it, checked on a clean
bffa088: the test matches
"asynchronously propagated GSIs" and the refusal says "global secondary
index"). `heartbeat_interval_secs` reaches no job yet: every in-catalog
job heartbeats at `JOB_HEARTBEAT_INTERVAL`, which the stale floor is
computed from.

Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 31, 2026
Shared traits (crates/storage). `BackupEngine` is replaced by two traits
every backend implements: `BackupCatalog`, the catalog index of backups
and restore jobs the engine binds (one row per backup addressed by
`BackupKey` (account, backup id, ARN), one restore job record per target
addressed by `RestoreTargetKey` (account, name, table id), the listings,
`start_backup_delete` under the row's lock, `remove_restore_target`,
`list_dead_restore_jobs`, `fail_restore_job`, `check_maintenance_claim`,
`catalog_id`) with its `JobClaim` (generation, heartbeat, conditional
`set_status`, `read`), and `LegacyBackupReader`, the frozen in-catalog
format's create, describe, list, delete, restore-source and restore,
whose `create` and `restore` now take the job's `JobPermit` and whose
`abandoned_creating_backups` reports a dead CREATING row. `BackupRow`
derives the API's source-table shapes from `source_table_details_json`
(reading only the table id and ARN, so a later build's required member
does not break a listing); `BackupStatus` maps DELETING to DELETED and
hides DELETED and FAILED. The in-use, not-found and LimitExceeded texts
every backend answers are one implementation in `backup_catalog`
(`limit_exceeded_message(JobBound)`, the text naming the bound met:
`[backup] max_concurrent_jobs`, the `[storage.sqlite] pool_size`
snapshot-slot bound, PostgreSQL's connection slots), as is
`JOB_HEARTBEAT_INTERVAL` (15 s), the period every backend's jobs
heartbeat at. `StorageError::AlreadyExists` is the claim's refusal;
`MigrationId::BackupCatalogIndex = 5` is the migration.
`util::require_served_catalog_version` is the rule `serve` applies on a
backend whose catalog carries a version string.

Config. A `[backup]` section (`deny_unknown_fields`, every key with a
default): `data_file_target_bytes`, `heartbeat_interval_secs`,
`stale_job_after_secs`, `failed_row_retention_secs`,
`reconcile_interval_secs`, `max_concurrent_jobs`, `spool_path`, with
`stale_job_after_secs` at least three times the period the jobs
heartbeat at (`max(heartbeat_interval_secs, JOB_HEARTBEAT_INTERVAL)`,
45 s at the least), `max_concurrent_jobs` at most 1024, and the floors
checked at load; `init` writes the commented block and the sample config
carries it. `ServerComponentsOptions.max_concurrent_jobs` lets the
PostgreSQL factory raise both pools to `2 * jobs + 10` with a warning.

Engine. `BackupRuntime` holds the job slots and the configured store
(none on this head); a request over `max_concurrent_jobs` is
`LimitExceededException` at once. Describe, List and Delete read the
catalog index first and fall back to the legacy reader; List merges
`list_account_backup_rows` with the reader's list under the existing
paging, filtering and cursor rules, and `TableName` accepts a table ARN
as CreateBackup does. CreateBackup runs the request order of the design
(ACTIVE probe; the region and the name fit a backup ARN, each refusal
naming its cause: `server.region` outside `[a-z0-9-]`, then a name over
255 bytes or made only of dots, checked before the ARN is formed so the
limit is named whatever the length; vector refusal; permit last;
deprecation warning; the legacy create); restore checks the target name
through `describe_table` after the ARN (a table of the name
`TableAlreadyExistsException`, a CREATING restore target
`TableInUseException` naming its source backup), then the backup, then
takes the permit last and hands it to the backend; the backend's fenced
name check under the registration stays. Delete over a row runs the
in-use check before the other-store check, as a restore does, and builds
the description before the DELETING write; a row whose recorded source
table this build cannot read is left out of ListBackups (logged) rather
than failing the listing. Both spellings of a backup ARN (as written and
re-spelled in this region) parse into `BackupArn` where the store-prefix
rules allow; a legacy backup of a name they refuse (dot-only, over 255
bytes) skips the catalog index and describes, restores and deletes
through the legacy reader as on main.

Reconciler (crates/engine/src/backup/reconciler.rs). A cycle at startup
and every `reconcile_interval_secs`, spawned by `serve` beside the
backend workers and drained with them: checks the maintenance claim,
fails each dead legacy restore (`heartbeat_at` older than
`stale_job_after_secs`) and removes its CREATING target (a target the
removal finds no longer CREATING is left to its owner and reported as
such), removes the target of a FAILED record whose removal did not land
once the record is that old, with no second FAILED write (the
FAILED-target duty of design 4(e), on every backend; on Cassandra, where
the removal is a claim to DELETING and then a drop, a target left
DELETING with its registration is listed on the same rule and its
removal resumed from the drop), logs a dead manifest restore once per
record and leaves it to a later release, deletes each abandoned
in-catalog CREATING backup through the reader, and records the worker
metrics (`WorkerLastSuccess`, `WorkerCycleLatency`, `WorkerErrorCount`
under `BackupReconciler`). It replaces the per-backend sweeps:
PostgreSQL's control-plane pass, SQLite's startup sweeps, MongoDB's
startup and worker sweep, Cassandra's 15 s poller.

PostgreSQL. Migration 5 (`0005_backup_catalog_index.sql`, breaking,
convergent, one transaction): `backup_index` (`backup_arn COLLATE "C"`,
so the listings' `(created_at, backup_arn)` order is byte order whatever
the database's collation), the twelve job columns on
`table_restores` with the backfill of existing registrations (DONE with
`completed_at = creation_date_time`, or RUNNING with the epoch heartbeat
and generation 0 where the table is CREATING), `backup_maintenance`,
`settings.catalog_id`. The claim is an advisory lock on a dedicated
connection (`ClaimSession`) that the backup job's heartbeats and status
writes travel through (the legacy restore heartbeats through it and
writes DONE and FAILED on pooled connections, each conditioned on
RUNNING and the generation); the reconciler's dead rule is the heartbeat
alone, the lock being the claim's exclusion, as on the other backends.
The legacy restore registers RUNNING with its generation, marks DONE in
the transaction that flips the table ACTIVE, and FAILED before it
removes a failed target. The restore slot semaphores (per account and
per server) and `sweep_abandoned_restores` are removed.
`RESTORE_LOCK_SEED` and `abort_restore` are `pub` and `#[doc(hidden)]`
so the tests bind them.

SQLite. Migration 5 as a code body with the same objects; the twelve
columns are nullable (ADD COLUMN on a populated table) and the
application writes every one. The claim is an in-process slot
(`JobSlots`, a drop guard), since one process serves a file; a restart
ages every RUNNING record to the epoch so the first cycle removes an
interrupted restore, and a CREATING `backups` row whose slot no task
holds is reported dead whatever `stale_after` says (`backups` has no
heartbeat column); the reader's `delete` removes such a row whole, with
no DELETED tombstone. The restore heartbeats every
`JOB_HEARTBEAT_INTERVAL` (15 s) under the engine write lock. Conditional
writes never have an unknown outcome here. `backup_server_slot_count`
stays until a later release folds it into `max_concurrent_jobs`; its
slots are shared by backups and table snapshots (#395), the formula is
stated once on `SqliteConfig::pool_size`, and the legacy create tries a
slot without waiting, as a snapshot open does, and answers the one
`LimitExceededException` text (`limit_exceeded_message`), naming the
`[storage.sqlite] pool_size` snapshot-slot bound, for both.

MongoDB. `backup_index` and `backup_maintenance` collections, a
`restore_job` subdocument on a restore target's `tables` document, and
the versioned step `002_backup_catalog_index` recorded in
`schema_history`, which backfills the registrations, writes the catalog
id and moves `catalog_version` to `0.0.5` (one constant,
`CATALOG_VERSION`, where three copies of `0.0.2` were). The claim is the
row, born owned by an upsert with `heartbeat_at: "$$NOW"`; the lock for
DeleteBackup and a restore's registration is a session transaction that
increments `lock_seq` on the backup's document, which replaces the
`restore_in_progress`/`delete_in_progress` claim markers and the sweep
that cleared them. Both in-use checks read at the transaction read
concern (majority-committed data) while DescribeTable reads at `local`,
so a RestoreTableFromBackup or DeleteBackup issued within the
majority-commit lag after DescribeTable first shows the target ACTIVE
can still answer BackupInUseException and succeeds when retried; the
differences document's concurrent-restores row states it with the
measured lag. The legacy DeleteBackup writes DELETING before the
drop and DELETED after; describe and list hide DELETING. A restore
request that outlives the 60 s registration bound answers
InternalServerError while its task goes on; the target it leaves is
stated in the differences document's restore row. The restore slot
semaphores are removed. `serve` refuses a catalog whose version is not
`CATALOG_VERSION`, naming `extenddb migrate --yes`. The protocol model
(`backup_protocol_model.rs`) is rewritten for the new steps.

Cassandra. `V006__backup_catalog_index.cql`, after which the runner
writes `0.0.5` (once the backfill step and the file's `schema_history`
row are in, so a run that fails in the step leaves 0.0.4 and `migrate
--yes` runs the file again): the index columns (`store_kind`,
`store_root`, `store_id`, `backup_id`, `owner_generation`,
`heartbeat_at`, `completed_at`, `failure_reason`,
`source_table_details_json`) on the reused `backups_by_arn`, the
status-keyed `backup_index_by_status`, the `restore_*` job columns on
`tables`, `backup_maintenance`, and a Rust backfill step keyed on the
file. The file's statements are `ALTER TABLE ... ADD IF NOT EXISTS` and
`CREATE TABLE IF NOT EXISTS`, which the runner qualifies with the
keyspace (#638); the step and the version write name the keyspace in
their own statements, and no code or test issues `USE`. An index row is
a `backups_by_arn` row with `store_kind` set, and the two readers skip
each other's rows. Claims are lightweight transactions `IF
owner_generation = ?`; the legacy create heartbeats its CREATING row and
polls its claim once per 64-item bucket, and
`abandoned_creating_backups` fences a stale one to DELETING under a
fresh `owner_generation` before reporting it, scanning DELETING rows
first so a row it fenced is reported once. A job stops after three
heartbeat errors in a row, as after a heartbeat that does not apply, and
touches neither its row nor its data again. An orphan
`backup_index_by_status` entry left by a crash between the row's write
and the batch is skipped by every reader; the sweep that removes it is
`backup sync`'s. `serve` refuses a catalog whose version is not
`CATALOG_VERSION`, as on MongoDB. `poll_abandoned_restores` and its
worker are removed. The crate gains a `test-hooks` feature (as
MongoDB's), compiled only for the `direct_integration` binary, which CI
runs with `--features test-hooks`.

Migration and versions. One change, one number: migration id 5 on
PostgreSQL and SQLite, `0.0.5` on MongoDB and Cassandra. The migration
is breaking (stop pre-upgrade servers first); a restore in flight at the
stop is a RUNNING record whose owner is dead, failed and removed on the
first reconciler pass.

Docs. The upgrade manual (migration 5 and its rollback, the Cassandra
and MongoDB `0.0.5` section with the Cassandra resume sentence, the
serve-time refusal, the removed 15 s sweep line), the admin guide
(`[backup]`, the job bound in place of the per-account and half-pool
restore slots, the pooler note), the deployment guide (the job bound in
place of the restore slots; the connection formulas gain
`+ max_concurrent_jobs`), the high-level design and getting-started
connection formulas, the limits document's concurrent-restores row, the
architecture guide, the MongoDB design document, the differences
document's restore rows (the MongoDB registration bound among them), the
admin guide's `region` row, the differences document's `BackupArn` and
backup creation rows, the limits document's `BackupName`/`TableName` row
and the upgrade manual's `server.region` paragraph (CreateBackup refused
under a region outside `[a-z0-9-]`; a name no backup ARN can carry
refused with the texts), `CONTRIBUTING.md`'s migration example
renumbered to 6, the sample config, `docs/schema/postgres.txt`
(regenerated for the collation) and `sqlite.txt` regenerated,
and the backup/restore design's PR 6 section and section 4 (the
migration numbers as implemented, revision log items 26 and 27).

Tests. extenddb-storage 196 (14 new; 197 beside a crate that enables its
`test-hooks` feature, as `cargo test --workspace` does), extenddb-config
14 (6 new), extenddb-engine 208 (25 new: the target name before the
backup and the permit, catalog describe, list, delete
and restore paths, permit order, the ARN rules, reconciler cycle,
duties, metrics and worker). extenddb-storage-postgres: lib 69,
tests/backup_restore 37 (28 kept, 1 rewritten, 2 removed, 9 new), every
other binary green (fresh 5, released 12, golden 5, crash 9, guards 6,
runs 5, owners 3, tag_blobs 1, vector_control_plane 42,
doc_version_literals 3, table_snapshot 81 with its restore record
written as a DONE legacy job, ...). extenddb-storage-sqlite 225 (3
rewritten, 11 new; 1 ignored benchmark). extenddb-storage-mongodb: lib
141 plus 28 live tests against a replica set (22 backup, 6 stream; 10
new or rewritten, 5 removed with the slots and the sweep),
interpreter_selftests 18, pushdown_parity 20.
extenddb-storage-cassandra: lib 78 (4 new), direct_integration 192 (12
new, 1 rewritten, 1 removed, 1 renamed; three of the new tests build
with `--features test-hooks`), ttl_integration 26 passed and the one
pre-existing failure below. Wire: tests/test_backup_restore_fidelity.py
(37) and tests/test_table_operations.py (27) against a `serve` of this
tree: 64 passed on PostgreSQL with `EXTENDDB_TEST_BACKEND=postgres
EXTENDDB_CONFIG=<config>` (the DELETING test reads the catalog), 63 and
1 skipped without them; on SQLite 63 and the DELETING test skipped
without them (with them it fails on the host's sqlite3 3.7.17 module,
which cannot read a partial index); on Cassandra 62 passed and 2 skipped
(the vector test and the DELETING test). `cargo fmt`, `clippy -D
warnings` on 1.97, `check --all-targets` on 1.88, `cargo doc` with no
new warnings.

Not changed here. The backup store (`[backup.store]`, the store identity
file) and the store-backed CreateBackup writer, worker and `backup`
commands; the manifest restore job; every `backup_index` row is "of
another store" for Delete and Restore until a store is configured. The
Cassandra `test_ttl_enable_rejects_asynchronous_gsi` failure predates
this change (it fails the same way without it, checked on a clean
bffa088: the test matches
"asynchronously propagated GSIs" and the refusal says "global secondary
index"). `heartbeat_interval_secs` reaches no job yet: every in-catalog
job heartbeats at `JOB_HEARTBEAT_INTERVAL`, which the stale floor is
computed from.

Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 31, 2026
Shared traits (crates/storage). `BackupEngine` is replaced by two traits
every backend implements: `BackupCatalog`, the catalog index of backups
and restore jobs the engine binds (one row per backup addressed by
`BackupKey` (account, backup id, ARN), one restore job record per target
addressed by `RestoreTargetKey` (account, name, table id), the listings,
`start_backup_delete` under the row's lock, `remove_restore_target`,
`list_dead_restore_jobs`, `fail_restore_job`, `check_maintenance_claim`,
`catalog_id`) with its `JobClaim` (generation, heartbeat, conditional
`set_status`, `read`), and `LegacyBackupReader`, the frozen in-catalog
format's create, describe, list, delete, restore-source and restore,
whose `create` and `restore` now take the job's `JobPermit` and whose
`abandoned_creating_backups` reports a dead CREATING row. `BackupRow`
derives the API's source-table shapes from `source_table_details_json`
(reading only the table id and ARN, so a later build's required member
does not break a listing); `BackupStatus` maps DELETING to DELETED and
hides DELETED and FAILED. The in-use, not-found and LimitExceeded texts
every backend answers are one implementation in `backup_catalog`
(`limit_exceeded_message(JobBound)`, the text naming the bound met:
`[backup] max_concurrent_jobs`, the `[storage.sqlite] pool_size`
snapshot-slot bound, PostgreSQL's connection slots), as is
`JOB_HEARTBEAT_INTERVAL` (15 s), the period every backend's jobs
heartbeat at. `StorageError::AlreadyExists` is the claim's refusal;
`MigrationId::BackupCatalogIndex = 5` is the migration.
`util::require_served_catalog_version` is the rule `serve` applies on a
backend whose catalog carries a version string.

Config. A `[backup]` section (`deny_unknown_fields`, every key with a
default): `data_file_target_bytes`, `heartbeat_interval_secs`,
`stale_job_after_secs`, `failed_row_retention_secs`,
`reconcile_interval_secs`, `max_concurrent_jobs`, `spool_path`, with
`stale_job_after_secs` at least three times the period the jobs
heartbeat at (`max(heartbeat_interval_secs, JOB_HEARTBEAT_INTERVAL)`,
45 s at the least), `max_concurrent_jobs` at most 1024, and the floors
checked at load; `init` writes the commented block and the sample config
carries it. `ServerComponentsOptions.max_concurrent_jobs` lets the
PostgreSQL factory raise both pools to `2 * jobs + 10` with a warning.

Engine. `BackupRuntime` holds the job slots and the configured store
(none on this head); a request over `max_concurrent_jobs` is
`LimitExceededException` at once. Describe, List and Delete read the
catalog index first and fall back to the legacy reader; List merges
`list_account_backup_rows` with the reader's list under the existing
paging, filtering and cursor rules, and `TableName` accepts a table ARN
as CreateBackup does. CreateBackup runs the request order of the design
(ACTIVE probe; the region and the name fit a backup ARN, each refusal
naming its cause: `server.region` outside `[a-z0-9-]`, then a name over
255 bytes or made only of dots, checked before the ARN is formed so the
limit is named whatever the length; vector refusal; permit last;
deprecation warning; the legacy create); restore checks the target name
through `describe_table` after the ARN (a table of the name
`TableAlreadyExistsException`, a CREATING restore target
`TableInUseException` naming its source backup), then the backup, then
takes the permit last and hands it to the backend; the backend's fenced
name check under the registration stays. Delete over a row runs the
in-use check before the other-store check, as a restore does, and builds
the description before the DELETING write; a row whose recorded source
table this build cannot read is left out of ListBackups (logged) rather
than failing the listing. Both spellings of a backup ARN (as written and
re-spelled in this region) parse into `BackupArn` where the store-prefix
rules allow; a legacy backup of a name they refuse (dot-only, over 255
bytes) skips the catalog index and describes, restores and deletes
through the legacy reader as on main.

Reconciler (crates/engine/src/backup/reconciler.rs). A cycle at startup
and every `reconcile_interval_secs`, spawned by `serve` beside the
backend workers and drained with them: checks the maintenance claim,
fails each dead legacy restore (`heartbeat_at` older than
`stale_job_after_secs`) and removes its CREATING target (a target the
removal finds no longer CREATING is left to its owner and reported as
such), removes the target of a FAILED record whose removal did not land
once the record is that old, with no second FAILED write (the
FAILED-target duty of design 4(e), on every backend; on Cassandra, where
the removal is a claim to DELETING and then a drop, a target left
DELETING with its registration is listed on the same rule and its
removal resumed from the drop), logs a dead manifest restore once per
record and leaves it to a later release, deletes each abandoned
in-catalog CREATING backup through the reader, and records the worker
metrics (`WorkerLastSuccess`, `WorkerCycleLatency`, `WorkerErrorCount`
under `BackupReconciler`). It replaces the per-backend sweeps:
PostgreSQL's control-plane pass, SQLite's startup sweeps, MongoDB's
startup and worker sweep, Cassandra's 15 s poller.

PostgreSQL. Migration 5 (`0005_backup_catalog_index.sql`, breaking,
convergent, one transaction): `backup_index` (`backup_arn COLLATE "C"`,
so the listings' `(created_at, backup_arn)` order is byte order whatever
the database's collation), the twelve job columns on
`table_restores` with the backfill of existing registrations (DONE with
`completed_at = creation_date_time`, or RUNNING with the epoch heartbeat
and generation 0 where the table is CREATING), `backup_maintenance`,
`settings.catalog_id`. The claim is an advisory lock on a dedicated
connection (`ClaimSession`) that the backup job's heartbeats and status
writes travel through (the legacy restore heartbeats through it and
writes DONE and FAILED on pooled connections, each conditioned on
RUNNING and the generation); the reconciler's dead rule is the heartbeat
alone, the lock being the claim's exclusion, as on the other backends.
The legacy restore registers RUNNING with its generation, marks DONE in
the transaction that flips the table ACTIVE, and FAILED before it
removes a failed target. The restore slot semaphores (per account and
per server) and `sweep_abandoned_restores` are removed.
`RESTORE_LOCK_SEED` and `abort_restore` are `pub` and `#[doc(hidden)]`
so the tests bind them.

SQLite. Migration 5 as a code body with the same objects; the twelve
columns are nullable (ADD COLUMN on a populated table) and the
application writes every one. The claim is an in-process slot
(`JobSlots`, a drop guard), since one process serves a file; a restart
ages every RUNNING record to the epoch so the first cycle removes an
interrupted restore, and a CREATING `backups` row whose slot no task
holds is reported dead whatever `stale_after` says (`backups` has no
heartbeat column); the reader's `delete` removes such a row whole, with
no DELETED tombstone. The restore heartbeats every
`JOB_HEARTBEAT_INTERVAL` (15 s) under the engine write lock. Conditional
writes never have an unknown outcome here. `backup_server_slot_count`
stays until a later release folds it into `max_concurrent_jobs`; its
slots are shared by backups and table snapshots (#395), the formula is
stated once on `SqliteConfig::pool_size`, and the legacy create tries a
slot without waiting, as a snapshot open does, and answers the one
`LimitExceededException` text (`limit_exceeded_message`), naming the
`[storage.sqlite] pool_size` snapshot-slot bound, for both.

MongoDB. `backup_index` and `backup_maintenance` collections, a
`restore_job` subdocument on a restore target's `tables` document, and
the versioned step `002_backup_catalog_index` recorded in
`schema_history`, which backfills the registrations, writes the catalog
id and moves `catalog_version` to `0.0.5` (one constant,
`CATALOG_VERSION`, where three copies of `0.0.2` were). The claim is the
row, born owned by an upsert with `heartbeat_at: "$$NOW"`; the lock for
DeleteBackup and a restore's registration is a session transaction that
increments `lock_seq` on the backup's document, which replaces the
`restore_in_progress`/`delete_in_progress` claim markers and the sweep
that cleared them. Both in-use checks read at the transaction read
concern (majority-committed data) while DescribeTable reads at `local`,
so a RestoreTableFromBackup or DeleteBackup issued within the
majority-commit lag after DescribeTable first shows the target ACTIVE
can still answer BackupInUseException and succeeds when retried; the
differences document's concurrent-restores row states it with the
measured lag. The legacy DeleteBackup writes DELETING before the
drop and DELETED after; describe and list hide DELETING. A restore
request that outlives the 60 s registration bound answers
InternalServerError while its task goes on; the target it leaves is
stated in the differences document's restore row. The restore slot
semaphores are removed. `serve` refuses a catalog whose version is not
`CATALOG_VERSION`, naming `extenddb migrate --yes`. The protocol model
(`backup_protocol_model.rs`) is rewritten for the new steps.

Cassandra. `V006__backup_catalog_index.cql`, after which the runner
writes `0.0.5` (once the backfill step and the file's `schema_history`
row are in, so a run that fails in the step leaves 0.0.4 and `migrate
--yes` runs the file again): the index columns (`store_kind`,
`store_root`, `store_id`, `backup_id`, `owner_generation`,
`heartbeat_at`, `completed_at`, `failure_reason`,
`source_table_details_json`) on the reused `backups_by_arn`, the
status-keyed `backup_index_by_status`, the `restore_*` job columns on
`tables`, `backup_maintenance`, and a Rust backfill step keyed on the
file. The file's statements are `ALTER TABLE ... ADD IF NOT EXISTS` and
`CREATE TABLE IF NOT EXISTS`, which the runner qualifies with the
keyspace (#638); the step and the version write name the keyspace in
their own statements, and no code or test issues `USE`. An index row is
a `backups_by_arn` row with `store_kind` set, and the two readers skip
each other's rows. Claims are lightweight transactions `IF
owner_generation = ?`; the legacy create heartbeats its CREATING row and
polls its claim once per 64-item bucket, and
`abandoned_creating_backups` fences a stale one to DELETING under a
fresh `owner_generation` before reporting it, scanning DELETING rows
first so a row it fenced is reported once. A job stops after three
heartbeat errors in a row, as after a heartbeat that does not apply, and
touches neither its row nor its data again. An orphan
`backup_index_by_status` entry left by a crash between the row's write
and the batch is skipped by every reader; the sweep that removes it is
`backup sync`'s. `serve` refuses a catalog whose version is not
`CATALOG_VERSION`, as on MongoDB. `poll_abandoned_restores` and its
worker are removed. The crate gains a `test-hooks` feature (as
MongoDB's), compiled only for the `direct_integration` binary, which CI
runs with `--features test-hooks`.

Migration and versions. One change, one number: migration id 5 on
PostgreSQL and SQLite, `0.0.5` on MongoDB and Cassandra. The migration
is breaking (stop pre-upgrade servers first); a restore in flight at the
stop is a RUNNING record whose owner is dead, failed and removed on the
first reconciler pass.

Docs. The upgrade manual (migration 5 and its rollback, the Cassandra
and MongoDB `0.0.5` section with the Cassandra resume sentence, the
serve-time refusal, the removed 15 s sweep line), the admin guide
(`[backup]`, the job bound in place of the per-account and half-pool
restore slots, the pooler note), the deployment guide (the job bound in
place of the restore slots; the connection formulas gain
`+ max_concurrent_jobs`), the high-level design and getting-started
connection formulas, the limits document's concurrent-restores row, the
architecture guide, the MongoDB design document, the differences
document's restore rows (the MongoDB registration bound among them), the
admin guide's `region` row, the differences document's `BackupArn` and
backup creation rows, the limits document's `BackupName`/`TableName` row
and the upgrade manual's `server.region` paragraph (CreateBackup refused
under a region outside `[a-z0-9-]`; a name no backup ARN can carry
refused with the texts), `CONTRIBUTING.md`'s migration example
renumbered to 6, the sample config, `docs/schema/postgres.txt`
(regenerated for the collation) and `sqlite.txt` regenerated,
and the backup/restore design's PR 6 section and section 4 (the
migration numbers as implemented, revision log items 26 and 27).

Tests. extenddb-storage 196 (14 new; 197 beside a crate that enables its
`test-hooks` feature, as `cargo test --workspace` does), extenddb-config
14 (6 new), extenddb-engine 208 (25 new: the target name before the
backup and the permit, catalog describe, list, delete and restore paths,
permit order, the ARN rules, reconciler cycle, duties, metrics and
worker). extenddb-storage-postgres: lib 69, tests/backup_restore 37 (28
kept, 1 rewritten, 2 removed, 9 new), every other binary green (fresh 5,
released 12, golden 5, crash 9, guards 6, runs 5, owners 3, tag_blobs 1,
vector_control_plane 42, doc_version_literals 3, table_snapshot 81 with
its restore record written as a DONE legacy job, ...).
extenddb-storage-sqlite 225 (3 rewritten, 11 new; 1 ignored benchmark).
extenddb-storage-mongodb: lib 141 plus 28 live tests against a replica
set (22 backup, 6 stream; 10 new or rewritten, 5 removed with the slots
and the sweep), interpreter_selftests 18, pushdown_parity 20.
extenddb-storage-cassandra: lib 78 (4 new), direct_integration 192 (12
new, 1 rewritten, 1 removed, 1 renamed; three of the new tests build
with `--features test-hooks`), ttl_integration 26 passed and the one
pre-existing failure below. tests/test_resource_tag_migration.py's
released-catalog fixture strips migration 5's objects before it clears
the ledger, since the check after migration 1 refuses a `table_restores`
carrying the job columns. Wire: tests/test_backup_restore_fidelity.py
(37) and tests/test_table_operations.py (27) against a `serve` of this
tree: 64 passed on PostgreSQL with `EXTENDDB_TEST_BACKEND=postgres
EXTENDDB_CONFIG=<config>` (the DELETING test reads the catalog), 63 and
1 skipped without them; on SQLite 63 and the DELETING test skipped
without them (with them it fails on the host's sqlite3 3.7.17 module,
which cannot read a partial index); on Cassandra 62 passed and 2 skipped
(the vector test and the DELETING test). `cargo fmt`, `clippy -D
warnings` on 1.97, `check --all-targets` on 1.88, `cargo doc` with no
new warnings.

Not changed here. The backup store (`[backup.store]`, the store identity
file) and the store-backed CreateBackup writer, worker and `backup`
commands; the manifest restore job; every `backup_index` row is "of
another store" for Delete and Restore until a store is configured. The
Cassandra `test_ttl_enable_rejects_asynchronous_gsi` failure predates
this change (it fails the same way without it, checked on a clean
bffa088: the test matches
"asynchronously propagated GSIs" and the refusal says "global secondary
index"). `heartbeat_interval_secs` reaches no job yet: every in-catalog
job heartbeats at `JOB_HEARTBEAT_INTERVAL`, which the stale floor is
computed from.

Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Nov 3, 2026
Shared traits (crates/storage). `BackupEngine` is replaced by two traits
every backend implements: `BackupCatalog`, the catalog index of backups
and restore jobs the engine binds (one row per backup addressed by
`BackupKey` (account, backup id, ARN), one restore job record per target
addressed by `RestoreTargetKey` (account, name, table id), the listings,
`start_backup_delete` under the row's lock, `remove_restore_target`,
`list_dead_restore_jobs`, `fail_restore_job`, `check_maintenance_claim`,
`catalog_id`) with its `JobClaim` (generation, heartbeat, conditional
`set_status`, `read`), and `LegacyBackupReader`, the frozen in-catalog
format's create, describe, list, delete, restore-source and restore,
whose `create` and `restore` now take the job's `JobPermit` and whose
`abandoned_creating_backups` reports a dead CREATING row. `BackupRow`
derives the API's source-table shapes from `source_table_details_json`
(reading only the table id and ARN, so a later build's required member
does not break a listing); `BackupStatus` maps DELETING to DELETED and
hides DELETED and FAILED. The in-use, not-found and LimitExceeded texts
every backend answers are one implementation in `backup_catalog`
(`limit_exceeded_message(JobBound)`, the text naming the bound met:
`[backup] max_concurrent_jobs`, the `[storage.sqlite] pool_size`
snapshot-slot bound, PostgreSQL's connection slots), as is
`JOB_HEARTBEAT_INTERVAL` (15 s), the period every backend's jobs
heartbeat at. `StorageError::AlreadyExists` is the claim's refusal;
`MigrationId::BackupCatalogIndex = 5` is the migration.
`util::require_served_catalog_version` is the rule `serve` applies on a
backend whose catalog carries a version string.

Config. A `[backup]` section (`deny_unknown_fields`, every key with a
default): `data_file_target_bytes`, `heartbeat_interval_secs`,
`stale_job_after_secs`, `failed_row_retention_secs`,
`reconcile_interval_secs`, `max_concurrent_jobs`, `spool_path`, with
`stale_job_after_secs` at least three times the period the jobs
heartbeat at (`max(heartbeat_interval_secs, JOB_HEARTBEAT_INTERVAL)`,
45 s at the least), `max_concurrent_jobs` at most 1024, and the floors
checked at load; `init` writes the commented block and the sample config
carries it. `ServerComponentsOptions.max_concurrent_jobs` lets the
PostgreSQL factory raise both pools to `2 * jobs + 10` with a warning.

Engine. `BackupRuntime` holds the job slots and the configured store
(none on this head); a request over `max_concurrent_jobs` is
`LimitExceededException` at once. Describe, List and Delete read the
catalog index first and fall back to the legacy reader; List merges
`list_account_backup_rows` with the reader's list under the existing
paging, filtering and cursor rules, and `TableName` accepts a table ARN
as CreateBackup does. CreateBackup runs the request order of the design
(ACTIVE probe; the region and the name fit a backup ARN, each refusal
naming its cause: `server.region` outside `[a-z0-9-]`, then a name over
255 bytes or made only of dots, checked before the ARN is formed so the
limit is named whatever the length; vector refusal; permit last;
deprecation warning; the legacy create); restore checks the target name
through `describe_table` after the ARN (a table of the name
`TableAlreadyExistsException`, a CREATING restore target
`TableInUseException` naming its source backup), then the backup, then
takes the permit last and hands it to the backend; the backend's fenced
name check under the registration stays. Delete over a row runs the
in-use check before the other-store check, as a restore does, and builds
the description before the DELETING write; a row whose recorded source
table this build cannot read is left out of ListBackups (logged) rather
than failing the listing. Both spellings of a backup ARN (as written and
re-spelled in this region) parse into `BackupArn` where the store-prefix
rules allow; a legacy backup of a name they refuse (dot-only, over 255
bytes) skips the catalog index and describes, restores and deletes
through the legacy reader as on main.

Reconciler (crates/engine/src/backup/reconciler.rs). A cycle at startup
and every `reconcile_interval_secs`, spawned by `serve` beside the
backend workers and drained with them: checks the maintenance claim,
fails each dead legacy restore (`heartbeat_at` older than
`stale_job_after_secs`) and removes its CREATING target (a target the
removal finds no longer CREATING is left to its owner and reported as
such), removes the target of a FAILED record whose removal did not land
once the record is that old, with no second FAILED write (the
FAILED-target duty of design 4(e), on every backend; on Cassandra, where
the removal is a claim to DELETING and then a drop, a target left
DELETING with its registration is listed on the same rule and its
removal resumed from the drop), logs a dead manifest restore once per
record and leaves it to a later release, deletes each abandoned
in-catalog CREATING backup through the reader, and records the worker
metrics (`WorkerLastSuccess`, `WorkerCycleLatency`, `WorkerErrorCount`
under `BackupReconciler`). It replaces the per-backend sweeps:
PostgreSQL's control-plane pass, SQLite's startup sweeps, MongoDB's
startup and worker sweep, Cassandra's 15 s poller.

PostgreSQL. Migration 5 (`0005_backup_catalog_index.sql`, breaking,
convergent, one transaction): `backup_index` (`backup_arn COLLATE "C"`,
so the listings' `(created_at, backup_arn)` order is byte order whatever
the database's collation), the twelve job columns on
`table_restores` with the backfill of existing registrations (DONE with
`completed_at = creation_date_time`, or RUNNING with the epoch heartbeat
and generation 0 where the table is CREATING), `backup_maintenance`,
`settings.catalog_id`. The claim is an advisory lock on a dedicated
connection (`ClaimSession`) that the backup job's heartbeats and status
writes travel through (the legacy restore heartbeats through it and
writes DONE and FAILED on pooled connections, each conditioned on
RUNNING and the generation); the reconciler's dead rule is the heartbeat
alone, the lock being the claim's exclusion, as on the other backends.
The legacy restore registers RUNNING with its generation, marks DONE in
the transaction that flips the table ACTIVE, and FAILED before it
removes a failed target. The restore slot semaphores (per account and
per server) and `sweep_abandoned_restores` are removed.
`RESTORE_LOCK_SEED` and `abort_restore` are `pub` and `#[doc(hidden)]`
so the tests bind them.

SQLite. Migration 5 as a code body with the same objects; the twelve
columns are nullable (ADD COLUMN on a populated table) and the
application writes every one. The claim is an in-process slot
(`JobSlots`, a drop guard), since one process serves a file; a restart
ages every RUNNING record to the epoch so the first cycle removes an
interrupted restore, and a CREATING `backups` row whose slot no task
holds is reported dead whatever `stale_after` says (`backups` has no
heartbeat column); the reader's `delete` removes such a row whole, with
no DELETED tombstone. The restore heartbeats every
`JOB_HEARTBEAT_INTERVAL` (15 s) under the engine write lock, so a beat
queues behind every writer holding or waiting for that lock.
Conditional writes never have an unknown outcome here.
`backup_server_slot_count` stays beside `max_concurrent_jobs`; its
slots are shared by backups and table snapshots (#395), the formula is
stated once on `SqliteConfig::pool_size`, and the legacy create tries a
slot without waiting, as a snapshot open does, and answers the one
`LimitExceededException` text (`limit_exceeded_message`), naming the
`[storage.sqlite] pool_size` snapshot-slot bound, for both.

MongoDB. `backup_index` and `backup_maintenance` collections, a
`restore_job` subdocument on a restore target's `tables` document, and
the versioned step `002_backup_catalog_index` recorded in
`schema_history`, which backfills the registrations, writes the catalog
id and moves `catalog_version` to `0.0.5` (one constant,
`CATALOG_VERSION`, where three copies of `0.0.2` were). The claim is the
row, born owned by an upsert with `heartbeat_at: "$$NOW"`; the lock for
DeleteBackup and a restore's registration is a session transaction that
increments `lock_seq` on the backup's document, which replaces the
`restore_in_progress`/`delete_in_progress` claim markers and the sweep
that cleared them. Both in-use checks read at the transaction read
concern (majority-committed data) while DescribeTable reads at `local`,
so a RestoreTableFromBackup or DeleteBackup issued within the
majority-commit lag after DescribeTable first shows the target ACTIVE
can still answer BackupInUseException and succeeds when retried; the
differences document's concurrent-restores row states it with the
measured lag. The legacy DeleteBackup writes DELETING before the
drop and DELETED after; describe and list hide DELETING. A restore
request that outlives the 60 s registration bound answers
InternalServerError while its task goes on; the target it leaves is
stated in the differences document's restore row. The restore slot
semaphores are removed. `serve` refuses a catalog whose version is not
`CATALOG_VERSION`, naming `extenddb migrate --yes`, and checks it before
the factory builds the data engine or starts the startup key repair of
#473 (`open_data_engine`), so a server that refuses has written
nothing. The protocol model
(`backup_protocol_model.rs`) is rewritten for the new steps.

Cassandra. `V006__backup_catalog_index.cql`, after which the runner
writes `0.0.5` (once the backfill step and the file's `schema_history`
row are in, so a run that fails in the step leaves 0.0.4 and `migrate
--yes` runs the file again): the index columns (`store_kind`,
`store_root`, `store_id`, `backup_id`, `owner_generation`,
`heartbeat_at`, `completed_at`, `failure_reason`,
`source_table_details_json`) on the reused `backups_by_arn`, the
status-keyed `backup_index_by_status`, the `restore_*` job columns on
`tables`, `backup_maintenance`, and a Rust backfill step keyed on the
file. The file's statements are `ALTER TABLE ... ADD IF NOT EXISTS` and
`CREATE TABLE IF NOT EXISTS`, which the runner qualifies with the
keyspace (#638); the step and the version write name the keyspace in
their own statements, and no code or test issues `USE`. An index row is
a `backups_by_arn` row with `store_kind` set, and the two readers skip
each other's rows. Claims are lightweight transactions `IF
owner_generation = ?`; the legacy create heartbeats its CREATING row and
polls its claim once per 64-item bucket, and
`abandoned_creating_backups` fences a stale one to DELETING under a
fresh `owner_generation` before reporting it, scanning DELETING rows
first so a row it fenced is reported once. A job stops after three
heartbeat errors in a row, as after a heartbeat that does not apply, and
touches neither its row nor its data again. An orphan
`backup_index_by_status` entry left by a crash between the row's write
and the batch is skipped by every reader; the sweep that removes it is
`backup sync`'s. `serve` refuses a catalog whose version is not
`CATALOG_VERSION`, as on MongoDB. `poll_abandoned_restores` and its
worker are removed. The crate gains a `test-hooks` feature (as
MongoDB's), compiled only for the `direct_integration` binary, which CI
runs with `--features test-hooks`.

Migration and versions. One change, one number: migration id 5 on
PostgreSQL and SQLite, `0.0.5` on MongoDB and Cassandra. The migration
is breaking (stop pre-upgrade servers first); a restore in flight at the
stop is a RUNNING record whose owner is dead, failed and removed on the
first reconciler pass.

Docs. The upgrade manual (migration 5 and its rollback, the Cassandra
and MongoDB `0.0.5` section with the Cassandra resume sentence, the
serve-time refusal, the removed 15 s sweep line, and the SQLite startup
sentence: the server ages every RUNNING restore record and the first
reconciler cycle fails an in-catalog restore and removes its target),
the admin guide (`[backup]`, the job bound in place of the per-account
and half-pool restore slots, the pooler note), the deployment guide (the
job bound in place of the restore slots; the connection formulas gain
`+ max_concurrent_jobs`), the high-level design and getting-started
connection formulas, the limits document's concurrent-restores row, the
architecture guide, the MongoDB design document (the version check, run
before the data engine is built), the differences
document's restore rows (the MongoDB registration bound among them), the
admin guide's `region` row, the differences document's `BackupArn` and
backup creation rows, the limits document's `BackupName`/`TableName` row
and the upgrade manual's `server.region` paragraph (CreateBackup refused
under a region outside `[a-z0-9-]`; a name no backup ARN can carry
refused with the texts), `CONTRIBUTING.md`'s migration example
renumbered to 6, the sample config, `docs/schema/postgres.txt`
(regenerated for the collation) and `sqlite.txt` regenerated,
and the backup/restore design's PR 6 section and section 4 (the
migration numbers as implemented, the interface block with
`list_dead_restore_jobs`, `fail_restore_job` and
`abandoned_creating_backups`, revision log items 26 and 27).

Tests. extenddb-storage 204 (14 new; 205 beside a crate that enables its
`test-hooks` feature, as `cargo test --workspace` does), extenddb-config
14 (6 new), extenddb-engine 208 (25 new: the target name before the
backup and the permit, catalog describe, list, delete and restore paths,
permit order, the ARN rules, reconciler cycle, duties, metrics and
worker). extenddb-storage-postgres: lib 75, tests/backup_restore 37 (28
kept, 1 rewritten, 2 removed, 9 new), every other binary green (fresh 5,
released 12, golden 7, crash 9, guards 7, runs 7, owners 3, serving 5,
tag_blobs 1, vector_control_plane 42, doc_version_literals 3,
table_snapshot 81 with its restore record written as a DONE legacy job,
...). extenddb-storage-sqlite 241 (3 rewritten, 12 new, among them a
target removed only under its full key; 1 ignored benchmark).
extenddb-storage-mongodb: lib 152 plus 45 live tests against a replica
set (22 backup, 6 stream, 17 key repair; 11 new or rewritten, among
them a catalog at the previous version refused with no repair started,
5 removed with the slots and the sweep), interpreter_selftests 18,
pushdown_parity 20.
extenddb-storage-cassandra: lib 78 (4 new), direct_integration 192 (12
new, 1 rewritten, 1 removed, 1 renamed; three of the new tests build
with `--features test-hooks`), ttl_integration 26 passed and the one
pre-existing failure below. tests/test_resource_tag_migration.py's
released-catalog fixture strips migration 5's objects before it clears
the ledger, since the check after migration 1 refuses a `table_restores`
carrying the job columns. Wire, on the tree before its rebase onto
4fa6769: tests/test_backup_restore_fidelity.py
(37) and tests/test_table_operations.py (27) against a `serve` of this
tree: 64 passed on PostgreSQL with `EXTENDDB_TEST_BACKEND=postgres
EXTENDDB_CONFIG=<config>` (the DELETING test reads the catalog), 63 and
1 skipped without them; on SQLite 63 and the DELETING test skipped
without them (with them it fails on the host's sqlite3 3.7.17 module,
which cannot read a partial index); on Cassandra 62 passed and 2 skipped
(the vector test and the DELETING test). On 4fa6769 the MongoDB
workflow's commands pass against a release build of this tree:
`run-mongodb-tests --rust-live -- --rust-integration` (tests/rust 512,
key repair live 17, records live 6) and the pytest job's `--pytest
--comprehensive --parallel` (1551 passed, 50 skipped; 340). `cargo fmt`,
`clippy -D warnings` on 1.97, `check --all-targets` on 1.88, `cargo doc`
with no new warnings.

Not changed here. The backup store (`[backup.store]`, the store identity
file) and the store-backed CreateBackup writer, worker and `backup`
commands; the manifest restore job; every `backup_index` row is "of
another store" for Delete and Restore until a store is configured. The
Cassandra `test_ttl_enable_rejects_asynchronous_gsi` failure predates
this change (it fails the same way without it, checked on a clean
bffa088: the test matches
"asynchronously propagated GSIs" and the refusal says "global secondary
index"). `heartbeat_interval_secs` reaches no job yet: every in-catalog
job heartbeats at `JOB_HEARTBEAT_INTERVAL`, which the stale floor is
computed from.

Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
@navareamol navareamol added this to the 1.0 milestone Nov 3, 2026
LeeroyHannigan added a commit that referenced this pull request Nov 3, 2026
…runs

The second DeleteTable reads the row again after the first commits,
finds it DELETING and answers TableBeingDeleted. Since the rebase on
#395 there is no status filter on that select and no second lookup.
LeeroyHannigan added a commit that referenced this pull request Nov 3, 2026
Merged with main, the storage-mongodb test build failed with E0063: the
MongoEngine literals in stream_records_live_tests.rs (from #543) and in
snapshot_engine.rs (from #395) do not set contention_deadline. Each test
that needed an engine listed every field, so each field added to
MongoEngine broke every such literal.

MongoEngine::new now builds the engine in from_parts, and a test-only
MongoEngine::for_test calls the same function with the driver's default
pool of 10 connections. The test literals in backup_live_tests.rs,
stream_records_live_tests.rs, snapshot_engine.rs and the restore backfill
test now call for_test and set only the fields they change with struct
update syntax. A new field is set in from_parts and nowhere else.

The four contention storage tests move from the data_engine test module
into data_engine_live_tests_contention.rs, next to the create-race tests
#431 moved. They build the engine through the live test module's
scratch_engine, are #[ignore]d like the other live tests and run under
devtools/run-mongodb-tests --rust-live, and set the deadline with struct
update syntax. The with_scratch_engine helpers and the second URI gate go
with them. What each test checks is unchanged.
@navareamol navareamol added the release-blocker Must ship for its milestone's release label Nov 3, 2026
robinnsc added a commit that referenced this pull request Nov 4, 2026
Shared traits (crates/storage). `BackupEngine` is replaced by two traits
every backend implements: `BackupCatalog`, the catalog index of backups
and restore jobs the engine binds (one row per backup addressed by
`BackupKey` (account, backup id, ARN), one restore job record per target
addressed by `RestoreTargetKey` (account, name, table id), the listings,
`start_backup_delete` under the row's lock, `remove_restore_target`,
`list_dead_restore_jobs`, `fail_restore_job`, `check_maintenance_claim`,
`catalog_id`) with its `JobClaim` (generation, heartbeat, conditional
`set_status`, `read`), and `LegacyBackupReader`, the frozen in-catalog
format's create, describe, list, delete, restore-source and restore,
whose `create` and `restore` now take the job's `JobPermit` and whose
`abandoned_creating_backups` reports a dead CREATING row. `BackupRow`
derives the API's source-table shapes from `source_table_details_json`
(reading only the table id and ARN, so a later build's required member
does not break a listing); `BackupStatus` maps DELETING to DELETED and
hides DELETED and FAILED. The in-use, not-found and LimitExceeded texts
every backend answers are one implementation in `backup_catalog`
(`limit_exceeded_message(JobBound)`, the text naming the bound met:
`[backup] max_concurrent_jobs`, the `[storage.sqlite] pool_size`
snapshot-slot bound, PostgreSQL's connection slots), as is
`JOB_HEARTBEAT_INTERVAL` (15 s), the period every backend's jobs
heartbeat at. `StorageError::AlreadyExists` is the claim's refusal;
`MigrationId::BackupCatalogIndex = 6` is the migration.

Config. A `[backup]` section (`deny_unknown_fields`, every key with a
default): `data_file_target_bytes`, `heartbeat_interval_secs`,
`stale_job_after_secs`, `failed_row_retention_secs`,
`reconcile_interval_secs`, `max_concurrent_jobs`, `spool_path`, with
`stale_job_after_secs` at least three times the period the jobs
heartbeat at (`max(heartbeat_interval_secs, JOB_HEARTBEAT_INTERVAL)`,
45 s at the least), `max_concurrent_jobs` at most 1024, and the floors
checked at load; `init` writes the commented block and the sample config
carries it. `ServerComponentsOptions.max_concurrent_jobs` lets the
PostgreSQL factory raise both pools to `2 * jobs + 10` with a warning.

Engine. `BackupRuntime` holds the job slots and the configured store
(none on this head); a request over `max_concurrent_jobs` is
`LimitExceededException` at once. Describe, List and Delete read the
catalog index first and fall back to the legacy reader; List merges
`list_account_backup_rows` with the reader's list under the existing
paging, filtering and cursor rules, and `TableName` accepts a table ARN
as CreateBackup does. CreateBackup runs the request order of the design
(ACTIVE probe; the region and the name fit a backup ARN, each refusal
naming its cause: `server.region` outside `[a-z0-9-]`, then a name over
255 bytes or made only of dots, checked before the ARN is formed so the
limit is named whatever the length; vector refusal; permit last;
deprecation warning; the legacy create); restore checks the target name
through `describe_table` after the ARN (a table of the name
`TableAlreadyExistsException`, a CREATING restore target
`TableInUseException` naming its source backup), then the backup, then
takes the permit last and hands it to the backend; the backend's fenced
name check under the registration stays. Delete over a row runs the
in-use check before the other-store check, as a restore does, and builds
the description before the DELETING write; a row whose recorded source
table this build cannot read is left out of ListBackups (logged) rather
than failing the listing. Both spellings of a backup ARN (as written and
re-spelled in this region) parse into `BackupArn` where the store-prefix
rules allow; a legacy backup of a name they refuse (dot-only, over 255
bytes) skips the catalog index and describes, restores and deletes
through the legacy reader as on main.

Reconciler (crates/engine/src/backup/reconciler.rs). A cycle at startup
and every `reconcile_interval_secs`, spawned by `serve` beside the
backend workers and drained with them: checks the maintenance claim,
fails each dead legacy restore (`heartbeat_at` older than
`stale_job_after_secs`) and removes its CREATING target (a target the
removal finds no longer CREATING is left to its owner and reported as
such), removes the target of a FAILED record whose removal did not land
once the record is that old, with no second FAILED write (the
FAILED-target duty of design 4(e), on every backend; on Cassandra, where
the removal is a claim to DELETING and then a drop, a target left
DELETING with its registration is listed on the same rule and its
removal resumed from the drop), logs a dead manifest restore once per
record and leaves it to a later release, deletes each abandoned
in-catalog CREATING backup through the reader, and records the worker
metrics (`WorkerLastSuccess`, `WorkerCycleLatency`, `WorkerErrorCount`
under `BackupReconciler`). It replaces the per-backend sweeps:
PostgreSQL's control-plane pass, SQLite's startup sweeps, MongoDB's
startup and worker sweep, Cassandra's 15 s poller.

PostgreSQL. Migration 6 (`0006_backup_catalog_index.sql`, breaking,
convergent, one transaction): `backup_index` (`backup_arn COLLATE "C"`,
so the listings' `(created_at, backup_arn)` order is byte order whatever
the database's collation), the twelve job columns on
`table_restores` with the backfill of existing registrations (DONE with
`completed_at = creation_date_time`, or RUNNING with the epoch heartbeat
and generation 0 where the table is CREATING), `backup_maintenance`,
`settings.catalog_id`. The claim is an advisory lock on a dedicated
connection (`ClaimSession`) that the backup job's heartbeats and status
writes travel through (the legacy restore heartbeats through it and
writes DONE and FAILED on pooled connections, each conditioned on
RUNNING and the generation); the reconciler's dead rule is the heartbeat
alone, the lock being the claim's exclusion, as on the other backends.
The legacy restore registers RUNNING with its generation, marks DONE in
the transaction that flips the table ACTIVE, and FAILED before it
removes a failed target. The restore slot semaphores (per account and
per server) and `sweep_abandoned_restores` are removed.
`RESTORE_LOCK_SEED` and `abort_restore` are `pub` and `#[doc(hidden)]`
so the tests bind them.

SQLite. Migration 6 as a code body (`m0006_backup_catalog_index.rs`)
with the same objects; the twelve columns are nullable (ADD COLUMN on a
populated table) and the application writes every one. The claim is an
in-process slot (`JobSlots`, a drop guard), since one process serves a
file; a restart ages every RUNNING record to the epoch so the first
cycle removes an interrupted restore, and a CREATING `backups` row whose
slot no task holds is reported dead whatever `stale_after` says
(`backups` has no heartbeat column); the reader's `delete` removes such
a row whole, with no DELETED tombstone. The restore heartbeats every
`JOB_HEARTBEAT_INTERVAL` (15 s) under the engine write lock, so a beat
queues behind every writer holding or waiting for that lock.
Conditional writes never have an unknown outcome here.
`backup_server_slot_count` stays beside `max_concurrent_jobs`; its
slots are shared by backups and table snapshots (#395), the formula is
stated once on `SqliteConfig::pool_size`, and the legacy create tries a
slot without waiting, as a snapshot open does, and answers the one
`LimitExceededException` text (`limit_exceeded_message`), naming the
`[storage.sqlite] pool_size` snapshot-slot bound, for both.

MongoDB. `backup_index` and `backup_maintenance` collections and a
`restore_job` subdocument on a restore target's `tables` document,
created by migration 6, a code body on the numbered runner
(`m0006_backup_catalog_index.rs`), which also backfills the
registrations and writes the catalog id; every request it sends is
safe to send again (an existing collection accepted by its error code,
the backfill limited to registrations without a job record, the id an
upsert with `$setOnInsert`). The claim is the row, born owned by an
upsert with `heartbeat_at: "$$NOW"`; the lock for DeleteBackup and a
restore's registration is a session transaction that increments
`lock_seq` on the backup's document, which replaces the
`restore_in_progress`/`delete_in_progress` claim markers and the sweep
that cleared them. Both in-use checks read at the transaction read
concern (majority-committed data) while DescribeTable reads at `local`,
so a RestoreTableFromBackup or DeleteBackup issued within the
majority-commit lag after DescribeTable first shows the target ACTIVE
can still answer BackupInUseException and succeeds when retried; the
differences document's concurrent-restores row states it with the
measured lag. The legacy DeleteBackup writes DELETING before the
drop and DELETED after; describe and list hide DELETING. A restore
request that outlives the 60 s registration bound answers
InternalServerError while its task goes on; the target it leaves is
stated in the differences document's restore row. The restore slot
semaphores and the startup sweep are removed. The factory registers
and reads the catalog's status (`register_and_check`) before it builds
the engine or starts #473's startup key repair, so a server that
refuses a catalog without migration 6 has written nothing; a live test
pins that refusal on scratch databases holding a document the repair
would rewrite. The protocol model (`backup_protocol_model.rs`) is
rewritten for the new steps.

Cassandra. Migration 6, a code body on the numbered runner
(`m0006_backup_catalog_index.rs`): it executes
`migrations/catalog/0006_backup_catalog_index.cql` as the runner
executes a file body (the lease checked before each statement, schema
agreement after each DDL statement), then backfills the registrations
with lightweight transactions conditioned on the job status being
unset and inserts the catalog id `IF NOT EXISTS`, so a run that fails
part way leaves migration 6 unrecorded and the next `migrate --yes`
runs the body again. The file adds the index columns (`store_kind`,
`store_root`, `store_id`, `backup_id`, `owner_generation`,
`heartbeat_at`, `completed_at`, `failure_reason`,
`source_table_details_json`) on the reused `backups_by_arn`, the
status-keyed `backup_index_by_status`, the `restore_*` job columns on
`tables`, and `backup_maintenance`, every statement an `ALTER TABLE ...
ADD IF NOT EXISTS` or `CREATE TABLE IF NOT EXISTS`, which the runner
qualifies with the keyspace; the backfill and the id name the keyspace
in their own statements, and no code or test issues `USE`. Code bodies
get the lease check and the file execution on `CassandraStores`
(`check`, `progress`, `run_file`, which moves there from the runner).
An index row is a `backups_by_arn` row with `store_kind` set, and the
two readers skip each other's rows. Claims are lightweight transactions
`IF owner_generation = ?`; the legacy create heartbeats its CREATING
row and polls its claim once per 64-item bucket, and
`abandoned_creating_backups` fences a stale one to DELETING under a
fresh `owner_generation` before reporting it, scanning DELETING rows
first so a row it fenced is reported once. A job stops after three
heartbeat errors in a row, as after a heartbeat that does not apply, and
touches neither its row nor its data again. An orphan
`backup_index_by_status` entry left by a crash between the row's write
and the batch is skipped by every reader; the sweep that removes it is
`backup sync`'s. `poll_abandoned_restores` and its worker are removed.
The crate gains a `test-hooks` feature (as MongoDB's), compiled only
for the `direct_integration` binary, which CI runs with `--features
test-hooks`.

Migration. One change, one number: migration 6, `backup_catalog_index`,
on every backend, recorded in each store's ledger (migration 5 is
#335's `drop_continuous_backups`). It is breaking (stop pre-upgrade
servers first; the previous release refuses a catalog carrying it, exit
4) and convergent; a restore in flight at the stop is a RUNNING record
whose owner is dead, failed and removed on the first reconciler pass.

Rebased onto main after #335 and #681 merged: the migration moved from
id 5 to 6, Cassandra's V006 file and MongoDB's versioned step became
migration 6 code bodies on #681's runner, the `0.0.5` catalog version
and this PR's own serve-time version checks went (the runner's status
check refuses the catalog on every backend), and the PITR code #335
removed is gone from the legacy reader.

Moved onto main at e8ca7df: #416's hand-written default for
`ServerComponentsOptions` gains `max_concurrent_jobs: 0`, and the
differences document's backup rows keep #509's text beside this PR's.

Docs. The upgrade manual (migration 6 and its rollback, with the
per-backend objects and the Cassandra and MongoDB rerun sentence, and
the SQLite startup sentence: the server ages every RUNNING restore
record and the first reconciler cycle fails an in-catalog restore and
removes its target), the admin guide (`[backup]`, the job bound in
place of the per-account and half-pool restore slots, the pooler note),
the deployment guide (the job bound in place of the restore slots; the
connection formulas gain `+ max_concurrent_jobs`), the high-level
design and getting-started connection formulas, the limits document's
concurrent-restores row, the differences document's restore rows (the
MongoDB registration bound among them), the admin guide's `region` row,
the differences document's `BackupArn` and backup creation rows, the
limits document's `BackupName`/`TableName` row and the upgrade manual's
`server.region` paragraph (CreateBackup refused under a region outside
`[a-z0-9-]`; a name no backup ARN can carry refused with the texts),
`CONTRIBUTING.md`'s migration example renumbered to 7, the sample
config, `docs/schema/postgres.txt` (regenerated for the collation),
`sqlite.txt`, `cassandra.txt` and `mongodb.txt` regenerated, and the
backup/restore design's PR 6 section and section 4 (the migration
number as implemented, the interface block with
`list_dead_restore_jobs`, `fail_restore_job` and
`abandoned_creating_backups`, revision log items 26 and 27).

Tests. Counts on this tree; added and removed are test functions against
main. The MongoDB and Cassandra counts are listed on this tree
(`--list`); their suites ran on the top of the stack. extenddb-storage
215 (13 added; 216 beside a crate that enables
its `test-hooks` feature, as `cargo test --workspace` does),
extenddb-config 14 (6 added), extenddb-engine 255 (25 added: the target
name before the backup and the permit, catalog describe, list, delete
and restore paths, permit order, the ARN rules, reconciler cycle,
duties, metrics and worker). extenddb-storage-postgres: lib 75,
tests/backup_restore 37 (27 kept, 1 rewritten, 2 removed, 9 added),
every other binary green (fresh 6, released 12, golden 7, crash 9,
guards 7, runs 7, owners 3, serving 5, tag_blobs 1, vector_control_plane
42, doc_version_literals 3, table_snapshot 81 with its restore record
written as a DONE legacy job, ...). extenddb-storage-sqlite 248 (17
added and 4 removed, among them a target removed only under its full key
and migration 6's golden test; 1 ignored benchmark).
extenddb-storage-mongodb: lib 165 plus 46 live tests against a replica
set (among them a catalog without migration 6 refused with no repair
started), migrations_fresh 8, guards 7, holders 10, locks 10, released 5
(migration 6's golden test added), interpreter_selftests 18,
pushdown_parity 20; 29 added and 27 removed with the slots, the sweep
and the claim markers. extenddb-storage-cassandra: lib 128 (4 added, 1
removed), migrations 21, migrations_stopped 8 (migration 6's golden test
added), direct_integration 205 (13 added, 1 rewritten, 1 removed; three
of the added tests build with `--features test-hooks`), ttl_integration
26 passed and main's one failure below. The released-catalog fixture of
tests/test_resource_tag_migration.py strips migration 6's objects before
it clears the ledger, since the check after migration 1 refuses a
`table_restores` carrying the job columns. Through the binary on every
backend: a catalog at main's migration 5 is refused by `serve` (exit 3),
migrated by `migrate --yes`, then served, and main's binary refuses it
(exit 4); a fresh install's schema equals `docs/schema/<backend>.txt`.
The MongoDB workflow's jobs pass the CI way (pytest 1636 passed, 50
skipped; comprehensive 502; `tests/rust` 514; the catalog checks), and
so do the Cassandra workflow's jobs (`tests/rust` 514, comprehensive
502, the catalog-lock, older-binary, two-node and authorizer checks),
apart from the pytest job's parallel leg on the review host, whose 30 s
request timeouts on account creation main's binary meets the same way
there and whose files pass run serially. Wire fidelity
(tests/test_backup_restore_fidelity.py, 36 passed and 1 skipped) ran
inside those pytest jobs on MongoDB and Cassandra; the PostgreSQL and
SQLite runs (64 and 63 passed) are from the tree before its rebases.
`cargo fmt`, `clippy -D warnings` on 1.97, `check --all-targets` on
1.88.

Not changed here. The backup store (`[backup.store]`, the store identity
file) and the store-backed CreateBackup writer, worker and `backup`
commands; the manifest restore job; every `backup_index` row is "of
another store" for Delete and Restore until a store is configured. The
Cassandra `test_ttl_enable_rejects_asynchronous_gsi` failure is main's:
the test and `metadata_engine.rs` are as on main (the test matches
"asynchronously propagated GSIs" and the refusal says "global secondary
index"). `heartbeat_interval_secs` reaches no job yet: every in-catalog
job heartbeats at `JOB_HEARTBEAT_INTERVAL`, which the stale floor is
computed from.

Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-blocker Must ship for its milestone's release v1 Needed for the 1.0 release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backup/restore: SnapshotEngine on every backend

4 participants