Skip to content

feat(backup): [backup.store], the store identity file, backup purge [backup/restore 7] - #672

Draft
robinnsc wants to merge 1 commit into
feat/backup-catalog-indexfrom
feat/backup-store-config
Draft

robinnsc wants to merge 1 commit into
feat/backup-catalog-indexfrom
feat/backup-store-config

Conversation

@robinnsc

@robinnsc robinnsc commented Oct 30, 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 · PR 6 the catalog index · this PR: configuration, the store identity file and backup purge · next: the CreateBackup job and worker (PR 8), the restore job (PR 9a).
Closes #670 (part of #624).

Stacked on PR 6 (feat/backup-catalog-index, head a4ece880), itself on #666's d4187ea9 (main through #395, #638, #412, #511, #471): this PR adds nothing to the catalog schema (PR 6 created backup_maintenance, the store_* columns and catalog_id); it binds them. The diff shows this PR's single commit on top of PR 6's. Where the two meet: the reconciler's cycle carries PR 6's ReconcilerState (here with the shutdown token), worker metrics and FAILED-target duty, whose three-way Removal reports a target left to its owner (targets_left_to_owner, which backup purge's zero-window pass prints), beside this PR's store check and DELETING duty; the permit refusal is PR 6's limit_exceeded_message(JobBound) naming [backup] max_concurrent_jobs, while the maintenance refusal keeps its own text naming the command; CreateBackup's request checks run in PR 6's order (check_backup_arn_fits: the region, then the table name, before the ARN), then the maintenance claim, then the permit; RestoreTableFromBackup's run in one order too: PR 6's target-name check through TableEngine::describe_table (check_restore_target_name), the backup row, this PR's store-health refusal on a row of the configured store, the maintenance claim, the permit (the engine's restore doc and the design's 5.3 step 1 with revision item 28 state it); the SQLite snapshot-slot refusal names PR 6's [storage.sqlite] pool_size snapshot-slot bound, and the limits doc's bound-naming sentence carries this PR's exception for the maintenance claim; DeleteBackup over a row builds its description before the DELETING write (PR 6) and then deletes the objects (this PR); backup purge's claim heartbeats at PR 6's JOB_HEARTBEAT_INTERVAL; the SQLite beside_server path builds the engine through the one constructor, so the backup slots it shares with #395's snapshots and PR 6's synchronous permit are the server's; Cassandra's list_dead_restore_jobs lists PR 6's DELETING targets with their registration and reads the coordinator clock after its scans (this PR); the engine's test double carries PR 6's record_still_running and its TableEngine (tables) beside this PR's row and store knobs, and PR 6's other-store cell builds its runtime through this PR's ConfiguredStore; every Cassandra statement here names its keyspace, as #638 requires.

What

The [backup.store] configuration with its per-backend defaults and validation, the store identity file and the health component, put_if_absent on the store trait, the maintenance claim on every backend, DeleteBackup running against the configured store, and extenddb backup list and backup purge. No request or response shape changes; the API-visible changes are the refusals of a store-backed row's restore and delete while a configured store is unhealthy or its first check pending, the LimitExceededException of CreateBackup and RestoreTableFromBackup while a maintenance claim is held, and the /health body (reason is the problem's category alone; checking before an S3 store's first check). backup list and backup purge say why there is no store (the default root failed its check, the section absent, disabled, the root not yet created); a defaulted root with rows for it is reported and refused as a configured one; the refusal before the first check says the first check is pending. backup list and the dry run give up on a store that does not answer after the check's bound; serve and the commands exit within 5 s of their last work with a thread parked on a stalled mount; a FIFO at the identity key is reported as neither readable nor creatable; a root with edge whitespace is quoted in the banner and the startup error.

Store (crates/backup-store). BackupStore::put_if_absent(key, body) -> Option<ObjectMeta> creates the object only if none is under the key and returns None, leaving the existing object untouched, otherwise. S3 sends PutObject (or CompleteMultipartUpload for a body over one part) with If-None-Match: *; a 412 is "present", a 409 ConditionalRequestConflict is re-sent after a back-off of 100 ms doubling to 1.6 s, eight waits in all, and a conditional complete that finds the key present aborts its upload. The filesystem store writes and syncs its temporary as put does (created O_CREAT | O_EXCL) and links it to the key, which fails if anything is there, so the object appears complete or not at all; the temporary is unlinked on every path; the trait doc says a failed parent sync after link leaves the object. put's temporary write and ancestor sync are factored out and shared; put_impl's unreachable arm is gone. BackupStoreKind::Disabled and open_described (the store with its kind and root label: the canonical directory, or s3://<bucket>/<prefix> with ?endpoint=<url> for the endpoint the client uses, from the config or the SDK's environment and shared config, spelled one way: lowercase scheme and host, no default port, no / for an empty path, no user information, query or fragment) are new; validate keeps a transport failure's class (the bucket joins the error chain) so a caller can tell it from a configuration error. check_private_directory(dir, what) is exported for the spool. README and module docs updated; the README names the S3-compatible-server caveat (a server that ignores If-None-Match makes the create a plain put) and the hard-link requirement (CIFS and FUSE mounts that refuse link with EPERM cannot hold a root).

Config (crates/config/src/backup.rs). store: Option<BackupStoreConfig> under [backup] (the crate's type, nested under its own key so deny_unknown_fields holds on both tables). resolve_store(database_file) applies the per-backend rule: an absent section on a file-backed SQLite database is a filesystem store at backups/ beside the file, marked defaulted (created 0700 by the server; a failure of its open, validation or identity check is a warning that leaves the store unconfigured); on a server database or an in-memory one it is unconfigured; kind = "disabled" is unconfigured on purpose. The spool: required with an S3 store except beside a database file (backup-spool/); an explicit spool_path is refused inside or containing a filesystem root, and when it is or contains the database directory, compared on normalized and, where they exist, canonicalized paths; a filesystem root that is or contains the database directory is refused beside the spool rule; a placement refusal spells both paths as compared. A relative [backup.store.filesystem] path or spool_path is made absolute against the working directory at load (make_paths_absolute), as storage.path is; an empty path is compared with nothing in resolve_store and left for open to refuse. StorageConfig::database_file (default None; SQLite answers its file through the one database_file rule it already has) is how the server learns the backend's file. The init template and extenddb.sample.toml carry the commented block.

Storage (crates/storage/src/backup_catalog.rs). read_store_ids(store_kind, store_root): the distinct ids of a root's rows, every status. maintenance_claim(holder, stale_after) -> Box<dyn MaintenanceClaim> and check_maintenance_claim(stale_after): the claim is backup_maintenance's one record, born owned with a fresh generation, heartbeated and released through the handle; a holder whose heartbeat is older than the stale window is dead, its claim is not reported held and is taken over; a second live holder is AlreadyExists naming the first; on PostgreSQL the check tests the advisory lock first (a transaction-scoped shared try-lock in one autocommit statement, so a cancelled request leaves no lock on the pool), so a live session is held whatever its heartbeat says; on SQLite a server's startup under the serve lock removes the record outright. remove_dead_creating_backup_row(key, stale_after): a CREATING row whose heartbeat is older than the window is removed outright (the maintenance command's write). ServerComponentsOptions::beside_server: a read beside a server takes no SQLite serve lock and runs no startup recovery (the catalog's status is still read, on a read-only connection); on PostgreSQL the factory starts the GSI queue workers only for a server. Per backend: PostgreSQL takes an advisory lock (backup_maintenance, the shared seed) on a dedicated connection as the proof of a live holder and upserts the row, and release deletes the row and closes the session; SQLite takes the row under the engine write lock; MongoDB takes over a stale record with a conditional update and inserts a missing one with an upsert whose filter a live record never matches ($expr is not allowed in an upsert's filter, so the two are two writes); Cassandra inserts IF NOT EXISTS and takes over a stale row IF owner_generation = <old>, its claim-row reads run at LOCAL_SERIAL, and an insert whose answer was lost is settled by re-reading the generation; its list_dead_restore_jobs reads the coordinator clock after its scans. MaintenanceClaim is a new trait (generation, heartbeat, release).

Engine (crates/engine/src/backup/). BackupRuntime.configured_store is a ConfiguredStore: kind, root label, the store handle, the spool (path, spool_host = <hostname>:<path>), the id once a check has read it, and the health as the last check left it: Checking until an S3 store's first check, Healthy, or Unhealthy; a transition between categories is logged once; a check that cannot read the catalog changes nothing. check_configured_store runs validate (until it has passed) then establish or recheck, under the store's bound (5 s for a filesystem root, 30 s for a bucket, under the SDK's per-attempt timeout), recording a timed-out check as unreachable; a filesystem root's calls run as tasks of their own, so an abandoned check's call stays parked on one thread until the mount answers and no check starts while it is (recorded unreachable again); an S3 store's abandoned request is dropped with the check; read_identity_bounded is the command's identity read under the same bound; the reconciler's cycle, which selects it against the shutdown token, and the backup command call it. A transport problem's text is the store error's whole cause chain; every other store error of a check is failed validation. store_identity runs the checks: establish reads extenddb-store.json, creates it with put_if_absent when the root has no file and the catalog no rows for the root, re-reads it, and compares it with catalog_id and the rows' ids; recheck re-reads and compares, rewriting a lost file with the known id when no rows exist; both end with the manifest probe (head of the newest AVAILABLE row's manifest). The identity file is read up to 64 KiB. The problems: another catalog's file, an id the rows do not carry, more than one id on the rows, a missing file with rows, a create race lost to a store that ignored the condition (the re-read holds another id), a missing manifest, the store unreachable, a failed validation, the file unreadable, the catalog unreadable; each has an operator line naming the ids and a /health reason that is the category alone. require_healthy_store is the ValidationException naming the kind and the category, or, before the first check, that the first check is pending. CreateBackup takes the in-catalog path whatever the store's health (the gate behind STORE_BACKED_CREATE is PR 8's) and logs that the store is configured; the in-catalog create and the restore refuse with LimitExceededException while a maintenance claim is held, after every other check and before the permit (refuse_while_maintenance_held; the check runs before the job's row, which the backends commit and detach from). HeldClaim heartbeats a maintenance claim on a timer for its life (Drop stops the timer; release waits for a heartbeat in flight). DeleteBackup of a configured store's row runs finish_backup_delete after start_backup_delete: the manifest, the prefix, a listing that must be empty, set_backup_deleted; a failure leaves the row DELETING and answers InternalServerError. The reconciler's cycle runs the store's check each cycle, records the outcome, and while healthy finishes the configured store's DELETING rows; while unhealthy it touches no row of the store; while a maintenance claim is held it runs only the duties the command waits on or finishes itself (the branch the later duties go in is empty and says so); check_maintenance_claim takes the stale window. maintenance also carries wait_for_quiet (WaitOutcome), purge_row and Interrupt, the purge's steps against the catalog trait, tested against the engine's double.

Server (crates/server/src/backup_store.rs, lib.rs). Startup resolves and opens the store, takes the spool (0700, .lock 0600 under flock; another live holder is a startup error naming the path; an existing spool must be owned by the server's user with mode 0700, parents included), and for a filesystem store runs validate and establish under a 5 s bound (LOCAL_CHECK_TIMEOUT, in the engine beside S3_CHECK_TIMEOUT, 30 s; past it, a warning and the store checking; a root that does not answer before open holds startup, as the database directory would); an S3 store's validate and establish run in the reconciler's first cycle, after the listener is up, so a stalled endpoint never holds the data plane (the store is checking until then; a validate refusal there makes it unhealthy). A configuration error that needs no request (open refusing the configuration, the spool rule, an S3 store without a spool on a backend with no default) stops startup with the path or bucket named; the defaulted SQLite root turns a validation or identity-check failure into a warning that leaves the store unconfigured while the catalog has no rows for it (read_store_ids); with rows for it the root is handled as a configured one, and configure_described carries the reason for an absent store out (NotConfigured) for backup list and backup purge to print. A filesystem root's I/O failure at validate's probe write degrades the store rather than stopping startup; the owner-only and existence refusals stay startup errors. A transport, identity or probe failure, or a check that does not finish within its bound, leaves the server running with the store unhealthy and logs it. /health is {"status": "healthy" | "degraded", "components": {...}}, HTTP 200 either way, with backup_store (status healthy, checking or unhealthy, kind, and when unhealthy reason, the category) when a store is configured; the console banner names the store beside the storage line, and the log names its id and the spool.

App (crates/app/src/cmd_backup.rs). extenddb backup list [--failed] prints the catalog's rows for the configured store's root, every store id they carry, with each row's store_root and store_id; it is a read beside a server (no SQLite serve lock, no startup recovery), reads the identity file without creating one, under the store's check bound (5 s for a root, 30 s for a bucket), runs on an unhealthy store naming the problem in its header, and prints (none) with exit 0 when the defaulted root does not exist yet. extenddb backup purge --confirm opens the catalog as serve does (on SQLite under the serve lock), refuses an unhealthy store, prints the plan, takes the maintenance claim and heartbeats it for its whole run (HeldClaim at JOB_HEARTBEAT_INTERVAL; a lost claim stops the deletion loop before its next row), on SQLite runs the reconciler's duties with a zero window before the wait (a legacy restore the stopped server left RUNNING is failed and its target removed (a manifest one, PR 9a's, is left for its resumer and waited on), the CREATING target of a FAILED restore removed, an in-catalog CREATING backup removed, a DELETING row finished, a target that is no longer the restore target its record names printed as left), waits up to 2 * stale_job_after_secs until no CREATING or FAILED row has a heartbeat within the window and no restore job is RUNNING, refuses and releases if they have not gone (and releases on a catalog read failure), then deletes each row: a CREATING row with a dead owner through remove_dead_creating_backup_row first and its prefix after, every other row's prefix (manifest first) and then the row outright through the status machine and remove_backup_row, leaving extenddb-store.json, exports/ and prefixes with no row; each deletion is logged under extenddb::audit. A row a DeleteBackup moves to DELETING or DELETED, or removes, between the command's read and its turn is finished and counted as deleted; on SQLite the owner window is zero (under the serve lock every owner is dead), so a CREATING or FAILED row with a young heartbeat is deleted, not waited on, and a server starting on the file clears a maintenance claim a killed purge left; SIGINT or SIGTERM releases the claim at once during the wait and before the next row during the deletion, and the wait's failures (jobs still running, the claim lost, the catalog unreadable, a signal) are each reported as themselves. Without --confirm it is a read like list: it prints the plan, changes nothing and exits 0, with no store configured as with one (an empty plan naming the reason). --config is accepted before or after the subcommand. Every command and serve end their tokio runtime with a 5 s bounded shutdown, so a thread parked on a mount does not hold the process open past its drain. extenddb destroy's help and output say the store is left untouched and that a re-created catalog needs the old root's extenddb-store.json removed or a new root.

Docs. Admin guide ([backup.store] section: the rules, the identity file, the health component, backup list/purge, the shared-root rule, the re-init paragraph; /health examples and the reason list with catalog unreadable; the spool_path row), the limits doc's backup store row, the upgrade manual (configure a store before the release that stops the in-catalog create; disabled as the opt-out; the SQLite default root rule and what the warning means; the re-init sentence; the rollback paragraph's store note), the deployment guide's multi-instance notes (/health degraded, one shared root, one spool per instance, the hard-link requirement, two host-local roots on an empty catalog), the sample config and init template ("will live, from a later release"), the store crate's README, the four backends' and the engine's module docs, design revision item 28. The four licence notice files are regenerated: the server binaries now link the store crate's AWS SDK tree (Apache-2.0, MIT, ISC), which cargo about adds.

Why

#396 sections 4(c), 4(f), 7, 8, 9 and the PR 7 section. The store-backed CreateBackup (PR 8) and the restore job (PR 9a) need a configured, validated store whose identity the catalog's rows carry, a health state that refuses work when the root is not the one the rows describe, and a maintenance claim that excludes jobs on every instance while an operator deletes, which this PR already applies to the in-catalog create and restore, before their row. Landing them first, with the identity file and the health component, lets PR 8 write rows under an id that is already checked at startup and each cycle.

Decisions taken while implementing, each stated in the module doc of the code it concerns: maintenance_claim takes the holder's label and the stale window and returns a MaintenanceClaim (heartbeat and release) rather than a JobClaim, and check_maintenance_claim takes the stale window (the design's signatures had no duration to measure a dead holder by, and no release); a configured store whose startup check failed keeps its id unknown, and a row of its kind and root is a row of this store for the refusals until a check reads the id; CreateBackup takes the in-catalog path whatever the store's health (no store-backed writer exists on this head); an S3 store's checks run after the listener so an endpoint cannot hold startup; purge's "live owner" for a CREATING row is measured on this host's clock against stale_job_after_secs, which can only lengthen the wait; purge waits on FAILED rows with a live heartbeat and deletes CREATING rows with a dead owner, the window being zero on SQLite; the restore part of its wait set is the RUNNING records alone: the FAILED records the zero-window listing also returns are the reconciler's duty; purge does not wait on DELETING rows but finishes them itself (nothing heartbeats one, the sequence is idempotent, and on SQLite no reconciler runs while the command holds the file); the jobs' claim check runs before the row on this head; the MongoDB claim is two writes.

Testing done

Counts are of the whole tree on PR 6's head a4ece880; "new" is against that head.

  • extenddb-backup-store: lib 37 (4 new: the conditional outcomes from synthetic replies, the root label in one spelling from four spellings of an endpoint, a validation transport failure keeping its class, disabled); conformance 123 (7 new: put_if_absent creates once and leaves the first body on both stores, the second call on the large key multipart so the aborted upload on S3 is exercised; eight concurrent callers on one key leave the winner's body, three times over, both stores; the filesystem refusals; an empty root path refused by name; a scripted HTTP/1.1 server answering 409 twice then 200, 409 then 412, and 409 forever, with the back-off measured), against moto at 127.0.0.1:9200; the SDK-environment cells also assert store_root() names the env-, profile- and services-sourced endpoint.
  • extenddb-config: 23 (9 new, 1 rewritten: the section parses as the crate's shape and refuses unknown keys at every level, the per-backend default, the spool required with S3 except beside a database file, every cell of the spool placement rule, path comparison through a symlink, relative paths made absolute with the empty-path cell, an empty root compared with nothing and left for open, a root that is or contains the database directory refused and a child, a sibling and the default accepted, a placement refusal spelling both paths as compared).
  • extenddb-engine: 241 (33 new: the identity file's shape and refusals, the public reason naming no root, compare's rules; an empty root creates the file and a second startup reads it; two instances on one empty root end with one id; another catalog's file; ids that disagree, a missing file with rows, rows with two ids; the manifest probe on the newest AVAILABLE row; a create race lost on a store ignoring the condition; an unreachable store unhealthy and recovering through the reconciler, with the id settled on recovery and both links of the transport cause chain in the reason; a stalled store's check abandoned at its bound (5 s for a filesystem root, 30 s for a bucket) with the dead-owner duties run and the store unreachable, the next cycle on a stalled root starting no check and waiting no bound while the abandoned call is parked, and on a stalled bucket each cycle sending a fresh request; a command's identity read returning at the same bound; a FIFO at the identity key neither readable nor creatable; a shutdown during the check ending the cycle without waiting the bound; a transition logged across categories only; a recheck rewriting a lost file; a store checked after startup checking until its first check, its refusal saying the first check is pending; the health reason the category only; an oversized identity file unreadable; a catalog read failure leaving the health unchanged; the refusals naming the kind on delete and the restore check with no slot taken, and the in-catalog create not refused; a healthy store leaving create on the in-catalog path; jobs refused while a maintenance claim is held, the window passed through; the heartbeat timer keeping a claim held through a long run and a dropped handle stopping it, on tokio's paused clock; delete of a configured store's row deleting manifest, prefix, then marking DELETED; a failed deletion left DELETING and finished by the reconciler once the store answers; another store's DELETING row untouched; a prefix that still lists an object left DELETING; purge finishing a row another deleter moved to DELETING or DELETED, or removed, between its read and its turn, and leaving a FAILED one; a young CREATING row left under a 300 s window and removed under the zero one; the wait's five outcomes (quiet on a FAILED record and a dead FAILED row, busy naming a live CREATING and FAILED row and a RUNNING restore), a signal ending its poll sleep at once; a DELETING row finished while the claim is held). The engine's MemStore double implements BackupStore in memory with a failure switch, a stall switch and a post-conditional-put overwrite hook; its catalog double implements the purge's row writes.
  • extenddb-server: 245 (9 new: the spool lock refusing a second instance, narrowing a permissive lock file, and an existing spool refused unless owner-only, a permissive parent included; a defaulted root with rows for it configured and unhealthy rather than unconfigured, and a foreign identity file a warning without rows and owned by another catalog with them; a defaulted root whose open refuses a warning without rows and a startup error naming the rows with them; the startup checks returning at their 5 s bound on a stalled validate and an I/O failure of the probe degrading where a permission refusal stops startup; NotConfigured reading as the commands print it; an unreadable catalog keeping a failing defaulted root configured; a root with edge whitespace quoted in the banner).
  • extenddb-app: 44 (1 new: the tokio runtime, multi-thread and current-thread, ending within its 5 s shutdown bound with a blocking thread parked forever).
  • extenddb-storage-postgres (127.0.0.1:55432): lib 69; backup_restore 45 (8 new: read_store_ids; the maintenance claim excluding a second holder, released, held by a dead session's heartbeat until it ages, then taken over, kept by a live session whatever its heartbeat, reported Lost to a holder whose generation moved with its row left and still reported held through the heartbeat arm, and leaving nothing but the holder's lock in pg_locks after a check; a dead CREATING row removed and a live or moved one kept; two PostgresEngines on one catalog with two FilesystemStores: one empty root gives one id and both read it back; host-local roots under one label leave the second unhealthy, missing file first and then, with the file copied, the manifest probe; a root owned by another catalog; the purge wait quiet on a FAILED record whose target is CREATING with no duty run, busy naming a FAILED row with a live heartbeat and quiet once it has aged; the factory with beside_server leaving a gsi_pending row for the server and applying it as a server); table_snapshot 81 and the other 21 binaries as before.
  • extenddb-storage-sqlite: 229 (4 new: read_store_ids, the maintenance claim with the stale takeover, a dead CREATING row removed, a server start clearing a maintenance claim a stopped command left).
  • extenddb-storage-mongodb (replica set at 127.0.0.1:27117): lib 141, interpreter_selftests 18, pushdown_parity 20; -- --ignored live_ 31, run twice (3 new: read_store_ids; the maintenance claim with the stale takeover and a heartbeat-less record of an older release treated as stale; a dead CREATING row removed).
  • extenddb-storage-cassandra (127.0.0.1:9042, one binary at a time): lib 78; direct_integration 195 with --features test-hooks (3 new: read_store_ids over the status partitions, the maintenance claim with the LWT takeover, a dead CREATING row removed with its status partition checked); ttl_integration 26 passed, 1 failed, 1 ignored: test_ttl_enable_rejects_asynchronous_gsi is the pre-existing failure PR 6 recorded, reproduced without this commit; the file is not touched here.
  • Wire: tests/test_backup_store_config.py (new; excluded from the backend-agnostic suite, devtools/run-tests ignores it): on the SQLite build 11 cells (the default root created 0700 with the identity file and reported healthy, the banner line, CreateBackup logging the configured store; disabled with no component and the deprecation warning; a group-writable parent making the default root a warning and the same root explicit a startup error; a missing explicit root and a spool in the database directory stopping startup; an S3 endpoint that refuses connections starting the server with the component unhealthy, reason unreachable, CreateBackup still served, the restore and delete refused naming s3 and not the bucket, recovering when a forwarder to moto starts listening, creating the identity file in the bucket, and degrading again when it stops; a stalled S3 endpoint holding neither the listener nor the reconciler: /health answered with the component checking, a seeded row's restore refused as pending, then after the 30 s bound unreachable, the next cycle's check opening a new connection within seconds and shutdown not waiting; one AVAILABLE row under the default root with no manifest making it a configured store, degraded with manifest probe failed, the row's restore and delete refused naming the kind, CreateBackup served, healthy again once the manifest is back, backup list listing the row and purge --confirm refusing on the unhealthy store; a relative root in daemon mode resolving against the shell's directory; a catalog re-created after destroy warning about the old default root; backup purge --confirm with stale_job_after_secs = 45 over seeded AVAILABLE, FAILED, DELETING and dead-owner CREATING rows, a FAILED and a CREATING row with heartbeats 5 s old, and a RUNNING legacy restore with a CREATING target, resolving the restore without waiting, deleting each prefix and row outright with no wait on the young rows, leaving a prefix with no row, exports/ and the identity file, logging six audit lines, releasing the claim, then the wait's limit and refusal on a FAILED row whose heartbeat is stamped 120 s ahead of the clock (under the zero window the only input still counted live): 90 s, refusing naming it and deleting nothing; a waiting purge sent SIGTERM exiting within 5 s with the claim released and nothing deleted, one SIGKILLed leaving its claim and the next server start clearing it with CreateBackup accepted at once; and the server starting healthy against the purged root afterwards; backup list and the dry run before any server creating nothing, the dry run naming the missing default root and exiting 0, the dry run and list beside a running server, backup purge --confirm on an empty store and refused while a server holds the SQLite serve lock, --config after the subcommand, and destroy leaving the store); on the PostgreSQL build 3 cells (no store: no component and the warning; an S3 store without spool_path stopping startup; a filesystem store healthy with the identity file naming postgres, and an S3 Express bucket name stopping startup naming the bucket). tests/test_backup_restore_fidelity.py and tests/test_table_operations.py on three served legs: SQLite with the default store (63 passed, 1 skipped: the DELETING-table test needs EXTENDDB_TEST_BACKEND and a host SQLite newer than this one's), PostgreSQL without a store and PostgreSQL with an S3 store on moto (64 passed each with EXTENDDB_TEST_BACKEND=postgres EXTENDDB_CONFIG=<toml>); every server drained its workers at shutdown; the deprecation warning appears on every CreateBackup without a store and never with one; the S3 leg's store is checking at the listener line and passed its check 140 ms later.
cargo fmt --all -- --check
cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings
cargo +1.97.0 clippy --locked -p extenddb-storage-cassandra --all-targets --features test-hooks -- -D warnings
CARGO_TARGET_DIR=target/t188 cargo +1.88.0 check --locked --workspace --all-targets
EXTENDDB_TEST_PG_REQUIRED=1 EXTENDDB_TEST_S3_REQUIRED=1 ... cargo test --locked --workspace --exclude extenddb-storage-cassandra --exclude extenddb-storage-mongodb
EXTENDDB_TEST_MONGODB_REQUIRED=1 cargo test --locked -p extenddb-storage-mongodb; ... --lib -- --ignored live_
EXTENDDB_TEST_CASSANDRA=required cargo test --locked -p extenddb-storage-cassandra --lib | --features test-hooks --test direct_integration | --test ttl_integration -- --test-threads=1
devtools/generate-software-license-notices --check [--backend mongodb|cassandra]; devtools/generate-dev-license-notices --check

Not changed here

  • The store-backed CreateBackup job and worker (PR 8): with a store configured, CreateBackup writes the in-catalog format and says so at info; the catalog has no CREATING or AVAILABLE rows of a store until then, so the manifest probe passes on an empty root and purge finds nothing to delete in a fresh deployment. The jobs' claim check after the row exists is PR 8's store-backed job; the in-catalog paths check before their row.
  • The manifest restore (PR 9a): the restore check of a configured store's AVAILABLE row still answers "this release restores in-catalog backups only", after the health check.
  • backup sync and its options, --adopt-store included (PR 10): a root whose file names another catalog stays unhealthy until an operator changes the file.
  • sqlite-memory's startup sync (PR 10).
  • The audit log target extenddb::audit is new with backup purge; CreateBackup, DeleteBackup and RestoreTableFromBackup are not yet logged under it.
  • The reconciler's other store duties (resolving a dead CREATING row, deleting a FAILED row's prefix, tombstone retention) are PR 8's.

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace), the pre-existing Cassandra TTL failure above excepted
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy --workspace --all-targets -- -D warnings)
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk format, or public CLI surface, an RFC has been accepted or is linked below. Otherwise, an ADR captures the decision (link below).

ADR / RFC: #396, sections 4(c), 4(f), 7, 8, 9 and the PR 7 section, revision item 28. Storage trait: BackupCatalog gains read_store_ids, maintenance_claim and remove_dead_creating_backup_row, check_maintenance_claim takes the stale window, MaintenanceClaim is new; ServerComponentsOptions gains beside_server; StorageConfig gains database_file with a default. BackupStore gains put_if_absent.

Breaking changes

  • /health answers {"status": ..., "components": {...}} instead of {"status": "healthy"}; status is degraded while a component is unhealthy, with HTTP 200 kept; reason is a category; an S3 store's component is checking right after startup. A probe that parsed the body for "healthy" alone now sees degraded during a store outage.
  • A configuration with [backup.store] is validated at startup: a bad root, bucket, part size, prefix, endpoint or spool placement, an S3 store without spool_path on PostgreSQL, MongoDB or Cassandra, and a spool another instance holds, stop the server with the path or bucket named. A relative filesystem.path or spool_path now resolves against the working directory at load; an existing spool with a mode other than 0700, or under a parent others can write to, stops startup.
  • On file-backed SQLite a server without [backup.store] now creates backups/ beside the database file (0700) and extenddb-store.json in it; a root that fails the owner-only rule or whose identity file names another catalog is a logged warning, not an error.
  • With a configured store that is unhealthy or not yet checked, the restore and delete of a store-backed backup answer ValidationException naming the store's kind; CreateBackup is not refused.
  • backup purge holding the maintenance claim makes CreateBackup and RestoreTableFromBackup answer LimitExceededException on every instance.
  • Out-of-tree storage backends must implement read_store_ids, maintenance_claim, remove_dead_creating_backup_row and the new check_maintenance_claim signature, and honour ServerComponentsOptions::beside_server (on PostgreSQL it also decides whether the GSI queue workers start, a note for backends with their own); out-of-tree BackupStore implementations must implement put_if_absent.
  • New CLI surface: extenddb backup list [--failed] and extenddb backup purge [--confirm].

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache License 2.0 and I agree to the Developer Certificate of Origin (DCO). See CONTRIBUTING.md for details.

@robinnsc
robinnsc force-pushed the feat/backup-catalog-index branch 2 times, most recently from 71951ee to 8cae404 Compare October 30, 2026 17:51
@robinnsc
robinnsc force-pushed the feat/backup-store-config branch from af481a3 to b354105 Compare October 30, 2026 19:24
@robinnsc
robinnsc force-pushed the feat/backup-catalog-index branch from 8cae404 to 1c931de Compare October 31, 2026 00:10
@robinnsc
robinnsc force-pushed the feat/backup-store-config branch from b354105 to 93bf0af Compare October 31, 2026 01:14
@robinnsc
robinnsc force-pushed the feat/backup-catalog-index branch from 1c931de to 80db2eb Compare October 31, 2026 04:36
@robinnsc
robinnsc force-pushed the feat/backup-store-config branch from 93bf0af to ae516f3 Compare October 31, 2026 05:50
@robinnsc
robinnsc force-pushed the feat/backup-catalog-index branch from 80db2eb to a4ece88 Compare October 31, 2026 07:16
The `[backup.store]` section (the backup store crate's shape nested
under `[backup]`, `deny_unknown_fields` on both), with the per-backend
default for an absent section (a filesystem store at `backups/` beside a
file-backed SQLite database, created 0700, whose validation or
identity-check failure is a warning that leaves the store unconfigured
while the catalog has no rows for it, and an unhealthy store or a
startup error once it has; nothing on PostgreSQL, MongoDB, Cassandra and
an in-memory database), `kind = "disabled"`, the spool path rule
(required with an S3 store except beside a database file; refused inside
or containing a filesystem root, or when it is or contains the database
directory; a filesystem root that is or contains the database directory
is refused the same way; an existing spool must be owner-only, parents
included), relative store and spool paths made absolute at load, and the
spool's `.lock`.

Startup opens the configured store, validates it and checks its
identity: `extenddb-store.json` at the root, created with the new
`BackupStore::put_if_absent` when the root has no file and the catalog
no rows for it (S3: `PutObject`/`CompleteMultipartUpload` with
`If-None-Match: *`, a 412 is present, a 409 retried; filesystem: the
temporary created O_EXCL and `link`ed to the key), re-read and compared
with the catalog's id and with the rows' ids (`read_store_ids`), then
the newest AVAILABLE row's manifest probed. A configuration error that
needs no request stops startup naming the path or bucket; a filesystem
store's checks run before the listener (under a 5 s bound that leaves
the store `checking` when a mount stops answering after the root is
opened; every later check runs under the same bound, 30 s for a bucket,
the cycle selects it against shutdown, a filesystem root's abandoned
call parks one thread until the mount answers and no check starts while
it is, and the process exits within 5 s of SIGTERM with it parked), an
S3 store's after it in the reconciler's first cycle (the component is
`checking` until then); a transport, validation, identity or probe
failure, a filesystem root's I/O failure at the probe write, or a check
that does not finish within its bound leaves the server running with the
store unhealthy: logged once, `/health` reports a `backup_store`
component (status `degraded`, HTTP 200 kept, `reason` the problem's
category alone), the restore and delete of a store-backed backup refuse
with a ValidationException naming the kind and the category and not the
root (or that the first check is pending), the reconciler acts on no row
of the store, and the check runs again each cycle until it passes; the
in-catalog create, which touches no store, is not refused. A check that
cannot read the catalog changes nothing. DeleteBackup of a configured
store's row now deletes the manifest, then the prefix, then marks the
row DELETED; the reconciler finishes DELETING rows the same way.

`BackupCatalog` gains `read_store_ids`, `maintenance_claim` and
`remove_dead_creating_backup_row`, a `MaintenanceClaim` (heartbeat,
release) held on `backup_maintenance` with each backend's claim
mechanics (PostgreSQL advisory lock and row, SQLite row under the write
lock, MongoDB conditional upsert, Cassandra LWT, its claim-row reads at
LOCAL_SERIAL) and the stale rule; `check_maintenance_claim` reports a
claim held while its heartbeat is within `stale_job_after_secs`
(PostgreSQL: while the holder's session holds the advisory lock, tested
with a transaction-scoped shared lock, then by the heartbeat; on SQLite
a server's startup under the serve lock removes the record outright);
the holder heartbeats it on a timer from the take to the release
(`HeldClaim`, at `JOB_HEARTBEAT_INTERVAL`);
`remove_dead_creating_backup_row` removes a CREATING row whose owner is
dead. CreateBackup and RestoreTableFromBackup refuse with
LimitExceededException while the claim is held, after every other check
and before the permit. `ServerComponentsOptions::beside_server` opens a
catalog for a read beside a server, without the SQLite serve lock and
the startup recovery; on PostgreSQL the factory starts the GSI queue
workers only for a server.

`extenddb backup list [--failed]` (a read beside a server, on an
unhealthy store too, that creates no identity file and reads it under
the store's check bound) and `extenddb backup purge --confirm`: the
latter takes the maintenance claim and heartbeats it for its whole run,
on SQLite resolves the jobs the stopped server left (the reconciler's
duties with a zero window: a RUNNING restore and the CREATING target of
a FAILED one, an in-catalog CREATING backup, a DELETING row; a target
that is no longer the restore target its record names is printed as
left; a RUNNING `manifest` record, which no release before PR 9a writes,
is left for its resumer and waited on), waits up to twice the stale
window for CREATING or FAILED rows with a live owner and RUNNING
restores, deletes each row's prefix and the row outright (a CREATING row
with a dead owner included, the row before its prefix), leaves
`extenddb-store.json`, `exports/` and prefixes with no row, and
audit-logs each deletion; a row a DeleteBackup moves to DELETING or
DELETED, or removes, between the command's read and its turn is finished
and counted as deleted; on SQLite the owner window is zero (under the
serve lock every owner is dead), so a CREATING or FAILED row with a
young heartbeat is deleted, not waited on, and a server starting on the
file clears a maintenance claim a killed purge left; SIGINT or SIGTERM
releases the claim at once during the wait and before the next row
during the deletion, and the wait's failures (jobs still running, the
claim lost, the catalog unreadable, a signal) are each reported as
themselves. The dry run is a read like `list` and exits 0, with no store
configured as with one; `--config` is accepted after the subcommand;
both commands say why there is no store when there is none. `extenddb
destroy` says it leaves the store and that a re-created catalog needs
the old root's identity file removed.

Docs: the admin guide (`[backup.store]`, `/health` components and the
reason list with `catalog unreadable`, `backup purge`, the shared-root
rule), the limits doc's backup store row, the upgrade manual (configure
a store before the release that stops the in-catalog create; `disabled`
as the opt-out; the SQLite default root rule; the re-init sentence), the
deployment guide's multi-instance notes (the hard-link requirement, a
root that refuses `link(2)` reported as failed validation, the
empty-catalog rule), the init template and sample config, the store
crate's README and module docs, the S3 root label built from the
endpoint the client uses, in one spelling; the identity file read up to
64 KiB; the console banner naming the store; design revision item 28;
and the licence notices (the server binaries now link the store crate's
AWS SDK tree).

Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
@robinnsc
robinnsc force-pushed the feat/backup-store-config branch from ae516f3 to 62d32f0 Compare October 31, 2026 07:16

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant