Repository navigation
Conversation
robinnsc
force-pushed
the
feat/backup-catalog-index
branch
4 times, most recently
from
October 31, 2026 04:36
1c931de to
80db2eb
Compare
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
force-pushed
the
feat/backup-catalog-index
branch
from
October 31, 2026 07:16
80db2eb to
a4ece88
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backup/restore series (design: #396): #384 restore fidelity (merged) · #393 store · #394 format · #395 SnapshotEngine · #666 catalog migration framework · this PR: the catalog index · next: configuration and the store identity file (PR 7), the CreateBackup job and worker (PR 8), the restore job (PR 9a).
Closes #669 (part of #624).
Stacked on #666 (base branch
feat/catalog-migrations, head d4187ea, which carriesmainthrough #395, #638, #412, #511 and #471): this PR adds migration id 5 to theMigrationIdregistry #666 introduces, and its PostgreSQL and SQLite bodies run through #666's runner (Kind::Breaking, convergent, one transaction). The diff shows this PR's single commit on top of #666. Where this head meets whatmainbrought in:StorageEnginerequiresBackupCatalog + LegacyBackupReader + SnapshotEngine(#395); SQLite'sbackup_server_slot_countslots are shared by backups and #395's table snapshots, with the formula stated once onSqliteConfig::pool_sizeand one refusal text; the PostgreSQL control-plane pass keeps #395'sdrop_unless_heldand loses onlysweep_abandoned_restores; Cassandra's V006 runs through #638's keyspace-qualifying runner, and the backfill step and the version write name the keyspace in their own statements. When #666 merges, rebasing ontomainchanges nothing but the base.What
The catalog index of design section 4 on every backend, the engine's Describe, List and Delete over it, the reconciler, and the legacy in-catalog paths kept beside it as a frozen reader. No request or response shape changes; the one visible API change is that
ListBackupsaccepts a table ARN inTableName, asCreateBackupalready does.Traits (
crates/storage/src/backup_catalog.rs).BackupEngineis replaced byBackupCatalogandLegacyBackupReader, both supertraits ofStorageEngine, implemented by all four backends.BackupCatalogaddresses every backup row byBackupKey(account id, backup id, ARN) and every restore target byRestoreTargetKey(account id, table name, table id); no method takes a bare id, andread_backup_rowreturns a row only when its account, table name and ARN equal the request's. Its methods:catalog_id,claim_backup(the row is inserted CREATING under a freshowner_generation; a second claim isAlreadyExists),list_backup_rows(by store identity and status) andlist_account_backup_rows(by account, table and time bounds), both ordered(created_at, backup_arn),read_backup_row,start_backup_delete(the in-use check and the AVAILABLE-to-DELETING write in one transaction under the row's lock;Deleting,InUse(table)orStatus),set_backup_deleted,remove_backup_row(DELETED or FAILED only),backup_in_use,remove_restore_target(conditional on a CREATING restore target with this table id, so it and the ACTIVE flip exclude each other),list_dead_restore_jobs(stale_after)(RUNNING records whose heartbeat is older than the window on the database's clock, and FAILED records whose table is still a CREATING restore target, or on Cassandra a DELETING one with its registration, once the record is that old),fail_restore_job(RUNNING to FAILED, conditioned),check_maintenance_claim.JobClaimis a job's handle on its row:generation,heartbeat,set_status(from, to, fields)conditioned on the generation andfrom(Matched,NotMatched, orUnknownwhen the connection failed and the write may have landed),read.LegacyBackupReaderis #384's create, describe, list, delete, restore-source and restore, frozen apart from the restore's registration, which writes the job fields;createandrestoretake the job'sJobPermitand hold it for the job's life, andabandoned_creating_backups(stale_after)reports a CREATING row whose create died.BackupRowderivesBackupDescriptionandBackupSummaryfromsource_table_details_json(the manifest'ssource_table; the summary reads only the table id and ARN, so a later build's required member does not break a listing);BackupStatusreports DELETING as DELETED and hides DELETED and FAILED. The in-use text ("Backup is being used to restore table : "), the not-found signal and theLimitExceededExceptiontext (limit_exceeded_message(JobBound), naming the bound met:[backup] max_concurrent_jobs, the[storage.sqlite] pool_sizesnapshot-slot bound, or the PostgreSQL server's connection slots) are one implementation every backend and the engine bind, as isJOB_HEARTBEAT_INTERVAL(15 s), the period every backend's jobs heartbeat at.StorageError::AlreadyExistsis new.Config (
crates/config/src/backup.rs).[backup]withdata_file_target_bytes(64 MiB, minimum 32 MiB),heartbeat_interval_secs(15),stale_job_after_secs(300, at least three times the period the jobs heartbeat at,max(heartbeat_interval_secs, 15): 45 at the least),failed_row_retention_secs(7 days),reconcile_interval_secs(60),max_concurrent_jobs(4, at most 1024),spool_path;deny_unknown_fields, every key optional, validated at load.initwrites the commented block;extenddb.sample.tomland the admin guide carry it.ServerComponentsOptions.max_concurrent_jobslets the PostgreSQL factory raisepool_sizeandcatalog_pool_sizeto2 * max_concurrent_jobs + 10with a warning naming both numbers.Engine (
crates/engine/src/backup.rs,backup/runtime.rs,backup/reconciler.rs).BackupRuntimeholds the job slots (JobPermit::try_acquire; a request over the bound isLimitExceededExceptionat once, no wait) and the configured store, which isNoneon this head, so everybackup_indexrow is a row of another store: Delete and Restore of one return the ValidationException that names no root. Describe, List and Delete read the catalog index first and fall back to the reader; List mergeslist_account_backup_rows(the time range as bounds,is_listedstatuses) with the reader's list under the existing paging, filtering and cursor exceptions. CreateBackup runs the design's step 1 order: the handler's ACTIVE probe,check_backup_arn_fits(the region first, refusing with a message namingserver.regionwhen it is outside[a-z0-9-]; then the store-prefix rules on the name, over 255 bytes or dot-only, before the ARN is formed so the limit is named whatever the length), the vector refusal, the permit, the deprecation warning, thenLegacyBackupReader::create(permit); restore checks the target name throughdescribe_tableafter the ARN (a table of the nameTableAlreadyExistsException, a CREATING restore targetTableInUseExceptionnaming its source backup), then the backup, then takes the permit last and passes it in; the backend's fenced name check under the registration stays. Both spellings of a backup ARN (as written and re-spelled in this region) parse intoBackupArnwhere 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. 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;DeleteStart::Status(AVAILABLE)is an internal error. The reconciler runs a cycle at startup and everyreconcile_interval_secs, spawned byservebeside the backend workers and drained with them: the maintenance-claim check, each dead legacy restore failed (fail_restore_job) and its target removed (remove_restore_target; a target the removal finds no longer CREATING is left to its owner and reported as such), the target of a FAILED record whose removal did not land removed once the record is stale, 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), a dead manifest restore logged once per record and left to PR 9a, each abandoned in-catalog CREATING backup deleted through the reader; it records the worker metrics (WorkerLastSuccess,WorkerCycleLatency,WorkerErrorCountunderBackupReconciler). It replaces the four backends' sweeps and their slot semaphores.PostgreSQL. Migration 5 (
migrations/0005_backup_catalog_index.sql):backup_index(primary key(account_id, backup_id),backup_idunique,backup_arn COLLATE "C"so the listings'(created_at, backup_arn)order is byte order whatever the database's collation, account/table and store/status indexes), twelve columns ontable_restores(status,kind,store_kind,store_root,store_id,owner_generation,heartbeat_at,spool_host,file_index,items_applied,failure_reason,completed_at) with the backfill of the registrations that exist (kind = legacy; DONE withcompleted_at = creation_date_time, or RUNNING with the epoch heartbeat and generation 0 where the table is CREATING, thenNOT NULLon the four claim columns),backup_maintenance, andsettings.catalog_idwritten once. The claim ispg_try_advisory_lock(hashtextextended('backup:' || backup_id, RESTORE_LOCK_SEED))on a dedicated, non-pooled 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), every writeWHERE owner_generation = $gen; the reconciler's dead rule is the heartbeat alone, the lock being the claim's exclusion, as on the other backends. The legacy restore registersRUNNING, kind = legacy, owner_generation, heartbeat_at = now()in the transaction that creates the target, marks DONE in the transaction that flips it ACTIVE, and on failure marks FAILED (conditioned on RUNNING and the generation) beforeabort_restore, removing the target only when that matched. The per-account and per-server restore slots,sweep_abandoned_restoresand the control-plane pass's call are removed;RESTORE_LOCK_SEEDandabort_restorearepuband#[doc(hidden)]so the tests bind them.docs/schema/postgres.txtis regenerated from a freshinit.SQLite. Migration 5 as the code body
m0005_backup_catalog_index.rs(fingerprint0005_backup_catalog_index/sqlite/1, golden fixture blessed): the same tables and indexes, column-checkedADD COLUMNs (nullable;ADD COLUMNon a populated table cannot addNOT NULLwithout a default, and the application writes every one), the backfill, the catalog id. One process serves a file (the serve lock), so the claim is an in-process slot (JobSlots, a set behind a mutex with a drop guard) and a restart is proof of death:expire_restore_job_heartbeatsages every RUNNING record to the epoch at startup so the reconciler's first cycle removes an interrupted restore, andabandoned_creating_backupsreports a CREATINGbackupsrow whose slot no task holds, ignoringstale_after(there is no heartbeat column onbackups). The reader'sdeleteof such a row removes it whole, items and definition, with no DELETED tombstone (a backup that never finished was never visible as one). The restore's registration carries the job fields in the same write transaction as the CREATING target; the copy marks DONE with the ACTIVE flip, FAILED before removal, and heartbeats everyJOB_HEARTBEAT_INTERVAL(15 s) under the engine write lock.WriteOutcome::Unknownis never produced. The two startup sweeps are removed;backup_server_slot_countstays until PR 8 folds it intomax_concurrent_jobs: its slots are shared by backups and table snapshots (#395), and the legacy create tries a slot without waiting, as a snapshot open does, answering the oneLimitExceededExceptiontext (limit_exceeded_message), naming the[storage.sqlite] pool_sizesnapshot-slot bound, for both.docs/schema/sqlite.txtregenerated.MongoDB. The
backup_indexcollection (top-levelaccount_id/backup_idunder a unique compound index,backup_idunique, account/table/created and store/status indexes),backup_maintenance, and arestore_jobsubdocument (status,kind,owner_generation,heartbeat_at,failure_reason,completed_at) on a restore target'stablesdocument beside the provenance fields DescribeTable reads; the versioned step002_backup_catalog_index, run byrun_catalog_migrationsafter the initial step and recorded inschema_history, creates them, backfills the registrations (DONE or RUNNING with the epoch heartbeat as on PostgreSQL,restore_operation_idandrestore_heartbeat_atremoved), writescatalog_idand setscatalog_versionto0.0.5; the expected version is one constant,CATALOG_VERSION, wherebootstrapper.rsheld two copies of0.0.2andoperations.rsa third. The claim is the row: anupdate_oneupsert filtered onowner_generation: {$exists: false}withheartbeat_at: "$$NOW", so a second claim trips the unique index. The lock for DeleteBackup and a restore's registration is a session transaction that$incs alock_seqfield on the backup's document while it is AVAILABLE, so the two conflict and order wholly; this replaces therestore_in_progress/delete_in_progressclaim markers and the sweep that cleared stale ones. Both in-use checks read at the transaction read concern (majority-committed data) while DescribeTable reads atlocal, 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 writesDELETINGbefore the drop andDELETEDafter, anddescribe/listhide DELETING as they hide DELETED; a retry of DeleteBackup on a DELETING backup finishes the drop. The restore's hand-over to the GSI backfill worker and the record's DONE are oneupdate_oneconditioned on the generation. A restore request that outlives the 60 s registration bound answers InternalServerError while its task goes on; the target it leaves (CREATING, holding the backup in use and the name until the copy ends, found through DescribeTable) is stated in the differences document's restore row.WriteOutcome::Unknownis the driver'sIoandConnectionPoolCleared.serverefuses a catalog whosecatalog_versionis notCATALOG_VERSIONwith a message namingextenddb migrate --yes(require_served_catalog). The restore slots andsweep_stale_backup_claimsare removed.backup_protocol_model.rsis rewritten for the new steps (registration, copy, hand-over, decide, drop, mark, stop, resume, the reconciler's removal).Cassandra.
V006__backup_catalog_index.cql, after which the runner writes0.0.5(once the backfill step and the file'sschema_historyrow are in, so a run that fails in the step leaves 0.0.4 andmigrate --yesruns the file again):backups_by_arngainsbackup_id,store_kind,store_root,store_id,owner_generation,heartbeat_at,completed_at,failure_reason,source_table_details_json;backup_index_by_status(PRIMARY KEY ((store_root, backup_status), backup_arn), withaccount_id,store_kind,store_idso a listing can re-read the row and scope to the store);tablesgains the twelverestore_*job columns;backup_maintenance(every statement anALTER TABLE ... ADD IF NOT EXISTSor aCREATE TABLE IF NOT EXISTS, which #638's runner qualifies with the keyspace; the file does not writecatalog_version); a Rust backfill step after the file (backfill_backup_catalog_index, LWTIF restore_status = nullso a second run changes nothing) writes the job fields and the catalog id. An index row is abackups_by_arnrow withstore_kindset; the legacy reader treats such a row as not found and the catalog ignores rows without it. Every claim is a lightweight transactionIF owner_generation = ?;backups_by_arnis written first and the list and status entries follow, every reader re-reading the authoritative row. The legacy create holds a claim on its CREATING row, heartbeats it and polls the claim once per 64-item bucket, so a copy that lost its row writes at most one bucket after the loss;abandoned_creating_backupsfences a stale CREATING row to DELETING under a freshowner_generationbefore reporting it, and reports stale DELETING rows (scanned first, so a row it just fenced is reported once). A job stops after three heartbeat errors in a row (a beat that hangs is not counted; the per-bucket poll of the claim bounds the copy then), as after a heartbeat that does not apply, and touches neither its row nor its data again; one error does not stop it.WriteOutcome::Unknownis the driver'sIoandTimeoutand the server'sWriteTimeoutandWriteFailure. An orphanbackup_index_by_statusentry left by a crash between the row's LWT and the batch is skipped by every reader and costs a wasted read until the row is removed under its current status (remove_backup_rowdeletes the entry under the status it read); the sweep that removes orphans isbackup sync's (PR 10).serverefuses a catalog whose version is notCATALOG_VERSION, as on MongoDB (CassandraEngine::require_served_catalog);require_migrated_catalogstays for an engine built without the factory.poll_abandoned_restores,sweep_abandoned_restoresand the worker are removed. The crate gains atest-hooksfeature (as MongoDB's), compiled only for thedirect_integrationbinary, which CI runs with--features test-hooks: a per-account heartbeat period and heartbeat-error injection, and a pause between a delete's in-use read and its claim.Versions. One change, one number on every backend: migration id 5 on PostgreSQL and SQLite;
0.0.5written by Cassandra's migration runner after V006 and by MongoDB's step002_backup_catalog_index. The design's PR 6 section and section 4 now state these numbers (revision log items 26 and 27).Docs. The upgrade manual (migration 5 and its rollback: the pre-upgrade dump restored, the previous binary refusing the migrated catalog with exit 4, and the
[backup]section removed from the configuration, which the previous binary refuses as an unknown field; a "Cassandra and MongoDB: catalog 0.0.5" section with the commands, the stop-first rule, the first reconciler pass, the serve-time refusal and the Cassandra resume sentence (a run failing in the step leaves 0.0.4 andmigrate --yesruns V006 again); the 0.0.4 section's 15 s sweep line now attributed to the 0.1.x server), the admin guide (the[backup]table; 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's version check, the differences document (the ListBackups paging row, the DeleteBackup-during-restore row, the interrupted-restore row, the two-servers row, theRestoreTableFromBackuprow's MongoDB registration bound, the concurrent-restores row's read-concern window and its restore-during-delete answer (BackupNotFoundExceptionon every backend), the restore-order row (the name checked before the job permit), the ListBackups cursor row under an invalid region, theBackupArnand backup creation rows: CreateBackup refused under aserver.regionoutside[a-z0-9-]and for a name no backup ARN can carry), the admin guide'sregionrow, the limits document'sBackupName/TableNamerow, the upgrade manual'sserver.regionparagraph,CONTRIBUTING.md's migration example renumbered to 6, the sample config,docs/schema/postgres.txtregenerated for the collation, the backup/restore design's version literals and revision log items 26 and 27.Why
#396 section 4 and the PR 6 section. The engine-owned backup path (PR 8) needs a row per backup it can list without the store, a claim that excludes a second owner and lets a successor detect a dead one, and one place every backend's rules meet; the legacy in-catalog paths of #384 stay reachable so backups taken before the series restore, but as a reader whose only unfrozen part is the restore's registration through the new job record. Replacing the four backends' sweeps and slot semaphores with one reconciler and one
max_concurrent_jobsbound is what makes the next PRs backend-agnostic. The migration and version step land first, in their own PR, because every later PR writes the rows this one creates.Decisions taken while integrating the per-backend work, each stated in the module doc of the backend it concerns:
JobPermittravels as a trait-method argument (keepsRestoreRequestClone); the MongoDB and Cassandra version is0.0.5so one number names one change; MongoDB and Cassandra get the serve-time version guard the SQL backends have through their ledgers; SQLite'sabandoned_creating_backupsignoresstale_afterand its dead-create delete leaves no tombstone; MongoDB's lock replaces the claim markers and DELETING precedes the drop; theWriteOutcome::Unknownclasses differ per driver; the 15 s heartbeat is one constant,extenddb_storage::backup_catalog::JOB_HEARTBEAT_INTERVAL, that every backend's heartbeat loop reads and the config floor is computed from, until PR 8 wiresheartbeat_interval_secs; a Cassandra job stops after three heartbeat errors in a row (a beat that hangs is not counted; the per-bucket poll of the claim bounds the copy then), as after a heartbeat that does not apply, and touches neither its row nor its data again, one error not stopping it (design PR 6 section); the in-catalog create on Cassandra polls its claim once per 64-item bucket; Cassandra's orphan status entries wait for PR 10's sweep; the[backup]doc says why the stale floor is a floor on SQLite.Testing done
extenddb-storage: 196 (14 new; 197 beside a crate that enables itstest-hooksfeature, ascargo test --workspacedoes: theLimitExceededExceptiontexts naming their bound,BackupStatus,BackupKey::from_arn,TimeBounds, the description and summary derived from the manifest fixturecrates/core/testdata/extenddb-manifest/v1.json, a summary needing only the source table id and ARN,pool_size_for_jobs, the serve-time version rule).extenddb-config: 14 (6 new: defaults, the stale floor as three times the period the jobs heartbeat at, the data-file floor, themax_concurrent_jobsupper bound, unknown keys refused).extenddb-engine: 208 (25 new: the target name checked before the backup and the permit, the region refusal namingserver.region, a removal that finds the target moved left to its owner, Describe, List and Delete over the catalog with the legacy fallback, Delete and Restore of a row of another store, Delete of a configured store's row, the in-use check before the other-store check, an unreadable source table left out of the listing and stopping a delete before the write, the permit taken last for restore and create, the ARN-fits rule, a name the store prefix refuses resolved through the legacy reader,TableNameas an ARN, the time range reaching the catalog as bounds, the reconciler's cycle, FAILED-target duty, once-per-record manifest log, worker metrics and worker with theFakeimplementing both traits).extenddb-storage-postgres(PostgreSQL 16 at127.0.0.1:55432,EXTENDDB_TEST_PG_REQUIRED=1): lib 69;tests/backup_restore37 (28 kept with renames,abandoned_restore_is_removed_only_when_its_heartbeat_is_stalerewritten over the reconciler's methods, 2 slot tests removed, 9 new: a copy whose record the reconciler failed stopping at its next heartbeat; a claim excludes a second owner, fences its writes and holds the advisory lock while it lives;start_backup_delete's status, in-use and tombstone outcomes;start_backup_deletewaiting for the row lock and then seeing the registration; listings scoped and ordered; migration 5's backfill plus the first reconciler pass; a legacy restore registers RUNNING and finishes DONE; a FAILED record whose target removal did not land listed once stale and removed; a target removed only while CREATING and only under its full key);doc_version_literals3,migrations_fresh5,migrations_released12,migrations_golden5,migrations_crash9,migrations_guards6,migrations_runs5,migrations_owners3,migrations_tag_blobs1,vector_control_plane42,gsi_online_build12,gsi_stale_writer1,key_collation3,migrate_data_db_name2,put_create_race9,stream_records_page_order1,stream_shard_ownership6,stream_shard_retention1,twi_conflict8.extenddb-storage-sqlite: 225 (3 rewritten from the startup sweeps to the reconciler's methods andexpire_restore_job_heartbeats; 11 new: the PostgreSQL catalog-index module ported, a dead CREATING backup reported and removed throughdelete, a restart aging an interrupted restore, a legacy restore's record RUNNING mid-copy then DONE, a failed restore marking FAILED before removal, a create refused at once when no snapshot slot is free, a FAILED record whose target removal did not land listed once stale, slots and generations; 1 ignored benchmark).extenddb-storage-mongodb(replica set atmongodb://127.0.0.1:27117/?directConnection=true,EXTENDDB_TEST_MONGODB_REQUIRED=1): lib 141 (withEXTENDDB_TEST_MONGODB_STANDALONE_URInaming a standalonemongod, as CI's workflow does; without it feat(storage): SnapshotEngine, a consistent table snapshot on every backend [backup/restore 4/4] #395'sa_standalone_server_is_unsupportedis the one failure, 140 passed);-- --ignored live_28 (22 backup, 6 stream): a heartbeat lost while$outruns leaving no orphan collection, the PostgreSQL catalog-index module ported, the migration step's backfill and the reconciler's first pass, a legacy restore's record, a delete and three restores ordered both ways over six rounds (accounted by the catalog), a FAILED record whose target removal did not land listed once stale, DELETING left by a failed or dropped delete and finished by a retry, a restore of another instance not dead while it heartbeats, a slow-clock process not dead, and the serve-time refusal of a0.0.2catalog;interpreter_selftests18,pushdown_parity20. The 7 protocol-model tests run by default (1.5 billion interleavings memoised, under a second).extenddb-storage-cassandra(Cassandra 4.1 at127.0.0.1:9042,EXTENDDB_TEST_CASSANDRA=required, one binary at a time): lib 78 (4 new: the unknown-outcome classes from a synthetic error body, generations, listing order, the consecutive heartbeat-error rule);direct_integration192 with--features test-hooks(12 new: a FAILED record whose target the removal's claim left DELETING listed once stale and its removal resumed from the drop, claim,start_backup_delete, scoped listings, V006's backfill and the first reconciler pass on an isolated catalog, a legacy restore's record, an abandoned in-catalog create reported once stale and finished (counting the report and showing the dead generation fenced out), a FAILED record whose target removal did not land listed once stale and removed, a V006 run failing in its step leaving 0.0.4 and resuming, the create's heartbeat observed mid-copy, three heartbeat errors stopping a create within a bucket and two not, a restore registering between a delete's in-use read and its claim refusing the delete on both delete paths;test_table_operations_work_on_a_catalog_without_v006now also assertsserve's refusal of the 0.0.4 catalog and its acceptance after V006);ttl_integration26 passed, 1 ignored, 1 pre-existing failure (below).tests/test_resource_tag_migration.py's released-catalog fixture strips migration 5's objects (the twelvetable_restorescolumns and their index,backup_index,backup_maintenance,catalog_id) before it clears the ledger, since the check after migration 1 refuses atable_restorescarrying the job columns; both cells pass on the SQLite build.tests/test_backup_restore_fidelity.py(37) andtests/test_table_operations.py(27) against aserveof this tree (extenddb initon scratch databases,provision-test-credentials,control_plane_delay_seconds 0.05): 64 passed on PostgreSQL withEXTENDDB_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 (withindex_propagation_delay_ms 0) 62 passed and 2 skipped (the vector test and the DELETING test); a server killed mid-CreateBackup and one killed mid-restore were each cleaned by the next server's first reconciler cycle after the stale window. The PostgreSQL and SQLite servers drained their workers at shutdown; the scratch databases and keyspaces were dropped.Not changed here
[backup.store], the store identity file (extenddb-store.json),backup purge(PR 7).BackupRuntime.configured_storeisNone, so on this head Delete and Restore of abackup_indexrow return the other-store ValidationException, andclaim_backup,set_backup_deletedandremove_backup_rowhave no production caller yet; each backend's tests drive them.heartbeat_interval_secsreaches no job yet: every in-catalog job heartbeats atJOB_HEARTBEAT_INTERVAL(15 s), which the stale floor is computed from.kind = manifestrecord is logged and left;remove_restore_targetdeletesrestore_progressrows only where that table exists.check_maintenance_claimreports anybackup_maintenancerow. Cassandra's orphanbackup_index_by_statusentries wait forbackup sync.describe_continuous_backupsandupdate_continuous_backupsstay onLegacyBackupReaderuntil PR 1 replaces them.ttl_integration::test_ttl_enable_rejects_asynchronous_gsifailure predates this PR: without this commit (checked on a clean checkout of bffa088) it fails the same assertion (the test expects "asynchronously propagated GSIs" in the refusal;metadata_engine::async_gsi_ttl_refusalsays "asynchronously propagated global secondary index"). Neither file is touched here.0.0.5for its own migration; whichever of PR 1 and this PR lands second takes the next numbers.backup_indexrow is deferred to PR 9a, which adds the first restore of such a row; this PR observes the row lock with a two-transaction test ofstart_backup_deleteon PostgreSQL and a paused-delete test on Cassandra.[backup]section removed from the configuration, which the previous binary refuses as an unknown field; no store exists on this head, so the dump loses nothing.Checklist
cargo test --workspace), the pre-existing Cassandra TTL failure above exceptedcargo fmt --check)cargo clippy --workspace --all-targets -- -D warnings)Storagetrait, 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, sections 3 and 4 and the PR 6 section; ADR-0023 (catalog migrations, the Cassandra and MongoDB bootstrapper mechanisms kept).
Storagetrait:BackupEngineis replaced byBackupCatalogandLegacyBackupReaderon theStorageEnginesupertrait bound;StorageErrorgainsAlreadyExists.Breaking changes
extenddb migrate --yes; a server with pending migrations refuses to start. A restore in flight at the stop is failed and its target removed on the new server's first reconciler pass.0.0.2to0.0.5and Cassandra's from0.0.4to0.0.5, each by one bootstrapper step run byextenddb migrate --yeswith pre-upgrade servers stopped; on both backendsservenow refuses a catalog at another version (exit status 1, naming the command), where the previous release's Cassandra server started and refused only the backup operations and the MongoDB server started regardless.[backup] max_concurrent_jobs(default 4, at most 1024) per instance for backups and restores together, refused at once withLimitExceededExceptionwhose text names the bound met beside its number ([backup] max_concurrent_jobs, the[storage.sqlite] pool_sizesnapshot-slot bound, or the PostgreSQL server's connection slots when the job's claim connection is refused). SQLite'sbackup_server_slot_countstays until PR 8 folds it intomax_concurrent_jobs, its slots shared by backups and table snapshots (feat(storage): SnapshotEngine, a consistent table snapshot on every backend [backup/restore 4/4] #395); a CreateBackup that finds no slot free is refused at once with the same text where it waited up to 30 s, so snapshots holding every slot refuse every CreateBackup with no backup running. On PostgreSQLpool_sizeandcatalog_pool_sizeare raised to at least2 * max_concurrent_jobs + 10(18 at the defaults), logged.[backup]section, all keys optional with defaults; a config file with an unknown key under it is refused at load, andstale_job_after_secsunder three times the heartbeat period (45) ormax_concurrent_jobsover 1024 is refused at load.CreateBackuprefuses a table whose name no backup ARN can carry: over 255 bytes (reachable with[limits] max_table_name_lengthraised) or made only of dots, withValidationException"TableName cannot be backed up: a backup ARN cannot be formed for it: invalid backup ARN: ...", where the previous release backed such a table up.server.regionoutside[a-z0-9-]CreateBackupis refused with aValidationExceptionnamingserver.region(no backup ARN can be formed), where the previous release created a backup thatDescribeBackup,DeleteBackupandRestoreTableFromBackupthen refused as not valid; backups taken before the upgrade under such a region answer as before. The startup warning says so.BackupCatalogandLegacyBackupReaderin place ofBackupEngine, and handleStorageError::AlreadyExistsin an exhaustive match.ListBackupsaccepts a table ARN inTableName(it was a ValidationException).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.