Repository navigation
Conversation
95b81f0 to
6793fae
Compare
edde7ea to
c96cb47
Compare
88d445d to
925509b
Compare
c96cb47 to
537097a
Compare
925509b to
695dc66
Compare
537097a to
37fde53
Compare
LeeroyHannigan
left a comment
There was a problem hiding this comment.
I built the head on SQLite, PostgreSQL, MongoDB and Cassandra, with and without the backup test hooks. The servers and the three options ran in containers as uid 1000, against filesystem roots and an S3 root on moto. Beside running servers, I stopped, killed and signalled the move and the forget options at their claim, inside the rewrite and during their waits.
On the merge of main at 0cfb0d9 (no conflict), clippy 1.97.0 with both CI feature sets and the 1.88.0 check are clean. The PR's targeted engine, app, storage, SQLite, PostgreSQL and server tests pass on it. All 36 CI jobs at this head pass. Each of the 100 new Rust tests and 13 new wire cells ran in CI on at least one backend.
I ran 111 mutants of the engine, the four catalogs, the store, the server and cmd_backup.rs against the suites. The suites killed 108, one of them only by a timeout I put around it. Of the three that survived, two are equivalent and one is inline.
I compared the answers after each option with Amazon DynamoDB in us-east-1 for the requests the service has. I also timed the PostgreSQL rewrite statement alone at 100,000 to 400,000 rows.
What matched the description:
- The three options conflict with each other and with
--adopt-storeand--prune-missing(exit 2). A SQLite server's serve lock refuses them (exit 6), and the refusals the docs list exit 1. - After the move, DescribeBackup and ListBackups answer as they did before it on SQLite, PostgreSQL and MongoDB, across 13 ListBackups shapes on PostgreSQL. Moved backups restore with all their items on the four backends and from the S3 root.
- The move refuses an incomplete copy, a tombstone over a complete copy, an unreadable copy and a rewritten identity file, and then moves no row.
- The move resolves a dead CREATING row whose copy is complete to AVAILABLE, and its restore reaches ACTIVE with all 25 items.
- A PostgreSQL move killed while its rewrite waits on a row lock commits nothing. A MongoDB move killed during its rewrite leaves a state that a re-run settles, and the catalog then holds no duplicate ARN.
- A second maintenance command fails at once and names the holder. On MongoDB and Cassandra a later command takes a stale claim from its holder by heartbeat age.
- While a command holds the claim, CreateBackup and RestoreTableFromBackup answer 400
LimitExceededExceptionand land no row. A CreateBackup that waited on a table lock while a command took the claim answered 400 at this head and 200 CREATING at the base. --forget-storeof another root removes its rows, deletes no object and refuses the configured root. Beside servers the forget options wait 59 s and print the wait. A FAILED-prefix deletion paused past its 30 s bound deleted nothing more on a filesystem root or on S3.- After either forget option, DescribeBackup, DeleteBackup and RestoreTableFromBackup of the ARN answer 400
BackupNotFoundException"Backup not found: ". The service answers DescribeBackup and RestoreTableFromBackup of an unknown ARN with the same text. A ListBackups cursor that names the removed backup resumes after its instant, as the service's does. --forget-backupclears the unhealthy store of a restored catalog whose newest manifest is gone, and finishes the deletion of a DELETING row of the configured store.- SIGTERM during a forget option's wait exits 1 and releases the claim.
- After
--forget-store, configuring the root again lets the next plain sync adopt its backups. - The sample output in the admin guide and the manual matches the binary line for line.
- The new and changed catalog methods behave alike on the four backends, except for the partial rewrites and Cassandra's entry counts the trait docs allow.
CodeQL has not run on this PR, because its base is not main. It runs once you retarget the PR to main.
Each item is inline. Four are Major:
- The claim goes unchecked during the Cassandra and MongoDB rewrite.
- The move fails on each run for a PostgreSQL root whose rewrite statement outlasts 30 s.
- The docs' memory statement for the move omits the configured store's rows.
- No test checks that Cassandra's rewrite compares the whole store after a lost answer, and the trait doc names only the root.
Eleven are Minor. Once you fix the four Major items, with their tests, I will approve. The Minor items can land in the same revision.
| } | ||
| let keys: Vec<BackupKey> = rows.iter().map(BackupRow::key).collect(); | ||
| match storage | ||
| .rewrite_store(target.from_kind, target.from_root, &keys, target.identity) |
There was a problem hiding this comment.
On Cassandra this call writes key by key, and on MongoDB in chunks of 1,000 keys, with no claim check between them. Twice, I sent SIGSTOP to a 10-key Cassandra move when it printed its claim line, and it stopped after rewriting 2 keys. Past stale_job_after_secs a plain sync took the claim, and on SIGCONT the move finished its rewrite and printed "complete: 10 row(s) moved". Please call rewrite_store in slices with recheck() before each, count the rest as left, and test a claim lost after the first slice.
| /// leaves room for a catalog far larger than that; past it the statement | ||
| /// fails, rolls back, and is not tried again. | ||
| #[doc(hidden)] | ||
| pub const REWRITE_STATEMENT_TIMEOUT: Duration = Duration::from_secs(30); |
There was a problem hiding this comment.
The rewrite is one UPDATE over all keys of the old root under this 30 s bound, and line 730 retries only a lock timeout. I ran the statement in plain SQL on a shared PostgreSQL 16 host, with migration 0006's indexes and 1,452-byte descriptions. 200,000 keys took 13.7 and 14.8 s, against the doc's 3.6 s, and 400,000 keys took 29.1 and 44.0 s. With a trigger slowing the statement to about 36 s, two consecutive moves exited 1 after 31 and 32 s, moving 0 of 3,001 rows. Please rewrite in bounded batches of keys or scale the timeout with the key count, and state the bound in the admin guide.
| let still: HashSet<&str> = left.iter().map(|r| r.backup_arn.as_str()).collect(); | ||
| // A key is moved when its row now names the configured store. | ||
| let mut under_to: HashSet<String> = HashSet::new(); | ||
| let mut listed = storage.list_backup_rows(None, target.identity); |
There was a problem hiding this comment.
This listing and the one at line 1389 read every row of the configured store whole, since each backend collects the rows first (fetch_all, try_collect). The admin guide (line 203), the manual (line 700) and the design (lines 172 and 613) base the peak on the old root's rows alone. A 50-row move on a PostgreSQL debug build peaked (memory.peak) at 6.8 MiB into an empty store. Into a store of 8,000 rows with 1.4 KB descriptions, it peaked at 60.7 MiB. Please state both reads in those four places, or read one row per moved key here, which removes the second read.
| /// | ||
| /// The backend's error for a failed read or write. On Cassandra an | ||
| /// answer lost in transit is settled by a serial read of the row: a row | ||
| /// that carries `to`'s root counts as rewritten, a row still of the old |
There was a problem hiding this comment.
After a lost answer, this doc counts a row carrying to's root as rewritten, while rewrite_store_row requires to's whole store, row.store == *to at line 1012 of crates/storage-cassandra/src/backup_catalog.rs. No test tells the two apart: with that comparison changed to row.store.store_root == to.store_root, the four Cassandra rewrite cells pass, including test_an_unknown_rewrite_outcome_is_settled_by_a_serial_read. The move decides what moved from its re-list, so today only the returned count would differ. Please name the whole store here, and add a direct cell where a lost answer's re-read finds to's root under another store id.
| } | ||
| lines.push( | ||
| " (to keep a root's backups, copy its tree under the configured root and run \ | ||
| extenddb backup sync --move-store <root>; to drop its rows, extenddb backup sync \ |
There was a problem hiding this comment.
This hint names --move-store <root> and --forget-store <root> for each listed store, and other_stores lists the configured root under another store id too. locate_root refuses that root for both options (RootRefusal::ConfiguredRoot), and the refusal points at purge and --prune-missing. No printed report reaches this case today, since a healthy store has one row id for its root and sync refuses an unhealthy store. Please omit that store from the hint or name --forget-backup <arn> for it, or drop the case from other_stores and design item 36.
| fn others_outcome(error: Option<&str>) -> anyhow::Result<()> { | ||
| match error { | ||
| Some(e) => { | ||
| anyhow::bail!("the rows of other stores could not be read: {e}; run the command again") |
There was a problem hiding this comment.
After a completed --move-store, the same command refuses the old root as having no rows (RootRefusal::NoRows). So when moving is set, this advice leads to that refusal. Please name plain backup sync there, as the re-list failure's text does. The listing failure at line 1831 and the stop at line 1843 give the same advice after a move and need the same change.
| ``` | ||
| `backup sync` and its options, each with its contract: Sync lists the root (every object, since the trait has no delimiter listing; the store lists in key order, so sync holds one prefix's objects at a time and the prefix of each manifest it listed, beside what it keeps of the root's rows, whose read is its larger cost (section 7), the reserved `changelog` and `exports/` components skipped; a filesystem store builds its own listing of the root first, section 7), checks each prefix against the catalog as the listing reaches it, reads the manifest and sidecar of the prefixes that have no row, and inserts an AVAILABLE row for each verified manifest whose data files the listing contains, every `data_files` key present with its `bytes`, with an insert-if-absent (`BackupCatalog::insert_backup_row`): a CreateBackup whose row landed between the check and the insert keeps its row and sync skips the prefix. Before adopting, sync parses the manifest's `backup_arn` with the one `BackupArn` type in `extenddb-core` and requires the parsed account, table and backup id to equal the listed path components and the `source_table` identity; a mismatch is logged and skipped. An ARN of another region is adopted and logged (requests resolve it by the ARN as written); one of another partition than `aws` is skipped, since every request refuses such an ARN before it reads a row (revision item 34). `--move-store <old root>` re-points the rows of that root to the configured store. It first resolves that root's CREATING and DELETING rows itself, against the configured store: a CREATING row becomes AVAILABLE if a verified manifest is there and FAILED otherwise, and a DELETING row whose prefix there holds no manifest has its deletion finished in the configured store. For each AVAILABLE row it compares the configured store's listing of the row's prefix with the manifest's `data_files` (every key present, with its `bytes`) as well as checking the verified manifest, and stops on a row whose copy is incomplete, naming it; `--forget-backup <arn>` deletes one row's catalog entry so the move can go ahead for the rest; it runs under the maintenance claim like the other commands, refuses a CREATING row with a live owner (the job's next conditional write would match nothing and its manifest would be adopted by the next sync) and a backup in use, one from which a restore target is CREATING, and for a legacy ARN, which has no new-format row, says so and does nothing. One rule covers the three tombstone states: the move refuses to carry a DELETING, DELETED or FAILED row whose prefix in the configured store holds a verified manifest, and names it (that prefix is a backup the operator copied before or during its deletion, not a leftover; `--forget-backup` on the row is the way out). Then it rewrites `store_kind`, `store_root` and `store_id`; the reconciler then cleans the leftovers of the moved tombstones in the configured store. `--move-store` and `--forget-store` match `store_root` exactly as stored, the `?endpoint=` suffix of an S3 root included. A root whose file names another catalog stays unhealthy until an operator adopts it with `backup sync --adopt-store`, which takes the maintenance claim, rewrites the file's owner to this catalog and then syncs, recording the file's store id on the rows it adopts (the `backup_index` row PR 9b's matrix seeds by hand is the row this adoption writes); the previous owner acts on the root until a later check that can read its catalog finds the store unhealthy, at the earliest one reconciler interval after the cycle in progress ends, unless its deletion fence (section 4(c)) refuses a deletion or a CreateBackup first, so every server of the previous catalog is stopped before the adoption. On `sqlite-memory` the catalog, and with it the `catalog_id`, is new at every start, so the server runs a plain `backup sync` itself at startup when a store is configured, which adopts a root whose file names a `sqlite-memory` catalog as `--adopt-store` does, rewriting the file's owner to the new `catalog_id` before it syncs, so the root has one owner at a time there too once the previous process has exited (a new in-memory server is started only after the old one has exited; one started beside it adopts the root while the old one still acts on it until a later check or its fence); a root owned by any other catalog is unhealthy there as anywhere, and the backup CLI commands do not exist there (no file to open), so that startup sync is the only catalog-loss recovery there. | ||
| `backup sync` and its options, each with its contract: Sync lists the root (every object, since the trait has no delimiter listing; the store lists in key order, so sync holds one prefix's objects at a time and the prefix of each manifest it listed, beside what it keeps of the root's rows, whose read is its larger cost (section 7), the reserved `changelog` and `exports/` components skipped; a filesystem store builds its own listing of the root first, section 7), checks each prefix against the catalog as the listing reaches it, reads the manifest and sidecar of the prefixes that have no row, and inserts an AVAILABLE row for each verified manifest whose data files the listing contains, every `data_files` key present with its `bytes`, with an insert-if-absent (`BackupCatalog::insert_backup_row`): a CreateBackup whose row landed between the check and the insert keeps its row and sync skips the prefix. Before adopting, sync parses the manifest's `backup_arn` with the one `BackupArn` type in `extenddb-core` and requires the parsed account, table and backup id to equal the listed path components and the `source_table` identity; a mismatch is logged and skipped. An ARN of another region is adopted and logged (requests resolve it by the ARN as written); one of another partition than `aws` is skipped, since every request refuses such an ARN before it reads a row (revision item 34). `--move-store <old root>` re-points the rows of that root to the configured store. It needs the configured store healthy, refuses the configured root and a root with no rows, and its wait covers the jobs of every store id the catalog records for the old root as well as the configured store's (revision item 36). It first resolves that root's CREATING and DELETING rows itself, against the configured store: a CREATING row whose owner is dead becomes AVAILABLE if a verified manifest is there, and otherwise stops the move with no status written, since a copy without the complete backup says nothing about the old root's prefix (revision item 36), and a DELETING row whose prefix there holds no verified manifest has its deletion finished in the configured store (a manifest that fails verification is a leftover the deletion removes). For each AVAILABLE row it compares the configured store's listing of the row's prefix with the manifest's `data_files` (every key present, with its `bytes`) as well as checking the verified manifest and that its size and item total are the row's, and stops on a row whose copy is incomplete, naming it (every row that stops the move is named in one run, and none moves while any does; a row that would give an account two rows of one backup id stops it too); `--forget-backup <arn>` deletes one row's catalog entry so the move can go ahead for the rest; it runs under the maintenance claim like the other commands, refuses a CREATING row with a live owner (the job's next conditional write would match nothing and its manifest would be adopted by the next sync), a FAILED row whose heartbeat is within `stale_job_after_secs` (its job may still write under the prefix) and a backup in use, one from which a restore target is CREATING, and for a legacy ARN, which has no new-format row, says so and does nothing. One rule covers the three tombstone states: the move refuses to carry a DELETING, DELETED or FAILED row whose prefix in the configured store holds a verified manifest, and names it (that prefix is a backup the operator copied before or during its deletion, not a leftover; `--forget-backup` on the row is the way out). Then, after one re-read of the configured root's identity file (the deletion fence's `check_catalog`), it rewrites `store_kind`, `store_root` and `store_id` of the rows still of the old root and not CREATING, and lists the old root again, reporting a row still there; the reconciler then cleans the leftovers of the moved tombstones in the configured store. `--move-store` and `--forget-store` match `store_root` exactly as stored, the `?endpoint=` suffix of an S3 root included. A root whose file names another catalog stays unhealthy until an operator adopts it with `backup sync --adopt-store`, which takes the maintenance claim, rewrites the file's owner to this catalog and then syncs, recording the file's store id on the rows it adopts (the `backup_index` row PR 9b's matrix seeds by hand is the row this adoption writes); the previous owner acts on the root until a later check that can read its catalog finds the store unhealthy, at the earliest one reconciler interval after the cycle in progress ends, unless its deletion fence (section 4(c)) refuses a deletion or a CreateBackup first, so every server of the previous catalog is stopped before the adoption. On `sqlite-memory` the catalog, and with it the `catalog_id`, is new at every start, so the server runs a plain `backup sync` itself at startup when a store is configured, which adopts a root whose file names a `sqlite-memory` catalog as `--adopt-store` does, rewriting the file's owner to the new `catalog_id` before it syncs, so the root has one owner at a time there too once the previous process has exited (a new in-memory server is started only after the old one has exited; one started beside it adopts the root while the old one still acts on it until a later check or its fence); a root owned by any other catalog is unhealthy there as anywhere, and the backup CLI commands do not exist there (no file to open), so that startup sync is the only catalog-loss recovery there. |
There was a problem hiding this comment.
This gives --forget-backup as the way out for all three tombstone states, while check_row's stop text and the manual offer --forget-backup for FAILED only. For a DELETING or DELETED row they say to remove the prefix from the configured root. They name --forget-backup there only as what would let a sync adopt the deleted backup again. Please split the sentence as item 36 does.
| stopped process, a frozen container or VM) in the middle of a deletion takes up that step when it resumes. A | ||
| `DELETING` row of another store keeps its bytes unless a server configured with that store finishes the | ||
| deletion (its reconciler does, the deletion the `DeleteBackup` asked for): if that root comes back with the | ||
| bytes, `backup sync` adopts the backup again. It waits for running jobs as `purge` does, and refuses if one is |
There was a problem hiding this comment.
With a restore running from backup b1 and stale_job_after_secs = 45, --forget-backup b2, a row no job reads, waited and exited 1 after 90.9 s. --forget-store and --move-store from a configuration on another root did the same. This paragraph and the admin guide's (line 205) give as examples only the forgotten rows' own jobs, and state no length for the wait. Please say that any running job holds the command up to 600 s at the default (twice stale_job_after_secs), or scope --forget-backup's wait to its ARNs.
| work.""" | ||
| d = deployment | ||
| if d.backend != "postgres": | ||
| pytest.skip("on SQLite the serve lock keeps every server stopped during the move") |
There was a problem hiding this comment.
This cell skips every backend but PostgreSQL with a reason about SQLite's serve lock, and the MongoDB CI job reports that reason. On MongoDB the cause is that _set_status and _catalog edit the catalog with SQL (line 1714). I ran the scenario by hand on MongoDB, and CreateBackup during the move's wait answered 400 LimitExceededException twice and wrote no row. Please give each skip its own reason, or give the cell a MongoDB catalog edit so it runs in mongodb-backup-store.
| std::fs::create_dir_all(&f).unwrap(); | ||
| symlink(f.join("y"), f.join("x")).unwrap(); | ||
| symlink(f.join("x"), f.join("y")).unwrap(); | ||
| assert!(link_chain(&f.join("x/backups")).len() <= LINK_HOPS + 1); |
There was a problem hiding this comment.
This assertion runs only after link_chain returns. With the hop limit at line 854 removed (for _ in 0..usize::MAX), the cell hung until a 300 s timeout I put around it. In CI that regression would show as the job's timeout after 40 to 55 minutes, in place of this cell's name. Please run the loop case on a thread with std::thread::spawn and fail when mpsc::Receiver::recv_timeout sees no result within a few seconds.
`extenddb backup sync --move-store <old root>` re-points the catalog
rows of a store root whose tree was copied under the configured root, so
the backups keep their ARNs. The old root is given as the catalog stores
it and matches byte for byte; the configured root and a root with no
rows are refused, the latter printing the stores the catalog holds rows
for. The move needs the configured store healthy, takes the maintenance
claim on every backend (on SQLite under the serve lock as well) and
waits for the running jobs of the configured store and of every store id
the catalog records for the old root. It then checks each row of the old
root against the configured store. A CREATING row whose owner is dead
goes through the reconciler's per-row resolution, now one body shared
with the dead-owner duty, and is written only AVAILABLE, when the copy
holds its complete backup: any other probe result stops the move with no
status written, since a copy without the complete backup says nothing
about the old root's prefix and a FAILED row would have the reconciler
delete the copy (a reconciler on the old root, or `--forget-backup`, is
the way out); one with a live owner stops the move. A DELETING row whose
copied prefix holds no verified manifest has its deletion finished in
the configured store behind the deletion fence. A DELETING, DELETED or
FAILED row whose copied prefix holds a verified manifest stops the move.
For a DELETING or DELETED row the text says the backup was deleted and
to remove that prefix (removing the row with `--forget-backup` instead
would have a sync adopt the prefix and bring back a backup a client
deleted); for a FAILED row it also offers `--forget-backup` and an
adopting sync. A store error during any of these checks stops the move
too. An AVAILABLE row needs its prefix complete under the configured
root (`probe_manifest`: the manifest, the sidecar, the digest, the row's
ARN, every data file at its recorded size) and the manifest's size and
item total equal to the row's. The old root's rows and the configured
root's, read before anything changes, are grouped by (account, backup
id) first, and every old-root row of a group of more than one stops the
move, so no such row gets a resolution or a deletion. Every stopping row
is named in one pass, and none moves while any does (exit 1). Otherwise
the fence re-reads the identity file once, `rewrite_store` moves the
rows in slices of 500 keys with a beat of the claim before each (a claim
another holder took during the rewrite, on MongoDB and Cassandra a
command stopped past `stale_job_after_secs`, stops the move within one
slice, exit 1, with the earlier slices moved and the rest left under the
old root), the old root is listed again (a row still there exits 1, and
the text says to copy its prefix under the new root or `--forget-backup`
it; a key the old root no longer lists counts as moved only when its
row, read by its ARN, 16 at a time, names the configured store), and a
plain sync of the configured root follows; a row of the old root in its
closing report of other stores (an insert that landed during that sync)
exits 1. Each row moved, resolved or finished is logged under
`extenddb::audit`; no object is copied and nothing under the old root is
touched. Every backend reads a listing's rows whole, so the peak memory
grows with the rows of both roots: the old root's, and the configured
root's, read for the check of one backup id per account (and again by
the sync after the move). A move of 20,000 rows of a five-GSI table into
an empty store peaked at 569 MiB on a SQLite debug build and 500 MiB on
a PostgreSQL one (51 and 49 MiB at 50 rows, so about 27 and 23 KiB per
row at 1,452 bytes of table description per row, growing with it), 37 s
each; a move of 50 rows on the PostgreSQL build peaked at 49 MiB into an
empty store and 231 MiB into a store of 8,000 rows. The backup manual's
procedure copies the tree without the old root's `extenddb-store.json`,
and says what to do when it was copied.
`--forget-store <root>` removes every row of a root other than the
configured one outright, and `--forget-backup <arn>` (repeatable, in
order, up to the first refusal; an ARN no row carries counts as done,
and every ARN's form is checked before the claim) one backup's row, of
any status: no tombstone is left and no object is deleted, but for a
DELETING row of the configured store, whose deletion the command
finishes after the wait, behind the fence (the deletion its DeleteBackup
asked for; it needs the store's check to pass, and the row is refused
otherwise; when the check failed on an identity mismatch the row's own
store id takes part in, the refusal says so and names the way out: the
same command with `[backup.store] kind = "disabled"`, which removes the
row and keeps its bytes, then a sync that adopts the backup, and
DeleteBackup again). A DELETING row of another store keeps its bytes
unless a server configured with that store finishes the deletion. Both
run on a store in any state and with none configured, under the claim on
every backend and after the wait, and open the store writing nothing:
the identity file is read and never created, and the check that writes
nothing runs once when the file agrees with the rows. Each status uses a
removal that exists: AVAILABLE `forget_available_backup_row` with its
in-use rule, DELETED and FAILED `remove_backup_row`, CREATING
`remove_dead_creating_backup_row`, DELETING `set_backup_deleted` then
`remove_backup_row`. They wait for running jobs as purge does, and any
running job holds them, not only the jobs of the rows they remove: a
live backup job of a store the wait covers, or any running restore
(refusing when one still runs at the wait's end, up to twice
`stale_job_after_secs`, 600 s at the default; with the configured
store's identity unknown the wait covers every store id the catalog
records for its root, and a failed read of those ids refuses the command
before the claim), and leave a CREATING or FAILED row whose heartbeat,
written after the wait, is within the owner window (or that is of a
store the wait does not cover; a FAILED row's text gives the heartbeat's
age as the condition to run again on, its job having ended with the
FAILED write), a row a restore is reading (the text names `extenddb
backup remove-target --account <id> <table>`), and every row of a
configured root that has no identity file under its rows (an unmounted
volume, which a server's check would give a new file once its last row
is gone) or, on SQLite, of the default root found missing while the
catalog holds rows for it (moved away, or a link to a volume that is
gone; the server crate's `configure_described` now reads the catalog's
ids for it, following every symbolic link along the path whether or not
its target exists, up to 40: a chain, a relative target, a directory
link; and answers `DefaultRootMissingWithRows`), and exit 1 naming each.
A failed read of the FAILED rows' owners leaves the row, and a DELETING
row a server finishes during the wait is removed as the DELETED row it
then is. The reconciler's FAILED-prefix, DELETED-prefix and tombstone
duties read the row and then the maintenance claim again before each
deletion and start none while the claim is held; each deletion that read
it free ends by its deadline, 30 s after that read was sent
(`DUTY_DELETION_BOUND`, the S3 store's per-call bound): a read answered
later starts nothing, and the store's new `delete_prefix_before` checks
the deadline before each removal (each request on S3). Beside servers
the commands wait 60 s from the claim before their first FAILED or
DELETED removal, printing the wait. What a duty can still do to a
removed row's bytes is the one removal, or one S3 request, in flight at
its deadline; the bound, like every lease of the design, assumes the
server's process keeps running, and one paused past the wait between its
last check and a call already sent runs that call when it resumes.
`--forget-backup` names a row under the ARN's other spelling without
removing it, reports an in-catalog backup as one and changes nothing,
and then runs the configured store's check once, writing nothing (none
when the root had no identity file as the command opened it), and prints
it, naming the next backup whose manifest is missing. That is the way
out of the unhealthy store a restored catalog is left with when its
newest AVAILABLE backup was deleted after the catalog's backup;
`--prune-missing` keeps refusing there, and plain sync's refusal names
`--forget-backup` with the ARN. On Cassandra `--forget-store` then
sweeps the root's status entries, each checked against its row: one
whose row is gone or names another store (a deleter whose entry batch
lands after a move of its row leaves one) is deleted, and one whose row
is of the root under another status (a status change whose entry batch
failed) is written again under the row's status; it reports both counts
and the ids still recorded, and names each row the listing after the
removals holds that the first did not (landed from a writer still
configured with the root), counted as left. `--forget-backup`'s help
says a later sync adopts the backup again where its bytes are. The three
options conflict with each other, with `--adopt-store` and with
`--prune-missing`.
Each of the three options confirms the maintenance claim with a beat of
its own (`HeldClaim::recheck`) right before each step that cannot be
undone, as sync does before its insert: the move before a dead CREATING
row's resolution, a DELETING row's finished deletion and each slice of
the rewrite; the forget options before each removal, a DELETING row's
DELETED write or finished deletion and the removal of the DELETED row it
leaves, and `--forget-store` before its sweep of the status entries. A
claim found lost there stops the command (exit 1) with the row as the
steps before it left it, naming the step. What remains is a stop after
that beat confirmed the claim and before the step's call reached the
catalog or the store, or with that call already sent: the call lands
when the process resumes.
Every sync that listed the root to its end now prints the rows of the
other stores: per store (kind, root, store id) the rows per status, each
CREATING and DELETING row by ARN, and the two options that act on them
(a store of the configured root under another store id, which both
refuse, names `--forget-backup <arn>` instead); on Cassandra the counts
are checked against the root's listing kept to each store's id, and what
no row of its status stands behind is printed as status entries. A
failed read of them exits 1.
`BackupCatalog` gains `list_backup_rows_by_root(kind, root, status)`,
`rewrite_store(from_kind, from_root, keys, to)`, which rewrites only
rows still of the old kind and root and not CREATING and returns the
count, and `summarize_store_roots()`, the rows per store and status.
PostgreSQL runs batches of 5,000 keys, each one `UPDATE` over `unnest`
of its keys in a transaction the client commits, under a 3 s lock
timeout and a 30 s statement timeout with up to three attempts, so a
killed command's statement commits nothing and no statement nears the
bound (one statement over 200,000 keys took 16.4 and 16.9 s over a
400,000-row catalog, a batch of 5,000 0.23 to 0.29 s), and one `GROUP
BY`; SQLite the same under the write lock and `BEGIN IMMEDIATE`; MongoDB
`update_many` in chunks of 1,000 keys that `$inc` `lock_seq` (the update
and a transaction that writes the document are serialized: the
transaction re-runs when the update came first, and the update waits for
the transaction otherwise), and a `$group`; Cassandra, per key, a serial
read, the new root's status entry, the lightweight transaction
conditioned on the old kind, root and status, then the old entry deleted
(the module doc says why this write puts the entry first), an unknown
outcome settled by a `LOCAL_SERIAL` read that counts the row only when
it carries the new store whole (kind, root and store id), a `NotMatched`
read again before the new entry is removed, the summary a count of
status entries, and the by-root listing a read of all five status
partitions of the root, deduped, each row read once and kept whatever
partition its entry is in; its two deleters' transactions now carry the
store they read, so a DeleteBackup that read a row before the move moves
the row's entry under its new root. `remove_backup_row` takes the store
the caller read and removes only a row still of it, on every backend
(Cassandra's transaction carries the condition), so a tombstone duty of
a server on the old root leaves a moved row. Two methods with defaults:
`summary_counts_status_entries` and `remove_orphan_status_entries`,
which answers `StatusEntrySweep { removed, repaired }`, both
Cassandra's. The engine's `Fake` and the server's `StubCatalog`
implement them all.
`crates/engine/src/backup/store_maintenance.rs` holds `move_store`,
`forget_store`, `forget_backups`, `locate_root` and `other_stores`.
`maintenance::wait_for_quiet` takes a slice of store identities. The
store-backed CreateBackup reads the maintenance claim again immediately
before `claim_backup`, and the restore immediately before its
registration, so a claim taken before that read refuses the job with no
row; one taken between the read and the insert is met by the command's
wait, by the move's rewrite, which skips CREATING rows, and its re-list,
or, for an insert during the move's sync, by its closing report or a
later plain sync. `OTHER_STORE` names the move.
Tests: per-backend cells for the three methods, the deleters against a
move, the rewrite's re-read, its client's death and its bounded retry
(Cassandra's direct tests on a node of their own, with stop, pause and
unknown-outcome hooks, and the deleters' entry batches after a move
swept from the old root, and rows whose entry is under an earlier status
listed, moved and repaired); the store crate's deadline cells on a
filesystem root and moto, the multipart uploads' half included; 59
engine cells on `Fake` and `MemStore`, four of them the claim's beat
before each step that cannot be undone (one a claim lost between two
slices of the rewrite); 11 CLI unit tests; a server-crate cell of the
missing default root's lookup through chained, relative and directory
links and a `..` path; wire cells on SQLite, PostgreSQL and MongoDB: a
copied tree moved, a FAILED row over a complete copy, a DELETED one with
the deleted backup's text, and a missing data file each stopping the
move, `--forget-backup` letting it finish, and a server on the new root
restoring a moved backup; the manual's move procedure in its order
beside a running server, and its remedy for a copied identity file;
another root's rows left alone by a reconciler over a copy of them,
reported, removed by `--forget-store` and adopted again when the root
returns; a store left unhealthy by its newest backups' missing manifests
cleared by `--forget-backup`; `--forget-backup`'s refusals, with no
store configured too, its ARN check, a rerun, an unmounted root whose
rows it refuses and a fresh root, both left without an identity file,
beside a server too, and a DELETING row whose deletion it finishes; on
PostgreSQL a CreateBackup refused during the move's wait, a command that
waits out a DELETING row and a dead restore, and the three options
refused while another command holds the claim; on SQLite the three
refused beside a server (exit 6), and the default root moved away or
behind a dangling link refused as an unmounted root; `--move-store`
refused on each unhealthy store shape; on PostgreSQL the rewrite's lock
and statement bounds read inside its transaction, and a rewrite of more
keys than one batch committing each batch on its own; on Cassandra a
lost answer whose re-read finds the new root under another store id, not
counted; the server cell's loop of links on a thread with a 5 s bound.
192 mutants of the new tests, 191 killed; the survivor is a wiring edit
no wire cell can reach, whose function an engine cell pins.
Docs: the backup manual's restored-catalog paragraph (the
`--forget-backup` procedure) and a new section, "Moving a store to a new
root"; the admin guide's synopsis, sample output, the full report of
other stores in place of "reported in part", and two paragraphs on the
three options; the deployment guide's shared-root rule, with the copy
leaving the identity file out; the differences doc's "Where backups
live" row. Design revision item 36, with the PR 10 section's split line,
interfaces block and sync contract and section 7's memory paragraph
edited with it.
Not here: `migrate-legacy` (#821); a sweep of Cassandra's orphan status
entries beyond the root `--forget-store` forgets; detecting a server
left configured with the old root (the manual says to reconfigure every
instance first); on Cassandra the same all-partition read for the
store-scoped `list_backup_rows` the reconciler reads, so a DELETING row
of the configured store whose entry batch failed is not finished by the
reconciler until its entry is repaired (`--forget-backup` of its ARN
finishes and removes it).
Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
695dc66 to
eb9e48d
Compare
37fde53 to
7aa7d33
Compare
Backup/restore series (design: #396): #384 restore fidelity (merged) · #393 store · #394 format · #395 SnapshotEngine · #666 catalog migration framework (merged) · PR 6 the catalog index · PR 7 configuration, the store identity file and
backup purge· #700 the CreateBackup job (merged) · #740 the restore job, the cross-backend matrix andbackup remove-target(merged) · #825backup sync,--prune-missing,--adopt-store· #855 the CI filters, the open measurement cells and the backup and restore manual · this PR:backup sync --move-store,--forget-store,--forget-backupand the report of the rows of other stores · next: #821migrate-legacy, #748 export and import through the store.Closes #820 (part of #624).
Stacked on #855 (
feat/backup-docs, headeb9e48de), itself on #825 (feat/backup-sync,84bc2639) onmainat887b6e26(#840); opened as a draft againstfeat/backup-docs. Rebased from #855's earlier head695dc66bwithgit rebase --onto eb9e48de 695dc66b, after #825 moved ontomainat887b6e26and took its changes for the store fence (a missing or unreadable identity file recorded as a store problem, the fence's own call slot, a fence in the backup job before its first put), the other region's spelling of an ARN (BackupArn::names_same_backupin the dead-owner probe),--adopt-store's check before it writes, the identity file read once more after every sync, and the sync summary'sincomplete; #855 changed the manual and the admin guide in the same paragraphs. Eight files conflicted, each resolved with both sides kept:cmd_backup.rs(#825 movedsync_held's prune counts intoPruneCountsand its summary word intorun_word; this PR's other-stores exit, the landed-rows refusal and its helpers followPruneCounts' check, in that order, beforecomplete_run),reconciler.rs(probe_manifestkeeps #825's region rule and doc, with this PR'spub(super)and its--move-storesentence),tests/test_backup_job.py's module doc (#825's list, with this PR's three options in the claim line), the design'sbackup syncparagraph, the differences doc's "Where backups live" row, the admin guide's identity, sync and sync-health paragraphs, the deployment guide's shared-root bullet and the manual'sdestroyparagraph (each a word-level merge with no overlapping change, or #855's sentences followed by this PR's). The design's items keep their numbers (36 is this PR's). The commit is 35 files, +15,683 −525. Before that, rebased from #855's earlier head925509bdwithgit rebase --onto 695dc66b 925509bd, after #825 gaveHeldClaimthe job's two-period rule andHeldClaim::recheck(), and plain sync on SQLite stopped running the dead-owner resolutions. Five files conflicted: the engine's tests (both sides appended; #825's cells, then this PR's),cmd_backup.rs's sync (#825's removal of plain SQLite sync's resolution kept), the design's revision log (#855's item 35, then this PR's item 36), the admin guide's identity and sync paragraphs and the backup manual'sdestroyparagraph (each side's added sentences kept, word for word). On that base the move and the forget options now confirm the claim with a beat right before each step that cannot be undone (below), and the commit is 35 files, +15,054 −530. Before that, rebased from #855's head88d445dawithgit rebase --onto 925509bd 88d445da. One file conflicted, the design's revision log, only because #855's rebase changed one phrase of its item 35 (its quickstart sample lines at migration 8): item 35 is #855's, and this PR's item 36 follows it unchanged, so no item is renumbered. The other files both sides changed merged without a conflict, and the commit is the same size as before (35 files, +14,027 −528). One commit. No migration, no schema change, no new dependency;Cargo.lockis unchanged. The design's PR 10 ships as three PRs (revision item 34): #825 is the catalog-loss recovery, this PR the options that rewrite or delete rows of other stores, #821migrate-legacy. Every binary below is a debug build of the tree before the rebase (88d445daplus this diff), except the lines of the rebased tree.What
Storage trait (
crates/storage). ThreeBackupCatalogmethods, with no default body.list_backup_rows_by_root(kind, root, status): every row of the kind and root whatever its store id, of one status or all, inlist_backup_rows' order, the root matched byte for byte (an S3 root's?endpoint=suffix included).rewrite_store(from_kind, from_root, keys, to) -> u64: each row ofkeysthat is still of the old kind and root and not CREATING getsto's kind, root and id; the count of rows rewritten. The design's signature had no "from" arguments; with them a row another writer moved, or the CREATING row of a server still configured with the old root, is left rather than moved without its bytes. A partial rewrite (between PostgreSQL batches, MongoDB chunks or Cassandra keys) leaves rows under both roots, which the trait doc states: every step is idempotent and the command names what is left.summarize_store_roots() -> Vec<StoreRootCount>: the rows per store (kind, root, id) and status, in one order on every backend (StoreRootCount::sort), for sync's report; the design had no method that lists the roots.BackupStatus::ALL.remove_backup_rowgains the store kind and root the caller read, and removes only a row still of them, on every backend (Cassandra's lightweight transactionIF backup_status = ? AND store_kind = ? AND store_root = ?, aNotMatchedansweringfalse), so a tombstone duty of a server on the old root does not remove a row the move rewrote after the duty read it. Two methods with default bodies:summary_counts_status_entries()(false; Cassandra'strue, its summary counting status entries) andremove_orphan_status_entries(kind, root) -> StatusEntrySweep { removed, repaired }(zero; Cassandra deletes the root's entries whose row is gone or names another store, and writes again under the row's status, then deletes, an entry whose row is of the root under another status, each checked against the row atLOCAL_SERIAL). The docs offorget_available_backup_row,remove_backup_row,set_backup_deleted(the listing-order reason no duty meets the DELETED state) andremove_dead_creating_backup_rowname their new callers.Store (
crates/backup-store).BackupStore::delete_prefix_before(prefix, deadline) -> PrefixDeletion { deleted, finished }: the filesystem walk checks the deadline beside its cancel flag before each removal, the S3 loop before eachListObjectsV2,DeleteObjects,ListMultipartUploadsandAbortMultipartUpload; a call that reaches it returns what it removed withfinishedfalse. The default body checks the deadline once and runsdelete_prefix(a one-step store); the engine's bounded-calls adapter keeps that default (an override it had has no caller, the duties calling the store directly, and is removed).delete_prefixis unchanged for its other callers.PostgreSQL, SQLite, MongoDB, Cassandra. PostgreSQL: the by-root listing is
list_backup_rows' statement without the id (the(store_kind, store_root)prefix ofidx_backup_index_store_status);rewrite_storein batches ofREWRITE_BATCH= 5,000 keys, each oneUPDATE ... WHERE (account_id, backup_id) IN (SELECT * FROM unnest($4, $5)) AND store_kind = $6 AND store_root = $7 AND status <> 'CREATING', whose row lock orders it against a restore'sFOR UPDATEand DeleteBackup's transaction, run in a transaction of its own that the client commits (a batch that fails leaves the batches before it committed) underSET LOCAL lock_timeout = '3s'andstatement_timeout = '30s', with up to three attempts on a lock timeout (pauses of 0.5 s and 1 s): the statement of a command killed while it waits on a row lock commits nothing, where an autocommit statement on the pool committed once the lock was granted, after the claim was gone; the summary oneGROUP BY. SQLite: the same statements,rewrite_storeoneBEGIN IMMEDIATEtransaction under the write lock, all rows or none (a cell with a trigger that fails the last key shows no row moved). MongoDB:findon kind, root and status;update_manyin chunks ofREWRITE_CHUNK_KEYS= 1,000 keys, filtered on the old kind and root andstatus: {$ne: "CREATING"}, that also$inclock_seq, a convention: the update and a transaction that writes the document (claim_restore_tx,start_backup_delete_tx) are serialized (when the update commits after the transaction's snapshot and before its write, the transaction re-runs and reads the row as the update left it; when the transaction wrote first, the update waits for it and applies its filter to the committed document), and a registration keeps the store its server is configured with; a$groupfor the summary. Cassandra: the by-root listing reads the five(root, status)partitions ofbackup_index_by_statuswhatever status it is asked for, filters entries on the kind, dedupes them by (account, ARN), re-reads eachbackups_by_arnrow once and keeps it when its kind and root match (and its status, when one is asked for), whatever partition its entry is in (root_index_arns, refactored fromstatus_index_arns): a row whose only entry is under an earlier status (a status change whose entry batch failed, which is logged and not sent again, or whose writer died between its transaction and its batch) is listed under its own status.rewrite_store, per key: aLOCAL_SERIALread, skipped unless of the old kind and root and not CREATING; the new root's status entry written; the lightweight transactionUPDATE backups_by_arn SET store_kind, store_root, store_id ... IF store_kind = ? AND store_root = ? AND backup_status = <read>; the old root's entry deleted (a failure logged). An unknown outcome is settled by a serial read (moved only when the row now carriestowhole, its kind, root and store id; a row ofto's root under another store id is another store's, left and not counted, as the trait doc now says);NotMatchedreads the row atLOCAL_SERIALfirst and keeps the new entry when the row already namesto(a lost answer completed by the retry's own prepare), and otherwise removes it and reads again, up to three attempts, after which the key is left uncounted.start_backup_delete's andset_backup_deleted's lightweight transactions now carry the store kind and root they read, so a DeleteBackup of a server on the old root that read the row before the rewrite does not apply, reads the row again and moves its status entry under the root the row now names. The module doc gives the one exception this makes to its row-first rule: a stop after the new entry leaves only an entry every reader skips, while the row first would leave a moved row that no listing of the configured store returns. The summary counts the status entries, unverified, as the trait doc says. Test hooksfail_rewrite_writes,stop_rewrite(RewriteStop::{AfterNewEntry, AfterRow}),pause_before_rewrite_writeandpause_before_deleted_write. Therewrite_storetrait doc says what a client that dies in the call leaves on each backend, andset_backup_deleted's why no deletion duty meets the DELETED state between it and the removal.Engine (
crates/engine/src/backup/). Newstore_maintenance.rs, written againstBackupCatalogand the configured store:locate_root(storage, root, configured_root): the kind of the rows under a root (both kinds are tried throughread_store_ids; an S3 label starts withs3://and a filesystem label is an absolute path) and the store ids the catalog records for it;RootRefusal::ConfiguredRoot, andNoRowswith the summary, so the command can print the stores the catalog knows.move_store: reads the old root's rows (each row'ssource_table_details_jsondropped as it is read) and the configured root's (account, backup id) pairs, both before anything changes, so a failed read stops the move with nothing changed; the two-rows check runs first: the configured root's rows and the old root's are grouped by (account, backup id), and every old-root row of a group of more than one stops the move and is checked no further (on Cassandra two spellings of an ARN share one prefix, which a finished deletion would empty); then it checks each other row against the configured store and names every row that stops the move in one pass. A CREATING row goes throughreconciler::resolve_dead_creating_row, split out ofresolve_dead_creating_rowsso both callers share one body (CreatingOutcome), with the configured store's calls andOnIncomplete::Leave: it is written AVAILABLE when the copy holds its complete backup, and any other probe result (no manifest, a data file missing, short or long, no sidecar, a manifest of another ARN) stops the move with no status written; a row not handed over has a live owner. A DELETING row with a verified manifest under its copied prefix stops the move; otherwisefinish_backup_deletedeletes what the copy brought behind the fence and sets DELETED. A DELETED or FAILED row with a verified manifest there stops the move. The stop's text is per status: for a DELETING or DELETED row it says the backup was deleted and to remove that prefix from the configured root, and names removing the row with--forget-backuponly as what would have a sync adopt the prefix and bring back AVAILABLE a backup a client deleted; for a FAILED row it offers the prefix's removal or--forget-backupand an adopting sync. A store error during any of these checks stops the move with the row named ("the configured store could not be read"). An AVAILABLE row is probed withprobe_manifest(nowpub(super)) and its manifest's size and item total must be the row's. A row that would give an account a second row of one backup id (against the configured root's rows, and among the moved rows, which only Cassandra can hold) stops the move, as said. With none stopping,StoreFence::check_catalogre-reads the identity file once,rewrite_storeruns in slices ofREWRITE_SLICE= 500 keys withHeldClaim::recheck()before each (a claim lost between slices stops the move,MoveReport::stoppednaming the rows it came before, with the earlier slices moved and the rest left under the old root, and the command's text says how many moved), and the old root is listed again: a row still there is reported as left (a CreateBackup of a server still on the old root, a rowrewrite_storedid not count, the slices after a lost claim); a key the old root no longer lists counts as moved only when its row, read by its ARN (MOVED_ROW_READS= 16 at a time, in place of the listing of the configured store), names the configured store, and one that names no such store (removed, or moved to a third store) is reported as gone (MoveReport::gone); when the old root's re-list fails after a rewrite that counted every key, every key is taken as moved, and when a key's read fails, that key and the rest are. The AVAILABLE resolutions and finished deletions are kept when the move stops. Audit lines (extenddb::audit,backup sync --move-store) for each row moved, resolved or finished.forget_storeandforget_backups: rows removed outright with the removals that exist (forget_row): AVAILABLEforget_available_backup_row, DELETED and FAILEDremove_backup_row, CREATINGremove_dead_creating_backup_rowon the catalog's clock, DELETINGset_backup_deletedthenremove_backup_row(no duty meets the DELETED state between them: one that listed rows before the claim listed this one DELETING, and one that lists it DELETED did so after the claim and reads it held). A DELETING row of the configured store is finished first when the store's check passed (finish_then_remove: the wait for the duties, so a stop leaves the row DELETING and untouched, thenfinish_backup_deletebehind the fence, then the removal,Forgotten::RemovedAfterDeletion, "removed: the row is gone, and this command deleted its objects first ..."), and refused otherwise (Forgotten::DeletionPending); when the opening's check failed onIdMismatchorMultipleRowIdsand the row carries one of those row ids (StoreProblem::mismatch_row_ids), the refusal isForgotten::DeletionBehindMismatch, which names the way out (the same command with[backup.store] kind = "disabled", a sync that adopts the backup, DeleteBackup again); a DELETED row whose deletion the command's own SQLite pass finished is reported the same way (ForgetRun::deletions_finished). Every row of a configured root whose opening found no identity file under rows is refused (Forgotten::RootWithoutFile), and so is every row of a defaulted root found missing with rows for it (ForgetRun::unmounted_root). A removal that meets another status re-reads once and applies that status's removal (a DELETING row of the configured store that a server's reconciler finishes during the wait is removed as the DELETED row it then is). A FAILED row whose heartbeat is within the owner window is left (Forgotten::LiveOwner, whose text for a FAILED row gives the condition to run again on, the heartbeat older thanstale_job_after_secs, since its job ended with the FAILED write;FailedOwners, onelist_backup_rows_older_thanper store; a failed read of that listing leaves the row,Forgotten::Failed).--forget-backupreads the ARN as given, then under its other spelling (BackupArnSpellings), then throughLegacyBackupReader::describe; an ARN that none of them finds isNoRow, printed "no row carries this ARN; nothing to remove" and counted as done, and the run stops at the first ARN it does not remove;check_arnschecks every ARN's form for the command before the claim. Both run under aForgetRun(the owner window, the instant before which no FAILED or DELETED row is removed, the callback that prints the wait, the stop, the configured store asForgetConfigured { store, healthy, mismatch_row_ids, file_missing_with_rows }, the missing defaulted root, the deletions the command's own pass finished): beside servers the first FAILED or DELETED removal waits untilFORGET_WAIT(60 s) after the claim was taken, asking the stop each second. After--forget-store's removals the root is listed again and the rows it still holds are kept by name (ForgetStoreReport::rows_left),remove_orphan_status_entriesruns for the root and both counts are reported (entries_swept), then the root's store ids are read again and reported. The in-use text namesextenddb backup remove-target --account <id> <table>.other_storesandgroup_counts: the summary grouped per store other than the configured one, and each CREATING and DELETING row named throughlist_backup_rows(Some(status), <that store>); when the backend's summary counts status entries, the store's rows are read fromlist_backup_rows_by_rootof its root kept to its id (so a row whose entry is under an earlier status counts under its own status and is named), each count is checked against them, and what no row of its status stands behind goes toOtherStore::entries.maintenance::wait_for_quietandbusy_jobstake&[StoreIdentity].reconciler.rs:Duty::fencedre-reads the row after the fence passes and skips one that is gone or whose status or store changed, then reads the maintenance claim again and starts nothing while it is held (each logged), for the FAILED-prefix, DELETED-prefix and tombstone duties; each deletion or removal that read the claim free must end byDUTY_DELETION_BOUND(30 s,S3_CHECK_TIMEOUT) after that read was sent:fencedanswersFenced::OutOfTimewhen the read was answered at or after the deadline (a slow catalog), the prefix deletion goes throughdelete_prefix_beforewith the deadline (an unfinished answer stops the duty), and the call is also dropped at the deadline; each of these stops its duty for the cycle (Acted::OutOfTime). The tombstone removal passes the store the duty listed the row with.ReconcilerState::for_maintenance_holder_without_store_checkruns a cycle with no store check, for the forget options' own pass on SQLite. The module doc,DUTY_DELETION_BOUNDandFORGET_WAITsay what remains (the one removal or S3 request in flight at the deadline) and the assumption every lease shares (a process paused past the window between its last check and a call already sent is outside it), name the DELETING exception and word theOTHER_STOREline for the move.job.rsreads the maintenance claim again immediately beforeclaim_backup, andrestore.rsimmediately beforeclaim_restore(refusedLimitExceededException, nothing inserted);backup.rs's comment on the check andjob.rs's state the window that remains, from that read to the insert, and what reports a row that lands in it.restore.rs:OTHER_STOREreads "...; configure that store in [backup.store], or copy its tree under the configured root and move the backup's rows to the configured store withextenddb backup sync --move-store <root>, and restore again", as design 5.3 step 7 says.runtime.rs:check_configured_store_read_only, the store's check that writes nothing (the identity read without the create and withoutvalidate, the id against the one the command opened the store with, the manifest probe).sync.rs: the module doc says that sync lists and changes no row of another store and that the command reports them.store_maintenance.rs:MoveTarget::claimandForgetRun::claimcarry the held claim (Nonein the engine cells that send no beat), and each step that cannot be undone is preceded byHeldClaim::recheck()(#825's), as sync's insert is: the move's resolution of a dead CREATING row, its finished deletion of a DELETING row and each slice of its rewrite; each removal of the forget options, a DELETING row'sset_backup_deletedor finished deletion and the removal of the DELETED row it leaves; and--forget-store's sweep of the status entries. A loss stops the run there (MoveReport::stopped,Forgotten::Stopped), naming the step and what it left; the module doc states the bound that remains, a call already sent when the process stopped, as #825 states it for sync and purge.App (
crates/app/src/cmd_backup.rs).backup sync --move-store <old root>,--forget-store <root>,--forget-backup <arn>(repeatable), each with clap help, conflicting with each other, with--adopt-storeand with--prune-missing(exit 2).--move-storeneedsIdentity::Known, locates the root before the claim, takes the claim on every backend (on SQLite too, beside the serve lock, as the other options do), waits over the configured identity and the old root's ids, runs the move, prints its sections (Resolved, Finished, Stopping the move, Moved, Gone during the move, Left under the old root) and the summary line (which counts the rows gone), exits 1 unless every row moved, and only then syncs the configured root; a row of the old root in that sync's closing report of other stores is named under "Rows that landed under the old root during the move" and exits 1. The forget options open inMode::Forget: the catalog as a writing command opens it (the serve lock on SQLite), the store described as a read leaves it (Caller::ReadOnlyCommand: no default root created, novalidateprobe; a defaulted root that does not exist is looked up in the catalog under the path and under each path met while every symbolic link along it is replaced by its target, component by component, whether or not the target exists, up to 40 links (a chain, a relative target, a directory link in the path), each also with its longest existing ancestor canonicalized, and with rows there the server crate answersNotConfigured::DefaultRootMissingWithRows, logged at WARN and printed in the header, whose rows the forget refuses and whose label--forget-storerefuses as the configured root), the identity file read and never created, and, when it agrees with the rows,check_configured_store_read_onlyrun once and recorded, so the store is healthy for a DELETING row's finished deletion only when that check passed; their own pass on SQLite runs without the store check. They keep the catalog open when no store is configured (openreturnsOpening::CatalogOnlyfor them), print the store's state in the header, locate the root (--forget-store), take the claim on every backend, wait (over the configured identity when known, otherwise the configured root's ids fromread_store_ids, whose failed read refuses the command before the claim, and the root's ids), remove, and print each row's outcome and the summary line;--forget-backupchecks every ARN's form before the claim and refuses at once, with nothing changed; beside servers the forget options print the wait before their first FAILED or DELETED removal. After--forget-backup, when a store is configured,check_configured_store_read_onlyruns once and is printed as "The configured store's check passes (store id ...)" or the problem, which forManifestMissingnames the next ARN and the command for it; when the opening found no identity file (Identity::Empty, a fresh root; orFileMissingWithRows, whose rows the removals refused) the check is not run and the command says so (ForgetCheck): for a fresh root that a server's first check creates the file, and otherwise to mount the volume before the root's last row is gone. Plain sync'sManifestMissingrefusal says to check first that the volume is mounted and the root shared, then prints--forget-backupwith the ARN. Every sync that listed the root to its end prints "Rows of other stores": per store the counts per status (on Cassandra, what no row of its status stands behind as "status entries with no row of that status behind them, which readers skip; with no row of the root left, --forget-store removes them"), the CREATING and DELETING rows, and the line naming--move-storeand--forget-store, printed only when a root other than the configured one is listed (a store of the configured root under another store id, which both options refuse, names--forget-backup <arn>on its own line); a failed read of them is printed and exits 1.--forget-storeprints the counts of status entries it removed and repaired, names under "Rows that landed under the root during the run" each row the listing after the removals holds that the first did not, counts those as left, and its summary says "incomplete" (not "stopped") for a run that read every row and left some. The forget header prints a store whose opening check gave no answer as "check pending" (its DELETING rows are refused), and the note that the check after the removals reads the store only when that check will run. A move whose rows were partly removed by another writer prints "No row is left under " with both counts; a move stopped between two slices says how many rows moved and to run it again; after a completed move, the advice of a failed listing, a stop and a failed report of other stores is plainbackup sync, which the option would otherwise refuse as a root with no rows. The clap help of--forget-backupnames the DELETING exception and the skipped check, says a later sync adopts the backup again where its bytes are, as--forget-store's does, and both forget options' help says they wait for running jobs aspurgedoes and leave a row whose job writes a heartbeat after the wait. The left-row and landed-row exits of the move say to copy the row's prefix under the new root or--forget-backupit. The module doc gains the three options.take_claim,adopt_wait_identityandroot_refusalare factored out; the line and exit decisions are pure functions with unit tests, and so are the decisions taken from the backend and the located root (owner_window,removals_from,forget_wait_identities,forget_check,others_outcome,manifest_missing_hint,old_root_listed). A move whose re-list fails says to run plain sync rather than the move again.Harness.
tests/test_backup_job.py: thirteen new cells and five extended (below); helpers_set_root,_set_store_disabled,_copy_root,_copy_tree_into,_tree_without_identity,_section,_age_row.tests/test_backup_store_config.py: the serve-lock cell runs the three options beside a SQLite server. The cell of a CreateBackup during the move's wait gives each skip its own reason (SQLite's serve lock; on MongoDB, the cell's SQL catalog edits). The server crate's cell of a loop of links runs the walk on a thread and fails by name when it has not ended within 5 s.Docs and texts. The backup manual: the restored-catalog paragraph now gives the
--forget-backup <arn>procedure in place of "This release has no command that clears it"; the claim paragraph names the three options among those that wait; the configuration section points a root that has to change at the move; a new section, "Moving a store to a new root" (reconfigure every server first, copy whole with the modes and without the old root'sextenddb-store.json, what to do when it was copied, finish the copy, the root label byte for byte, the checks per status, a CREATING row whose copy is incomplete and its two ways out, the stop and its way out, the summary line, the re-list, plain sync after an interrupted move, what to check before deleting the old root,OTHER_STORE, memory, and--forget-storeto drop a root's rows, with the wait beside servers and what a paused server can still do, a DELETING row of another store keeping its bytes, the status entries a Cassandra report line may count, a row landed during the move's sync, and--forget-backup's DELETING row and unmounted-root refusal); the restored-catalog paragraph says to check that the volume is mounted before forgetting rows. The admin guide: the identity paragraph (the move, the copy without the identity file), the synopsis, the exit-1 list, the sample output with the other stores' section, the newest-manifest sentence (--forget-backup), the full report in place of "reported in part", and two paragraphs on the three options with the measured memory of both reads, the rewrite's slices and PostgreSQL's batches and bounds with the measured statement times, the wait (any running job holds the command, up to twicestale_job_after_secs, 600 s at the default) and a sample output (the CREATING rule, a key counted as moved, the jobs' claim read before the insert, the duties' claim read, the deadline in the store and the forget's wait, with what remains and the lease assumption, the DELETING row finished by the forget, the opening that writes nothing and the unmounted-root refusal,--forget-store's sweep of Cassandra's status entries, the check after--forget-backupthat writes nothing, a rerun of an ARN list, the per-row memory figure). The deployment guide's shared-root bullet says how to change the root later, the copy leaving the identity file out; the differences doc's "Where backups live" row says a moved root keeps its ARNs. Design: revision item 36; the trait block'sremove_backup_rowsignature, the PR 10 section's split line, interfaces block (rewrite_store's arguments,summarize_store_roots,remove_orphan_status_entries) and sync contract, and section 7's memory paragraph, edited with it.Why
Design 4(c), 4(g) and the PR 10 section. #825 left three gaps that this PR closes. A store root could not change without losing its rows: a tree copied to a new root has no rows there, and sync skips every prefix whose backup id a row of the old root holds, so the backups were reachable only by adopting them again under the new root's store, and a failed restore of another store had no remedy but configuring that store. Rows of a root that is gone could not be dropped without a tombstone, and a tombstone is what a deletion duty acts on. And a catalog restored from a database backup whose newest AVAILABLE backup was deleted after the catalog's backup stays unhealthy ("manifest probe failed"), which
--prune-missingrefuses, with the prefix put back as the only way out. Plain sync also reported the rows of other roots only in part.Choices to weigh
summarize_store_roots. The design's interfaces gavelist_backup_rows_by_root, which takes a root; nothing listed the roots the report has to name. The alternative was onelist_account_backup_rowsper account, which reads every row of the catalog whole (about 22.5 KiB per row of a five-GSI table) to count them. The method is one grouping query on three backends; on Cassandra it counts status entries without re-reading the rows, so the report checks each of that backend's counts against the root's listing kept to the store's id (summary_counts_status_entries) and prints what no row of its status stands behind as status entries, which--forget-storeof that root then removes. That listing reads all five status partitions of the root, so a row whose entry batch failed counts as a row: the move, the forget options and the report all see it, which the closing check before deleting an old root needs. The CREATING and DELETING rows it names are read throughlist_backup_rowsof each store, which re-reads every row.rewrite_storecarries the old kind and root and excludes CREATING. Between the move's checks and its rewrite a server still on the old root can write (its CreateBackup after the wait, its DeleteBackup, which the claim does not exclude). Moving a CREATING row whose job writes to the old root would give an AVAILABLE row of the new root with no bytes there. A row another writer moved to DELETING may still move; the configured store's reconciler then finishes that deletion under the new root, which is what the DeleteBackup asked for. The rows not counted are found by the re-list and reported.read_store_idsof the new root then counts that entry; its id is the configured id, so no identity mismatch follows. A DeleteBackup of a server on the old root that read a row before the rewrite would have moved the status entry under the old root and left the moved row listed under no root; its lightweight transactions now carry the store it read, so it does not apply, reads the row again and moves the entry under the root the row names.Duty::fenced's two reads, a deadline inside the store, and a wait. A reconciler cycle reads the maintenance claim once, at its start, and its FAILED-prefix, DELETED-prefix and tombstone duties act on rows listed when each began. A cycle that started just before a forget's claim could delete the prefix of a FAILED row the operator forgot precisely to keep it (measured through the binary on PostgreSQL before this change: up to 101 of 3,000 prefixes deleted after their rows' removal).fencedre-reads the row (gone, another status, another store: skipped) and then the claim (held: nothing starts), two catalog reads per deletion; a deletion or removal that read the claim free must end byDUTY_DELETION_BOUND, 30 s after that read was sent (the S3 store's per-call bound, under the SDK's 60 s attempt timeout): a read answered after the deadline starts nothing (a claim read answered two minutes late deleted the whole prefix of a row a forget had removed before this change, in an engine cell), and the deadline is checked inside the store, before each removal of the filesystem walk and each request of the S3 loop, since a timer outside it fires only once a paused process's runtime runs again (a server SIGSTOPped mid-deletion and resumed after the forget removed 150 and 21 links in two runs through the binary before this change; 0 and 1 after it, the one being the removal in flight). Beside servers the forget options waitFORGET_WAIT, the bound plus one 30 s store call, 60 s from the claim, before their first FAILED or DELETED removal, printing the wait (on SQLite the serve lock keeps every server out and nothing waits). What remains is the one removal, or one S3 request (up to 1,000 keys), in flight at the deadline, and a process paused past the wait between its last check and a call already sent; the bound shares that assumption with every lease of the design, and the texts state it. The alternative of marking the row before each deletion, which the removal would be conditioned on, would close it at a write per deletion and a fourth removal condition on every backend.--forget-backupis the way out ofManifestMissing, so it cannot require a healthy store, and a deployment that turnedkind = "disabled"must still be able to drop an old root's rows; the removals delete no object (but for the DELETING exception below) and read no store, the opening reads the identity file without creating it and runs the check that writes nothing, and the store's check after--forget-backupwrites nothing either. An empty mount point under the rows (FileMissingWithRows) is the case that needs more: a running server's check rewrites a missing file once the root has no rows and reports the store healthy, and the documented rerun opened the store with the writing check (through the binary before this change: a new identity file on the empty mount point, and a CreateBackup landing there). So every row of that root is refused, naming the unmounted volume, and nothing changes until the volume is mounted or[backup.store]points at the root that holds the backups. SQLite's defaulted root, moved away or behind a link to a volume that is gone, left the store unconfigured for a read-only command and so was not refused (the rows were removed, and the next server wrote a new identity file at the empty path); the server crate now reads the catalog's ids for it, following every symbolic link along the path (a chain of links, a relative target, a directory link whose target is gone, up to 40 links), and the forget refuses its rows the same way.finish_backup_deletebehind the fence before the removal, which needs the store's check to have passed, and refuses the row otherwise ("let a server finish the DeleteBackup, or run again once the store's check passes"); the per-row line says when the command deleted the objects. The wait for the duties comes before the deletion, so a stop during it leaves the row DELETING and its objects untouched, and "not reached" is then true. When the check failed on an identity mismatch that the row's own store id takes part in (the manual's copied identity file), the refusal says so and names the way out, the same command with[backup.store] kind = "disabled", whose generic DELETING path removes the row and keeps its bytes; run through the SQLite binary: the refusal, the removal with the bytes kept, the move's sync adopting the backup AVAILABLE, and a server's DeleteBackup emptying its prefix. A DELETING row of another store keeps its bytes unless a server configured with that store finishes the deletion (the one its DeleteBackup asked for): the command cannot reach that root, and a sync of it after its return with the bytes adopts the backup again, which the texts say.--prune-missingkeeps refusing onManifestMissing, which is also the host-local-root detector (4(c)): pruning while it holds would remove rows whose bytes are on another host. The store's check after--forget-backupis printed and does not change the exit: the rows asked for were removed, and the check names the next ARN to forget when another newest manifest is gone.remove_backup_rownames its store. DELETING is removed asset_backup_deletedthenremove_backup_row. The DELETED state between them is never acted on, by the order of the listings: a duty that lists rows after the claim reads the claim before it acts, and one that listed them before saw the row DELETING.remove_backup_rowtakes the store the caller read, so a tombstone duty of a server still on the old root, past its fence just before the move took the claim, leaves a row the rewrite moved (the copy's leftovers would otherwise have no row and no duty).--forget-backupremoves it.claim_backup, and the restore immediately before its registration, so an open held on a lock that returns while a command holds the claim inserts nothing (through the binary on PostgreSQL: refusedLimitExceededException, no row). The window from that read to the insert remains, however long the insert takes: a row that lands during the move's wait is listed by it, one before the rewrite stays CREATING under the old root (the rewrite skips CREATING) and the re-list reports it, one during the move's sync is named by that sync's closing report of other stores and the move exits 1 (through the binary on PostgreSQL: a CreateBackup held atclaim_backuppast the re-list and released during the sync, then "a row landed under the old root during the move", exit 1), and one after the report only a later plain sync reports; the backup manual says to check that report before deleting the old root.purge's, holds on such a row and refuses the command if it is still live at the wait's end, and the removal leaves one whose heartbeat was written after the wait, or one of a store the wait does not cover, rather than removing a row whose prefix is still changing. The rule applies to--forget-storeand to--forget-backupalike.extenddb-store.json. The servers restarted in step 1 give the new root its own file; copying the old one over it turns every running server's store unhealthy (an identity mismatch), and with a backup taken under the new root in between the move refuses too. The manual gives the copy commands with the exclusion and the way out when the file was copied (a restart; with a backup taken meanwhile,--forget-backupof it before the move, whose sync adopts it again), both run through the binary.backup list, so both the old root's read and the configured root's count (the sync after the move reads the configured root again). Measured at 20,000 rows moved into an empty store (below), within 12 % ofbackup listover the same rows: about 23 KiB per row on PostgreSQL and 27 on SQLite above about 50 MiB, at 1,452 bytes of table description per row; the figure grows with the description. A 50-row move on the PostgreSQL build peaked at 49 MiB into an empty store and 231 MiB into one of 8,000 rows, the same 23 KiB per row of the configured root. After the rewrite the move reads each moved row by its ARN rather than listing the configured store a second time. Streaming the listings out of the backends, or reading the configured root's (account, backup id) pairs alone, is not done here; the texts state both reads.stale_job_after_secswhose claim another command took finished its rewrite on resuming. The move now callsrewrite_store500 keys at a time with a beat of the claim before each, so such a move stops within one slice; what remains is the slice in flight. On PostgreSQL one statement over every key outgrew the 30 s statement timeout well inside the sizes a catalog can reach (in plain SQL over a 400,000-row catalog with 0006's indexes and 1,449-byte descriptions, 200,000 keys in one statement took 16.4 and 16.9 s; batches of 5,000 0.23 to 0.29 s each, 20,000 0.9 to 1.1 s), sorewrite_storeruns batches of 5,000 keys, each committed on its own; scaling the timeout with the key count instead would keep a long transaction holding row locks against DeleteBackup and restores. Each slice of the move is one batch.Testing done
On the tree rebased onto
eb9e48de(#825 at84bc2639, main at887b6e26), debug builds into a shared target, every tracked file touched before each build:cargo fmt --all -- --checkandcargo +1.97.0 clippy --locked --workspace --all-targets -- -D warningsclean, also with--features extenddb-storage-cassandra/test-hooks,extenddb-storage-mongodb/test-hooksand with--features extenddb-engine/backup-test-hooks; engine--lib757 passed, app 61, server 376, core 1087, storage 241; SQLite--lib backup69; PostgreSQL--test backup_restore64 (--test-threads=2); Cassandra direct, this PR's 13 cells, on a node of its own; the move and forget cells oftests/test_backup_job.pyon the SQLite build 9 passed, 3 skipped (-n 2), and, with the second-command cell, on the PostgreSQL build 12 passed, 2 skipped (-n 2).On this tree (head
37fde534with the changes for the rewrite's slices, PostgreSQL's batches, the per-key read after the rewrite and the texts), debug builds into a shared target, every tracked file touched before each build:cargo fmt --all -- --checkandcargo +1.97.0 clippy --locked --workspace --all-targets -- -D warningsclean, also with--features extenddb-storage-cassandra/test-hooks,extenddb-storage-mongodb/test-hooksand with--features extenddb-engine/backup-test-hooks; engine--lib536 passed, app 60, serverbackup_store11, storage 237; PostgreSQL--test backup_restore64 (--test-threads=4); Cassandra direct, the nine rewrite cells, on a node of its own; the move and forget cells oftests/test_backup_job.pyon the SQLite build 9 passed, 3 skipped (-n 4), and on the PostgreSQL build 10 passed, 2 skipped (-n 2). New cells: enginemove_store_rechecks_the_claim_before_each_slice_of_the_rewrite(501 rows: a claim lost before the second slice leaves 500 moved and the last left and listed, after onerewrite_storecall; a second run moves it; a claim kept throughout makes two calls, of 500 keys and 1) anda_failed_row_left_for_its_heartbeat_names_the_heartbeats_age;a_key_counts_as_moved_only_when_its_row_names_the_configured_storegains a row moved to a third store before the rewrite (gone, not moved) and a failed read of a moved key's row (every key taken as moved, the move incomplete); PostgreSQLa_rewrite_of_more_keys_than_one_batch_commits_each_batch_on_its_own(5,001 keys with the last row locked past every attempt: an error, 5,000 rows moved, then the last alone on a second call); Cassandratest_a_lost_rewrite_answer_does_not_count_the_row_under_anothers_store_id(a lost answer whose serial read finds the new root under another store id: counted 0, the other store kept and listed). Mutants, each a targeted edit reverted by a second edit: the beat only before the first slice; PostgreSQL's batches as one statement (chunks(keys.len())); Cassandra's lost-answer comparison onstore_rootalone; the link walk's hop limit removed (the server cell fails by name after 5 s); the per-key read counting any row as moved; a failed read counted as gone; the configured-root hint, the landed rows and the advice after a move each disabled: 9 of 9 killed. Through the PostgreSQL build: a move of 3,001 rows under a trigger that sleeps 12 ms per row the rewrite updates (about 36 s for one statement over every key, past the 30 s bound, and about 6 s per slice) completed in 44.3 s, exit 0; peak RSS of a 50-row move, 49 MiB into an empty store and 231 MiB into a store of 8,000 rows (1.8 and 5.8 s). A move stopped with SIGSTOP at its claim line on Cassandra or MongoDB, past the stale window, was not run through a binary here; the engine cell covers the check between slices.On the tree rebased onto
695dc66b(#825 atb42dbfa9, main atb8ad9cd5), debug builds, every tracked file touched:cargo fmt --all -- --checkandcargo +1.97.0 clippy --locked --workspace --all-targets -- -D warningsclean, also with--features extenddb-storage-cassandra/test-hooks,extenddb-storage-mongodb/test-hooksand with--features extenddb-engine/backup-test-hooks; engine--lib534 passed, app 60, server 344, storage 237; SQLite--lib backup69; PostgreSQLbackup_restore63 (--test-threads=2); MongoDB live, this PR's backup-method cells and the insert cell, 7 passed; Cassandra direct on a node of its own, this PR's cells and the insert cell, 21 passed; the wire suitestests/test_backup_store_config.py tests/test_backup_job.pyon the SQLite build 46 passed, 13 skipped (-n 4) and on the PostgreSQL build 39 passed, 20 skipped (-n 2). New engine cells for the claim's beat before each step that cannot be undone:forget_store_rechecks_the_claim_before_each_step_that_cannot_be_undone(each status's removal, another store's DELETING row's DELETED write and removal, the sweep),forget_backup_rechecks_the_claim_before_a_deleting_rows_deletion_and_its_removalandmove_store_rechecks_the_claim_before_each_resolution_deletion_and_the_rewrite, with a claim that confirms N beats and then answersLost, exact beat counts and the row and objects asserted at each stop. Mutants, each re-check removed in turn with the file written back and checked by hash: 11, all killed, each at its own case.On the tree rebased onto
925509bd, before that (main atb8ad9cd5), debug builds, every tracked file touched:cargo fmt --all -- --checkandcargo +1.97.0 clippy --locked --workspace --all-targets -- -D warningsclean, also with--features extenddb-storage-cassandra/test-hooks,extenddb-storage-mongodb/test-hooksand with--features extenddb-engine/backup-test-hooks; engine--lib518 passed, app 59, server 343, storage 237; SQLite--lib backup68; PostgreSQLbackup_restore63 (--test-threads=2); the wire suitestests/test_backup_store_config.py tests/test_backup_job.pyon the SQLite build 44 passed, 13 skipped (-n 4).Then, on the same tree, for the store errors during the move's checks, the guards of the forget options and the report, the move's stop text per status, the missing default root's lookup through every link of its path, the configured root's ids read before the forget's wait, and PostgreSQL's rewrite bounds:
cargo fmt --all -- --checkand the three clippy lines above clean; engine--lib522 passed, app 60, server 344, storage 237; PostgreSQL's sixcatalog_index::cells of the backup methods passed (--test-threads=2). New engine cells:a_store_error_during_the_moves_checks_stops_it(a store read failing under the prefix of a DELETED, a FAILED, a DELETING and an AVAILABLE row over a complete copy: the row named, nothing rewritten, no object deleted, the row unchanged),a_failed_read_of_the_failed_rows_owners_removes_nothing(through theFake's newolder_than_error),a_deleting_row_a_server_finishes_during_the_wait_is_removed_deletedandthe_report_of_other_stores_keeps_two_ids_of_one_root_apart(Cassandra's report path, two ids under one root);move_store_names_every_row_that_stops_it_and_moves_nonenow checks the text per status, andforget_backup_refuses_the_rows_of_a_missing_root_with_rowsa row of a root whose label extends the missing one. New server cella_missing_default_root_is_looked_up_through_every_link_of_its_path(an absolute chain, a directory link, a relative chain, a database directory reached through a link, a..path, a loop of links), against aStubCatalogthat answers for one root; app cella_failed_read_of_the_configured_roots_ids_refuses_the_forget. PostgreSQL's held-lock cell now readsSHOW lock_timeoutandstatement_timeoutinside the rewrite's transaction (3s,30s) as well as after it. The deadUnsupportedarm of--forget-backup's legacy read is removed: no backend'sdescribereturns it. Mutants, each killed by its cell: the three store errors taken as success (DELETED and FAILED, DELETING, AVAILABLE), the owner read's failure taken as dead, a finish that found the row DELETED reported gone, Cassandra's report counting every id of the root, the unmounted root matched by prefix, every status given the adoption remedy, one link followed, a relative target used as it is, no canonical-ancestor label, the failed read of the configured root's ids taken as none, and PostgreSQL's rewrite without itsstatement_timeout: 13 of 13. Through the SQLite build: the five link shapes of a missing default root (one link, a relative link, a chain, a directory link in the path, a relative chain, each with the volume renamed away) and the database path through/home/robinnsc -> /local/home/robinnscall refuse--forget-backupof the row (exit 1, the row kept; the three chained shapes removed it with exit 0 before);test_move_store_moves_a_copied_tree_and_stops_on_an_incomplete_copy, which now also stops on a DELETED row over a complete copy with its text, andtest_forget_refuses_the_rows_of_a_missing_default_rootpassed on the SQLite build, the first also on the PostgreSQL build.Measured on the tree before the rebase, debug builds, a 16-core Xeon Platinum 8124M host (30 GiB, Linux 5.10), the services local.
cargo fmt --all -- --checkclean;cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warningsclean, and with--features extenddb-storage-cassandra/test-hooks,extenddb-storage-mongodb/test-hooks, with--features extenddb-engine/backup-test-hooks, and on-p extenddb-storage-cassandra --all-targets --features test-hooks;cargo +1.88.0 check --locked --workspace --all-targetsclean, no warning.--lib: 516 passed, 50 of them new: 13 (move_store_resolves_checks_and_moves_each_status_of_the_old_root,move_store_names_every_row_that_stops_it_and_moves_none,rows_the_rewrite_does_not_move_are_listed_as_left,move_store_on_a_root_another_catalog_adopted_moves_nothing,move_store_refuses_a_second_row_of_one_backup_id,the_wait_covers_every_store_it_is_given,locate_root_finds_the_kind_and_ids_and_refuses_the_rest,forget_store_removes_the_roots_rows_outright_and_reports_what_it_left,forget_backup_removes_each_row_in_order_and_stops_at_the_first_refusal,a_stop_before_the_first_row_forgets_nothing,forget_backup_of_the_newest_rows_deleted_backup_heals_the_store,a_duty_whose_row_is_forgotten_or_moved_after_its_listing_acts_on_nothing,the_report_of_other_stores_counts_and_names_their_rows_and_nothing_acts_on_them) and 16 (a_dead_creating_row_whose_copy_is_incomplete_stops_the_move_unwrittenover six copy shapes,the_moves_owner_window_is_the_one_the_takeover_readswith aFakewhose takeover honoursstale_after,an_available_row_whose_manifest_counts_other_items_stops_the_move,two_rows_of_one_backup_id_among_the_moved_stop_the_move,a_stop_during_the_checks_leaves_the_later_rows_unchecked,a_key_counts_as_moved_only_when_its_row_names_the_configured_store,a_removal_that_meets_another_status_or_store_reads_the_row_again,forget_backup_goes_past_an_arn_no_row_carries,the_arns_are_checked_before_the_claim,a_duty_that_finds_the_claim_held_after_its_reread_acts_on_nothing,a_duty_keeps_the_prefix_of_a_row_that_turned_available_after_its_listing,a_duty_deletion_that_outlasts_the_bound_stops_the_duty_for_the_cycle,the_deleted_prefix_and_tombstone_calls_stop_their_duty_at_the_bound,the_forget_options_wait_out_the_duties_before_a_failed_removal,a_claim_taken_after_the_requests_check_refuses_the_create_before_its_row,the_read_only_check_writes_nothing_to_the_root) and 14 (a_claim_read_answered_after_the_bound_starts_no_deletion, a claim read answered 120 s late on the paused clock, for a prefix deletion and a tombstone removal;a_deletion_the_store_stopped_at_its_deadline_stops_the_duty;the_two_rows_check_comes_before_any_row_of_the_old_root_is_checked, with the configured root's failed read changing nothing;a_stop_after_the_last_check_rewrites_nothing;the_forget_options_wait_out_the_duties_before_a_deleted_removal;a_duty_whose_claim_read_or_reread_fails_deletes_nothing;forget_backup_reads_a_legacy_backup_under_the_arn_as_written;forget_backup_tells_dead_failed_rows_of_two_stores_apart;forget_store_removes_the_roots_entries_with_no_row_and_reports_the_rest, through aFakewith orphan and pending status entries;forget_backup_finishes_a_deleting_row_of_the_configured_store_first;forget_backup_refuses_the_rows_of_a_configured_root_without_its_file;the_report_of_other_stores_names_status_entries_with_no_row;the_tombstone_duty_leaves_a_row_moved_after_its_fence;a_cycle_without_the_store_check_writes_nothing_to_the_root) and 7 (every_row_of_one_backup_id_among_the_moved_is_checked_no_further, a DELETING and an AVAILABLE row of one id over a copy missing a data file: neither checked, the prefix whole;the_failed_prefix_duty_keeps_a_row_moved_after_its_listing, the store half of the duty's re-read;move_store_checks_a_row_whose_status_entry_is_under_an_earlier_status, through theFake's newstale_entries, stopping on a complete copy and otherwise finished and moved;the_report_of_other_stores_counts_a_row_whose_entry_is_under_an_earlier_status;a_stop_during_the_wait_before_a_deleting_rows_finish_deletes_nothing;a_deleting_row_behind_an_identity_mismatch_is_refused_naming_the_way_out;forget_backup_refuses_the_rows_of_a_missing_root_with_rows);forget_store_removes_the_roots_entries_with_no_row_and_reports_the_restnow also covers a removed row's stale entry and a left row's entry repaired, andtwo_rows_of_one_backup_id_among_the_moved_stop_the_movenames both rows; the existing move cell no longer resolves a CREATING row FAILED, the restore claim cell reads the claim before and after the registration, and three cells count the extra claim reads. App--lib59 passed (10 new: the landed row of the old root and the status-entries line; the conflicts and the repeat, the other-stores lines, the exit and line functions of the move, the forgets and the store check, the check's plan after--forget-backup, the window, wait and stores taken from the backend, and the other stores' exit with the refusal's ARN). Server--lib backup19 passed (StubCatalog, and the sentence ofDefaultRootMissingWithRows). Storage--lib233 passed (the_root_tally_counts_each_store_and_status_apart_in_one_order).--lib backup67 passed, PostgreSQL--test backup_restore61 passed (--test-threads=2), each withlist_backup_rows_by_root_returns_every_id_and_status_of_the_root_and_nothing_else,rewrite_store_moves_only_the_old_roots_rows_that_are_not_creatingandsummarize_store_roots_counts_the_rows_of_each_store_and_status; MongoDB--lib -- --ignored --test-threads=1 live_71 passed, the same three aslive_cells pluslive_rewrite_store_moves_every_chunk_of_keys(1,001 keys); Cassandra, on a single 4.1 node of its own, the three plustest_a_stopped_rewrite_leaves_the_row_listed_under_one_root(eachRewriteStop) andtest_an_unknown_rewrite_outcome_is_settled_by_a_serial_read, withtest_read_store_ids_lists_the_distinct_ids_of_a_root: 6 passed (--features test-hooks --test direct_integration -- --exact). Added with the deleters' store condition and the rewrite's transaction: on Cassandratest_a_delete_that_read_the_row_before_a_rewrite_moves_its_entry_under_the_new_root,test_a_rewrite_whose_row_changes_before_its_transaction_reads_the_row_again,test_list_backup_rows_by_root_skips_an_entry_whose_row_has_another_status_or_kindandtest_the_summary_counts_the_old_entry_a_stopped_rewrite_leaves(17 passed with the 13 existing cells on the changed methods); on PostgreSQLa_rewrite_whose_client_dies_mid_statement_rewrites_nothinganda_rewrite_that_meets_a_held_row_lock_runs_again_a_bounded_number_of_times(catalog_index::25 passed); on SQLitea_rewrite_that_fails_on_a_later_key_moves_no_row(4 passed with the PR's three); MongoDB's four cells passed again. Added with the deadline, the store-conditioned removal and the sweep: the store crate'sdelete_prefix_before_honours_its_deadline(filesystem and moto),delete_prefix_before_stops_at_its_deadline(filesystem, a walk held past its deadline right before a removal: the removal in flight and no other) anddelete_prefix_before_sends_no_request_after_its_deadline(moto, a deletion held at its firstListObjectsV2and at its firstDeleteObjects: that request and no other), with the conformance suite 127 passed; on every backend the removal cell ofstart_backup_delete_checks_the_status_and_the_in_use_rule_under_the_locknow rewrites the DELETED row to another store and finds it left by a removal naming the store read (SQLite--lib backup68 passed, PostgreSQL--test backup_restore63 passed, MongoDB's fivelive_cells passed); PostgreSQL's held-lock cell bounds the three attempts from both sides and readsSHOW lock_timeoutandstatement_timeouton the rewrite's own connection after it (the session's defaults); on Cassandratest_the_entries_deleters_leave_behind_a_rewrite_are_swept_from_the_old_root(the interleavings, through three new pause hooks: two orphan entries under the old root, swept to none, the moved rows still listed, a tombstone removal that read before the rewrite leaving the moved row), with 22 existing cells on the changed methods by--exact, all passed. Added with the all-partition by-root listing and the sweep's repair: on Cassandratest_a_row_whose_entry_is_under_an_earlier_status_is_listed_moved_and_repaired(a DeleteBackup stopped after its DELETING transaction, a job's AVAILABLE transaction applied with its entry left under CREATING, and a row with an extra DELETED entry: the status-scoped listing lists neither of the first two, the by-root listing lists each row once under its own status,rewrite_storeof the DELETING row moves it, and the sweep deletes its stale entry and repairs the other two), passed with the PR's 11 other cells andtest_start_backup_delete_checks_...by--exact; on every backend the removal cell also rewrites the DELETED row to the same root under the other kind and finds it left by a removal naming the kind read, and the summary cell holds a CREATING and an AVAILABLE row in one store (PostgreSQL 4, SQLite 4 and MongoDB 5 changed cells passed); the store crate'sdelete_prefix_before_aborts_no_upload_after_its_deadline(moto: held at the lastDeleteObjectsof a page with an upload open, noListMultipartUploadssent and the upload left; held at the firstAbortMultipartUploadof two, the second not sent) and a one-object prefix past its deadline indelete_prefix_before_honours_its_deadline(filesystem and moto), the 18delete_prefixconformance cells passed.tests/test_backup_store_config.py tests/test_backup_job.pyon the SQLite build 44 passed, 13 skipped (the cells beside servers or on PostgreSQL only;-n 4), on the PostgreSQL build 39 passed, 18 skipped (the SQLite-only cells;-n 2); the move and forget cells oftest_backup_job.pyon the MongoDB build 8 passed, 5 skipped (the SQLite-only, PostgreSQL-only and catalog-editing ones), on a replica set of their own. Added:test_forget_refuses_the_rows_of_a_missing_default_root(SQLite, the default root renamed away and behind a dangling link: the row refused as an unmounted root's, the header naming the missing root with rows,--forget-storeof it refused as the configured root, nothing created at the path). Added:test_forget_backup_beside_a_server_keeps_the_rows_of_an_unmounted_root(PostgreSQL, MongoDB: the refusal, no identity file written, the server still unhealthy),test_forget_backup_finishes_a_deleting_row_of_the_configured_store(SQLite, PostgreSQL: the prefix deleted, the line saying so, nothing adopted by the next sync) andtest_the_forget_options_write_nothing_to_a_fresh_root(all three, run alone after the full runs);test_forget_backup_checks_its_arns_first_...now ends with the unmounted root's last row refused twice, nothing written. The new cells:test_the_manuals_move_procedure_beside_a_running_serverandtest_a_copied_identity_file_is_undone_by_the_manuals_remedy(PostgreSQL, MongoDB),test_forget_backup_checks_its_arns_first_goes_past_removed_ones_and_writes_no_store(all three),test_a_maintenance_command_waits_out_a_deleting_row_and_a_dead_restore(PostgreSQL); the other-roots cell now copies the other root's tree under the configured root before the reconciler's cycles and checks it unchanged, and checks the wait line beside servers. No PostgreSQL database left (the list equal to the one taken before).--forget-backupand the move finishing, a server on the new root describing and restoring a moved backup,--forget-storeand the root's return, and the forget options withkind = "disabled".--forget-backupgoing past a refusal, the left rows not reported,locate_rootaccepting the configured root, the configured store in the report); 4 in the CLI's pure functions; 5 in the CLI's wiring against the wire cells, each with the binary rebuilt (the other stores' section always empty, no check after--forget-backup, a stopped move's exit ignored,--forget-storenot knowing the configured root, and on PostgreSQL the move's wait over the configured store only). 49 of 49 killed, each on its test's assertion. For the cells added with the CREATING rule, the duties' claim read and bound, the forget's wait, the jobs' claim read, the read-only check and the deleters' store condition, each the same way: 13 in the backends (Cassandra 8: the store condition dropped from either deleter,NotMatchedwithout its serial re-read or keeping the new entry, the by-root listing without its status or kind re-check, the rewrite's transaction not conditioned on the status, the old entry removed before the after-row point; PostgreSQL 4: the rewrite on the pool, one attempt, unbounded attempts, no lock timeout; SQLite 1: no transaction), all killed; 24 in the engine (the move writing FAILED for an incomplete copy, the duty's claim read, each of the three time bounds, each duty's stop at the bound, the forget's wait skipped or shortened, the status half of the re-read, the item total, the two-rows check among the moved rows, the removal's re-read and its store check, the move's owner window, the re-list failure branch, a key counted moved without its row under the configured store, the per-row stop,NoRownot done, the ARN check, the job's and the restore's claim reads before the insert, the read-only check's id comparison and its write), all killed; 6 in the CLI's new pure functions, all killed; 4 in the CLI's wiring against the wire cells with the binary rebuilt (the check after--forget-backuprun on an empty root with the writing check, the ARNs checked after the claim, the forget's wait for jobs skipped: killed; the writing check put back only where the opening found a file: survives, since no wire cell reaches that path with a root that loses its file after the opening, and the engine'sthe_read_only_check_writes_nothing_to_the_rootpins the function); and 2 of the procedure cells (the identity file copied, the remedy's--forget-backupleft out): killed. For the deadline, the store-conditioned removal, the sweep, the forget's configured store and the move's order: 3 in the store crate (the walk's deadline check, the S3 check beforeListObjectsV2, beforeDeleteObjects); 10 in the backends (the removal's store condition dropped on PostgreSQL, SQLite and MongoDB, and on Cassandra from the read and the transaction or from the transaction alone; the sweep removing nothing, or rows of the root too; PostgreSQL's fourth attempt, no pause, session-level settings); 19 in the engine (the late claim read acted on, the deadline taken after the read, an unfinished deletion taken as done, a failed claim read or re-read taken as free, the SQLite pass's store check, the two-rows row checked anyway, the stop before the fence, the wait for FAILED rows only, the legacy read under the ARN as written, one dead set for every store, the ids after--forget-store, the sweep skipped, the report's counts unverified, the DELETING row removed unfinished or finished on an unhealthy store, the unmounted root's rows removed, the SQLite pass's deletions unreported); 3 in the CLI's wiring with the binary rebuilt (the unmounted root's refusal, the DELETING finish, the forget opening with the writing check: the last survived the existing cells and is killed by the fresh-root cell added for it). 35 of 35 killed, each on its test's assertion. For the all-partition by-root listing, the sweep's repair, the wait before the DELETING finish, the two-rows grouping, the mismatch refusal and the missing default root: 4 in the store crate (no check beforeListMultipartUploads, none beforeAbortMultipartUpload, the abort sweep's unfinished answer ignored, the walk's one-file branch without its check); 11 in the backends (remove_backup_rowwithout the kind on PostgreSQL, SQLite, MongoDB and Cassandra; the summary's sort dropped on PostgreSQL, SQLite and MongoDB; on Cassandra the by-root listing requiring the entry's status, the by-root listing without its dedupe, the sweep leaving a stale entry, the sweep's repair without its write); 8 in the engine (the report of other stores through the store-scoped listing, the sweep's counts dropped, the finish before the wait, the first row of a group checked, the mismatch refusal dropped, the missing root's rows removed, the re-read without its store half, and theFake's by-root listing skipping a stale-entry row as the old Cassandra listing did); 3 in the wiring with the SQLite binary rebuilt (the catalog's rows for a missing default root not read, a dangling link's target not tried, the missing root not passed to the forget). 26 of 26 killed, each on its test's assertion. The landed-row exit has its pure function's unit test and the binary run below; no wire cell holds a CreateBackup inside the move's sync.--forget-storeof that root from a configuration naming another root: in three runs the duty deleted 249, 258 and 259 prefixes before the claim and none after it (its re-read of the claim refused the other 3,000 times), the command waited 58 s and removed the 3,001 rows, and 0 prefixes were deleted after their row's removal (101 before this change). A CreateBackup whose snapshot open waits on a table lock while--move-storeruns, released once the move has exited: the move exits 0 and the CreateBackup still lands AVAILABLE under the old root, its re-read of the claim coming after the claim's release; plain sync on the new root reports it among the rows of other stores, which the manual now says to check. The same CreateBackup released while a command holds the claim (waiting on a live job):LimitExceededException, no row. Run again after the deadline moved into the store and the landed-row exit: a server SIGSTOPped in the middle of a FAILED-prefix deletion of 200,000 hard links,--forget-storeof its root waiting 59 s and removing the row, then SIGCONT: 0 and 1 links removed after the SIGCONT in two runs (150 and 21 before), the server logging "reached its 30 s bound after 513 object(s) and stopped"; a CreateBackup held atclaim_backup(after its claim read) until the move's sync, then released: the move prints the row under "Rows that landed under the old root during the move" and exits 1 (it exited 0 before).--move-store(design section 7 and the admin guide state it): a root of 20,000 AVAILABLE rows of a five-GSI table, each prefix complete under the copy (one CreateBackup and 19,999 copies with their manifests rewritten to their own ARN, adopted bybackup sync; the copy made with hard links): 569 MiB peak RSS and 37.1 s on the SQLite build, 500 MiB and 36.9 s on the PostgreSQL build (51 and 49 MiB at 50 rows);backup listover the same rows 508 and 472 MiB, and the plain sync of the new root after the move 554 and 495 MiB. Filesystem roots on a local NVMe disk, PostgreSQL on localhost: an S3 store pays a request per manifest, sidecar and prefix listing, and a network database the read's round trips, neither measured.Acceptance lines of #820 (the design's PR 10 acceptance list) and where each is tested:
the_report_of_other_stores_...; wiretest_another_roots_rows_are_left_alone_until_forget_store_drops_them(a FAILED and a DELETED row dated 2000 and a DELETING row of another root keep their rows and bytes through three cycles of a server on the configured root, which holds a copy of that root's tree, unchanged too, and the report counts and names them) and the move cell's report--move-storere-points its rows and Describe and Restore work against the new rootmove_store_resolves_...; wiretest_move_store_moves_a_copied_tree_and_stops_on_an_incomplete_copy(SQLite, PostgreSQL, MongoDB)--move-storeresolves the old root's CREATING and DELETING rows, stops on a row whose data files were not all copied, and refuses a DELETING, DELETED or FAILED row whose copied prefix holds a verified manifestmove_store_resolves_...,move_store_names_every_row_...(each status and each incomplete shape) anda_dead_creating_row_whose_copy_is_incomplete_stops_the_move_unwritten(a CREATING row resolves only to AVAILABLE; each incomplete shape stops the move with no status written); wire, the move cell (a FAILED row over a complete copy, a missing data file)--forget-backuprefuses a CREATING row with a live owner and a backup from which a restore target is CREATING, and says so and does nothing for a legacy ARNforget_backup_removes_each_row_...; the backends' existingforget_available_backup_rowin-use cells (PostgreSQL, SQLite, MongoDB, Cassandra), which the option calls; wiretest_forget_backup_leaves_a_live_creating_row_and_an_in_catalog_backup(the live CREATING row on PostgreSQL; on SQLite the serve lock makes every owner dead)test_a_second_maintenance_command_fails_at_oncewith the three options (PostgreSQL)test_backup_list_and_purge_on_an_empty_store_and_the_serve_lockwith the three options (exit 6)test_prune_missing_and_the_unhealthy_store_refusalswith--move-storeon each shape; the forget options run on an unhealthy store by design (the clears cell)--forget-storeand the store's return, sync adopts its prefixesforget_store_removes_...; wire, the another-roots cell--move-storeeither finishes before the rewrite or is refused (or, landing during the move's sync, makes the move exit 1)rows_the_rewrite_does_not_move_are_listed_as_left(a CREATING row landing after the checks is left and reported),the_wait_covers_every_store_it_is_givenanda_claim_taken_after_the_requests_check_refuses_the_create_before_its_row; wiretest_a_create_backup_during_the_moves_wait_is_refused(PostgreSQL). One admitted before the claim whose open returns after the move has exited still lands under the old root (through the binary, above): the line holds for a CreateBackup that reaches its insert while the move runs, and the manual says to check plain sync's report of other stores before deleting the old root--move-store Astops on a row with one data file missing and--forget-backuplets the move finish, sync reports that prefix as incomplete and does not adopt it, and adopts it once the file is copiedtest_a_maintenance_command_waits_out_a_deleting_row_and_a_dead_restore(PostgreSQL: the two edited at the catalog,--forget-backupof the restored backup waiting, a server started during the wait finishing the one and failing the other under the claim, then the row removed); the engine'sthe_reconciler_reports_a_held_maintenance_claim_and_still_resolves_dead_ownerscovers the cycle under the claim--forget-storedeletes that root's rows outright, leaving no DELETED tombstoneforget_store_removes_...(a cycle afterwards deletes nothing); wire, the another-roots cell (backup listempty, the tree byte-identical)--move-storeand--forget-storematchstore_rootexactly as stored, the?endpoint=suffix included/, a prefix); enginelocate_root_...(the S3 label shape); wire, the move cell (a trailing/)move_store_resolves_...(the next cycle deletes a moved FAILED row's prefix and a DELETED row's objects and removes the tombstones)--forget-backupis the way outforget_backup_of_the_newest_rows_deleted_backup_heals_the_store; wiretest_forget_backup_clears_a_store_whose_newest_manifests_are_gone(SQLite, PostgreSQL, MongoDB; beside servers the running server's/healthturns healthy) andtest_forget_backup_checks_its_arns_first_goes_past_removed_ones_and_writes_no_store(the check after it writes nothing to an empty root)test_the_manuals_move_procedure_beside_a_running_serverandtest_a_copied_identity_file_is_undone_by_the_manuals_remedy(PostgreSQL, MongoDB)Cassandra has no wire leg (its CreateBackup stays in-catalog, item 19); its share is the direct tests above.
Not in this PR
extenddb backup migrate-legacy <arn>|--all, with its acceptance cells.backup_index_by_statusentries beyond the root--forget-storeforgets: an orphan under the configured root, or under a root no one forgets, stays (every reader skips it; item 34 says the same of the status-entry orphans).list_backup_rows(from before this PR), which the reconciler reads: a status change whose entry batch failed (logged, not sent again) leaves the row out of that listing until its entry is repaired, so a DELETING row of the configured store whose DELETING batch failed is not finished by the reconciler;--forget-backupof its ARN, which reads the row by its ARN, finishes the deletion and removes the row, and--forget-store's sweep repairs such entries on the roots it may forget, not the configured one.Breaking changes
None on the wire.
backup sync's report gains a section, and a sync that cannot read the rows of other stores now exits 1.OTHER_STORE's text names the move; tests assert the constant by identity. Each FAILED-prefix, DELETED-prefix and tombstone deletion of the reconciler reads its row and the maintenance claim once more, and ends by 30 s after that read before the duty stops for the cycle. The store-backed CreateBackup and restore read the maintenance claim once more before their insert.BackupStoregainsdelete_prefix_before, with a default body;BackupCatalog::remove_backup_rowtakes the store kind and root (every implementation in the tree is updated), and gains two methods with default bodies (remove_orphan_status_entriesansweringStatusEntrySweep).--forget-backupof a DELETING row of the configured store now deletes its objects first, and refuses it while the store's check fails; it refuses the rows of a configured root that has no identity file under them, and on SQLite those of the default root found missing with rows for it; the read-only commands (backup list,purge's dry run) print that missing root with rows in place of "no server has run with this database file".DCO
Signed-off-by: Scott Robinson robinnsc@amazon.com