Repository navigation
feat(backup): catalog index, BackupCatalog, JobClaim, legacy reader [backup/restore 6] - #671
Conversation
80db2eb to
a4ece88
Compare
a4ece88 to
7f79f87
Compare
7f79f87 to
f4320f9
Compare
There was a problem hiding this comment.
I built the server at this head for SQLite, PostgreSQL and Cassandra. I ran the PR's backup tests against PostgreSQL 16, a MongoDB 7 replica set and Cassandra 4.1. I sent the requests this PR changes to Amazon DynamoDB in us-east-1, at least twice per shape. I sent the same requests to this head on SQLite and PostgreSQL, and to main on SQLite. I ran two PostgreSQL servers on one catalog under SIGSTOP and SIGKILL, ended claim and registration sessions with pg_terminate_backend, and killed a SQLite server mid-copy. I also ran 67 mutants of this PR's code against its tests.
The per-crate test counts in the description match the CI logs for this head, apart from MongoDB's lib (166 in CI, 165 in the description).
A SQLite restart ages an interrupted restore, and the first cycle removes its target. Ending the PostgreSQL claim session stops the copy at the next heartbeat and removes the target. In 620 races of DeleteBackup against RestoreTableFromBackup on PostgreSQL and SQLite, no DeleteBackup succeeded while a target of the backup was CREATING. Every ACTIVE target contained all the backup's items. Two servers racing one restore claim made exactly one CREATING target in each of 40 runs.
This head describes, lists and refuses catalog index rows in each status as the description says. With max_concurrent_jobs = 1, every check that needs no job slot answered before the bound. The new PostgreSQL and Cassandra tests run against a server in CI.
The table ARN in the caller's region, another account's ARN, backup ARN ids, the ListBackups cursor checks and the restore response members match the service. Two restore answers now match it where main did not. An existing target with an override the backup refuses, and an in-use backup into an existing table, both answer TableAlreadyExistsException. 51 of the 67 mutants fail a test that CI runs. Clippy 1.97.0 and the 1.88.0 check with test-hooks are clean, and the PR adds no third-party crate.
Four Blocking items and seventeen more are inline. Once they are fixed, I will approve.
| // 3. The name's syntax, then the name itself, before the backup is read. | ||
| let target_table_name = required_str(body, "TargetTableName", "targetTableName")?; | ||
| extenddb_core::validation::validate_table_name(target_table_name, limits)?; | ||
| check_restore_target_name(storage, account_id, target_table_name).await?; |
There was a problem hiding this comment.
Blocking: this checks the target name before reading the backup, so a restore of a missing backup into an existing table answers TableAlreadyExistsException Table already exists: <name>. Into a table that CreateTable is still creating, it answers the same, and into a CREATING restore target it answers TableInUseException Table: <name> is already being restored from backup: <arn>. Amazon DynamoDB (twice each) and main answer BackupNotFoundException Backup not found: <arn> in all three cases. Please move this check after resolve_backup and before validate_override_for_source, the order the service uses. Please also change restore_checks_the_target_name_before_the_backup_and_the_permit and the differences row to match.
| validate_name_member(name, "tableName", table_name_bounds(limits))?; | ||
| Some(name.as_str()) | ||
| } | ||
| Some(Value::String(_)) => Some(create_backup_table_name(body, account_id, limits)?), |
There was a problem hiding this comment.
Blocking: ListBackups now parses TableName with create_backup_table_name. So arn:aws:dynamodb:us-west-2:<account>:table/<name> or an ARN with the region US-EAST-1 answers 200 with the server-region table's backups on SQLite and PostgreSQL. Amazon DynamoDB answers ValidationException 1 validation error detected: Invalid AWS region in '<arn>' (twice each), and main answers ValidationException 1 validation error detected: Value '<arn>' at 'tableName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+. Please refuse an ARN whose region is not server.region in that function, with the service's text. The refusal also covers the same gap CreateBackup has on main. The CreateBackup and ListBackups rows of the differences document need the same change.
| /// while it waited. The text is the one every refusal over a job bound | ||
| /// answers ([`limit_exceeded_message`]), naming this one. | ||
| pub(crate) fn take_permit(&self) -> Result<JobPermit, DynamoDbError> { | ||
| JobPermit::try_acquire(&self.slots).ok_or_else(|| { |
There was a problem hiding this comment.
Blocking: with the default max_concurrent_jobs = 4, the fifth of five restores sent together answers LimitExceededException Too many backup and restore jobs are running on this server (at most 4 at once, the [backup] max_concurrent_jobs bound); retry later on SQLite and PostgreSQL. A CreateBackup while four jobs run answers the same. Amazon DynamoDB accepted all five restores, and it accepted six CreateBackup calls sent together in each of two runs. Main accepted both requests on SQLite, and its PostgreSQL and MongoDB restores wait up to 30 s for a slot. Please wait a bounded time for a slot, or raise the default. In both cases, please name the bound in the differences rows for CreateBackup and RestoreTableFromBackup.
| } | ||
| Err(e) => return Err(create_backup_storage_err_to_dynamo(e, table_name)), | ||
| }; | ||
| check_backup_arn_fits(region, account_id, table_name)?; |
There was a problem hiding this comment.
Nit: this refuses CreateBackup of a table named ... (and, from the code, of a name over 255 bytes) with ValidationException TableName cannot be backed up: a backup ARN cannot be formed for it: invalid backup ARN: table name "..." is only dots. Main answered 200 AVAILABLE. Amazon DynamoDB answered ContinuousBackupsUnavailableException Backups are being enabled for the table: .... Please retry later 13.2 s after CreateTable, and at 33.7 s it answered 200 with BackupStatus CREATING and the ARN arn:aws:dynamodb:us-east-1:<account>:table/.../backup/<id>. Nothing the in-catalog create writes here needs a store prefix. Please keep it as on main and apply these rules in #700's store-backed writer. The CreateBackup rows of the differences and limits documents then need the same change.
| .map_err(db_err)?; | ||
| Ok(creating | ||
| .into_iter() | ||
| .filter(|(_, arn)| !self.job_slots.holds(&legacy_backup_slot_key(arn))) |
There was a problem hiding this comment.
Blocking: this filter runs after the read of the CREATING rows. The reconciler removes each listed row through delete, which also removes an AVAILABLE backup. A create that commits AVAILABLE and frees its slot between the read and this filter loses its backup, and no test covers that window. With a 25 s sleep added before this line, CreateBackup answered AVAILABLE, and 26 s later the reconciler removed the backup and its 5,000 items. Please remove abandoned creates in a write under the engine write lock. Limit the write to CREATING rows with a free slot, and test that window.
| //! `legacy` is marked FAILED and its target removed through | ||
| //! `remove_restore_target`, never resumed: an in-catalog copy has no data | ||
| //! files or progress to resume from. One of kind `manifest` is left for the | ||
| //! duty that resumes it, and logged once per record for as long as this |
There was a problem hiding this comment.
This is true per worker start: run creates ReconcilerState, and serve restarts the workers each time it takes the serving registration again. On PostgreSQL the reconciler logged the same dead manifest record again 17 s later, after I ended the serving-registration session and the server registered again. Please keep the logged set in serve, or say "once per worker start".
| | 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`. Each backend removes such a target without operator action, logs the removal with the table name (at warn; at info on Cassandra), and then lets `DeleteBackup` and a new restore into the name succeed. Measured after `SIGKILL` mid-copy and an immediate restart *(earlier build)*: **PostgreSQL** removes it on a control-plane pass once the target is older than 60 s and its restore lock is free (removed 60 s after the kill). **SQLite** removes it when the next server starts (0.1 s after the restart). **MongoDB** and **Cassandra** remove it once its owner's heartbeat is more than 60 s old, by a sweep that runs at startup and every 15 s, so about 45 to 80 s after the owner stopped (the heartbeat's last refresh can be up to one 15 s interval before the stop; measured: 60 s on MongoDB, 62 s on Cassandra). Until then `DeleteTable` and `DeleteBackup` are refused as during a running restore. Not covered *(from the code)*: on MongoDB, a target whose base copy had finished is not swept but is finished by the index backfill worker after a restart; 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). On Cassandra, a target left `CREATING` by a build before catalog 0.0.4 carries no heartbeat and is not swept. The MongoDB and Cassandra heartbeats compare wall clocks across servers (MongoDB uses the database server's clock for refreshes), so a server whose clock is far behind can have a live restore swept <!-- version-literal-ok: names the catalog that began recording restore heartbeats --> | | ||
| | `DeleteBackup` and `DeleteTable` during a restore | `DeleteTable` on the `CREATING` target is `ResourceInUseException`. `DeleteBackup` during a restore was not measured | While the target is `CREATING`, `DeleteTable` on it is `ResourceInUseException` on all four backends, as on the service. `DeleteBackup` on its source backup is `BackupInUseException` on all four: that refusal is ExtendDB's contract, not measured against the service. Both succeed once the target is `ACTIVE`. How the restore and `DeleteBackup` are ordered differs *(from the code)*. PostgreSQL and SQLite register the restore in the transaction that creates the target, holding the backup row, so a concurrent `DeleteBackup` is ordered strictly before (the restore then finds no backup) or after (it finds the restore). MongoDB runs the restore's registration and `DeleteBackup`'s claim each as one session transaction that first increments a lock field on the backup's catalog document while it is `AVAILABLE`, so the two conflict and one orders wholly before the other: a `DeleteBackup` that wins leaves the registration no `AVAILABLE` backup (`BackupNotFoundException`), a registration that wins makes the `DeleteBackup` `BackupInUseException`. Cassandra uses two lightweight transactions on different partitions: the target's row is inserted first (`IF NOT EXISTS`, which reserves the name), then the restore is registered on the backup's row (`IF backup_status = 'AVAILABLE'`), which `DeleteBackup` claims on the same partition; a concurrent `DescribeTable` can therefore see a `CREATING` target for a restore that then fails with `BackupNotFoundException`, after which the target is removed. On MongoDB `DeleteBackup` writes the backup `DELETING` before it drops the data and `DELETED` after; a `DeleteBackup` whose request fails or is cancelled after the drop started leaves the backup `DELETING`, which `DescribeBackup`, `ListBackups` and a restore treat as gone (`BackupNotFoundException`), and a retry of `DeleteBackup` finishes the drop and answers `DELETED` *(from the code and storage-level tests)*. MongoDB reads the catalog from the primary with read concern `local` regardless of the connection string, which the lock and the job records rely on; the connection string must use `readPreference=primary` (any other read preference is refused at startup). The write concern follows the connection string and must be `majority` (the server default): a connection string with `w=1` is refused at startup, because a `DELETING` write or a job record acknowledged under `w=1` just before a primary failover can be rolled back | | ||
| | Concurrent restores of one backup | A second `RestoreTableFromBackup` of a backup while a restore from it is still `CREATING` is `BackupInUseException` (measured) | The same on all four backends: the second restore is refused with `BackupInUseException` and creates nothing; the check runs inside the transaction that registers the restore (PostgreSQL, with the backup row locked `FOR UPDATE`; SQLite), inside the claim on the backup document followed by a check for a `CREATING` target of the backup (MongoDB), or inside the lightweight transaction on the backup row (Cassandra), so two simultaneous requests cannot both pass. Once the first target is `ACTIVE` the backup restores again; on MongoDB the check reads majority-committed data (at the default transaction read concern) while `DescribeTable` reads at `local`, so a `RestoreTableFromBackup` or `DeleteBackup` issued within the majority-commit lag after `DescribeTable` first shows the target `ACTIVE` can still be `BackupInUseException` and succeeds when retried (the lag is the journal flush plus any concurrent batch write still holding an earlier oplog slot; measured at 7 to 12 ms, once in about 2,000 observations, with a 3,000-item insert running beside the write). A restore of a backup whose `DeleteBackup` is in progress answers `BackupNotFoundException` on every backend (the service's answer was not measured), by two mechanisms: on MongoDB and Cassandra the delete marks the backup `DELETING` before it drops the data, and the restore finds no `AVAILABLE` backup; on PostgreSQL and SQLite the restore waits for the delete's transaction and then finds no backup. Earlier builds accepted concurrent restores of one backup on PostgreSQL, SQLite, and Cassandra, and on MongoDB after a 2 s retry | | ||
| | 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. 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 | |
There was a problem hiding this comment.
On MongoDB the registration writes the first heartbeat with the process's own clock (local_now). $$NOW replaces that heartbeat only once the target's collections and indexes exist. If a host's clock is behind the database's by more than stale_job_after_secs, another server can list that host's new restore dead in that window. I read this from the code and did not run it. Please write $$NOW in the registration, or state the exception in this row.
| /// backend before. | ||
| #[must_use] | ||
| pub fn backup_in_use_message(table: &str, backup_arn: &str) -> String { | ||
| format!("Backup is being used to restore table {table}: {backup_arn}") |
There was a problem hiding this comment.
Amazon DynamoDB's BackupInUseException text for a restore of an in-use backup is Backup is being used to restore another table: <arn>, with no table name (nine samples). Main's MongoDB answered BackupInUseException with that text when another restore held the claim, and this head answers Backup is being used to restore table <name>: <arn> through this function. I read the MongoDB side from the code at main and at this head, as I ran no MongoDB server. Please return the service's text for the restore refusals, and keep the table name in a log line.
| /// and the heartbeat keeps running. | ||
| #[tokio::test] | ||
| async fn three_heartbeat_errors_in_a_row_stop_the_job_and_two_do_not() { | ||
| let errors = usize::try_from(HEARTBEAT_ERRORS_TO_STOP).unwrap(); |
There was a problem hiding this comment.
This test reads its expected count from HEARTBEAT_ERRORS_TO_STOP, so it still passes with the constant at 4. Please use the literals 3 and 2, as the direct test does.
| test-util = ["extenddb-storage/test-util"] | ||
| # Coordination hooks used only by this crate's integration test binary | ||
| # (`src/test_hooks.rs`). Production builds do not compile them. | ||
| test-hooks = [] |
There was a problem hiding this comment.
CI runs clippy and the 1.88.0 build without this feature. So CI never lints src/test_hooks.rs and the hook call sites, or builds them at the MSRV. Clippy 1.97.0 and the 1.88.0 check with the feature are clean at this head. Please add --features extenddb-storage-cassandra/test-hooks, and the MongoDB crate's test-hooks, to the clippy step so clippy lints the next change here.
f4320f9 to
03aa0bd
Compare
|
Thanks for the runs and the mutants. Every item is addressed on the new head; the tests you asked for were each run against the mutation you named and fail there.
On the CodeQL note at From #672's review, the DELETING row of a store other than the configured one: DescribeBackup reports it DELETED because |
LeeroyHannigan
left a comment
There was a problem hiding this comment.
I checked every item from my last review at 03aa0bd. I ran the round-one reproductions again on the backends each item named. I applied the round-one mutants and ran the tests your reply names. I also sent the ARN shapes the new region check covers to Amazon DynamoDB in us-east-1, twice each.
All 21 inline items and the item in the review body are fixed. Each reproduction now gives the right answer, and each named test fails on its mutant and passes after the revert.
The change that moved the target name check brought one new Major item and one Minor item, both inline. The description also gives -- --ignored --test-threads=1 live_ as 46 tests, and this head lists 48 ignored tests whose names contain live_.
Once you fix the Major item, I will approve.
| .await | ||
| .map_err(storage_err_to_dynamo)? | ||
| { | ||
| check_catalog_row_for_restore(storage, runtime, &arn, &row).await?; |
There was a problem hiding this comment.
Major: for a catalog index row, which #700 writes, this call runs the in-use, CREATING and store checks before the target name check. On SQLite, a restore of such a row, written by hand and in use, into an existing ACTIVE table answered BackupInUseException twice. Amazon DynamoDB answers TableAlreadyExistsException for an in-use backup into an existing table, and the round-one head did too. Please check the name first, as for a legacy backup, and add a test of a catalog row restored into an existing name.
There was a problem hiding this comment.
Fixed. For a catalog row the request now checks the row's own state first (check_catalog_row_status: CREATING is BackupInUseException, DELETING, DELETED or FAILED BackupNotFoundException), then the target name (check_restore_target_name), then the in-use rule and the store checks (check_catalog_row_for_restore), so the order matches the legacy path and the service: an in-use backup into an existing ACTIVE table is TableAlreadyExistsException, and a backup into its own CREATING target TableInUseException. a_catalog_row_restore_checks_the_target_name_after_the_row_and_before_its_in_use_rule restores an in-use row into an existing table (TableAlreadyExistsException), into a CREATING restore target (TableInUseException naming the backup), into a free name (the in-use rule, then, with the backup free, the store refusal), and a DELETED row into an existing table (BackupNotFoundException with no table read). Design 5.3 step 1 states the order; #700 and #740 carry the same order on their store-backed paths.
| if name.contains('/') { | ||
| return Err(not_valid()); | ||
| } | ||
| if *arn_region != region { |
There was a problem hiding this comment.
Minor: this region check runs before the account check at line 385. In a dev-mode build, an ARN with another account and another region answers ValidationException "1 validation error detected: Invalid AWS region in ''". Amazon DynamoDB answers AccessDeniedException "Access is denied" for that ARN on CreateBackup and ListBackups, twice each. Please check the account first.
There was a problem hiding this comment.
Fixed. create_backup_table_name checks the ARN's account before its region, so another account's ARN in another region is AccessDeniedException "Access is denied" on CreateBackup and ListBackups; another_accounts_arn_in_another_region_is_access_denied_before_the_region_check covers both, and the differences document's backup-creation row says the account is checked first.
03aa0bd to
bfa5732
Compare
|
Both inline items are fixed as above, and the description's MongoDB |
LeeroyHannigan
left a comment
There was a problem hiding this comment.
I checked both items from my last review and the description at bfa5732. You fixed all three.
On SQLite, a catalog row that is in use, restored into an existing ACTIVE table, now answers TableAlreadyExistsException, as the service does. The new test fails when I put back either earlier order of the checks, and it passes on this head. Another account's ARN in another region now answers "Access is denied" on CreateBackup and ListBackups. Its test fails with the region checked first. The description gives 48 live_ tests, and --list gives 48.
The new test brings one Minor item, inline. I also tried to measure the service's answer for a restore of a CREATING backup into an existing table. Every DescribeBackup, the first sent 4 ms after CreateBackup answered, read AVAILABLE. So no restore met a CREATING backup, and that answer is still unmeasured.
From #672, the answers for a DELETING row of another store still disagree. At #672's head, DescribeBackup answers DELETED, RestoreTableFromBackup answers BackupNotFoundException, and DeleteBackup answers that the backup is in another store. The server logs no warning for the row. Design 4(g) says that a restore of such a row returns the ValidationException. Please make the design and the code agree, here or in a follow-up. It is not a condition for this approval.
| ); | ||
|
|
||
| // A row that is not AVAILABLE: its own answer, the name unread. | ||
| let fake = fake_with_row(BackupStatus::Deleted); |
There was a problem hiding this comment.
Minor: this checks a DELETED row only. With the CREATING check moved after the name check, all 106 backup:: tests still pass. Please add a CREATING row restored into an existing table.
There was a problem hiding this comment.
Fixed. The cell now runs its row-first case over a CREATING row as well as a DELETED one: a CREATING backup restored into an existing table answers BackupInUseException ("Backup is being created: ") before the name is read. With the CREATING check moved after the name check the cell fails on that case (it passed before, as you found); back in place, backup:: is 106 passed.
Shared traits (crates/storage). `BackupEngine` is replaced by two traits every backend implements: `BackupCatalog`, the catalog index of backups and restore jobs the engine binds (one row per backup addressed by `BackupKey` (account, backup id, ARN), one restore job record per target addressed by `RestoreTargetKey` (account, name, table id), the listings, `start_backup_delete` under the row's lock, `remove_restore_target`, `list_dead_restore_jobs`, `fail_restore_job`, `check_maintenance_claim`, `catalog_id`) with its `JobClaim` (generation, heartbeat, conditional `set_status`, `read`), and `LegacyBackupReader`, the frozen in-catalog format's create, describe, list, delete, restore-source and restore, whose `create` and `restore` now take the job's `JobPermit` and whose `abandoned_creating_backups` reports a dead CREATING row. `BackupRow` derives the API's source-table shapes from `source_table_details_json` (reading only the table id and ARN, so a later build's required member does not break a listing); `BackupStatus` maps DELETING to DELETED and hides DELETED and FAILED. The in-use, not-found and LimitExceeded texts every backend answers are one implementation in `backup_catalog` (`limit_exceeded_message(JobBound)`, the text naming the bound met: `[backup] max_concurrent_jobs`, the `[storage.sqlite] pool_size` snapshot-slot bound, PostgreSQL's connection slots), as is `JOB_HEARTBEAT_INTERVAL` (15 s), the period every backend's jobs heartbeat at. `StorageError::AlreadyExists` is the claim's refusal; `MigrationId::BackupCatalogIndex = 6` is the migration. Config. A `[backup]` section (`deny_unknown_fields`, every key with a default): `data_file_target_bytes`, `heartbeat_interval_secs`, `stale_job_after_secs`, `failed_row_retention_secs`, `reconcile_interval_secs`, `max_concurrent_jobs`, `spool_path`, with `stale_job_after_secs` at least three times the period the jobs heartbeat at (`max(heartbeat_interval_secs, JOB_HEARTBEAT_INTERVAL)`, 45 s at the least), `max_concurrent_jobs` at most 1024, and the floors checked at load; `init` writes the commented block and the sample config carries it. `ServerComponentsOptions.max_concurrent_jobs` lets the PostgreSQL factory raise both pools to `2 * jobs + 10` with a warning. Engine. `BackupRuntime` holds the job slots and the configured store (none on this head); a request over `max_concurrent_jobs` is `LimitExceededException` at once. Describe, List and Delete read the catalog index first and fall back to the legacy reader; List merges `list_account_backup_rows` with the reader's list under the existing paging, filtering and cursor rules, and `TableName` accepts a table ARN as CreateBackup does. CreateBackup runs the request order of the design (ACTIVE probe; the region and the name fit a backup ARN, each refusal naming its cause: `server.region` outside `[a-z0-9-]`, then a name over 255 bytes or made only of dots, checked before the ARN is formed so the limit is named whatever the length; vector refusal; permit last; deprecation warning; the legacy create); restore checks the target name through `describe_table` after the ARN (a table of the name `TableAlreadyExistsException`, a CREATING restore target `TableInUseException` naming its source backup), then the backup, then takes the permit last and hands it to the backend; the backend's fenced name check under the registration stays. Delete over a row runs the in-use check before the other-store check, as a restore does, and builds the description before the DELETING write; a row whose recorded source table this build cannot read is left out of ListBackups (logged) rather than failing the listing. Both spellings of a backup ARN (as written and re-spelled in this region) parse into `BackupArn` where the store-prefix rules allow; a legacy backup of a name they refuse (dot-only, over 255 bytes) skips the catalog index and describes, restores and deletes through the legacy reader as on main. Reconciler (crates/engine/src/backup/reconciler.rs). A cycle at startup and every `reconcile_interval_secs`, spawned by `serve` beside the backend workers and drained with them: checks the maintenance claim, fails each dead legacy restore (`heartbeat_at` older than `stale_job_after_secs`) and removes its CREATING target (a target the removal finds no longer CREATING is left to its owner and reported as such), removes the target of a FAILED record whose removal did not land once the record is that old, with no second FAILED write (the FAILED-target duty of design 4(e), on every backend; on Cassandra, where the removal is a claim to DELETING and then a drop, a target left DELETING with its registration is listed on the same rule and its removal resumed from the drop), logs a dead manifest restore once per record and leaves it to a later release, deletes each abandoned in-catalog CREATING backup through the reader, and records the worker metrics (`WorkerLastSuccess`, `WorkerCycleLatency`, `WorkerErrorCount` under `BackupReconciler`). It replaces the per-backend sweeps: PostgreSQL's control-plane pass, SQLite's startup sweeps, MongoDB's startup and worker sweep, Cassandra's 15 s poller. PostgreSQL. Migration 6 (`0006_backup_catalog_index.sql`, breaking, convergent, one transaction): `backup_index` (`backup_arn COLLATE "C"`, so the listings' `(created_at, backup_arn)` order is byte order whatever the database's collation), the twelve job columns on `table_restores` with the backfill of existing registrations (DONE with `completed_at = creation_date_time`, or RUNNING with the epoch heartbeat and generation 0 where the table is CREATING), `backup_maintenance`, `settings.catalog_id`. The claim is an advisory lock on a dedicated connection (`ClaimSession`) that the backup job's heartbeats and status writes travel through (the legacy restore heartbeats through it and writes DONE and FAILED on pooled connections, each conditioned on RUNNING and the generation); the reconciler's dead rule is the heartbeat alone, the lock being the claim's exclusion, as on the other backends. The legacy restore registers RUNNING with its generation, marks DONE in the transaction that flips the table ACTIVE, and FAILED before it removes a failed target. The restore slot semaphores (per account and per server) and `sweep_abandoned_restores` are removed. `RESTORE_LOCK_SEED` and `abort_restore` are `pub` and `#[doc(hidden)]` so the tests bind them. SQLite. Migration 6 as a code body (`m0006_backup_catalog_index.rs`) with the same objects; the twelve columns are nullable (ADD COLUMN on a populated table) and the application writes every one. The claim is an in-process slot (`JobSlots`, a drop guard), since one process serves a file; a restart ages every RUNNING record to the epoch so the first cycle removes an interrupted restore, and a CREATING `backups` row whose slot no task holds is reported dead whatever `stale_after` says (`backups` has no heartbeat column); the reader's `delete` removes such a row whole, with no DELETED tombstone. The restore heartbeats every `JOB_HEARTBEAT_INTERVAL` (15 s) under the engine write lock, so a beat queues behind every writer holding or waiting for that lock. Conditional writes never have an unknown outcome here. `backup_server_slot_count` stays beside `max_concurrent_jobs`; its slots are shared by backups and table snapshots (#395), the formula is stated once on `SqliteConfig::pool_size`, and the legacy create tries a slot without waiting, as a snapshot open does, and answers the one `LimitExceededException` text (`limit_exceeded_message`), naming the `[storage.sqlite] pool_size` snapshot-slot bound, for both. MongoDB. `backup_index` and `backup_maintenance` collections and a `restore_job` subdocument on a restore target's `tables` document, created by migration 6, a code body on the numbered runner (`m0006_backup_catalog_index.rs`), which also backfills the registrations and writes the catalog id; every request it sends is safe to send again (an existing collection accepted by its error code, the backfill limited to registrations without a job record, the id an upsert with `$setOnInsert`). The claim is the row, born owned by an upsert with `heartbeat_at: "$$NOW"`; the lock for DeleteBackup and a restore's registration is a session transaction that increments `lock_seq` on the backup's document, which replaces the `restore_in_progress`/`delete_in_progress` claim markers and the sweep that cleared them. Both in-use checks read at the transaction read concern (majority-committed data) while DescribeTable reads at `local`, so a RestoreTableFromBackup or DeleteBackup issued within the majority-commit lag after DescribeTable first shows the target ACTIVE can still answer BackupInUseException and succeeds when retried; the differences document's concurrent-restores row states it with the measured lag. The legacy DeleteBackup writes DELETING before the drop and DELETED after; describe and list hide DELETING. A restore request that outlives the 60 s registration bound answers InternalServerError while its task goes on; the target it leaves is stated in the differences document's restore row. The restore slot semaphores and the startup sweep are removed. The factory registers and reads the catalog's status (`register_and_check`) before it builds the engine or starts #473's startup key repair, so a server that refuses a catalog without migration 6 has written nothing; a live test pins that refusal on scratch databases holding a document the repair would rewrite. The protocol model (`backup_protocol_model.rs`) is rewritten for the new steps. Cassandra. Migration 6, a code body on the numbered runner (`m0006_backup_catalog_index.rs`): it executes `migrations/catalog/0006_backup_catalog_index.cql` as the runner executes a file body (the lease checked before each statement, schema agreement after each DDL statement), then backfills the registrations with lightweight transactions conditioned on the job status being unset and inserts the catalog id `IF NOT EXISTS`, so a run that fails part way leaves migration 6 unrecorded and the next `migrate --yes` runs the body again. The file adds the index columns (`store_kind`, `store_root`, `store_id`, `backup_id`, `owner_generation`, `heartbeat_at`, `completed_at`, `failure_reason`, `source_table_details_json`) on the reused `backups_by_arn`, the status-keyed `backup_index_by_status`, the `restore_*` job columns on `tables`, and `backup_maintenance`, every statement an `ALTER TABLE ... ADD IF NOT EXISTS` or `CREATE TABLE IF NOT EXISTS`, which the runner qualifies with the keyspace; the backfill and the id name the keyspace in their own statements, and no code or test issues `USE`. Code bodies get the lease check and the file execution on `CassandraStores` (`check`, `progress`, `run_file`, which moves there from the runner). An index row is a `backups_by_arn` row with `store_kind` set, and the two readers skip each other's rows. Claims are lightweight transactions `IF owner_generation = ?`; the legacy create heartbeats its CREATING row and polls its claim once per 64-item bucket, and `abandoned_creating_backups` fences a stale one to DELETING under a fresh `owner_generation` before reporting it, scanning DELETING rows first so a row it fenced is reported once. A job stops after three heartbeat errors in a row, as after a heartbeat that does not apply, and touches neither its row nor its data again. An orphan `backup_index_by_status` entry left by a crash between the row's write and the batch is skipped by every reader; the sweep that removes it is `backup sync`'s. `poll_abandoned_restores` and its worker are removed. The crate gains a `test-hooks` feature (as MongoDB's), compiled only for the `direct_integration` binary, which CI runs with `--features test-hooks`. Migration. One change, one number: migration 6, `backup_catalog_index`, on every backend, recorded in each store's ledger (migration 5 is servers first; the previous release refuses a catalog carrying it, exit 4) and convergent; a restore in flight at the stop is a RUNNING record whose owner is dead, failed and removed on the first reconciler pass. Rebased onto main after #335 and #681 merged: the migration moved from id 5 to 6, Cassandra's V006 file and MongoDB's versioned step became migration 6 code bodies on #681's runner, the `0.0.5` catalog version and this PR's own serve-time version checks went (the runner's status check refuses the catalog on every backend), and the PITR code #335 removed is gone from the legacy reader. Moved onto main at e8ca7df: #416's hand-written default for `ServerComponentsOptions` gains `max_concurrent_jobs: 0`, and the differences document's backup rows keep #509's text beside this PR's. Docs. The upgrade manual (migration 6 and its rollback, with the per-backend objects and the Cassandra and MongoDB rerun sentence, and the SQLite startup sentence: the server ages every RUNNING restore record and the first reconciler cycle fails an in-catalog restore and removes its target), the admin guide (`[backup]`, the job bound in place of the per-account and half-pool restore slots, the pooler note), the deployment guide (the job bound in place of the restore slots; the connection formulas gain `+ max_concurrent_jobs`), the high-level design and getting-started connection formulas, the limits document's concurrent-restores row, the differences document's restore rows (the MongoDB registration bound among them), the admin guide's `region` row, the differences document's `BackupArn` and backup creation rows, the limits document's `BackupName`/`TableName` row and the upgrade manual's `server.region` paragraph (CreateBackup refused under a region outside `[a-z0-9-]`; a name no backup ARN can carry refused with the texts), `CONTRIBUTING.md`'s migration example renumbered to 7, the sample config, `docs/schema/postgres.txt` (regenerated for the collation), `sqlite.txt`, `cassandra.txt` and `mongodb.txt` regenerated, and the backup/restore design's PR 6 section and section 4 (the migration number as implemented, the interface block with `list_dead_restore_jobs`, `fail_restore_job` and `abandoned_creating_backups`, revision log items 26 and 27). Tests. Counts on this tree; added and removed are test functions against main. The MongoDB and Cassandra counts are listed on this tree (`--list`); their suites ran on the top of the stack. extenddb-storage 215 (13 added; 216 beside a crate that enables its `test-hooks` feature, as `cargo test --workspace` does), extenddb-config 14 (6 added), extenddb-engine 255 (25 added: the target name before the backup and the permit, catalog describe, list, delete and restore paths, permit order, the ARN rules, reconciler cycle, duties, metrics and worker). extenddb-storage-postgres: lib 75, tests/backup_restore 37 (27 kept, 1 rewritten, 2 removed, 9 added), every other binary green (fresh 6, released 12, golden 7, crash 9, guards 7, runs 7, owners 3, serving 5, tag_blobs 1, vector_control_plane 42, doc_version_literals 3, table_snapshot 81 with its restore record written as a DONE legacy job, ...). extenddb-storage-sqlite 248 (17 added and 4 removed, among them a target removed only under its full key and migration 6's golden test; 1 ignored benchmark). extenddb-storage-mongodb: lib 165 plus 46 live tests against a replica set (among them a catalog without migration 6 refused with no repair started), migrations_fresh 8, guards 7, holders 10, locks 10, released 5 (migration 6's golden test added), interpreter_selftests 18, pushdown_parity 20; 29 added and 27 removed with the slots, the sweep and the claim markers. extenddb-storage-cassandra: lib 128 (4 added, 1 removed), migrations 21, migrations_stopped 8 (migration 6's golden test added), direct_integration 205 (13 added, 1 rewritten, 1 removed; three of the added tests build with `--features test-hooks`), ttl_integration 26 passed and main's one failure below. The released-catalog fixture of tests/test_resource_tag_migration.py strips migration 6's objects before it clears the ledger, since the check after migration 1 refuses a `table_restores` carrying the job columns. Through the binary on every backend: a catalog at main's migration 5 is refused by `serve` (exit 3), migrated by `migrate --yes`, then served, and main's binary refuses it (exit 4); a fresh install's schema equals `docs/schema/<backend>.txt`. The MongoDB workflow's jobs pass the CI way (pytest 1636 passed, 50 skipped; comprehensive 502; `tests/rust` 514; the catalog checks), and so do the Cassandra workflow's jobs (`tests/rust` 514, comprehensive 502, the catalog-lock, older-binary, two-node and authorizer checks), apart from the pytest job's parallel leg on the review host, whose 30 s request timeouts on account creation main's binary meets the same way there and whose files pass run serially. Wire fidelity (tests/test_backup_restore_fidelity.py, 36 passed and 1 skipped) ran inside those pytest jobs on MongoDB and Cassandra; the PostgreSQL and SQLite runs (64 and 63 passed) are from the tree before its rebases. `cargo fmt`, `clippy -D warnings` on 1.97, `check --all-targets` on 1.88. Not changed here. The backup store (`[backup.store]`, the store identity file) and the store-backed CreateBackup writer, worker and `backup` commands; the manifest restore job; every `backup_index` row is "of another store" for Delete and Restore until a store is configured. The Cassandra `test_ttl_enable_rejects_asynchronous_gsi` failure is main's: the test and `metadata_engine.rs` are as on main (the test matches "asynchronously propagated GSIs" and the refusal says "global secondary index"). `heartbeat_interval_secs` reaches no job yet: every in-catalog job heartbeats at `JOB_HEARTBEAT_INTERVAL`, which the stale floor is computed from. Co-authored-by: Lee <leeroyhannigan@yahoo.ie> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
bfa5732 to
6ff3748
Compare
|
The Minor is fixed as above (head |
LeeroyHannigan
left a comment
There was a problem hiding this comment.
I checked the test item and the design note at 6ff3748.
You fixed the test item. With the CREATING check moved after the name check, the cell fails, and it passes on this head.
The new 4(g) text gives the answers I measured for a DELETING row of another store. Two of its statements still differ from the code, inline. The code's answers match section 4(a), so the fix is to the text. As I said last time, it is not a condition for this approval.
On this head, the cassandra-storage job fails in test_ttl_enable_rejects_asynchronous_gsi and a_backfill_the_recovery_worker_started_ends_before_the_worker_returns. Both also fail on main, and this PR does not touch them.
|
|
||
| (f) The maintenance claim. `backup purge`, `backup remove-target` and `backup sync` with `--move-store`, `--forget-store`, `--forget-backup`, `--prune-missing` or `--adopt-store` change rows or bytes that jobs on any instance may be using, and a job permit (5.1 step 1) is per instance, so these commands take a catalog-level maintenance claim first (PR 10's section: the claim per backend and the SQLite serve lock). Every backup and restore job checks the claim after its own claim and row exist (5.1 step 2, 5.3 step 4) and refuses while it is held; while a claim is held the reconciler runs only the duties that resolve the states the command waits on (DELETING rows, rows whose owner is dead, the FAILED-target duty) and skips every other duty; the command waits until no CREATING backup row with a live owner, no DELETING row of the configured store and no RUNNING `restore_jobs` row remains, and refuses and releases the claim if they have not gone within `2 * stale_job_after_secs`, which it prints; a second maintenance command while one holds the claim fails at once. Because the job checks after its row exists and the command checks rows after its claim exists, one of the two always sees the other. | ||
|
|
||
| (g) The store, the rows and sync. The store is the source of truth; the row is a cache. Sync considers only rows of the configured store (`store_kind`, `store_root`, `store_id`); rows for another store are reported, not touched, and `backup sync --forget-store <root>` is the explicit opt-in to drop them: it deletes that root's catalog rows outright, leaving no DELETED tombstone, so no deletion duty ever acts on that store's bytes. Sync reports a row whose manifest is gone and marks it DELETING only with `--prune-missing`; without a resolving option (PR 10) it never modifies CREATING, DELETING, DELETED or FAILED rows nor adopts their prefixes, and it never adopts a prefix without a manifest, one whose sidecar does not match, or one whose listing lacks a data file the manifest lists or holds it at another size. Neither Describe nor Restore reads the store for an ARN that has no row: a backup that exists only in the store is BackupNotFound until `backup sync` adopts it, so these rules are the only path from a prefix to an AVAILABLE row. ListBackups lists rows of another store like any other (the response shape has no marker); Describe of such a row returns it and logs a warning; DeleteBackup and Restore of such a row return a ValidationException saying the backup is in a store other than the configured one, without naming either root. No API error names a store root. | ||
| (g) The store, the rows and sync. The store is the source of truth; the row is a cache. Sync considers only rows of the configured store (`store_kind`, `store_root`, `store_id`); rows for another store are reported, not touched, and `backup sync --forget-store <root>` is the explicit opt-in to drop them: it deletes that root's catalog rows outright, leaving no DELETED tombstone, so no deletion duty ever acts on that store's bytes. Sync reports a row whose manifest is gone and marks it DELETING only with `--prune-missing`; without a resolving option (PR 10) it never modifies CREATING, DELETING, DELETED or FAILED rows nor adopts their prefixes, and it never adopts a prefix without a manifest, one whose sidecar does not match, or one whose listing lacks a data file the manifest lists or holds it at another size. Neither Describe nor Restore reads the store for an ARN that has no row: a backup that exists only in the store is BackupNotFound until `backup sync` adopts it, so these rules are the only path from a prefix to an AVAILABLE row. ListBackups lists rows of another store like any other (the response shape has no marker), and Describe answers such a row by its lifecycle state as it answers any row (AVAILABLE described, DELETING and DELETED as DELETED, CREATING as CREATING, FAILED not found), with no warning: the store a row belongs to is the operator's concern (`backup list` prints every row's store), not the client's. DeleteBackup and Restore check the row's own state first, as for a row of the configured store (a CREATING row is BackupInUseException, a DELETED or FAILED row BackupNotFoundException, and for Restore a DELETING row too), and then refuse what is left, an AVAILABLE row and for DeleteBackup a DELETING one (whose deletion only its own store's server can finish), with a ValidationException saying the backup is in a store other than the configured one, without naming either root. No API error names a store root. |
There was a problem hiding this comment.
Minor: on SQLite at this head, DescribeBackup of a DELETED row answers BackupNotFoundException, as section 4(a) says, where this text says DELETED. The in-use rule and a restore's name check run before the other-store ValidationException, so an AVAILABLE row restored into an existing table answers TableAlreadyExistsException. Please make these two statements match the code.
crates/storage-mongodb/src/data_engine.rs: main made TxErr pub(crate) so that the backup catalog code (#671) can run its own write transactions through the same retry loop. This branch gives TxErr::Transient the driver's error text, which the message for a spent retry budget reports. The result keeps main's visibility and this branch's variant: a pub(crate) TxErr whose Transient carries a String. crates/storage-mongodb/src/backup_catalog.rs, crates/storage-mongodb/src/backup_engine.rs and crates/storage-mongodb/src/table_engine.rs are new on main and matched TxErr::Transient and CommitOutcome::RerunBody as unit variants. This branch carries the driver's text in both. Their match arms now bind the text with a wildcard, and the arm in backup_engine.rs that turns a Transient storage error from create_table_for_restore into TxErr passes the cause through. The backup paths keep their own messages for a spent retry budget.
crates/config/src/lib.rs: main added the backup module (#671) at the end of the file, where this branch adds the catalog_upgrade module and its re-exports. The result declares both. docs/manuals/05-admin-guide.md: main rewrote the region row of the [server] table (CreateBackup is refused under a region outside the ARN class). This branch adds the catalog_upgrade and catalog_upgrade_lock_timeout rows below it. The result keeps main's region row and this branch's two rows. docs/manuals/07-upgrade-manual.md: in the Current Status paragraph main counts migrations 1 to 6 and names migration 6, the backup catalog index. This branch adds the serve --migrate alternative to the migrate --yes step and says that a server refuses pending migrations by default. The result keeps main's count and its migration 6 sentence with this branch's clause and link.
crates/storage/src/migrations/registry.rs: main added migration 6, BackupCatalogIndex (#671). This branch added StreamPending as 6. The merged list keeps main's 6 and numbers StreamPending 7. The RELEASED_THROUGH comment names 7 as the id the v1.0 release PR raises it to. crates/storage-postgres/src/migrations/mod.rs: main added the arm for BackupCatalogIndex, whose catalog body is 0006_backup_catalog_index.sql, and listed it in the transactional arm of runs_in. This branch added the arm for StreamPending with a data body and listed it in runs_in. The merged file has both arms and both ids in runs_in. The data body is renamed 0007_stream_pending.sql, and its header comment names migration 7. crates/storage-sqlite/src/migrations/mod.rs, crates/storage-mongodb/src/migrations/mod.rs, crates/storage-cassandra/src/migrations/mod.rs: main added the arm that applies BackupCatalogIndex on each backend. This branch added the arm that records StreamPending as not applicable with the backend's reason. Each merged file has both arms. crates/storage-mongodb/tests/migrations_fresh.rs: main replaced the version literals with MigrationId::LATEST.number() and added migration 6 to the applied steps and to the applied ledger rows. This branch wrote 6 as the version and added its migration as a not applicable step and row. The merged file keeps main's LATEST reads, lists migration 7 as an applied step and a not applicable ledger row beside 6, names 3, 4 and 7 as the ids that do not apply on MongoDB, moves the ledger row for a later binary from 7 to 8, and asserts LATEST is 7. crates/storage-mongodb/tests/migrations_guards.rs, crates/storage-mongodb/tests/migrations_holders.rs, crates/storage-mongodb/tests/migrations_locks.rs, crates/storage-mongodb/tests/migrations_released.rs: main reads the version from MigrationId::LATEST where this branch wrote 6. The merged files keep main's reads. The step counts in migrations_locks.rs and migrations_released.rs move from 7 to 8, because migration 1 records two rows and ids 2 to 7 one each. CONTRIBUTING.md: both sides moved the worked example from migration 6 to 7, and main noted that 6 is backup_catalog_index. The merged example is migration 8, notes that 6 and 7 are backup_catalog_index and stream_pending, and lists StreamPending in the runs_in excerpt. docs/manuals/07-upgrade-manual.md: main described migration 6 in the status paragraph and added a section on it. This branch described its migration in the same paragraph. The merged paragraph says the release carries migrations 1 to 7 and describes migration 7 after main's sentence on 6, the breaking range is 1 to 7, and main's section stays. Both sides moved the sample outputs in docs/getting-started.md and in the quickstart, Linux install and macOS install manuals from migration 5 to 6. The merged files show 7. The design guide, the comment in stream_engine.rs on the sequence cache and the comment in tests/test_cli_vector_catalog_migration.py on the data database bodies name migration 7.
Backup/restore series (design: #396): #384 restore fidelity (merged) · #393 store · #394 format · #395 SnapshotEngine · #666 catalog migration framework · this PR: the catalog index · next: configuration and the store identity file (PR 7), the CreateBackup job and worker (PR 8), the restore job (PR 9a).
Closes #669 (part of #624).
Base:
mainat 94b5f68 (which carries #725, #798, #806, #776, #764, #535, #513 and #773 over e8ca7df; the move onto it conflicted in one file,crates/storage-cassandra/tests/direct/backup_engine.rs, where #806 rewrotetest_restore_heartbeat_runs_during_target_ddlto check order, the number of target tables that exist at the first heartbeat, in place of wall-clock time: the merged test keeps #806's order check and this PR'sWRITETIME(restore_heartbeat_at)reading of the beat). Main at e8ca7df carries #666's migration runner, #681 (Cassandra and MongoDB on that runner) and #335 (PITR answered as unsupported, migration 5). This PR adds migration 6,backup_catalog_index, to theMigrationIdregistry, with a body on every backend that the runner applies (Kind::Breaking, convergent). The rebase onto main moved the migration from id 5 to 6, turned Cassandra's V006 file and MongoDB's versioned step into migration 6 code bodies on #681's runner, and dropped the0.0.5catalog version, this PR's own serve-time version checks (the runner's status check refuses the catalog on every backend) and the PITR methods #335 removed. The move from 03baa32 to e8ca7df (#463, #433, #782, #785, #786, #416, #791, #457, #424, #509, #718, #734) changed one thing in the code: #416 replacedServerComponentsOptions' derivedDefaultwith a hand-written one, which now sets this PR'smax_concurrent_jobsto0; the differences document's backup rows keep #509's text beside this PR's. Where this head meets what main brought in:StorageEnginerequiresBackupCatalog + LegacyBackupReader + SnapshotEngine(#395); SQLite'sbackup_server_slot_countslots are shared by backups and #395's table snapshots, with the formula stated once onSqliteConfig::pool_sizeand one refusal text; the PostgreSQL control-plane pass keeps #395'sdrop_unless_heldand loses onlysweep_abandoned_restores; MongoDB's factory registers and checks the catalog before it builds the engine or starts #473's startup key repair, so a server that refuses has written nothing.What
The catalog index of design section 4 on every backend, the engine's Describe, List and Delete over it, the reconciler, and the legacy in-catalog paths kept beside it as a frozen reader. No request or response shape changes; the one visible API change is that
ListBackupsaccepts a table ARN inTableName, asCreateBackupalready does.Traits (
crates/storage/src/backup_catalog.rs).BackupEngineis replaced byBackupCatalogandLegacyBackupReader, both supertraits ofStorageEngine, implemented by all four backends.BackupCatalogaddresses every backup row byBackupKey(account id, backup id, ARN) and every restore target byRestoreTargetKey(account id, table name, table id); no method takes a bare id, andread_backup_rowreturns a row only when its account, table name and ARN equal the request's. Its methods:catalog_id,claim_backup(the row is inserted CREATING under a freshowner_generation; a second claim isAlreadyExists),list_backup_rows(by store identity and status) andlist_account_backup_rows(by account, table and time bounds), both ordered(created_at, backup_arn),read_backup_row,start_backup_delete(the in-use check and the AVAILABLE-to-DELETING write in one transaction under the row's lock;Deleting,InUse(table)orStatus),set_backup_deleted,remove_backup_row(DELETED or FAILED only),backup_in_use,remove_restore_target(conditional on a CREATING restore target with this table id, so it and the ACTIVE flip exclude each other),list_dead_restore_jobs(stale_after)(RUNNING records whose heartbeat is older than the window on the database's clock, and FAILED records whose table is still a CREATING restore target, or on Cassandra a DELETING one with its registration, once the record is that old),fail_restore_job(key, owner_generation, heartbeat_at, reason)(RUNNING to FAILED, conditioned on RUNNING and on the record still carrying theowner_generationandheartbeat_atthe listing read, written with a fresh generation, so a heartbeat that landed after the listing makes it match nothing: design 4(e)),check_maintenance_claim.JobClaimis a job's handle on its row:generation,heartbeat,set_status(from, to, fields)conditioned on the generation andfrom(Matched,NotMatched, orUnknownwhen the connection failed and the write may have landed),read.LegacyBackupReaderis #384's create, describe, list, delete, restore-source and restore, frozen apart from the restore's registration, which writes the job fields;createandrestoretake the job'sJobPermitand hold it for the job's life, andabandoned_creating_backups(stale_after)reports a CREATING row whose create died.BackupRowderivesBackupDescriptionandBackupSummaryfromsource_table_details_json(the manifest'ssource_table; the summary reads only the table id and ARN, so a later build's required member does not break a listing);BackupStatusreports DELETING as DELETED and hides DELETED and FAILED;SourceTableFeatureDetailsis always present,{}when the source recorded no index, stream, TTL or SSE, as the service returns it. The in-use text ("Backup is being used to restore another table: ", the service's, with no table name; the name goes to a log line), the not-found signal and theLimitExceededExceptiontext (limit_exceeded_message(JobBound), naming the bound met:[backup] max_concurrent_jobs, the[storage.sqlite] pool_sizesnapshot-slot bound, or the PostgreSQL server's connection slots) are one implementation every backend and the engine bind, as isJOB_HEARTBEAT_INTERVAL(15 s), the period every backend's jobs heartbeat at.StorageError::AlreadyExistsis new.Config (
crates/config/src/backup.rs).[backup]withdata_file_target_bytes(64 MiB, minimum 32 MiB),heartbeat_interval_secs(15),stale_job_after_secs(300, at least three times the period the jobs heartbeat at,max(heartbeat_interval_secs, 15): 45 at the least),failed_row_retention_secs(7 days),reconcile_interval_secs(60),max_concurrent_jobs(4, at most 1024),spool_path;deny_unknown_fields, every key optional, validated at load.initwrites the commented block;extenddb.sample.tomland the admin guide carry it.ServerComponentsOptions.max_concurrent_jobslets the PostgreSQL factory raisepool_sizeandcatalog_pool_sizeto2 * max_concurrent_jobs + 10with a warning naming both numbers.Engine (
crates/engine/src/backup.rs,backup/runtime.rs,backup/reconciler.rs).BackupRuntimeholds the job slots (JobPermit::acquire_within: a request that finds every slot held waits up toPERMIT_WAIT, 30 s, for one, the wait main's PostgreSQL and MongoDB restores had, and is thenLimitExceededException;try_acquirestays for callers that must not wait; the service accepted five restores and six creates sent together) and the configured store, which isNoneon this head, so everybackup_indexrow is a row of another store: Delete and Restore of one return the ValidationException that names no root. Describe, List and Delete read the catalog index first and fall back to the reader; List mergeslist_account_backup_rows(the time range as bounds,is_listedstatuses) with the reader's list under the existing paging, filtering and cursor exceptions. CreateBackup runs the design's step 1 order: the handler's ACTIVE probe,check_backup_arn_fits(the region alone, refusing with a message namingserver.regionwhen it is outside[a-z0-9-]; a name over 255 bytes or made only of dots is backed up as on main, in-catalog, where an earlier head of this PR refused it: nothing the in-catalog create writes needs a store prefix, and the service backs such a table up, so the store-prefix name rules are the store-backed writer's, PR 8), the vector refusal, the permit, the deprecation warning, thenLegacyBackupReader::create(permit); restore resolves the backup, then checks the target name throughdescribe_table(a table of the nameTableAlreadyExistsException, a CREATING restore targetTableInUseExceptionnaming its source backup), before the overrides are checked against the source, the order the service uses (a restore of a missing backup into an existing table, a tableCreateTableis still creating or a CREATING restore target isBackupNotFoundException, measured on DynamoDB twice each; an earlier head checked the name first), then takes the permit last and passes it in;ListBackupsandCreateBackuprefuse aTableNameARN whose region is notserver.region(compared as written) with the service'sValidationException"1 validation error detected: Invalid AWS region in ''" (an earlier head resolved it to the server region's table); the backend's fenced name check under the registration stays. Both spellings of a backup ARN (as written and re-spelled in this region) parse intoBackupArnwhere the store-prefix rules allow; a legacy backup of a name they refuse (dot-only, over 255 bytes) skips the catalog index and describes, restores and deletes through the legacy reader as on main. Delete over a row runs the in-use check before the other-store check, as a restore does, and builds the description before the DELETING write; a row whose recorded source table this build cannot read is left out of ListBackups (logged) rather than failing the listing;DeleteStart::Status(AVAILABLE)is an internal error. The reconciler runs a cycle at startup and everyreconcile_interval_secs, spawned byservebeside the backend workers and drained with them: the maintenance-claim check, each dead legacy restore failed (fail_restore_job) and its target removed (remove_restore_target; a target the removal finds no longer CREATING is left to its owner and reported as such), the target of a FAILED record whose removal did not land removed once the record is stale, with no second FAILED write (the FAILED-target duty of design 4(e), on every backend; on Cassandra, where the removal is a claim to DELETING and then a drop, a target left DELETING with its registration is listed on the same rule and its removal resumed from the drop), a dead manifest restore logged once per record and left to PR 9a (the set of logged records lives inserve, outside the worker set, so a worker restart after the serving registration is taken again does not log the same record again), a record whose row this build cannot read (akindor status no release writes) logged and skipped by every backend's listing rather than failing the cycle, each abandoned in-catalog CREATING backup deleted through the reader; it records the worker metrics (WorkerLastSuccess,WorkerCycleLatency,WorkerErrorCountunderBackupReconciler). It replaces the four backends' sweeps and their slot semaphores.PostgreSQL. Migration 6 (
migrations/0006_backup_catalog_index.sql):backup_index(primary key(account_id, backup_id),backup_idunique,backup_arn COLLATE "C"so the listings'(created_at, backup_arn)order is byte order whatever the database's collation, account/table and store/status indexes), twelve columns ontable_restores(status,kind,store_kind,store_root,store_id,owner_generation,heartbeat_at,spool_host,file_index,items_applied,failure_reason,completed_at) with the backfill of the registrations that exist (kind = legacy; DONE withcompleted_at = creation_date_time, or RUNNING with the epoch heartbeat and generation 0 where the table is CREATING, thenNOT NULLon the four claim columns),backup_maintenance, andsettings.catalog_idwritten once. The claim ispg_try_advisory_lock(hashtextextended('backup:' || backup_id, RESTORE_LOCK_SEED))on a dedicated, non-pooled connection (ClaimSession) that the backup job's heartbeats and status writes travel through (the legacy restore heartbeats through it and writes DONE and FAILED on pooled connections, each conditioned on RUNNING and the generation), every writeWHERE owner_generation = $gen; the reconciler's dead rule is the heartbeat alone, the lock being the claim's exclusion, as on the other backends;fail_restore_jobwritesWHERE status = 'RUNNING' AND owner_generation = $listed AND heartbeat_at = $listed. The legacy restore registersRUNNING, kind = legacy, owner_generation, heartbeat_at = now()in the transaction that creates the target, marks DONE in the transaction that flips it ACTIVE, and on failure marks FAILED (conditioned on RUNNING and the generation) beforeabort_restore, removing the target only when that matched. The per-account and per-server restore slots,sweep_abandoned_restoresand the control-plane pass's call are removed;RESTORE_LOCK_SEEDandabort_restorearepuband#[doc(hidden)]so the tests bind them.docs/schema/postgres.txtis regenerated from a freshinit.SQLite. Migration 6 as the code body
m0006_backup_catalog_index.rs(fingerprint0006_backup_catalog_index/sqlite/1, golden fixture blessed): the same tables and indexes, column-checkedADD COLUMNs (nullable;ADD COLUMNon a populated table cannot addNOT NULLwithout a default, and the application writes every one), the backfill, the catalog id. One process serves a file (the serve lock), so the claim is an in-process slot (JobSlots, a set behind a mutex with a drop guard) and a restart is proof of death:expire_restore_job_heartbeatsages every RUNNING record to the epoch at startup so the reconciler's first cycle removes an interrupted restore, andabandoned_creating_backupsremoves, in one write under the engine write lock (BEGIN IMMEDIATE), every CREATINGbackupsrow whose slot no task holds, items and definition, with no DELETED tombstone (a backup that never finished was never visible as one), ignoringstale_after(there is no heartbeat column onbackups), and reports what it removed; an earlier head read the rows and filtered them after the read, so a create that committed AVAILABLE and freed its slot between the read and the filter lost its backup through the reader'sdelete, which the window test now holds open. A legacy restore holds aJobSlotsslot (restore_slot_key(table_id)) for its life, and the dead listing skips a RUNNING record whose slot a task of this process holds, whatever its heartbeat says: the heartbeat waits for the write lock behind every writer (a GSI backfill), so under the slot rule of design 4(e) the slot is the liveness test and the heartbeat decides only once the slot is free (a restart frees every slot). The restore's registration carries the job fields in the same write transaction as the CREATING target; the copy marks DONE with the ACTIVE flip, FAILED before removal, and heartbeats everyJOB_HEARTBEAT_INTERVAL(15 s) under the engine write lock, so a beat queues behind every writer holding or waiting for that lock (SqliteJobClaim::heartbeat's doc).WriteOutcome::Unknownis never produced. The two startup sweeps are removed;backup_server_slot_countstays (PR 8 keeps it as the backend's admission bound and raises the pool for it): its slots are shared by backups and table snapshots (#395), and the legacy create tries a slot without waiting, as a snapshot open does, answering the oneLimitExceededExceptiontext (limit_exceeded_message), naming the[storage.sqlite] pool_sizesnapshot-slot bound, for both.docs/schema/sqlite.txtregenerated.MongoDB. The
backup_indexcollection (top-levelaccount_id/backup_idunder a unique compound index,backup_idunique, account/table/created and store/status indexes),backup_maintenance, and arestore_jobsubdocument (status,kind,owner_generation,heartbeat_at,failure_reason,completed_at) on a restore target'stablesdocument beside the provenance fields DescribeTable reads; migration 6, a code body on the numbered runner (src/migrations/m0006_backup_catalog_index.rs, fingerprint0006_backup_catalog_index/mongodb/1), creates them, backfills the registrations (DONE or RUNNING with the epoch heartbeat as on PostgreSQL,restore_operation_idandrestore_heartbeat_atremoved) and writescatalog_id; every request it sends is safe to send again (a collection that exists accepted by its NamespaceExists code, the backfill limited to registrations without a job record, the id an upsert with$setOnInsert), so a migrator stopped part way runs it again. The registration writes its first heartbeat as$$NOWinside the registration's transaction (an earlier head wrote the process's clock, so a host behind the database could be listed dead by another server within the window). The claim is the row: anupdate_oneupsert filtered onowner_generation: {$exists: false}withheartbeat_at: "$$NOW", so a second claim trips the unique index. The lock for DeleteBackup and a restore's registration is a session transaction that$incs alock_seqfield on the backup's document while it is AVAILABLE, so the two conflict and order wholly; this replaces therestore_in_progress/delete_in_progressclaim markers and the sweep that cleared stale ones. Both in-use checks read at the transaction read concern (majority-committed data) while DescribeTable reads atlocal, so a RestoreTableFromBackup or DeleteBackup issued within the majority-commit lag after DescribeTable first shows the target ACTIVE can still answer BackupInUseException and succeeds when retried; the differences document's concurrent-restores row states it with the measured lag. The legacy DeleteBackup writesDELETINGbefore the drop andDELETEDafter, anddescribe/listhide DELETING as they hide DELETED; a retry of DeleteBackup on a DELETING backup finishes the drop. The restore's hand-over to the GSI backfill worker and the record's DONE are oneupdate_oneconditioned on the generation. A restore request that outlives the 60 s registration bound answers InternalServerError while its task goes on; the target it leaves (CREATING, holding the backup in use and the name until the copy ends, found through DescribeTable) is stated in the differences document's restore row.WriteOutcome::Unknownis the driver'sIoandConnectionPoolCleared. The factory's order is main's (#681): it registers and reads the catalog's status (register_and_check) before it builds the engine or starts #473's startup key repair, so a server that refuses a catalog without migration 6 has written nothing; a live test on scratch databases plants a document stored without its key under a catalog at migration 5, showsregister_and_checkrefusing it as behind (migration 6 pending, no registration left) with the document untouched, and shows a repair started once the migrated catalog is accepted rewriting it. The restore slots andsweep_stale_backup_claims, at startup and in the GSI backfill worker, are removed.backup_protocol_model.rsis rewritten for the new steps (registration, copy, hand-over, decide, drop, mark, stop, resume, the reconciler's removal).Cassandra. Migration 6, a code body on the numbered runner (
src/migrations/m0006_backup_catalog_index.rs, fingerprint0006_backup_catalog_index/cassandra/1), which executesmigrations/catalog/0006_backup_catalog_index.cqlas the runner executes a file body (the lease checked before each statement, schema agreement after each DDL statement) and then backfills; a run that fails part way leaves migration 6 unrecorded,servekeeps refusing the catalog, and the nextmigrate --yesruns the body again. The file:backups_by_arngainsbackup_id,store_kind,store_root,store_id,owner_generation,heartbeat_at,completed_at,failure_reason,source_table_details_json;backup_index_by_status(PRIMARY KEY ((store_root, backup_status), backup_arn), withaccount_id,store_kind,store_idso a listing can re-read the row and scope to the store);tablesgains the twelverestore_*job columns;backup_maintenance(every statement anALTER TABLE ... ADD IF NOT EXISTSor aCREATE TABLE IF NOT EXISTS, which the runner qualifies with the keyspace); the body's backfill (an LWTIF table_id = ? AND restore_status = nullper registration, so a second run changes nothing) writes the job fields, and the catalog id is insertedIF NOT EXISTS. Code bodies get the lease check and the file execution onCassandraStores(check,progress,run_file, which moves there from the runner). An index row is abackups_by_arnrow withstore_kindset; the legacy reader treats such a row as not found and the catalog ignores rows without it. Every claim is a lightweight transactionIF owner_generation = ?;backups_by_arnis written first and the list and status entries follow, every reader re-reading the authoritative row. The legacy restore's heartbeat isIF table_id = ? AND table_status = 'CREATING' AND restore_status = 'RUNNING', so a FAILED write, the reconciler's or the job's own, stops the copy at its next heartbeat as on the other three backends (an earlier head's heartbeat did not require RUNNING, and a copy went on after a FAILED write). The legacy create holds a claim on its CREATING row, heartbeats it and polls the claim once per 64-item bucket, so a copy that lost its row writes at most one bucket after the loss;abandoned_creating_backupsfences a stale CREATING row to DELETING under a freshowner_generationbefore reporting it, and reports stale DELETING rows (scanned first, so a row it just fenced is reported once). A job stops after three heartbeat errors in a row (a beat that hangs is not counted; the per-bucket poll of the claim bounds the copy then), as after a heartbeat that does not apply, and touches neither its row nor its data again; one error does not stop it.WriteOutcome::Unknownis the driver'sIoandTimeoutand the server'sWriteTimeoutandWriteFailure. An orphanbackup_index_by_statusentry left by a crash between the row's LWT and the batch is skipped by every reader and costs a wasted read until the row is removed under its current status (remove_backup_rowdeletes the entry under the status it read); the sweep that removes orphans isbackup sync's (PR 10).poll_abandoned_restores,sweep_abandoned_restoresand the worker are removed. The crate gains atest-hooksfeature (as MongoDB's), compiled only for thedirect_integrationbinary, which CI runs with--features test-hooks: a per-account heartbeat period and heartbeat-error injection, and a pause between a delete's in-use read and its claim.Migration. One change, one number on every backend: migration 6,
backup_catalog_index, recorded in each store's ledger by the runner (migration 5 is #335'sdrop_continuous_backups). The design's PR 6 section and section 4 state the number (revision log items 26 and 27).Docs. The upgrade manual (the SQLite startup sentence: the server ages every RUNNING restore record at startup and the reconciler's first cycle fails an in-catalog restore and removes its target, the cycle running beside the listener; migration 6: its objects on each backend, the stop-first rule, the first reconciler pass, and on Cassandra and MongoDB the rerun of a body that failed part way; its rollback: the pre-upgrade backup restored, the previous binary refusing the migrated catalog with exit 4, and the
[backup]section removed from the configuration, which the previous binary refuses as an unknown field; the 0.0.4 section's 15 s sweep line now attributed to the 0.1.x server), the admin guide (the[backup]table; the job bound in place of the per-account and half-pool restore slots; the pooler note), the deployment guide (the job bound in place of the restore slots; the connection formulas gain+ max_concurrent_jobs), the high-level design and getting-started connection formulas, the limits document's concurrent-restores row, the differences document (the ListBackups paging row, the DeleteBackup-during-restore row, the interrupted-restore row, the two-servers row, theRestoreTableFromBackuprow's MongoDB registration bound, the concurrent-restores row's read-concern window and its restore-during-delete answer (BackupNotFoundExceptionon every backend), the restore-order row (the name checked before the job permit), the ListBackups cursor row under an invalid region, theBackupArnand backup creation rows: CreateBackup refused under aserver.regionoutside[a-z0-9-]and for a name no backup ARN can carry), the admin guide'sregionrow, the limits document'sBackupName/TableNamerow, the upgrade manual'sserver.regionparagraph,CONTRIBUTING.md's migration example renumbered to 7, the differences document's restore-order, ListBackups, CreateBackup and MongoDB two-servers rows and the limits document'sTableNamerow for this round's changes (the name after the backup; the region refusal; the 30 s slot wait; the dot-only name backed up;$$NOW), the admin guide'smax_concurrent_jobsrow and the deployment guide's bound sentence, design revision item 28, the sample config,docs/schema/postgres.txtregenerated for the collation andsqlite.txt,cassandra.txtandmongodb.txtfor the new objects, the backup/restore design's migration numbers, its PR 6 interface block (now withlist_dead_restore_jobs,fail_restore_jobandLegacyBackupReader::abandoned_creating_backups) and revision log items 26 and 27.Why
#396 section 4 and the PR 6 section. The engine-owned backup path (PR 8) needs a row per backup it can list without the store, a claim that excludes a second owner and lets a successor detect a dead one, and one place every backend's rules meet; the legacy in-catalog paths of #384 stay reachable so backups taken before the series restore, but as a reader whose only unfrozen part is the restore's registration through the new job record. Replacing the four backends' sweeps and slot semaphores with one reconciler and one
max_concurrent_jobsbound is what makes the next PRs backend-agnostic. The migration lands first, in its own PR, because every later PR writes the rows this one creates.Decisions taken while integrating the per-backend work, each stated in the module doc of the backend it concerns:
JobPermittravels as a trait-method argument (keepsRestoreRequestClone); the migration is one id on every backend, so one number names one change; SQLite'sabandoned_creating_backupsignoresstale_afterand its dead-create delete leaves no tombstone; MongoDB's lock replaces the claim markers and DELETING precedes the drop; theWriteOutcome::Unknownclasses differ per driver; the 15 s heartbeat is one constant,extenddb_storage::backup_catalog::JOB_HEARTBEAT_INTERVAL, that every backend's heartbeat loop reads and the config floor is computed from, until PR 8 wiresheartbeat_interval_secs; a Cassandra job stops after three heartbeat errors in a row (a beat that hangs is not counted; the per-bucket poll of the claim bounds the copy then), as after a heartbeat that does not apply, and touches neither its row nor its data again, one error not stopping it (design PR 6 section); the in-catalog create on Cassandra polls its claim once per 64-item bucket; Cassandra's orphan status entries wait for PR 10's sweep; the[backup]doc says why the stale floor is a floor on SQLite.Testing done
Third review round (one inline Minor; design 4(g) from the review body):
a_catalog_row_restore_checks_the_target_name_after_the_row_and_before_its_in_use_rulenow runs its row-first case over a CREATING row as well as a DELETED one: a CREATING backup restored into an existing table answersBackupInUseException("Backup is being created: ") before the name is read, so moving the CREATING check after the name check fails the cell (it passed before). Design 4(g) now says what the code does for a row of another store: Describe answers it by its lifecycle state as it answers any row, with no warning, and Delete and Restore check the row's own state first (CREATINGBackupInUseException, DELETED or FAILEDBackupNotFoundException, and for Restore a DELETING row too), then refuse what is left, an AVAILABLE row and for Delete a DELETING one, with the other-storeValidationException; the code is unchanged. Enginebackup::106 passed on this head, clippy 1.97 clean.Second review round (two inline items): on the catalog-row path of a restore (the rows #700 writes), the target name is now checked once the row is known to exist and AVAILABLE (
check_catalog_row_status), before the backup's in-use rule and the store checks (check_catalog_row_for_restore), as on the legacy path: an in-use backup restored into an existing table answersTableAlreadyExistsException, as the service does.a_catalog_row_restore_checks_the_target_name_after_the_row_and_before_its_in_use_rulecovers the existing name, the CREATING restore target, the free name reaching the in-use rule and then the store refusal, and a DELETED row answering before the name is read.create_backup_table_namechecks the ARN's account before its region, so another account's ARN in another region isAccessDeniedExceptionon CreateBackup and ListBackups (another_accounts_arn_in_another_region_is_access_denied_before_the_region_check). Enginebackup::106 passed on this head (259 engine tests listed), clippy 1.97 clean. The MongoDBlive_count below is corrected to 48, what--listgives on this head.Counts are on this tree; added and removed count test functions against
main. After the move onto e8ca7df the counts are re-measured: the workspace run (PostgreSQL and SQLite) on this tree, the MongoDB and Cassandra counts listed on this tree (--list) with their suites run on the top of the stack (#740); the other bullets are from 03baa32.extenddb-storage: 215 (13 added; 216 beside a crate that enables itstest-hooksfeature, ascargo test --workspacedoes: theLimitExceededExceptiontexts naming their bound,BackupStatus,BackupKey::from_arn,TimeBounds, the description and summary derived from the manifest fixturecrates/core/testdata/extenddb-manifest/v1.json, a summary needing only the source table id and ARN,pool_size_for_jobs).extenddb-config: 14 (6 added: defaults, the stale floor as three times the period the jobs heartbeat at, the data-file floor, themax_concurrent_jobsupper bound, unknown keys refused).extenddb-engine: 255 (25 added: the target name checked before the backup and the permit, the region refusal namingserver.region, a removal that finds the target moved left to its owner, Describe, List and Delete over the catalog with the legacy fallback, Delete and Restore of a row of another store, Delete of a configured store's row, the in-use check before the other-store check, an unreadable source table left out of the listing and stopping a delete before the write, the permit taken last for restore and create, the ARN-fits rule, a name the store prefix refuses resolved through the legacy reader,TableNameas an ARN, the time range reaching the catalog as bounds, the reconciler's cycle, FAILED-target duty, once-per-record manifest log, worker metrics and worker with theFakeimplementing both traits).extenddb-storage-postgres(PostgreSQL 16 at127.0.0.1:55432,EXTENDDB_TEST_PG_REQUIRED=1): lib 75;tests/backup_restore37 (27 kept with renames,abandoned_restore_is_removed_only_when_its_heartbeat_is_stalerewritten over the reconciler's methods, 2 slot tests removed, 9 added: a copy whose record the reconciler failed stopping at its next heartbeat; a claim excludes a second owner, fences its writes and holds the advisory lock while it lives;start_backup_delete's status, in-use and tombstone outcomes;start_backup_deletewaiting for the row lock and then seeing the registration; listings scoped and ordered; migration 6's backfill plus the first reconciler pass; a legacy restore registers RUNNING and finishes DONE; a FAILED record whose target removal did not land listed once stale and removed; a target removed only while CREATING and only under its full key);doc_version_literals3,migrations_fresh6,migrations_released12,migrations_golden7,migrations_crash9,migrations_guards7,migrations_runs7,migrations_owners3,migrations_serving5,migrations_tag_blobs1,vector_control_plane42,gsi_online_build12,gsi_stale_writer1,key_collation3,migrate_data_db_name3,put_create_race9,stream_records_page_order1,stream_shard_ownership6,stream_shard_retention1,twi_conflict8,table_snapshot81 (2 ignored).extenddb-storage-sqlite: 248 (3 rewritten from the startup sweeps to the reconciler's methods andexpire_restore_job_heartbeats, 1 slot test now refusing without the wait; 13 added, migration 6's golden test among them: a target removed only while CREATING and only under its full key (the right table id under another account or name removes nothing), the PostgreSQL catalog-index module ported, a dead CREATING backup reported and removed throughdelete, a restart aging an interrupted restore, a legacy restore's record RUNNING mid-copy then DONE, a failed restore marking FAILED before removal, a create refused at once when no snapshot slot is free, a FAILED record whose target removal did not land listed once stale, slots and generations; 1 ignored benchmark).extenddb-storage-mongodb(replica set atmongodb://127.0.0.1:27117/?directConnection=true,EXTENDDB_TEST_MONGODB_REQUIRED=1): lib 165 with feat(storage): SnapshotEngine, a consistent table snapshot on every backend [backup/restore 4/4] #395'sa_standalone_server_is_unsupportedskipped (it needs the standalonemongodthat CI's workflow names inEXTENDDB_TEST_MONGODB_STANDALONE_URI);-- --ignored --test-threads=1 live_48 (backup, stream and fix(mongodb): store the key when UpdateItem creates an item #473's key repair live tests, one of the key repair's new here: a catalog at migration 5 refused byregister_and_checkas behind with no repair started, and a repair started after the migrated catalog is accepted rewriting the planted document); 29 added and 27 removed with the slots, the sweep and the claim markers. The live tests cover a heartbeat lost while$outruns leaving no orphan collection, the PostgreSQL catalog-index module ported, migration 6's backfill and the reconciler's first pass, a legacy restore's record, a delete and three restores ordered both ways over six rounds (accounted by the catalog), a FAILED record whose target removal did not land listed once stale, DELETING left by a failed or dropped delete and finished by a retry, a restore of another instance not dead while it heartbeats, and a slow-clock process not dead;migrations_fresh8,migrations_guards7,migrations_holders10,migrations_locks10,migrations_released5 (migration 6's golden test added),interpreter_selftests18,pushdown_parity20. The 7 protocol-model tests run by default (1.5 billion interleavings memoised, under a second).extenddb-storage-cassandra(Cassandra 4.1 at127.0.0.1:9042,EXTENDDB_TEST_CASSANDRA=required, one binary at a time): lib 128 (4 added, 1 removed: the unknown-outcome classes from a synthetic error body, generations, listing order, the consecutive heartbeat-error rule);direct_integration205 with--features test-hooks(13 added, 1 rewritten, 1 removed; the added: a FAILED record whose target the removal's claim left DELETING listed once stale and its removal resumed from the drop, claim,start_backup_delete, scoped listings, migration 6's backfill and the first reconciler pass on an isolated catalog, a legacy restore's record, an abandoned in-catalog create reported once stale and finished (counting the report and showing the dead generation fenced out), a FAILED record whose target removal did not land listed once stale and removed, a migration 6 whose backfill fails staying unrecorded and resuming,serve's registration refusing a catalog without migration 6 and accepting it once migrated, the create's heartbeat observed mid-copy, three heartbeat errors stopping a create within a bucket and two not, a restore registering between a delete's in-use read and its claim refusing the delete on both delete paths);migrations21;migrations_stopped8 (migration 6's golden test added);ttl_integration26 passed, 1 ignored, and main's 1 failure (below).tests/test_resource_tag_migration.py's released-catalog fixture strips migration 6's objects (the twelvetable_restorescolumns and their index,backup_index,backup_maintenance,catalog_id) before it clears the ledger, since the check after migration 1 refuses atable_restorescarrying the job columns; both cells pass on the SQLite build.maincreated (SQLite, PostgreSQL, Cassandra, MongoDB):migrate --listshows migrations 1 to 6;serverefuses the catalog at migration 5 with exit 3 naming migration 6 and themigrate --yescommand;migrate --yesapplies migration 6 (the ledger records it applied,settings.catalog_idis written);servethen starts, andmain's binary refuses the migrated catalog with exit 4. A freshinit's schema equalsdocs/schema/<backend>.txton all four.integration-mongodb.yml, the CI way, on a release build of this tree withmongodb-test-hooks:mongodb-pytest(run-mongodb-tests -- --pytest --comprehensive --parallel: 1636 passed, 50 skipped, 9 xfailed, 3 xpassed; comprehensive 502),mongodb-rust(--rust-live -- --rust-integration:tests/rust514, records live 6, the migration binaries, live Rust 18; the--rustleg as the workspacecargo testwithout service env),mongodb-snapshot(11),mongodb-catalog(the schema, catalog-lock, auth, released and 0.1.14 older-binary checks),mongodb-production-build.integration-cassandra.yml, the CI way:cassandra-storage(the five Cassandra commands below),cassandra-rust(tests/rust514),cassandra-pytest's comprehensive leg (502),cassandra-catalog-checks(schema, catalog lock, and the v0.1.14 tag's binary against migrated catalogs),cassandra-two-nodes(migrations21 on a two-node cluster),cassandra-authorizer,cassandra-production-build.cassandra-pytest's--pytest --parallelleg on the review host ends with 30 s request timeouts on account creation (1 failed, 291 errors, no other failure);main's binary and tests meet the same timeouts there (2 failed, 233 errors), and every file with a timeout passes run without--parallel(326 passed).tests/test_backup_restore_fidelity.py(36 passed, 1 skipped) andtests/test_table_operations.py(26 passed) ran inside both backends' pytest jobs. The PostgreSQL and SQLite fidelity runs (64 and 63 passed) are from the tree before its rebases.Review round (Lee's review of 2026-11-04), on this head (
cargo fmt --check,cargo check --workspace --tests,cargo +1.97.0 clippy --all-targets --features extenddb-storage-cassandra/test-hooks,extenddb-storage-mongodb/test-hooks -- -D warningsclean):extenddb-enginebackup::104,extenddb-storage228,extenddb-config14,extenddb-storage-sqlitebackup50, PostgreSQLbackup_restore40 (3 added: the FAILED write matching only the listed generation and heartbeat, a record failed after the last batch leaving the CREATING target to the reconciler, an unreadable record skipped by the dead listing), MongoDBbackup_engine::live_tests::24 (2 added: the registration's first heartbeat on the server clock, the FAILED write's conditions), Cassandradirect_integration --features test-hooksbackup_engine::31 (5 added: a FAILED record and a target claimed to DELETING each stopping the copy at its next heartbeat, a create that heartbeats between the scan and the fence keeping its row, a stale CREATING index row skipped by the abandoned-create scan, the FAILED write's conditions). Engine: the target name checked after the backup and before the permit, a request over the bound waiting a bounded time, a name the store prefix refuses accepted, the reconciler leaving a record whose owner heartbeated after the listing. SQLite: the abandoned-create removal under the write lock and a create committing AVAILABLE during the scan keeping its backup, a running restore holding its slot and not listed dead by its heartbeat, a copy whose record was failed stopping at its next batch, an unreadable record skipped, the heartbeat requiring RUNNING. Storage: 10,000 generations each non-zero and below 2^63, a permit waited for within the bound, a source without features reporting{}.AND status = 'RUNNING'(the heartbeat test fails); SQLite per-batch check withoutAND r.status = 'RUNNING'(the batch test fails); the generation's>> 1removed (the generation test fails); PostgreSQLdone == 1replaced bytrue(the last-batch test fails); Cassandra's fence withoutAND heartbeat_at = ?and with>=(the scan-and-fence test fails both ways); Cassandra's stale CREATING skip disabled (the skip test fails).devtools/run-mongodb-tests --rust-livegains thebackup_engine::live_tests::step withEXTENDDB_TEST_MONGODB_REQUIRED=1, so themongodb-rustjob runs the 24 live backup tests that ran in no CI job;.github/workflows/clippy.ymllints with the Cassandra and MongoDB crates'test-hooksfeatures. Neither was run in CI from here.Not changed here
[backup.store], the store identity file (extenddb-store.json),backup purge(PR 7).BackupRuntime.configured_storeisNone, so on this head Delete and Restore of abackup_indexrow return the other-store ValidationException, andclaim_backup,set_backup_deletedandremove_backup_rowhave no production caller yet; each backend's tests drive them.heartbeat_interval_secsreaches no job yet: every in-catalog job heartbeats atJOB_HEARTBEAT_INTERVAL(15 s), which the stale floor is computed from.kind = manifestrecord is logged and left;remove_restore_targetdeletesrestore_progressrows only where that table exists.check_maintenance_claimreports anybackup_maintenancerow. Cassandra's orphanbackup_index_by_statusentries wait forbackup sync.ttl_integration::test_ttl_enable_rejects_asynchronous_gsifailure is main's: the test expects "asynchronously propagated GSIs" in the refusal andmetadata_engine::async_gsi_ttl_refusalsays "asynchronously propagated global secondary index"; this PR leaves both files as onmain.backup_indexrow is deferred to PR 9a, which adds the first restore of such a row; this PR observes the row lock with a two-transaction test ofstart_backup_deleteon PostgreSQL and a paused-delete test on Cassandra.[backup]section removed from the configuration, which the previous binary refuses as an unknown field; no store exists on this head, so the dump loses nothing.Checklist
cargo test --workspace), main's 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 3 and 4 and the PR 6 section; ADR-0023 (catalog migrations; migration 6 is one more numbered migration on every backend).
Storagetrait:BackupEngineis replaced byBackupCatalogandLegacyBackupReaderon theStorageEnginesupertrait bound;StorageErrorgainsAlreadyExists.Breaking changes
extenddb migrate --yes; a server with pending migrations refuses to start (exit 3), and the previous release refuses the migrated catalog (exit 4). A restore in flight at the stop is failed and its target removed on the new server's first reconciler pass.[backup] max_concurrent_jobs(default 4, at most 1024) per instance for backups and restores together, waited for up to 30 s and then refused withLimitExceededExceptionwhose text names the bound met beside its number ([backup] max_concurrent_jobs, the[storage.sqlite] pool_sizesnapshot-slot bound, or the PostgreSQL server's connection slots when the job's claim connection is refused). SQLite'sbackup_server_slot_countstays (PR 8 keeps it as the backend's admission bound and raises the pool for it), its slots shared by backups and table snapshots (feat(storage): SnapshotEngine, a consistent table snapshot on every backend [backup/restore 4/4] #395); a CreateBackup that finds no slot free is refused at once with the same text where it waited up to 30 s, so snapshots holding every slot refuse every CreateBackup with no backup running. On PostgreSQLpool_sizeandcatalog_pool_sizeare raised to at least2 * max_concurrent_jobs + 10(18 at the defaults), logged.[backup]section, all keys optional with defaults; a config file with an unknown key under it is refused at load, andstale_job_after_secsunder three times the heartbeat period (45) ormax_concurrent_jobsover 1024 is refused at load.server.regionoutside[a-z0-9-]CreateBackupis refused with aValidationExceptionnamingserver.region(no backup ARN can be formed), where the previous release created a backup thatDescribeBackup,DeleteBackupandRestoreTableFromBackupthen refused as not valid; backups taken before the upgrade under such a region answer as before. The startup warning says so.BackupCatalogandLegacyBackupReaderin place ofBackupEngine, and handleStorageError::AlreadyExistsin an exhaustive match.ListBackupsaccepts a table ARN inTableName(it was a ValidationException); an ARN whose region is notserver.regionisValidationException"1 validation error detected: Invalid AWS region in ''" onListBackupsandCreateBackup(the service's answer; main answered the pattern-constraint text).RestoreTableFromBackupchecks the target name after the backup: a restore of a missing backup into an existing table or a CREATING target isBackupNotFoundException, as on main and the service.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.