Repository navigation
Conversation
robinnsc
force-pushed
the
feat/backup-catalog-index
branch
2 times, most recently
from
October 30, 2026 17:51
71951ee to
8cae404
Compare
robinnsc
force-pushed
the
feat/backup-store-config
branch
from
October 30, 2026 19:24
af481a3 to
b354105
Compare
robinnsc
force-pushed
the
feat/backup-catalog-index
branch
from
October 31, 2026 00:10
8cae404 to
1c931de
Compare
robinnsc
force-pushed
the
feat/backup-store-config
branch
from
October 31, 2026 01:14
b354105 to
93bf0af
Compare
robinnsc
force-pushed
the
feat/backup-catalog-index
branch
from
October 31, 2026 04:36
1c931de to
80db2eb
Compare
robinnsc
force-pushed
the
feat/backup-store-config
branch
from
October 31, 2026 05:50
93bf0af to
ae516f3
Compare
robinnsc
force-pushed
the
feat/backup-catalog-index
branch
from
October 31, 2026 07:16
80db2eb to
a4ece88
Compare
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
force-pushed
the
feat/backup-store-config
branch
from
October 31, 2026 07:16
ae516f3 to
62d32f0
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backup/restore series (design: #396): #384 restore fidelity (merged) · #393 store · #394 format · #395 SnapshotEngine · #666 catalog migration framework · 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, heada4ece880), itself on #666'sd4187ea9(main through #395, #638, #412, #511, #471): this PR adds nothing to the catalog schema (PR 6 createdbackup_maintenance, thestore_*columns andcatalog_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'sReconcilerState(here with the shutdown token), worker metrics and FAILED-target duty, whose three-wayRemovalreports a target left to its owner (targets_left_to_owner, whichbackup purge's zero-window pass prints), beside this PR's store check and DELETING duty; the permit refusal is PR 6'slimit_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 throughTableEngine::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-slotbound, 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'sJOB_HEARTBEAT_INTERVAL; the SQLitebeside_serverpath 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'slist_dead_restore_jobslists 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'srecord_still_runningand itsTableEngine(tables) beside this PR's row and store knobs, and PR 6's other-store cell builds its runtime through this PR'sConfiguredStore; 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_absenton the store trait, the maintenance claim on every backend, DeleteBackup running against the configured store, andextenddb backup listandbackup 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/healthbody (reasonis the problem's category alone;checkingbefore an S3 store's first check).backup listandbackup purgesay 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 listand the dry run give up on a store that does not answer after the check's bound;serveand 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 returnsNone, leaving the existing object untouched, otherwise. S3 sendsPutObject(orCompleteMultipartUploadfor a body over one part) withIf-None-Match: *; a 412 is "present", a 409ConditionalRequestConflictis 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 asputdoes (createdO_CREAT | O_EXCL) andlinks 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 afterlinkleaves the object.put's temporary write and ancestor sync are factored out and shared;put_impl's unreachable arm is gone.BackupStoreKind::Disabledandopen_described(the store with its kind and root label: the canonical directory, ors3://<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;validatekeeps 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 ignoresIf-None-Matchmakes the create a plain put) and the hard-link requirement (CIFS and FUSE mounts that refuselinkwith 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 sodeny_unknown_fieldsholds 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 atbackups/beside the file, marked defaulted (created0700by 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 explicitspool_pathis 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] pathorspool_pathis made absolute against the working directory at load (make_paths_absolute), asstorage.pathis; an empty path is compared with nothing inresolve_storeand left foropento refuse.StorageConfig::database_file(defaultNone; SQLite answers its file through the onedatabase_filerule it already has) is how the server learns the backend's file. The init template andextenddb.sample.tomlcarry 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>andcheck_maintenance_claim(stale_after): the claim isbackup_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 isAlreadyExistsnaming 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, andreleasedeletes 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 ($expris not allowed in an upsert's filter, so the two are two writes); Cassandra insertsIF NOT EXISTSand takes over a stale rowIF owner_generation = <old>, its claim-row reads run atLOCAL_SERIAL, and an insert whose answer was lost is settled by re-reading the generation; itslist_dead_restore_jobsreads the coordinator clock after its scans.MaintenanceClaimis a new trait (generation,heartbeat,release).Engine (
crates/engine/src/backup/).BackupRuntime.configured_storeis aConfiguredStore: 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:Checkinguntil an S3 store's first check,Healthy, orUnhealthy; a transition between categories is logged once; a check that cannot read the catalog changes nothing.check_configured_storerunsvalidate(until it has passed) thenestablishorrecheck, 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 asunreachable; 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 (recordedunreachableagain); an S3 store's abandoned request is dropped with the check;read_identity_boundedis 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 isfailed validation.store_identityruns the checks:establishreadsextenddb-store.json, creates it withput_if_absentwhen the root has no file and the catalog no rows for the root, re-reads it, and compares it withcatalog_idand the rows' ids;recheckre-reads and compares, rewriting a lost file with the known id when no rows exist; both end with the manifest probe (headof 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/healthreasonthat is the category alone.require_healthy_storeis 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 behindSTORE_BACKED_CREATEis 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).HeldClaimheartbeats a maintenance claim on a timer for its life (Dropstops the timer;releasewaits for a heartbeat in flight). DeleteBackup of a configured store's row runsfinish_backup_deleteafterstart_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_claimtakes the stale window.maintenancealso carrieswait_for_quiet(WaitOutcome),purge_rowandInterrupt, 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,.lock0600underflock; another live holder is a startup error naming the path; an existing spool must be owned by the server's user with mode0700, parents included), and for a filesystem store runsvalidateandestablishunder a 5 s bound (LOCAL_CHECK_TIMEOUT, in the engine besideS3_CHECK_TIMEOUT, 30 s; past it, a warning and the storechecking; a root that does not answer beforeopenholds startup, as the database directory would); an S3 store'svalidateandestablishrun in the reconciler's first cycle, after the listener is up, so a stalled endpoint never holds the data plane (the store ischeckinguntil then; avalidaterefusal there makes it unhealthy). A configuration error that needs no request (openrefusing 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, andconfigure_describedcarries the reason for an absent store out (NotConfigured) forbackup listandbackup purgeto print. A filesystem root's I/O failure atvalidate'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./healthis{"status": "healthy" | "degraded", "components": {...}}, HTTP 200 either way, withbackup_store(statushealthy,checkingorunhealthy,kind, and when unhealthyreason, 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'sstore_rootandstore_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 --confirmopens the catalog asservedoes (on SQLite under the serve lock), refuses an unhealthy store, prints the plan, takes the maintenance claim and heartbeats it for its whole run (HeldClaimatJOB_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 (amanifestone, 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 to2 * stale_job_after_secsuntil 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 throughremove_dead_creating_backup_rowfirst and its prefix after, every other row's prefix (manifest first) and then the row outright through the status machine andremove_backup_row, leavingextenddb-store.json,exports/and prefixes with no row; each deletion is logged underextenddb::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--confirmit is a read likelist: it prints the plan, changes nothing and exits 0, with no store configured as with one (an empty plan naming the reason).--configis accepted before or after the subcommand. Every command andserveend 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'sextenddb-store.jsonremoved 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;/healthexamples and the reason list withcatalog unreadable; thespool_pathrow), the limits doc's backup store row, the upgrade manual (configure a store before the release that stops the in-catalog create;disabledas 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 (/healthdegraded, 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), whichcargo aboutadds.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_claimtakes the holder's label and the stale window and returns aMaintenanceClaim(heartbeat and release) rather than aJobClaim, andcheck_maintenance_claimtakes 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 againststale_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);conformance123 (7 new:put_if_absentcreates 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 at127.0.0.1:9200; the SDK-environment cells also assertstore_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 foropen, 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 storeunreachable, 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 startupcheckinguntil 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'sMemStoredouble implementsBackupStorein 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 andowned by another catalogwith them; a defaulted root whoseopenrefuses a warning without rows and a startup error naming the rows with them; the startup checks returning at their 5 s bound on a stalledvalidateand an I/O failure of the probe degrading where a permission refusal stops startup;NotConfiguredreading 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_restore45 (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, reportedLostto 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 inpg_locksafter a check; a dead CREATING row removed and a live or moved one kept; twoPostgresEngines on one catalog with twoFilesystemStores: 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 withbeside_serverleaving agsi_pendingrow for the server and applying it as a server);table_snapshot81 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 at127.0.0.1:27117): lib 141,interpreter_selftests18,pushdown_parity20;-- --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_integration195 with--features test-hooks(3 new:read_store_idsover the status partitions, the maintenance claim with the LWT takeover, a dead CREATING row removed with its status partition checked);ttl_integration26 passed, 1 failed, 1 ignored:test_ttl_enable_rejects_asynchronous_gsiis the pre-existing failure PR 6 recorded, reproduced without this commit; the file is not touched here.tests/test_backup_store_config.py(new; excluded from the backend-agnostic suite,devtools/run-testsignores it): on the SQLite build 11 cells (the default root created0700with the identity file and reported healthy, the banner line, CreateBackup logging the configured store;disabledwith 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 componentunhealthy, reasonunreachable, CreateBackup still served, the restore and delete refused namings3and 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:/healthanswered with the componentchecking, a seeded row's restore refused as pending, then after the 30 s boundunreachable, 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 withmanifest probe failed, the row's restore and delete refused naming the kind, CreateBackup served, healthy again once the manifest is back,backup listlisting the row andpurge --confirmrefusing on the unhealthy store; a relative root in daemon mode resolving against the shell's directory; a catalog re-created afterdestroywarning about the old default root;backup purge --confirmwithstale_job_after_secs = 45over 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 listand the dry run before any server creating nothing, the dry run naming the missing default root and exiting 0, the dry run andlistbeside a running server,backup purge --confirmon an empty store and refused while a server holds the SQLite serve lock,--configafter the subcommand, anddestroyleaving the store); on the PostgreSQL build 3 cells (no store: no component and the warning; an S3 store withoutspool_pathstopping startup; a filesystem store healthy with the identity file namingpostgres, and an S3 Express bucket name stopping startup naming the bucket).tests/test_backup_restore_fidelity.pyandtests/test_table_operations.pyon three served legs: SQLite with the default store (63 passed, 1 skipped: the DELETING-table test needsEXTENDDB_TEST_BACKENDand a host SQLite newer than this one's), PostgreSQL without a store and PostgreSQL with an S3 store on moto (64 passed each withEXTENDDB_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 ischeckingat the listener line andpassed its check140 ms later.Not changed here
backup syncand its options,--adopt-storeincluded (PR 10): a root whose file names another catalog stays unhealthy until an operator changes the file.sqlite-memory's startup sync (PR 10).extenddb::auditis new withbackup purge; CreateBackup, DeleteBackup and RestoreTableFromBackup are not yet logged under it.Checklist
cargo test --workspace), the pre-existing Cassandra TTL failure above exceptedcargo fmt --check)cargo clippy --workspace --all-targets -- -D warnings)Storagetrait, auth model, on-disk format, or public CLI surface, an RFC has been accepted or is linked below. Otherwise, an ADR captures the decision (link below).ADR / RFC: #396, sections 4(c), 4(f), 7, 8, 9 and the PR 7 section, revision item 28.
Storagetrait:BackupCataloggainsread_store_ids,maintenance_claimandremove_dead_creating_backup_row,check_maintenance_claimtakes the stale window,MaintenanceClaimis new;ServerComponentsOptionsgainsbeside_server;StorageConfiggainsdatabase_filewith a default.BackupStoregainsput_if_absent.Breaking changes
/healthanswers{"status": ..., "components": {...}}instead of{"status": "healthy"};statusisdegradedwhile a component is unhealthy, with HTTP 200 kept;reasonis a category; an S3 store's component ischeckingright after startup. A probe that parsed the body for"healthy"alone now seesdegradedduring a store outage.[backup.store]is validated at startup: a bad root, bucket, part size, prefix, endpoint or spool placement, an S3 store withoutspool_pathon PostgreSQL, MongoDB or Cassandra, and a spool another instance holds, stop the server with the path or bucket named. A relativefilesystem.pathorspool_pathnow resolves against the working directory at load; an existing spool with a mode other than0700, or under a parent others can write to, stops startup.[backup.store]now createsbackups/beside the database file (0700) andextenddb-store.jsonin it; a root that fails the owner-only rule or whose identity file names another catalog is a logged warning, not an error.ValidationExceptionnaming the store's kind; CreateBackup is not refused.backup purgeholding the maintenance claim makes CreateBackup and RestoreTableFromBackup answerLimitExceededExceptionon every instance.read_store_ids,maintenance_claim,remove_dead_creating_backup_rowand the newcheck_maintenance_claimsignature, and honourServerComponentsOptions::beside_server(on PostgreSQL it also decides whether the GSI queue workers start, a note for backends with their own); out-of-treeBackupStoreimplementations must implementput_if_absent.extenddb backup list [--failed]andextenddb 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.