Repository navigation
Conversation
robinnsc
force-pushed
the
feat/backup-store-maintenance
branch
from
November 6, 2026 23:06
6793fae to
edde7ea
Compare
robinnsc
force-pushed
the
feat/backup-migrate-legacy
branch
from
November 6, 2026 23:11
0ba46fa to
f702c8d
Compare
robinnsc
force-pushed
the
feat/backup-store-maintenance
branch
from
November 7, 2026 03:14
edde7ea to
c96cb47
Compare
robinnsc
force-pushed
the
feat/backup-migrate-legacy
branch
3 times, most recently
from
November 8, 2026 08:07
27070b6 to
403b0af
Compare
`extenddb backup migrate-legacy <arn>` or `--all` (exactly one) copies in-catalog backups into the configured store, each under its own ARN, backup id, name and creation time, and replaces each by its catalog row, AVAILABLE and unowned as a row `backup sync` inserts. `<arn>` is checked as a backup ARN before anything is opened (exit 2) and resolved as written and rebuilt in this server's region; `--all` takes every in-catalog backup of every account that is not DELETED, oldest first, goes on past one it does not migrate, and then, for every deleted one, deletes what a stopped run left under its prefix and removes its in-catalog record once the prefix is clean. The command needs the configured store healthy, opens the catalog as `serve` does (on SQLite under the serve lock, so the server is stopped for it), takes the maintenance claim on every backend for its whole run and waits as `purge` does, so every backup and restore job of the catalog answers LimitExceededException until it ends. Per backup, in this order: an id with no creation instant is refused before anything is read (that is every backup a 0.1.x release took: a store prefix carries only the 14-digit shape); then a table name the store-prefix rules refuse; a DELETING backup whose replacement committed and stopped (MongoDB) is finished; a catalog row of the id under either spelling of the ARN (the configured store's under the stored spelling: an earlier run's, finished; any other: refused, since the prefix may be that row's), then under any ARN (`row_holding_backup_id`, refused); CREATING and DELETING are skipped; a backup the backend cannot read (MongoDB's with no item collection), a name the manifest refuses, what the legacy restore refuses (a vector index, a multi-part key) and a SQLite backup taken before catalog 0.0.4 are refused. Then the copy: objects under the prefix with no row are an earlier run's when the manifest there, if any, names this backup (one naming another refuses the backup with the prefix left), and are deleted through the deletion fence first; the fence runs again before the first put; the items stream through the backup job's writer, each checked with the item validator under this instance's limits; the manifest is written last with `snapshot.kind` `legacy_in_catalog`, no marker, `unordered`, and `source_backend` and `source_extenddb_version` naming this binary. The prefix is read back through `verify_prefix`, sync's check, and the item count is checked against the in-catalog backup's twice. A refused item, a failed or short read or a copy that does not read back deletes the copy through the fence and keeps the in-catalog backup; after a failed or short read a backup no longer AVAILABLE is skipped, "deleted during the copy". An index row of the id met at the replacement refuses it and keeps the copy. Before each step that cannot be undone (the manifest's put, every deletion under a prefix, each replacement call, a record's removal) the command re-checks the claim with one beat, and a failure stops the run with what it wrote left. A lost claim, a signal, an unhealthy store, a failed listing or catalog call, and a replacement whose answer did not come stop the run with exit 1; a signal is acted on before the next backup and inside a copy at the writer's next step, and one received during a replacement stops the run after it. Exit 0 only when every backup reached is in the store or deleted. One `extenddb::audit` line is logged per backup the run deleted or replaced anything for, none for one it left as it was; a `refused` or `failed` line says the in-catalog backup is kept only where it is; the summary counts migrated, finished, already in the store, deleted, refused, skipped and failed apart. A backup DeleteBackup answered DELETED never comes back from a copy a stopped run left. The replacement deletes the `backups` row on PostgreSQL and SQLite, so a DELETED row there is always a DeleteBackup's; MongoDB's replacement marks its document; Cassandra's converts the row. `LegacyBackupReader::holder_status` answers for an in-catalog backup in any status that the replacement did not replace, and `list_deleted` lists the DELETED ones: `backup sync` never adopts a prefix whose ARN such a backup holds, naming the command for a deleted one, and `migrate-legacy` deletes that copy through the fence, lists the prefix again and, once it is empty and no catalog row holds the id, removes the DELETED record (`remove_deleted`), reporting the backup deleted. So a DELETED record whose id carries an instant is a prefix not yet confirmed clean, and PR 14's migration is to refuse while one exists. A deleted backup whose ARN an AVAILABLE row holds again (a sync of an earlier release adopted a stopped run's copy) is refused, naming the row and the choice. Before a deletion under a prefix the command looks the id up by `BackupCatalog::row_holding_backup_id`, the key the inserts conflict on; Cassandra, which indexes no backup id, answers none and is covered by the manifest check. `finish_replacement` finishes MongoDB's replacement that committed and stopped before its drop, whatever the catalog row holds since. The manifest's source table is the table the legacy restore creates (`source_table_from_legacy`, pinned by an exhaustive property against `restore_input` and `restore_input_from_manifest`), with the recorded table class, TTL and stream and the SSE description of the recorded specification, so the stored specification's `KMSMasterKeyId` is not carried over; no answer reports a key id before or after. The vector state is `[]` on PostgreSQL and SQLite and `null` on MongoDB and Cassandra. The replacement is one step per backend, `LegacyBackupReader::replace_with_index_row`, in place of an insert followed by the reader's delete, which let a DeleteBackup between the two answer DELETED for a backup that then came back, and which cannot work on Cassandra, where both rows share one key. PostgreSQL: one transaction (the row `FOR UPDATE`, the CREATING-target check, the insert, then the items, the definition and the row deleted). SQLite: the same under the write lock and `BEGIN IMMEDIATE`. MongoDB: one session transaction that inserts the row and marks the document DELETING with `migrated_to_store`, then the collection's drop and DELETED; a commit whose answer is lost is settled by reading both back. Cassandra: the row converted in place by a lightweight transaction `IF backup_status = 'AVAILABLE' AND store_kind = null AND active_restores = null`, then its list and status entries and the item buckets' deletion; an unknown outcome is settled by a serial read, and a second call finishes a conversion that stopped after the row, writing entries only for a row still AVAILABLE. Four frozen paths change so a request orders wholly before or after a replacement: PostgreSQL's delete reads the status under its row lock, MongoDB's reads a `migrated_to_store` document as not found, and Cassandra's delete claim and its legacy restore registration carry `store_kind = null`. `LegacyBackupReader` also gains `items(account, arn)`, a frozen copy of each restore's read as a stream (PostgreSQL a REPEATABLE READ catalog transaction held for the copy, SQLite keyset batches each re-checking AVAILABLE, MongoDB a cursor with `batch_size` and a frozen decode, Cassandra each bucket paged 8 rows at a time), and `list_all()`, which returns a row that does not decode as an error. The core format gains `SnapshotDescriptor::KIND_LEGACY_IN_CATALOG`; no format version or fixture byte changes. The writer runs under a `ClaimWatch`, the job's heartbeat or the maintenance claim, whose loss now cancels a token; the job's tests run unchanged. `HeldClaim` confirms the claim by the job's rule with one restriction: each beat is bounded by one period, a beat in flight counts only when it started on schedule, and a claim with no beat confirmed within two periods reads as lost, for purge and the options of `backup sync` too. PostgreSQL's maintenance take now refuses while the claim row's heartbeat is younger than `stale_job_after_secs`, even with the advisory lock free, as the other backends' take does: a holder whose catalog session ended runs until its next beat finds the loss. So every holder stops acting within two periods of its last confirmed beat, and a taker needs a heartbeat at least three periods old; after a killed command on PostgreSQL the next one fails, naming the holder, until the window passes. `backup sync` skips a prefix whose ARN an in-catalog backup holds, under either spelling, naming `migrate-legacy <arn>`. DescribeBackup, DeleteBackup and RestoreTableFromBackup read the catalog row once more after a legacy not-found (RestoreTableFromBackup also after its registration's), and ListBackups reads the legacy reader first, so a replacement between the two reads never answers BackupNotFound or drops the backup from a page; ListBackups lists an ARN both readers hold once, with the row's summary. Tests: per-backend cells for the stream (batch and bucket boundaries, a pinned snapshot, a delete ending it, the AVAILABLE re-check inside PostgreSQL's snapshot), `list_all` and `list_deleted` across accounts, `holder_status`, `remove_deleted` and `row_holding_backup_id`, the replacement's answers, a real replacement and a DeleteBackup queued on each other in both orders (PostgreSQL), PostgreSQL's take of a young dead claim refused, MongoDB's finish and lost commit answer, Cassandra's lost answer and stop, a legacy restore registering after a conversion, a registration between the conversion's read and its transaction, and a conversion finished after a delete; 41 engine cells on `Fake` and `MemStore` (each refusal, the fence at each deletion, the counts, a store serving another manifest, every rerun rule, a deleted backup's copy and record, a row of the id under any ARN, a claim taken over before each step that cannot be undone, a stalled heartbeat, signals during a copy and a replacement, requests racing a replacement, the sync skip, the dedupe) and six of `HeldClaim`; wire cells on SQLite, PostgreSQL and MongoDB (two in-catalog backups migrated, described, listed, restored and deleted under their ARNs with the in-catalog rows gone; `--all` past a #618 backup and a 13-digit one, both kept; a stopped migration's copies of two deleted backups, not adopted and then deleted; the claim against a second command, and the serve lock); SIGKILL at the three kill points on SQLite and PostgreSQL, each rerun finishing, and a kill or a refused deletion followed by DeleteBackup, the backup staying deleted (PostgreSQL on S3 too); a first run whose claim a second took over (PostgreSQL after its session ended, MongoDB stopped past the window and resumed with the catalog paused), the second's backup left whole; a deleted backup's record removed only once its prefix is clean (SQLite). 143 mutants of the new tests, all killed. Peak RSS 56 MiB flat on PostgreSQL (75 at most measured beside a server); on SQLite 55 to 149 MiB, its page cache, bounded by 32 MB times `pool_size`. Docs: the backup manual's legacy section (the command, its cost, what it refuses and the way out, what a migrated backup reports, a stopped run and a deleted backup's copy and record, MongoDB's and Cassandra's stopped replacements, Cassandra's recurring step, memory); the upgrade manual's store, 0.1.x and rollback paragraphs and `--all` after the last DeleteBackup; the admin guide's paragraph, sample output and `stale_job_after_secs` row; the differences doc's two rows. Design revision item 37, with sections 3, 4(c), 4(f), 4(g) and 7, the PR 10 paragraph, its maintenance-claim paragraph and transitions, the PR 14 lines (rows that are not DELETED, and DELETED records whose id carries an instant) and section 10's row edited with it. Not here: removing the in-catalog reader (#749); a 13-digit backup carried into the store format; a Cassandra wire leg (direct tests only). Co-authored-by: Lee <leeroyhannigan@yahoo.ie> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc
force-pushed
the
feat/backup-store-maintenance
branch
from
November 8, 2026 09:33
c96cb47 to
537097a
Compare
robinnsc
force-pushed
the
feat/backup-migrate-legacy
branch
from
November 8, 2026 09:33
403b0af to
33ccfc9
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backup/restore series (design: #396): #384 restore fidelity (merged) · #393 store · #394 format · #395 SnapshotEngine · #666 catalog migration framework (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 · #880backup sync --move-store,--forget-store,--forget-backup· this PR:backup migrate-legacy· next: #748 export and import through the store, #749 the in-catalog reader's removal.Closes #821 (part of #624).
Stacked on #880 (
feat/backup-store-maintenance, head537097af), itself on #855 (feat/backup-docs,925509bd) and #825 (be84a7eb) onmainatb8ad9cd5(#740 merged as76b9710d, then #792); opened as a draft againstfeat/backup-store-maintenance. Rebased from #880's earlier headc96cb472withgit rebase --onto 537097af c96cb472. Two files conflicted: the admin guide's[backup]table, where main changed theheartbeat_interval_secsrow (on PostgreSQL, MongoDB and Cassandra a job's beat in flight no longer counts) and this PR thestale_job_after_secsrow, so each row is taken from the side that changed it; and the upgrade manual's[backup.store]paragraph, which keeps this PR'smigrate-legacysentences with main's "Migration 8" (restore_progress) twice. The design's revision log merged without a conflict; item 37 follows #880's 36, so no item is renumbered. The commit is the same size as before (38 files, +11,610 −311). One commit. No migration, no schema change, no new dependency;Cargo.lockis unchanged. This is the third of the three PRs the design's PR 10 ships as (revision item 34). Every binary and measurement below is a debug build of the tree before the rebase (c96cb472plus this diff), unless a line says otherwise.What
Format (
crates/core).SnapshotDescriptor::KIND_LEGACY_IN_CATALOG=legacy_in_catalog, whose doc says the marker is empty, that the copy has the legacy create's consistency (a point-in-time snapshot on PostgreSQL and SQLite; on MongoDB an$outof the live collection and on Cassandra a paged read of the live table, neither point-in-time), and thatsource_backendandsource_extenddb_versionname the binary that wrote the copy (the in-catalog creator's version was never recorded). No format version change and no fixture byte change; a unit test parses and round-trips a manifest of that kind.is_valid_backup_nameis exported, so the command refuses a name the manifest would refuse before it writes anything.The definition (
crates/storage/src/backup_definition.rs).source_table_from_legacy(source, vector_state, region, account), besiderestore_input: the table the legacy restore creates, by one rule on every backend. It runsensure_restorable, refusesLegacyVectorState::Unknown(a SQLite backup with no definition), and otherwise takesrestore_inputwith no overrides (a backup with no definition keeps its keys, its key attribute definitions and the 5/5 fallback) plus the definition's table class, TTL and stream andSseDescription::from_specificationof its SSE specification. The vector state is[]on PostgreSQL and SQLite with no names (PostgreSQL with no definition and a NULL or zero snapshot too) andnullon MongoDB and Cassandra. Tests: an exhaustive property over every combination of key schema, billing, recorded throughput, GSI shapes, LSI, four SSE specifications, on-demand throughput, class, TTL and stream and no definition, times two vector states and four override sets, thatrestore_input_from_manifest(source_table_from_legacy(s))restores whats.restore_inputrestores; the one difference, aKMSMasterKeyIdnot carried, has a test of its own.Storage trait (
crates/storage/src/backup_catalog.rs).LegacyBackupReadergainsitems(account, arn) -> LegacyBackupItems(the name, the recorded source, the vector state and a'staticitem stream),list_all() -> Vec<LegacyBackupKey>(the in-catalog backups of every account that are not DELETED, by creation time then ARN; a row that does not decode is an error),list_deleted()(the DELETED ones, which are always a DeleteBackup's),holder_status(account, arn) -> Option<BackupStatus>(the status, DELETED included, of an in-catalog backup at the ARN that the replacement did not replace),finish_replacement(account, arn) -> bool(MongoDB's finish of a replacement that committed and stopped before its drop;falseelsewhere, the trait's default),remove_deleted(account, arn) -> bool(the removal of a DELETED in-catalog backup that is not a replacement's, conditioned on both, once nothing is left under its prefix) andreplace_with_index_row(row) -> Replaced { Replaced, AlreadyReplaced, NotAvailable(String), InUse(String) }; an index row of the id under another ARN or store isAlreadyExistswith nothing written (Cassandra sees only the ARN's own partition, which the trait doc says, and the engine covers).BackupCataloggainsrow_holding_backup_id(account, backup_id), the row of the id whatever its ARN, table and store, the key the inserts conflict on; Cassandra, which indexes no backup id, answersNone, and the doc says why. The trait doc names the replacement as its second write beside the restore's registration.MaintenanceClaimgainsheartbeat_within(bound)andheartbeat_parks_before_its_bound(), withJobClaim's defaults; SQLite's starts the bound once the beat holds its write lock.PostgreSQL, SQLite, MongoDB, Cassandra. Each stream is a frozen copy of the backend's restore read: PostgreSQL a
REPEATABLE READ READ ONLYcatalog transaction whose first statement re-checks AVAILABLE, owned by a spawned task sending through a one-slot channel; SQLite the restore's keyset batches (500 rows or 4 MiB), each in its own read transaction re-checking AVAILABLE; MongoDB a cursor on_backup_<backup_id>withbatch_size(DEFAULT_FETCH_BATCH)andlegacy_item_from_document, a copy ofdocument_to_item, andUnsupportedfor a document with nobackup_id; Cassandra the buckets in order, each read in pages ofLEGACY_ITEMS_PAGE= 8 rows byitem_index. The replacement: PostgreSQL one transaction (thebackupsrowFOR UPDATE, the CREATING-target check, the insert statementinsert_backup_rowused, nowinsert_unowned_row, then the items, the definition and thebackupsrow deleted, so a DELETEDbackupsrow is always a DeleteBackup's); SQLite the same under the write lock andBEGIN IMMEDIATE; MongoDBstart_replacement, one session transaction ($inc lock_seqwhile AVAILABLE, the CREATING-target check, the upsert, DELETING withmigrated_to_store: true), thenfinish_legacy_delete's drop and DELETED, a DELETING document with an index row of the same ARN and store being finished asAlreadyReplaced, and a commit whose answer is lost settled byreplacement_landed(the marked document and the index document read back: the call's own replacement answersReplaced); Cassandrareplace_legacy_row, the row converted in place by one lightweight transactionIF backup_status = 'AVAILABLE' AND store_kind = null AND active_restores = nullaftercreating_restore_target, thenwrite_index_entries(sent once more on a failure) andcleanup_backup_payload, an unknown outcome settled byread_index_row_serial,NotMatchedread again up to three attempts, and a converted row without its entries finished by the next call, its entries written only for a row the serial read finds AVAILABLE (one deleted since gets its buckets deleted andNotAvailable).holder_statusandlist_deleted: thebackupsrows on PostgreSQL and SQLite; the documents without the marker on MongoDB, whosefinish_replacementfinishes a marked DELETING document whatever its index row holds; the rows withstore_kindnull on Cassandra.remove_deleted:DELETE ... AND backup_status = 'DELETED'on PostgreSQL and SQLite (SQLite under the write lock), MongoDB'sdelete_oneof a DELETED document without the marker, and on Cassandra a lightweight transactionIF backup_status = 'DELETED' AND store_kind = null, a lost answer settled by a read of the partition.row_holding_backup_id: thebackup_indexrow by(account_id, backup_id)on PostgreSQL and SQLite, the index document by the same fields on MongoDB. PostgreSQL's maintenance take, with the advisory lock taken, now refuses while thebackup_maintenancerow's heartbeat is younger thanstale_job_after_secs(the session closed,AlreadyExistsnaming the holder and the window), as the other backends' take does; the module doc says why. Cassandra's conversion writes the insert's columns,created_atamong them, which a unit cell compares with the insert's column set. Four frozen paths change, each to order a request wholly before or after a replacement: PostgreSQL's delete reads the status under itsFOR UPDATElock and answers not found for anything but AVAILABLE; MongoDB's reads a DELETING document markedmigrated_to_storeas not found, indeleteand in the lock's miss; Cassandra's DELETING claim carriesAND store_kind = null, and so does Cassandra's legacy restore registration (register_restore(.., in_catalog)), whosefalsere-reads throughload_backupand answers not found for an index row. SQLite's are unchanged: the command holds the serve lock. Module docs name the stream and the replacement.Fakeimplements the seven reader methods androw_holding_backup_idwith fault knobs, itsholder_statusansweringNonefor a marked backup as MongoDB does; PostgreSQL'stest-hooksgainlegacy_items_pause(after the stream's AVAILABLE re-check, before its first read); Cassandra'stest-hooksgainfail_convert_writes,stop_convert_after_row,pause_before_convertandpause_before_legacy_restore_registration; MongoDB's test liveness gainsreplacement_commit_unknown.Engine (
crates/engine/src/backup/migrate_legacy.rs).migrate(storage, &Migration, Selection::{Arn, All}, on_backup) -> MigrateReport, written againstBackupCatalog + LegacyBackupReaderand the configured store.--alltakeslist_all, thenlist_deleted;<arn>is resolved throughholder_statusunder its two spellings (no listing of every account), thenfinish_replacement, then the catalog row (AVAILABLE "already in the store", DELETING or DELETED "deleted", after the replacement's call; another store's or a CREATING or FAILED row refused). Per backup, in this order: the id (no instant: refused before anything is read, the text naming 0.1.x and the way out); the table name by the store-prefix rules and the partition; a DELETING backup handed tofinish_replacement; a catalog row of the id under either spelling (read_catalog_row: one of the configured store under the stored spelling is finished, reported by its status; any other refuses the backup before anything is deleted, since the prefix carries no region), then a row of the id under any ARN (row_holding_backup_id), refused the same way; the status (CREATING and DELETING skipped, named); the open (Unsupportedrefused); the name;ensure_restorable("the legacy restore refuses it too"); the unknown vector state. Then the copy: a listing of the prefix, whose objects with no row are an earlier run's when the manifest there, if any, names this backup (StoreCalls::manifest_arn; one that names another backup or does not read refuses the backup with the prefix left, which on Cassandra is what tells another row's prefix), and are deleted through the fence; the fence before the first put;writer::write_data_fileswith the item validator under the instance's limits; the count check (its text names PostgreSQL's case, a wrong recorded count, and the way out); the three documents and the manifest (unordered,legacy_in_catalog, no marker);verify_prefixand the second count check; the claim re-checked;replace_with_index_rowwith the row built as sync builds it (the manifest's name, size, item total andsource_table, the id's instant ascreated_at, unowned). A refusal or failure after the first put deletes the copy through the fence;AlreadyExistsat the replacement keeps it, with a note. After a stream error or a short count the command re-readsholder_statusand reportsSkipped("deleted during the copy") when the backup is no longer AVAILABLE. Before each step that cannot be undone (the manifest's put,delete_copy, the leftover's deletion,clean_deleted's deletion, every replacement call,finish_replacementand the record's removal) it callsHeldClaim::recheck(); a failure stops the run with what it wrote left, and the stop says so. A DELETED backup goes toclean_deleted: a catalog row of the id under either spelling or any ARN refuses it (one under its own ARN and AVAILABLE means the backup is in the catalog again, after abackup syncof an earlier release adopted a stopped run's copy: the refusal names the row and the choice, DeleteBackup the ARN again or keep it, in--alltoo and with no audit line; any other may own the prefix), then the listing, the fence, the copy's deletion when its manifest, if any, names this backup, the listing again, and once that lists nothingremove_deleted, each with a note; solist_deletedshrinks, and a DELETED in-catalog row whose id carries an instant is a prefix not yet confirmed clean.--allreports only a deleted backup it did something for, and an id with no instant is never listed in the store nor removed.Outcome::{Migrated, Finished, InStore, Deleted, Refused, NotFound, Skipped, Failed}, carried onBackupOutcomewithin_catalog_kept(false for a deleted backup and after a replacement call whose answer did not come) andwrote(the run deleted or replaced anything for it;finishreads it fromholder_status,clean_deletedfrom what it removed);MigrateReport::stoppedfor a lost claim, a signal, an unhealthy or fenced store, a failed listing, a catalog call and a replacement whose call fails, and for a signal received while the last replacement ran. The writer (job/writer.rs) runs under aClaimWatch(confirms_the_claim, the loss token, the reason):Pipeline<'a, W: ?Sized = HeartbeatHandle>, so the job'sPipeline<'_>is unchanged, andimpl ClaimWatch for HeldClaim, whose timer cancels alost_signaltoken when it records the loss.maintenance.rs:HeldClaimrecords its last confirmed beat and the beat in flight, bounds each beat withheartbeat_within(interval)(a timeout is a loss), andlost()also answers once the last confirmed beat is two periods old with no beat on schedule in flight within its bound (a parked SQLite beat confirms while it waits). A beat is on schedule when it started within 1.1 periods of the last confirmed one (the beats fall one period apart from the take,interval_at), so a process stopped and resumed starts a late beat that confirms nothing; the flight is marked by a drop guard (InFlight), cleared by a timer that ends mid-beat, and a confirmation is recorded before the flight ends.recheck()is one beat under the period's bound that counts as a confirmed beat or records the loss as the timer does; purge's loop and the waits and loops ofbackup sync's options read the samelost().is_item_refusalsharesrefusal_reason's words through one constant.put_documentis generic over the watch. Underbackup-test-hooksthe writer'safter_data_fileand the command'sbefore_manifestandafter_manifestkill points pause it as they pause a job.Sync, Describe, Delete, Restore and ListBackups.
sync.rs:SkipReason::InCatalog { backup_arn, status }("an in-catalog backup (STATUS) holds this ARN; run extenddb backup migrate-legacy ", and for DELETED "an in-catalog backup with this ARN was deleted; this prefix is a copy a stopped migration left, which extenddb backup migrate-legacy deletes"), not transient, fromholder_statusunder both spellings before the row read under them;sync,finish_group,adopt_prefixandstartup_syncnow needLegacyBackupReader, which every caller's storage has.backup.rs: DescribeBackup, DeleteBackup and RestoreTableFromBackup read the catalog row once more after a legacy not-found (RestoreTableFromBackup after the source read's and after the registration's, then taking the row's path,restore_from_row), and ListBackups reads the legacy reader before the catalog index, then drops an in-catalog summary whose ARN a listed catalog row has.App (
crates/app/src/cmd_backup/migrate_legacy.rs, a child ofcmd_backup).BackupCommand::MigrateLegacy { arn, all }with a requiredArgGroup(usagemigrate-legacy <ARN|--all>, exit 2 for neither or both) and<arn>checked by a clapvalue_parser(backup_arn_arg, exit 2 before anything is opened),Mode::Write, the registration nameextenddb backup migrate-legacy. The command needsIdentity::Known, takes the claim on every backend, startsHeldClaim, runswait_for_jobsover the configured identity (on SQLite afterresolve_dead_owners, with a zero window), runs the migration, releases the claim, prints one line per backup with its notes indented (outcome_lines; "Its in-catalog backup is kept" after arefusedorfailedline only wherein_catalog_keptholds, so never for a deleted backup nor after a replacement call whose answer did not come) and the summary line (N migrated, N finished, N already in the store, N deleted, N refused, N skipped, N failed), logs oneextenddb::auditline per backup the run deleted or replaced anything for (audited, bywrote; none for an outcome that wrote nothing), and exits 0 only when every backup reached is in the store or deleted. The refusal with no store configured says the command needs one.backup_arn_argreturns the bare reason, so clap's error states it once. The subcommand's help names the item check's and the legacy restore's refusals and--all's clean-up after deleted backups;lib.rs's help and the module doc name the command.Harness.
tests/test_backup_job.py: three new cells, one extended (below); helpers_catalog_rows,_in_catalog_rows,_copy_in_catalog_backup,_keep_in_catalog_backup,_put_back_in_catalog_backup,_typed_canonical,_migrate.tests/test_backup_store_config.py: the serve-lock cell runsmigrate-legacybeside a SQLite server.Docs and texts. The backup manual's Legacy backups section: "Migrating legacy backups into the store" in place of "This release has no command that converts a legacy backup" (the two forms; what is kept; the check and the one-step replacement per backend; the cost: the claim, every backup and restore job answering
LimitExceededException, the wait, SQLite stopped, a killed command's claim held until its heartbeat is older thanstale_job_after_secs(PostgreSQL too; SQLite's serve lock takes it), PostgreSQL's heldREPEATABLE READtransaction and the vacuum it holds back;<arn>to migrate in pieces; sample output, and that the log lines share standard output; the outcomes, the notes and exit (audit lines only for a write, "deleted" with the record's removal, "deleted during the copy", where "Its in-catalog backup is kept" is said, the claim re-checked before each step that cannot be undone); the refusals, every 0.1.x backup first, with the list's lead-in qualified and the list completed (the count check, a row of the id under another ARN or a manifest of another backup under the prefix, a CREATING or FAILED row of the configured store, the partition,AlreadyExistsat the replacement, a deleted backup an AVAILABLE row holds again); the KMS sentence (the stored specification loses the key id, and no answer reports one before or after); the restore-and-back-up way out, kept from the earlier text; what a migrated backup reports; a stopped run, sync's two skips and a deleted backup's copy and record (the guarantee rests on the record, which--allremoves once the prefix is clean, so--allis run after the last DeleteBackup of an in-catalog backup before the release that removes the reader); MongoDB's and Cassandra's stopped replacements, and running<arn>before deleting a Cassandra backup whose conversion stopped; Cassandra's recurring--all, which exits 1 while a create is in flight; memory, measured on this change's earlier revision, with Cassandra's table shape); the vector-index sentence corrected; the Strategy section's Cassandra paragraph and the catalog-lost paragraph point to it. The upgrade manual: the store paragraph, the 0.1.x paragraph (refused on their ids), the rollback paragraph (a backup migrated after the upgrade is adopted by--adopt-storelike any other backup this release wrote) and--allrun after the last DeleteBackup of an in-catalog backup, and why. The admin guide: the command's paragraph (the leftover rule by id and manifest, the record's removal, the re-check, the line kinds, the audit lines, memory) and sample output with its refused line, the registration names, and thestale_job_after_secsrow (a dead maintenance command's claim, on PostgreSQL too). The differences doc: "Where backups live" and the maintenance-claim row. Design: revision item 37, with sections 3, 4(c), 4(f), 4(g) and 7, PR 10's claim paragraph (PostgreSQL's take needs the stale window), the PR 10 paragraph and transitions, the PR 14 lines (its migration also refuses while a DELETED in-catalog row of a DeleteBackup whose id carries an instant exists, naming--all; a DELETING one names DeleteBackup again) and section 10's upgrade-manual row edited with it.Why
Design section 3, 4(f), 4(g) and the PR 10 section's
migrate-legacyparagraph, and PR 14's precondition: before the in-catalog reader is removed, operators need a way to move the backups it holds into the store without losing their ARNs. Until now the only way was a restore and a new backup, which gives a new ARN, a new creation time and, for a table that still exists, an interim table. Cassandra keeps writing in-catalog backups until PR 14 settles its backup path, so there the command is a recurring step.Choices to weigh
delete". DeleteBackup does not read the maintenance claim. In the design's order a DeleteBackup that landed between the AVAILABLE check and the insert was answered DELETED for a backup that then came back as the migrated row, and a restore target in CREATING made thedeleterefuse after the insert, leaving both rows and two ListBackups entries. On Cassandra the order cannot work at all: the legacy row and the index row share thebackups_by_arnkey, and the frozendeleteremoves the list rows the migrated row uses and leaves a DELETED row that every later insert answersfalsefor. The cost is one write method on the frozen trait (as the restore's registration already is) and four small changes to frozen paths (three deletes and Cassandra's legacy registration), each pinned by a test and a mutant; PR 14 removes all of it with the trait.kind = "disabled", and on Cassandra. The texts say so plainly, and the refusal names the way out. The pre-0.0.4 SQLite refusal is in practice a subset of this one; it stays as a defensive check, reachable through a hand-seeded row, with its own SQLite cell.restore_inputwith no overrides plus the features DescribeBackup reports, so a migrated backup restores the table the in-catalog backup restored. The design refused only the pre-0.0.4 SQLite case; a backup with no definition on PostgreSQL, MongoDB and Cassandra migrates with its keys and the 5/5 fallback, as its legacy restore creates it. What the legacy restore refuses (vector indexes, a multi-part key) is refused here too, so the migration lifts no refusal. The SSE specification'sKMSMasterKeyIddoes not survive the manifest's SSE description; the manual names it.LimitExceededExceptionfor the run, and on SQLite the server is stopped.<arn>migrates in pieces.backup syncskips such a prefix, naming the command, so a sync between the crash and the rerun cannot give the backup two rows. Cassandra already skipped it through the insert'sfalse.backupsrow on PostgreSQL and SQLite (the frozen delete keeps its tombstone), MongoDB'smigrated_to_storemarker already tells them apart, and Cassandra's converted row is an index row.list_deletedlists every DeleteBackup's tombstone, so--alllists one prefix per deleted in-catalog backup with an instant in its id (none of a 0.1.x release's). Once that prefix lists nothing and no catalog row holds the id,--allremoves the tombstone (remove_deleted), so the list shrinks instead of growing with every DeleteBackup, and a tombstone left means a prefix not yet confirmed clean. The guarantee therefore rests on those tombstones until--allhas cleaned them: PR 14's migration is to refuse while one with an instant-bearing id exists, and the manuals say to run--allafter the last DeleteBackup of an in-catalog backup. A row of the id under any ARN keeps the prefix, since the inserts conflict on(account_id, backup_id)and a prefix carries no region:row_holding_backup_idon PostgreSQL, SQLite and MongoDB, and on Cassandra, which can read only an ARN's partition, the manifest under the prefix, which must name this backup.stale_job_after_secs, which the config holds to at least three periods:HeldClaimbounds each beat by a period and reads a claim with no beat confirmed within two periods as lost, counting a beat in flight only when it started on schedule (SQLite's parked beat confirming while it waits); PostgreSQL's take refuses while the row's heartbeat is younger than the window, as the other backends' take does; and the command callsHeldClaim::recheck(), one live beat, before each step that cannot be undone, since the job's other fences (generation-conditioned writes, a prefix of its own) have no counterpart here. The cost: after a killed command on PostgreSQL the next one fails, naming the holder, until the window passes (Breaking changes). The bound and the on-schedule rule also apply topurgeand the options ofbackup sync, which read the samelost().table_size_bytesis the sum of the copied items' sizes, as the job records it, soBackupSizeBytesandTableSizeBytescan differ from what the in-catalog backup reported; the name, ARN, id, creation times and item count do not.--allgoes on past a refused, skipped or failed backup and exits 1 at the end with the counts; a global condition (claim lost, a failed re-check, signal, store unhealthy or fenced, listing or catalog failure, a replacement whose answer did not come) stops the run at once.list_alldoes not list, so--alldoes not finish it;migrate-legacy <arn>does (its "already in the store" path runs the replacement once more). Until then the backup is served by Describe, List and Restore, andbackup list, which reads status entries, does not show it. The manual says so.backup syncwithout the in-catalog skip (a feat(backup): backup sync, --adopt-store and --prune-missing [backup/restore 10] #825 or feat(backup): backup sync --move-store, --forget-store, --forget-backup [backup/restore 10b] #880 build during a rolling upgrade) adopting a stopped run's copy before the migration replaced it. DescribeBackup, DeleteBackup and RestoreTableFromBackup read the catalog row once more after a legacy not-found, for the same window.Testing done
On the tree rebased onto
537097af(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--lib565 passed, app 63, server 343, storage 241; SQLite--lib backup75; PostgreSQLbackup_restore74 (--test-threads=2); the wire suitestests/test_backup_store_config.py tests/test_backup_job.pyon the SQLite build 47 passed, 13 skipped (-n 4), as before the rebase.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-hooksand with--features extenddb-engine/backup-test-hooks;cargo +1.88.0 check --locked --workspace --all-targetsclean.a_manifest_of_the_legacy_in_catalog_kind_parses_and_round_trips;--lib backup_format181 passed, 1 ignored. Storage:--lib237 passed, with the foursource_table_from_legacycells (the exhaustive property; the described features and the legacy identity; the KMS key id; the refusals and the unknown vector state).legacy_migration7 cells (the stream across both batch bounds with 1,201 small and three 3 MiB items; a stream ending at a DeleteBackup after its batch;list_allacross accounts; the replacement's insert and removal in one step, thebackupsrow gone, an adopted copy finished, another store refused, in-use and unavailable refused with nothing written;holder_statusandlist_deletedtelling a delete from a replacement,remove_deletedof a DeleteBackup's row only and once,row_holding_backup_idby the id and the account); PostgreSQLlegacy_migration11 cells (the stream from its pinned snapshot while another transaction deletes every item row; the same replacement and holder cells; a legacy delete queued behind a replacement answers not found; a real replacement and a DeleteBackup queued on each other in both orders, the replacement's insert or the delete's item deletion held behind a table lock, each asserting the answers, so a replacement withoutFOR UPDATEfails at an answer; a DeleteBackup committed while the stream is paused after its AVAILABLE re-check, the stream reading every item of the pinned snapshot), and the dead-holder claim cell refusing the take while the row's heartbeat is young; MongoDB 9 live cells (2,500 items past a cursor batch; a drop ending a stream; the replacement; a replacement stopped before its drop finished by the next call, DeleteBackup leaving it; the same stop with the index row removed since, finished byfinish_replacement;holder_statusandlist_deleted; a commit whose answer is lost answeringReplaced; the in-use and unavailable refusals) and the unit cells oflock_miss(a migrated document) andlegacy_key(a missing field is an error); Cassandra 9 direct cells on a node of their own (200 items over four buckets in pages of 8;list_all; the conversion in place, the legacy describe, list and delete no longer seeing it; in-use and unavailable; a lost answer either way, a stop after the row finished by the next call, and a DeleteBackup paused between its read and its claim answering not found; a legacy restore paused between its DDL and its registration while the row is converted, answering not found with its target removed; a registration landing between the conversion's read and its transaction,InUse; a stopped conversion of a backup deleted since, its buckets deleted and no entry written;holder_statusandlist_deleted), run with the 6 existing cells that reach the changed delete, registration,restore_source, insert and abandoned-create paths (15 passed),holder_status_and_list_deletedcoveringremove_deleted(an index row and an AVAILABLE row left, the DELETED one removed once), and the unit cell comparing the conversion's columns with the insert's. The PostgreSQLbackup_restoresuite: 74 passed; SQLite--lib: 289 passed; MongoDB livelive_: 80 passed,--lib backup: 31 passed.--lib: 563 passed, 41 of them inbackup::tests::migrate_legacy: the copy under the ARN and its row; each refusal before the copy writing nothing; a refused item deleting the copy and--allgoing on; a short or failing read; the fence on a root another catalog adopted, before a leftover's deletion, at a copy's deletion after its manifest and at a deleted backup's copy; a copy that does not read back; a store serving another backup's copy (the manifest's ARN) or another count (the second count check); the items' size and the region and creation-time notes; a copy an earlier run left; a backup whose row is in the store;<arn>and the finish by the row's status; a row under the other spelling;AlreadyExistskeeping the copy; a replacement that fails; deleted or in use at the replacement; CREATING and DELETING skipped; MongoDB's stopped replacement finished whatever its row holds; a deleted backup's whole and partial copies (the sync skip,--alland<arn>deleting them, a row's prefix left, nothing listed for an id with no instant); a signal, a lost claim, a stalled heartbeat before the replacement, a signal during a slow copy and during the replacement; the claim's loss token; the ARN's spellings; sync's skip under both spellings; Describe, Delete, Restore and ListBackups racing a replacement; the ListBackups dedupe; a claim taken over since its last beat, which stops each step that cannot be undone (the manifest, the leftover's deletion,delete_copy,clean_deleted's deletion, the record's removal, the replacement,finish's andfinish_replacement), and one taken over during the copy, caught before the next such step; a lost claim read before each backup; a row of the id under any ARN keeping the prefix, and with no id index (Cassandra's case) a manifest of another backup keeping it; a deleted backup's record removed only once its prefix is clean (not while a deletion is refused, and after the listing again); a deleted backup an AVAILABLE row holds again, refused rather than called deleted; a read a DeleteBackup ended, and a backup deleted before its open, skipped as deleted while--allgoes on; a restore whose registration meets a replacement taking the row's path; sync's skip of an in-catalog backup and its copy's manifest both under another region;Fake'sholder_statusansweringNonefor a marked backup. Six cells ofHeldClaim(a hanging beat lost after two periods and a parked one confirmed; a stopped timer lost after two periods; a beat that starts after a gap confirming nothing, parked or not; a beat on schedule confirming only within its bound; a beat that panics leaving no flight;recheck()counting as a beat or recording the loss). The job, writer, sync and purge cells pass unchanged;describe_backup_of_another_accounts_id_is_not_foundnow counts the second row read. App--lib: 63 passed (the outcome lines and notes, the audit lines, the ARN check, arefusedorfailedline of each kind saying the in-catalog backup is kept only where it is, an audit line only after a write).tests/test_backup_store_config.py tests/test_backup_job.pyon the SQLite build 47 passed, 13 skipped (-n 4), on the PostgreSQL build 42 passed, 18 skipped (-n 2); the migrate cells on the MongoDB build (a replica set of their own) 2 passed, 1 skipped. New:test_migrate_legacy_carries_in_catalog_backups_into_the_store_under_their_arns(303 items with a GSI and NS, B, nested M and L, BOOL and NULL values; two in-catalog backups;<arn>spelt in another region,--all, reruns, "1 already in the store"; the manifests; the in-catalogbackupsrows gone with no item or definition row; ListBackups once each, DescribeBackup as before, the restore's items and GSI, DeleteBackup; beside the server on PostgreSQL and MongoDB),test_migrate_legacy_refuses_a_backup_with_no_instant_or_an_item_618_let_in_and_keeps_it(--allpast a BatchWriteItem, TransactWriteItems and other operations store N values that fail number validation as written #618 backup and, on SQLite and PostgreSQL, a 13-digit copy of a backup's rows; the three refused items logged; both kept and AVAILABLE; the 13-digit one restored and backed up again into the store) andtest_a_backup_deleted_after_a_stopped_migration_never_comes_back(SQLite, PostgreSQL: a malformed<arn>exits 2 with no claim taken; two migrations put back as a stop after the copy leaves the catalog, one prefix cut to its data files; sync adopts neither, before and after DeleteBackup of both;--alldeletes both copies, each line with its note, "2 deleted", exit 0; both stay BackupNotFound). Extended:test_a_second_maintenance_command_fails_at_once(PostgreSQL:migrate-legacy --allrefused while--prune-missingholds the claim, then holding it while it waits for a live job,backup purge --confirmrefused beside it, a SIGTERM stopping it before any change) and the serve-lock cell (exit 6 beside a SQLite server).11544ecd):initwithkind = "disabled", two CreateBackups, the store configured, the command run (refused with exit 6 beside a SQLite server, nothing written), boto3 afterwards: 33 and 30 checks, none failed.backup-test-hooks, a 40,000-item table in two data files (this diff's first form): after the first data file, before the manifest and after it, the in-catalog backup was whole with no row; after the manifest a plainbackup syncskipped the prefix naming the command and wrote no row; each rerun deleted the copy, migrated 40,000 items and removed the in-catalog backup; a restore of the last held every item. On PostgreSQL the killed command's advisory lock ended with its session, so that first form's rerun took the claim at once; on this tree the rerun takes it once the dead holder's heartbeat is older thanstale_job_after_secs(the takeover runs below). On this tree'sbackup-test-hooksbuilds: SIGKILL after the manifest and after the first data file, then DeleteBackup of the in-catalog backup, then--all,<arn>and a plain sync: the copy deleted ("1 deleted"), nothing adopted, the backup BackupNotFound, on SQLite and PostgreSQL, and on PostgreSQL with an S3 store (moto); on PostgreSQL a DeleteBackup while the run was paused after its manifest, with the prefix made unwritable: the run skipped with its note and exit 1, sync adopted nothing,--allthen deleted the copy; a SIGTERM at each point exits 1 (after the manifest the replacement completes first); the kill-after-manifest rerun migrates. Cassandra's stop after the conversion is the direct cell above (no binary kill point and no Cassandra wire leg).ensure_restorableignored,nullwritten as[], the class or the SSE description dropped; a batch or bucket or page boundary skipped, a stream truncated or read outside its snapshot, the per-batch AVAILABLE check dropped; the in-use check skipped, deletion before the insert, Cassandra'sstore_kindleft null or the buckets deleted before the conversion, a lost answer read as applied; each frozen delete's new check dropped; DELETED rows or index rows listed), 10 in the engine (the id refusal, the first count check and both, the fence before the first put,--allstopping at a refusal, the leftover kept, the re-read skipped, the sync skip, the dedupe, the loss token), 3 on the wire (a run with refusals exiting 0, the<ARN>/--allgroup optional, the wait skipped); and since: 18 in the backends (the replacement leaving a DELETED row on PostgreSQL or SQLite,list_deletedorholder_statusfiltering wrongly on each backend, PostgreSQL's replacement withoutFOR UPDATE, MongoDB'sholder_statusignoring the marker,finish_replacementa no-op,lock_misswithout the marker, the lost commit not settled,list_alltaking a default for a missing field, Cassandra's registration withoutstore_kind = null, the conversion withoutactive_restores = null, a deleted row's finish writing entries), 29 in the engine (a deleted backup's prefix adopted by sync, not cleaned, not listed, cleaned without the fence or over a row; MongoDB's stopped replacement not finished;HeldClaimwith no bound and no two-period clause, either alone, or no parked clause; the row read under one spelling; the copy deleted onAlreadyExists; the finish ignoring the row's status or calling for a CREATING row; each of the four re-reads dropped; ListBackups reading the index first; the fence before a leftover's and a copy's deletion; the second count check; the claim check before the replacement; the manifest's ARN check;table_size_bytesnot set; the copy kept on a signal; the region and the creation-time notes; the finish'sInUseandNotAvailableanswers; the signal after the last replacement), 3 in the app (the notes not printed, the in-store finish not audited, the in-catalog suffix on every refusal), 2 on the wire (<arn>not checked before the claim, deleted backups not cleaned): 97, all killed; and on this tree 46 more, each a targeted edit with the named cell run and the bytes written back and checked by hash, all killed: 4 inHeldClaim(any late beat confirming, the flight cleared without the guard, the re-check recording no loss or not counting as a beat), 8 re-checks dropped (before the manifest, the replacement, the leftover's deletion,delete_copy,clean_deleted's deletion, the record's removal,finish's replacement,finish_replacement), 4 of the id rule (the lookup dropped in the migration and inclean_deleted, the manifest check dropped at the leftover and at a deleted backup's copy), 3 of the record's removal (never, before the prefix is known clean, without the listing again), 3 of the kept and skipped reports (no re-read after a short or failed read,in_catalog_keptalways true, a lost replacement answer not recorded), one each for the deleted backup an AVAILABLE row holds, the audit of a write, the restore's third read, the leftover'swrote, MongoDB's stopped replacement answeringDeletedand the five engine cells of other findings, 3 in the app (the suffix on every line, an audit line without a write, the clap error doubled) and 11 in the backends (PostgreSQL's take of a young dead row, its stream's AVAILABLE re-check outside the snapshot, its replacement withoutFOR UPDATE;remove_deletedof any status on each of the four;row_holding_backup_idof any account on PostgreSQL, SQLite and MongoDB; Cassandra's conversion withoutcreated_at): 143 in all.backup-test-hooksbuilds, 45,000 items padded to 1,000 bytes,stale_job_after_secs = 45, a second run on another config (32 MiB data files): on PostgreSQL a first run paused before its manifest had its claim session ended (pg_terminate_backend) right after a beat; the second was refused 12 times, naming the holder, and migrated 51.6 s after the beat; the first, released, exited 1 at its re-check; the row AVAILABLE with its six objects, a restore holding 45,000 of 45,000 items. On MongoDB (a replica set of its own) the first was stopped (SIGSTOP) 52 s at the same point, the second migrated, the replica set was paused (docker pause) as the first resumed and was released; the first exited 1 ("no heartbeat confirmed the maintenance claim within two periods"), the prefix unchanged throughout, the restore 45,000 of 45,000. On the head before this change both sequences ended with an AVAILABLE row whose prefix was empty. On SQLite: a whole copy left with the in-catalog backup AVAILABLE, DeleteBackup, sync adopting nothing,--allwith the prefix made unwritable (exit 1, the record kept, no "Its in-catalog backup is kept"),--allagain (the copy deleted, the record removed, "1 deleted"), a third--all("0 deleted", no audit line), sync,<arn>refused (no record and no row), DescribeBackup BackupNotFound.ru_maxrssof the command throughwait4, two runs each:An independent measurement of the same shape on a host of the same description gave 68.4 to 74.9 MiB on PostgreSQL beside a running server and, on SQLite, 81.0 MiB for 1,000 items over a 562 MB file and 121.2 and 148.6 MiB for 250,000. So PostgreSQL is flat in the item count at 56 to 75 MiB, and SQLite's excess (55 to 149 MiB measured) is the page cache of the command's connections (
PRAGMA cache_size = -32000per connection), bounded by about 32 MB times the pool's connections (pool_size, 10 by default), its small-backup figure following the database file's size. The earlier run of this PR measured MongoDB and Cassandra flat at 56.1 and 55.2 MiB for 1,000 and 100,000 items (12.4 and 10.6 s for 100,000; Cassandra without GSIs, whose asynchronous queue over 100,000 writes timed the node out); their read paths are unchanged by this revision.Acceptance lines of #821 (the design's PR 10 acceptance list) and where each is tested:
migrate-legacyon a backup the PR 0 binary created produces a prefix Describe, List and Restore serve under the original ARN, and the legacy rows are gonean_in_catalog_backup_is_copied_under_its_arn_and_replaced_by_its_row; the backends' replacement cells (no non-DELETED row, no items, definition, collection or buckets); wiretest_migrate_legacy_carries_in_catalog_backups_...(SQLite, PostgreSQL, MongoDB; the in-catalog rows read on SQLite and PostgreSQL). Those cells' backups are taken by this tree's in-catalog create, the frozen copy of the PR 0 (#384) code each backend keeps, so they stand in for the PR 0 binary's; an independent run on this change's earlier form (head27070b6c) also migrated, on SQLite, a GSI table's backup with B and NS values that main94b5f689(catalog migration 5) took, aftermigrate --yes: 40 items, DescribeBackup differing only inBackupSizeBytes, the restore holding 40 items with 40 GSI hitsmigrate-legacyrefuses a backup with a 13-digit id, one holding a value #618 let in and a pre-0.0.4 SQLite backup, keeps their legacy rows and names the reasoneach_refusal_before_the_copy_...anda_refused_item_deletes_the_copy_...; storagea_copy_refuses_what_the_legacy_restore_refuses_and_an_unknown_vector_stateand SQLite's stream cell (the definition row removed:Unknown); wiretest_migrate_legacy_refuses_...(the 13-digit id on SQLite and PostgreSQL, #618 on all three)test_a_second_maintenance_command_fails_at_once(PostgreSQL, both ways)migrate-legacywaits through the sharedwait_for_jobs(cmd_backup.rs), the function whose wire celltest_a_maintenance_command_waits_out_a_deleting_row_and_a_dead_restoreruns it fromsync --forget-backup; the migrate cells' second-command cell shows the command waiting for a live job, and an independent binary run on PostgreSQL waited for a running legacy restore of a 60,000-item backup and migrated after ita_read_short_...,a_copy_that_does_not_read_back_...migrate_legacy_on_a_root_another_catalog_adopted_writes_nothingcompleted_atset)sync_skips_a_prefix_...anda_backup_deleted_after_a_stopped_copy_never_comes_back; wiretest_a_backup_deleted_after_a_stopped_migration_never_comes_back; SIGKILL after the manifest, thenbackup sync(SQLite, PostgreSQL)Cassandra has no wire leg (no pytest fixture runs it); its share is the direct cells above and the memory run.
Not in this PR
backup_items. The manual says to runmigrate-legacy <arn>before deleting such a backup.Breaking changes
None for a released build's data.
backup syncskips a prefix whose ARN an in-catalog backup holds, in any status, where it could adopt it before; no release wrote such a prefix. ListBackups lists an ARN both readers hold once. On PostgreSQL, MongoDB and Cassandra an in-catalog DeleteBackup that read the backup before a replacement committed answersBackupNotFoundException, where it could have answeredDELETEDfor a backup that lives on in the store. On PostgreSQL the second of two concurrent DeleteBackups of one in-catalog backup now answersBackupNotFoundException(the status is read under the row lock), where it answeredDELETED. On Cassandra a legacy restore whose registration lands after a migration converted its backup answersBackupNotFoundException. A maintenance command (purge,migrate-legacy, the options ofbackup sync) whose heartbeat does not confirm the claim within two periods, counting a beat in flight only when it started on schedule, now stops as for a lost claim. On PostgreSQL a maintenance command (purge,remove-target,migrate-legacy, the writing forms ofbackup sync) started after another was killed, or after the other's catalog session ended, now fails at once, naming the holder, until the dead holder's heartbeat is older than[backup] stale_job_after_secs(300 s by default), as on MongoDB and Cassandra; before, it took the claim as soon as the advisory lock was free. A script that reruns a killed command at once on PostgreSQL must retry or wait for the window. SQLite is unchanged: under the serve lock the next command takes the claim at once.DCO
Signed-off-by: Scott Robinson robinnsc@amazon.com