Skip to content

feat(backup): backup sync, --adopt-store and --prune-missing [backup/restore 10] - #825

Draft
robinnsc wants to merge 5 commits into
feat/restore-jobfrom
feat/backup-sync
Draft

robinnsc wants to merge 5 commits into
feat/restore-jobfrom
feat/backup-sync

Conversation

@robinnsc

@robinnsc robinnsc commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

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 and backup remove-target · this PR: extenddb backup sync, --prune-missing, --adopt-store and the sqlite-memory startup sync · next: #820 the store-maintenance options, #821 migrate-legacy.
Closes #746 (part of #624).

Stacked on #740 (feat/restore-job, head 9803ab7f), itself on #700 (c461442a), #672 (ba6b82d4) and #671 (6ff37487) on main at 94b5f689. Rebased from #740's earlier head 6bdd1177 onto 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, its purge_rows and its MongoDB deployment of tests/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's run with sync added; sync takes 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 through purge_rows; the skip of FAILED maintenance rows dropped, #700 writes no such row), backup/reconciler.rs, backup/runtime.rs (both sides' ConfiguredStore fields; the fence's read through BoundedCalls), 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.md and tests/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.lock is 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). Two BackupCatalog methods, with no default body. insert_backup_row(&BackupRow) -> bool, beside claim_backup: insert the row as given, unowned (owner_generation 0), its status as given, completed_at and heartbeat_at on the database's clock; false when 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 with start_backup_delete's in-use rule, leaving no tombstone and touching no object; InUse(table) and Status(status) leave the row. StorageConfig::database_location keeps the reason a location names no file.

Core (crates/core). layout.rs gains EXPORTS_COMPONENT, CHANGELOG_COMPONENT, StoreKey and classify_store_key(key), which tells a key outside every backup prefix, a key under a reserved component (exports/ first, changelog third), 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 its Option form. Only the 14-digit id shape is a backup prefix, and the triple is validated through BackupArn::new.

PostgreSQL, SQLite, MongoDB, Cassandra. insert_backup_row: PostgreSQL INSERT ... ON CONFLICT DO NOTHING with no conflict target (the key and the unique backup_id both 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). SQLite INSERT ... ON CONFLICT DO NOTHING (so a NOT NULL or CHECK violation is an error, not a false) under the write lock and BEGIN IMMEDIATE, without a job slot. MongoDB the claim's pipeline upsert filtered on owner_generation: {$exists: false}, writing owner_generation: 0 and $$NOW for the two stamps, E11000 (the unique backup_id) meaning a row exists; row_fields takes the status, and claim_backup passes CREATING. Cassandra the backups_by_arn lightweight transaction IF NOT EXISTS, then the LOCAL_QUORUM batch of backups_by_account, backups_by_table and the backup_index_by_status entry under the row's status (write_index_entries, the batch claim_backup wrote inline, now shared); an unknown outcome or a NotMatched answer is re-read at LOCAL_SERIAL and 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 after NotMatched answers false (a legacy in-catalog backup holds the ARN); a failed entry batch is sent once more. forget_available_backup_row shares one body with start_backup_delete on every backend, with a remove flag: PostgreSQL and SQLite start_backup_delete_tx(key, remove) (the row lock, the status check, the in-use rule, then UPDATE ... DELETING or DELETE); MongoDB start_backup_delete_tx with delete_one, its exhaustion error naming DeleteBackup or the --prune-missing removal; Cassandra start_backup_delete_lwt(key, remove), one loop under one condition (IF backup_status = 'AVAILABLE' AND active_restores = null) through conditional_write, where a removal's unknown outcome is settled by a LOCAL_SERIAL read (no row: removed, then the entries; a row: the loop runs again) and an entry batch that fails after the removal is logged and answered Removed. SQLite's claim_backup keeps INSERT OR IGNORE, with a comment saying why it ignores only a key conflict.

Engine (crates/engine/src/backup/). New sync.rs, written against BackupCatalog and &dyn BackupStore, which the CLI and the server both call:

  • sync reads 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 through SyncTarget::stop ends the run before the next prefix. A prefix with no row is verified by reconciler::verify_prefix (split out of probe_manifest, which keeps its comparison of the whole ARN and maps to StatusFields; 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's BackupArnSpellings, 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 answers false for 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 and source_table, the id's instant as created_at, the configured store's identity. An insert that answers false is 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_skips counts them. The report also lists the AVAILABLE rows whose manifest the listing lacks.
  • prune_missing heads each such row's manifest once more and removes the row outright (forget_available_backup_row), deleting no object; it reports InUse and a row another writer moved, and checks the claim and the signal before each row. A head that fails keeps the row (PruneOutcome::Failed): the store has not said whether the manifest is back. PruneOutcome::needs_another_run names the outcomes a later run may change (Failed, InUse), which the command exits 1 for.
  • startup_sync is the in-memory catalog's sync: when the store is healthy and the sync pending, bounded by STARTUP_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's ephemeral_catalog member; adopt(store, kind, root, catalog, backend, ephemeral, rule) rewrites the file's catalog_id and backend with a plain put, keeping store_id and created_at_ms, reads it back (AdoptRefusal::RaceLost when another write landed over it), refuses when this catalog's rows for the root carry another store id, and logs an extenddb::audit intent line before the put and the audit line after the re-read with its outcome; AdoptRefusal::NoFile carries the rows' store ids, so a root that lost its file under rows is identity file missing and a fresh one identity file unreadable rather than unreachable; establish of an in-memory catalog adopts a root whose file names another catalog and carries the marker (AdoptRule::EphemeralOwner), and recheck never adopts. runtime.rs: ConfiguredStore::with_ephemeral_catalog and the pending startup sync (take, retry); BackupRuntime::with_region; and StoreFence (ConfiguredStore::fence), the deletion fence: it shares the store's health and reads the catalog id once, and its check re-reads extenddb-store.json with one get under the store's check bound, refuses with no read on a store that is not healthy, and when the file names another catalog records ForeignCatalog on 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) records IdMismatch and 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 before purge_row removes 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 each delete_prefix of a backup's objects: finish_backup_delete (now taking the fence and answering FinishError; 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's fail_row (FailurePath::Fenced), the reconciler's FAILED-prefix and DELETED-prefix duties, and purge_row; before the reconciler removes a tombstone; and in CreateBackup before claim_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: StoreCalls and verify_prefix are pub(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_row takes the fence, and its comment says why nothing adopts a prefix between its two steps (every sync takes the claim); restore.rs: OTHER_STORE and 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-missing removes only a row whose manifest is gone.

Server (crates/server). backup_store.rs: Caller::SyncCommand, as Command except 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 from backend == "sqlite" and database_file().is_none(); under the exception the unhealthy line says the root is the shared default one with no rows, configured for backup sync; the warning for a defaulted root another catalog owns names backup 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 as serve opens the catalog (on SQLite under the serve lock, after resolve_dead_owners), its serving registration named extenddb 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-missing left for a later run (a store or catalog call failed, a failed head included, 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-missing and --adopt-store also wait for running jobs as purge does. 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 cells test_an_adopter_whose_put_lands_during_the_sync_ends_it_with_exit_1 and test_a_prefix_the_store_cannot_read_leaves_the_sync_incomplete pin 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. purge passes the store's fence to purge_row. Every backup subcommand 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 names sync. destroy's output names backup 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-store on 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_object and test_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), and test_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 /health then reports owned by another catalog, and the adopting catalog's server is healthy and restores the backup), and test_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), and test_an_adopter_whose_put_lands_during_the_sync_ends_it_with_exit_1 (a second catalog's --adopt-store with 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, and backup sync in both tuples of test_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-matrix copies the source's prefix and runs extenddb backup sync on the target (on SQLite with the target's server stopped) in place of seed_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 report owned by another catalog); matrix-nightly.yml runs it from SQLite to PostgreSQL and back, and its comments and requirements-matrix.txt's say what the script now does.

Docs and texts. The admin guide: a backup sync paragraph (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-store paragraph (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, /health silent 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 (purge deletes 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-store after the re-upgrade, the defaulted SQLite root's warning until then, the serve lock or the claim for sync), the differences ("Where backups live"), usage (cross-backend restore, where --adopt-store of 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's migrate-legacy row; 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 orphan backup_index_by_status sweep and the MongoDB orphan target-collection sweep to backup sync say no sweep removes them yet, and the MongoDB error line in remove_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 between insert_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-memory lost it at every restart. The split keeps this PR to the commands that touch no row of another store: plain sync and --prune-missing act only on the configured store's rows, --adopt-store rewrites one file and then syncs, and the startup sync is the same sync. --move-store, --forget-store and --forget-backup rewrite or delete rows of other stores, a different set of invariants with two more trait methods per backend (#820); migrate-legacy is 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 by destroy and init finds 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 exception backup sync --adopt-store would answer "no store" on exactly the root it exists for. The exception is scoped to the backup sync command and to a root another catalog owns; the server and purge keep the warning. The alternative is to require an explicit [backup.store] naming <dir>/backups for the adoption: that is a configured root, unhealthy owned by another catalog, which --adopt-store adopts, 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-store there takes the root from whichever catalog wrote its file; the refusal, the server's warning and the admin guide say so.
  • The startup sync runs on every start of an in-memory catalog with a store, not only after an adoption. The catalog is new at every start, so there is no row to compare with and nothing to decide; on a fresh root the sync is one listing. A filesystem store syncs before the listener, an S3 store in the reconciler's cycle after its first healthy check, since an S3 check never holds the listener; every run is bounded at 60 s, and one past the bound, or one that met a store or catalog error on a prefix, runs again in the next cycle. Until a run ends, requests for the earlier backups answer BackupNotFound (with /health possibly already healthy); the :memory: row says so and names the log line that ends it. The adoption that precedes it is inside establish and only for a file carrying ephemeral_catalog: true; recheck never 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-missing removes 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-missing there removes the row and deletes nothing), and test_prune_missing_on_a_host_local_root_deletes_no_object pins 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, since purge deletes 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.
  • The dead end that follows. When the newest AVAILABLE row's own manifest is gone, the probe marks the store unhealthy, so plain sync and --prune-missing both 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-backup will be the other. The admin guide states it; test_prune_missing_and_the_unhealthy_store_refusals pins 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.
  • Plain sync takes the maintenance claim beside servers, with no wait. The insert-if-absent orders it against a CreateBackup on any instance (an_insert_that_meets_a_row_leaves_it_and_says_which), but not against purge, 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 until stale_job_after_secs passes (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").
  • The rows are read once, before the listing; the listing streams. The design's "checks each prefix against the catalog as the listing reaches it" holds with one read of the root's rows up front (read_backup_row needs 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's list_backup_rows collects 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 of source_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 list peaks 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's list_backup_rows; the design, the admin guide and section 7 state the cost instead.
  • The deletion fence narrows the previous owner's window; it does not close it. After an adoption the previous owner's store stays healthy until a later check that can read its catalog finds it, at the earliest one reconciler interval (60 s by default) after its cycle in progress ends, and in that window its DeleteBackup, its reconciler and its failed jobs would delete bytes under the new owner's AVAILABLE rows, which would then leave the new owner's store unhealthy with no way out in this release. The fence re-reads the identity file right before each deletion (and before a CreateBackup's first write), so the window shrinks to the gap between one get and the call after it (on S3 a listing and a DeleteObjects under 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 small get per 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 failed delete_prefix is) 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.
  • Rows of other roots are reported in part. Sync reports the prefixes whose backup id a row of another root holds. The rows of the configured root under another store id cannot reach it (such a store is unhealthy, which sync refuses), so the report has no section for them. A listing of every root's rows needs 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 to 9803ab7f, whose two conflicts were text in runtime.rs and the design, was checked with fmt, clippy and the engine, server and app suites: engine 466, server 310, app 49): cargo fmt --all -- --check and cargo +1.97.0 clippy --locked --workspace --all-targets --features extenddb-storage-cassandra/test-hooks -- -D warnings clean; engine --lib 464 passed (with and without backup-test-hooks: #740's 423 plus this PR's 42, less the dropped wait cell), server 310, app 49, core 801; PostgreSQL backup_restore 58; SQLite --lib 278 (1 ignored), its -- backup restore_job_tests 72, backup_job_panic and restore_job_resume 1 + 1; MongoDB live live_ 67 (--test-threads=1); Cassandra direct_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 by delete_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 -- --check clean; cargo +1.97.0 clippy --locked --workspace --all-targets --features extenddb-storage-cassandra/test-hooks -- -D warnings clean; cargo +1.88.0 check --locked --workspace --all-targets clean with no warning on b58d879.
  • Engine --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 no delete_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 unhealthy owned by another catalog; the fence's read failing alone (a MemStore failure 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 through reconciler::cycle on 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 (a Fake knob 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-store of a marked file writes ephemeral_catalog: false, an in-memory establish on that file answers ForeignCatalog and writes nothing, and an in-memory recheck of 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 through reconciler::cycle with 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 (a source_table of 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 (a Fake knob) skipping the prefix as a catalog error with no insert, and the next sync adopting it; a Fake mode 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-missing with a failed head (a MemStore knob) keeping the row as Failed, and needs_another_run true for Failed and InUse only; 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 (a MemStore list record); adopt(.., AdoptRule::EphemeralOwner) on a durable file refused with nothing written, and an in-memory establish on it reading the file once; the fence refusing a file of this catalog under another store id and recording IdMismatch; server --lib 305 before the rebase, 310 on it (backup_store with the SyncCommand cells, the exception's scope among them); app 49 (the adopting sync's last read, the transient-skip exit); core --lib 800 before the rebase, 801 on it, with the layout cells (every reserved component, the id grid is_backup_id accepts and refuses, traversal, separators, control and non-ASCII characters).
  • On the live services: PostgreSQL backup_restore 51 passed (58 on the rebased tree); SQLite's backup_job_panic and restore_job_resume, which drive the engine's job and reconciler, pass; SQLite --lib 268 (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 live live_ 63 with --test-threads=1 (67 on the rebased tree); Cassandra direct_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 (a backups_by_arn row 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 a test-hooks seam on conditional_write, and the failed entry batch) and the start_backup_delete cell, which on each backend also covers forget_available_backup_row (removed and listed nowhere, the in-use and status refusals, a missing row; on Cassandra the three index entries read directly).
  • Mutants, each a targeted edit, run, and the file restored (those of the earlier rules on the earlier tree): 6 in layout.rs; the four backends' inserts (the insert that overwrites, the one that ignores backup_id uniqueness, 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, NotMatched not completing a torn insert, the batch not re-sent); forget_available_backup_row without 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); the SyncCommand exception 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 recording ForeignCatalog, the fence reading the file on a store already unhealthy, adopt keeping the file's marker, and recheck rewriting 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-store taking no claim, plain sync taking no claim beside servers, and the prune through start_backup_delete and finish_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: None in the CLI's sync (the wire SIGTERM cell); PostgreSQL's shared body without FOR UPDATE, Cassandra's shared condition as IF EXISTS and without the in-use pre-check, and Cassandra's forget with the entry error returned, Unknown never or always counted as removed, and no lost-answer exhaustion check. All 21 were killed (a 22nd, the wait on a FAILED maintenance row, 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 --lib suite, the reader's validation replaced by a plain parse, a failed spelling read treated as no row, a failed prune head falling through to the removal, Incomplete counted as transient, the row check's insert after an adoption removed, InUse not 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 in establish and the one in adopt each removed, and the fence's store-id comparison removed; Cassandra's NotMatched arm with is_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_run answering Ok, --adopt-store's wait gated on --prune-missing alone, 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.
  • Wire, by name with the moto variables, on Python 3.10: tests/test_backup_store_config.py tests/test_backup_job.py on 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 4 exhausts the server's connection slots).
  • Storage, for the shared delete and forget body and Cassandra's forget: PostgreSQL backup_restore's two row-lock cells (start_backup_delete_waits_... and the new forget_available_backup_row_waits_for_the_row_lock_and_then_sees_the_registration) and start_backup_delete_checks_...; SQLite --lib -- backup restore_job_tests 63 passed (72 on the rebased tree); MongoDB's two live_ cells of the shared body; Cassandra --exact under the lock, 6 passed, among them test_an_unknown_forget_outcome_is_settled_by_a_serial_read and test_a_forget_whose_entry_removal_fails_answers_removed (seams fail_forget_writes and fail_index_entry_removals) and test_a_forget_clears_a_finished_restores_registration_and_removes_the_row.
  • Binary checks on b58d879 (SQLite): SIGTERM one second into a sync of 2,000 prefixes: exit 1, "stopped"; the in-memory refusal and the parse errors of file://example.com/... and file: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; destroy and init, then plain sync refusing and --adopt-store adopting 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), the sqlite-memory shape 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-missing with nothing written; --adopt-store of 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-key cells 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-store and 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 refuses CREATE 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/restore: backup sync --move-store, --forget-store, --forget-backup #820: backup sync --move-store, --forget-store and --forget-backup, with BackupCatalog::list_backup_rows_by_root and rewrite_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's OTHER_STORE reason 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).
  • Backup/restore: backup migrate-legacy #821: extenddb backup migrate-legacy <arn>|--all, with its acceptance cells.
  • The Cassandra sweep of orphan backup_index_by_status entries and the MongoDB sweep of the data collections a failed restore-target removal leaves; the code says no sweep removes them yet.
  • The acceptance line "a maintenance command started while a DELETING row and a RUNNING restore with a dead owner exist proceeds once the reconciler has finished the one and failed the other" is PR 9's wait (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, purge and remove-target now refuse an in-memory SQLite database (exit 1), where they opened a new, empty catalog.

DCO

Signed-off-by: Scott Robinson robinnsc@amazon.com

@robinnsc
robinnsc force-pushed the feat/backup-sync branch 2 times, most recently from 81dfb04 to b58d879 Compare November 5, 2026 02:18
@robinnsc
robinnsc force-pushed the feat/restore-job branch 5 times, most recently from ad78bbd to 94cd324 Compare November 5, 2026 08:48
robinnsc and others added 4 commits November 5, 2026 09:34
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>
`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>

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant