Repository navigation
Conversation
robinnsc
force-pushed
the
feat/backup-sync
branch
2 times, most recently
from
November 5, 2026 02:18
81dfb04 to
b58d879
Compare
robinnsc
force-pushed
the
feat/restore-job
branch
from
November 5, 2026 03:40
6bdd117 to
2979565
Compare
robinnsc
force-pushed
the
feat/backup-sync
branch
from
November 5, 2026 05:21
b58d879 to
434f942
Compare
robinnsc
force-pushed
the
feat/restore-job
branch
5 times, most recently
from
November 5, 2026 08:48
ad78bbd to
94cd324
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 = 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 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>
The `[backup.store]` section (the backup store crate's shape nested under `[backup]`, `deny_unknown_fields` on both), with the per-backend default for an absent section (a filesystem store at `backups/` beside a file-backed SQLite database, created 0700, whose validation or identity-check failure is a warning that leaves the store unconfigured while the catalog has no rows for it, and an unhealthy store or a startup error once it has; nothing on PostgreSQL, MongoDB, Cassandra and an in-memory database), `kind = "disabled"`, the spool path rule (required with an S3 store except beside a database file; refused inside or containing a filesystem root, or when it is or contains the database directory; a filesystem root that is or contains the database directory is refused the same way; an existing spool must be owner-only, parents included), relative store and spool paths made absolute at load, and the spool's `.lock`. Startup opens the configured store, validates it and checks its identity: `extenddb-store.json` at the root, created with the new `BackupStore::put_if_absent` when the root has no file and the catalog no rows for it (S3: `PutObject`/`CompleteMultipartUpload` with `If-None-Match: *`, a 412 is present, a 409 retried; filesystem: the temporary created O_EXCL and `link`ed to the key), re-read and compared with the catalog's id and with the rows' ids (`read_store_ids`), then the newest AVAILABLE row's manifest probed. A configuration error that needs no request stops startup naming the path or bucket; a filesystem store's checks run before the listener (under a 5 s bound that leaves the store `checking` when a mount stops answering after the root is opened; every later check runs under the same bound, 30 s for a bucket, the cycle selects it against shutdown, a filesystem root's abandoned call parks one thread until the mount answers and no check starts while it is, and the process exits within 5 s of SIGTERM with it parked), an S3 store's after it in the reconciler's first cycle (the component is `checking` until then); a transport, validation, identity or probe failure, a filesystem root's I/O failure at the probe write, or a check that does not finish within its bound leaves the server running with the store unhealthy: logged once, `/health` reports a `backup_store` component (status `degraded`, HTTP 200 kept, `reason` the problem's category alone), the restore and delete of a store-backed backup refuse with a ValidationException naming the kind and the category and not the root (or that the first check is pending), the reconciler acts on no row of the store, and the check runs again each cycle until it passes; the in-catalog create, which touches no store, is not refused. A check that cannot read the catalog changes nothing. DeleteBackup of a configured store's row now deletes the manifest, then the prefix, then marks the row DELETED; the reconciler finishes DELETING rows the same way. `BackupCatalog` gains `read_store_ids`, `maintenance_claim`, `remove_dead_creating_backup_row` and `list_backup_rows_older_than` (the rows of a store with a status whose heartbeat, or completion, is older than an age, compared in the backend's query on the database's clock), a `MaintenanceClaim` (heartbeat, release) held on `backup_maintenance` with each backend's claim mechanics (PostgreSQL advisory lock and row, the take trying the lock again over about 10 ms so a job's one-statement shared lock does not refuse it; SQLite row under the write lock, taken over whatever its heartbeat by a command under the serve lock; MongoDB conditional upsert; Cassandra LWT, its claim-row reads at LOCAL_SERIAL) and the stale rule; `check_maintenance_claim` reports a claim held while its heartbeat is within `stale_job_after_secs` (PostgreSQL: while the holder's session holds the advisory lock, tested with a transaction-scoped shared lock, then by the heartbeat; on SQLite a server's startup under the serve lock removes the record outright); the holder heartbeats it on a timer from the take to the release (`HeldClaim`, at `JOB_HEARTBEAT_INTERVAL`); `remove_dead_creating_backup_row` removes a CREATING row whose owner is dead. On this head CreateBackup and RestoreTableFromBackup (the in-catalog paths) refuse with LimitExceededException while the claim is held, after every other check and before the permit, the message naming a backup maintenance command rather than one command. `ServerComponentsOptions::beside_server` opens a catalog for a read beside a server, without the SQLite serve lock and the startup recovery; on PostgreSQL the factory starts the GSI queue workers only for a server, and on MongoDB it starts the repair of documents stored without their key (#473) only for a server. `ServerComponentsOptions::command` names the command that opens the catalog: its serving registration carries the name (PostgreSQL's session `application_name`; MongoDB's `extenddb_servers` document, in a `command` field; Cassandra's `extenddb_servers` row, after the release and ` as ` in `release`, the table having no column for it), so a migration waiting for the registrations names the command, and a refusal while a migration runs does not advise starting a server. The reconciler's cycle takes the caller's word that it holds the maintenance claim (`ReconcilerState::for_maintenance_holder`) in place of reading it. `extenddb backup list [--failed]` (a read beside a server, on an unhealthy store too, that creates no identity file and reads it under the store's check bound) and `extenddb backup purge --confirm`: the latter takes the maintenance claim and heartbeats it for its whole run, on SQLite resolves the jobs the stopped server left (the reconciler's duties with a zero window, run as the claim's holder so the cycle takes the held branch without reading the claim back through that window: a RUNNING restore and the CREATING target of a FAILED one, an in-catalog CREATING backup, a DELETING row; a target that is no longer the restore target its record names is printed as left; a RUNNING `manifest` record, which no release before PR 9a writes, is left for its resumer and waited on), waits up to twice the stale window for CREATING or FAILED rows with a live owner (a heartbeat within the window on the catalog's clock) and RUNNING restores, deletes each row's prefix and the row outright (a CREATING row with a dead owner included, the row before its prefix), leaves `extenddb-store.json`, `exports/` and prefixes with no row, and audit-logs each deletion; a row a DeleteBackup moves to DELETING or DELETED, or removes, between the command's read and its turn is finished and counted as deleted; on SQLite the owner window is zero (under the serve lock every owner is dead), so a CREATING or FAILED row with a young heartbeat is deleted, not waited on, and a server starting on the file clears a maintenance claim a killed purge left, as a purge run after it takes the claim over; SIGINT or SIGTERM releases the claim at once during the wait and before the next row during the deletion, and the wait's failures (jobs still running, the claim lost, the catalog unreadable, a signal) are each reported as themselves. The dry run is a read like `list` and exits 0, with no store configured as with one; `--config` is accepted after the subcommand; both commands say why there is no store when there is none. On PostgreSQL, MongoDB and Cassandra both commands hold a serving registration named after the command for their run and release it when they exit, so `migrate --yes` does not wait for it to expire, and a catalog the backend refuses ends them with the message and exit code `serve` gives (3 for a catalog behind the binary, with the `migrate --yes` advice; 6 while a migration runs); on SQLite `purge --confirm` beside a running server is refused on the serve lock with exit 6, as a second `serve` is. `extenddb destroy` says it leaves the store and that a re-created catalog needs the old root's identity file removed. Docs: the admin guide (`[backup.store]`, `/health` components and the reason list with `catalog unreadable`, `backup purge`, the shared-root rule), the limits doc's backup store row, the upgrade manual (configure a store before the release that stops the in-catalog create; `disabled` as the opt-out; the SQLite default root rule; the re-init sentence), the deployment guide's multi-instance notes (the hard-link requirement, a root that refuses `link(2)` reported as failed validation, the empty-catalog rule), the init template and sample config, the store crate's README and module docs, the S3 root label built from the endpoint the client uses, in one spelling; the identity file read up to 64 KiB; the console banner naming the store, a filesystem root in the canonical spelling the log line and the rows use (the configured one beside it where they differ); the admin guide's purge paragraph on the commands' registration on the three backends, the SQLite serve lock's exit 6 and the refusal's text; the MongoDB design's startup paragraph; design revision item 28; and the licence notices (the server binaries now link the store crate's AWS SDK tree). Rebased onto PR 6 after main merged #335 and #681: MongoDB's and Cassandra's factories now register through the numbered runner, so the backup commands register there too, under their names, and MongoDB's key repair starts in `build_components`, after `register_and_check`, for a server only; the reconciler runs in #666's restartable worker set, and the PostgreSQL refusal cell forgets migration 6, PR 6's id. Moved with PR 6 onto main at e8ca7df: #416's hand-written default for `ServerComponentsOptions` sets `beside_server` and `command` as the derived one did, and SQLite's factory opens its engine with #416's `options.max_item_size_bytes`, with the `beside_server` return after it. Co-authored-by: Lee <leeroyhannigan@yahoo.ie> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
With a configured `[backup.store]` on PostgreSQL, SQLite and MongoDB, CreateBackup is the store-backed job of the design's 5.1: the request checks the table and the ARN, requires a healthy store, takes a job permit and spawns a detached task that opens the table snapshot, mints the backup id from the snapshot instant, builds the manifest's `source_table` from the snapshot's description and the table's TTL, inserts the CREATING row under the job's claim (`claim_backup`), checks the maintenance claim once the row exists (FAILED `maintenance` and LimitExceededException while one is held) and answers CREATING through a one-shot channel; the task then runs the job: items validated with the new `extenddb_core::validation::validate_restorable_item` (one walk, every `N` parsed once on the accepting path, the key attributes checked with the format's own rule and against the instance's key size limits, every attribute name against its limit, nesting depth and the instance's `max_item_size_bytes`; an item whose data file line would exceed the 32 MiB cap under a raised item limit is refused the same way, its line measured through a counting writer that holds no line, and an item over the default item limit is validated and measured on the blocking pool; every text an error carries is bounded on its own, a key value at 12,288 escaped bytes so a legal key appears whole whatever its bytes, and written as a JSON string's content with the Unicode line separators escaped too, so neither a control character nor a line separator in a tenant's item can add a line to the server's log), encoded by `DataFileEncoder` on a blocking task into 1 MiB chunks over a bounded channel that is the put's body, one data file closed at the first item that takes it to or past `data_file_target_bytes`, `KeyRangeTracker` on a key-ordered table, then the three documents, a claim re-check, the manifest, and the AVAILABLE write with the manifest's size, count and `source_table` (`StatusFields.source_table_details_json`, new on every backend). Every status write is conditioned on the generation and on CREATING; each unknown outcome is followed by a re-read, the write is sent once more at most, and after the second write a re-read showing the row in the target status counts as matched and anything else as matching no row; a write that matches no row ends the job with no store access; the second write is for a backend whose claim connection survives the unknown outcome (MongoDB, Cassandra): on PostgreSQL it fails with the session and the row is the reconciler's; an upload failure stops the feeder at once, so the FAILED write does not wait for the snapshot stream. A refused item stops the encoding but not the validation: the rest of the snapshot is read and checked, each refused item logged with its key, attribute path and reason beside the ARN (up to 1,000, then a count), and the backup FAILED with the count and the first item, naming failed after a refusal. The heartbeat is a timer through the claim (`max(heartbeat_interval_secs, 15)` s); a beat that is lost, errors or does not return within a period raises the abort, and no put starts unless a beat confirmed the claim within two periods, the claim's insert standing for the first beat; on PostgreSQL a stopped process keeps its row while its session lives and, resumed after more than two periods, stops as a lost claim with no catalog write. On any failure (a put error, a refused item, `SnapshotExpired`, shutdown) the FAILED write comes first and `delete_prefix` only after it matched; `BackupRuntime::drain_jobs` (30 s) cancels and awaits every job's failure path before the registration supervisor's return and the stop of the server's workers, a job still running at that bound keeps the serving registration as a worker that could not be stopped does, and `extenddb stop` waits 76 s, the sum of the server's shutdown bounds (connections 5 s, jobs 30 s, the supervisor, the workers' stop and the registration's release 36 s, runtime 5 s), where it gave up at 10 s; a write into the chunk sink after the stop is `BrokenPipe`, which the encoder does not retry, so a cancelled job ends at once. Without a store, and on Cassandra always, CreateBackup keeps the in-catalog create, with `refuse_backup_of_vector_table` in front of it alone: the store-backed path backs vector tables up. The reconciler's full duty list, one cycle: the maintenance check, the dead restore jobs, the abandoned in-catalog backups, the store's check, and while the store is healthy: the DELETING rows finished, dead CREATING rows taken over through the new `BackupCatalog::claim_dead_backup(key, stale_after)` (also under a maintenance claim) and resolved to AVAILABLE when a complete, verified manifest with every data file at its recorded size is under the prefix, else FAILED (`owner dead ...`), never continuing the upload; then, without a maintenance claim, the prefixes of FAILED rows whose heartbeat is stale deleted (the same cycle's for a row it just failed), DELETED prefixes swept, FAILED and DELETED tombstones past `failed_row_retention_secs` removed once their prefix is empty, and Cassandra's `active_restores` entries swept (`sweep_restore_registrations`). The ages are the backend's own predicates (PR 7's `list_backup_rows_older_than(status, store, clock, age)` on the heartbeat or the completion column); the engine holds no clock. `backup purge`'s own cycle runs as the claim's holder (PR 7's `ReconcilerState::for_maintenance_holder`), so it skips these deleting duties whatever its zero window. A duty's store call that fails records `BackupStoreErrors` under the call's name (`get`, `list`, `delete_prefix`), though the cycle completes. Per backend: PostgreSQL takes the row's advisory lock then a conditional update under `now() - stale`, sets TCP keepalives on the claim connection, and names `[storage.postgres] pool_size snapshot-slot` in its snapshot refusal; SQLite takes the row's in-process slot, so a row whose slot no task holds (a stopped process, a job task that panicked) is dead at once, and raises `pool_size` to `2 * (max_concurrent_jobs + 1)` (at most 34) so the slot refuses no job the permit admitted; MongoDB's takeover is one `find_one_and_update` on `$$NOW`; Cassandra's an LWT carrying the generation, the status and the heartbeat it read. The snapshot streams take a byte budget: PostgreSQL's cursor is replaced by keyset pages in the same transaction, three layers (`LIMIT n` after the key, the running byte sum, the cut at `SNAPSHOT_FETCH_BYTES` = 64 MiB) with the key columns under qualified aliases and an outer `ORDER BY`, `n` 16 for the first page and then the budget divided by the largest row of the last pages holding at least a row bound's worth of rows, never under 4 (the server serializes every row the inner `LIMIT` reads, so a page costs it `n` rows of text; sized from the average, the page after a mixed page read 810 large rows for the 3 it returned; sized from the previous page alone, the bound oscillated on a table whose large rows are scattered among small ones, 5,555 rows read for 1,000 kept, where the memory reads 1,901; the page that follows a row bound's worth of small rows still reads up to `n` rows of up to the line cap, which is the bound the record states; a walk that stops at the budget on the server was measured and not taken, one index probe per row costing a 1M-row backup 95% of its duration), the rows parsed as they arrive, the stream ending when fewer rows than asked came back and none was cut; SQLite pulls rows one at a time and stops after the row that reaches the budget, with the pool's `row_buffer_size` at four so the driver's worker reads at most five rows past it, the four it buffers and the one it holds (its default of 50 read a whole table of 27.7 MB rows ahead of the first page). `snapshot_conformance::run_page_bounds` checks that no page holds more than the budget plus one row and that the cut fired, on both backends and fails at once on a stream that repeats rows; an `EXPLAIN` test pins the index scan on PostgreSQL on the six key shapes a table can have, a second the bitmap scan and `Sort` the planner chooses near the end of a small table, and a third the row bound's memory on a table with 27 MB rows every 33rd key. Metrics: `BackupJobs` and `BackupJobDuration` (the outcome in the operation dimension; a FAILED job's duration covers its FAILED write and `delete_prefix`), `BackupBytesUploaded`, `BackupSnapshotAge`, `BackupStoreErrors` (the job's `put` and `delete_prefix`, the reconciler's `get`, `list` and `delete_prefix`), `QuerySource::BackupJob`. Kill points behind `extenddb-engine/backup-test-hooks` (`EXTENDDB_BACKUP_PAUSE`); `extenddb version` prints `backup-test-hooks enabled` on such a build. Harness and CI: `_create_backup` and `_cleanup` wait for AVAILABLE; the vector wire test reads `EXTENDDB_TEST_BACKUP_STORE` and fails unset; the restore cells skip on a server with a store until PR 9a, as does `test_auth_permissions.py`'s restore-authorization cell, whose backup cleanup waits for AVAILABLE; the fidelity suite checks the server's `backup_store` health component against the variable; `tests/test_backup_job.py` (the job bound, the four #618 write paths, the oversize item, a refused item named by its whole key) and `tests/test_backup_store_config.py` (the create refused on an unhealthy store, the objects under the root) run by name on the PostgreSQL and SQLite jobs; the PostgreSQL job appends a filesystem store after `init`; a SQLite job against an S3 store on moto, whose cache is saved only on main and whose actions are pinned to main's commits, as every other job's; the start steps and `devtools/run-tests`' health check wait for a healthy store; `devtools/run-tests --pytest` refuses without the variable (its `--help`, `CONTRIBUTING.md` and the developer test guide name it); the Rust integration suite waits for AVAILABLE after every create it goes on to describe, delete or restore, deletes its backups and reads the variable the same way, and every Rust integration job sets it (`filesystem` on the SQLite job, whose server has the defaulted root; `none` elsewhere); `tests/test_abac.py`'s backup cell waits for AVAILABLE before its delete and `tests/test_authz_resource_arn.py`'s fixture deletes its backup and table when the module ends. Docs: the differences document (vector, restore-interruption, where backups live, backup creation, consistency, a #618 row), the limits document (the slot and pool raise, the store row, a data-file row, the contents row), the usage guide's backups line (the restore of a store-backed backup is a later release's), the upgrade manual (the asynchronous create, #618, the rollback paragraph), the admin guide's stop paragraph and the architecture guide's shutdown step (the job drain before the supervisor, the workers and the release), `[backup]` and `[backup.store]` (with `MALLOC_ARENA_MAX=1` for hosts that back up large items, which costs the request path 3.5 to 9.2% on SQLite and about 2% on PostgreSQL (the scans, 2.1%)) and the deployment guide's service unit (the line commented out with that condition), the troubleshooting entry for `stop`'s timeout, the sample and init templates, the measurement record as `docs/design/15-backup-restore-bench-pr8.md`, design revision item 29, which makes section 7's memory paragraph the sizing rule the worst-expansion runs measured (the terms at the line cap times the allocator's factor, flat under one arena on both backends and growing with the large rows under glibc's default arenas, stated at the default item limit) and records the item channel kept at four, PostgreSQL's outcome for a stopped and resumed job (5.1 step 4, `heartbeat.rs`), the second status write's scope (4(e), `failure.rs`) and the reconciler's `BackupStoreErrors` (section 9). Rebased onto main through PR 7: main merged #335, #666 and #681 under the stack, so the catalog migration this PR's columns come from is 6, the job drain runs in #666's shutdown path (this PR's worker drain is gone, the reconciler being one of the workers the supervisor stops and starts) and `stop` sums that path's bounds, and the Cassandra Rust job's `none` sits on #681's `cassandra-rust` job. Moved with PRs 6 and 7 onto main at e8ca7df: SQLite's factory opens `new_for_server` with #416's `options.max_item_size_bytes`, and the item validator's module doc, the differences document's #618 row, the upgrade manual and the design's Drawbacks name a second class of item the backup refuses that earlier builds stored: an attribute name over the limit that UpdateItem and a TransactWriteItems Update did not check before #433. Co-authored-by: Lee <leeroyhannigan@yahoo.ie> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
With a configured `[backup.store]`, RestoreTableFromBackup of a store-backed backup is the restore job of the design's 5.3 on PostgreSQL, SQLite, MongoDB and Cassandra: the request checks the `backup_index` row and the ARN, requires a healthy store, takes a job permit, resolves the key schema, and spawns a detached task that registers the CREATING target and its RUNNING restore record under the backend's own owner generation (`claim_restore`, atomically with the backup row's lock the in-use and delete checks take), answers CREATING with a RestoreSummary through a one-shot channel, and then runs the job: the manifest and sidecar verified (each read bounded, 4 KiB and 16 MiB), every data file downloaded and its digests checked before any is loaded (spooled under `spool_path` for an S3 store, the spooled manifest and sidecar written `.partial`, synced and renamed like the files; read twice in place for a filesystem store), each file applied by `bulk_load` with its secondary- and vector-index rows and its progress recorded in the same write, the target counted against the manifest's item total, and the ACTIVE flip. A backup restores into the backend that took it (another backend once a later release's `backup sync --adopt-store` has indexed it); a vector backup restores into PostgreSQL or SQLite and is refused before any table into MongoDB or Cassandra; a backup of a multi-part-key table restores with its item set; the restored table's size is the source table's. Progress is recorded inside `bulk_load`, in the write that applied the items: a `restore_progress` row in the file's data transaction on PostgreSQL (data database, migration 7; a plain INSERT for a file applied from its start, an upsert for one resumed past it), a `tables`-row update per batch on SQLite and Cassandra, a `restore_job.file_index` update per file on MongoDB. The memory bounds are in bytes of text: the load's chunk closes at 32 MiB of data-file line text, the SQLite and Cassandra batch at 32 MiB of serialized item text (10,000 items when smaller), PostgreSQL's at 4 MiB per INSERT. A dead owner's restore is resumed, not discarded: the reconciler of any instance with the configured store takes the record over once its heartbeat is stale (`claim_restore_dead`, on SQLite at once after a restart by the record's slot, on PostgreSQL once the owner's claim session has ended), stamping a fresh heartbeat in the same write so a second taker finds the record live, and resumes from the recorded progress, re-downloading to its own spool when the dead owner's `spool_host` differs; the resumed job's first confirmed beat is the takeover's return, and a fresh job's is the one beat it sends right after `claim_restore` returns and before the reply (on PostgreSQL `now()` is the transaction's start, so a registration stalled on the `backup_index` lock or the DDL would otherwise commit a stale heartbeat; a first beat that finds the claim lost fails the record and answers InternalServerError). Once the server's shutdown has begun the reconciler takes nothing over and writes no FAILED, since a resume spawned on the way out would stop at once and leave the record a second stale window; once its own worker set is stopping (a lost serving registration) it spawns no resume either, a resume being a job that the workers' stop would not stop. A resume with no progress into a non-empty target is FAILED `target_not_empty` and left for `extenddb backup remove-target --account <id> <table>`, which ships here (pulled forward from PR 10), under a maintenance claim as well, where the reconciler had failed such a record `OWNER_DEAD` and removed its target: on MongoDB and Cassandra, which record progress after the write, an ordinary crash before the first progress record (the whole first data file on MongoDB, the first 10,000 items or 32 MiB on Cassandra) ends in that state, and nothing else removes a target DeleteTable refuses while the backup it holds in use refuses DeleteBackup and a second restore. The command takes the maintenance claim, removes a FAILED record's target whatever the reason, a RUNNING record's dead target (dead by the backend's `list_dead_restore_jobs` on the database's clock, never by the command host's) or a legacy record's target with its record, and refuses a live RUNNING restore, a DONE restore and a table no record names; it opens the catalog without creating the default root or an identity file; on PostgreSQL, MongoDB and Cassandra its serving registration is named `extenddb backup remove-target` and released when it exits, and its refusals of a catalog under migration or behind the binary exit 6 and 3, as `serve`'s do. `backup purge` on SQLite runs its own reconciler cycle as the claim's holder (`ReconcilerState::for_maintenance_holder`), so a dead store-backed restore it meets is failed and its target removed, never resumed inside the command. The feeder checks the claim at every chunk boundary; MongoDB and Cassandra read the record before each write batch. A duplicate key is caught by the base table's plain INSERT on PostgreSQL and SQLite, by the unique `_id` on MongoDB (suppressed only on the one unrecorded file a resume re-applies, `BulkLoadFile.reapplying`), and by the final count on Cassandra, whose upserts collapse it; a key-ordered file that leaves its `key_range` fails the file. An `N` sort key past Decimal128 fails a MongoDB restore with the write path's text. MongoDB's `bulk_load` holds the GSI backfill test gate (a `test-hooks` build only) before the first insert of the restore's first file, as the in-catalog copy does before its `$out`, so the integration test that no index completes before the base load runs on the store path. MongoDB's snapshot open, which a store-backed CreateBackup takes, waits up to 2 s for the majority-committed snapshot to show a table a `local` read shows ACTIVE, so a CreateBackup sent as soon as DescribeTable says ACTIVE is not refused as a missing table. A `finish_restore` error is an unknown outcome re-read through the claim (DONE under the job's generation is the restore complete, RUNNING one more write, anything else the DONE write that matched no record); PostgreSQL's post-commit progress delete is logged and swept, never the restore's failure. An unknown outcome of Cassandra's progress lightweight transaction is re-read at LOCAL_SERIAL (applied, one retry, or the lost claim; a second unknown outcome fails the restore), and a Cassandra registration whose record a reconciler took over during its DDL removes nothing and answers the lost claim. PostgreSQL's `remove_restore_target` drops a vector index's `_ddb_vec_` relation with the data table. The lost-claim stop line names the heartbeat timer's reason. A resumed Cassandra restore re-applies at most one batch through the put path, which since #424 rewrites an unchanged index row by its INSERT alone (#742) and stamps every index row with its base row's version; the re-apply test checks each GSI and LSI row's write time against its base row's version after the load and after each re-apply. Two concurrent registrations of one backup leave one target on Cassandra as on MongoDB (the registration LWT's `active_restores = null` is the fence). The download retry bound (15 min) counts from the job's first attempt. `.verified` markers hold the file's digest; a stale spool entry is removed before use; a job whose record became another's leaves its entry to the sweep, which lists the spool before the records so a restore registered between the two reads keeps its entry. A restore stopped by shutdown leaves its row RUNNING with its spool and progress and writes no status; a lost claim or a DONE write that matched no record ends the job with no table access. Metrics: `RestoreJobs`, `RestoreJobDuration`, `RestoreBytesDownloaded`, `BackupStoreErrors{get}` on every `get` the store failed. The feature chain `extenddb-app/backup-test-hooks` now pulls `extenddb-storage/test-hooks` so the backends' restore pause points compile into a hooks build. The cross-backend matrix is a committed `devtools/backup-restore-matrix` and a nightly `.github/workflows/matrix-nightly.yml`: each cell copies a source's backup prefix under a root the target owns and seeds the target's `backup_index` row as `backup sync --adopt-store` will (PR 10), so a cell proves the restore and not the adoption. Its twelve plain, four duplicate-key and eight vector cells passed on the final binaries; the nightly installs `postgresql-client` for the host `psql` the script's PostgreSQL seed and teardown call and checks it and the two drivers before the cells, and has had no run yet (`workflow_dispatch` needs the file on the default branch). The restore kill points ran on every backend they apply to, each ACTIVE outcome asserting the base table's and the GSI's counts, the two first-reach points ending in `target_not_empty` and then `remove-target`. Harness and CI: the 24 restore cells of `tests/test_backup_restore_fidelity.py` run on a server with a store, `tests/test_auth_permissions.py`'s restore cell runs, `tests/rust`'s backup suite restores the store's backups, `tests/test_backup_job.py` gains the tamper, SIGTERM, store outage, duplicate-key, key-range and multi-part-key cells on both the PostgreSQL and SQLite deployments and the `remove-target` cell on SQLite; `tests/test_mongodb_backup_restore.py` gains the vector-manifest refusal and the Decimal128 rules, `devtools/run-mongodb-tests` serves a filesystem store (or none, with `--backup-store none`, for the legacy LSI fallback cell) and starts from an empty root, and the PostgreSQL CI job starts moto for the outage cell; the nightly's cache step saves only on main, and its actions are pinned to the commits main pins. The replaced-file engine test runs at a 1 s heartbeat (its hashing pass shares the test's one runtime thread with the timer, and under load outlasted two 50 ms periods), `Progress::from_records` has a cell for a record and a progress row naming one file, the in-memory SQLite cell restores its store-backed backup, the SQLite job suite gains the purge cell above and the store-config suite a `remove-target` cell that leaves no default root, and the PostgreSQL exit-code cell covers `remove-target`. The MongoDB `--ignored live_` cells pass with `--test-threads=1`; in parallel three legacy copy cells can time out behind the backfill worker. Docs: the differences, limits, usage and upgrade documents lose the "restore is a later release's" refusal, the admin guide gains the spool's contents, the restore's measured memory with the shape and input order it was measured on, the two-GSI and the unordered-backup figures beside it (`docs/design/15-backup-restore-bench-pr9.md`; a table with indexes costs SQLite's page cache above the no-index figure, an unordered backup 1.5 to 1.8 times the key-ordered figure, and the final COUNT(*) a page cache bounded by `cache_size`, the same at 1M and 3M rows) and the `remove-target` command, the differences document labels its restore timings as the in-catalog restore's beside the store-backed ones, the test guide and CONTRIBUTING name the job suite and the matrix, and design revision item 30 records PR 9 as implemented. The admin guide's `stale_job_after_secs` row and purge paragraph and the upgrade manual's SQLite startup sentence say a dead store-backed restore is resumed by a server and failed by a purge; the upgrade manual says migration 7 is declared breaking on PostgreSQL and does not apply on SQLite, MongoDB and Cassandra; design 4(e) and the reconciler's module doc say what a lost serving registration does: the reconciler stops with the server's workers, as `registration.rs` says, and the running jobs go on (#755); CONTRIBUTING's worked example becomes migration 8; the heartbeat and SQLite restore-engine module docs say what holds a SQLite beat; and the PR 9a load paragraph gives PostgreSQL's batch as 500 rows or 4 MiB. Rebased onto main through PR 8: main merged #335, #666 and #681 under the stack, so `restore_progress` is migration 7 (5 is main's `drop_continuous_backups`, 6 PR 6's), declared not applicable in #681's MongoDB and Cassandra registries as on SQLite, and main's migration tests that count the ids count it; the reconciler's resumes meet #666's restartable worker set (above) and PR 8's job drain, which a restore job joins as a backup job does; `remove-target` releases its registration on exit and is named on every backend that registers; and Cassandra's `claim_restore` no longer calls the catalog check #681 removed. Moved with PRs 6 to 8 onto main at e8ca7df: #424 fixed #742 on main (an unchanged re-put keeps its GSI and LSI rows, through the put path and the GSI worker), so this PR's change to `sync_indexes` and `gsi_apply_index` and its two index tests are dropped, main's tests covering the case; and main's #463 stream-clock live test sits beside this PR's MongoDB restore tests. Co-authored-by: Lee <leeroyhannigan@yahoo.ie> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc
force-pushed
the
feat/restore-job
branch
from
November 5, 2026 10:03
94cd324 to
9803ab7
Compare
`extenddb backup sync` rebuilds the catalog's rows from the configured
backup store. It reads the configured root's rows first (every status,
every store id the catalog records for the root), then lists the whole
root and handles each `<account>/<table>/<backup_id>/` prefix as the
listing passes it: the store lists in key order, so sync holds one
prefix's objects and the prefix of each manifest it listed, and of the
rows only each one's status, store id and table and the keys of the
configured store's AVAILABLE rows. Every backend reads the rows whole
before sync sees the first, so the read is the larger cost: about 22.5
KiB of peak memory per row of a table with five GSIs (1,144 MiB at
50,000 rows, measured on a SQLite debug build), which the admin guide
and section 7 state. A prefix with a row is skipped before anything
under it is read. A prefix with no row is verified as the reconciler's
dead-owner duty verifies one (`verify_prefix`, split out of
`probe_manifest`: the manifest and sidecar within their bounds, the
digest, the reader's acceptance, every data file at its recorded size
from the listing already held), and its manifest's ARN must name the
prefix's account, table and backup id. An ARN of another region is
adopted and logged; one of another partition is skipped, since every
request refuses such an ARN. Before the insert the row is read under
both spellings of the ARN (this server's region and the manifest's), and
the prefix is skipped when the other spelling has a row; a row under the
manifest's own spelling is left to the insert. The prefix then gets an
AVAILABLE row through the new `BackupCatalog::insert_backup_row`:
unowned, in the configured store's identity, with the manifest's name,
ARN, size, item total and source table and the id's instant as
`created_at`. A prefix with a row of any status, without a manifest or
sidecar, with a sidecar or data file that does not match, whose manifest
names another backup, or under `exports/` or a table's `changelog/`, is
reported with its reason. An insert that answers false (a CreateBackup
that landed after the read, a row of another root, account or table
holding the id, on Cassandra a claim's row whose list entries are
missing) is re-read and reported. The report also names the AVAILABLE
rows whose manifest is gone (the rows that were AVAILABLE when the
catalog was read).
On PostgreSQL, MongoDB and Cassandra every sync takes the maintenance
claim: plain sync without the wait for running jobs, since its
insert-if-absent orders it against a CreateBackup; the claim orders it
against `purge`, which removes a dead CREATING row before it deletes a
prefix that can hold a complete manifest. On SQLite every form runs
under the serve lock after the dead-owner resolutions purge runs. Every
sync stops on a signal before its next prefix and exits 1. It exits 1 on
a refusal (an unhealthy or unchecked store, naming the problem, with no
row changed), on a listing that failed part-way (the rows inserted
before it stay), and when a prefix was skipped for a store read or a
catalog call that failed, since the run is incomplete, and when
`--prune-missing` left a row a later run may remove; it exits 0
otherwise, skipped prefixes included. The serving registration is named
`extenddb backup sync`. A sync killed while it holds the claim keeps
every instance's jobs refused until `stale_job_after_secs` passes, as a
killed purge does, and the admin guide says so.
`--prune-missing` waits for running jobs as purge does and removes each
AVAILABLE row whose manifest is gone outright, through the new
`BackupCatalog::forget_available_backup_row` (the row's lock, the status
check and the in-use rule of `start_backup_delete`, then the delete),
and deletes nothing in the store. A row whose manifest's `head` fails
is kept; that row, a row a restore is reading ("run the command again
once the restore ends") and one whose removal failed exit 1, while a row
whose manifest is back or that another writer moved is reported with
exit 0 (`PruneOutcome::needs_another_run`). The objects a removed row's
prefix still holds stay until an operator deletes the prefix by hand:
purge deletes only the prefixes of rows. No tombstone is left for a
deletion duty, so on host-local roots a prune on the instance that
lacks a backup's bytes leaves them on the other host; the row is gone
for every instance, so the backup is BackupNotFound everywhere until a
sync on the host that holds the bytes adopts it again, the design's
host-local acceptance line as amended. On PostgreSQL, SQLite and
Cassandra `start_backup_delete` and `forget_available_backup_row` share
one body with a `remove` flag, as on MongoDB, so the existing race cells
cover both; Cassandra's forget settles an unknown outcome with a
`LOCAL_SERIAL` read and answers removed when only the entry batch after
the removal fails (logged). `--adopt-store` is the way a durable catalog
takes a root whose `extenddb-store.json` names another catalog: under
the claim, `store_identity::adopt` logs an intent line, rewrites the
file's catalog id and backend with a plain put, keeping the store id and
creation time, reads it back and logs the outcome under
`extenddb::audit` (adopted or race lost), refuses when this catalog's
rows for the root carry another store id, and syncs; the command then
reads the file once more and fails naming the owner if another catalog
adopted the root during the sync. On a root this catalog owns it says so
and syncs. For `backup sync` a defaulted SQLite root whose file names
another catalog is configured and unhealthy (`Caller::SyncCommand`)
rather than the warning that leaves it unconfigured, so `--adopt-store`
can adopt the root a catalog re-created by `destroy` and `init` finds;
the server and purge keep the warning, and the warning and `destroy`
name `backup sync --adopt-store`.
The previous owner of an adopted root acts on it until a later check
that can read its catalog, at the earliest one reconciler interval after
its cycle in progress ends, so every deletion from the store now runs a
fence first: `StoreFence` re-reads `extenddb-store.json` (one `get`
through the store's bounded calls, under its check bound) right before
the `delete_prefix` of `finish_backup_delete` (DeleteBackup and the
reconciler's DELETING duty), the backup job's failure path, the
FAILED-prefix and DELETED-prefix duties and purge, before the reconciler
removes a tombstone, and before a CreateBackup's row and first put.
DeleteBackup and purge also run it before they change the row, so a
DeleteBackup refused for a root another catalog owns leaves the row
AVAILABLE and DescribeBackup never answers it with DELETED; an adoption
between the two reads leaves the row DELETING with nothing deleted and
the request answered DELETED, as for any deletion that fails after the
DELETING write, finished only if this catalog owns the root again. When
the file names another catalog it refuses, records `ForeignCatalog` so
the store is unhealthy at once (a file naming this catalog under another
store id than the store's check settled is refused and recorded as
`IdMismatch`), and the request answers the unhealthy store's
ValidationException; the reconciler's cycle re-tests the health after
each store duty and runs none after the refusal, the dead-owner duty
included. A file it cannot read, or a root with no file, refuses the
call and leaves the health to the next check; DeleteBackup takes that,
before its DELETING write, as a store failure: it writes DELETING,
answers DELETED, and its second read refuses the deletion. A write
already past the read still lands, so the refusal, the command's output
and the admin guide say to stop every server of the catalog the file
names before adopting, and to start a new in-memory server only after
the old one has exited.
`insert_backup_row` on every backend: PostgreSQL `INSERT ... ON CONFLICT
DO NOTHING` with no conflict target, on the pool; SQLite `INSERT ... ON
CONFLICT DO NOTHING` under the write lock and `BEGIN IMMEDIATE`, without
a job slot; MongoDB the claim's pipeline upsert filtered on an absent
`owner_generation`, with `owner_generation: 0` and E11000 meaning a row
exists (`row_fields` takes the status); Cassandra the `backups_by_arn`
lightweight transaction `IF NOT EXISTS` and then the batch of the list
rows and the status entry under the row's status (shared with
`claim_backup`, sent once more on a failure), an unknown outcome or a
`NotMatched` answer settled by a `LOCAL_SERIAL` re-read, which counts a
stored row equal to this one as inserted and writes its entries, so the
next sync completes an insert that stopped between its two writes; no
row after a lost answer is sent once more, and no row after `NotMatched`
answers false (a legacy in-catalog backup holds the ARN; a direct cell
inserts over such a row). Cassandra has
no `backup_id` uniqueness, so its insert-if-absent is per ARN spelling,
and sync's read under both spellings closes the gap. `layout.rs` gains
`EXPORTS_COMPONENT`, `CHANGELOG_COMPONENT`, `classify_store_key` and
`parse_store_prefix`, which accepts only the 14-digit id shape and
validates through `BackupArn::new`.
An in-memory SQLite catalog is new at every start. The identity file
gains `ephemeral_catalog` (`#[serde(default)]`), written true by a
server whose SQLite database names no file; such a server's
`store_identity::establish` adopts a root whose file names another
catalog and carries the marker, never in `recheck`, and a root a durable
catalog owns stays unhealthy. The server then syncs with no claim, on
every start with a store configured: before it serves for a filesystem
store, in the reconciler's cycle after a healthy check for an S3 store.
Each run is bounded at 60 s; one that stops early, reaches the bound or
met a store or catalog error on a prefix stays pending for the next
cycle. Every run lists the root from its first key, so the startup sync
completes only while listing the whole root fits in the bound; past it
the sync stays pending and is retried every cycle, and on a filesystem
store an abandoned run's walk of the root runs to its end on the
blocking pool while the next cycle starts another. Every `extenddb
backup` command refuses an in-memory database before it opens a catalog,
exit 1, saying why; a location that does not parse is reported with its
parse error.
Wire cells, on the SQLite and PostgreSQL deployments (the MongoDB
deployment skips them: they edit rows with SQL): dropped rows rebuilt
and Describe, List and Restore working; every skip reason above;
`--prune-missing` removing the row and deleting no object; an empty
root, a root holding only the identity file and one with another store
id refused with no row changed and nothing written; the missing newest
manifest refused until it is back; on SQLite sync beside a server exit
6; `--adopt-store` from a second catalog, the first catalog's server
then reporting the store owned by another catalog, and on PostgreSQL
that server, still running with its last check healthy, refusing a
DeleteBackup of the adopted backup, leaving its bytes and its row
AVAILABLE; on PostgreSQL a second maintenance command failing at once
while `--prune-missing` waits, a plain sync failing at once while purge
holds the claim, and `--prune-missing` on a host-local root leaving the
other host's bytes, which a sync there adopts again; a SIGTERM during
the prefix loop exiting 1 with the rows inserted so far kept and the
claim free; and the in-memory restart serving the previous start's
backup; an identity file rewritten to name a third catalog while an
adopting sync is in its prefix loop ending the command with exit 1,
naming that catalog; a row-less prefix whose manifest the store cannot
read reported and the run exiting 1 as incomplete, then adopted once it
can be read; on PostgreSQL `--adopt-store` waiting for a running job;
and `backup sync` exiting 6 and 3 on the catalog refusals. App unit
tests cover the decisions behind the last read and the transient-skip
exit; the wire cells pin their call sites.
`devtools/backup-restore-matrix` adopts the copied prefix with `backup
sync` in place of the hand-written `backup_index` row, gains an
`--adopt-store` cell shape, and the nightly runs it from SQLite to
PostgreSQL and back.
Docs: the admin guide's `backup sync` paragraph (what it adopts and
never adopts, the output, the exit codes, the refusals, the claim, the
SQLite serve lock, the partial report of other roots, the case with no
way out but putting the prefix back), the identity paragraph
(`--adopt-store`, the default-root exception and the shared default
root, the marker, the deletion fence and the rule to stop the previous
owner's servers first), the `:memory:` row and the in-memory refusal;
the upgrade manual's rollback paragraph; the differences, usage and
limits rows; the developer test guide's matrix paragraph; and design
revision item 34 with the PR 10 section's note of the split, and 4(c),
4(e), 4(f), 4(g), decision 6 and section 7 edited with it. The code
comments that gave the Cassandra orphan-entry sweep and the MongoDB
orphan-collection sweep to `backup sync` say no sweep removes them yet,
and the MongoDB error line names the collections for an operator to
drop.
Not here: `--move-store`, `--forget-store`, `--forget-backup`,
`list_backup_rows_by_root`, `rewrite_store` and the full report of the
rows of other roots (#820); `migrate-legacy` (#821).
Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc
force-pushed
the
feat/backup-sync
branch
from
November 5, 2026 11:11
434f942 to
358ff8b
Compare
robinnsc
force-pushed
the
feat/restore-job
branch
from
November 5, 2026 19:16
9803ab7 to
0773f03
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 (merged) · PR 6 the catalog index · PR 7 configuration, the store identity file and
backup purge· #700 the CreateBackup job · #740 the restore job, the cross-backend matrix andbackup remove-target· this PR:extenddb backup sync,--prune-missing,--adopt-storeand thesqlite-memorystartup sync · next: #820 the store-maintenance options, #821migrate-legacy.Closes #746 (part of #624).
Stacked on #740 (
feat/restore-job, head9803ab7f), itself on #700 (c461442a), #672 (ba6b82d4) and #671 (6ff37487) onmainat94b5f689. Rebased from #740's earlier head6bdd1177onto that stack after its review rounds and main's eight commits under it: PR 10 now meets #740's bounded store calls, its DeleteBackup that answers DELETED once the DELETING write commits, its CreateBackup that checks the maintenance claim before any row, its signal listener opened before every command's catalog, itspurge_rowsand its MongoDB deployment oftests/test_backup_job.py, and is adapted to each (below). The rebase conflicted in 14 files, each resolved by keeping both sides' intent:crates/app/src/cmd_backup.rs(#740'srunwithsyncadded;synctakes the run's signal handle),crates/engine/src/backup.rs(DeleteBackup's second fence logged and answered DELETED),backup/job.rs,backup/maintenance.rs(the fence threaded throughpurge_rows; the skip of FAILEDmaintenancerows dropped, #700 writes no such row),backup/reconciler.rs,backup/runtime.rs(both sides'ConfiguredStorefields; the fence's read throughBoundedCalls),backup/tests.rs,crates/storage-cassandra/src/test_hooks.rs,crates/storage-postgres/src/backup_catalog.rs,docs/design/15-backup-restore.md(this PR's revision item renumbered 34 after #740's 28 to 33),docs/differences-from-dynamodb.md,docs/manuals/05-admin-guide.md,docs/manuals/06-developer-test-guide.mdandtests/test_backup_job.py(the sync cells skip on the MongoDB deployment). The design's PR 10 is split in three (revision item 34): this PR is the catalog-loss recovery, #820 the options that rewrite or delete rows of other stores, #821 the legacy-backup migration. No migration, no schema change, no new dependency;Cargo.lockis unchanged. The identity file stays format version 1: its one new member is#[serde(default)], so this release and #740's read each other's files.What
Storage trait (
crates/storage). TwoBackupCatalogmethods, with no default body.insert_backup_row(&BackupRow) -> bool, besideclaim_backup: insert the row as given, unowned (owner_generation0), its status as given,completed_atandheartbeat_aton the database's clock;falsewhen a row with its key exists, or with its backup id on the three backends that index it. The trait doc says that Cassandra's check is per ARN spelling and that sync closes the gap, and that on Cassandra a stored row equal to this one counts as inserted.forget_available_backup_row(&BackupKey) -> ForgetRow: remove an AVAILABLE row outright under the row's lock withstart_backup_delete's in-use rule, leaving no tombstone and touching no object;InUse(table)andStatus(status)leave the row.StorageConfig::database_locationkeeps the reason a location names no file.Core (
crates/core).layout.rsgainsEXPORTS_COMPONENT,CHANGELOG_COMPONENT,StoreKeyandclassify_store_key(key), which tells a key outside every backup prefix, a key under a reserved component (exports/first,changelogthird), three components that are not a backup prefix (another id shape, a component no ARN carries) with the reason, and a backup prefix with its account, table and id;parse_store_prefix(key)is itsOptionform. Only the 14-digit id shape is a backup prefix, and the triple is validated throughBackupArn::new.PostgreSQL, SQLite, MongoDB, Cassandra.
insert_backup_row: PostgreSQLINSERT ... ON CONFLICT DO NOTHINGwith no conflict target (the key and the uniquebackup_idboth refuse it), on the pool, with no advisory lock (a job that holds the id's lock and has not inserted its row yet finds this one and is refused). SQLiteINSERT ... ON CONFLICT DO NOTHING(so a NOT NULL or CHECK violation is an error, not afalse) under the write lock andBEGIN IMMEDIATE, without a job slot. MongoDB the claim's pipeline upsert filtered onowner_generation: {$exists: false}, writingowner_generation: 0and$$NOWfor the two stamps, E11000 (the uniquebackup_id) meaning a row exists;row_fieldstakes the status, andclaim_backuppasses CREATING. Cassandra thebackups_by_arnlightweight transactionIF NOT EXISTS, then the LOCAL_QUORUM batch ofbackups_by_account,backups_by_tableand thebackup_index_by_statusentry under the row's status (write_index_entries, the batchclaim_backupwrote inline, now shared); an unknown outcome or aNotMatchedanswer is re-read atLOCAL_SERIALand settled as inserted when the row read is this one (its entries then written, which completes an insert that stopped between its two writes), as existing when it is another; no row after a lost answer is sent once more (then an error), and no row afterNotMatchedanswersfalse(a legacy in-catalog backup holds the ARN); a failed entry batch is sent once more.forget_available_backup_rowshares one body withstart_backup_deleteon every backend, with aremoveflag: PostgreSQL and SQLitestart_backup_delete_tx(key, remove)(the row lock, the status check, the in-use rule, thenUPDATE ... DELETINGorDELETE); MongoDBstart_backup_delete_txwithdelete_one, its exhaustion error naming DeleteBackup or the--prune-missingremoval; Cassandrastart_backup_delete_lwt(key, remove), one loop under one condition (IF backup_status = 'AVAILABLE' AND active_restores = null) throughconditional_write, where a removal's unknown outcome is settled by aLOCAL_SERIALread (no row: removed, then the entries; a row: the loop runs again) and an entry batch that fails after the removal is logged and answeredRemoved. SQLite'sclaim_backupkeepsINSERT OR IGNORE, with a comment saying why it ignores only a key conflict.Engine (
crates/engine/src/backup/). Newsync.rs, written againstBackupCatalogand&dyn BackupStore, which the CLI and the server both call:syncreads the configured root's rows of every status and every store id first, then lists the whole root and handles each prefix as the listing passes it (the store lists in key order; sync holds one prefix's objects and the prefix of each manifest it listed, and keeps of the rows only each one's status, store id and table and the keys of the configured store's AVAILABLE rows), checking it against the rows before it reads anything under it. A listing that fails part-way keeps the rows inserted before it and makes no missing-manifest report; a stop asked throughSyncTarget::stopends the run before the next prefix. A prefix with no row is verified byreconciler::verify_prefix(split out ofprobe_manifest, which keeps its comparison of the whole ARN and maps toStatusFields; the dead-owner tests pass unchanged): the manifest and sidecar within their bounds, the digest, the reader's acceptance, and every data file the manifest lists at its recorded size, from the listing already held. The manifest's ARN must name the listed account, table and backup id; another region is adopted and logged, another partition skipped (no request can address it). Before the insert the row is read under both spellings of the ARN (this server's region and the manifest's) through the engine'sBackupArnSpellings, and a backup the other spelling has a row for is skipped; a row under the manifest's own spelling is left to the insert, which answersfalsefor it or, on Cassandra, completes its own insert that stopped before its entries. The adopted row is the one a CreateBackup leaves: the manifest's name, ARN, size, item total andsource_table, the id's instant ascreated_at, the configured store's identity. An insert that answersfalseis re-read so the report says which row it met. A failed read of one prefix's manifest or sidecar (SkipReason::Store) or a failed catalog call (SkipReason::Catalog) is reported for that prefix and the sync goes on;SyncReport::transient_skipscounts them. The report also lists the AVAILABLE rows whose manifest the listing lacks.prune_missingheads each such row's manifest once more and removes the row outright (forget_available_backup_row), deleting no object; it reportsInUseand a row another writer moved, and checks the claim and the signal before each row. Aheadthat fails keeps the row (PruneOutcome::Failed): the store has not said whether the manifest is back.PruneOutcome::needs_another_runnames the outcomes a later run may change (Failed,InUse), which the command exits 1 for.startup_syncis the in-memory catalog's sync: when the store is healthy and the sync pending, bounded bySTARTUP_SYNC_TIMEOUT(60 s); a run that stops before it listed the whole root, reaches the bound, or skipped a prefix for a store or catalog error stays pending, those skips logged at WARN. Every run lists the root from its first key, so the startup sync completes only while listing the whole root fits in the bound; past it the sync stays pending and is retried every cycle.store_identity.rs: the file'sephemeral_catalogmember;adopt(store, kind, root, catalog, backend, ephemeral, rule)rewrites the file'scatalog_idandbackendwith a plainput, keepingstore_idandcreated_at_ms, reads it back (AdoptRefusal::RaceLostwhen another write landed over it), refuses when this catalog's rows for the root carry another store id, and logs anextenddb::auditintent line before the put and the audit line after the re-read with its outcome;AdoptRefusal::NoFilecarries the rows' store ids, so a root that lost its file under rows isidentity file missingand a fresh oneidentity file unreadablerather thanunreachable;establishof an in-memory catalog adopts a root whose file names another catalog and carries the marker (AdoptRule::EphemeralOwner), andrechecknever adopts.runtime.rs:ConfiguredStore::with_ephemeral_catalogand the pending startup sync (take, retry);BackupRuntime::with_region; andStoreFence(ConfiguredStore::fence), the deletion fence: it shares the store's health and reads the catalog id once, and itscheckre-readsextenddb-store.jsonwith onegetunder the store's check bound, refuses with no read on a store that is not healthy, and when the file names another catalog recordsForeignCatalogon the store and refuses, and when it names this catalog under another store id than the one the store's check settled (a root replaced by another root of the catalog; the store's id is now shared with its fences) recordsIdMismatchand refuses; a file it cannot read, or a root with no file, refuses the call and leaves the health as it was. It runs before DeleteBackup's DELETING write and beforepurge_rowremoves a dead CREATING row or moves an AVAILABLE one, so a refusal there for a root another catalog owns leaves the row as read (DescribeBackup still answers AVAILABLE; a file DeleteBackup cannot read there is a store failure, and the request goes on to the DELETING write), and again right before eachdelete_prefixof a backup's objects:finish_backup_delete(now taking the fence and answeringFinishError; an adoption between the two reads leaves the row DELETING with nothing deleted and DeleteBackup answering DELETED, as for any deletion that fails after the DELETING write, which this catalog's reconciler finishes only if this catalog owns the root again), the job'sfail_row(FailurePath::Fenced), the reconciler's FAILED-prefix and DELETED-prefix duties, andpurge_row; before the reconciler removes a tombstone; and in CreateBackup beforeclaim_backup, so a fenced create writes no row and no object. The restore deletes nothing in the store and has no fence, and neither has--prune-missing's removal of a row, which deletes no object;StoreFence's type doc names every call site and says so.reconciler.rs:StoreCallsandverify_prefixarepub(super), the cycle runs a pending startup sync after a healthy store check, within its bound, and it re-tests the store's health after each store duty, so a fence refusal stops the duties after it, the dead-owner duty included; the SQLite command's prelude logs a line of its own instead of "a maintenance claim is held".maintenance.rs:purge_rowtakes the fence, and its comment says why nothing adopts a prefix between its two steps (every sync takes the claim);restore.rs:OTHER_STOREand the missing-manifest error line say what an operator can do in this release; a missing sidecar's line says to put the prefix back, since--prune-missingremoves only a row whose manifest is gone.Server (
crates/server).backup_store.rs:Caller::SyncCommand, asCommandexcept that a defaulted SQLite root whose file names another catalog, while the catalog has no rows for it, is configured and unhealthy rather than the warning that leaves it unconfigured (PR 7's leniency, which the server and purge keep); the ephemeral flag frombackend == "sqlite"anddatabase_file().is_none(); under the exception the unhealthy line says the root is the shared default one with no rows, configured forbackup sync; the warning for a defaulted root another catalog owns namesbackup sync --adopt-store; a command's unhealthy-store line is its own, without the server's sentence about starting and/health.serve.rs: the region on the runtime, and the startup sync of a filesystem store before the routes serve, within the engine's bound.App (
crates/app).extenddb backup sync [--adopt-store] [--prune-missing]: opened asserveopens the catalog (on SQLite under the serve lock, afterresolve_dead_owners), its serving registration namedextenddb backup sync; prints a header naming the store, the adopted ARNs, each skipped prefix with its reason, the AVAILABLE rows whose manifest is gone (with "run with --prune-missing to remove the rows, which deletes no object" when the option is absent), and a count line; exits 0 once it ran, 1 on a refusal (an unhealthy store, a root another catalog owns without--adopt-store, a held or lost claim), a listing that failed part-way, a stop, a row--prune-missingleft for a later run (a store or catalog call failed, a failedheadincluded, or a restore is reading the backup, with "run the command again once the restore ends"; a row whose manifest is back or that another writer moved is reported with exit 0), or a prefix skipped for a store or catalog error, 6 and 3 on the catalog refusals. The missing-manifest heading says the rows were AVAILABLE when the catalog was read, since a DeleteBackup on another instance can move one during the run. On PostgreSQL, MongoDB and Cassandra every sync takes the maintenance claim and heartbeats it, plain sync without the wait;--prune-missingand--adopt-storealso wait for running jobs aspurgedoes. Every sync installs the signal listener and stops before its next prefix. An adopting sync reads the identity file once more at the end and fails naming the owner if it changed (owner_after_sync); a run with a store or catalog skip exits 1 (complete_run); app unit tests cover both decisions, and the wire cellstest_an_adopter_whose_put_lands_during_the_sync_ends_it_with_exit_1andtest_a_prefix_the_store_cannot_read_leaves_the_sync_incompletepin their call sites. An adoption whose check then fails says the identity file was rewritten, so the root is taken from its previous owner. A stopped or failed run prints "(not examined)" under the missing-manifest header, and a stopped prune's count line says "stopped". Plain sync's refusal of a root another catalog owns,--adopt-store's output and its help say to stop every server of the catalog the file names before adopting (a running one acts on the root until a later check that can read its catalog, its fence excepted), and the refusal that every database file of a directory shares the default root beside it.purgepasses the store's fence topurge_row. Everybackupsubcommand refuses an in-memory SQLite database before it opens a catalog, and reports a location that does not parse with its parse error. The clap help namessync.destroy's output namesbackup sync --adopt-store.Harness and matrix.
tests/test_backup_job.py, on the SQLite and PostgreSQL deployments:test_sync_rebuilds_dropped_rows_and_adopts_only_verified_prefixes,test_prune_missing_and_the_unhealthy_store_refusals,test_a_second_maintenance_command_fails_at_once(PostgreSQL; on SQLite the serve lock refuses a second writing command first;--adopt-storeon a root the catalog owns waits for the same live job, "Waiting for running jobs to finish", and a signal stops it before any change),test_prune_missing_on_a_host_local_root_deletes_no_objectandtest_sync_is_refused_while_purge_holds_the_claim(PostgreSQL),test_adopt_store_makes_this_catalog_the_owner(a second catalog of the build's backend, its PostgreSQL databases dropped after), andtest_the_previous_owner_deletes_nothing_under_an_adopted_root(PostgreSQL: the previous owner's server keeps running with its next check an hour away, its DeleteBackup of the adopted backup is refused, the bytes stay and DescribeBackup there still answers AVAILABLE, its/healththen reportsowned by another catalog, and the adopting catalog's server is healthy and restores the backup), andtest_sigterm_during_the_prefix_loop_stops_sync_and_keeps_its_rows(SIGTERM after the first two prefixes' rows are back, 10,000 skipped copies still to go: exit 1, "stopped", the rows kept, and the next sync runs at once and adopts nothing more),test_a_prefix_the_store_cannot_read_leaves_the_sync_incomplete(a row-less prefix whose manifest is mode 000: the "store could not be read" skip, exit 1 "the run is incomplete", and the next sync adopts it once the mode is restored), andtest_an_adopter_whose_put_lands_during_the_sync_ends_it_with_exit_1(a second catalog's--adopt-storewith 10,000 skipped copies under the root; once "Adopted the root:" is printed and while the sync is in its prefix loop the identity file is rewritten to name a third catalog, and the command exits 1 naming it and the store id).tests/test_backup_store_config.py:test_an_in_memory_server_restarted_onto_its_root_serves_the_previous_starts_backups, andbackup syncin both tuples oftest_a_refused_catalog_ends_the_backup_commands_with_the_serve_exit_codes(exit 6 while a migration holds the serving key, 3 on a catalog behind the binary).devtools/backup-restore-matrixcopies the source's prefix and runsextenddb backup syncon the target (on SQLite with the target's server stopped) in place ofseed_row, checks the adopted row's name and size against the manifest, and gains--adopt-store(no copy: the target's store is the source's root; after the adoption the restarted source must reportowned by another catalog);matrix-nightly.ymlruns it from SQLite to PostgreSQL and back, and its comments andrequirements-matrix.txt's say what the script now does.Docs and texts. The admin guide: a
backup syncparagraph (what it adopts and never adopts, its output and memory (the rows' read, measured), its exit codes, its refusals, the claim and what a killed sync costs, the signal, the SQLite serve lock, the partial report of other roots, the case with no way out but putting the prefix back), the identity paragraph (--adopt-store, the default-root exception and the default root every database file of a directory shares, the marker, the deletion fence (before the row change and before the deletion, what a late refusal leaves, a root with no file, the S3 gap, a stale check, the failed job's orphan prefix), the previous owner's window and the rule to stop every server of the catalog a file names before adopting its root), the--adopt-storeparagraph (the same rule), the:memory:row (the 60 s bound and the listing it must fit, an abandoned run's filesystem walk that runs to its end while the next cycle starts another,/healthsilent until a filesystem startup sync ends and a signal then ending the process at once, the BackupNotFound window until the startup sync's log line and, for a prefix that met a store or catalog error, until a later cycle's line, the backup a server killed inside a DeleteBackup serves again, the partial prefix of a killed CreateBackup no command removes, and a new in-memory server started only after the old one has exited) and the in-memory refusal, the registrations list. The sync paragraph also gives the per-prefix memory term (about 350 bytes per prefix at one object per prefix), says the report lists every prefix the catalog already has a row for, that the objects a pruned row's prefix still holds stay until an operator deletes the prefix by hand (purgedeletes only the prefixes of rows), and that on PostgreSQL the next maintenance command takes a killed command's claim over at once; the identity paragraph gives the fence's store-id rule. The upgrade manual's rollback paragraph (--adopt-storeafter the re-upgrade, the defaulted SQLite root's warning until then, the serve lock or the claim forsync), the differences ("Where backups live"), usage (cross-backend restore, where--adopt-storeof the source's root moves the root to the target's catalog: stop the source's servers first) and limits (backup contents) rows, the developer test guide's matrix paragraph. Design: revision item 34 (the fence included), the PR 10 section's note of the split, the PR 9b paragraph and the doc-row table'smigrate-legacyrow; 4(c) (the previous owner acts until a later check that can read its catalog unless the fence stops it), the PR 10 section's host-local acceptance line (amended to the rule implemented), 4(e), 4(f), 4(g), decision 6, section 7's memory paragraph and the PR 10 contract edited with item 34. The comments that gave the Cassandra orphanbackup_index_by_statussweep and the MongoDB orphan target-collection sweep tobackup syncsay no sweep removes them yet, and the MongoDB error line inremove_restore_target, which an operator reads, now says the collections it names can be dropped by hand. Cassandra's module doc says that a status entry under an earlier status stays until a sweep exists, and names the path that writes one by design (a DeleteBackup betweeninsert_backup_row's transaction and its entry batch); the trait doc, the sync module doc and item 34 qualify "no two rows of one backup id" as under the manifest's account, table and id; sync's sentence for an insert that met a row it cannot re-read names an earlier release's in-catalog backup at the ARN;STARTUP_SYNC_TIMEOUT's doc states the abandoned filesystem walk;destroy's sentence says that on the default SQLite root the re-created catalog's server logs the warning and leaves the store unconfigured.Why
Design 4(c), 4(f), 4(g) and the PR 10 section. The store is the source of truth and the row a cache; until this PR nothing rebuilt the cache, so a lost or re-created catalog lost its view of every store-backed backup, and
sqlite-memorylost it at every restart. The split keeps this PR to the commands that touch no row of another store: plain sync and--prune-missingact only on the configured store's rows,--adopt-storerewrites one file and then syncs, and the startup sync is the same sync.--move-store,--forget-storeand--forget-backuprewrite or delete rows of other stores, a different set of invariants with two more trait methods per backend (#820);migrate-legacyis a separate data path (#821).Choices to weigh
Caller::SyncCommand, the one exception to PR 7's defaulted-root leniency. On SQLite's defaulted root a catalog re-created bydestroyandinitfinds the root's file naming the old catalog and no rows of its own, and PR 7 (item 29) makes that a warning that leaves the store unconfigured, so CreateBackup keeps working on a server that never configured backups. Without the exceptionbackup sync --adopt-storewould answer "no store" on exactly the root it exists for. The exception is scoped to thebackup synccommand and to a root another catalog owns; the server andpurgekeep the warning. The alternative is to require an explicit[backup.store]naming<dir>/backupsfor the adoption: that is a configured root, unhealthyowned by another catalog, which--adopt-storeadopts, so it is a remedy too, at the cost of one configuration line the operator has to know to write. The default root is shared by every database file in its directory, so with either choice--adopt-storethere takes the root from whichever catalog wrote its file; the refusal, the server's warning and the admin guide say so./healthpossibly already healthy); the:memory:row says so and names the log line that ends it. The adoption that precedes it is insideestablishand only for a file carryingephemeral_catalog: true;rechecknever adopts, so two in-memory servers on one root (a non-goal) do not take it from each other every cycle, and a durable catalog's root stays unhealthy for an in-memory server.--prune-missingremoves rows and deletes no object. The manifest probe sees only the newest AVAILABLE row's manifest, so a host-local root that holds the newest backup is healthy while it lacks older ones. A prune that moved those rows to DELETING or DELETED would let the other host's deletion duties delete their bytes. Removing the row outright, as 4(g) does for--forget-store, leaves nothing for a duty to act on; the row is gone for every instance, so the backup is BackupNotFound on every instance until a sync on the host that holds the bytes adopts it again; the design's host-local acceptance line is amended to that rule (no reconciler deletes a prefix because its own root lacks the bytes, and--prune-missingthere removes the row and deletes nothing), andtest_prune_missing_on_a_host_local_root_deletes_no_objectpins it. The cost: a row pruned because its prefix was deleted by hand leaves that prefix's other objects for the operator to delete by hand, sincepurgedeletes only the prefixes of rows and every later sync reports the prefix as not a complete backup. The alternative was to prune only ARNs the operator names; the option would then need an argument, which the design does not give it.--prune-missingboth refuse, and the row cannot be pruned. The way out in this PR is to put the prefix (or its manifest) back; Backup/restore: backup sync --move-store, --forget-store, --forget-backup #820's--forget-backupwill be the other. The admin guide states it;test_prune_missing_and_the_unhealthy_store_refusalspins both refusals and the recovery once the manifest is back. This predates the PR (the probe is PR 7's) and is the same condition the host-local case shows, so the PR does not change it.an_insert_that_meets_a_row_leaves_it_and_says_which), but not againstpurge, which removes a dead CREATING row before it deletes the prefix, and that prefix can hold a complete manifest (the job died after writing it); a sync between the two steps would adopt bytes purge then deletes. With the claim the two never overlap (test_sync_is_refused_while_purge_holds_the_claim), and 4(f) now names plain sync. The cost is that CreateBackup and RestoreTableFromBackup are refused while a sync runs, as during purge (a refused CreateBackup writes no row: the request checks the claim before any). A sync killed while it holds the claim keeps every instance's jobs refused untilstale_job_after_secspasses (300 s by default); the admin guide says so. A prefix purge leaves after a failed deletion is a backup with no row: when it holds a complete manifest a later sync adopts it and the next purge deletes it; an incomplete one stays too, for the operator to delete by hand. On SQLite it runs under the serve lock, after the dead-owner resolutions, as the design's every-writing-command rule requires; that prelude is purge's (ReconcilerState::for_maintenance_holder), and that prelude now logs a line of its own ("run by a maintenance command before its own work").read_backup_rowneeds a full ARN, which sync has only after reading the manifest, and Cassandra keys by ARN); the read under the ARN's two spellings and the insert-if-absent then decide against rows that land later and rows of other roots, on Cassandra too. The listing is consumed as it streams with no store trait change, but the filesystem store builds its whole listing in memory first, so on a filesystem root the peak barely moved (98 MiB at 300,000 objects against 102 before). Sync keeps of each row only what it uses, but every backend'slist_backup_rowscollects its whole result first, so the read of the rows is the larger cost on a catalog with many rows of the root, tombstones included: about 22.5 KiB of peak RSS per row of a table with five GSIs (1,452 bytes ofsource_table_details_json), 610 MiB at 25,000 rows and 1,144 MiB at 50,000 against 47 MiB with none, measured on the SQLite debug build of this tree on a 16-core x86_64 Linux host with glibc's default arenas;backup listpeaks the same, and with a one-object prefix per row sync now peaks at 1,145 MiB where it held the rows beside the listing at 1,181. Streaming the rows would change every backend'slist_backup_rows; the design, the admin guide and section 7 state the cost instead.getand the call after it (on S3 a listing and aDeleteObjectsunder the SDK's retries, seconds when throttled), and the first refusal marks the store unhealthy for later requests (a check that read the file before the adoption and ends after the refusal records it healthy until the next cycle: deletions stay fenced, restores are accepted). DeleteBackup and purge run it before they change the row as well as before the deletion, so a DeleteBackup refused for a root another catalog owns leaves the row AVAILABLE and DescribeBackup never answers that refusal with DELETED; the cost is one smallgetper CreateBackup and two per deletion. A put already past the read still lands, which is why the texts state the precondition (stop every server of the previous catalog first) rather than promise the fence enforces it. A file the fence cannot read refuses the deletion (it is retried, as a faileddelete_prefixis) and changes no health, so a transient error does not take the store down; at DeleteBackup's first read it is a store failure, so the request writes DELETING and answers DELETED and the second read refuses the deletion. A refusal at the second read (an adoption between the two) leaves the previous owner's row DELETING with nothing deleted, and DeleteBackup answers DELETED, as for any deletion that fails after its DELETING write; that catalog's reconciler acts on no row of its now unhealthy store, and finishes the deletion only if the catalog owns the root again. A root with no identity file also refuses the fenced call. A failed job of the previous owner leaves its partial prefix under the adopted root, which nothing of the new owner removes; the admin guide says so. The fence keeps the catalog id it read first: a catalog re-created beside a running process is then refused every deletion and CreateBackup until the process restarts, the safe direction, which the comment states.list_backup_rows_by_root, which Backup/restore: backup sync --move-store, --forget-store, --forget-backup #820 adds with the options that act on them.Testing done
Re-measured on the tree rebased onto #740's
94cd324e, debug builds (the move to9803ab7f, whose two conflicts were text inruntime.rsand the design, was checked with fmt, clippy and the engine, server and app suites: engine 466, server 310, app 49):cargo fmt --all -- --checkandcargo +1.97.0 clippy --locked --workspace --all-targets --features extenddb-storage-cassandra/test-hooks -- -D warningsclean; engine--lib464 passed (with and withoutbackup-test-hooks: #740's 423 plus this PR's 42, less the dropped wait cell), server 310, app 49, core 801; PostgreSQLbackup_restore58; SQLite--lib278 (1 ignored), its-- backup restore_job_tests72,backup_job_panicandrestore_job_resume1 + 1; MongoDB livelive_67 (--test-threads=1); Cassandradirect_integration(test-hooks,--exact, under the shared node's lock) the five insert and three forget cells named below, 8 passed; the wire suites as below (35 passed, 8 skipped on SQLite; 27 passed, 16 skipped on PostgreSQL). The mutant at the rebase's DeleteBackup change (its first fence never refusing) is killed bydelete_backup_on_a_root_another_catalog_adopted_deletes_nothing. Everything else below is carried from the tree before the rebase, as each line says.The counts below were measured on the final tree, and the binaries are debug builds of it, except where a line says the earlier tree (the first commit of this PR, before the changes to sync's claim, prune, partition and spelling rules, the streaming listing and the startup bound) or b58d879 (the tree before the last changes: the prune exit for a row in use, the fence's store-id rule, and the cells and texts listed above; they change no code of the core and storage crates, where the trait's change is a doc comment, so the core, PostgreSQL, SQLite and MongoDB counts below are b58d879's).
cargo fmt --all -- --checkclean;cargo +1.97.0 clippy --locked --workspace --all-targets --features extenddb-storage-cassandra/test-hooks -- -D warningsclean;cargo +1.88.0 check --locked --workspace --all-targetsclean with no warning on b58d879.--lib: 431 passed before the rebase (464 on it), among them the deletion fence at each call site, each on a store whose last check passed and a root another catalog adopted since (DeleteBackup refused before its DELETING write with nodelete_prefix, the row left AVAILABLE, and a second request refused at the gate with no read; an adoption between DeleteBackup's two reads leaving the row DELETING with nothing deleted and DELETED answered; purge refused before it changes the row; the reconciler's DELETING, FAILED-prefix, DELETED-prefix and tombstone duties with the file rewritten right after the cycle's check read it, each acting on nothing; CreateBackup refused before any row or put; the job's failure path writing FAILED and deleting nothing; purge deleting nothing), each leaving the store unhealthyowned by another catalog; the fence's read failing alone (aMemStorefailure of the identity key) at DeleteBackup, CreateBackup, the job's failure path, purge and each reconciler deletion, each refused with nothing deleted and the health left, no row moved but DeleteBackup's, which writes DELETING and answers DELETED; a stalled read refused at the bound and a root with no identity file refused (DeleteBackup answering DELETED with the row DELETING in both); a fence refusal in the DELETING duty stopping the dead-owner duty before it reads the foreign root; the startup sync throughreconciler::cycleon a root listing that never answers, cut at 60 s with the DELETING row finished in the same cycle and the sync pending; the next sync completing its own torn insert (aFakeknob for Cassandra's rule) while a row under the other spelling still skips; a key ending in the manifest's name below a prefix not hiding a missing manifest; the marker cell (a durable--adopt-storeof a marked file writesephemeral_catalog: false, an in-memoryestablishon that file answersForeignCatalogand writes nothing, and an in-memoryrecheckof a lost file writes it back marked); and the sync cells (every adopt and skip reason, another account, table or partition in the manifest, a row of another root under the canonical spelling, a prefix under another table with a row's backup id, a CreateBackup racing the insert, an insert that fails, a stop before a prefix, a listing failing part-way, a failed row read, only AVAILABLE rows reported missing,--prune-missing's outcomes with no object deleted and no tombstone, the adoption and its lost race, the in-memory adoption with the marker true and false, the startup sync throughreconciler::cyclewith a store error keeping it pending and with a recheck finding the root foreign, the abandoned startup sync, the dead-owner probe refusing a manifest of another region), and: four manifests whose sidecar matches and that the reader refuses (asource_tableof another table, a creation time that is not the id's instant, a../data-file key, a size total that is not the files' sum), each skipped as "the manifest is refused"; a failed read of the row under the ARN's spellings (aFakeknob) skipping the prefix as a catalog error with no insert, and the next sync adopting it; aFakemode with rows unique by ARN alone (Cassandra's rule) and two prefixes of one id under two tables, the second skipped on the row check;--prune-missingwith a failedhead(aMemStoreknob) keeping the row asFailed, andneeds_another_runtrue forFailedandInUseonly; the startup sync with an incomplete prefix left done; a cycle with a pending startup sync on a stalled listing returning at the shutdown, well before the bound, with the sync pending; the fence refusal in the DELETING duty also leaving the prefixes of a stale FAILED row and a DELETED row unlisted (aMemStorelist record);adopt(.., AdoptRule::EphemeralOwner)on a durable file refused with nothing written, and an in-memoryestablishon it reading the file once; the fence refusing a file of this catalog under another store id and recordingIdMismatch; server--lib305 before the rebase, 310 on it (backup_storewith theSyncCommandcells, the exception's scope among them); app 49 (the adopting sync's last read, the transient-skip exit); core--lib800 before the rebase, 801 on it, with the layout cells (every reserved component, the id gridis_backup_idaccepts and refuses, traversal, separators, control and non-ASCII characters).backup_restore51 passed (58 on the rebased tree); SQLite'sbackup_job_panicandrestore_job_resume, which drive the engine's job and reconciler, pass; SQLite--lib268 (measured before the deletion fence, as MongoDB's and Cassandra's below are, and 278 on the rebased tree; the fence changes no storage crate, and none of them depends on the engine); MongoDB livelive_63 with--test-threads=1(67 on the rebased tree); Cassandradirect_integration(--features test-hooks, under the shared node's lock,--exact)test_an_insert_over_an_in_catalog_backup_answers_false_and_writes_no_entry(abackups_by_arnrow with no store kind at the ARN:false, no list or status entry) on the final tree, and the insert cells (with the completed torn insert, the unknown-outcome cells through atest-hooksseam onconditional_write, and the failed entry batch) and thestart_backup_deletecell, which on each backend also coversforget_available_backup_row(removed and listed nowhere, the in-use and status refusals, a missing row; on Cassandra the three index entries read directly).layout.rs; the four backends' inserts (the insert that overwrites, the one that ignoresbackup_iduniqueness, a wrong generation or stamp, on Cassandra the list rows and the status entry skipped or misplaced, the settlement ignoring the generation, the store or the status, any row read after a lost answer counted as inserted, the single attempt,NotMatchednot completing a torn insert, the batch not re-sent);forget_available_backup_rowwithout the in-use rule (PostgreSQL, SQLite), marking DELETING (MongoDB), leaving its index entries (Cassandra); the engine's sync, adoption and startup-sync rules (the account, table and partition checks, the spelling read, missing manifests of any status, the startup sync on an unhealthy store or with a transient skip marked done, an insert error, the stop, the prune deleting bytes, the other-table reason, the probe comparing only the backup id, and the earlier rules); theSyncCommandexception removed; the deletion fence removed at each of its six call sites (finish_backup_delete, the reconciler's prefix deletion and its tombstone removal,fail_row, CreateBackup,purge_row), the fence not recordingForeignCatalog, the fence reading the file on a store already unhealthy,adoptkeeping the file's marker, andrecheckrewriting a lost file without the marker; and at the binary level against the wire cells:finish_backup_delete's fence removed (the previous owner's DeleteBackup then succeeds), the row check before the manifest read removed, the startup sync before the listener removed, the in-memory CLI refusal removed,--adopt-storetaking no claim, plain sync taking no claim beside servers, and the prune throughstart_backup_deleteandfinish_backup_delete. Each was caught at the assertion it targets. On the final tree, each against the whole crate suite: the spelling skip for either spelling; no fence before DeleteBackup's DELETING write, or before purge's row change; the dead-owner duty after a refusal; missing-manifest keys of any status; any key ending in the manifest's name; the startup sync's timeout removed; the fence's read error passing, its read unbounded, a root with no file passing; the adopting sync's last read accepting another owner and a transient skip exiting 0 (app cells);stop: Nonein the CLI's sync (the wire SIGTERM cell); PostgreSQL's shared body withoutFOR UPDATE, Cassandra's shared condition asIF EXISTSand without the in-use pre-check, and Cassandra's forget with the entry error returned,Unknownnever or always counted as removed, and no lost-answer exhaustion check. All 21 were killed (a 22nd, the wait on a FAILEDmaintenancerow, targeted code the rebase dropped). On the final tree, 16 more, each a targeted edit, run, and the file restored with its md5 checked: in the engine, against its whole--libsuite, the reader's validation replaced by a plain parse, a failed spelling read treated as no row, a failed pruneheadfalling through to the removal,Incompletecounted as transient, the row check's insert after an adoption removed,InUsenot counted for another run, the cycle's shutdown select replaced by awaiting the sync, the three health re-tests before the FAILED-prefix, DELETED-prefix and tombstone duties removed, the marker guard inestablishand the one inadopteach removed, and the fence's store-id comparison removed; Cassandra'sNotMatchedarm withis_none_or(killed by the in-catalog backup cell, which the four earlier insert cells let pass); and at the binary level (PostgreSQL debug builds, the targeted wire cell) the adopting sync's last read removed,complete_runansweringOk,--adopt-store's wait gated on--prune-missingalone, and sync's catalog refusal turned into a plain error (exit 1 where 6 is expected). All 16 were killed at the assertion each targets.tests/test_backup_store_config.py tests/test_backup_job.pyon the SQLite build 35 passed, 8 skipped (the PostgreSQL-only cells;pytest -n 4), on the PostgreSQL build 27 passed, 16 skipped (the SQLite-only cells;pytest -n 2, since-n 4exhausts the server's connection slots).backup_restore's two row-lock cells (start_backup_delete_waits_...and the newforget_available_backup_row_waits_for_the_row_lock_and_then_sees_the_registration) andstart_backup_delete_checks_...; SQLite--lib -- backup restore_job_tests63 passed (72 on the rebased tree); MongoDB's twolive_cells of the shared body; Cassandra--exactunder the lock, 6 passed, among themtest_an_unknown_forget_outcome_is_settled_by_a_serial_readandtest_a_forget_whose_entry_removal_fails_answers_removed(seamsfail_forget_writesandfail_index_entry_removals) andtest_a_forget_clears_a_finished_restores_registration_and_removes_the_row.SIGTERMone second into a sync of 2,000 prefixes: exit 1, "stopped"; the in-memory refusal and the parse errors offile://example.com/...andfile:x.sqlite?bogus=1; peak RSS 45 MiB empty, 66 MiB at 100,000 objects, 98 MiB at 300,000 (no rows); with rows of the root, 610 MiB at 25,000 and 1,144 MiB at 50,000 rows of a five-GSI table, 148 MiB at 50,000 rows whose source description is{}. On the earlier tree: SQLite (a writing sync beside a server exits 6;destroyandinit, then plain sync refusing and--adopt-storeadopting the root and its backup, which the restarted server describes, lists and restores; a prefix copied into another deployment's root adopted and restored there), thesqlite-memoryshape on a filesystem and on an S3 store (moto: the reconciler's first cycle adopts the bucket's root and syncs; Describe and Restore of the previous start's ARN), and PostgreSQL beside its server (a SQLite deployment's backup copied in, adopted and restored; the volume unmounted refusing sync and--prune-missingwith nothing written;--adopt-storeof a SQLite root and of an in-memory catalog's root).devtools/backup-restore-matrix, one invocation per cell: sqlite→sqlite, sqlite→postgres, postgres→sqlite, postgres→postgres, the two--duplicate-keycells into SQLite and PostgreSQL, sqlite→postgres and postgres→sqlite--vector, and sqlite→postgres and postgres→sqlite--adopt-store: 10 of 10 PASSED (25 to 33 s each) on the earlier tree; on b58d879 sqlite→postgres, postgres→postgres, sqlite→postgres--adopt-storeand postgres→sqlite PASSED again (28 to 33 s), the PostgreSQL targets' syncs now under the maintenance claim beside the server. On b58d879 sqlite→mongodb also PASSED, the target's sync taking the claim beside its server and adopting the backup. postgres→cassandra could not run on this host: the shared Cassandra node refusesCREATE ROLE ... PASSWORD(CassandraRoleManager doesn't support PASSWORD), which is the environment's, not the change's; it passed on an earlier tree of this PR. The nightly runs the MongoDB and Cassandra target cells on a schedule, not on this PR.Not in this PR
backup sync --move-store,--forget-storeand--forget-backup, withBackupCatalog::list_backup_rows_by_rootandrewrite_store, and the full report of the rows of other roots, with their acceptance cells (--move-store's resolutions and refusals,--forget-backup's refusals, the store's return after--forget-store, a CreateBackup racing--move-store).restore.rs'sOTHER_STOREreason and the reconciler's module doc now say what an operator can do without--move-store(configure the store the record names and restore again).extenddb backup migrate-legacy <arn>|--all, with its acceptance cells.backup_index_by_statusentries and the MongoDB sweep of the data collections a failed restore-target removal leaves; the code says no sweep removes them yet.wait_for_quiet) and its engine tests; this PR's claimed options reuse it unchanged.Breaking changes
None. The identity file's new member defaults to
false, so #740's binary reads a file this release wrote and this release reads #740's.extenddb backup list,purgeandremove-targetnow refuse an in-memory SQLite database (exit 1), where they opened a new, empty catalog.DCO
Signed-off-by: Scott Robinson robinnsc@amazon.com