Skip to content

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

Open
robinnsc wants to merge 1 commit into
mainfrom
feat/backup-sync
Open

robinnsc wants to merge 1 commit into
mainfrom
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 (merged) · #740 the restore job, the cross-backend matrix and backup remove-target (merged) · 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).

Based on main at 887b6e26 (#840). Rebased from main at b8ad9cd5 (32 commits, #474's BackupGsi/BackupLsi manifest types among them) with no conflict; the change set is the same (47 files, +12,923 −883), and fmt, clippy 1.97.0 in the three feature sets, the engine, app, server, core and storage --lib suites, SQLite --lib backup, PostgreSQL backup_restore and the sync, prune and adopt wire cells on the SQLite and PostgreSQL builds pass on the rebased tree. Before that, based on main at b8ad9cd5 (#740 merged as 76b9710d, then #792), rebased with git rebase --onto origin/main 9803ab7f from #740's head 9803ab7f, which this PR was stacked on; #740 merged with the content of its later head 15f38c72, 143 files away from 9803ab7f. The rebase conflicted in one file, the design's revision log: main's item 33 is kept as main has it (it gained the sentence on migration 8) and this PR's item 34 follows it unchanged, so no item is renumbered; the seven other files both sides changed (backup/tests.rs, Cassandra's backup_engine.rs direct tests, the SQLite and trait backup_catalog.rs, the differences document, the admin guide and the upgrade manual) merged without a conflict, and the commit is the same size as before (44 files, +10,195 −758). Before that, it was rebased from #740's earlier head 6bdd1177 onto that stack after its later revisions 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 then skipped on the MongoDB deployment; they now run there, below). 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. MaintenanceClaim gains heartbeat_within(bound) and heartbeat_parks_before_its_bound(), with JobClaim's defaults (the whole call bounded; false); SQLite overrides both, its bound starting once the beat holds the engine write lock, and true. 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, and SyncTarget::claim, the claim the command holds, is re-checked with one beat right before each insert, a claim found lost ending the run there with that prefix not adopted (the startup sync passes None). A prefix with no row is verified by reconciler::verify_prefix (split out of probe_manifest, which maps to StatusFields and, like the restore's manifest check, accepts a manifest whose ARN differs from the row's in the region alone, BackupArn::names_same_backup): 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; a manifest of another region is adopted under this server's region's spelling of the ARN (BackupArn::in_region) and logged, so ListBackups names this server's region and DescribeBackup, RestoreTableFromBackup and DeleteBackup find the backup under either spelling, while the manifest keeps its own; another partition is 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 manifest's spelling, another region's, has a row for is skipped; a row under this server's 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, size, item total and source_table, the ARN in this server's region, the id's instant as created_at, the configured store's identity. An insert that answers false is re-read by its ARN so the report says which row it met, with no listing of the rows of its status. 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, checks the claim and the signal before each row, and re-checks the claim with one beat right before each removal (a claim found lost ends the loop with that row left). 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 on every run and the other skips at INFO until a run has listed the whole root and logged its line of counts (a run cut at the bound or stopped by an error names no prefix, so the next run names them), and at DEBUG after, with the line of counts. 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; on both store kinds the listing is collected whole (BoundedCalls::list, one ObjectMeta per object held until the run ends) before the run sees its first key, so a listing that outlasts the bound reaches no prefix. Its store calls run as the checks run theirs (ConfiguredStore::calls_within), so on a filesystem root an abandoned walk holds the root's call slot and is marked in flight until it ends, and the check and the duties are refused at once rather than starting a call beside it (BoundedCalls now also marks a parked call whose caller drops it before its bound); the mark records which caller's call has not returned (InFlightMark) and the refusal names it ("an abandoned startup sync's store call has not returned"), and since that refusal records the store unreachable, CreateBackup, DeleteBackup and restores are refused until the walk ends, after which the first cycle checks the store healthy and runs the sync again. Taking the pending sync is the one gate.

store_identity.rs: the file's ephemeral_catalog member; adopt(store, kind, root, catalog, backend, ephemeral, rule, claim) first runs the check the server runs on the root as it will be once this catalog owns it (compare and the manifest probe) and refuses a root this catalog cannot use with nothing written (AdoptRefusal::Unusable), then re-checks claim (the command's maintenance claim; None for the startup adoption) with one beat right before the rewrite, AdoptRefusal::ClaimLost with nothing written when it is lost, then 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; every store call of the adoption that fails in transport names its step (at_step), the manifest probe of a root this catalog already owns included; 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, in a call slot and with an in-flight mark of its own on a filesystem root (so a deletion holding the root's slot does not hold it), 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 root with no file, a file the store refuses to serve and one that does not parse are refused and recorded as the next check would record them (FileMissingWithRows, Denied, FileUnreadable, Validate), so CreateBackup answers the unhealthy-store ValidationException; only a read that fails in transport refuses and leaves the health as it was (CreateBackup then answers InternalServerError). 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, a root with no file, or a file the store refuses to serve or that does not parse leaves the row as read with its objects (DeleteBackup answers the unhealthy-store 400 ValidationException, and DescribeBackup still answers AVAILABLE; a fence read that fails in transport 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; in CreateBackup before claim_backup, so a fenced create writes no row and no object, and in the backup job right before its first put, so a root adopted after the request gets no object of the job (FAILED with the fence's reason). DeleteBackup's log line for a deletion the fence refused after the DELETING write is worded from what the fence found (fenced_deletion_note). 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: HeldClaim confirms the claim by the job heartbeat's rule (job/heartbeat.rs): each beat bounded by one period through heartbeat_within, a beat that answers Lost, fails or outlives the bound recording the loss, the claim read lost once its last confirmed beat (the take standing for the first) is two periods old, a beat in flight counting only where heartbeat_parks_before_its_bound (SQLite), and the timer's ticks one period apart from the take; HeldClaim::recheck sends one beat, under the same bound, and records a loss as the timer does (a claim already read lost answers at once). purge_row takes the fence and the claim, re-checks the claim right before it removes a dead CREATING row and right before it deletes that row's prefix, and its comment says why nothing adopts a prefix between the two steps while each command reads its claim held, and what remains (a call already sent when the process was stopped); rows of other statuses keep their row through the deletion, so they need no re-check; 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, and backup sync logs a root another catalog owns at WARN, not ERROR. 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; plain sync there runs no dead-owner resolution, and --prune-missing and --adopt-store run resolve_dead_owners before their wait, as purge does), 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. Every sync reads the identity file once more at the end and fails naming the owner if it changed (owner_after_sync), plain or adopting; a run with a store or catalog skip exits 1 (complete_run) and its count line says incomplete (run_word); a row left for a later run exits 1 (PruneCounts); app unit tests cover these 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. The adoption runs on the store's bounded calls (store_identity::adopt_configured): each store call under the 5 s bound, the catalog's reads and the claim's beat beside it, and a store that stops answering ends the command naming the step that did not finish. 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, on SQLite's default root only, that every database file of a directory shares that root. resolve_dead_owners (purge's and the claimed options') now prints the store-backed restores it failed and removed, those it failed, and the targets it left for backup remove-target. --adopt-store's help says it waits for running jobs. A defaulted root's ConfiguredStore carries defaulted once its health is settled too, which the refusal reads. 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, PostgreSQL and MongoDB deployments (MongoDB's row edits run mongosh in the replica set's container, EXTENDDB_TEST_MONGODB_CONTAINER, which integration-mongodb.yml's backup-store job now sets; the cells that need a second catalog skip on MongoDB, whose catalog database name is fixed per replica set; "PostgreSQL" below reads "beside a server", PostgreSQL and MongoDB): 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). test_plain_sync_on_sqlite_leaves_a_stopped_servers_restore_to_resume (SQLite: SIGTERM mid-restore, plain sync exits 0 and fails nothing, the restart resumes the restore to ACTIVE with every item); test_sync_rebuilds_dropped_rows_and_adopts_only_verified_prefixes now expects the DELETING and CREATING rows left as they were on both deployments; test_adopt_store_makes_this_catalog_the_owner asserts the default-root sentence absent for a configured root. tests/test_backup_store_config.py: test_an_in_memory_server_restarted_onto_its_root_serves_the_previous_starts_backups, test_adopt_store_takes_the_default_root_a_re_created_catalog_finds (a store-backed backup on the default root, destroy and init, plain sync exit 1 naming --adopt-store and the shared default root, --adopt-store adopting the backup with the store id kept, the restarted server describing and listing it: the command's Caller::SyncCommand), 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. devtools/backup-kill-points.py, whose Cassandra restore points seeded the target's row with the matrix's seed_row, now copies the prefix and runs extenddb backup sync beside the Cassandra target's server, requiring exit 0 and the one backup adopted, as the matrix's adopt_backup does; its comments and the nightly's Cassandra build step say so. Engine cells: the claim's two-period rule (a timer that stops), a beat that hangs bounded by its period, a beat in flight confirming only where it parks (a paused clock: a beat on schedule that never returns, and a process stopped three periods between beats), a beat that panics leaving no flight, the re-check counting as a beat and recording a loss, and the re-check before purge's removal of a dead CREATING row and its prefix, sync's insert, --prune-missing's removal and --adopt-store's rewrite, each with a claim lost there. The startup sync's log rule: a first run whose listing fails and one cut at its bound name no prefix, the next run names the lasting skip at INFO and the run after it at DEBUG (a_startup_sync_whose_first_run_fails_logs_its_skips_at_info_on_the_next, ..._is_cut_at_its_bound_...). The region rule at the restore: a row over a manifest that spells the backup in another region restores (a_row_over_a_manifest_of_another_region_restores) and resumes (a_resume_whose_store_documents_fail_verification_is_failed). A stop at the second of three prefixes, inside the listing's loop, makes no missing-manifest report. The fence's CreateBackup cell has a third case, the filesystem store's error for a file at mode 000, recorded as failed validation. SQLite: the_maintenance_claims_beat_is_bounded_from_the_write_lock. The four backends' insert_backup_row_inserts_an_unowned_row_only_when_absent cells insert a row whose created_at is a year before the insert and assert completed_at and heartbeat_at within 60 s of the insert.

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, an S3 server error recorded as failed validation, 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, held whole in memory; an abandoned run's filesystem walk that runs to its end while every check is refused on it and the store is unreachable, CreateBackup, DeleteBackup and restores refused, until the first cycle after it runs the sync again, with a smaller root or a durable catalog as the remedy for a root too large to list in 60 s; /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 prefixes a stopped server had not finished writing or deleting (a killed CreateBackup, a failed backup, a deletion cut after its manifest), which no command removes, the startup sync's "not adopted: not a complete backup" line that names them, and the rule to delete by hand only a prefix with no extenddb-manifest.json and no row in ListBackups, 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 states the claim's heartbeat rule and its re-checks with the bound that remains, and what plain sync (no resolution) and the two claimed options (purge's resolutions, a RUNNING store-backed restore failed) do on SQLite with what a stopped server left; the :memory: row gives the remedy for a root an earlier development build's in-memory server wrote (its file has no ephemeral_catalog: remove it, and the next start writes a new one and adopts the backups). 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", where an adopted backup of another region keeps the manifest's region in its source table's ARN, in DescribeBackup's SourceTableDetails.TableArn, ListBackups' BackupSummaries[].TableArn and the restore's RestoreSummary.SourceTableArn), 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) (--move-store is #820's), 4(f), 4(g), decision 6, section 7's memory paragraph, the PR 10 contract, 5.3's failure step (OTHER_STORE's reason as this PR words it) and the testing summary's kill-point line edited with item 34, which also states the claim's rule, the re-checks and plain sync on SQLite. 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 and the sync module doc qualify "no two rows of one backup id" as under this server's or the manifest's spelling of the ARN (item 34 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 listing collected whole on both store kinds, the abandoned filesystem walk and what it costs until it ends; destroy's sentence says that on the default SQLite root the re-created catalog's server logs the warning and leaves the store unconfigured. The backup job's line for a job failed on an unhealthy store says its objects are never deleted while another catalog has adopted the root.

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 act at once while each reads its claim held (test_sync_is_refused_while_purge_holds_the_claim), and 4(f) now names plain sync. On MongoDB and Cassandra a claim is taken over on its heartbeat's age alone, so a command stopped past stale_job_after_secs (SIGSTOP, a paused VM) can resume after another took its claim: the claim reads lost once no beat confirmed it for two periods, and the command sends one beat right before each step it cannot undo and stops there when the claim is lost. What remains is a stop after that beat confirmed the claim, or with the step's call already sent: the call lands when the process resumes. 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, which is all the design's rule that every writing command refuses to run beside a server asks: the lock keeps every server and every other command off the file, so nothing changes between the row read and the insert, and plain sync runs no dead-owner resolution. Purge's resolutions (ReconcilerState::for_maintenance_holder) would fail a store-backed restore a stopped server left RUNNING and remove its target, which the next server start resumes instead; plain sync skips the rows a stopped server left and the next start resolves them. --prune-missing and --adopt-store take the claim on SQLite and run those resolutions before their wait, as purge does (that prelude logs a line of its own, "run by a maintenance command before its own work"), and print what they did.
  • 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 row and again before its job's first put), 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, a root with no file, or a file the store refuses to serve or that does not parse answers 400 and leaves the row AVAILABLE with its objects, and DescribeBackup never answers that refusal with DELETED; the cost is two small gets per CreateBackup (the request's and the job's) and two per DeleteBackup. 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 root with no file, or a file the store refuses to serve or that does not parse, is recorded as the next check would record it, so the store is unhealthy at once and CreateBackup answers the unhealthy-store ValidationException rather than a 500 on a store /health reports healthy; a read that fails in transport (the store unreachable, the read past its bound) refuses the deletion (it is retried, as a failed delete_prefix is) and changes no health; an S3 server error the SDK's retries did not clear (a 500 InternalError, a 503 SlowDown) is not one: the S3 store reports it as it reports other refusals, so the fence records failed validation, as the store's check does, and the store is unhealthy until a check passes (one reconciler interval, 60 s by default), refusing CreateBackup, DeleteBackup and restores in that time. The fence reads more often than the check (twice per CreateBackup and per DeleteBackup), so a throttled bucket meets it more often; classing 5xx answers as transport would change the S3 store's error mapping for every caller, which this PR leaves as it is, and the admin guide states the case; 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 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

On this tree (main at 887b6e26 with this PR's changes, among them the in-flight mark that names its caller, the adoption's probe step, the startup sync's documented walk and the region adoption's three fields; 47 files, +13,388 −907 against main), debug builds through a shared target with every tracked file touched first: cargo fmt --all -- --check and cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings clean, also with --features extenddb-storage-cassandra/test-hooks,extenddb-storage-mongodb/test-hooks and with --features extenddb-engine/backup-test-hooks; engine --lib 701 passed (backup:: 338; 3 cells extended: the cycle cut at its bound now holds the walk and lets it return, asserting the check refused on it with the text naming the startup sync's call, the store unreachable, and the next cycle adopting the backup and finishing the deletion; the adoption's step cell gains the probe of an owned root, on a head that never answers; the restore's region cell adopts its prefix through sync with the manifest's source table in the other region and asserts the three fields), app 50, server 375. Mutants, each a targeted edit undone by the reverse edit, against the cells named: the refusal's text fixed to "check" whatever the caller (killed by the cycle cell); the probe's error mapped without its step (killed by the step cell); the cycle running the startup sync once per process (killed by the cycle cell); the adopted row's table_arn rebuilt in the server's region, and the restore's SourceTableArn rebuilt from the backup's ARN (each killed by the restore's region cell, the first surviving the earlier region cell, which reads no field). One edit that disabled the re-arm in the run's timeout branch survived the cycle cell: there the listing call's own 60 s bound ends the run first (Err(Listing), which re-arms), as the skip-lines cell's comment says; the "left pending" assertion covers the re-arm on that path. No wire cell asserts a text this pass changed.

On the tree before this pass (main at 887b6e26 with this PR's changes, among them the startup sync's rule for its skip lines, the region cells at the restore and the resume, the stop inside the listing's loop, the fence's filesystem case, DeleteBackup's doc and the texts; 47 files, +13,210 −884 against main), debug builds through a shared target with every tracked file touched first: cargo fmt --all -- --check and cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings clean, also with --features extenddb-storage-cassandra/test-hooks,extenddb-storage-mongodb/test-hooks and with --features extenddb-engine/backup-test-hooks; engine --lib 701 passed (3 new cells, 3 extended), app 50, server 375, core 1087 (1 ignored). Mutants, each a targeted edit undone by the reverse edit with the file's md5 checked, each against the cells named for it, 7 of 7 killed: the skip lines' rule decided at the take (the flag set when the sync is taken; and, with the flag as it is, only the process's first run logging at INFO, against each of the two new cells alone), the flag never set, the restore's manifest check and the resume's requiring the row's region, a missing-manifest report made at a stop inside the listing's loop, and the fence taking the filesystem store's I/O error for a transport failure. Wire on the sqlite-memory build, Python 3.10 with the moto variables: the --adopt-store cells, the in-memory restart cell and the region cell, 5 passed (-n 3). Binary check on that build, an in-memory catalog over a filesystem root holding one backup and a copy of its prefix under another id, with an account directory at mode 000: the startup sync before the listener and the first reconciler cycle's each logged one WARN line ("startup sync of the in-memory catalog stopped: the store's listing failed: read directory .../999999999999: Permission denied (os error 13); no row was written; the next reconciler cycle runs it again") and no prefix line; once the directory was readable again the next cycle logged at INFO "backup store startup sync: 123456789012/Music/01794287129941-5c08ebdc/ not adopted: the manifest names arn:aws:dynamodb:us-east-1:123456789012:table/Music/backup/01794287129940-5c08ebdc, whose account, table and backup id are not the prefix's" and "1 backup(s) adopted, 1 prefix(es) skipped", and DescribeBackup of the backup answered AVAILABLE. The S3 server error at the fence (Choices to weigh) is read from the code; moto injects no faults.

What CI does not run. No Cassandra deployment runs tests/test_backup_job.py, so backup sync, --prune-missing and --adopt-store have no wire leg on Cassandra (its engine fakes and the crate's direct cells cover the trait methods). No CI cell runs backup sync against an S3 store: the wire cells, the in-memory restart cell and the matrix use filesystem roots, and the engine cells an in-memory store. A sync against moto was run by hand on 84bc263 with the PostgreSQL build: a dropped row re-adopted with exit 0, plain sync refusing a root whose file was rewritten to name another catalog and naming --adopt-store, and --adopt-store adopting it with the store id kept and ephemeral_catalog: false written; an in-memory server restarted over a moto root adopted it in its first cycle and synced its 3 backups. The nightly matrix (matrix-nightly.yml, which runs the changed devtools/backup-restore-matrix and devtools/backup-kill-points.py, its MongoDB and Cassandra targets and the four kill-point jobs among them) runs nowhere else; main's last two scheduled runs failed in restore-kill-points-mongodb.

Nightly matrix on this branch: started on 4812e3e8, https://github.com/ExtendDB/extenddb/actions/runs/42775123844 (result to follow).

On the tree before the rebase onto 887b6e26 (based on b8ad9cd5; the fence's recorded problems, its own slot and the job's fence; the adoption's check before the rewrite and its bounded calls; the region adoption; the startup sync's log levels, take and walk; every sync's last read; the MongoDB wire coverage; 47 files, +12,923 −883 against main), debug builds through a shared target with every tracked file touched first: cargo fmt --all -- --check and cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings clean, also with --features extenddb-storage-cassandra/test-hooks,extenddb-storage-mongodb/test-hooks and with --features extenddb-engine/backup-test-hooks; engine --lib 489 passed (12 new cells, 8 rewritten to the new rules), app 50, server 343, core 803. Mutants, each a targeted edit with the file restored from its copy and its digest checked, each against the cells named for it: 25 of 26 killed (the fence's missing and denied reads unrecorded, the fence in the calls' slot, no fence in the job, the adoption without its probe, unbounded, comparing the catalog id alone, without the rows' ids on a missing file or without the two-ids refusal; the startup sync's retry at INFO, its listing unbounded, its run without the take, a dropped call left unmarked; the row under the manifest's region, the probe requiring the same region, the missing-manifest filter without the store id, the fence reading the catalog id on each call, the prune's stop before the first row only and its audit line removed, the re-check unbounded and the trait default unbounded, the timer counting from its first run, the fenced deletion's line naming an adoption for a missing file; in the app the prune exiting 0 for a row left and complete for a transient skip); the 26th (the cache's early return removed, get_or_init keeping it) is equivalent. Wire, by name with the moto variables, Python 3.10: SQLite 13 passed, 4 skipped (-n 4, with tests/test_backup_store_config.py's in-memory and adoption cells); PostgreSQL 14 passed, 1 skipped (the SQLite-only cell); MongoDB, on a private mongo:7 replica set with EXTENDDB_TEST_MONGODB_CONTAINER set, 10 passed, 4 skipped (the three cells with a second catalog and the SQLite-only cell). At the binary level the restore's manifest check requiring the row's region (PostgreSQL build) fails the region cell at its restore. Binary checks on PostgreSQL with a filesystem root and the reconciler an hour away: with extenddb-store.json removed, CreateBackup on a store /health reported healthy answered 400 ValidationException "... unhealthy (identity file missing) ..." twice (13 and 3 ms), and /health then reported the store unhealthy; at mode 000 the same, "unhealthy (failed validation)", the reason the filesystem store gives a file it may not read; a CreateBackup sent 1 s into a DeleteBackup of a prefix of 300,000 objects (the DeleteBackup answering DELETED after 5.6 s, the prefix gone after 74 s) answered 200 CREATING in 81 ms and its backup became AVAILABLE.

On an earlier tree (the claim's rule and re-checks, plain sync on SQLite, the kill-point script, the texts; 45 files, +11,351 −822 against main), debug builds through a shared target with every tracked file touched first: cargo fmt --all -- --check and cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings clean, also with --features extenddb-storage-cassandra/test-hooks,extenddb-storage-mongodb/test-hooks and with --features extenddb-engine/backup-test-hooks; engine --lib 477 passed (9 new), app 49, server 343, storage 236; SQLite --lib backup 64 and the new maintenance-claim cell; PostgreSQL backup_restore 58; MongoDB live_insert_backup_row_inserts_an_unowned_row_only_when_absent and Cassandra test_insert_backup_row_inserts_an_unowned_row_only_when_absent (--exact, test-hooks) pass. Mutants, each a targeted edit, run and the file restored with its md5 checked, all 16 killed: in the engine the two-period rule off, a beat in flight confirming on every backend, the timer's beat unbounded, recheck recording no loss, and each of the five re-checks removed (purge's two, sync's, the prune's, the adoption's); SQLite's maintenance beat bounded over the whole call and its parks false; SQLite's and PostgreSQL's insert writing created_at as both clock columns; and at the binary level (SQLite debug builds, the targeted wire cells) backup sync configured as Caller::Command, plain SQLite sync running the dead-owner resolutions again, and the default-root sentence on every root. Wire, tests/test_backup_store_config.py tests/test_backup_job.py by name with the moto variables, Python 3.10: on the SQLite build 37 passed, 8 skipped (-n 4; the two new cells are SQLite-only; the default-root cell failed on the first build, whose ConfiguredStore did not carry defaulted for a settled health, and tests/test_backup_store_config.py passed whole on the corrected build, 15 passed, 4 skipped), on the PostgreSQL build 27 passed, 18 skipped (-n 2). devtools/backup-kill-points.py restore cassandra on a private Cassandra node (the script's port changed in a scratch copy only), with a SQLite source: restore_after_download, restore_after_file_commit, restore_cassandra_before_progress (first and second reach), restore_between_failed_and_removal and sigterm, 6 of 6 passed, each target's backup sync exiting 0 with the backup adopted; restore sqlite restore_after_download passed. The lines below are carried from the earlier trees, as each says.

On the tree rebased onto main at b8ad9cd5, debug builds: cargo fmt --all -- --check and cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings clean, also with --features extenddb-storage-cassandra/test-hooks,extenddb-storage-mongodb/test-hooks and with --features extenddb-engine/backup-test-hooks; engine --lib 468 passed, app 49, server 343, storage 236; SQLite --lib backup 64; PostgreSQL backup_restore 58 (--test-threads=2); the wire suites tests/test_backup_store_config.py tests/test_backup_job.py on the SQLite build 35 passed, 8 skipped (-n 4). The lines below are carried from the earlier trees, as each says.

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 6 times, most recently from 94cd324 to 9803ab7 Compare November 5, 2026 10:03
Base automatically changed from feat/restore-job to main November 7, 2026 00:51
@robinnsc
robinnsc force-pushed the feat/backup-sync branch 2 times, most recently from be84a7e to b42dbfa Compare November 8, 2026 17:31
robinnsc added a commit that referenced this pull request Nov 8, 2026
`extenddb backup migrate-legacy <arn>` or `--all` (exactly one) copies
in-catalog backups into the configured store, each under its own ARN,
backup id, name and creation time, and replaces each by its catalog row,
AVAILABLE and unowned as a row `backup sync` inserts. `<arn>` is checked
as a backup ARN before anything is opened (exit 2) and resolved as
written and rebuilt in this server's region; `--all` takes every
in-catalog backup of every account that is not DELETED, oldest first,
goes on past one it does not migrate, and then, for every deleted one,
deletes what a stopped run left under its prefix and removes its
in-catalog record once the prefix is clean. The command needs the
configured store healthy, opens the catalog as `serve` does (on SQLite
under the serve lock, so the server is stopped for it), takes the
maintenance claim on every backend for its whole run and waits as
`purge` does, so every backup and restore job of the catalog answers
LimitExceededException until it ends.

Per backup, in this order: an id with no creation instant is refused
before anything is read (that is every backup a 0.1.x release took: a
store prefix carries only the 14-digit shape); then a table name the
store-prefix rules refuse; a DELETING backup whose replacement committed
and stopped (MongoDB) is finished; a catalog row of the id under either
spelling of the ARN (the configured store's under the stored spelling:
an earlier run's, finished; any other: refused, since the prefix may be
that row's), then under any ARN (`row_holding_backup_id`, refused);
CREATING and DELETING are skipped; a backup the backend cannot read
(MongoDB's with no item collection), a name the manifest refuses, what
the legacy restore refuses (a vector index, a multi-part key) and a
SQLite backup taken before catalog 0.0.4 are refused. Then the copy:
objects under the prefix with no row are an earlier run's when the
manifest there, if any, names this backup (one naming another refuses
the backup with the prefix left), and are deleted through the deletion
fence first, by a walk of the prefix that starts no removal more than
two heartbeat periods after the claim's last confirmed beat; the fence
runs again before the first put; the items stream through the backup
job's writer, each checked with the item validator under this instance's
limits; the manifest is written last with `snapshot.kind`
`legacy_in_catalog`, no marker, `unordered`, and `source_backend` and
`source_extenddb_version` naming this binary. The prefix is read back
through `verify_prefix`, sync's check, and the item count is checked
against the in-catalog backup's twice. A refused item, a failed or short
read or a copy that does not read back deletes the copy (the objects
this run wrote, one by one) through the fence and keeps the in-catalog
backup; after a failed or short read a backup no longer AVAILABLE is
skipped, "deleted during the copy". An index row of the id met at the
replacement refuses it and keeps the copy. Before each step that cannot
be undone (the manifest's put, every deletion under a prefix, each
replacement call, a record's removal) the command re-checks the claim
with one beat, and a failure stops the run with what it wrote left. A
lost claim, a signal, an unhealthy store, a failed listing or catalog
call, and a replacement whose answer did not come stop the run with exit
1; a signal is acted on before the next backup and inside a copy at the
writer's next step (the copy deleted and named in a note), one received
during a replacement stops the run after it, and a second signal exits
at once with 130, leaving what a kill leaves. Exit 0 only when every
backup reached is in the store or deleted. One `extenddb::audit` line is
logged per backup the run deleted or replaced anything for, none for one
it left as it was; a `refused` or `failed` line says the in-catalog
backup is kept only where it is (not once the claim is lost); the
summary counts migrated, finished, already in the store, deleted,
refused, skipped and failed apart.

Two runs can meet under one prefix: one stopped past the stale window
(SIGSTOP, a paused VM) whose claim a second run took. Every object of a
copy is therefore written with `put_if_absent`, and an object already
under a key stops the run with that object left; a run deletes its own
copy object by object, only what its writer recorded, with the store
trait's new `delete_written` (S3 `DeleteObject` with `If-Match` on the
put's ETag; a filesystem root renames the file aside, removes it if its
size and modification time are the put's and links it back otherwise),
reading the claim before each; the prefix walk is kept for a leftover no
row holds. A call already sent when the process stopped completes on
resume: such a put cannot replace another run's object, and such a
deletion reaches another run's object at its key only on S3 when the
bytes are the same, or on a filesystem root stopped again between the
rename and the link back. With `put` and `delete_prefix`, a run stopped
at a data file's rename, or at its deletion's first removal, and resumed
after a second run migrated the backup changed the second run's
AVAILABLE copy; with this change both stops leave it byte for byte
(PostgreSQL and MongoDB, under gdb).

A backup DeleteBackup answered DELETED never comes back from a copy a
stopped run left. The replacement deletes the `backups` row on
PostgreSQL and SQLite, so a DELETED row there is always a
DeleteBackup's; MongoDB's replacement marks its document; Cassandra's
converts the row. `LegacyBackupReader::holder_status` answers for an
in-catalog backup in any status that the replacement did not replace,
and `list_deleted` lists the DELETED ones: `backup sync` never adopts a
prefix whose ARN such a backup holds, naming the command for a deleted
one, and `migrate-legacy` deletes that copy through the fence, lists the
prefix again and, once it is empty and no catalog row holds the id,
removes the DELETED record (`remove_deleted`), reporting the backup
deleted. So a DELETED record whose id carries an instant is a prefix not
yet confirmed clean, and PR 14's migration is to refuse while one
exists. A deleted backup whose ARN an AVAILABLE row holds again (a sync
of an earlier release adopted a stopped run's copy) is refused, naming
the row and the choice. Before a deletion under a prefix the command
looks the id up by `BackupCatalog::row_holding_backup_id`, the key the
inserts conflict on; Cassandra, which indexes no backup id, answers none
and is covered by the manifest check. `finish_replacement` finishes
MongoDB's replacement that committed and stopped before its drop,
whatever the catalog row holds since.

The manifest's source table is the table the legacy restore creates
(`source_table_from_legacy`, pinned by an exhaustive property against
`restore_input` and `restore_input_from_manifest`), with the recorded
table class, TTL and stream and the SSE description of the recorded
specification, so the stored specification's `KMSMasterKeyId` is not
carried over; no answer reports a key id before or after. The vector
state is `[]` on PostgreSQL and SQLite and `null` on MongoDB and
Cassandra.

The replacement is one step per backend,
`LegacyBackupReader::replace_with_index_row`, in place of an insert
followed by the reader's delete, which let a DeleteBackup between the
two answer DELETED for a backup that then came back, and which cannot
work on Cassandra, where both rows share one key. PostgreSQL: one
transaction (the row `FOR UPDATE`, the CREATING-target check, the
insert, then the items, the definition and the row deleted). SQLite:
the same under the write lock and `BEGIN IMMEDIATE`. MongoDB: one
session transaction that inserts the row and marks the document
DELETING with `migrated_to_store`, then the collection's drop and
DELETED; a commit whose answer is lost is settled by reading both back.
Cassandra: the row converted in place by a lightweight transaction `IF
backup_status = 'AVAILABLE' AND store_kind = null AND active_restores =
null`, then its list and status entries and the item buckets' deletion;
an unknown outcome is settled by a serial read, and a second call
finishes a conversion that stopped after the row, writing entries only
for a row still AVAILABLE. Four frozen paths change so a request orders
wholly before or after a replacement: PostgreSQL's delete reads the
status under its row lock, MongoDB's reads a `migrated_to_store`
document as not found, and Cassandra's delete claim and its legacy
restore registration carry `store_kind = null`.

`LegacyBackupReader` also gains `items(account, arn)`, a frozen copy of
each restore's read as a stream (PostgreSQL a REPEATABLE READ catalog
transaction held for the copy, SQLite keyset batches each re-checking
AVAILABLE, MongoDB a cursor with `batch_size` and a frozen decode,
Cassandra each bucket paged 8 rows at a time at consistency ALL, since
the in-catalog create wrote at ONE: a replica that does not answer fails
the backup naming it, and a count the read does not reproduce names a
repair before the rerun, not the legacy restore, which reads at ONE),
and `list_all()`, which returns a row that does not decode as an error.
The core format gains `SnapshotDescriptor::KIND_LEGACY_IN_CATALOG`; no
format version or fixture byte changes. The writer runs under a
`ClaimWatch`, the job's heartbeat or the maintenance claim, whose loss
now cancels a token; the job's tests run unchanged. `HeldClaim` keeps
the job's rule #825 gave it (each beat bounded by one period, a claim
with no beat confirmed within two periods read as lost, a beat in flight
counted only on SQLite, where it parks on the process's own write lock)
and adds one restriction: a parked beat counts only when it started on
schedule, within 1.1 periods of the last confirmed one, so a process
stopped and resumed is not confirmed by its late beat; it also dates
each confirmation at its answer and gives `acting_deadline()`, two
periods after it. PostgreSQL's maintenance take now refuses while the
claim row's heartbeat is younger than `stale_job_after_secs`, even with
the advisory lock free, as the other backends' take does: a holder whose
catalog session ended runs until its next beat finds the loss. So on
PostgreSQL, MongoDB and Cassandra a holder starts no call more than two
periods after the answer of its last confirmed beat, which wrote the
heartbeat at most one period before that answer, and a taker needs a
heartbeat at least three periods old (on SQLite the serve lock keeps
others out); after a killed command on PostgreSQL the next one fails,
naming the holder, until the window passes. Purge, sync and this command
take the claim through one shared function, and on SQLite the dead-owner
pass logs its removal of a legacy backup left CREATING under
`extenddb::audit`. The replacement answers AlreadyExists for a row of
the id under another ARN or store whatever the in-catalog backup's
status.

`backup sync` skips a prefix whose ARN an in-catalog backup holds,
under either spelling, naming `migrate-legacy <arn>`. DescribeBackup,
DeleteBackup and RestoreTableFromBackup read the catalog row once more
after a legacy not-found (RestoreTableFromBackup also after its
registration's), and ListBackups reads the legacy reader first, so a
replacement between the two reads never answers BackupNotFound or drops
the backup from a page; ListBackups lists an ARN both readers hold
once, with the row's summary.

Tests: per-backend cells for the stream (batch and bucket boundaries, a
pinned snapshot, a delete ending it, the AVAILABLE re-check inside
PostgreSQL's snapshot), `list_all` and `list_deleted` across accounts,
`holder_status`, `remove_deleted` and `row_holding_backup_id`, the
replacement's answers, a real replacement and a DeleteBackup queued on
each other in both orders (PostgreSQL), PostgreSQL's take of a young
dead claim refused, MongoDB's finish and lost commit answer, Cassandra's
lost answer and stop and the item read's consistency and replica error,
SQLite's maintenance beat parked on the write lock, the store's
`delete_written` on a filesystem root and on S3, a legacy restore
registering after a conversion, a registration between the conversion's
read and its transaction, and a conversion finished after a delete; 49
engine cells on `Fake` and `MemStore` (each refusal, an object another
run wrote, a failed copy deleting only its own objects, a loss read
during that deletion, the leftover walk's deadline, the fence at each
deletion, the counts, a store serving another manifest, every rerun
rule, a deleted backup's copy and record, a row of the id under any ARN,
a claim taken over before each step that cannot be undone, a stalled
heartbeat, signals during a copy and a replacement, requests racing a
replacement, the sync skip, the dedupe) and eight of `HeldClaim` (four
of them #825's, extended here; a late beat in flight confirms nothing
where it does not park, and #825's in-flight cell now finds a parked
beat that starts late confirming nothing); wire cells on SQLite,
PostgreSQL and MongoDB (two in-catalog backups migrated, described,
listed, restored and deleted under their ARNs with the in-catalog rows
gone; `--all` past a #618 backup and a 13-digit one, both kept; a
stopped migration's copies of two deleted backups, not adopted and then
deleted; the claim against a second command, on PostgreSQL and on
MongoDB beside a live holder, and the serve lock); SIGKILL at the three
kill points on SQLite and PostgreSQL, each rerun finishing, and a kill
or a refused deletion followed by DeleteBackup, the backup staying
deleted (PostgreSQL on S3 too); a first run whose claim a second took
over (PostgreSQL after its session ended, MongoDB stopped past the
window and resumed with the catalog paused), the second's backup left
whole; a deleted backup's record removed only once its prefix is clean
(SQLite). 169 mutants of the new tests, all killed. Peak RSS 48 to 56
MiB flat on PostgreSQL (75 at most measured beside a server); on SQLite
55 to 149 MiB, its page cache, bounded by 32 MB times `pool_size`.

Docs: the backup manual's legacy section (the command, its cost, what it
refuses and the way out, what a migrated backup reports, a stopped run
and a deleted backup's copy and record, MongoDB's and Cassandra's
stopped replacements, Cassandra's recurring step, memory); the upgrade
manual's store, 0.1.x and rollback paragraphs and `--all` after the
last DeleteBackup; the admin guide's paragraph, sample output and
`stale_job_after_secs` row; the differences doc's two rows. Design
revision item 37, with sections 3, 4(c), 4(f), 4(g) and 7, the PR 10
paragraph, its maintenance-claim paragraph and transitions, the PR 14
lines (rows that are not DELETED, and DELETED records whose id carries
an instant) and section 10's row edited with it.

Not here: removing the in-catalog reader (#749); a 13-digit backup
carried into the store format; a Cassandra wire leg (direct tests only).

Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
Comment thread crates/app/src/cmd_backup.rs Dismissed
Comment thread crates/core/src/backup_format/layout.rs Dismissed
Comment thread crates/core/src/backup_format/layout.rs Dismissed
Comment thread crates/core/src/backup_format/layout.rs Dismissed
Comment thread crates/core/src/backup_format/layout.rs Dismissed
@robinnsc
robinnsc marked this pull request as ready for review November 9, 2026 07:02
@robinnsc robinnsc added release-blocker Must ship for its milestone's release v1 Needed for the 1.0 release labels Nov 9, 2026
@robinnsc robinnsc added this to the 1.0 milestone Nov 9, 2026

@LeeroyHannigan LeeroyHannigan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I built the head on SQLite, PostgreSQL, MongoDB, Cassandra and sqlite-memory and ran the servers and the commands in a container as uid 1000. I ran backup sync, --prune-missing and --adopt-store beside running servers on the four backends and against an S3 store. I ran the fence, the claim and the startup sync with two catalogs on one root, with killed, stopped and signalled commands. I merged main at 0cfb0d9 (no conflict). On the merge I ran clippy 1.97.0 with both CI feature sets and the 1.88.0 check of the feature-gated code. The engine, core, storage, config, app, SQLite and server suites pass on it. All 49 CI jobs at this head pass. I ran 49 mutants of sync.rs, maintenance.rs, runtime.rs, store_identity.rs, reconciler.rs, layout.rs, the SQLite catalog, the server's store config and cmd_backup.rs against the suites. That is 50 runs, with one mutant run against two suites. The suites killed 34 runs. Of the 16 that survived, one is equivalent and 14 are inline as test gaps. A PostgreSQL wire cell in CI asserts what the last one breaks, but I did not run that cell. I compared the wire answers with the service for the cursor shapes, another region's spelling of a backup ARN and CreateBackup.

What matched the description:

  • The two trait methods behave the same on the four backends within the trait doc, with Cassandra's equal-row exception.
  • Sync's report, its exit codes 0, 1, 3 and 6, and the serve lock on SQLite.
  • DescribeBackup and ListBackups of an adopted row equal the CreateBackup record field for field on the four backends and S3.
  • Restores of adopted rows reach ACTIVE with all items on the four backends and S3.
  • The maintenance claim and its heartbeat rule. The next command takes a killed sync's claim over at once on PostgreSQL, and after stale_job_after_secs on MongoDB and Cassandra.
  • A sync stopped past two periods loses its claim and stops before its next insert.
  • SIGTERM stops a sync before its next prefix and releases the claim, and a second command fails at once naming the holder.
  • --prune-missing removes the row and no object, the backup answers BackupNotFoundException after it, and the next sync re-adopts it once the manifest is back.
  • --adopt-store and the previous owner's fence hold on PostgreSQL, MongoDB, SQLite and Cassandra.
  • The fence refuses the previous owner's DeleteBackup before the row changes, the bytes stay, the store goes unhealthy and the gate then refuses CreateBackup. Main deletes the bytes under the other catalog's root for the same requests.
  • The in-memory startup sync, the ephemeral_catalog marker and the restart after SIGKILL serve the previous start's backups.
  • A CreateBackup job and a restore in flight across an adoption complete as described.
  • A second account cannot list, describe, restore or delete the first account's adopted backups.

The five CodeQL alerts point at assertion messages inside #[cfg(test)] in layout.rs, and at a stdout print in cmd_backup.rs of an account id. The printed ARN beside it already carries that id.

Each item is inline. One is Blocking: CreateBackup answers 500 on a store /health reports healthy, where main and the service answer 200. Nine are Major. Three are defects: the --adopt-store rewrite before the probe, the startup sync's log volume on a retry, and the other-region adoption. Six are properties the description claims that no test or CI job covers. Thirteen are Minor. Once the Blocking item and the nine Major items are fixed, with their tests, I will approve. The Minor items can land in the same revision.

.await
.map_err(|e| internal(&format!("the catalog id could not be read: {e}")))?;
if let Err(problem) = env.fence.check(&catalog_id).await {
return Err(env.fence.refusal().unwrap_or_else(|| {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: CreateBackup answers 500 here on a store /health reports healthy. The fence's check returns a problem it does not record, so refusal() is None and internal() answers. Two states reach it. With extenddb-store.json missing or at mode 000 (PostgreSQL, an S3 root and a filesystem root), CreateBackup answered 500 InternalServerError "Internal server error" twice each. Only after the next check did it answer 400 ValidationException "The filesystem backup store configured on this server is unhealthy (identity file missing)". A DeleteBackup of a 300,000-object prefix held the filesystem store's one call slot. A CreateBackup sent 1 s into it took 5015 ms to fail with 500. The file was readable the whole time, and the requests sent once the walk ended answered 200. Main answers 200 CREATING in both states: four CreateBackup requests sent during the same 5 s deletion took 5 to 32 ms and answered 200. The service answers CreateBackup of an ACTIVE table 200. The PR's cell a_fence_whose_read_fails_refuses_each_call_and_leaves_the_health (tests.rs line 21939) pins the 500. When the read finds no file or cannot read it, record the store's health as the next check would, and answer the unhealthy-store ValidationException. Read the file outside the deletions' call slot, or run this fence in the job right before its first put. Keep 500 for a read that failed in transport.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed as you proposed. StoreFence::check (crates/engine/src/backup/runtime.rs:888-951) now records on the store what the next check would record: FileMissingWithRows for a root with no file (:941), and the read's own problem for a file the store refuses to serve or that does not parse; only a read that failed in transport records nothing (:924). CreateBackup answers refusal(), the unhealthy-store ValidationException, for every recorded problem and keeps the 500 for transport (crates/engine/src/backup/job.rs:350-357): a missing file answers unhealthy (identity file missing), an S3 AccessDenied answers unhealthy (permission denied), and a filesystem file at mode 000 answers unhealthy (failed validation), because the filesystem store reports EACCES as an I/O error. I also did both of the other options: the fence reads in a call slot of its own (fence_slot and fence_in_flight, runtime.rs:311-312 and :648-649), so a deletion holding the root's call slot does not hold it, and the job runs the fence again right before its first put (job.rs:481-489). Tests in crates/engine/src/backup/tests.rs: create_backup_on_a_root_whose_identity_file_is_missing_or_denied_is_refused_as_unhealthy (:23017, the three states, then refused at the gate with no read), create_backup_during_a_long_deletion_answers_at_once (:23093, CREATING in zero time 1 s into a 4 s deletion), a_root_adopted_after_the_request_gets_no_object_of_the_job (:23139), and the cell that pinned the 500, renamed a_fence_whose_read_fails_in_transport_refuses_each_call_and_leaves_the_health (:22805), which now covers transport only.

problem,
};
let identity = compare(&reread, &catalog_id, row_ids, kind, root).map_err(&unhealthy)?;
manifest_probe(store, &identity, catalog)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The probe runs after the put at line 713. It reads only this catalog's newest AVAILABLE row and one head. The store id is the same before and after the put, so the probe gives the same answer before the rewrite. With one AVAILABLE row of this catalog whose manifest is gone, adopt answered AdoptedUnhealthy after its put rewrote the file to name this catalog. I also ran it with two catalogs on one root. A owned the root and made its newest backup, B adopted the root and B's server deleted that backup, and then A ran --adopt-store. A's command exited 1 with "rewrote the identity file, so the root is taken from its previous owner, but the sync did not run". A's plain sync and --prune-missing then refused (store unhealthy, manifest missing), and B's refused (owned by another catalog). Neither catalog could sync, prune or delete under the root until B adopted it back. Run compare and manifest_probe before the claim's re-check and the put, so adopt refuses a root this catalog cannot use with nothing written. No test names AdoptedUnhealthy or that sentence, and the engine suite passes (477 tests) with the probe call removed. Add that cell.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

adopt now runs compare and manifest_probe on the root as it will be once this catalog owns it, before the claim's re-check and the put (crates/engine/src/backup/store_identity.rs:721-726; the put is at :740). A root this catalog cannot use is refused as AdoptRefusal::Unusable with nothing written and the file still naming its owner, so AdoptedUnhealthy and the command's rewrote the identity file ... but the sync did not run branch are gone. adopt_store_refuses_a_root_this_catalog_cannot_use_with_nothing_written (crates/engine/src/backup/tests.rs:21168) asserts no put, the file unchanged and no audit intent line, and kills the mutant with the probe call removed. The wire cell test_adopt_store_checks_the_root_before_it_writes (tests/test_backup_job.py:2675) asserts exit 1 with the refusal, the file's bytes unchanged and the row still AVAILABLE, then the adoption and a plain sync once the manifest is back.

skipped.reason
);
} else {
tracing::info!(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

While a startup sync stays pending, each reconciler cycle runs it again and writes this line for each prefix of the root again. An ARN mismatch gets a second line from the WARN at line 632. On a 3,002-prefix root with one manifest at mode 000 and a 1 s cycle, 6 runs in 12 s wrote 36,053 log lines. 18,000 of them were WARN and 18,041 INFO, and the log had 0 new lines in the 5 s after I restored the mode. With the default 60 s cycle a root of N backups writes N lines per minute until the error clears. Log the per-prefix lines on the first run only, or at DEBUG on a retry (rearm knows the sync ran before). Keep the WARN for the transient skips and the end line's counts, and add a cell asserting the line count of a second run.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The lasting skips are now logged at INFO only until a run of the startup sync has reported (listed the whole root and written its line of counts), and at DEBUG after that (crates/engine/src/backup/sync.rs:1006 and :1040-1063); the transient skips stay at WARN and the line of counts stays at INFO. I keyed this on a flag set after the line of counts (record_startup_sync_report, sync.rs:1080; crates/engine/src/backup/runtime.rs:542-549) rather than on the first run, because a first run cut at its bound or ended by an error names no prefix, and its reasons would then never reach the log at INFO. The second WARN in adopt_prefix for an ARN mismatch or another partition is removed; the report carries the reason. a_second_startup_sync_run_logs_only_the_transient_skips_and_the_counts (crates/engine/src/backup/tests.rs:21637) asserts that the second run adds one INFO line (the counts), one WARN and three DEBUG lines, and a_startup_sync_whose_first_run_fails_logs_its_skips_at_info_on_the_next and a_startup_sync_whose_first_run_is_cut_at_its_bound_logs_its_skips_at_info_on_the_next (tests.rs:21833 and :21838) cover a first run that does not report.

}
let foreign = foreign_note(&arn, target.region);
match &foreign {
Some(note) => tracing::warn!(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sync adopts a manifest of another region under that region's ARN. I copied two prefixes of a us-west-2 server under a us-east-1 PostgreSQL server's root and synced them (exit 0, this WARN line twice). ListBackups and ListBackups with TableName on the us-east-1 server then listed both with us-west-2 BackupArns. DescribeBackup and RestoreTableFromBackup with the us-east-1 spelling answered 400 BackupNotFoundException "Backup not found: " twice each. The same server answered both spellings 200 for the backups it made, and main does the same for its own backups. The ListBackups answers I measured on the service all name the endpoint's region. The service also finds a backup under another region's spelling of its ARN: DescribeBackup answers 200 and echoes the spelling sent. Adopt the row under this server's region and let the restore accept a manifest that names another region's spelling of the row's ARN. Or skip such a manifest with a reason, as for another partition. State the result in docs/differences-from-dynamodb.md either way.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Sync now adopts such a manifest under this server's region: the row's ARN is the manifest's rebuilt with BackupArn::in_region (crates/engine/src/backup/sync.rs:681-684; crates/core/src/backup_format/layout.rs:503), and the manifest keeps its own spelling. The restore's two manifest checks and the dead-owner probe accept a manifest that differs from the row in the region alone (names_same_backup, layout.rs:493; crates/engine/src/backup/restore.rs:251 and :1345; crates/engine/src/backup/reconciler.rs:1464). The Where backups live row of docs/differences-from-dynamodb.md (line 127) states the result, including that DescribeBackup answers this server's spelling where the service echoes the spelling sent, and that the source table's ARN keeps the manifest's region. Covered by sync_adopts_a_backup_of_another_region_under_this_servers_region (crates/engine/src/backup/tests.rs:20246), the_dead_owner_probe_accepts_another_region_and_refuses_another_partition (:22312), a_row_over_a_manifest_of_another_region_restores (:20312), a resume case in a_resume_whose_store_documents_fail_verification_is_failed (:17818), and the wire cell test_sync_adopts_another_regions_prefix_under_this_servers_region (tests/test_backup_job.py:2595), which checks ListBackups with and without TableName, DescribeBackup under both spellings, and a restore and a delete by the us-west-2 spelling.

};
let mut listed = storage.list_backup_rows(None, &of_id);
while let Some(row) = listed.try_next().await? {
if row.status == BackupStatus::Available && row.store.store_id == identity.store_id {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No test covers the store id half of this filter. With && row.store.store_id == identity.store_id removed, AVAILABLE rows of the root under another store id become --prune-missing candidates. The engine suite still passes (477 tests). The other_id row in sync_reports_missing_manifests_and_rows_of_other_stores_and_changes_none has a complete prefix at tests.rs line 20403, so the filter is never reached. Give that row no manifest under the root and assert it is absent from missing_manifests. Then run prune_missing over the report and assert the row stays.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Extended sync_reports_missing_manifests_and_rows_of_other_stores_and_changes_none (crates/engine/src/backup/tests.rs:20577) with other_id_gone, an AVAILABLE row under store id store-0 with no prefix under the root. The cell asserts that missing_manifests is exactly the two rows of this store id (:20634), so other_id_gone is absent, then runs prune_missing over the report and asserts two outcomes with other_id_gone still AVAILABLE under store-0 (:20657-20673). It kills the mutant with && row.store.store_id == identity.store_id removed from the filter (crates/engine/src/backup/sync.rs:443).

row_ids: row_ids.clone(),
})?;
if file.catalog_id == catalog_id {
if row_ids.len() > 1 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Neither refusal of this first read has a test. With row_ids: Vec::new() at line 669 the engine suite passes (477 tests): the only cell on NoFile (tests.rs line 20777) asserts the fresh-root case. Under that change an operator whose root lost its file under rows is told "plain sync creates the file" for a root plain sync refuses. With the MultipleRowIds branch disabled, the suite passes too. adopt then answers AlreadyOwned, and the command records the store healthy and syncs a root the check holds unhealthy. Both branches need the file changed between the command's check and this read. Add a cell that removes the file under a row of the root before adopt and asserts NoFile { row_ids: [..] } and FileMissingWithRows. Add a second with rows under two store ids and a file naming this catalog, asserting MultipleRowIds with nothing written.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added adopt_stores_first_read_refuses_a_missing_file_under_rows_and_rows_of_two_ids (crates/engine/src/backup/tests.rs:21212). Its first case runs adopt on a root with no file under an AVAILABLE row of store-1 and asserts NoFile { row_ids: [store-1] }, FileMissingWithRows([store-1]) from into_problem, and nothing written. Its second case puts rows under store-1 and store-9 beside a file naming this catalog and asserts MultipleRowIds([store-1, store-9]) with no put and the file unchanged. The two cases kill the row_ids: Vec::new() mutant and the mutant with the MultipleRowIds branch disabled (crates/engine/src/backup/store_identity.rs:676-682).

Ok(outcome) => outcome,
Err(reason) => return (done, Some(reason)),
};
if outcome == PruneOutcome::Pruned {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No test asserts this audit line: with it removed the engine suite passes (477 tests), and no wire cell looks for it. The only audit assertion in tests/test_backup_job.py (line 2372) is the adoption's. Assert this line in the --prune-missing wire cell as the adoption cell does.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

test_prune_missing_and_the_unhealthy_store_refusals now asserts exactly one extenddb::audit line containing removed an AVAILABLE row whose manifest is not under the root, the pruned row's ARN and backup sync --prune-missing (tests/test_backup_job.py:1983-1987). In the engine, prune_missing_stops_after_a_removal_when_asked (crates/engine/src/backup/tests.rs:20911) counts the line once, which kills the mutant with the line at crates/engine/src/backup/sync.rs:906-914 removed.

return None;
}
let identity = store.identity().filter(|_| store.is_healthy())?;
if !store.take_startup_sync() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No test covers this take: running the sync whether or not take_startup_sync answered true passes the engine suite (477 tests). The insert-if-absent orders two concurrent runs on an in-memory catalog. The cost is a doubled listing and a report line saying a row landed after the read. Add a cell that takes the pending sync and asserts that a second startup_sync answers None with no listing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I removed the earlier startup_sync_pending() read, so take_startup_sync is the one gate (crates/engine/src/backup/sync.rs:997-1001; crates/engine/src/backup/runtime.rs:535-537); with that read in place the mutant could not be reached from a test. a_taken_startup_sync_is_not_run_by_a_second_caller (crates/engine/src/backup/tests.rs:21846) takes the pending sync, then asserts that startup_sync answers None with no listing of the root, no row read, no insert and the sync not handed back. It kills the mutant that runs the sync without the take.

period,
);
tokio::time::advance(3 * period).await;
while started.load(std::sync::atomic::Ordering::SeqCst) == 0 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This wait has no deadline. When the timer counts its ticks from the task's first run instead of the take (line 169), the resumed beat never starts. The cell then spins until an outside timeout stops it (300 s here, with the 476 other cells passed). In CI it would hold run-tests until the job's 15-minute bound instead of failing at an assertion. Bound the wait, with a yield count and an assertion or tokio::time::timeout, so the cell fails naming the beat that did not start.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The wait is now bounded at 1,000 yields, with an assertion naming the beat (crates/engine/src/backup/maintenance.rs:1218-1228). With the timer counting its ticks from the task's first run, a_beat_in_flight_confirms_the_claim_only_where_the_beat_parks (:1184) fails at parks false: the resumed beat did not start within 1000 yields in 0.01 s instead of spinning.

Comment thread crates/engine/src/backup/runtime.rs Outdated
/// write) and its deletion of the prefix; the reconciler's deletion of a
/// DELETING row's objects, its FAILED-prefix and DELETED-prefix duties and
/// its removal of tombstones; the backup job's failure path; and
/// CreateBackup's first put. `backup sync --prune-missing`'s removal of a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This doc and the admin guide (docs/manuals/05-admin-guide.md line 174, "writes a CreateBackup's first object") place the fence at CreateBackup's first put. The code runs it at the request, before claim_backup (job.rs lines 331 to 346), and the job's puts are not fenced. A CreateBackup job I paused before its manifest while another catalog adopted the root completed that manifest under the new owner's root. The new owner's next sync adopted it, as the description says of a put past the read. Say "before CreateBackup's row" here and in the guide, unless the fence moves into the job. Also, "right before each delete_prefix" is one read before two deletions in finish_backup_delete and purge_row (maintenance.rs lines 617 to 624).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I moved the fence into the job as well: CreateBackup reads the file at the request, before its row (crates/engine/src/backup/job.rs:338-357), and the job reads it again right before its first put (job.rs:481-489), a refusal failing the row with no object written. Puts after the first are not fenced; the StoreFence doc's last paragraph says the fence narrows the window and does not close it. The doc (crates/engine/src/backup/runtime.rs:770-782) and the admin guide (docs/manuals/05-admin-guide.md:174) now name both reads, and say that purge reads the file once before its row change and its two deletions rather than right before each delete_prefix; finish_backup_delete likewise runs one fence before its two deletions (crates/engine/src/backup.rs:1431-1455). a_root_adopted_after_the_request_gets_no_object_of_the_job (crates/engine/src/backup/tests.rs:23139) adopts the root between the two reads and asserts the row FAILED with the fence's reason, no put and no delete; it kills the mutant without the job's fence.

robinnsc added a commit that referenced this pull request Nov 10, 2026
`extenddb backup migrate-legacy <arn>` or `--all` (exactly one) copies
in-catalog backups into the configured store, each under its own ARN,
backup id, name and creation time, and replaces each by its catalog row,
AVAILABLE and unowned as a row `backup sync` inserts. `<arn>` is checked
as a backup ARN before anything is opened (exit 2) and resolved as
written and rebuilt in this server's region; `--all` takes every
in-catalog backup of every account that is not DELETED, oldest first,
goes on past one it does not migrate, and then, for every deleted one,
deletes what a stopped run left under its prefix and removes its
in-catalog record once the prefix is clean. The command needs the
configured store healthy, opens the catalog as `serve` does (on SQLite
under the serve lock, so the server is stopped for it), takes the
maintenance claim on every backend for its whole run and waits as
`purge` does, so every backup and restore job of the catalog answers
LimitExceededException until it ends.

Per backup, in this order: a backup id of the 13-digit shape a 0.1.x
release issued is refused before anything is read (every backup a 0.1.x
release took: a store prefix carries only the 14-digit shape); then a
table name the store-prefix rules refuse; a DELETING backup whose
replacement committed and stopped (MongoDB) is finished; a catalog row
of the id under either spelling of the ARN (the configured store's under
the stored spelling: an earlier run's, finished once its prefix reads
back as the copy is read, `verify_prefix`, the manifest's ARN and the
in-catalog backup's count, and refused naming the row, the in-catalog
backup kept, when the prefix is gone, short or another backup's; any
other: refused, since the prefix may be that row's), then under any ARN
and any account (`row_holding_backup_id`, refused); CREATING and
DELETING are skipped; a backup the backend cannot read (MongoDB's with
no item collection), a name the manifest refuses, what the legacy
restore refuses (a vector index, a multi-part key) and a SQLite backup
taken before catalog 0.0.4 are refused. Then the copy: objects under the
prefix with no row are an earlier run's when the manifest there, if any,
names this backup (one naming another refuses the backup with the prefix
left), and are deleted through the deletion fence first, by a walk of
the prefix that starts no removal more than two heartbeat periods after
the claim's last confirmed beat; the fence runs again before the first
put, and beside the claim's re-check before the manifest's put and
before the replacement, a root another catalog owns since stopping the
run with the copy and the in-catalog backup kept; the items stream
through the backup job's writer, each checked with the item validator
under this instance's limits; the manifest is written last with
`snapshot.kind` `legacy_in_catalog`, no marker, `unordered`, and
`source_backend` and `source_extenddb_version` naming this binary. The
prefix is read back through `verify_prefix`, sync's check, and the item
count is checked against the in-catalog backup's twice. A refused item,
a failed or short read or a copy that does not read back deletes the
copy (the objects this run wrote, one by one) through the fence and
keeps the in-catalog backup; after a failed or short read a backup no
longer AVAILABLE is skipped, "deleted during the copy", and one whose
status cannot be re-read fails, kept. An index row of the id met at the
replacement refuses it and keeps the copy. Before each step that cannot
be undone (the manifest's put, every deletion under a prefix, each
replacement call, a record's removal) the command re-checks the claim
with one beat, and a failure stops the run with what it wrote left, at a
copy's deletion too. A lost claim, a signal, an unhealthy store, a
failed listing or catalog call, and a replacement whose answer did not
come stop the run with exit 1; a signal is acted on before the next
backup and inside a copy at the writer's next step (the objects it
recorded deleted and named in a note), one received during a replacement
stops the run after it, and a second signal exits at once with 130,
leaving what a kill leaves. Exit 0 only when every backup reached is in
the store or deleted. One `extenddb::audit` line is logged per backup
the run deleted or replaced anything for, whatever its outcome (a
refused, skipped or failed backup whose earlier copy it deleted; every
Cassandra finish), none for one it left as it was; a stop says the claim
is released only when its release succeeded; a `refused` or `failed`
line says the in-catalog backup is kept only where it is (not once the
claim is lost); the summary counts migrated, finished, already in the
store, deleted, refused, skipped and failed apart.

Two runs can meet under one prefix: one stopped past the stale window
(SIGSTOP, a paused VM) whose claim a second run took. Every object of a
copy is therefore written with `put_if_absent`, and an object already
under a key stops the run with that object left (another run's, or this
run's own put that the SDK retried after a lost answer and S3 answered
412; the stop says both); after a put that failed, the key is read back
with `head` and the object found is recorded with the store's metadata,
and a key the head cannot read is left to the next run, since a deletion
without a condition could reach another run's object; a run deletes its
own copy object by object, only what its writer recorded, with the store
trait's new `delete_written` (S3 `DeleteObject` with `If-Match` on the
put's ETag, a 409 ConditionalRequestConflict re-sent as the conditional
put re-sends it; a filesystem root renames the file aside, removes it if
its size and modification time are the put's and links it back
otherwise), reading the claim before each; the prefix walk is kept for a
leftover no row holds. A call already sent when the process stopped
completes on resume: such a put cannot replace another run's object, and
such a deletion reaches another run's object at its key only on S3 when
the bytes are the same, or on a filesystem root stopped again between
the rename and the link back. With `put` and `delete_prefix`, a run
stopped at a data file's rename, or at its deletion's first removal, and
resumed after a second run migrated the backup changed the second run's
AVAILABLE copy; with this change both stops leave it byte for byte
(PostgreSQL and MongoDB, under gdb).

A backup DeleteBackup answered DELETED never comes back from a copy a
stopped run left. The replacement deletes the `backups` row on
PostgreSQL and SQLite, so a DELETED row there is always a
DeleteBackup's; MongoDB's replacement marks its document; Cassandra's
converts the row. `LegacyBackupReader::holder_status` answers for an
in-catalog backup in any status that the replacement did not replace,
and `list_deleted` lists the DELETED ones: `backup sync` never adopts a
prefix whose ARN such a backup holds, naming the command for a deleted
one, and `migrate-legacy` deletes that copy through the fence, lists the
prefix again and, once it is empty and no catalog row holds the id,
removes the DELETED record (`remove_deleted`), reporting the backup
deleted. So a DELETED record whose id has the 14-digit shape is a prefix
not yet confirmed clean, and PR 14's migration is to refuse while one
exists. A deleted backup whose ARN an AVAILABLE row holds again (a sync
of an earlier release adopted a stopped run's copy) is refused, naming
the row and the choice. Before a deletion under a prefix the command
looks the id up by `BackupCatalog::row_holding_backup_id(backup_id)`, by
the id alone: the index holds it unique, so the inserts conflict on it,
another account's row included; Cassandra, which indexes no backup id,
answers none and is covered by the manifest check. `finish_replacement`
finishes MongoDB's replacement that committed and stopped before its
drop, whatever the catalog row holds since.

The manifest's source table is the table the legacy restore creates
(`source_table_from_legacy`, pinned by an exhaustive property against
`restore_input` and `restore_input_from_manifest`), with the recorded
table class, TTL and stream and the SSE description of the recorded
specification, its key ARN built in the server's region as the legacy
DescribeBackup builds it, so the stored specification's `KMSMasterKeyId`
is not carried over; no answer reports a key id before or after. The
vector state is `[]` on PostgreSQL and SQLite and `null` on MongoDB and
Cassandra. Its index lists are the manifest's own `BackupGsi` and
`BackupLsi`, which record no GSI warm throughput.

The replacement is one step per backend,
`LegacyBackupReader::replace_with_index_row`, in place of an insert
followed by the reader's delete, which let a DeleteBackup between the
two answer DELETED for a backup that then came back, and which cannot
work on Cassandra, where both rows share one key. PostgreSQL: one
transaction (the row `FOR UPDATE`, the CREATING-target check, the
insert, then the items, the definition and the row deleted). SQLite: the
same under the write lock and `BEGIN IMMEDIATE`. MongoDB: one session
transaction that inserts the row and marks the document DELETING with
`migrated_to_store`, then the collection's drop and DELETED; a commit
whose answer is lost is settled by reading both back. Cassandra: the row
converted in place by a lightweight transaction `IF backup_status =
'AVAILABLE' AND store_kind = null AND active_restores = null`, then its
list and status entries and the item buckets' deletion; an unknown
outcome is settled by a serial read, a condition that failed at every
attempt with no answer lost wrote nothing and answers
`Replaced::NotApplied` (the backup fails, its copy deleted and the
in-catalog backup unchanged), and a second call finishes a conversion
that stopped after the row, writing entries only for a row still
AVAILABLE; a stop whose answer did not come names `migrate-legacy <arn>`
on Cassandra, since `--all` lists no converted row. Four frozen paths
change so a request orders wholly before or after a replacement:
PostgreSQL's delete reads the status under its row lock, MongoDB's reads
a `migrated_to_store` document as not found, and Cassandra's delete
claim and its legacy restore registration carry `store_kind = null`.
Only the DeleteBackup that changes an in-catalog backup answers DELETED
on every backend, every other one, concurrent or later,
BackupNotFoundException: PostgreSQL reads the status under the row lock,
SQLite again under the write lock, and MongoDB and Cassandra finish
another DeleteBackup's DELETING and answer not found (before, MongoDB
and Cassandra answered DELETED to two or three concurrent callers by
timing, and SQLite to both of two).

`LegacyBackupReader` also gains `items(account, arn)`, a frozen copy of
each restore's read as a stream (PostgreSQL a REPEATABLE READ catalog
transaction held for the copy, SQLite keyset batches each re-checking
AVAILABLE, MongoDB a cursor with `batch_size` and a frozen decode,
Cassandra each bucket paged 8 rows at a time at consistency ALL, since
the in-catalog create wrote at ONE: a replica that does not answer fails
the backup naming it, and a count the read does not reproduce names a
repair before the rerun, not the legacy restore, which reads at ONE),
and `list_all()`, which returns a row that does not decode as an error
(Cassandra's scan of `backups_by_arn` read in pages of 1,000 rows). The
core format gains `SnapshotDescriptor::KIND_LEGACY_IN_CATALOG`; no
format version or fixture byte changes. The writer runs under a
`ClaimWatch`, the job's heartbeat or the maintenance claim, whose loss
now cancels a token; the job's tests run unchanged. `HeldClaim` keeps
the job's rule #825 gave it (each beat bounded by one period, a claim
with no beat confirmed within two periods read as lost, a beat in flight
counted only on SQLite, where it parks on the process's own write lock)
and adds one restriction: a parked beat counts only when it started on
schedule, within 1.1 periods of the last confirmed one, so a process
stopped and resumed is not confirmed by its late beat; it also dates
each confirmation at its answer and gives `acting_deadline()`, two
periods after it, and `lost()` reads the last confirmation and the beat
in flight under one lock, which the timer writes as one at a beat's end,
so a parked beat that confirms is never read lost as it ends.
PostgreSQL's maintenance take now refuses while the claim row's
heartbeat is younger than `stale_job_after_secs`, even with the advisory
lock free, as the other backends' take does: a holder whose catalog
session ended runs until its next beat finds the loss. So on PostgreSQL,
MongoDB and Cassandra a holder starts no call more than two periods
after the answer of its last confirmed beat, which wrote the heartbeat
at most one period before that answer, and a taker needs a heartbeat at
least three periods old (on SQLite the serve lock keeps others out);
after a killed command on PostgreSQL the next one fails, naming the
holder, until the window passes. Purge, sync and this command take the
claim through one shared function, and on SQLite the dead-owner pass
logs its removal of a legacy backup left CREATING under
`extenddb::audit`. The replacement answers AlreadyExists for a row of
the id under another ARN or store whatever the in-catalog backup's
status.

`backup sync` skips a prefix whose ARN an in-catalog backup holds,
under either spelling, naming `migrate-legacy <arn>`. DescribeBackup,
DeleteBackup and RestoreTableFromBackup read the catalog row once more
after a legacy not-found (RestoreTableFromBackup also after its
registration's), and ListBackups reads the legacy reader first, so a
replacement between the two reads never answers BackupNotFound or drops
the backup from a page; ListBackups lists an ARN both readers hold
once, with the row's summary.

Tests: per-backend cells for the stream (batch and bucket boundaries, a
pinned snapshot, a delete ending it, the AVAILABLE re-check inside
PostgreSQL's snapshot), `list_all` and `list_deleted` across accounts,
`holder_status`, `remove_deleted` and `row_holding_backup_id`, the
replacement's answers, a real replacement and a DeleteBackup queued on
each other in both orders (PostgreSQL), PostgreSQL's take of a young
dead claim refused, MongoDB's finish and lost commit answer, Cassandra's
lost answer and stop and the item read's consistency and replica error,
SQLite's maintenance beat parked on the write lock, the store's
`delete_written` on a filesystem root and on S3, a legacy restore
registering after a conversion, a registration between the conversion's
read and its transaction, a conversion finished after a delete, a
conversion that never applies, the paged listing, and two concurrent
DeleteBackups of one in-catalog backup on SQLite, MongoDB and Cassandra;
57 engine cells on `Fake` and `MemStore` (each refusal, an object
another run wrote, a failed copy deleting only its own objects, a loss
read during that deletion, the leftover walk's deadline, the fence at
each deletion and beside the re-checks before the manifest and the
replacement, a finish whose row's prefix is gone, short or another
backup's, a status re-read that fails, a loss recorded mid-walk, a put
whose answer was lost, a conversion that did not apply, the SSE key
ARN's region, the counts, a store serving another manifest, every rerun
rule, a deleted backup's copy and record, a row of the id under any ARN,
a claim taken over before each step that cannot be undone, a stalled
heartbeat, signals during a copy and a replacement, requests racing a
replacement, the sync skip, the dedupe) and ten of `HeldClaim` (four of
them #825's, extended here; the 1.1-period window on a paused clock;
parked beats confirming back to back read by a thread without pause; a
late beat in flight confirms nothing where it does not park, and #825's
in-flight cell now finds a parked beat that starts late confirming
nothing); wire cells on SQLite, PostgreSQL and MongoDB (two in-catalog
backups migrated, described, listed, restored and deleted under their
ARNs with the in-catalog rows gone; `--all` past a #618 backup and a
13-digit one, both kept; a stopped migration's copies of two deleted
backups, not adopted and then deleted; the claim against a second
command, on PostgreSQL and on MongoDB beside a live holder, and the
serve lock); SIGKILL at the three kill points on SQLite and PostgreSQL,
each rerun finishing, and a kill or a refused deletion followed by
DeleteBackup, the backup staying deleted (PostgreSQL on S3 too); a first
run whose claim a second took over (PostgreSQL after its session ended,
MongoDB stopped past the window and resumed with the catalog paused),
the second's backup left whole; a deleted backup's record removed only
once its prefix is clean (SQLite). 190 mutants of the new tests, all
killed. Peak RSS 48 to 56 MiB flat on PostgreSQL (75 at most measured
beside a server); on SQLite 55 to 149 MiB, its page cache, bounded by 32
MB times `pool_size`.

Docs: the backup manual's legacy section (the command, its cost, what it
refuses and the way out, what a migrated backup reports, a stopped run
and a deleted backup's copy and record, MongoDB's and Cassandra's
stopped replacements, Cassandra's recurring step, memory); the upgrade
manual's store, 0.1.x and rollback paragraphs and `--all` after the last
DeleteBackup; the admin guide's paragraph, sample output and
`stale_job_after_secs` row; the differences doc's two rows and a row for
concurrent DeleteBackups of one backup. Design revision item 37, with
sections 3, 4(c), 4(f), 4(g) and 7, the PR 10 paragraph, its
maintenance-claim paragraph and transitions, the PR 14 lines (rows that
are not DELETED, and DELETED records whose id has the 14-digit shape)
and section 10's row edited with it.

Not here: removing the in-catalog reader (#749); a 13-digit backup
carried into the store format; a Cassandra wire leg (direct tests only).

Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>

@LeeroyHannigan LeeroyHannigan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Round 2, at 84bc263. I checked the 23 items of my round-1 review against this head, and this head fixes all 23. Item 8's MongoDB sync cells now run through mongosh in the CI job's container, and the PR names the Cassandra gap. All 64 checks pass at this head, the matrix jobs included. I ran cargo test -p extenddb-engine --lib backup:: (335 passed), the core layout tests (17 passed) and the app's cmd_backup tests (4 passed).

The changes since round 1 bring four new items: one Major and three Minor. Each one is in code or text that this push changed.

  1. Major: docs/design/15-backup-restore.md line 595, and the same sentence in docs/manuals/05-admin-guide.md line 143 (the path row). The design describes a startup sync abandoned at its bound. It says the walk "runs to its end on the blocking pool, holding the listing it builds, and the next cycle starts another walk". At this head the walk holds the root's call slot and is marked in flight (runtime.rs 697 and 1139). The next cycle's check fails at once with "a call of an earlier check has not returned" (runtime.rs 1004 to 1011). That check records the store unreachable. So startup_sync does not run (sync.rs 995), and no second walk starts. The cell a_cycles_startup_sync_cut_at_its_bound_leaves_its_walk_in_flight (tests.rs 21447) asserts this. Neither document says that the store stays unhealthy for the rest of an abandoned walk, with CreateBackup, DeleteBackup and restores refused. Fix: state the behaviour and its consequence in both places, or re-arm the walk after a cut run.

  2. Minor: sync.rs line 1001. first_run is startup_sync_takes() == 1. A first run cut at the bound logs no per-prefix line, and the second run logs the lasting skips at DEBUG. So an operator never sees at INFO which prefixes the sync skips for good. Fix: key the INFO pass on whether a completed run logged the lines. Add a cell where the first run is cut and the second one completes.

  3. Minor: runtime.rs lines 1004 to 1011, 1198 to 1203 and 1296 to 1301, and sync.rs line 1006.

    • The refusal text "a call of an earlier check has not returned" is the same whatever set the in-flight mark. After an abandoned startup sync it names a check that did not exist. Fix: record which caller's call is in flight, and name it.
    • On an S3 store BoundedCalls::list collects the whole root listing before its first key (try_collect), so the startup sync holds every ObjectMeta of the root in memory. At b42dbfa it streamed the S3 listing. Fix: state this in the STARTUP_SYNC_TIMEOUT doc, or stream the listing page by page under the bound.
  4. Minor: store_identity.rs lines 684 to 687. On the AlreadyOwned path the probe's error maps to AdoptRefusal::Problem without at_step. A store that stops answering during that probe ends --adopt-store with "the adoption's store call did not finish within 5 s" and no step. The other store calls of the adoption name their step, and the commit message says this one does too. Fix: map it through at_step("the manifest probe of", ...), and add the case to adopt_store_on_a_store_that_stops_answering_names_the_step.

robinnsc added a commit that referenced this pull request Nov 10, 2026
`extenddb backup migrate-legacy <arn>` or `--all` (exactly one) copies
in-catalog backups into the configured store, each under its own ARN,
backup id, name and creation time, and replaces each by its catalog row,
AVAILABLE and unowned as a row `backup sync` inserts. `<arn>` is checked
as a backup ARN before anything is opened (exit 2) and resolved as
written and rebuilt in this server's region; `--all` takes every
in-catalog backup of every account that is not DELETED, oldest first,
goes on past one it does not migrate, and then, for every deleted one,
deletes what a stopped run left under its prefix and removes its
in-catalog record once the prefix is clean. The command needs the
configured store healthy, opens the catalog as `serve` does (on SQLite
under the serve lock, so the server is stopped for it), takes the
maintenance claim on every backend for its whole run and waits as
`purge` does, so every backup and restore job of the catalog answers
LimitExceededException until it ends.

Per backup, in this order: a backup id of the 13-digit shape a 0.1.x
release issued is refused before anything is read (every backup a 0.1.x
release took: a store prefix carries only the 14-digit shape); then a
table name the store-prefix rules refuse; a DELETING backup whose
replacement committed and stopped (MongoDB) is finished; a catalog row
of the id under either spelling of the ARN (the configured store's under
the stored spelling: an earlier run's, finished once its prefix reads
back as the copy is read, `verify_prefix`, the manifest's ARN and the
in-catalog backup's count, and refused naming the row, the in-catalog
backup kept, when the prefix is gone, short or another backup's; any
other: refused, since the prefix may be that row's), then under any ARN
and any account (`row_holding_backup_id`, refused); CREATING and
DELETING are skipped; a backup the backend cannot read (MongoDB's with
no item collection), a name the manifest refuses, what the legacy
restore refuses (a vector index, a multi-part key) and a SQLite backup
taken before catalog 0.0.4 are refused. Then the copy: objects under the
prefix with no row are an earlier run's when the manifest there, if any,
names this backup (one naming another refuses the backup with the prefix
left), and are deleted through the deletion fence first, by a walk of
the prefix that starts no removal more than two heartbeat periods after
the claim's last confirmed beat; the fence runs again before the first
put, and beside the claim's re-check before the manifest's put and
before the replacement, a root another catalog owns since stopping the
run with the copy and the in-catalog backup kept; the items stream
through the backup job's writer, each checked with the item validator
under this instance's limits; the manifest is written last with
`snapshot.kind` `legacy_in_catalog`, no marker, `unordered`, and
`source_backend` and `source_extenddb_version` naming this binary. The
prefix is read back through `verify_prefix`, sync's check, and the item
count is checked against the in-catalog backup's twice. A refused item,
a failed or short read or a copy that does not read back deletes the
copy (the objects this run wrote, one by one) through the fence and
keeps the in-catalog backup; after a failed or short read a backup no
longer AVAILABLE is skipped, "deleted during the copy", and one whose
status cannot be re-read fails, kept. An index row of the id met at the
replacement refuses it and keeps the copy. Before each step that cannot
be undone (the manifest's put, every deletion under a prefix, each
replacement call, a record's removal) the command re-checks the claim
with one beat, and a failure stops the run with what it wrote left, at a
copy's deletion too. A lost claim, a signal, an unhealthy store, a
failed listing or catalog call, and a replacement whose answer did not
come stop the run with exit 1; a signal is acted on before the next
backup and inside a copy at the writer's next step (the objects it
recorded deleted and named in a note), one received during a replacement
stops the run after it, and a second signal exits at once with 130,
leaving what a kill leaves. Exit 0 only when every backup reached is in
the store or deleted. One `extenddb::audit` line is logged per backup
the run deleted or replaced anything for, whatever its outcome (a
refused, skipped or failed backup whose earlier copy it deleted; every
Cassandra finish), none for one it left as it was (a finish beside an
in-catalog backup the replacement leaves, CREATING or, outside MongoDB,
DELETING, writes nothing); a stop says the claim is released only when
its release succeeded, and after a lost claim that the run no longer
holds it; a fence stop names the backup and says its in-catalog backup
is kept; a `refused` or `failed` line says the in-catalog backup is kept
only where it is (not once the claim is lost); the summary counts
migrated, finished, already in the store, deleted, refused, skipped and
failed apart.

Two runs can meet under one prefix: one stopped past the stale window
(SIGSTOP, a paused VM) whose claim a second run took. Every object of a
copy is therefore written with `put_if_absent`, and an object already
under a key stops the run with that object left (another run's, or this
run's own put that the SDK retried after a lost answer and S3 answered
412; the stop says both); after a put that failed, the key is read back
with `head` and the object found is recorded with the store's metadata,
and a key the head cannot read is left to the next run, since a deletion
without a condition could reach another run's object; a run deletes its
own copy object by object, only what its writer recorded, with the store
trait's new `delete_written` (S3 `DeleteObject` with `If-Match` on the
put's ETag, a 409 ConditionalRequestConflict re-sent as the conditional
put re-sends it; a filesystem root renames the file aside, removes it if
its size and modification time are the put's and links it back
otherwise; the trait's default, for a test double with neither, deletes
the key only when nothing is under `key/`), reading the claim before
each; the prefix walk is kept for a leftover no row holds. A call
already sent when the process stopped completes on resume: such a put
cannot replace another run's object, and such a deletion reaches another
run's object at its key only on S3 when the bytes are the same, or on a
filesystem root stopped again between the rename and the link back. With
`put` and `delete_prefix`, a run stopped at a data file's rename, or at
its deletion's first removal, and resumed after a second run migrated
the backup changed the second run's AVAILABLE copy; with this change
both stops leave it byte for byte (PostgreSQL and MongoDB, under gdb).

A backup DeleteBackup answered DELETED never comes back from a copy a
stopped run left. The replacement deletes the `backups` row on
PostgreSQL and SQLite, so a DELETED row there is always a
DeleteBackup's; MongoDB's replacement marks its document; Cassandra's
converts the row. `LegacyBackupReader::holder_status` answers for an
in-catalog backup in any status that the replacement did not replace,
and `list_deleted` lists the DELETED ones: `backup sync` never adopts a
prefix whose ARN such a backup holds, naming the command for a deleted
one, and `migrate-legacy` deletes that copy through the fence, lists the
prefix again and, once it is empty and no catalog row holds the id,
removes the DELETED record (`remove_deleted`), reporting the backup
deleted. So a DELETED record whose id has the 14-digit shape is a prefix
not yet confirmed clean, and PR 14's migration is to refuse while one
exists. A deleted backup whose ARN an AVAILABLE row holds again (a sync
of an earlier release adopted a stopped run's copy) is refused, naming
the row and the choice. Before a deletion under a prefix the command
looks the id up by `BackupCatalog::row_holding_backup_id(backup_id)`, by
the id alone: the index holds it unique, so the inserts conflict on it,
another account's row included; Cassandra, which indexes no backup id,
answers none and is covered by the manifest check. `finish_replacement`
finishes MongoDB's replacement that committed and stopped before its
drop, whatever the catalog row holds since.

The manifest's source table is the table the legacy restore creates
(`source_table_from_legacy`, pinned by an exhaustive property against
`restore_input` and `restore_input_from_manifest`), with the recorded
table class, TTL and stream and the SSE description of the recorded
specification, its key ARN built in the server's region as the legacy
DescribeBackup builds it, so the stored specification's `KMSMasterKeyId`
is not carried over; no answer reports a key id before or after. The
table's and each GSI's throughput are the ones the backup recorded, none
on demand: a GSI of a provisioned table that recorded none or 0/0 (a
table created on demand and switched with UpdateTable) keeps that, and a
backup with no definition and no recorded throughput records none, so
DescribeBackup reports the same throughput before and after the
migration; a restore of the copy gives such a GSI the table's throughput
and such a table 5/5, as the legacy restore does
(`restore_input_from_manifest` reads a provisioned `source_table` with
no throughput as 5/5; only such a copy has one). DescribeBackup then
differs only in `BackupSizeBytes`, `TableSizeBytes` and, for a source
that recorded no index, stream, TTL or SSE, `SourceTableFeatureDetails`,
which the legacy answer omits and the row's reports as `{}`, as for a
store-backed backup. The vector state is `[]` on PostgreSQL and SQLite
and `null` on MongoDB and Cassandra. Its index lists are the manifest's
own `BackupGsi` and `BackupLsi`, which record no GSI warm throughput.

The replacement is one step per backend,
`LegacyBackupReader::replace_with_index_row`, in place of an insert
followed by the reader's delete, which let a DeleteBackup between the
two answer DELETED for a backup that then came back, and which cannot
work on Cassandra, where both rows share one key. PostgreSQL: one
transaction (the row `FOR UPDATE`, the CREATING-target check, the
insert, then the items, the definition and the row deleted). SQLite: the
same under the write lock and `BEGIN IMMEDIATE`. MongoDB: one session
transaction that inserts the row and marks the document DELETING with
`migrated_to_store`, then the collection's drop and DELETED; a commit
whose answer is lost is settled by reading both back. Cassandra: the row
converted in place by a lightweight transaction `IF backup_status =
'AVAILABLE' AND store_kind = null AND active_restores = null`, then its
list and status entries and the item buckets' deletion; an unknown
outcome is settled by a serial read, a condition that failed at every
attempt with no answer lost wrote nothing and answers
`Replaced::NotApplied` (the backup fails, its copy deleted and the
in-catalog backup unchanged), and a second call finishes a conversion
that stopped after the row, writing entries only for a row still
AVAILABLE; a stop whose answer did not come names `migrate-legacy <arn>`
on Cassandra, since `--all` lists no converted row. Four frozen paths
change so a request orders wholly before or after a replacement:
PostgreSQL's delete reads the status under its row lock, MongoDB's reads
a `migrated_to_store` document as not found, and Cassandra's delete
claim and its legacy restore registration carry `store_kind = null`.
Only the DeleteBackup that changes an in-catalog backup answers DELETED
on every backend, every other one, concurrent or later,
BackupNotFoundException: PostgreSQL reads the status under the row lock,
SQLite again under the write lock, and MongoDB and Cassandra finish
another DeleteBackup's DELETING and answer not found (before, MongoDB
and Cassandra answered DELETED to two or three concurrent callers by
timing, and SQLite to both of two).

`LegacyBackupReader` also gains `items(account, arn)`, a frozen copy of
each restore's read as a stream (PostgreSQL a REPEATABLE READ catalog
transaction held for the copy, SQLite keyset batches each re-checking
AVAILABLE, MongoDB a cursor with `batch_size` and a frozen decode,
Cassandra each bucket paged 8 rows at a time at consistency ALL, since
the in-catalog create wrote at ONE: a replica that does not answer fails
the backup naming it, and a count the read does not reproduce names a
repair before the rerun, not the legacy restore, which reads at ONE),
and `list_all()`, which returns a row that does not decode as an error
(Cassandra's scan of `backups_by_arn` read in pages of 1,000 rows). The
core format gains `SnapshotDescriptor::KIND_LEGACY_IN_CATALOG`; no
format version or fixture byte changes. The writer runs under a
`ClaimWatch`, the job's heartbeat or the maintenance claim, whose loss
now cancels a token; the job's tests run unchanged. `HeldClaim` keeps
the job's rule #825 gave it (each beat bounded by one period, a claim
with no beat confirmed within two periods read as lost, a beat in flight
counted only on SQLite, where it parks on the process's own write lock)
and adds one restriction: a parked beat counts only when it started on
schedule, within 1.1 periods of the last confirmed one, so a process
stopped and resumed is not confirmed by its late beat; it also dates
each confirmation at its answer and gives `acting_deadline()`, two
periods after it, and `lost()` reads the last confirmation and the beat
in flight under one lock, which the timer writes as one at a beat's end,
so a parked beat that confirms is never read lost as it ends.
PostgreSQL's maintenance take now refuses while the claim row's
heartbeat is younger than `stale_job_after_secs`, even with the advisory
lock free, as the other backends' take does: a holder whose catalog
session ended runs until its next beat finds the loss. So on PostgreSQL,
MongoDB and Cassandra a holder starts no call more than two periods
after the answer of its last confirmed beat, which wrote the heartbeat
at most one period before that answer, and a taker needs a heartbeat at
least three periods old (on SQLite the serve lock keeps others out);
after a killed command on PostgreSQL the next one fails, naming the
holder, until the window passes. Purge, sync and this command take the
claim through one shared function, and on SQLite the dead-owner pass
logs its removal of a legacy backup left CREATING under
`extenddb::audit`. The replacement answers AlreadyExists for a row of
the id under another ARN or store whatever the in-catalog backup's
status.

`backup sync` skips a complete prefix whose manifest's ARN an in-catalog
backup holds, under either spelling, naming `migrate-legacy <arn>` (a
prefix a run left before its manifest is reported incomplete, as any
other). DescribeBackup, DeleteBackup and RestoreTableFromBackup read the
catalog row once more after a legacy not-found (RestoreTableFromBackup
also after its registration's), and ListBackups reads the legacy reader
first, so a replacement between the two reads never answers
BackupNotFound or drops the backup from a page; ListBackups lists an ARN
both readers hold once, with the row's summary.

Tests: per-backend cells for the stream (batch and bucket boundaries, a
pinned snapshot, a delete ending it, the AVAILABLE re-check inside
PostgreSQL's snapshot), `list_all` and `list_deleted` across accounts,
`holder_status`, `remove_deleted` and `row_holding_backup_id`, the
replacement's answers, a real replacement and a DeleteBackup queued on
each other in both orders (PostgreSQL), PostgreSQL's take of a young
dead claim refused, MongoDB's finish and lost commit answer, Cassandra's
lost answer and stop and the item read's consistency and replica error,
SQLite's maintenance beat parked on the write lock, the copy's recorded
throughput and the 5/5 restore of a provisioned copy that recorded none,
the store's `delete_written` on a filesystem root and on S3, a legacy
restore registering after a conversion, a registration between the
conversion's read and its transaction, a conversion finished after a
delete, a conversion that never applies, the paged listing, and two
concurrent DeleteBackups of one in-catalog backup on SQLite, MongoDB and
Cassandra; 60 engine cells on `Fake` and `MemStore` (each refusal,
DescribeBackup before and after a migration for eight shapes of
definition and throughput, a finish beside an in-catalog backup it
leaves, the store trait's default `delete_written`, an object another
run wrote, a failed copy deleting only its own objects, a loss read
during that deletion, the leftover walk's deadline, the fence at each
deletion and beside the re-checks before the manifest and the
replacement, a finish whose row's prefix is gone, short or another
backup's, a status re-read that fails, a loss recorded mid-walk, a put
whose answer was lost, a conversion that did not apply, the SSE key
ARN's region, the counts, a store serving another manifest, every rerun
rule, a deleted backup's copy and record, a row of the id under any ARN,
a claim taken over before each step that cannot be undone, a stalled
heartbeat, signals during a copy and a replacement, requests racing a
replacement, the sync skip, the dedupe) and ten of `HeldClaim` (four of
them #825's, extended here; the 1.1-period window on a paused clock;
parked beats confirming back to back read by a thread without pause; a
late beat in flight confirms nothing where it does not park, and #825's
in-flight cell now finds a parked beat that starts late confirming
nothing); wire cells on SQLite, PostgreSQL and MongoDB (two in-catalog
backups migrated, described, listed, restored and deleted under their
ARNs with the in-catalog rows gone; `--all` past a #618 backup and a
13-digit one, both kept; a stopped migration's copies of two deleted
backups, not adopted and then deleted; the claim against a second
command, on PostgreSQL and on MongoDB beside a live holder, and the
serve lock); SIGKILL at the three kill points on SQLite and PostgreSQL,
each rerun finishing, and a kill or a refused deletion followed by
DeleteBackup, the backup staying deleted (PostgreSQL on S3 too); a first
run whose claim a second took over (PostgreSQL after its session ended,
MongoDB stopped past the window and resumed with the catalog paused),
the second's backup left whole; a deleted backup's record removed only
once its prefix is clean (SQLite). 201 mutants of the new tests, all
killed. Peak RSS 48 to 56 MiB flat on PostgreSQL (75 at most measured
beside a server); on SQLite 55 to 149 MiB, its page cache, bounded by 32
MB times `pool_size`.

Docs: the backup manual's legacy section (the command, its cost, what it
refuses and the way out, what a migrated backup reports, a stopped run
and a deleted backup's copy and record, MongoDB's and Cassandra's
stopped replacements, Cassandra's recurring step, memory); the upgrade
manual's store, 0.1.x and rollback paragraphs and `--all` after the last
DeleteBackup; the admin guide's paragraph, sample output and
`stale_job_after_secs` row; the differences doc's two rows and a row for
concurrent DeleteBackups of one backup. Design revision item 37, with
sections 3, 4(c), 4(f), 4(g) and 7, the PR 10 paragraph, its
maintenance-claim paragraph and transitions, the PR 14 lines (rows that
are not DELETED, and DELETED records whose id has the 14-digit shape)
and section 10's row edited with it.

Not here: removing the in-catalog reader (#749); a 13-digit backup
carried into the store format; a Cassandra wire leg (direct tests only).

Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Nov 10, 2026
`extenddb backup migrate-legacy <arn>` or `--all` (exactly one) copies
in-catalog backups into the configured store, each under its own ARN,
backup id, name and creation time, and replaces each by its catalog row,
AVAILABLE and unowned as a row `backup sync` inserts. `<arn>` is checked
as a backup ARN before anything is opened (exit 2) and resolved as
written and rebuilt in this server's region; `--all` takes every
in-catalog backup of every account that is not DELETED, oldest first,
goes on past one it does not migrate, and then, for every deleted one,
deletes what a stopped run left under its prefix and removes its
in-catalog record once the prefix is clean. The command needs the
configured store healthy, opens the catalog as `serve` does (on SQLite
under the serve lock, so the server is stopped for it), takes the
maintenance claim on every backend for its whole run and waits as
`purge` does, so every backup and restore job of the catalog answers
LimitExceededException until it ends.

Per backup, in this order: a backup id of the 13-digit shape a 0.1.x
release issued is refused before anything is read (every backup a 0.1.x
release took: a store prefix carries only the 14-digit shape); then a
table name the store-prefix rules refuse; a DELETING backup whose
replacement committed and stopped (MongoDB) is finished; a catalog row
of the id under either spelling of the ARN (the configured store's under
the stored spelling: an earlier run's, finished once its prefix reads
back as the copy is read, `verify_prefix`, the manifest's ARN and the
in-catalog backup's count, and refused naming the row, the in-catalog
backup kept, when the prefix is gone, short or another backup's; any
other: refused, since the prefix may be that row's), then under any ARN
and any account (`row_holding_backup_id`, refused); CREATING and
DELETING are skipped; a backup the backend cannot read (MongoDB's with
no item collection), a name the manifest refuses, what the legacy
restore refuses (a vector index, a multi-part key) and a SQLite backup
taken before catalog 0.0.4 are refused. Then the copy: objects under the
prefix with no row are an earlier run's when the manifest there, if any,
names this backup (one naming another refuses the backup with the prefix
left), and are deleted through the deletion fence first, by a walk of
the prefix that starts no removal more than two heartbeat periods after
the claim's last confirmed beat; the fence runs again before the first
put, and beside the claim's re-check before the manifest's put and
before the replacement, a root another catalog owns since stopping the
run with the copy and the in-catalog backup kept; the items stream
through the backup job's writer, each checked with the item validator
under this instance's limits; the manifest is written last with
`snapshot.kind` `legacy_in_catalog`, no marker, `unordered`, and
`source_backend` and `source_extenddb_version` naming this binary. The
prefix is read back through `verify_prefix`, sync's check, and the item
count is checked against the in-catalog backup's twice. A refused item,
a failed or short read or a copy that does not read back deletes the
copy (the objects this run wrote, one by one) through the fence and
keeps the in-catalog backup; after a failed or short read a backup no
longer AVAILABLE is skipped, "deleted during the copy", and one whose
status cannot be re-read fails, kept. An index row of the id met at the
replacement refuses it and keeps the copy. Before each step that cannot
be undone (the manifest's put, every deletion under a prefix, each
replacement call, a record's removal) the command re-checks the claim
with one beat, and a failure stops the run with what it wrote left, at a
copy's deletion too. A lost claim, a signal, an unhealthy store, a
failed listing or catalog call, and a replacement whose answer did not
come stop the run with exit 1; a signal is acted on before the next
backup and inside a copy at the writer's next step (the objects it
recorded deleted and named in a note), one received during a replacement
stops the run after it, and a second signal exits at once with 130,
leaving what a kill leaves. Exit 0 only when every backup reached is in
the store or deleted. One `extenddb::audit` line is logged per backup
the run deleted or replaced anything for, whatever its outcome (a
refused, skipped or failed backup whose earlier copy it deleted; every
Cassandra finish), none for one it left as it was (a finish beside an
in-catalog backup the replacement leaves, CREATING or, outside MongoDB,
DELETING, writes nothing); a stop says the claim is released only when
its release succeeded, and after a lost claim that the run no longer
holds it; a fence stop names the backup and says its in-catalog backup
is kept; a `refused` or `failed` line says the in-catalog backup is kept
only where it is (not once the claim is lost); the summary counts
migrated, finished, already in the store, deleted, refused, skipped and
failed apart.

Two runs can meet under one prefix: one stopped past the stale window
(SIGSTOP, a paused VM) whose claim a second run took. Every object of a
copy is therefore written with `put_if_absent`, and an object already
under a key stops the run with that object left (another run's, or this
run's own put that the SDK retried after a lost answer and S3 answered
412; the stop says both); after a put that failed, the key is read back
with `head` and the object found is recorded with the store's metadata,
and a key the head cannot read is left to the next run, since a deletion
without a condition could reach another run's object; a run deletes its
own copy object by object, only what its writer recorded, with the store
trait's new `delete_written` (S3 `DeleteObject` with `If-Match` on the
put's ETag, a 409 ConditionalRequestConflict re-sent as the conditional
put re-sends it; a filesystem root renames the file aside, removes it if
its size and modification time are the put's and links it back
otherwise; the trait's default, for a test double with neither, deletes
the key only when nothing is under `key/`), reading the claim before
each; the prefix walk is kept for a leftover no row holds. A call
already sent when the process stopped completes on resume: such a put
cannot replace another run's object, and such a deletion reaches another
run's object at its key only on S3 when the bytes are the same, or on a
filesystem root stopped again between the rename and the link back. With
`put` and `delete_prefix`, a run stopped at a data file's rename, or at
its deletion's first removal, and resumed after a second run migrated
the backup changed the second run's AVAILABLE copy; with this change
both stops leave it byte for byte (PostgreSQL and MongoDB, under gdb).

A backup DeleteBackup answered DELETED never comes back from a copy a
stopped run left. The replacement deletes the `backups` row on
PostgreSQL and SQLite, so a DELETED row there is always a
DeleteBackup's; MongoDB's replacement marks its document; Cassandra's
converts the row. `LegacyBackupReader::holder_status` answers for an
in-catalog backup in any status that the replacement did not replace,
and `list_deleted` lists the DELETED ones: `backup sync` never adopts a
prefix whose ARN such a backup holds, naming the command for a deleted
one, and `migrate-legacy` deletes that copy through the fence, lists the
prefix again and, once it is empty and no catalog row holds the id,
removes the DELETED record (`remove_deleted`), reporting the backup
deleted. So a DELETED record whose id has the 14-digit shape is a prefix
not yet confirmed clean, and PR 14's migration is to refuse while one
exists. A deleted backup whose ARN an AVAILABLE row holds again (a sync
of an earlier release adopted a stopped run's copy) is refused, naming
the row and the choice. Before a deletion under a prefix the command
looks the id up by `BackupCatalog::row_holding_backup_id(backup_id)`, by
the id alone: the index holds it unique, so the inserts conflict on it,
another account's row included; Cassandra, which indexes no backup id,
answers none and is covered by the manifest check. `finish_replacement`
finishes MongoDB's replacement that committed and stopped before its
drop, whatever the catalog row holds since.

The manifest's source table is the table the legacy restore creates
(`source_table_from_legacy`, pinned by an exhaustive property against
`restore_input` and `restore_input_from_manifest`), with the recorded
table class, TTL and stream and the SSE description of the recorded
specification, its key ARN built in the server's region as the legacy
DescribeBackup builds it, so the stored specification's `KMSMasterKeyId`
is not carried over; no answer reports a key id before or after. The
table's and each GSI's throughput are the ones the backup recorded, none
on demand: a GSI of a provisioned table that recorded none or 0/0 (a
table created on demand and switched with UpdateTable) keeps that, and a
backup with no definition and no recorded throughput records none, so
DescribeBackup reports the same throughput before and after the
migration; a restore of the copy gives such a GSI the table's throughput
and such a table 5/5, as the legacy restore does
(`restore_input_from_manifest` reads a provisioned `source_table` with
no throughput as 5/5; only such a copy has one). DescribeBackup then
differs only in `BackupSizeBytes`, `TableSizeBytes` and, for a source
that recorded no index, stream, TTL or SSE, `SourceTableFeatureDetails`,
which the legacy answer omits and the row's reports as `{}`, as for a
store-backed backup. The vector state is `[]` on PostgreSQL and SQLite
and `null` on MongoDB and Cassandra. Its index lists are the manifest's
own `BackupGsi` and `BackupLsi`, which record no GSI warm throughput.

The replacement is one step per backend,
`LegacyBackupReader::replace_with_index_row`, in place of an insert
followed by the reader's delete, which let a DeleteBackup between the
two answer DELETED for a backup that then came back, and which cannot
work on Cassandra, where both rows share one key. PostgreSQL: one
transaction (the row `FOR UPDATE`, the CREATING-target check, the
insert, then the items, the definition and the row deleted). SQLite: the
same under the write lock and `BEGIN IMMEDIATE`. MongoDB: one session
transaction that inserts the row and marks the document DELETING with
`migrated_to_store`, then the collection's drop and DELETED; a commit
whose answer is lost is settled by reading both back. Cassandra: the row
converted in place by a lightweight transaction `IF backup_status =
'AVAILABLE' AND store_kind = null AND active_restores = null`, then its
list and status entries and the item buckets' deletion; an unknown
outcome is settled by a serial read, a condition that failed at every
attempt with no answer lost wrote nothing and answers
`Replaced::NotApplied` (the backup fails, its copy deleted and the
in-catalog backup unchanged), and a second call finishes a conversion
that stopped after the row, writing entries only for a row still
AVAILABLE; a stop whose answer did not come names `migrate-legacy <arn>`
on Cassandra, since `--all` lists no converted row. Four frozen paths
change so a request orders wholly before or after a replacement:
PostgreSQL's delete reads the status under its row lock, MongoDB's reads
a `migrated_to_store` document as not found, and Cassandra's delete
claim and its legacy restore registration carry `store_kind = null`.
Only the DeleteBackup that changes an in-catalog backup answers DELETED
on every backend, every other one, concurrent or later,
BackupNotFoundException: PostgreSQL reads the status under the row lock,
SQLite again under the write lock, and MongoDB and Cassandra finish
another DeleteBackup's DELETING and answer not found (before, MongoDB
and Cassandra answered DELETED to two or three concurrent callers by
timing, and SQLite to both of two).

`LegacyBackupReader` also gains `items(account, arn)`, a frozen copy of
each restore's read as a stream (PostgreSQL a REPEATABLE READ catalog
transaction held for the copy, SQLite keyset batches each re-checking
AVAILABLE, MongoDB a cursor with `batch_size` and a frozen decode,
Cassandra each bucket paged 8 rows at a time at consistency ALL, since
the in-catalog create wrote at ONE: a replica that does not answer fails
the backup naming it, and a count the read does not reproduce names a
repair before the rerun, not the legacy restore, which reads at ONE),
and `list_all()`, which returns a row that does not decode as an error
(Cassandra's scan of `backups_by_arn` read in pages of 1,000 rows). The
core format gains `SnapshotDescriptor::KIND_LEGACY_IN_CATALOG`; no
format version or fixture byte changes. The writer runs under a
`ClaimWatch`, the job's heartbeat or the maintenance claim, whose loss
now cancels a token; the job's tests run unchanged. `HeldClaim` keeps
the job's rule #825 gave it (each beat bounded by one period, a claim
with no beat confirmed within two periods read as lost, a beat in flight
counted only on SQLite, where it parks on the process's own write lock)
and adds one restriction: a parked beat counts only when it started on
schedule, within 1.1 periods of the last confirmed one, so a process
stopped and resumed is not confirmed by its late beat; it also dates
each confirmation at its answer and gives `acting_deadline()`, two
periods after it, and `lost()` reads the last confirmation and the beat
in flight under one lock, which the timer writes as one at a beat's end,
so a parked beat that confirms is never read lost as it ends.
PostgreSQL's maintenance take now refuses while the claim row's
heartbeat is younger than `stale_job_after_secs`, even with the advisory
lock free, as the other backends' take does: a holder whose catalog
session ended runs until its next beat finds the loss. So on PostgreSQL,
MongoDB and Cassandra a holder starts no call more than two periods
after the answer of its last confirmed beat, which wrote the heartbeat
at most one period before that answer, and a taker needs a heartbeat at
least three periods old (on SQLite the serve lock keeps others out);
after a killed command on PostgreSQL the next one fails, naming the
holder, until the window passes. Purge, sync and this command take the
claim through one shared function, and on SQLite the dead-owner pass
logs its removal of a legacy backup left CREATING under
`extenddb::audit`. The replacement answers AlreadyExists for a row of
the id under another ARN or store whatever the in-catalog backup's
status.

`backup sync` skips a complete prefix whose manifest's ARN an in-catalog
backup holds, under either spelling, naming `migrate-legacy <arn>` (a
prefix a run left before its manifest is reported incomplete, as any
other). DescribeBackup, DeleteBackup and RestoreTableFromBackup read the
catalog row once more after a legacy not-found (RestoreTableFromBackup
also after its registration's), and ListBackups reads the legacy reader
first, so a replacement between the two reads never answers
BackupNotFound or drops the backup from a page; ListBackups lists an ARN
both readers hold once, with the row's summary.

Tests: per-backend cells for the stream (batch and bucket boundaries, a
pinned snapshot, a delete ending it, the AVAILABLE re-check inside
PostgreSQL's snapshot), `list_all` and `list_deleted` across accounts,
`holder_status`, `remove_deleted` and `row_holding_backup_id`, the
replacement's answers, a real replacement and a DeleteBackup queued on
each other in both orders (PostgreSQL), PostgreSQL's take of a young
dead claim refused, MongoDB's finish and lost commit answer, Cassandra's
lost answer and stop and the item read's consistency and replica error,
SQLite's maintenance beat parked on the write lock, the copy's recorded
throughput and the 5/5 restore of a provisioned copy that recorded none,
the store's `delete_written` on a filesystem root and on S3, a legacy
restore registering after a conversion, a registration between the
conversion's read and its transaction, a conversion finished after a
delete, a conversion that never applies, the paged listing, and two
concurrent DeleteBackups of one in-catalog backup on SQLite, MongoDB and
Cassandra; 60 engine cells on `Fake` and `MemStore` (each refusal,
DescribeBackup before and after a migration for eight shapes of
definition and throughput, a finish beside an in-catalog backup it
leaves, the store trait's default `delete_written`, an object another
run wrote, a failed copy deleting only its own objects, a loss read
during that deletion, the leftover walk's deadline, the fence at each
deletion and beside the re-checks before the manifest and the
replacement, a finish whose row's prefix is gone, short or another
backup's, a status re-read that fails, a loss recorded mid-walk, a put
whose answer was lost, a conversion that did not apply, the SSE key
ARN's region, the counts, a store serving another manifest, every rerun
rule, a deleted backup's copy and record, a row of the id under any ARN,
a claim taken over before each step that cannot be undone, a stalled
heartbeat, signals during a copy and a replacement, requests racing a
replacement, the sync skip, the dedupe) and ten of `HeldClaim` (four of
them #825's, extended here; the 1.1-period window on a paused clock;
parked beats confirming back to back read by a thread without pause; a
late beat in flight confirms nothing where it does not park, and #825's
in-flight cell now finds a parked beat that starts late confirming
nothing); wire cells on SQLite, PostgreSQL and MongoDB (two in-catalog
backups migrated, described, listed, restored and deleted under their
ARNs with the in-catalog rows gone; `--all` past a #618 backup and a
13-digit one, both kept; a stopped migration's copies of two deleted
backups, not adopted and then deleted; the claim against a second
command, on PostgreSQL and on MongoDB beside a live holder, and the
serve lock); SIGKILL at the three kill points on SQLite and PostgreSQL,
each rerun finishing, and a kill or a refused deletion followed by
DeleteBackup, the backup staying deleted (PostgreSQL on S3 too); a first
run whose claim a second took over (PostgreSQL after its session ended,
MongoDB stopped past the window and resumed with the catalog paused),
the second's backup left whole; a deleted backup's record removed only
once its prefix is clean (SQLite). 201 mutants of the new tests, all
killed. Peak RSS 48 to 56 MiB flat on PostgreSQL (75 at most measured
beside a server); on SQLite 55 to 149 MiB, its page cache, bounded by 32
MB times `pool_size`.

Docs: the backup manual's legacy section (the command, its cost, what it
refuses and the way out, what a migrated backup reports, a stopped run
and a deleted backup's copy and record, MongoDB's and Cassandra's
stopped replacements, Cassandra's recurring step, memory); the upgrade
manual's store, 0.1.x and rollback paragraphs and `--all` after the last
DeleteBackup; the admin guide's paragraph, sample output and
`stale_job_after_secs` row; the differences doc's two rows and a row for
concurrent DeleteBackups of one backup. Design revision item 37, with
sections 3, 4(c), 4(f), 4(g) and 7, the PR 10 paragraph, its
maintenance-claim paragraph and transitions, the PR 14 lines (rows that
are not DELETED, and DELETED records whose id has the 14-digit shape)
and section 10's row edited with it.

Not here: removing the in-catalog reader (#749); a 13-digit backup
carried into the store format; a Cassandra wire leg (direct tests only).

Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
@robinnsc

Copy link
Copy Markdown
Collaborator Author

New head: 4812e3e8, on main at 887b6e26 (#840). Each inline comment is answered in its thread with the change, where it is on this head, and the test that covers it. The nightly matrix is running on this head: https://github.com/ExtendDB/extenddb/actions/runs/42775123844. The description's testing section links it and names what CI does not run: no Cassandra leg for tests/test_backup_job.py, and no backup sync against an S3 store, for which it describes a manual moto run.

Other changes: DeleteBackup's doc and the comment above its first fence now say that a root another catalog owns, a missing identity file, a file the store refuses to serve and one that does not parse are refused with 400, the row left AVAILABLE with its objects, and that only a transport failure goes on to the DELETING write. An S3 server error the SDK's retries did not clear (500 InternalError, 503 SlowDown) is recorded at the fence as failed validation, which the admin guide and the description now state; I left s3.rs's error mapping alone, because classing 5xx as transport would change it for every caller. The admin guide also says that a prefix an in-memory server had not finished writing or deleting stays under the root with no row, to be deleted by hand.

@LeeroyHannigan LeeroyHannigan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Round 3, at 4812e3e. This head fixes round-2 item 2. The INFO report now keys on a run that reported. Two new cells cover a first run that fails and a first run cut at its bound. Items 1, 3 and 4 are not fixed, and item 1 is Major. The changes since round 2 bring two new Minor items. Each item has an inline comment below. I ran cargo test -p extenddb-engine --lib backup:: (338 passed), and all 64 checks pass at this head.

Once you fix the Major item, I will approve. The Minor items can land in the same revision.

Comment thread docs/design/15-backup-restore.md Outdated
@@ -591,3 +591,5 @@ Changed after a review of the draft against main `9f43d03` and PRs #384, #335, #
32. (2026-11-04, after the maintainer's review of PR 8.) The heartbeat's one-period bound is placed by the backend (`JobClaim::heartbeat_within`, a provided method that bounds the whole call; SQLite's override takes the engine write lock first and bounds only the `UPDATE`, so a writer holding that lock past a period, a GSI backfill say, delays the beat and the claim re-check and ends no job; the re-check's confirmation counts as a beat for the pre-put check; after the maintainer's second round, the pre-put check also passes while a beat is in flight and has not failed, since a backup large enough to reach a put while the beat was parked on the lock past two periods stopped at that put, the maintainer's 180,000-item backup beside a GSI build twice at the third or fourth data file, and the reconciler then failed the row and deleted its objects; the safety argument is in 5.1 step 4 and `heartbeat.rs`'s module doc, and the tests are a SQLite cell whose second data file is put under a hold of the lock, `a_put_during_a_writers_hold_passes_on_the_beat_parked_behind_it`, and an engine cell with a claim whose beat parks, `a_beat_parked_on_the_write_lock_confirms_the_claim_until_it_returns_lost`), and `heartbeat.rs`'s SIGSTOP text says that a job with no beat in flight resumes under its own generation once its next beat succeeds. The store-backed create checks the maintenance claim before the permit, as the in-catalog paths do, and inserts no row for a refusal (5.1 step 2 and 4(f) are edited: a FAILED `maintenance` row carried the insert's heartbeat and `backup purge`'s wait counted it as a live job for the stale window). Before every put the job checks the store as the runtime holds it (healthy, and its identity the row's) and on a failure fails the row with the store's reason and makes no store call, the prefix's deletion included (4(c)'s swapped-root case). The open and the insert are selected against the shutdown token. The uploader cancels the feeder's token before it awaits an encoder, so a put that fails mid-file ends the job while the stream is held. A data file whose stored size differs from the encoder's compressed count fails the job (the dead-owner probe refuses that difference). The AVAILABLE write sets `TableSizeBytes` from the stream as it sets `ItemCount` (the differences document names both). `failed_row_retention_secs` is at least `stale_job_after_secs` (a FAILED row removed sooner left a stopped owner's late object with no row to delete it under). A registration lost while serving cancels the jobs' token, which the runtime replaces for the jobs that start under the new registration; the shutdown sequence after the listener is one function the server's tests drive with a job that outlives a short drain. The plain-HTTP listener's drain is bounded as the TLS listener's is. CreateBackup is bound to the in-catalog path on SQLite where the serve lock is not taken (not Unix), since the slot claim is sound only with one process per file. 4(c) states the gap of the different-roots guard before the first AVAILABLE row. The compose files' `stop_grace_period` is 80 s. The kill-point scripts are committed under `devtools/` and the clippy job lints `backup-test-hooks`; a MongoDB job runs the backup suites against a filesystem store. The tests added are the review's: the lock held past two periods on SQLite, the mid-file put failure with the stream held, the unhealthy and renamed store mid-job, the key-ordered stream out of order, `BackupStoreErrors` for the job's `delete_prefix` and the probe's `list`, the re-read under another generation, the two-period sample, the nesting rule through lists and mixed levels, the keepalive literals, the driver's five-row run-ahead, the beat between the takeover's read and its LWT on Cassandra, `pool_size_for_jobs(11, 4)`, and the retention floor. After the third round the clause applies only where the beat parks before its bound (`JobClaim::heartbeat_parks_before_its_bound`, `true` on SQLite only): on PostgreSQL, MongoDB and Cassandra a beat in flight confirms nothing, since a process stopped mid-beat and resumed would otherwise pass the check until its timer raised the loss, and one stopped between beats for the round trip of the beat its missed tick starts, a case a bound of one period from the beat's start (the third round's first fix) did not cover (`a_beat_in_flight_confirms_the_claim_only_where_the_beat_parks`, `a_stop_between_beats_passes_no_check_while_the_resumed_beat_runs`).

33. (2026-11-01, PR 9 as implemented — the design's PR 9a and PR 9b as one PR, the maintainer's decision.) The restore job runs from the store on PostgreSQL, SQLite, MongoDB and Cassandra, same-backend and cross-backend, from a filesystem or an S3 store; the matrix and the nightly are PR 9b's, shipped here. `RestoreTableFromBackup` of a `backup_index` row no longer answers the "in a backup store; this release restores in-catalog backups only" refusal `check_catalog_row_for_restore` carried; a healthy configured store's AVAILABLE row is restored, and the refusal is gone from the engine and the differences, usage and upgrade docs (5.3, section 10, PR 9a). The interface: `RestoreEngine`, a new trait in `StorageEngine`'s bound with `bulk_load(target, claim, BulkLoadFile { file_index, start_at, reapplying }, items) -> applied`, `finish_restore(target, claim, item_count, size_bytes) -> Finished { Active, Pending, NotMatched }`, `count_restore_target(target) -> u64`, `read_restore_progress` (default `[]`) and `sweep_restore_progress(running) -> u64` (default 0); `StorageEngine` has no methods of its own, so `bulk_load` is not "a new `StorageEngine` method" (section 3's PR 9a interface list). `BackupCatalog` gains `claim_restore(account_id, input: CreateTableInput, &RestoreRegistrationRow) -> (TableDescription, Box<dyn JobClaim>)` (the registration creates the CREATING target and returns the description the request answers; the record is born owned under the backend's generation, `RestoreRegistrationRow.owner_generation` being `UNOWNED_GENERATION` and the backend minting its own), `claim_restore_dead(key, stale_after, spool_host) -> Option<Box<dyn JobClaim>>` (the restore analogue of `claim_dead_backup`, writing the claimant's `spool_host`, a fresh generation and a fresh `heartbeat_at` on the database's clock in one conditional write, where the backup takeover leaves the heartbeat: nothing waits on a restore record's old heartbeat after a takeover, and a second taker must find the record live at once, 4(e)), `read_restore_job`, `list_restore_jobs(Option<JobStatus>)` and `remove_restore_target`/`fail_restore_job` as the reconciler uses them; `RestoreJobRow` carries `store`, `spool_host`, `file_index`, `items_applied`, `failure_reason`, `completed_at`, so the FAILED-target duty reads a row's reason and the sweeps read its store and `spool_host` (section 3, 4, 5.3, PR 9a, PR 9b). The design's `claim_restore(&RestoreJobRow) -> Box<dyn JobClaim>` and `record_progress(&dyn JobClaim, file_index, items_applied)` are gone: there is no `record_progress`; every backend records progress inside `bulk_load`, in the write that applied the items (PostgreSQL a `restore_progress` row in the file's data transaction, SQLite and Cassandra a `tables`-row update per batch, MongoDB a `restore_job.file_index` update per file), and the design's "conditioned on the generation" holds on SQLite, MongoDB and Cassandra but on PostgreSQL the progress row lives in the data database, which holds no job record, so the fence there is the base table's primary key (a file applied twice fails on its first key, a recorded file on the progress row's own key) and the claim's liveness, which the engine confirms before each file and whose loss, the heartbeat's, aborts a file in flight; the generation fences only the record's own writes in the catalog (DONE, FAILED, the takeover) (5.3 step 5, PR 9a). `bulk_load` carries `reapplying` so MongoDB's duplicate suppression is explicit: on a resume the first file the engine meets is `reapplying = true` and every later file false; MongoDB suppresses an all-E11000 `insert_many` only when `reapplying`, else "the backup holds a key twice"; PostgreSQL and SQLite never suppress (their base rows are plain `INSERT`s, a duplicate fails the file whatever the flag, and SQLite has no unrecorded partial write, its progress being in the batch's transaction); Cassandra's `put_restored_item` is an upsert, so a duplicate collapses and `count_restore_target` one short of the manifest's total is the only duplicate check there (5.3 step 6, PR 9a). The restore ends by counting the target against the manifest's item total on every backend, so an unordered backup holding one key in two files and a key-range check that missed are both caught; a key-ordered file runs #394's per-file `KeyRange` check, which ends the file's stream at an item outside its range. The migration for PostgreSQL's `restore_progress` is id 6 (`MigrationId::RestoreProgress`), a `StoreKind::Data` body on PostgreSQL (`data_migrations/0006_restore_progress.sql`), `NotApplicable` on SQLite, and nothing on MongoDB and Cassandra, whose bootstrappers have no `MigrationId` table; this corrects "data migration 005 for `restore_progress`" in the PR 9a section (its acceptance line and its load paragraph) and the PR 6 line on reserving numbers; item 14's "data migration 005 reserved" stands as the history it is (4(i), PR 6, PR 9a). The manifest registration locks the `backup_index` row (the lock `start_backup_delete` takes: PostgreSQL `SELECT ... FOR UPDATE`, SQLite the `BEGIN IMMEDIATE` write transaction, MongoDB `$inc lock_seq` on the document, Cassandra the `active_restores` lightweight transaction on the backup's partition), while the legacy registration keeps the `backups` row; a legacy and a store-backed restore into one name exclude each other (PostgreSQL the advisory `restore_lock_key`, which both paths take; on SQLite the store-backed path's `restore:<account>/<table>` slot and, for a legacy restore, the `tables` row under the write transaction), so a `DeleteBackup` of a `backup_index` row meets only store-backed registrations and the two orderings resolve one before the other (DeleteBackup wins → the registration is `BackupNotFound` and no target; the registration wins → DeleteBackup is `BackupInUse` and the row stays AVAILABLE) (5.2, 5.3, PR 9a). The restore's ACTIVE flip sets the table and every secondary and vector index of the target in one catalog transaction on PostgreSQL and SQLite (an index of a restore target is created ACTIVE, and `activate_restore_target` sets ACTIVE any `indexes` or `vector_indexes` row that is not), on MongoDB the job writes DONE and `restore_backfill_pending` and the backfill worker flips the table ACTIVE once every index has left CREATING (`finish_restore` returns `Pending` there), and on Cassandra the one lightweight transaction flips the table alone, its index rows created ACTIVE (`Active`, never `Pending`), so a Query on each GSI and `SearchVectors` answer as soon as the target reports ACTIVE (5.3 step 6, PR 9a). The request bounds its wait for the detached registration at `RESTORE_REGISTRATION_TIMEOUT` (60 s) on every backend; past it it answers `InternalServerError` while the task goes on and the target appears through `DescribeTable`, so the differences document's MongoDB-only sentence becomes every backend's (5.3 step 4, PR 9a). The maintenance check runs after the registration on the manifest path (5.3 step 4 as written) and before the permit on the legacy path (item 29), both orders in `restore_table_from_backup` and `restore::start`; the restore's first confirmed beat stands at the instant `claim_restore` returned (the record's `heartbeat_at` was stamped inside the registration, before the return, so the instant is at or after the beat it stands for by the registration's remaining duration, which each backend bounds, 4(e)), where the backup job takes the instant before its claim's insert (5.1 step 4), so a registration that took longer than two heartbeat periods does not end the job at its first check; a held claim on the manifest path leaves a FAILED `maintenance` record whose target the job removes before it answers `LimitExceededException`. Only `StoreError::Transport` is retried on a data-file download, for up to `DOWNLOAD_RETRY_BOUND` (15 min, counted from the job's first download attempt, every file's retries against it) with `min(2^n s, 60 s)` plus up to a second of jitter, so the retry covers S3 stores (a filesystem store's `get` errors are `Io` and fail at once); `NotFound`, a denial, and a digest or size mismatch fail at once naming the file. On a filesystem store "verified in place" is two `get` passes, a hashing pass before any load and a decoding pass through `ExtenddbManifest::reader` that hashes again (so a file replaced between the passes fails in the load) with no spool; on an S3 store the files are spooled as `restore-<table id>/<i>.json.gz` with `.partial` → `sync_all` → rename → `.verified` (the marker holding the file's sha256, compared with the manifest's before the file is trusted; an entry left by a run of this host for a record another host then owned is removed before use) and read through `reader_prehashed`, the spool at `spool_path` (the defaulted `backup-spool/` on file-backed SQLite). A restore the shutdown stops records `RestoreJobs{cancelled}` and no worker metric, keeps its spool and its progress and writes no status (the `SHUTDOWN` reason is a backup's alone); a lost claim or a DONE write that matched no record records `RestoreJobs{failed}` and `WorkerErrorCount{RestoreJob}` with no catalog or table access; `fail_restore` writes FAILED then runs `remove_restore_target` only when that write matched. A job that learnt the record is another's (the lost claim, the DONE write's `NotMatched`, `fail_restore`'s no-table-access arm) leaves its spool entry to the sweep, since a same-host successor may be reading it (the entry is keyed by table id); every other end but the shutdown's removes it (4(e), 5.3 step 7, PR 9a). The load hands the backend one `bulk_load` per file over a stream fed in chunks of at most `LOAD_CHUNK_ITEMS` (1000) items or `LOAD_CHUNK_BYTES` (32 MiB of data-file line text) through a channel of two chunks; a restore holds the parsed manifest, one decoded line and its parse, the decoded items of four chunk positions and the backend's batch (32 MiB of item text on SQLite and Cassandra, 4 MiB per INSERT on PostgreSQL, 1,000 documents on MongoDB), which is section 7's restore sentence, with the figures measured on this PR's final tree (`docs/design/15-backup-restore-bench-pr9.md`: 848 MiB over idle on SQLite and 778 MiB on PostgreSQL at 24 worst-shape items under the default arenas, 392 and 427 MiB under one arena; 89 and 16 MiB at 1M key-ordered items of 200 bytes, in 26.8 s and 9.5 s, 144 to 164 and 16 MiB for the same items unordered), which replace the earlier sentence, "A restore holds one line's decode plus one insert batch plus the parsed manifest for the whole job; neither grows with the table": the terms stand, the figures are measured, and a restore of large items is of the backup's order. The metrics are `MetricName::{RestoreJobs, RestoreJobDuration, RestoreBytesDownloaded}` with the outcome in the operation dimension, `QuerySource::RestoreJob` for the worker metrics, and `BackupStoreErrors{get}` on every failed `get` of a restore job (the request's and a resume's two document reads and each download attempt the store failed, retried or not; a digest mismatch and the spool's own errors are not the store's); section 9's `restore_jobs_total{outcome}` and `restore_job_duration_seconds` are a converter's spellings, as item 31 said for the backup names (section 9, PR 9a). A backend's refusal of an item in `bulk_load` is not a wire exception: on MongoDB an `N` sort-key value past Decimal128's 34 significant digits is the write path's `ValidationException`, which becomes the restore's FAILED reason, and the client sees the target disappear; a 38-digit non-key number is stored as text and restored intact on every backend. The reconciler resumes a dead store-backed restore from its recorded progress (per file on PostgreSQL and MongoDB, per batch on SQLite and Cassandra) under `claim_restore_dead` once the heartbeat is stale (on SQLite at once after a restart, by the record's slot; on PostgreSQL once the owner's claim session has ended as well), re-downloading to its own spool when the dead owner's `spool_host` is not this host's; it fails a dead manifest record under a maintenance claim before it looks at the record's store (`OWNER_DEAD`, the target removed; `target_not_empty`, the target left for `remove-target`, when it recorded no progress and the target holds items), fails a record of a store other than the configured one or of no store with `OTHER_STORE` (naming `backup sync --move-store`, the target left to the FAILED-target duty), skips a resume without a permit, and FAILs a resume with no recorded progress into a non-empty target as `target_not_empty`, left for `extenddb backup remove-target --account <id> <table>` (PR 10's command, shipped with PR 9, below); its two sweeps (`restore_progress` rows of tables that are not RUNNING restore targets; spool entries of restores not RUNNING with this host's `spool_host`) run only without the maintenance claim and read `list_restore_jobs(Some(RUNNING))`, a backend that cannot list them logging once and skipping both (section 4, 5.3 step 7, PR 9a). A store-backed backup of a multi-part-key table is written `unordered` and restores on every backend, the target laid out as a natively created table (`bulk_load` keys rows with the backend's own key functions), while a legacy backup of one keeps the reader's refusal; the #384 override tests run through both the legacy and the manifest paths for every case with a definition and assert the same input or the same refusal, the manifest path deriving the SSE specification from the manifest's `sse_description` where the legacy path keeps the specification as written. The cross-backend matrix (PR 9b): a cell copies the source's backup prefix into a root the target catalog owns and inserts the target's `backup_index` row as `backup sync --adopt-store` will (PR 10), so the cell proves the restore and not the adoption; it runs from the committed `devtools/backup-restore-matrix`, and `.github/workflows/matrix-nightly.yml` (a `schedule` cron and `workflow_dispatch`) runs the twelve plain cells and the duplicate-key and vector cells nightly, building the four backend binaries and starting PostgreSQL, a MongoDB replica set and Cassandra as the integration workflows do; the seed stands in for `backup sync --adopt-store` until PR 10 replaces it. The restore kill points are run by hand with the `kill-points.py` posted with PR 9 (outside the repository, as PR 8's was), one mode per backend where it applies: `restore_after_download`, `restore_after_file_commit`, `restore_sqlite_mid_batch`, `restore_cassandra_before_progress`, `restore_mongodb_after_insert_many`, `restore_postgres_before_progress_delete`, `restore_mongodb_before_worker_flip`, `restore_between_failed_and_removal`, `restore_fenced_out`, `pg_terminate_backend` and `sigterm`, each a SIGKILL or a signal at the named point with a restart and the outcome asserted (the exact count after a resume, or the target gone for a failing restore). Two points pause before the first progress record of a file or batch (`restore_mongodb_after_insert_many`, `restore_cassandra_before_progress`), so a kill at their first reach leaves written items and no progress and the resume answers `target_not_empty` (the rule of 5.3 step 7, asserted); the re-application path (MongoDB's E11000 suppression of the one unrecorded file, Cassandra's re-applied batch) is run by killing at the point's second reach, after an earlier file's or batch's progress is recorded (the script's `--second-reach`): on MongoDB a 24,000-item backup in three files resumed from file 1 with file 0 recorded and reached ACTIVE with the exact count (file 1's 8,227 items re-applied as duplicates, 7,544 applied by the resume); on Cassandra a one-file backup of 12,000 items killed before the second batch's progress write resumed from item 10,000 and reached ACTIVE with 12,000 (2,000 applied by the resume), and `restore_after_file_commit` resumed from file 1 to the exact count. A Cassandra server's CreateBackup is the in-catalog format (section 3), so a Cassandra kill point takes its backup on a SQLite source, copies the prefix under the Cassandra target's root and seeds the target's `backup_index` row as the matrix does; the single-node Cassandra of the harness needs `replication_factor = 1` in the server's configuration, as `init` writes it, since under the default of 3 every lightweight transaction and quorum read on a keyspace the server creates answers "No nodes available in query plan", which is what had blocked these points. The feature chain for the backends' pause points: `extenddb-app/backup-test-hooks` → `extenddb-server/backup-test-hooks` now pulls `extenddb-storage/test-hooks` beside `extenddb-engine/backup-test-hooks`, so a hooks build compiles `extenddb_storage::backup_catalog::restore_pause_point` and each backend's own point (`restore_sqlite_mid_batch`, `restore_cassandra_before_progress`, `restore_mongodb_after_insert_many`, `restore_postgres_before_progress_delete`, each a `pub const` beside `restore_pause_point` in `extenddb_storage::backup_catalog`, the one spelling the backends and the kill-point script use) in without naming a backend crate (the server depends on none directly); the engine's five restore points are its `KillPoint` variants (`RestoreAfterDownload`, `RestoreAfterFileCommit`, `RestoreMongodbBeforeWorkerFlip`, `RestoreBetweenFailedAndRemoval`, `RestoreFencedOut`), and that enum is the engine's list, not the whole list. Corrections made before PR 9 merged: (1) the restore takeover writes `heartbeat_at = now()` in the one conditional write with the generation and `spool_host`, on every backend, so a second taker finds the record live at once; the backup takeover keeps leaving it (4(e) states why the rules differ); (2) `LOAD_CHUNK_BYTES` is summed from the decoded line's length (the decoder's `bytes_decoded` across one item), not `item_size_bytes`; section 7's terms are restated in that unit; (3) the download retry bound is per job: `download_all` takes the job's download start once and every file's retries count against it; the module doc, 5.3 step 3 and this item say so; (4) a job that learnt the record is another's (a lost claim, a DONE or FAILED write that matched no record) leaves its spool entry to the reconciler's sweep; (5) the `.verified` marker holds the file's sha256 and `is_verified` compares it with the manifest's; `Entry::open` removes an entry whose record's `spool_host` was another host's before use; the resume reads the manifest from the spool only when the record named this host before the takeover (it read the record after the takeover, which had just written this host's name); (6) the decoder checks the heartbeat's pre-write rule (`confirms_the_claim`) at every chunk boundary beside the shutdown token; MongoDB and Cassandra re-read the record per batch; (7) a restore's `claimed_at` is the instant `claim_restore` returned, the record's `heartbeat_at` having been stamped inside the registration. Cassandra's takeover re-applies the `active_restores` entry when absent; the SQLite and Cassandra batch is bounded by `RESTORE_BATCH_BYTES` (32 MiB of item text) beside `RESTORE_BATCH_ITEMS` (10,000), both in `extenddb_storage::restore_engine`; MongoDB and Cassandra read the record before each write batch; `sweep_restore_progress` deletes from a positive list; the backends' pause-point names are `pub const`s beside `restore_pause_point` in `extenddb_storage::backup_catalog`, one spelling each (`RESTORE_SQLITE_MID_BATCH`, `RESTORE_MONGODB_AFTER_INSERT_MANY`, `RESTORE_CASSANDRA_BEFORE_PROGRESS`, `RESTORE_POSTGRES_BEFORE_PROGRESS_DELETE`), and the engine's `KillPoint` is its own list; Cassandra's `count_restore_target` reads the catalog first and answers `TableNotFound` for a key that is not a CREATING restore target with that id, as the other three do; the MongoDB registration stamps the record's heartbeat from the server's clock inside its transaction (the registering process's clock is replaced; the test registers from a process an hour behind); a duplicate primary key fails the file with `data file <key>: holds a primary key the target already has (the backup holds a key twice): <backend error>`, the engine naming the file by its key once and the backends naming no file; SQLite's startup line for an aged RUNNING record names the reconciler's duty per kind. The reconciler's store check runs first in the cycle, so the manifest arm reads this cycle's health, and the skip line names `not yet checked` for a store whose first check has not recorded; a maintenance-held dead manifest restore is counted once, in `manifest_restores_failed_and_removed`; `RestorableKeySchema::resolve` runs in the request with step 2's refusals; the request's second table-name check and second `check_supported` are gone: the parser's identity rule and version gate run first; the engine's kill points log `job reached kill point <name>`; each backend's own point is named beside `restore_pause_point`; `finish_restore`'s `Pending`, the DONE write's `NotMatched`, the pre-write claim checks of `run`, the download's pre-attempt check and `WorkerErrorCount{RestoreJob}` on a FAILED job have engine tests; the #384 override tests run through both paths for every case with a definition. The wire test `test_create_backup_of_a_vector_table_is_refused` is `test_vector_table_backup_refused_in_catalog_and_restored_from_store`, since it asserts the round trip on a server with a store; the multi-part-key wire cell lives in `tests/test_backup_job.py` on a deployment that enables `allow_multipart_table_keys`, compares the restored item set with the source's `Scan` and makes no `GetItem` by the whole key (the preview's item paths address such a table by its first HASH attribute on every backend: a `GetItem` by the whole key finds nothing on PostgreSQL and the first HASH key's one item on SQLite, restored or not). Corrections made before PR 9 merged, second set: `extenddb backup remove-target --account <id> <table>` ships with PR 9 rather than PR 10 (the PR 10 section's clause says so): the `target_not_empty` rule was written for a catalog restored from a dump that names a restore which finished and was written to since, but its common trigger on MongoDB and Cassandra, which record progress after the write, is 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: the kill leaves written items and no progress), and without the command this release had no way to remove such a target (DeleteTable refuses a restore target, the FAILED-target duty skips the reason, DeleteBackup and a second restore of the backup are `BackupInUseException`); the first-reach runs of `restore_mongodb_after_insert_many` and `restore_cassandra_before_progress` are that state, so the PR 9a acceptance line, the PR 9a load paragraph, the testing summary and the differences doc's restore-interruption row name it as the third outcome beside "finishes with the right count" and "gone", where they had named two; the command is the one section 4's duty list describes (the maintenance claim, a FAILED record's target whatever the reason, a RUNNING record's dead target, a legacy record's target, the three refusals, no store needed, the audit line), its tests are the engine's maintenance tests and one wire cell on SQLite seeded into the state at the catalog, and the two first-reach kill points run it and assert the target gone and DeleteBackup allowed. The spool sweep lists the spool before the records (the design's rule, which the shipped order broke); the spooled manifest and sidecar are written as the data files are and a spooled pair that fails verification yields to the store's; the resume's first confirmed beat is the takeover's return, taken beside `claim_restore_dead`; a `finish_restore` error is an unknown outcome re-read through the claim; `BackupStoreErrors{get}` counts every `get` the store failed; the request's and the resume's document reads are bounded (4 KiB, 16 MiB); the restored table's size is the source table's; `RestorePostgresBeforeProgressDelete` is gone from the engine's `KillPoint` (one spelling each holds: the four backend names are the storage crate's constants and nothing else); a registration that fails after the request stopped waiting is logged. The restore's re-applied batch on Cassandra relies on the write path rewriting an unchanged item's GSI/LSI row instead of deleting it (#742, fixed on main by #424), and the `restore_cassandra_before_progress` second-reach run reaches ACTIVE with the GSI count exact where before #742 was corrected it was one batch short. PostgreSQL's `finish_restore` deletes the progress rows after the flip's commit and does not fail the restore if that delete errors (logged and swept). A file's PostgreSQL progress row is a plain INSERT from `start_at == 0` and an upsert from `start_at > 0`. Two concurrent registrations of one backup leave one target on Cassandra (the registration LWT's `active_restores = null` is the fence). What bounds a registration's duration per backend (the advisory/`FOR UPDATE` lock, the slot, `transactionLifetimeLimitSeconds`, the LWT timeouts), so the `claimed_at`-minus-`heartbeat_at` gap can never reach the stale window, replaces the earlier "conservative" in 4(e) and in this item, which had the direction wrong: an instant later than the stamp makes the pre-write check admit a beat older than two periods by the gap, so the gap needs a bound, not a name. The SQLite restore's published memory figures are the no-index shape; a table with indexes costs SQLite's page cache (`cache_size`) plus arena fragmentation, bounded, not the restore's buffers (the record's index table: 143 to 225 MiB at 100k and 165 to 172 MiB at 300k with two GSIs on SQLite, 23 to 34 MiB on PostgreSQL), and the 60,000-item shape of the differences doc's in-catalog timings was measured store-backed on PostgreSQL, SQLite and MongoDB (0.67, 1.85 and 3.74 s to ACTIVE), the row now labelling which path each figure is. The MongoDB CI jobs' `EXTENDDB_TEST_BACKUP_STORE` is `filesystem` from this PR (`devtools/run-mongodb-tests` serves a filesystem root), where it was `none`; the Cassandra job's stays `none`; the runner's `--backup-store none` is the one way to run the legacy LSI fallback cell, the in-catalog restore path's one wire cell on MongoDB, which no CI job runs. The PR 11 line owns the two unmeasured cells (the MongoDB and Cassandra 1M restores, the vector cell) and no longer the nightly schedule, shipped here. The kill-point cells as run on the pushed head's hooks binaries (4c353191), every ACTIVE outcome asserting the base table's and the GSI's `Scan` counts: `restore_after_download`, `restore_after_file_commit`, `restore_between_failed_and_removal` and `sigterm` on all four backends; `restore_sqlite_mid_batch`; `restore_postgres_before_progress_delete`, `restore_fenced_out` and `pg_terminate_backend` on PostgreSQL; `restore_mongodb_after_insert_many` at its first reach (`target_not_empty`, then `remove-target`) and its second (the re-application, 24,000 items in three files); `restore_mongodb_before_worker_flip`; `restore_cassandra_before_progress` at its first reach (`target_not_empty`, then `remove-target`) and its second (12,000 items, the GSI exact); the Cassandra points with a SQLite source. Rerun on the build after the last corrections (the first beat before the reply, the Cassandra re-read and fence, the PostgreSQL vector drop): `pg_terminate_backend` as reworked (the lost claim at the next check 20 s after the termination, the record RUNNING with its `(0, 8228)` progress row, the takeover 45.6 s after the termination and 46.7 s after the last beat, ACTIVE 24,000 on base and GSI), `restore_after_file_commit` and `restore_mongodb_before_worker_flip` on MongoDB (resumed from data file 1 after 0 of its items, 3,771 applied by the resume, DONE then the worker's flip, 12,000 exact; DONE before the kill and the restarted worker's flip after it), `restore_cassandra_before_progress` at its second reach (resumed after 10,000, 2,000 applied, 12,000 on base and GSI) and `restore_between_failed_and_removal` on PostgreSQL with a vector backup (the target gone after the stale window, its data table and `_ddb_vec_` relation gone with it, where the pushed head's build left the relation). The matrix's four `--duplicate-key` and eight `--vector` cells ran on the final binaries. Corrections made before PR 9 merged, third set. The job's first heartbeat is sent right after `claim_restore` returns and before the reply, and the pre-write check counts its two periods from that beat's return; a `Lost` or failed first beat fails the record ("the claim was lost before the job started") and answers `InternalServerError`. `extenddb backup remove-target` decides a RUNNING owner dead through `list_dead_restore_jobs(owner_window)` on the database's clock, and the purge's wait decides a backup row's owner live through `list_backup_rows_older_than` on the heartbeat clock; neither reads a host clock. The reconciler takes nothing over and writes no FAILED on its manifest arm once the server's shutdown has begun. Under a maintenance claim a dead record with no progress over a target that holds items is failed `target_not_empty` through `fail_restore_job`, not `OWNER_DEAD`. The reconciler's `target_not_empty` lines print `extenddb backup remove-target --account <account> <table>`. The restore job's stop line names the heartbeat timer's reason when the timer raised the loss, and the timer logs the reason at WARN. A DONE write whose answer was lost and whose record re-reads DONE under the job's generation ends with "complete (DONE)", not "ACTIVE". A spool document write that fails and a resume that fails before it runs remove this host's entry. The engine's tests of the 60 s bound, the slot held to the job's end and the re-reads' generation checks are cells of their own. Cassandra's progress lightweight transaction re-reads an unknown outcome (applied, one retry, or the lost claim; a second unknown outcome fails the restore), where the earlier text said the restore fails and the reconciler resumes it, which the FAILED write's removal made false. The Cassandra registration's refusal path conditions the target's removal on its own generation; a record a reconciler took over during the DDL keeps its target. PostgreSQL's `abort_restore` drops a vector index's relation with the data table. The PostgreSQL record's `heartbeat_at` is the registration transaction's start. Tests: `test_an_unknown_progress_write_outcome_is_decided_by_a_re_read`, `test_a_registration_taken_over_during_its_ddl_leaves_the_successors_target`, `test_a_mid_file_resume_records_start_at_in_every_batch_progress`, `remove_restore_target_drops_the_vector_relation_with_the_data_table`. The engine's `KillPoint` has five restore variants, named above. SQLite's takeover clock is `strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, the `NOW` constant of its catalog (4(e) names it). `extenddb backup remove-target` refuses a RUNNING record with a live owner by its own text ("stop it or wait for it"); the DONE and no-record refusals are the two "not a restore target" ones. The published 1M figures are the key-ordered shape's and the record says so beside them, with the same items measured as an unordered backup (SQLite 144 to 164 MiB over idle in 66 to 90 s against 87 to 98 MiB in 27 to 56 s key-ordered; PostgreSQL 16 MiB either way), and SQLite's final `COUNT(*)` is the counting connection's page cache, bounded by `cache_size` (32 MiB) and the same size at 1M and at 3M rows (22 to 28 MiB), where the record had called it the one term that grows with the table from a single measurement. The `pg_terminate_backend` cell of `kill-points.py` pauses the job at `restore_after_file_commit`, terminates the claim session while it is paused (psql must answer a row), waits past one heartbeat period, releases, and asserts the lost-claim stop line, the record RUNNING with its progress row, the takeover no earlier than `stale_job_after_secs` after the owner's last beat and the exact counts; the shipped cell had terminated the session after a fixed sleep and asserted the final count alone, which a 2.6 s restore satisfied without the case ever running. The script's `--vector` switch gives the source table a vector index, and the tamper cell on PostgreSQL then asserts the removed target's data table and `_ddb_vec_` relation gone. `.github/workflows/matrix-nightly.yml` installs `postgresql-client` and checks `psql --version` and the two drivers' import before the cells; it has had no run, since `workflow_dispatch` needs the file on the default branch, so its first run after the merge is the proof of the runner image. The upgrade manual counts migrations 1 to 7 as breaking and migration 8 as declared breaking on PostgreSQL for want of the additive-declaration job (on SQLite, MongoDB and Cassandra it does not apply). The body sentences each of these corrects are edited with this item (section 1's duplicate rule and Decimal128 bullets, section 3's PR 9a interface list, 4(b)'s progress members and metric name, 4(e) and 4(i), 5.2, 5.3's preamble and steps 1 and 3 to 7, section 7's restore memory sentence, section 9, section 10's table, the PR 6, PR 8, PR 9a and PR 9b sections, the PR 10 clause naming what the seed stands in for and the command shipped here, the PR 11 line, the testing summary and the review checklist); earlier items keep their wording, as item 22 requires. With the rebase onto #666's head (2026-11-03): the jobs and the reconciler are not gated by the PostgreSQL serving registration, and 4(e) says what they do in the registration's retry window, GitHub issue #755 being the gate; `backup purge`'s own cycle under the SQLite serve lock fails a dead store-backed restore and removes its target rather than resuming it (4(f)), since the command tells the cycle it holds the claim instead of the cycle reading it through a zero window; the PR 9a load paragraph gives PostgreSQL's batch as 500 rows or 4 MiB, as 5.3 step 5 does; and MongoDB's snapshot open waits, at most 2 s, for the majority-committed snapshot to show a table a `local` read shows ACTIVE, so a store-backed CreateBackup sent as soon as DescribeTable says ACTIVE is not refused as a table that does not exist. With the rebase onto `main` (2026-11-04), after #335, #666 and #681 merged: `restore_progress` was migration 7 (4(i)), declared not applicable in the MongoDB and Cassandra registries #681 added, as on SQLite; 4(e) cites the module doc of `crates/server/src/registration.rs` for what a lost serving registration does, which replaces this item's 2026-11-03 clause on it: the reconciler stops with the server's workers and spawns no resume once its own token has fired, and the running jobs go on, #755 being the gate for them; a restore job joins PR 8's job drain as a backup job does; and `remove-target` releases its serving registration when it exits and is named on MongoDB and Cassandra as on PostgreSQL. With the move onto `main` at e8ca7df3 (2026-11-04), after #424 merged: the Cassandra write path's rule for an unchanged index row (rewritten by its INSERT alone, #742) and its version stamps on every index row are #424's, so this PR no longer changes `sync_indexes` or the GSI worker; the PR 9a load paragraph, 5.3 steps 5 and 6 and this item cite #424 for what a re-applied batch relies on, and a restored item's index rows carry its base row's `version` as #424's write path gives them (`put_restored_item` is that path). After the second review round (2026-11-05): the Cassandra load no longer goes through `put_restored_item`'s lightweight transaction but writes each item's base and index rows in one logged batch `USING TIMESTAMP` a reading taken before the batch's claim check (`put_restored_item_at`), the fence against a batch a stalled writer lands after a takeover (the PR 9a load paragraph); the index rows still carry the base row's `version`, which is now that timestamp. A resume with no recorded progress fails `target_not_empty` only when the target holds more items than the backup's row counts; at or below the total it re-applies the first file with upserts (step 7), so a Cassandra crash before the first progress record resumes to ACTIVE. One instance's sixth takeover of a record fails it (`RESTORE_TAKEOVERS`, step 7). `restore_target_ready` on Cassandra checks each GSI's and LSI's table as well as the data table. The nightly workflow runs every restore kill point of every backend in four jobs (the testing summary); the kill-point script's first-reach cells assert ACTIVE with the exact count and its `--remove-target` flag is gone (the `target_not_empty` state is seeded by `tests/test_backup_job.py`'s `remove-target` cell). With the rebase onto `main` after #671 and #672 merged (2026-11-06), `restore_progress` is migration 8: #819 took 7 for PostgreSQL's `stream_pending`, so the SQL file is `0008_restore_progress.sql`, each backend's arm follows `StreamPending`, and CONTRIBUTING's worked example is migration 9.

34. (2026-11-04, PR 10 as implemented, #746.) PR 10 is split in three: #746 ships plain `backup sync`, `--prune-missing`, `--adopt-store`, the `sqlite-memory` startup sync and `BackupCatalog::insert_backup_row`; #820 carries `--move-store`, `--forget-store` and `--forget-backup` with `list_backup_rows_by_root` and `rewrite_store`, which rewrite or delete rows of other stores, and the full report of the rows of other roots; #821 carries `migrate-legacy`, whose data path (the frozen reader, `DataFileEncoder`, the item validator) shares nothing with sync but the insert; `remove-target` shipped with PR 9 (item 33). The PR 10 section's line, the PR 9b paragraph (the matrix now adopts with `backup sync`) and the doc-row table's `migrate-legacy` row say so (PR 9b, PR 10, section 10). Neither the Cassandra sweep of orphan `backup_index_by_status` entries nor the MongoDB sweep of the data collections a failed restore-target removal leaves is in any of the three: the code comments that gave them to `backup sync` now say no sweep removes them yet, and the MongoDB error line names the collections for an operator to drop. `insert_backup_row(row) -> bool` inserts the row as given, unowned (`owner_generation` 0), with `completed_at` and `heartbeat_at` on the database's clock, and answers `false` when a row with its key exists: PostgreSQL `INSERT ... ON CONFLICT DO NOTHING` with no conflict target, so both unique indexes (the key and `backup_id` alone) refuse it, on the pool and with no advisory lock; 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 `owner_generation: {$exists: false}` with the row's status, `owner_generation: 0`, and E11000 (the unique `backup_id`) meaning a row exists; Cassandra the `backups_by_arn` lightweight transaction `IF NOT EXISTS`, then the LOCAL_QUORUM batch of the two list rows and the `backup_index_by_status` entry under the row's status (the batch `claim_backup` wrote inline, now shared), an unknown outcome or a `NotMatched` answer 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, and no row after `NotMatched` answers `false` (a legacy in-catalog backup holds the ARN); a failed batch of the entries is sent once more before the error. The row is AVAILABLE from the transaction on, so a DeleteBackup between the transaction and the batch moves a status entry the batch has not written yet, and the batch then writes an AVAILABLE entry no later write removes; every reader of the status table re-reads the row and skips it, one wasted read each, until a sweep of orphan entries exists. Cassandra has no `backup_id` uniqueness, so its insert-if-absent is per ARN spelling: the same id under another region is another row there, as the trait doc says, and sync closes the gap itself by reading the row under both spellings of the manifest's ARN (this server's region and the manifest's) before the insert and skipping a backup the manifest's spelling, when it is another region's, has a row for, while a row under this server's spelling, the one the insert writes, is left to the insert (which answers `false` for it or, on Cassandra, completes its own torn insert), so through sync no account holds two rows of one backup id under the manifest's account, table and id on any backend (section 3, 4); on Cassandra a row of that id under another table that the rows read did not return (a row of another root, or one whose list entries are missing) is not seen by that read, and within one run the row check keeps a second prefix of an id the run adopted out. Sync reads the configured root's rows first, every status and every store id the catalog records for the root (`read_store_ids`, then `list_backup_rows` per id), keeping of each only the status, store id and table by account and backup id and the key of each AVAILABLE row of the configured store id (the backends collect the rows whole first, so the read peaks at about 22.5 KiB per row of a five-GSI table, section 7), and checks each listed prefix against that set before it reads anything under the prefix; the sync contract's "checks each prefix against the catalog as the listing reaches it" holds with the rows read once up front, and the insert-if-absent then decides against a CreateBackup that landed after the read and against rows of other roots. The listing is consumed as it streams: the store lists in key order, so a prefix is complete once the listing passes it, and sync holds that prefix's objects and the prefix of each manifest it listed; the filesystem store's own listing is the larger cost, measured in section 7. A listing that fails part-way keeps the rows inserted before it, does not read the prefix it was in, and makes no missing-manifest report. An insert that answers `false` is re-read to say which row it met: one that landed after the read, one of another store, account or table holding the backup id, or on Cassandra a row whose list entries are missing (a CreateBackup's claim that stopped between its two writes; sync's own insert that stopped there is completed by the next sync of the prefix). The prefix check is the dead-owner duty's: `reconciler::verify_prefix` reads the manifest and sidecar within their bounds, checks the digest and the reader's acceptance and every listed data file at its size from the listing sync already holds; the dead-owner probe compares the manifest's ARN with the row's apart from the region (a backup adopted from another region's manifest carries this server's region in its row), and sync compares the manifest's account, table and backup id with the path, adopts a manifest of another region under this server's region's spelling of the ARN, logging it (requests resolve it under either spelling), and skips one of another partition than `aws`, since every request refuses such an ARN before it reads a row. `layout.rs` gains `EXPORTS_COMPONENT`, `CHANGELOG_COMPONENT`, `classify_store_key` and `parse_store_prefix`: keys outside every backup prefix are passed over, the reserved components and a third component of another id shape (only the 14-digit shape carries a manifest) are reported as skipped and never read. The adopted row carries the manifest's name, ARN, size and item total and `source_table`, the id's instant as `created_at`, and the configured store's identity (section 4, PR 10). Rows of other roots are reported in part: sync prints the prefixes whose backup id a row of another root holds (the read under the ARN's spellings, or the insert's re-read). The rows of the configured root under another store id are not a case sync meets: a store whose rows carry another id than its file, or more than one id, is unhealthy, and sync refuses an unhealthy store. A listing of every root's rows needs `list_backup_rows_by_root`, which is #820's, so until then 4(g)'s "rows for another store are reported" holds for that one case (4(g)). Every sync takes the maintenance claim where it runs beside servers (PostgreSQL, MongoDB, Cassandra), plain sync with no wait for running jobs: its insert-if-absent orders it against a CreateBackup, and the claim orders it against `purge`, whose `purge_row` removes a dead CREATING row before it deletes the prefix, which can hold a complete manifest (a job that died after its manifest); a sync between the two steps would adopt bytes purge then deletes. The commands' claim (`HeldClaim`) is confirmed by the job's rule: each beat is bounded by one period (`MaintenanceClaim::heartbeat_within`, whose bound on SQLite starts once the beat holds the engine write lock), the claim reads lost once its last confirmed beat is two periods old, and a beat in flight counts only where it parks before its bound (`heartbeat_parks_before_its_bound`, SQLite); before each step that cannot be undone the command sends one beat of its own (`HeldClaim::recheck`) and stops if it finds the claim lost: sync's insert, `--prune-missing`'s removal, `--adopt-store`'s rewrite of the identity file, and purge's removal of a dead CREATING row and the deletion of that row's prefix. On MongoDB and Cassandra, which take a claim over on the heartbeat's age alone, a command stopped (SIGSTOP, a paused VM) past `stale_job_after_secs` can lose its claim to another command; it then stops before its next such step, so sync and purge never act at once while each reads its claim held. What remains is a stop after the re-check confirmed the claim and before the step's own call reached the catalog or the store, or with that call already sent: the call lands when the process resumes. While the claim is held the jobs refuse, as during purge (4(f)), and a sync killed while it holds the claim keeps the jobs of every instance refused until its heartbeat is `stale_job_after_secs` old. The restore's line for a row whose prefix lacks its sidecar says to put the prefix back; only a missing manifest names `--prune-missing`, the one case it removes. On SQLite it opens the catalog as `serve` does, under the serve lock (exit 6 beside a server, the refusal purge gives), and plain sync runs no dead-owner resolution: the lock keeps every server and every other command off the file, which is all the PR 10 rule that every writing command refuses to run beside a server asks, so nothing changes between the row read and the insert. The rows a stopped server left (a CREATING or DELETING backup, a RUNNING store-backed restore) are rows sync skips, and the next server start resolves the backups and resumes the restore; the dead-owner resolutions purge runs would fail that restore (`OWNER_DEAD`, under the claim no resume is spawned) and remove its target. `--prune-missing` and `--adopt-store` take the claim on SQLite too and run those resolutions before their wait, as purge does, so they fail such a restore and remove its target; every command that runs them prints what they did, the failed restores included. `--prune-missing` and `--adopt-store` also wait for running jobs as purge does; every sync installs purge's signal handling, checks it before each prefix and exits 1 "stopped" on a signal, releasing the claim it holds and its serving registration. `--prune-missing` removes each AVAILABLE row whose manifest is gone outright, through `BackupCatalog::forget_available_backup_row` (added here on the four backends: under the row's lock as `start_backup_delete` takes it, the status check and the in-use rule with a delete of the row, and on Cassandra its index entries after it), and deletes nothing in the store. The manifest probe sees only the newest AVAILABLE row's manifest, so a host-local root that holds the newest backup passes it while it lacks older ones; with no tombstone left, no deletion duty on the host that holds those bytes acts on them, but the row is gone for every instance: the backup is BackupNotFound on every instance until a sync on the host that holds the bytes adopts it again, which is the host-local acceptance line as amended here. 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, and every later sync reports the prefix as not a complete backup. `--prune-missing` exits 1 when it leaves a row a later run may remove (a store or catalog call failed, the manifest's `head` included, which keeps the row; or a restore is reading the backup, "run the command again once the restore ends") and 0 when the rows it leaves have their manifest back or were moved by another writer. An unhealthy or `checking` store refuses plain sync and `--prune-missing`, exit 1, naming the problem and changing no row. Sync exits 1 when a prefix was skipped for a store read or catalog call that failed (the run is incomplete; the next run adopts it), and a startup sync that met one stays pending, its skips logged at WARN on every run and its other skips at INFO until a run has listed the whole root and logged its line of counts (a first run cut at the bound or stopped by an error does not count) and at DEBUG after, so a retried sync of a large root does not log a line per prefix each cycle; the command's summary line reads `incomplete` for such a run. So when the newest AVAILABLE row's own manifest is gone the store is unhealthy and neither command runs; the way out in this release is to put the prefix back (#820's `--forget-backup` is the other). The serving registration is named `extenddb backup sync`, and the catalog refusals exit 6 and 3 as the other commands' do (4(f), PR 10). `--adopt-store` is `store_identity::adopt`: with the claim held it rewrites `extenddb-store.json` with a plain `put`, the file's `catalog_id` and `backend` changed and its `store_id` and `created_at_ms` kept, reads it back, and syncs with the adopted identity; it logs an `extenddb::audit` intent line before the put and the audit line after the re-read with its outcome (adopted, or race lost), so a put whose caller stopped waiting is on record too. The re-read detects another adopter's put that landed between this put and the re-read (the store trait has no conditional replace); the command reads the file once more after the sync and fails naming the owner when it changed, which detects one that landed during the sync; a put after both reads is found by this catalog's next check. It refuses when this catalog's rows for the root carry another store id, and on a root this catalog owns it says so and syncs. A later check of the previous owner that can read its catalog finds the file naming another catalog, at the earliest one reconciler interval after its cycle in progress ends; until then it acts on the root, except where its deletion fence stops it. `StoreFence` re-reads the file (one `get` through the store's bounded calls, under its check bound and, on a filesystem root, in a call slot of its own, so a deletion holding the root's call slot does not hold it) right before the deletion of a backup's objects (`finish_backup_delete`, which DeleteBackup and the reconciler's DELETING duty run, one read before its two `delete_prefix` calls, the backup job's failure path, the FAILED-prefix and DELETED-prefix duties, purge, whose one read comes before its row change and its two deletions), before DeleteBackup's DELETING write and before purge's removal of a dead CREATING row or its DELETING write, before the reconciler removes a tombstone, before a CreateBackup's row (at the request) and again in the job right before its first put; when the file names another catalog it refuses, records `ForeignCatalog` so the store is unhealthy at once (and a file naming this catalog under another store id than the store's check settled, a root replaced by another root of the catalog, is refused and recorded as `IdMismatch`; DeleteBackup and CreateBackup answer the unhealthy store's `ValidationException`, and the cycle re-tests the store's health after each store duty and runs none after the refusal, the dead-owner duty included), a root with no file, a file the store refuses to serve and one that does not parse are refused and recorded as the next check would record them (`FileMissingWithRows`, `Denied`, `FileUnreadable`, `Validate`), so CreateBackup answers the unhealthy store's `ValidationException` for them too and a job refused before its first put is FAILED with no object; and a read that fails in transport refuses the call and leaves the health to the next check (CreateBackup answers `InternalServerError`), except at DeleteBackup's read before the DELETING write, where it is a store failure: the request writes DELETING and answers DELETED, as for a deletion the store does not answer (revision item 30), and the read before the deletion refuses it. A refusal before the row is changed leaves it as read, so DescribeBackup does not answer a refused DeleteBackup with DELETED; one at the read before the deletion (an adoption between the two reads) leaves the row DELETING with nothing deleted and DeleteBackup answering DELETED (revision item 30), which this catalog's reconciler finishes only if this catalog owns the root again. The restore deletes nothing in the store, so it has no fence, and neither has `--prune-missing`'s removal of a row, which deletes no object. A write already past the fence's read lands, so every server of the catalog the file names is stopped before an adoption, and a new in-memory server is started only after the old one has exited; the admin guide, the refusal and the command's output say so (4(c)). For `backup sync` a defaulted SQLite root whose file names another catalog while this catalog has no rows for it is configured and unhealthy (`Caller::SyncCommand`) instead of the warning that leaves it unconfigured (item 29), so `--adopt-store` can adopt the root a catalog re-created by `destroy` and `init` finds; the server and purge keep the warning (4(c), section 7, PR 7). The identity file gains `ephemeral_catalog` (`#[serde(default)]`, so the format stays version 1 both ways), written `true` by a server whose SQLite database is in memory (`database_file()` names no file: the state, not the `sqlite-memory` cargo feature) and `false` by every other writer, an adoption included. The adoption of a root an in-memory catalog owned is part of `store_identity::establish` when this catalog is itself in memory and the file carries the marker, and never of `recheck`, so two in-memory servers on one root do not take it from each other each cycle; a root whose file names a durable catalog stays unhealthy for an in-memory server, as 4(c) requires. The startup sync runs on every start of an in-memory catalog with a store configured, not only after an adoption (on a fresh root it is one listing): for a filesystem store in `serve` before the routes serve; for an S3 store, and for a startup sync not finished, in the reconciler's cycle after a healthy check. Every run is bounded at 60 s (`sync::STARTUP_SYNC_TIMEOUT`), in `serve` and in the cycle, where the duties after it wait at most that bound; a run that stops before it listed the whole root, reaches the bound (the rows it inserted stay, but every run lists the root from its first key, so the sync completes only while listing the whole root fits in the bound; past it the sync stays pending and is retried every cycle), or skipped a prefix for a store or catalog error stays pending. On a filesystem store a run abandoned at the bound does not stop its listing (the walk has no cancel flag): it runs to its end on the blocking pool, holding the listing it builds, and the next cycle starts another walk; and `serve` answers nothing, `/health` included, until the startup sync before the routes ends, while a signal in that time ends the process at once. Until a run finishes, requests for the earlier backups answer `BackupNotFoundException`, `/health` possibly already healthy; the log line that reports the adopted count ends that window, and for a prefix that line reports as having met a store or catalog error, a later cycle's line does. It takes no claim: the process is the catalog's only user (4(c), PR 7, PR 10). Every `extenddb backup` command refuses an in-memory SQLite database before it opens a catalog, exit 1, with a sentence saying why (a location that does not parse is reported with its parse error instead, as `serve` reports it): it would open a new, empty catalog that ends with the command, and a writing command would hand an adopted root to a catalog nothing can open again; this is the sync contract's "the backup CLI commands do not exist there" (PR 10). The matrix (`devtools/backup-restore-matrix`) copies the source's prefix under the target's root and runs `extenddb backup sync` on the target (on SQLite with the target's server stopped) in place of the hand-written row, so a cell proves the adoption and the restore; `--adopt-store` points the target's store at the source's root, adopts it and checks that the source's server then reports the store owned by another catalog, and the nightly runs that cell from SQLite to PostgreSQL and back (PR 9b). 4(c), 4(e), 4(f), 4(g), decision 6, section 7's memory paragraph, the PR 10 contract, 5.3's failure step (`OTHER_STORE`'s reason, whose text this PR changed) and the testing summary's kill-point line (a Cassandra kill point adopts its copied prefix with `backup sync`) are edited with this item.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Major, from round 2, not fixed. This item still says that after a startup sync cut at its bound "the next cycle starts another walk". The admin guide's path row (05-admin-guide.md line 143) says the same. The code is the same as at 84bc263. The abandoned walk keeps the in-flight mark, so the next check fails and records the store unhealthy (runtime.rs lines 1003 to 1012). startup_sync does not run on an unhealthy store (sync.rs line 996). a_cycles_startup_sync_cut_at_its_bound_leaves_its_walk_in_flight (tests.rs line 21563) asserts this and passes. Neither document says that the store stays unhealthy until the abandoned walk ends, with CreateBackup, DeleteBackup and restores refused. Fix: state the behaviour and its consequence in both places, or re-arm the walk after a cut run, with a test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Documented rather than re-armed. The walk has no cancel flag, so clearing the mark after a cut run would let each cycle start another walk of a root the last one has not finished listing, each holding its own listing on the blocking pool; the mark is what keeps that at one. At fa0bbd9 the sentence is replaced in docs/design/15-backup-restore.md:595 (item 34), docs/manuals/05-admin-guide.md:143 (the path row, with the refusal text quoted) and the STARTUP_SYNC_TIMEOUT doc at crates/engine/src/backup/sync.rs:165-187: on both store kinds the whole listing is collected before the first key; on S3 the abandoned requests end with the dropped future; on a filesystem store the walk runs to its end holding the listing and the root's call slot, so no second walk starts, each cycle's check is refused at once and records the store unreachable, CreateBackup, DeleteBackup and restores are refused and the sync does not run until the first cycle after the walk ends, which runs it again from the first key; a root too large to list within the bound is never synced by an in-memory server. The timeout WARN at sync.rs:1039-1052 says the same. a_cycles_startup_sync_cut_at_its_bound_leaves_its_walk_in_flight (tests.rs:21618) now holds the root listing instead of stalling it for good: it keeps the earlier assertions, then asserts the second cycle's health is Unhealthy(Transport("an abandoned startup sync's store call has not returned")) with reason unreachable and no insert, releases the hold, and asserts the third cycle finds the store healthy, the sync no longer pending, sync_arn(1) AVAILABLE and the DELETING row's deletion finished, manifest then prefix.

Comment thread docs/manuals/05-admin-guide.md Outdated
| Key | Default | Description |
|-----|---------|-------------|
| `path` | `extenddb.sqlite` | Database file (`init --sqlite-path` writes it); `:memory:` for an ephemeral database |
| `path` | `extenddb.sqlite` | Database file (`init --sqlite-path` writes it); `:memory:` for an ephemeral database, whose catalog is new at every start: with `[backup.store]` configured the server adopts the root an earlier in-memory catalog owned and syncs its backups into the new catalog before it serves, within 60 s (an S3 store in the reconciler's first cycle; a sync not finished in 60 s, or one that met a store or catalog error on a prefix, runs again in 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 60 s; past that it stays pending, is retried every cycle, and the backups beyond what a run reaches are not served. On a filesystem store a run abandoned at the 60 s bound does not stop its listing: the walk of the root runs to its end on the blocking pool, holding the listing it builds, and the next cycle starts another walk. On a filesystem store the server does not answer, `/health` included, until the startup sync ends (a liveness probe with a short timeout restarts such a server on a large root, each start syncing from the first key), and a signal then ends the process at once. Until the log line `startup sync of the in-memory catalog: N backup(s) adopted` the earlier backups answer `BackupNotFoundException`, although `/health` may already report the store healthy; a prefix that line reports as having met a store or catalog error stays so until a later cycle's line. Each run names the prefixes it skipped for a store or catalog error at WARN; until one run has listed the whole root and logged its line of counts, each run also names every other prefix it skipped at INFO (so the reasons reach the log when the first run was cut at the bound or stopped by an error), and a later run of a sync left pending logs those at DEBUG, so a root of many backups with one that fails costs one WARN line and the line of counts per cycle. A server killed inside a DeleteBackup (between the DELETING write and the manifest's deletion), or restarted after a DeleteBackup whose manifest deletion failed, serves that backup again at its next start, since the startup sync adopts the complete prefix; a prefix a stopped server had not finished writing or deleting (a killed CreateBackup, a failed backup whose prefix deletion had not finished, a deletion cut after its manifest) stays under the root with no row to drive its deletion (the next start's sync skips a prefix without its manifest); delete it by hand, since every `extenddb backup` command refuses an in-memory database. Start a new in-memory server only after the old one has exited: one started beside it adopts the root while the old one still acts on it until a later check finds it adopted, its deletion fence (the identity paragraph) excepted. A root that an in-memory server of an earlier development build wrote has an `extenddb-store.json` without `ephemeral_catalog` (no release wrote one), which reads as a durable catalog's: the server finds the root owned by another catalog, its store stays unhealthy, and no command can adopt it. Remove that `extenddb-store.json`; the next start writes a new one (a new store id) and adopts the backups under the root. Every `extenddb backup` command refuses it |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor, new. This row now says to delete by hand a prefix that a stopped server had not finished writing or deleting. It does not say how to find such a prefix. It also does not say how to tell it from the prefix of a CreateBackup that the running server is still writing. That prefix has no manifest until its last put either. The startup sync names each such prefix in its "not adopted: not a complete backup" line (sync.rs lines 1048 to 1053). A running CreateBackup has a row that ListBackups returns. Fix: name that log line, and say to delete only a prefix that has no extenddb-manifest.json and no row in ListBackups. The Major item's sentence is in this row too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

docs/manuals/05-admin-guide.md:143 at fa0bbd9 names the log line (backup store startup sync: <prefix> not adopted: not a complete backup: ...) and says to delete by hand only a prefix that has no extenddb-manifest.json and whose backup ListBackups does not return, since a CreateBackup the running server is still writing has no manifest until its last put either and its row is in ListBackups with status CREATING. The Major item's sentence in the same row is replaced as described in that thread. Text only, no code change.

Comment thread crates/engine/src/backup/sync.rs Outdated
/// prefixes on the row check, but every run lists the root from its first
/// key: a root whose listing alone takes longer than the bound is never
/// synced, and the sync stays pending and is retried every cycle (on a
/// filesystem store the whole listing is built before the first key, so no

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor, from round 2, not fixed. The STARTUP_SYNC_TIMEOUT doc says that only a filesystem store builds the whole listing before the first key. On S3 the startup sync's listing also goes through BoundedCalls::list, which runs try_collect (runtime.rs line 1302). So it holds every ObjectMeta of the root, and it reaches no prefix when the listing outlasts the bound. After an abandoned startup sync, the refusal "a call of an earlier check has not returned" (runtime.rs line 1011) names a check that never ran. Fix: state the S3 case in this doc, or stream the listing under the bound, and name the caller whose call is in flight.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Both parts done at fa0bbd9. The STARTUP_SYNC_TIMEOUT doc (crates/engine/src/backup/sync.rs:165-187) now states that on both store kinds the listing goes through BoundedCalls::list, which collects the store's stream into a Vec of ObjectMeta held in memory until the run ends, so a listing that outlasts the bound reaches no prefix; S3's abandoned requests end with the dropped future, the filesystem walk does not stop. The listing is not streamed. The in-flight mark is now InFlightMark (runtime.rs:1123, a Mutex<Option<&'static str>> holding the BoundedCalls caller name), set by MarkIfAbandoned (runtime.rs:1179) and cleared by ClearOnDrop (runtime.rs:1159); the refusal is InFlightMark::refusal(caller) (runtime.rs:1149), "an abandoned {caller} has not returned", so a check refused after an abandoned startup sync reads "an abandoned startup sync's store call has not returned" and after an abandoned check "an abandoned check has not returned", and check_configured_store's INFO line (runtime.rs:1008-1014) names the caller too. The check cell asserts the check text (tests.rs:9521) and a_cycles_startup_sync_cut_at_its_bound_leaves_its_walk_in_flight asserts the startup sync text (tests.rs:21685); a refusal that returned the check text whatever the caller fails the second.

let identity =
compare(&file, &catalog_id, row_ids, kind, root).map_err(AdoptRefusal::Problem)?;
manifest_probe(store, &identity, catalog)
.await

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor, from round 2, not fixed. On the AlreadyOwned path the manifest probe's error still maps to AdoptRefusal::Problem without at_step. The other store calls of the adoption name their step (lines 675, 714, 743 and 752). The commit message says that a store that stops answering "ends the command naming the step that did not finish". Fix: map it through at_step("the manifest probe of", ...), and add the case to adopt_store_on_a_store_that_stops_answering_names_the_step.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

crates/engine/src/backup/store_identity.rs:687 at fa0bbd9 maps the AlreadyOwned probe's error through at_step("the manifest probe of", problem), so the refusal reads "the manifest probe of extenddb-store.json: the adoption's store call did not finish within 5 s". adopt_store_on_a_store_that_stops_answering_names_the_step (tests.rs:21314) gains the case "the probe of an owned root" (tests.rs:21324): the identity file names this catalog, the catalog holds an AVAILABLE row of the store, and the head of that row's manifest never answers (a new MemStore::stall_heads_of hook, tests.rs:949); it asserts the step text, the 5 s bound, no put, and the call left in flight. Reverting the mapping to .map_err(AdoptRefusal::Problem) fails that case.

Comment thread docs/differences-from-dynamodb.md Outdated
| Restore interrupted by the server stopping | Managed by the service | The copy runs in the server process, so a stopped process leaves its target `CREATING` with a RUNNING restore job record whose heartbeat stops. The engine's reconciler, once at startup and then every `backup.reconcile_interval_secs` (default 60 s), marks the record FAILED and removes the target once the heartbeat is older than `backup.stale_job_after_secs` (default 300 s) on the database's clock, on every backend alike, logs the removal with the table name, and then lets `DeleteBackup` and a new restore into the name succeed *(from the code)*. On SQLite a restart ages every record of the stopped process at once (one process serves the file), so the first cycle removes it without the stale window. Until then `DeleteTable` and `DeleteBackup` are refused as during a running restore. A store-backed restore (one run from a `[backup.store]`) is instead resumed, not discarded: its owner's death leaves a RUNNING restore job record with a stale heartbeat and the target `CREATING`, and the reconciler of any instance with the configured store takes the record over under a fresh owner generation once the heartbeat is stale (on SQLite at once after a restart, by the record's slot; on PostgreSQL once the owner's claim session has ended as well), stamping a fresh heartbeat in the same write so no second instance takes it too, and resumes the load from its recorded progress (per data file on PostgreSQL, per batch on SQLite, MongoDB and Cassandra), re-downloading to its own spool when the dead owner's `spool_host` is not this host's; the target reaches `ACTIVE` with the backup's full item count, its secondary indexes complete: a resumed Cassandra restore re-applies its last batch as idempotent upserts that rewrite the same base and index rows (the write path rewrites an unchanged item's GSI and LSI rows rather than deleting them, #424). A resume that finds no recorded progress and a target holding more items than the backup's total is `FAILED` with reason `target_not_empty` and the target is left in place: the rule protects a catalog restored from a dump that names a restore which finished and was written to since. A target holding no more than the total with no recorded progress is a first data file applied in part: on Cassandra, which records a batch's progress after its writes, an ordinary crash before the first progress record (the first 10,000 items or 32 MiB) leaves written items and no progress, and the resume re-applies the first file with idempotent upserts and reaches `ACTIVE` with the exact count *(from the code; the storage and engine cells)*. MongoDB records each batch's documents and its progress in one transaction, so a mid-file crash leaves no items the record does not account for and resumes to `ACTIVE`; only a crash before the first batch commits leaves an empty target, which resumes from the start. A record that one instance's reconciler has taken over five times without the restore ending (a resume whose final count or progress read fails every time, or that stops with the record RUNNING) is failed on the next takeover with reason `owner dead: taken over 5 times by one instance without the restore ending` and its target removed, so a record that can never finish does not hold its name and its backup for as long as the server runs; the count is per instance and a restart starts it over *(from the code)*. The target stays `CREATING` with a `FAILED` record: `DeleteTable` on it is `ResourceInUseException`, `DeleteBackup` of its backup and a second restore of that backup are `BackupInUseException`, and the reconciler does not remove it; `extenddb backup remove-target --account <id> <table>` (the admin guide's `backup` section) removes the target and its record, after which `DeleteBackup` and a new restore into the name succeed. A legacy (in-catalog) restore is still failed and its target removed. Measured *(binary-level, this release)*: SIGTERM during a store-backed restore left the record RUNNING and the restarted server resumed it to `ACTIVE` with every item (on PostgreSQL after the 45 s stale window, on SQLite at once); a SIGKILL at each restore kill point left the target to finish with the exact count (the base table's and the GSI's) or, for a failing restore, gone, except at the first reach of the Cassandra point that pauses before a batch's first progress record: on Cassandra (killed after the first batch's 10,000 upserts, before their record) the resume of that build answered `target_not_empty` and left the target `CREATING`, and `extenddb backup remove-target` then removed it, after which `DescribeTable` was `ResourceNotFoundException` and `DeleteBackup` succeeded; this release resumes that case with upserts to `ACTIVE` *(from the code; the nightly kill-point job runs it)*. On MongoDB a SIGKILL after a file's `insert_many` leaves the batch's items and its progress committed together, so the resume continues to `ACTIVE` rather than `target_not_empty`. Not covered *(from the code)*: on MongoDB, a target whose base copy had finished has its record DONE and is left alone by the reconciler; the index backfill worker finishes it after a restart, and if that backfill fails on every attempt the worker retries it every 5 s with a warning and never gives up, so the target stays `CREATING` (and `DeleteTable` and `DeleteBackup` stay refused) until the cause is fixed or the target's catalog document is removed by hand (no failing case was found to reproduce this). A target left `CREATING` by a build before this release is backfilled by the catalog migration as a RUNNING record with a heartbeat at the epoch, so the first cycle removes it. Heartbeats and the stale window are measured on the database server's clock, so the application hosts' clocks play no part. The same reconciler resolves a store-backed `CreateBackup` the server stopped (one reconciler with one duty list, in place of per-backend sweeps): a `CREATING` backup row whose owner is dead (on PostgreSQL and MongoDB a heartbeat older than `stale_job_after_secs`, on PostgreSQL once the owner's claim session has ended as well; on SQLite, where one process serves the file, every `CREATING` row after a restart and, at runtime, a row whose in-process slot no task holds, so a job task that died is resolved by the next cycle without a restart) is taken over under a fresh owner generation and becomes `AVAILABLE` when a complete, verified manifest is under its prefix in the configured store (the process died between the manifest write and the status write) and `FAILED` otherwise, never continuing the upload; the objects of a `FAILED` backup are deleted once its heartbeat is older than `stale_job_after_secs`; a `DELETING` backup's deletion is finished; a `DELETED` backup's prefix that still lists objects is swept; and `FAILED` and `DELETED` rows are removed once their prefix is empty and their `completed_at` is older than `failed_row_retention_secs` (7 days). Measured *(binary-level, this release)*: SIGKILL between the data file and the manifest left the row `CREATING` and the data file under the prefix, and after the restart the row was `FAILED` in the first cycle on SQLite and 46 s after the kill on PostgreSQL (`stale_job_after_secs = 45`), with the prefix deleted in the same cycle on PostgreSQL (the stale heartbeat is what made the owner dead) and once the dead owner's heartbeat was stale on SQLite, 45 s after the kill (the slot made the owner dead at once; the prefix waits for the heartbeat clock on every backend); the same at a kill between the three documents and the manifest, which leaves the documents under the prefix and no manifest; SIGKILL between the manifest write and the status write left the five objects, and the row was `AVAILABLE` with the manifest's size and count after the restart (SQLite at once, PostgreSQL after 48 s); SIGTERM during an upload left the row `FAILED` with reason `shutdown` and an empty prefix. These duties run only while the configured store is healthy, and under a maintenance claim only the dead-owner and `DELETING` duties run. A `FAILED` backup is `BackupNotFound` to clients; `extenddb backup list --failed` prints it with its reason |
| Point-in-time recovery | 35-day window: `UpdateContinuousBackups` enables continuous backups, and `RestoreTableToPointInTime` restores to any second in the window | Not supported; use on-demand backups (`CreateBackup` and `RestoreTableFromBackup`) instead. `DescribeContinuousBackups` reports `ContinuousBackupsStatus: ENABLED` (the service reports this unconditionally) with `PointInTimeRecoveryStatus: DISABLED` and no restorable-time window. `UpdateContinuousBackups` with `PointInTimeRecoveryEnabled: true` returns `ContinuousBackupsUnavailableException`; with `false` it returns the same description. `RestoreTableToPointInTime` checks the source table, then returns `PointInTimeRecoveryUnavailableException` ("Point in time recovery is not enabled for table '<name>'"), the exception the service models on that operation. A table that does not exist is `TableNotFoundException` ("Table not found: <name>") on all three operations, as on the service. Same on all four backends |
| Where backups live | Managed storage, independent of the table | With a configured `[backup.store]` (a filesystem root, which is the default beside a SQLite database file, or an S3 bucket and prefix) on PostgreSQL, SQLite and MongoDB: under the store's root, at `<account id>/<table name>/<backup id>/`, as gzip data files of DynamoDB JSON lines (`data/000001.json.gz`, ...) and the manifest documents (`extenddb-manifest.json` last, the commit record), with a `backup_index` row in the catalog as the index; a lost database loses the rows, not the bytes (a later release's `backup sync` re-adopts a root's backups into a new catalog). Without a store, and on Cassandra always, inside the database the server already uses, so a lost database loses its backups too: on PostgreSQL in the catalog database (`backups`, `backup_items`, `backup_definitions`, `table_restores`), on SQLite in the one database file, on MongoDB as a `backups` metadata collection in the catalog database and one data collection per backup in the data database, on Cassandra as backup metadata in the catalog keyspace and item payloads in the account's keyspace; take database-level backups for off-host copies there |
| Where backups live | Managed storage, independent of the table | With a configured `[backup.store]` (a filesystem root, which is the default beside a SQLite database file, or an S3 bucket and prefix) on PostgreSQL, SQLite and MongoDB: under the store's root, at `<account id>/<table name>/<backup id>/`, as gzip data files of DynamoDB JSON lines (`data/000001.json.gz`, ...) and the manifest documents (`extenddb-manifest.json` last, the commit record), with a `backup_index` row in the catalog as the index; a lost database loses the rows, not the bytes (`extenddb backup sync` gives a new catalog a row for each backup under the root, and `--adopt-store` first makes it the owner of a root another catalog owned). A prefix copied from a server of another region, whose manifest spells its ARN in that region, is adopted under this server's region: its row carries the ARN this server issues, so `ListBackups` names this server's region (every `ListBackups` answer measured on the service names the endpoint's region) and `DescribeBackup`, `RestoreTableFromBackup` and `DeleteBackup` find it under either region's spelling, as they find the backups this server made and as the service finds a backup under another region's spelling of its ARN (measured; `DescribeBackup` answers with this server's spelling, as it does for this server's own backups, where the service echoes the spelling sent); the manifest keeps the spelling it was written with, and so does the source table's ARN, which `DescribeBackup`'s `SourceTableDetails.TableArn` and the restore's `RestoreSummary.SourceTableArn` answer in the manifest's region (a backup this server made names this server's region in all three ARNs); the restore and the reconciler accept a manifest whose ARN differs from the row's in the region alone. Without a store, and on Cassandra always, inside the database the server already uses, so a lost database loses its backups too: on PostgreSQL in the catalog database (`backups`, `backup_items`, `backup_definitions`, `table_restores`), on SQLite in the one database file, on MongoDB as a `backups` metadata collection in the catalog database and one data collection per backup in the data database, on Cassandra as backup metadata in the catalog keyspace and item payloads in the account's keyspace; take database-level backups for off-host copies there |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor, new. The new sentence says that DescribeBackup's SourceTableDetails.TableArn and the restore's RestoreSummary.SourceTableArn answer in the manifest's region. ListBackups' BackupSummaries[].TableArn comes from the same stored JSON (BackupRow::summary, crates/storage/src/backup_catalog.rs lines 429 to 447), so it does the same, and the sentence leaves it out. No cell checks the region of any of the three fields for a backup adopted from another region. Fix: add ListBackups' TableArn to the sentence. In one of the adoption cells, write the manifest's source table in the other region and assert the three fields.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

docs/differences-from-dynamodb.md:127 at fa0bbd9 names DescribeBackup's SourceTableDetails.TableArn, ListBackups' BackupSummaries[].TableArn and the restore's RestoreSummary.SourceTableArn as answering in the manifest's region. a_row_over_a_manifest_of_another_region_restores (tests.rs:20323) is now an adoption cell: the seeded row is removed, the manifest's backup_arn and source_table.table_arn are rewritten in eu-west-1 with the sidecar recomputed, sync adopts the prefix under this server's region, and the cell asserts DescribeBackup's source_table_details.table_arn, ListBackups' BackupSummaries[0].TableArn (with BackupArn in this server's region) and the restore response's RestoreSummary.SourceTableArn, all equal to the other region's table ARN, and the restore still reaches ACTIVE with every item. Rebuilding the adopted row's table_arn in the server's region fails it at the Describe assertion; rebuilding the restore's source_table_arn from the backup's ARN fails it at SourceTableArn.

`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. A manifest whose ARN is in
another region is adopted under this server's region's spelling of the
ARN and logged, so ListBackups names this server's region and
DescribeBackup, RestoreTableFromBackup and DeleteBackup find the backup
under either spelling, as the service does; the manifest keeps its
spelling, and the dead-owner probe and the restore accept a manifest
whose ARN differs from the row's in the region alone
(`BackupArn::names_same_backup`, with `BackupArn::in_region`). 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
manifest's spelling, another region's, has a row; a row under this
server's 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,
size, item total and source table, the ARN in this server's region 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 by its ARN and reported,
with no listing of the rows of its status. 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. `HeldClaim` confirms the claim
by the job heartbeat's rule: each beat is bounded by one period
(`MaintenanceClaim::heartbeat_within`, whose bound on SQLite starts once
the beat holds the write lock), the claim reads lost once its last
confirmed beat is two periods old, and a beat in flight counts only
where it parks before its bound (`heartbeat_parks_before_its_bound`,
SQLite). `HeldClaim::recheck` sends one beat right before each step that
cannot be undone (sync's insert, `--prune-missing`'s removal,
`--adopt-store`'s rewrite of the identity file, purge's removal of a
dead CREATING row and the deletion of its prefix) and the command stops
there when the claim is lost, so a command stopped past
`stale_job_after_secs` on MongoDB or Cassandra, whose claim another
command took over, stops before its next such step; a call already sent
when it was stopped still lands. On SQLite every form runs under the
serve lock, and plain sync runs no dead-owner resolution: the lock
already keeps every server and command off the file, so the CREATING and
DELETING rows and the RUNNING restore a stopped server left are skipped
and its next start resumes the restore. `--prune-missing` and
`--adopt-store` take the claim there and run purge's resolutions first,
which fail such a restore and remove its target; every command that runs
them prints what they did, the failed restores included. 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` first runs the check the server runs
on the root as it will be once this catalog owns it (`compare` and the
manifest probe) and refuses a root this catalog cannot use with nothing
written (`AdoptRefusal::Unusable`, which replaces the state of a rewrite
whose check then failed); then it 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 runs it
on the store's bounded calls (`adopt_configured`): each store call under
the 5 s bound, the catalog's reads and the claim's beat beside it, and a
store that stops answering ends the command naming the step that did not
finish, the manifest probe of a root this catalog already owns included.
Every sync, plain or adopting, then reads the file once more and
fails naming the owner if another catalog adopted the root during the
run. 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`;
`backup sync` logs a root another catalog owns at WARN, since the
adoption or the refusal that follows says the rest. Plain sync's refusal
of a root another catalog owns says that every database file of a
directory shares the default root only when the root is that default
one.

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 and, on a
filesystem root, in a call slot and with an in-flight mark of its own,
so a deletion holding the root's slot for seconds does not hold it)
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, before a CreateBackup's row, and in the backup job
right before its first put, a refusal there failing the row with no
object written. DeleteBackup and purge also run it before they change
the row, so a DeleteBackup refused for a root another catalog owns, a
root with no file, or a file the store refuses to serve or that does not
parse answers 400 and leaves the row AVAILABLE with its objects, 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 root with no
file, a file the store refuses to serve and one that does not parse are
refused and recorded as the next check would record them
(`FileMissingWithRows`, `Denied`, `FileUnreadable`, `Validate`), so
CreateBackup answers the unhealthy-store ValidationException for them;
only a read that fails in transport refuses the call and leaves the
health to the next check: CreateBackup then answers InternalServerError,
and DeleteBackup takes it, before its DELETING write, as a store
failure: it writes DELETING, answers DELETED, and its second read
refuses the deletion, logged with what the fence found (an adoption only
for a file naming another catalog). 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 the
admin guide says to start a new in-memory server only after the old one
has exited. An S3 server error the SDK's retries did not clear (a 503
SlowDown) is not a transport failure at the fence: the store reports it
as it reports other refusals, so the fence records `failed validation`,
as the check does, and the store stays unhealthy until a check passes.

`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; on both store kinds
the listing is collected whole, one `ObjectMeta` per object held until
the run ends, before the run sees its first key, so a listing that
outlasts the bound reaches no prefix. Its store calls run as the checks
run theirs (`ConfiguredStore::calls_within`), so on a filesystem store
an abandoned run's walk of the root holds the root's call slot and is
marked in flight until it ends, and the check and the duties after it
are refused at once instead of starting a call beside it; a parked call
whose caller drops it before its bound is marked the same way. The mark
records which caller's call has not returned (`InFlightMark`), and the
refusal names it ("an abandoned startup sync's store call has not
returned"); that refusal records the store unreachable, so CreateBackup,
DeleteBackup and restores are refused until the walk ends, and the first
cycle after it checks the store healthy and runs the sync again. Until a
run has listed the whole root and logged its line of
counts, each run logs every skipped prefix (a run cut at the bound or
stopped by an error names none, so the next run names them); after that
a run of a sync left pending logs the store and catalog skips at WARN
and the rest at DEBUG, with the line of counts. Taking the pending sync
is the one gate, so of two callers one runs it. 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, PostgreSQL and MongoDB deployments (MongoDB's
row edits run `mongosh` in the replica set's container, which the
MongoDB backup-store job names; the cells with a second catalog skip
there, since a second MongoDB catalog needs a replica set of its own; no
Cassandra job runs the file): dropped rows rebuilt and Describe, List
and Restore working; every skip reason above; `--prune-missing` removing
the row, deleting no object and logging its audit line; 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; beside a server (PostgreSQL, MongoDB) 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, its summary line saying so, then adopted once it can be
read; beside a server `--adopt-store` waiting for a running job; another
region's prefixes adopted under this server's region, listed in it,
described by either spelling, restored and deleted by the other
region's; `--adopt-store` on a root whose file names another catalog
refused with nothing written while the newest backup's manifest is gone,
and adopting once it is back; a plain sync whose root's file is
rewritten during its prefix loop exiting 1 naming the catalog; and
`backup sync` exiting 6 and 3 on the catalog refusals; on SQLite plain
sync after a SIGTERM mid-restore leaving the RUNNING record, which the
next start resumes to ACTIVE with every item, and a store-backed backup
on the default root after `destroy` and `init` refused by plain sync and
adopted by `--adopt-store` (the command's `Caller::SyncCommand`). App
unit tests cover the decisions behind the last read, the transient-skip
exit, the summary line's word and the prune's exit for a row left for a
later run (`PruneCounts`); the wire cells pin their call sites. Engine
cells cover the claim's two-period rule, its beat bound, a beat in
flight on a backend whose beat parks and one whose beat does not (a
paused clock, a process stopped between beats), each re-check site with
a claim lost at it, and a claim that implements only `heartbeat` failing
a re-check at one period through the trait's default bound. Engine cells
also cover the fence: a missing identity file, an S3 denial and a
filesystem read refused at mode 000 recorded and CreateBackup refused as
unhealthy, the fence's own slot (a CreateBackup 1 s into a 4 s deletion
answering at once), the job's fence before its first put, the catalog id
kept for the process and the fenced deletion's log line; the adoption's
refusals with nothing written (`Unusable`, a missing file under rows,
rows of two store ids, a re-read under another store id) and its bound
naming the step, the manifest probe of an owned root included; the
region adoption, with the source table's ARN answered in the manifest's
region by DescribeBackup, ListBackups and the restore's summary, and a
restore and a resume over a manifest that spells the backup in another
region; a prune stopped after
its first removal; a stop inside the listing's loop making no
missing-manifest report; a second startup sync run's log lines, and a
first run that fails or is cut at its bound naming no prefix and the
next naming them at INFO; the taken startup sync; and the walk an
abandoned startup sync leaves in flight, the check refused on it naming
the sync's call, and the sync run by the first cycle after the walk
returns. A SQLite cell covers the
maintenance claim's beat parked behind the write lock, and the four
backends' insert cells now insert a row a year older than the insert, so
the two clock columns are told apart from `created_at`.
`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. `devtools/backup-kill-points.py`, which seeded a
Cassandra kill point's row with the matrix's removed `seed_row`, adopts
the copied prefix with `backup sync` beside the target's server, as the
matrix does.

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 (where it runs, what it records,
its own slot) and the rule to stop the previous owner's servers first),
the adoption's check before the rewrite and its bounded calls, the
startup sync's log levels, the `:memory:` row (with the remedy for a
root an earlier development build's in-memory server wrote; the prefixes
a stopped server had not finished writing or deleting, which no command
removes, the startup sync's "not adopted: not a complete backup" line
that names them, and the rule to delete by hand only a prefix with no
`extenddb-manifest.json` and no row in ListBackups; and what an
abandoned filesystem walk costs until it ends, with a smaller root or a
durable catalog for a root too large to list in 60 s) and the in-memory
refusal, the claim's heartbeat rule
and its re-checks, and what plain sync, `--prune-missing` and
`--adopt-store` do on SQLite with what a stopped server left; the
upgrade manual's rollback paragraph; the differences (an adopted backup
of another region, whose source table ARN keeps the manifest's region in
DescribeBackup, ListBackups and the restore's summary), 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, section 7, 5.3's `OTHER_STORE` step
and the testing summary's kill-point line 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. The backup job's line for a job failed on an unhealthy store says
its objects stay while another catalog has adopted the root.

Not here: `--move-store`, `--forget-store`, `--forget-backup`,
`list_backup_rows_by_root`, `rewrite_store` and the full report of the
rows of other roots (#820); `migrate-legacy` (#821).

Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Nov 10, 2026
`extenddb backup migrate-legacy <arn>` or `--all` (exactly one) copies
in-catalog backups into the configured store, each under its own ARN,
backup id, name and creation time, and replaces each by its catalog row,
AVAILABLE and unowned as a row `backup sync` inserts. `<arn>` is checked
as a backup ARN before anything is opened (exit 2) and resolved as
written and rebuilt in this server's region; `--all` takes every
in-catalog backup of every account that is not DELETED, oldest first,
goes on past one it does not migrate, and then, for every deleted one,
deletes what a stopped run left under its prefix and removes its
in-catalog record once the prefix is clean. The command needs the
configured store healthy, opens the catalog as `serve` does (on SQLite
under the serve lock, so the server is stopped for it), takes the
maintenance claim on every backend for its whole run and waits as
`purge` does, so every backup and restore job of the catalog answers
LimitExceededException until it ends.

Per backup, in this order: a backup id of the 13-digit shape a 0.1.x
release issued is refused before anything is read (every backup a 0.1.x
release took: a store prefix carries only the 14-digit shape); then a
table name the store-prefix rules refuse; a DELETING backup whose
replacement committed and stopped (MongoDB) is finished; a catalog row
of the id under either spelling of the ARN (the configured store's under
the stored spelling: an earlier run's, finished once its prefix reads
back as the copy is read, `verify_prefix`, the manifest's ARN and the
in-catalog backup's count, and refused naming the row, the in-catalog
backup kept, when the prefix is gone, short or another backup's, the
last saying to look at the prefix first, since the next run refuses on
that manifest and copies nothing; any other: refused, since the prefix
may be that row's), then under any ARN
and any account (`row_holding_backup_id`, refused); CREATING and
DELETING are skipped; a backup the backend cannot read (MongoDB's with
no item collection), a name the manifest refuses, what the legacy
restore refuses (a vector index, a multi-part key) and a SQLite backup
taken before catalog 0.0.4 are refused. Then the copy: objects under the
prefix with no row are an earlier run's when the manifest there, if any,
names this backup (one naming another refuses the backup with the prefix
left), and are deleted through the deletion fence first, by a walk of
the prefix that starts no removal more than two heartbeat periods after
the claim's last confirmed beat; the fence runs again before the first
put, and beside the claim's re-check before the manifest's put and
before the replacement, a root another catalog owns since stopping the
run with the copy and the in-catalog backup kept; the items stream
through the backup job's writer, each checked with the item validator
under this instance's limits; the manifest is written last with
`snapshot.kind` `legacy_in_catalog`, no marker, `unordered`, and
`source_backend` and `source_extenddb_version` naming this binary. The
prefix is read back through `verify_prefix`, sync's check, and the item
count is checked against the in-catalog backup's twice. A refused item,
a failed or short read or a copy that does not read back deletes the
copy (the objects this run wrote, one by one) through the fence and
keeps the in-catalog backup; after a failed or short read a backup no
longer AVAILABLE is skipped, "deleted during the copy", and one whose
status cannot be re-read fails, kept. An index row of the id met at the
replacement refuses it and keeps the copy. Before each step that cannot
be undone (the manifest's put, every deletion under a prefix, each
replacement call, a record's removal) the command re-checks the claim
with one beat, and a failure stops the run with what it wrote left, at a
copy's deletion too. A lost claim, a signal, an unhealthy store, a
failed listing or catalog call, and a replacement whose answer did not
come stop the run with exit 1; a signal is acted on before the next
backup and inside a copy at the writer's next step (the objects it
recorded deleted and named in a note), one received during a replacement
stops the run after it, and a second signal exits at once with 130,
leaving what a kill leaves. Exit 0 only when every backup reached is in
the store or deleted. One `extenddb::audit` line is logged per backup
the run deleted or replaced anything for, whatever its outcome (a
refused, skipped or failed backup whose earlier copy it deleted; every
Cassandra finish), none for one it left as it was (a finish beside an
in-catalog backup the replacement leaves, CREATING or, outside MongoDB,
DELETING, writes nothing); a stop says the claim is released only when
its release succeeded, and after a lost claim that the run no longer
holds it; a fence stop names the backup and says its in-catalog backup
is kept; a `refused` or `failed` line says the in-catalog backup is kept
only where it is (not once the claim is lost), and once (a failure whose
reason is the fence's stop does not repeat it); the summary counts
migrated, finished, already in the store, deleted, refused, skipped and
failed apart.

Two runs can meet under one prefix: one stopped past the stale window
(SIGSTOP, a paused VM) whose claim a second run took. Every object of a
copy is therefore written with `put_if_absent`, and an object already
under a key stops the run with that object left (another run's, or this
run's own put that the SDK retried after a lost answer and S3 answered
412; the stop says both); after a put that failed, the key is read back
with `head` and the object found is recorded with the store's metadata,
and a key the head cannot read is left to the next run, since a deletion
without a condition could reach another run's object; a run deletes its
own copy object by object, only what its writer recorded, with the store
trait's new `delete_written` (S3 `DeleteObject` with `If-Match` on the
put's ETag, a 409 ConditionalRequestConflict re-sent as the conditional
put re-sends it; a filesystem root renames the file aside, removes it if
its size and modification time are the put's and links it back
otherwise; the trait's default, for a test double with neither, deletes
the key only when nothing is under `key/`), reading the claim before
each; the prefix walk is kept for a leftover no row holds. A call
already sent when the process stopped completes on resume: such a put
cannot replace another run's object, and such a deletion reaches another
run's object at its key only on S3 when the bytes are the same, or on a
filesystem root stopped again between the rename and the link back. With
`put` and `delete_prefix`, a run stopped at a data file's rename, or at
its deletion's first removal, and resumed after a second run migrated
the backup changed the second run's AVAILABLE copy; with this change
both stops leave it byte for byte (PostgreSQL and MongoDB, under gdb).

A backup DeleteBackup answered DELETED never comes back from a copy a
stopped run left. The replacement deletes the `backups` row on
PostgreSQL and SQLite, so a DELETED row there is always a
DeleteBackup's; MongoDB's replacement marks its document; Cassandra's
converts the row. `LegacyBackupReader::holder_status` answers for an
in-catalog backup in any status that the replacement did not replace,
and `list_deleted` lists the DELETED ones: `backup sync` never adopts a
prefix whose ARN such a backup holds, naming the command for a deleted
one, and `migrate-legacy` deletes that copy through the fence, lists the
prefix again and, once it is empty and no catalog row holds the id,
removes the DELETED record (`remove_deleted`), reporting the backup
deleted. So a DELETED record whose id has the 14-digit shape is a prefix
not yet confirmed clean, and PR 14's migration is to refuse while one
exists. A deleted backup whose ARN an AVAILABLE row holds again (a sync
of an earlier release adopted a stopped run's copy) is refused, naming
the row and the choice. Before a deletion under a prefix the command
looks the id up by `BackupCatalog::row_holding_backup_id(backup_id)`, by
the id alone: the index holds it unique, so the inserts conflict on it,
another account's row included; Cassandra, which indexes no backup id,
answers none and is covered by the manifest check. `finish_replacement`
finishes MongoDB's replacement that committed and stopped before its
drop, whatever the catalog row holds since.

The manifest's source table is the table the legacy restore creates
(`source_table_from_legacy`, pinned by an exhaustive property against
`restore_input` and `restore_input_from_manifest`), with the recorded
table class, TTL and stream and the SSE description of the recorded
specification, its key ARN built in the server's region as the legacy
DescribeBackup builds it, so the stored specification's `KMSMasterKeyId`
is not carried over; no answer reports a key id before or after. The
table's and each GSI's throughput are the ones the backup recorded, none
on demand: a GSI of a provisioned table that recorded none or 0/0 (a
table created on demand and switched with UpdateTable) keeps that, and a
backup with no definition and no recorded throughput records none, so
DescribeBackup reports the same throughput before and after the
migration; a restore of the copy gives such a GSI the table's throughput
and such a table 5/5, as the legacy restore does
(`restore_input_from_manifest` reads a provisioned `source_table` with
no throughput as 5/5; only such a copy has one). DescribeBackup then
differs only in `BackupSizeBytes`, `TableSizeBytes` and, for a source
that recorded no index, stream, TTL or SSE, `SourceTableFeatureDetails`,
which the legacy answer omits and the row's reports as `{}`, as for a
store-backed backup. The vector state is `[]` on PostgreSQL and SQLite
and `null` on MongoDB and Cassandra. Its index lists are the manifest's
own `BackupGsi` and `BackupLsi`, which record no GSI warm throughput.

The replacement is one step per backend,
`LegacyBackupReader::replace_with_index_row`, in place of an insert
followed by the reader's delete, which let a DeleteBackup between the
two answer DELETED for a backup that then came back, and which cannot
work on Cassandra, where both rows share one key. PostgreSQL: one
transaction (the row `FOR UPDATE`, the CREATING-target check, the
insert, then the items, the definition and the row deleted). SQLite: the
same under the write lock and `BEGIN IMMEDIATE`. MongoDB: one session
transaction that inserts the row and marks the document DELETING with
`migrated_to_store`, then the collection's drop and DELETED; a commit
whose answer is lost is settled by reading both back. Cassandra: the row
converted in place by a lightweight transaction `IF backup_status =
'AVAILABLE' AND store_kind = null AND active_restores = null`, then its
list and status entries and the item buckets' deletion; an unknown
outcome is settled by a serial read, a condition that failed at every
attempt with no answer lost wrote nothing and answers
`Replaced::NotApplied` (the backup fails, its copy deleted and the
in-catalog backup unchanged), and a second call finishes a conversion
that stopped after the row, writing entries only for a row still
AVAILABLE; a stop whose answer did not come names `migrate-legacy <arn>`
on Cassandra, since `--all` lists no converted row. Four frozen paths
change so a request orders wholly before or after a replacement:
PostgreSQL's delete reads the status under its row lock, MongoDB's reads
a `migrated_to_store` document as not found, and Cassandra's delete
claim and its legacy restore registration carry `store_kind = null`.
Only the DeleteBackup that changes an in-catalog backup answers DELETED
on every backend, every other one, concurrent or later,
BackupNotFoundException: PostgreSQL reads the status under the row lock,
SQLite again under the write lock, and MongoDB and Cassandra finish
another DeleteBackup's DELETING and answer not found (before, MongoDB
and Cassandra answered DELETED to two or three concurrent callers by
timing, and SQLite to both of two). The reconciler removes a Cassandra
row that `abandoned_creating_backups` fenced to DELETING through the
reader's new `delete_fenced`, the second half of its delete, which
answers Ok for a DELETING or DELETED row, so the removal is counted and
gets its audit line where `delete` answered it not found.

`LegacyBackupReader` also gains `items(account, arn)`, a frozen copy of
each restore's read as a stream (PostgreSQL a REPEATABLE READ catalog
transaction held for the copy, SQLite keyset batches each re-checking
AVAILABLE, MongoDB a cursor with `batch_size` and a frozen decode,
Cassandra each bucket paged 8 rows at a time at consistency ALL, since
the in-catalog create wrote at ONE: a replica that does not answer fails
the backup naming it, and a count the read does not reproduce names a
repair before the rerun, not the legacy restore, which reads at ONE),
and `list_all()`, which returns a row that does not decode as an error
(Cassandra's scan of `backups_by_arn` read in pages of 1,000 rows). The
core format gains `SnapshotDescriptor::KIND_LEGACY_IN_CATALOG`; no
format version or fixture byte changes. The writer runs under a
`ClaimWatch`, the job's heartbeat or the maintenance claim, whose loss
now cancels a token; the job's tests run unchanged. `HeldClaim` keeps
the job's rule #825 gave it (each beat bounded by one period, a claim
with no beat confirmed within two periods read as lost, a beat in flight
counted only on SQLite, where it parks on the process's own write lock)
and adds one restriction: a parked beat counts only when it started on
schedule, within 1.1 periods of the last confirmed one, so a process
stopped and resumed is not confirmed by its late beat; it also dates
each confirmation at its answer and gives `acting_deadline()`, two
periods after it, and `lost()` reads the last confirmation and the beat
in flight under one lock, which the timer writes as one at a beat's end,
so a parked beat that confirms is never read lost as it ends.
PostgreSQL's maintenance take now refuses while the claim row's
heartbeat is younger than `stale_job_after_secs`, even with the advisory
lock free, as the other backends' take does: a holder whose catalog
session ended runs until its next beat finds the loss. So on PostgreSQL,
MongoDB and Cassandra a holder starts no call more than two periods
after the answer of its last confirmed beat, which wrote the heartbeat
at most one period before that answer, and a taker needs a heartbeat at
least three periods old (on SQLite the serve lock keeps others out);
after a killed command on PostgreSQL the next one fails, naming the
holder, until the window passes. Purge, sync and this command take the
claim through one shared function, and on SQLite the dead-owner pass
logs its removal of a legacy backup left CREATING under
`extenddb::audit`. The replacement answers AlreadyExists for a row of
the id under another ARN or store whatever the in-catalog backup's
status.

`backup sync` skips a complete prefix whose manifest's ARN an in-catalog
backup holds, under either spelling, naming `migrate-legacy <arn>` (a
prefix a run left before its manifest is reported incomplete, as any
other). DescribeBackup, DeleteBackup and RestoreTableFromBackup read the
catalog row once more after a legacy not-found (RestoreTableFromBackup
also after its registration's), and ListBackups reads the legacy reader
first, so a replacement between the two reads never answers
BackupNotFound or drops the backup from a page; ListBackups lists an ARN
both readers hold once, with the row's summary.

Tests: per-backend cells for the stream (batch and bucket boundaries, a
pinned snapshot, a delete ending it, the AVAILABLE re-check inside
PostgreSQL's snapshot), `list_all` and `list_deleted` across accounts,
`holder_status`, `remove_deleted` and `row_holding_backup_id`, the
replacement's answers, a real replacement and a DeleteBackup queued on
each other in both orders (PostgreSQL), PostgreSQL's take of a young
dead claim refused, MongoDB's finish and lost commit answer, Cassandra's
lost answer and stop and the item read's consistency and replica error,
SQLite's maintenance beat parked on the write lock, the copy's recorded
throughput and the 5/5 restore of a provisioned copy that recorded none,
the store's `delete_written` on a filesystem root and on S3, a legacy
restore registering after a conversion, a registration between the
conversion's read and its transaction, a conversion finished after a
delete, a conversion that never applies, the paged listing, a client's
not-found and the reconciler's Ok for a fenced row side by side, and two
concurrent DeleteBackups of one in-catalog backup on SQLite, MongoDB and
Cassandra; 60 engine cells on `Fake` and `MemStore` (each refusal,
DescribeBackup before and after a migration for eight shapes of
definition and throughput, a finish beside an in-catalog backup it
leaves, on MongoDB too, the store trait's default `delete_written`, an
object another run wrote, a failed copy deleting only its own objects, a
loss read
during that deletion, the leftover walk's deadline, the fence at each
deletion and beside the re-checks before the manifest and the
replacement, a finish whose row's prefix is gone, short or another
backup's, a status re-read that fails, a loss recorded mid-walk, a put
whose answer was lost, a conversion that did not apply, the SSE key
ARN's region, the counts, a store serving another manifest, every rerun
rule, a deleted backup's copy and record, a row of the id under any ARN,
a claim taken over before each step that cannot be undone, a stalled
heartbeat, signals during a copy and a replacement, requests racing a
replacement, the sync skip, the dedupe) and ten of `HeldClaim` (four of
them #825's, extended here; the 1.1-period window on a paused clock;
parked beats confirming back to back read by a thread without pause; a
late beat in flight confirms nothing where it does not park, and #825's
in-flight cell now finds a parked beat that starts late confirming
nothing); wire cells on SQLite, PostgreSQL and MongoDB (two in-catalog
backups migrated, described, listed, restored and deleted under their
ARNs with the in-catalog rows gone; `--all` past a #618 backup and a
13-digit one, both kept; a stopped migration's copies of two deleted
backups, not adopted and then deleted; the claim against a second
command, on PostgreSQL and on MongoDB beside a live holder, and the
serve lock); SIGKILL at the three kill points on SQLite and PostgreSQL,
each rerun finishing, and a kill or a refused deletion followed by
DeleteBackup, the backup staying deleted (PostgreSQL on S3 too); a first
run whose claim a second took over (PostgreSQL after its session ended,
MongoDB stopped past the window and resumed with the catalog paused),
the second's backup left whole; a deleted backup's record removed only
once its prefix is clean (SQLite). 205 mutants of the new tests, all
killed. Peak RSS 48 to 56 MiB flat on PostgreSQL (75 at most measured
beside a server); on SQLite 55 to 149 MiB, its page cache, bounded by 32
MB times `pool_size`.

Docs: the backup manual's legacy section (the command, its cost, what it
refuses and the way out, what a migrated backup reports, a stopped run
and a deleted backup's copy and record, MongoDB's and Cassandra's
stopped replacements, Cassandra's recurring step, memory); the upgrade
manual's store, 0.1.x and rollback paragraphs and `--all` after the last
DeleteBackup; the admin guide's paragraph, sample output and
`stale_job_after_secs` row; the differences doc's two rows and a row for
concurrent DeleteBackups of one backup. Design revision item 37, with
sections 3, 4(c), 4(f), 4(g) and 7, the PR 10 paragraph, its
maintenance-claim paragraph and transitions, the PR 14 lines (rows that
are not DELETED, and DELETED records whose id has the 14-digit shape)
and section 10's row edited with it.

Not here: removing the in-catalog reader (#749); a 13-digit backup
carried into the store format; a Cassandra wire leg (direct tests only).

Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
@robinnsc

Copy link
Copy Markdown
Collaborator Author

Pushed fa0bbd9 (47 files against main 887b6e2; 7 files, +227 −72 against 4812e3e). Your review on 84bc263 arrived while the changes of the next push were being made, so that push did not see it. Each of the five open items is answered in its thread: the Major one is documented rather than re-armed, with the reason there, and the four Minor ones are fixed as proposed. On this head cargo test -p extenddb-engine --lib passes 701 (338 under backup::), app 50 and server 375 pass, fmt and clippy with -D warnings are clean including the storage test-hook features, and the nightly matrix passed on the branch: https://github.com/ExtendDB/extenddb/actions/runs/42775123844.

@LeeroyHannigan LeeroyHannigan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Round 4, at fa0bbd9. This head fixes all five round-3 items. For the Major, design item 34 and the admin guide's path row now describe the abandoned walk as the code runs it. The walk holds the root's call slot and the in-flight mark, and the server refuses each check until the walk ends. The first healthy cycle after it syncs again from the first key. I agree with your reason not to re-arm the sync: the filesystem walk has no cancel flag.

One new Minor item has an inline comment. It is not a condition for my approval.

again from the first key of the root{}",
store.kind,
STARTUP_SYNC_TIMEOUT.as_secs(),
if store.kind == StoreKind::Filesystem {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor, new. The clause "(once the abandoned listing of the root has returned; until then the store is unreachable)" depends on store.kind alone. So every filesystem run cut at the bound prints it. A run cut after its listing ended has no abandoned listing. The run lists the root first (line 502). Then it reads the manifests through the bounded calls and inserts the rows through the catalog (line 739), which the bounded calls do not cover. After a cut in a catalog call, nothing sets the mark. After a cut in a manifest read, the mark clears when that read returns. The next check then runs as usual. The case that the clause is for can end in the Err arm instead (lines 1103 to 1107). Your PR body says that in the cycle cell the listing's own 60 s bound ends the run first, with Err(Listing). That line does not say that the store is unreachable. No cell checks the text of the timeout branch. Fix: after either arm, choose the clause on store.check_call_in_flight(), not on the store kind. Word it for any store call of the run that is still running. Change the comment at lines 1036 to 1038 to match.

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backup/restore: backup sync, --adopt-store, --prune-missing, the sqlite-memory startup sync

3 participants