Skip to content

fix(backup): restore sort keys, indexes, and throughput; bound memory; recover abandoned restores - #384

Merged
robinnsc merged 1 commit into
mainfrom
fix/backup-restore-fidelity
Oct 23, 2026
Merged

robinnsc merged 1 commit into
mainfrom
fix/backup-restore-fidelity

Conversation

@robinnsc

@robinnsc robinnsc commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

What

Backup and restore now behave the same way on all four storage backends (PostgreSQL, SQLite, MongoDB, Cassandra), and closer to DynamoDB, on the points the round-2 and round-4 reviews measured against the service.

  • The engine resolves a backup into one validated CreateTableInput before any backend sees it. It runs the multi-part-key and vector-index refusals, applies the overrides, and runs validate_create_table on the result, for every backend. Backends now only create the target, register the restore, and copy. Cassandra (Feat/storage cassandra in tree #338) previously skipped the refusals and dropped the secondary indexes.
  • RestoreTableFromBackup returns the target CREATING and copies in a background task on all four backends, so a client that times out or disconnects no longer loses the restore or locks the name and the backup. Once the request has been authenticated and authorized, the engine runs the rest of it (reading the backup, validation, registering the restore) in a task of its own, so a client that disconnects before the response no longer cancels it part-way. A disconnect while the request is still being authenticated or authorized cancels it with nothing created (see Not changed here).
  • While a target is CREATING, DeleteTable on it is ResourceInUseException and DeleteBackup on its source is BackupInUseException. A target whose server stopped mid-copy is removed without operator action, and each removal is logged with the table name:
    • PostgreSQL: on a control-plane pass after 60 s.
    • SQLite: at the next start.
    • MongoDB and Cassandra: by a heartbeat sweep that runs at startup and every 15 s, about 45 to 80 s after the owner stopped (the last heartbeat can be up to one 15 s interval before the stop).
  • RestoreSummary matches the service. RestoreDateTime is the backup's creation time and SourceTableArn is set. No response that describes a table (CreateTable, UpdateTable, DeleteTable, DescribeTable, the restore response) carries RestoreSummary unless the table is CREATING; one rule in TableDescription::prepare_for_response applies it to all of them.
  • DescribeBackup reports SourceTableDetails.ProvisionedThroughput, which is required in the shape and {0, 0} for on-demand. It also reports SourceTableFeatureDetails: GSIs, LSIs, TTL, stream, and SSE. A definition version this build does not know is described without feature details; any other failure to read the definition (a row that does not decode, a lost connection) is an internal error, as it is for RestoreTableFromBackup, instead of a description that hides it.
  • DeleteBackup returns the full DescribeBackup description with BackupStatus DELETED, including SourceTableDetails.ProvisionedThroughput and SourceTableFeatureDetails. It alone tolerates a definition that cannot be read (it answers {0, 0} and no feature details, and logs a warning), so a damaged backup stays deletable; this is ExtendDB's choice, stated in the docs.
  • ListBackups honours Limit (1 to 100, default 100) and ExclusiveStartBackupArn, and returns LastEvaluatedBackupArn. Summaries carry TableId. It filters on BackupType (USER when absent, every type for ALL; every ExtendDB backup is USER) and on TimeRangeLowerBound (inclusive) and TimeRangeUpperBound (exclusive), before paging. The cursor is checked as the service checks it (measured on DynamoDB): one from another account is ValidationException "Invalid Request: The supplied ExclusiveStartBackupArn does not match the account", one from another region or another table than the TableName filter is refused with the same shape of text, a time bound before the epoch is refused, and a lower bound above the upper bound is "Time range lower bound must be less than or equal to upper bound".
  • BackupName and TableName on CreateBackup, and TableName on ListBackups, are validated as the service model says: 3 to 255 characters and [a-zA-Z0-9_.-]+, with the service's "1 validation error detected: Value '' at 'backupName' failed to satisfy constraint: ..." texts, joined under "2 validation errors detected:" when both fail (the order of the two clauses was not captured; the length clause comes first). TableName takes the deployment's configured table-name limits. Before this a 200 KB BackupName was stored and returned in every ListBackups page (a 500 on Cassandra) and a NUL byte was a 500 on PostgreSQL. CreateBackup accepts TableName as the table's ARN (measured on DynamoDB; botocore refuses the ARN form client-side, so only a hand-built request sends it); CreateBackup on a table that does not exist or is still CREATING is TableNotFoundException "Table not found: " (measured) on all four backends.
  • Restoring into an existing name is TableAlreadyExistsException; into a name that is itself a CREATING restore target, TableInUseException (measured on DynamoDB); the text is the service's "Table: is already being restored from backup: " whichever backup is restored, where the service answers "Attempt to change a resource which is still in use: Table: is being used." for a different backup. A second RestoreTableFromBackup of a backup while a restore from it is still CREATING is BackupInUseException (measured). Both are decided inside the registration transaction (PostgreSQL, with the backup row locked FOR UPDATE; SQLite), the claim (MongoDB), or the lightweight transaction (Cassandra), so two simultaneous requests cannot both pass; the name is checked before the backup and before a restore slot is taken, so the same backup restored again into its own CREATING target's name is TableInUseException at once on all four, also while the account's restore slots are all in use. A restore of a backup whose DeleteBackup is in progress answers BackupInUseException on MongoDB and BackupNotFoundException on the other three (documented; the service's answer was not measured). A plain CreateTable into a CREATING restore target stays ResourceInUseException "Table already exists: ".
  • BackupArn and ExclusiveStartBackupArn are checked as the service checks them (measured on DynamoDB): a string that is not arn:aws:dynamodb:<region>:<account>:table/<name>/backup/<id> with a region in [a-z0-9-], a digit-only account, a name in [a-zA-Z0-9_.-], over 1024 bytes, or carrying a control or non-ASCII byte is ValidationException "Invalid Request: BackupArn is not valid"; a well-shaped ARN whose id is not a backup id is "Invalid Backup ARN" (which text goes with which input was captured for one input only, a bare 13-digit id answered "not valid"; the rest of the pairing is ExtendDB's). Surrounding ASCII whitespace is trimmed (the service was measured trimming a trailing newline). An ARN of the caller's account in another region resolves: the engine looks the backup up under the ARN rebuilt in the server's region first and, when the ARN as written differs, under that spelling, so backups created under an earlier server.region stay reachable by the ARN ListBackups reports (not by that ARN rewritten with the new region), and ListBackups accepts such a backup's stored ARN as ExclusiveStartBackupArn, so paging passes it. A server.region outside [a-z0-9-] logs a startup warning naming the consequence. The ARN is checked before the target name; another account's is AccessDeniedException; another partition's is ValidationException. Before this, every malformed shape resolved to BackupNotFoundException and a NUL byte was a 500 on PostgreSQL.
  • Overrides:
    • SSESpecificationOverride is applied.
    • GlobalSecondaryIndexOverride, LocalSecondaryIndexOverride, and OnDemandThroughputOverride are applied as the service applies them (measured on DynamoDB): an empty list restores no indexes of that kind, a subset restores those indexes and prunes the attribute definitions only they used, a GSI entry's ProvisionedThroughput is the restored index's throughput, a one-member or changed OnDemandThroughputOverride is the restored table's on-demand throughput (including a PROVISIONED backup restored as on-demand), and the on-demand-to-PROVISIONED route works with per-index throughput in the list. Refused as on the service: an unknown or changed index definition (key schema or projection), a throughput member contradicting the billing mode, {0, 0}, and a PROVISIONED restore of a GSI without throughput, with the service's text "Invalid Request: Must override ProvisionedThroughput if BillingMode is overridden to PROVISIONED for index ": that refusal fires whenever BillingModeOverride=PROVISIONED is sent with a GSI and no list entry supplies its throughput, whatever the backup's billing mode or recorded throughput (Lee's measurement was on an on-demand backup; the PROVISIONED-backup case follows the service's own message and was not measured), and when a list entry omits throughput on a PROVISIONED restore without the override (text not measured). A GSI entry's OnDemandThroughput is accepted and not recorded (ExtendDB keeps no per-index on-demand maximum). NonKeyAttributes are compared as a set.
    • TableClass is no longer carried by a restore: the target is STANDARD, as on the service (measured). The backup definition keeps the class.
    • The RestoreTableFromBackup response no longer carries DeletionProtectionEnabled or the table's ProvisionedThroughput.NumberOfDecreasesToday, which the service omits there; a GSI's NumberOfDecreasesToday is kept (not measured).
    • The refusal of an on-demand backup with GSIs restored as PROVISIONED now says what works: restore with PAY_PER_REQUEST, then switch with UpdateTable.
  • A PROVISIONED backup whose GSI has no provisioned throughput restores, and that GSI takes the table's throughput (or ProvisionedThroughputOverride's). ExtendDB's CreateTable accepts such a table and UpdateTable to PROVISIONED produces one; the service cannot produce such a backup, so the choice is ExtendDB's. The earlier restore-only stopgap that refused these backups is removed.
  • CreateTable and UpdateTable now apply the two checks the stopgap applied only at restore: a GSI ProvisionedThroughput below 1 on a PROVISIONED table (and on UpdateTable's GSI Create) is ValidationException, and so are more than 100 projected NonKeyAttributes across a table's GSIs and LSIs. Restore runs the same checks through validate_create_table.
  • CreateBackup of a table with vector indexes is refused up front. It no longer returns an AVAILABLE backup that can never be restored.
  • Backup ids use one shared shape on every backend: 14-digit zero-padded epoch milliseconds, then 8 hex digits. BackupCreationDateTime is that millisecond. The ids earlier builds issued (13 digits plus the hex suffix from v0.1.3 to v0.1.14; bare 13 digits from PostgreSQL v0.1.0 to v0.1.2) still resolve; they carry no time, so one that has been deleted or filtered out cannot serve as a ListBackups cursor.
  • The first read on a restore target after DescribeTable reports ACTIVE succeeds (measured on DynamoDB). The server's key-info cache no longer stores a negative entry for a name the catalog has in a non-ACTIVE state: on a not-found answer it asks the catalog once more with DescribeTable and caches nothing. Authorization (main's fix(auth): enforce dynamodb:LeadingKeys on Query, batch and transaction operations #418) records such a name as missing in the key-info snapshot it hands the engine, and fails closed on any other lookup error, so a request against a CREATING name costs one table_key_info and one describe_table load (measured on SQLite with this PR's earlier hand-off, which fix(auth): enforce dynamodb:LeadingKeys on Query, batch and transaction operations #418's snapshot replaces: 40 and 41 catalog loads for 40 refused GetItems). Before this a read during the copy poisoned the cache and reads failed for up to 5 s after ACTIVE; CreateTable targets had the same lag and get the same fix.
  • PostgreSQL:
    • The copy commits per batch, with one multi-row INSERT per index.
    • The restore lock session is heartbeated.
    • One server process runs at most 4 restores per account and at most half of pool_size in total (capped at 16; 10 with the default pool_size). A restore that cannot get both within 30 s is LimitExceededException. The whole restore, admission included, runs in a task the request only waits on, so a client that disconnects during the wait does not cancel it; the account slots belong to the engine, not the process.
    • extenddb migrate finds the data database through the name init recorded in the catalog's settings, so a deployment initialised with --data-db <other-name> migrates without further flags, and accepts --data-db to override it. Before, it derived the name from the catalog database name and failed with "database does not exist".
    • Failing to open the restore lock connection is LimitExceededException only when PostgreSQL refuses it with too_many_connections (SQLSTATE 53300); any other failure is an internal error.
    • serve refuses a catalog whose schema_history lacks one of the binary's catalog migration files; the message names the files and extenddb migrate --yes. The stored version alone does not show this case, because another migration of the same number can have stamped the same version.
    • A catalog created by a pre-release 0.0.4 build (no table_restores.source_table_arn) gets the column at serve startup, with a warning. That step waits at most 5 s for the table lock (lock_timeout) and otherwise refuses to start with the migrate advice. serve no longer drops the pre-release restore_date_time default, which a still-running earlier-build server relies on; only migrate --yes does.
    • The unreachable restore_table_to_point_in_time is removed from the trait and from every backend.
  • SQLite:
    • serve refuses to start a file database whose tables are in a pre-release 0.0.4 shape, and names the step and extenddb migrate --yes. The upgrade's backup_items rebuild is named in the preview with its row count and logged at start and finish. The pool_size clamp is logged.
    • CreateBackup is admitted against the pool: at most pool_size / 2 - 1 backups at once (4 with the default of 10; each holds its reader and its writer), LimitExceededException after a 30 s wait; a refused backup writes nothing. Before this, a flood of CreateBackups took every pooled connection and every other request waited behind them.
    • A GSI added by UpdateTable with ProvisionedThroughput on a PROVISIONED table made every DescribeTable of that table fail (Internal("index pt: missing field NumberOfDecreasesToday")). The index's throughput is now written in the shape DescribeTable reads, and rows earlier builds wrote are read as the same units with NumberOfDecreasesToday 0, so such a table is describable again after the upgrade without touching the database file.
  • MongoDB:
    • DeleteBackup, and the sweep finishing an interrupted delete, no longer release their claim once the backup's data may have been dropped. An error or cancellation after that point leaves the backup AVAILABLE and in use until the sweep finishes the delete; before, it could be left AVAILABLE with no data, and a restore of it produced an empty table.
    • A DeleteBackup that finds the backup claimed by another delete is BackupInUseException ("Backup is being deleted"), where it was BackupNotFoundException.
    • The engine opens the catalog database with read preference primary, read concern local, and write concern majority whatever the connection string says, and the $out copies of CreateBackup and restore are written with w: majority, so the claim protocol's reads see every write the primary acknowledged and an acknowledged claim survives a failover. A connection string that sets a weaker write concern (w=0, a numeric or tagged w, journal=false) is refused at startup, as a non-primary read preference already was.
    • A restore holds its claim on the backup only until the target's catalog document is inserted. A concurrent restore of the same backup that finds the claim held retries for up to 2 s and then fails with BackupInUseException. Before, it failed at once with BackupNotFoundException.
    • Heartbeats refresh at once rather than after the first interval, and a failed refresh no longer skips the others.
    • The sweep leaves a stale delete claim alone while a restore from that backup is CREATING, and finishes the delete afterwards.
    • Twenty concurrent RestoreTableFromBackup requests no longer wedge the server for every later request (reproduced on the previous build with 20 restores of 20 distinct backups: 6 answered, 14 timed out after 60 s, and every later request timed out for the rest of the run; 20 restores of one backup did not wedge, because one backup's claim serializes them). The registration's hand-over left createCollection's pooled connection checked out while it waited for another connection, and the Rust driver never times a checkout out (waitQueueTimeoutMS is not supported in mongodb 3.8.0), so with ten such restores the pool was exhausted for good. Both futures are now polled together. Restores are admitted per account (4) and per server (min(16, pool/2), where the pool is the engine client's maxPoolSize: max_connections, or the driver default of 10, so 5), with LimitExceededException after 30 s; a request whose registration takes longer than 60 s answers an internal error while the restore continues and DescribeTable reports its target once it exists. The whole restore, admission included, runs in a task the request only waits on. The one-restore-at-a-time exclusion on a backup is bounded by the heartbeat: a restore that stalls between its claim and its insert for longer than the stale window loses the claim to the sweep and can be joined by a second restore of the backup (documented; PostgreSQL and Cassandra have no such window).
    • UpdateTable on a CREATING restore target is refused (ResourceInUseException, as on the other three backends; the service answers ResourceNotFoundException). Before, it changed the target mid-restore: billing mode, a third GSI, a stream, and deletion protection were accepted and the finished table was not what the backup described.
    • BackupInUse texts match the other backends ("Backup is being used to restore table : ", or "... to restore another table: " while the claim is held and the target is not yet known).
    • The test-only protocol model covers the new steps (two restores, the delete and its stop points, the sweep's delete takeover) and checks liveness as well as safety. The four tests that explore the full model take about 25 s each in a debug build and are #[ignore]d; cargo test -p extenddb-storage-mongodb --lib protocol_model -- --include-ignored runs them, and the module doc says when to. Its doc comment now says that RRelease is one atomic step for the release, the lost-claim recheck, and the abort, and lists what is not modelled (release failures, the sweep's pre-takeover target check).
  • Cassandra:
    • An on-demand GSI now reports ProvisionedThroughput {0, 0} in DescribeTable, and a GSI added by UpdateTable with throughput reports it.
    • An ordinary CreateTable no longer names the V005 restore columns, so an unmigrated catalog breaks only CreateBackup, RestoreTableFromBackup, and DeleteBackup.
    • The restore heartbeat starts when the target row is inserted, before the target's tables are created, and stops on every exit from the copy (an abort-on-drop guard), including a panic.
    • On a catalog that has not been migrated to V005, CreateBackup, RestoreTableFromBackup, and DeleteBackup are a ValidationException naming extenddb migrate --yes, instead of an internal error; a running server notices the migration without a restart.
    • UpdateTimeToLive enabling TTL on a table with an asynchronously propagated GSI is a ValidationException naming the table, the index, and its delay, instead of an internal error that SDKs retry (the engine now maps a backend's validation refusal in the TTL handlers).
    • extenddb init writes the application role's username and password into the generated config, so serve starts against a PasswordAuthenticator node without editing it. The config file is created with mode 0600 instead of being written and then narrowed.
    • The abandoned-restore sweep runs once at startup and stops on shutdown.
    • The restore registration's lightweight transaction requires backup_status = 'AVAILABLE' AND active_restores = null, so a second restore of a backup whose first restore is CREATING is BackupInUseException and a restore into a CREATING restore target is TableInUseException, as on the other backends. A restore whose registration attempts are all taken by a restore that was just starting answers BackupInUseException ("another restore of this backup is starting; retry") instead of "Backup not found".
  • Backup and restore authorization is main's (fix(auth): authorize resource-ARN operations against the resource they act on #419, released in v0.1.14): every backup operation is evaluated on the resource the service evaluates, canonicalized to this deployment. This PR adds one thing on top: a BackupArn is trimmed of surrounding ASCII whitespace before it is canonicalized, as the engine trims it before resolving the backup, so a Deny on a backup ARN cannot be bypassed by sending the ARN with a trailing newline (unit test backup_arn_with_surrounding_whitespace_authorizes_the_trimmed_backup). The engine resolves a BackupArn of the caller's account under the server's region first and then as written, which is the same table and backup id that authorization evaluated. tests/test_auth_permissions.py::TestBackupAuthorization covers ListBackups per table, the restore's seven item actions, and a tagged existing target (an aws:ResourceTag Allow: TableAlreadyExistsException; without the tag: AccessDeniedException) in authenticated mode on all four backends.
  • Catalog version guards (PostgreSQL converge, SQLite apply, extenddb migrate) refuse a stored version they cannot parse instead of overwriting it, and serve prints the same refusal line as migrate instead of wrapping it as an internal error.
  • extenddb migrate:
    • The preview without --yes exits 3 when work is pending and 0 when up to date. It used to exit 1 with an error. This covers main's resource-tag condition-key rewrite (fix(auth): evaluate resource tags only as aws:ResourceTag, matching DynamoDB #421) too, whose detection and apply step now sit behind a small ResourceTagStep trait so the exit-status tests can run without a catalog; main's tests/test_resource_tag_migration.py is updated to expect exit 3 for its preview.
    • On PostgreSQL it treats any catalog migration file the ledger does not record as pending, even when the stored version already matches, and applies it with --yes. It lists such files under data migrations as catalog <file>.
    • A failed admin connection is one plain line that names the role it tried and says to pass --pg-user and EXTENDDB_PG_PASSWORD. Before, it read Internal("Internal(\"Cannot connect as admin: ...\")"). init and destroy print bootstrapper errors the same way.
    • It accepts --cassandra-user and --cassandra-pass, and reads the password from EXTENDDB_CASSANDRA_PASSWORD when the flag is absent (so does init). Without them it could not migrate a Cassandra deployment that uses PasswordAuthenticator.
  • Build and CI: extenddb-app gains an async-trait dev-dependency. .github/workflows/integration.yml adds --test backup_restore to the PostgreSQL storage-level test step. devtools/run-cassandra-tests runs the fidelity file's large-backup tests with 10,000 items unless EXTENDDB_TEST_BACKUP_ITEMS is set (see Not changed here).
  • Docs:
    • docs/differences-from-dynamodb.md: the Backup and Restore section, rewritten for four backends, with a seven-member overrides table; rows that rest on the code rather than a running server are marked. Two Secondary Indexes rows for the new CreateTable/UpdateTable checks. New rows for BackupArn shapes, name validation, the cursor checks, reads on a restore target, UpdateTable on a CREATING target, what a restore carries across, backup contents, and the authorization resources.
    • docs/dynamodb-limits.md: the backup rows; projected attributes are now enforced. docs/technical-debt.md: that limit removed from the unenforced list.
    • docs/manuals/07-upgrade-manual.md: parameterized backup and rollback commands, the migrate credentials and exit codes, and PostgreSQL, SQLite, Cassandra, and MongoDB subsections for 0.0.4 (including serve's refusal of unrecorded catalog files, the 5 s lock wait, and MongoDB's read and write concern).
    • docs/manuals/05-admin-guide.md: [storage.sqlite]; the PostgreSQL restore limits and the session-mode pooler requirement.
    • docs/manuals/01-architecture-guide.md, docs/design/13-storage-mongodb.md (restore flow; catalog read preference and concern), and the version literals in the getting-started and install guides.

Why

Round-2 review by Lee Hannigan, which requested changes. It found four blocking problems:

  • The merge with Feat/storage cassandra in tree #338 did not compile, and Cassandra's restore skipped the refusals and the indexes.
  • PostgreSQL and SQLite copied inside the request, so a client timeout lost the restore (PostgreSQL) or locked the name and backup until restart (SQLite).
  • DescribeBackup returned the pre-PR subset.
  • The PR body and the docs stated behaviour the build did not have.

The review also listed wire differences measured against DynamoDB, PostgreSQL robustness issues, tenancy issues (a process-global restore limit, unvalidated stored definitions, and catalog guards that failed open), authorization of backup operations against table/*, and the upgrade-path problems in the manual. A further review of this round found the MongoDB delete-claim release, and the shapes that CreateTable accepted and restore refused.

Round-4 review by Lee Hannigan (sent directly) on the previous revision found 19 further problems, all addressed here: twenty concurrent MongoDB restores wedged the server; a SQLite CreateBackup flood took the whole pool; MongoDB UpdateTable changed a CREATING restore target; DescribeBackup masked database failures; reads on a restored table kept failing for seconds after ACTIVE; PostgreSQL migrate could not find a data database named at init; BackupName, TableName, BackupArn and the ListBackups cursor were not validated as the service validates them; CreateBackup of a missing table, a restore into a CREATING target, and a second restore of a backup gave the wrong errors; DeleteBackup returned a partial description; TableName as an ARN was refused; index and on-demand overrides the service applies were refused, and two requests the service refuses were accepted; TableClass was carried across; and the docs and test comments narrated the review. It also listed pre-existing issues, of which the SQLite GSI-throughput storage shape and the authorization resources of ListBackups and RestoreTableFromBackup are fixed here (the others are under Not changed here).

Open questions for the maintainers:

Testing done

Release binaries were built from the code in this PR, one per backend feature, and served on 127.0.0.1 against PostgreSQL 16, mongo:7 (single-node replica set), and cassandra:4.1 with PasswordAuthenticator, all on one host. Timings compare backends; they do not predict a networked deployment. The fidelity table below ran on those binaries; the large-backup timings and the further checks after them ran on earlier builds of this branch, as each says.

tests/test_backup_restore_fidelity.py, tests/test_ttl.py, tests/test_abac.py, and tests/test_auth_permissions.py, each run in full on release binaries built from this PR's head after its rebase onto main (b5bffa38, v0.1.14), plus main's tests/test_authz_resource_arn.py (Cassandra with index_propagation_delay_ms set to 0, as CI runs it; see #407):

Backend test_backup_restore_fidelity.py (37) test_ttl.py (11) test_abac.py (69) test_auth_permissions.py (63) test_authz_resource_arn.py (43)
PostgreSQL 37 passed 11 passed 69 passed 63 passed 43 passed
SQLite 37 passed 11 passed 69 passed 63 passed 43 passed
MongoDB 36 passed, 1 skipped (no vector indexes) 11 passed 69 passed 63 passed 43 passed
Cassandra 36 passed, 1 skipped (no vector indexes) 11 passed 69 passed 63 passed 43 passed

test_abac.py and test_authz_resource_arn.py include the tests main's #418 to #421 added. In the full run the three TestBackupAuthorization tests failed on every backend because their message check expected the resource to end the AccessDeniedException text, and main's evaluator now appends the reason ("because no identity-based policy allows ...", "with an explicit deny ..."); with the check loosened to accept a suffix, the class passed 3/3 on all four backends on the same binaries. tests/test_resource_tag_migration.py (main's, SQLite only) passed 2/2 under Python 3.12 once its preview assertion was changed to this PR's contract (exit 3, "run again with --yes to apply"); the host's Python 3.7 sqlite3 module cannot read the catalog's partial index.

Earlier passes in this round found three defects, fixed before the run above: test_first_read_after_active_succeeds raced the CREATING to ACTIVE transition on PostgreSQL (its copy of 60,000 items takes about 1.5 s; the test now accepts a read that succeeds once DescribeTable confirms ACTIVE); and SQLite, MongoDB, and Cassandra answered BackupInUseException where the service answers TableInUseException for a restore of a backup into its own CREATING target's name, because they checked the backup before the name (PostgreSQL checked the name first). All four now check the name at the storage layer before the backup and before a restore slot is taken; storage-level tests on each pin the same-backup case and a missing backup restored into the name, and on PostgreSQL and MongoDB a retry answered at once while every account slot is held. The engine still reads the backup before the storage call, so at the wire a missing backup is BackupNotFoundException whatever the name; the storage order matters when a backup is deleted between the engine's read and the registration, and for a retry. One Cassandra pass ran against a node that had accumulated about 1,640 tables over two days of test runs and was pausing over 80 s per young-generation GC (from the node's own log); four of its five failures were copies or teardowns that timed out, the fifth was the ordering defect. The runs above used a cassandra:4.1 container created the day before them.

The large-backup tests use a 60,000-item on-demand backup of a table with only a partition key and no secondary indexes (secondary indexes are covered by the smaller tests; with GSIs on 60,000 items the fixture kept a backend's GSI queue or backfill worker busy for minutes and slowed other modules' GSI tests):

  • test_restore_response_arrives_before_its_copy_finishes: a client with a 10 s read timeout and no retries gets the CREATING response within the timeout, the target is seen CREATING while it is polled, and it reaches ACTIVE with every item. It shows the response does not wait for the copy; it does not drop the client.

    Measured on an earlier build of this PR:

    Backend Response ACTIVE after
    PostgreSQL 0.031 s 1.52 s
    SQLite 0.017 s 4.67 s
    MongoDB 0.082 s 3.90 s
    Cassandra 0.147 s 10.65 s

    Cassandra's copy writes every item through the ordinary put path (a read and a lightweight-transaction write per item, 8 at a time, with the index writes synchronous); that is why it is several times slower than the others. A restore-only plain-insert path is storage-cassandra: restore copy writes every item through the LWT put path #404.

    On Cassandra the response waits for the target's schema. With five restores at once it took 0.75 to 1.3 s on an earlier build, and inside the full Cassandra pytest suite it once exceeded 1 s, which is why the test's client timeout is 10 s; the check that the copy runs after the response is that the target is still CREATING when polled.

  • test_restore_survives_a_client_that_disconnects_before_the_response: a client with a 1 ms read timeout sends the request and closes the connection before any response. The test accepts either outcome that leaves no half-made state: the restore was registered and finishes whole, or the request was cancelled before registration and a new restore into the same name from the same backup is accepted and finishes whole. It prints which. On all four backends the restore was registered and finished. The engine unit test a_restore_request_dropped_while_the_backup_is_read_still_restores drops the request future while the backup is being read and checks that the restore still runs.

  • test_target_and_backup_are_held_while_the_copy_runs: DeleteBackup (ExtendDB only) and DeleteTable during the copy. The status is read immediately before each probe; a probe on a CREATING target must be refused, and each probe must land in the window. On all four backends both probes were refused while the target was CREATING, and the restore then finished whole.

Other new or changed tests: DescribeBackup feature details from an on-demand source and from a PROVISIONED source with SSE (throughput values; the SSEDescription assertion is ExtendDB only); ListBackups with Limit=1 over three backups (exactly one summary per page and three pages, ExtendDB only; the service's contract allows a cursor on the last page); TableAlreadyExistsException; malformed and foreign BackupArn; empty index overrides, the SSE override, and no SSEDescription without it; CreateBackup of a vector table; no RestoreSummary on UpdateTable of an ACTIVE restored table; (ExtendDB only) restore of a PROVISIONED backup whose GSI has no throughput; ListBackups filtered by BackupType and a time range; index overrides built from DescribeBackup (as the console does) and an equal OnDemandThroughputOverride, both restoring; a BackupArn in another partition; and no DeletionProtectionEnabled in the restore response. The DynamoDB-branch message in test_provisioned_restore_override_requiring_gsi_override_leaves_no_target is the service's reply as measured during review (Invalid Request: Must override ProvisionedThroughput if BillingMode is overridden to PROVISIONED for index by_gpk). tests/test_abac.py::TestBackupResourceTags reproduces the TagResource case from the review in authenticated mode: a user allowed DescribeBackup only on aws:ResourceTag/classification = dev cannot tag the backup and stays denied (main's #419 refuses the tag operations on a backup ARN; the test pins that against this PR's backups). tests/test_ttl.py gains a TTL-with-GSI test: it requires TTL to be enabled on PostgreSQL, SQLite, and MongoDB (passed on each, with the backend given by the new EXTENDDB_TEST_BACKEND that devtools/run-tests --backend exports), and on Cassandra accepts the refusal, which it got at the default delay (ValidationException naming by_gpk and 10 ms). The Cassandra refusal is pinned by the live storage-level test test_ttl_on_a_table_with_an_async_gsi_is_refused_as_a_validation_error (refused at 50 ms; enabled once the GSI is synchronous). test_index_overrides_listing_the_backups_indexes_restore_them runs for a PROVISIONED and an on-demand backup and passed on all four backends. tests/test_ttl.py::test_disable_ttl now waits for ENABLING to end before it disables TTL; it failed once on a loaded PostgreSQL run with "Time to live is being updated".

Checks that only ExtendDB can satisfy, and assertions not measured on the service, are skipped against DynamoDB. The file was not run against DynamoDB in this round.

Against older builds: on the round-2 build (8473dd0e), on PostgreSQL, 15 of 19 earlier tests failed, among them the response test with ReadTimeoutError. On SQLite, the round-3 build before this pass's engine fixes fails the UpdateTable test (RestoreSummary with RestoreInProgress: false in the response) and the GSI-throughput test (ProvisionedThroughput must be specified for index).

Further checks with built binaries, all four backends unless noted, on earlier builds of this branch (not repeated on the final binaries):

  • A SIGKILL mid-restore followed by an immediate restart. The target was removed after 60 s on PostgreSQL, 0.1 s after the restart on SQLite, 60 s on MongoDB, and 62 s on Cassandra. Afterwards DeleteBackup and a new restore into the name succeeded.

  • A writer updating an early item and then a late item during CreateBackup. 0 pairs out of order on PostgreSQL and SQLite; 16 of 66 on MongoDB and 123 of 800 on Cassandra. Neither MongoDB nor Cassandra takes a point-in-time backup; the docs say so.

  • Concurrent restores on PostgreSQL: with four restores running in one account, a fifth in the same account waited for a slot (3.1 s) while another account's restore answered in 0.05 s.

  • Upgrades from origin/main (catalog 0.0.3) with extenddb migrate:

    • PostgreSQL: preview exit 3, --yes exit 0, preview again exit 0. A table left CREATING was removed at startup and logged by name. Rolling back with the manual's parameterized dump and restore brought the 0.0.3 binary back. A catalog in the pre-release 0.0.4 shape was converged by serve and by migrate --yes.
    • SQLite: the rebuild was named in the preview with its row count and logged on apply. Rolling back by restoring the copied files brought the 0.0.3 binary back.
    • Cassandra: on an unmigrated catalog, a 0.0.4 server served every table and item operation tried, and ListBackups and DescribeBackup worked; CreateBackup, RestoreTableFromBackup, and DeleteBackup answered ValidationException "Backups are unavailable until the ExtendDB catalog is migrated: it does not record catalog migration V005..." naming extenddb migrate --yes (re-measured on the final binary; an earlier build returned internal errors). After migrate --yes, all of them worked.

    Backups taken before the upgrade restored afterwards on PostgreSQL, SQLite, and Cassandra.

  • PostgreSQL serve with a debug build of this round's PostgreSQL changes: on a catalog whose ledger names another 003_*.sql at 0.0.4, it exits 1 naming 003_backup_definitions.sql and extenddb migrate --yes; after migrate --yes it starts. On a pre-release 0.0.4 shape with another session holding a transaction on table_restores, it exits 1 after about 6 s with could not lock the table within 5 s; once the lock is free it adds the column, keeps the restore_date_time default, and starts, and migrate then reports only the default step.

  • The migrate error text with no admin credentials and with a wrong password (PostgreSQL), and the SQLite pool_size log line.

The docs mark which statements rest on the code or on storage-level tests rather than on a running server, and which numbers come from an earlier build.

Rust, on the final tree:

  • cargo test --workspace without the PostgreSQL storage crate and the Cassandra live test targets: all passed. extenddb 1, extenddb-app 28, extenddb-auth 219, extenddb-cache 27, extenddb-config 7, extenddb-core 519, extenddb-engine 178, extenddb-server 202, extenddb-storage 125, extenddb-storage-mongodb lib 122 (22 live and model tests ignored), its interpreter_selftests 18 and pushdown_parity 20, extenddb-storage-sqlite 141 (1 ignored); doc-tests 4.
  • Against PostgreSQL 16, cargo test -p extenddb-storage-postgres --tests: lib 53, backup_restore 30, catalog_converge 5, doc_version_literals 3, key_collation 3, migrate_data_db_name 2, put_create_race 9, stream_shard_retention 1, twi_conflict 8, vector_control_plane 42, all passed.
  • Against mongo:7, cargo test -p extenddb-storage-mongodb --lib -- --ignored --skip protocol_model live_: 18 passed under default test parallelism (three runs in a row before the rebase, one after); the protocol model's tests run with the workspace.
  • Against cassandra:4.1, cargo test -p extenddb-storage-cassandra --lib: 61 passed; --test direct_integration: 149 passed. test_restore_sweep_worker_sweeps_at_startup_and_stops_on_shutdown, which this PR adds, missed its 8 s deadline on an earlier head on a node that had run tests for about a day (in the full run and alone); on a freshly created node it passed, and it passed in this head's full run. The sweep it exercises has not changed since then.
  • cargo fmt --all -- --check, cargo +1.97.0 clippy --workspace --all-targets -- -D warnings, and cargo +1.88.0 check --workspace --locked --all-targets: clean. In one workspace run extenddb-cache's stale_hit_serves_cached_and_spawns_refresh, a timing test in a crate this PR does not touch, failed once and then passed five times alone and in the full rerun.

The engine, authorization, and the first round of backend fixes were each checked with a mutant (the fix reverted or broken, the new test run; every mutant was caught); of the later backend fixes, the name-before-backup order on SQLite and MongoDB and the cursor and key-info fixes were mutant-checked, the others were not. The MongoDB wedge was reproduced on the build before its fix (20 restores of 20 distinct backups: 6 answered, 14 timed out, every later request timed out) and the build with the fix answered all 20 in 1.1 s with every target ACTIVE after 6.9 s (both before the later admission change; the storage-level test live_twenty_concurrent_restores_of_distinct_backups_do_not_wedge_the_pool runs in the live suite above); the SQLite backup flood was checked at storage level (16 backups, at most 4 CREATING at once, another account's reads unaffected).

CI-equivalent legs (devtools/run-tests): tests/rust integration passed 512 on PostgreSQL, SQLite, and MongoDB, and the PostgreSQL no-vector leg passed 7, on an earlier build of this branch. The pytest legs (--pytest --comprehensive --parallel) fail exactly the tests that fail on origin/main's binaries on the same host (host Python 3.7 and botocore 1.33): PostgreSQL, SQLite, and MongoDB on the previous build of this branch, and Cassandra on the final one, with 2 workers as CI's 4-core runner gives: 6 failed against main's 6 failed and 1 error (main's extra error is a fixture failure in test_stream_table_reuse.py), plus 4 in tests/python on both. The PR's Cassandra CI job has failed on two runs, on different tests each time. In the first, test_gsi_configured_delay_range saw about 1 s GSI delays; that came from the GSI worker change this revision takes out (#407). In the second, three FGAC tests' teardown DeleteTable returned 500 because their account's keyspace was missing tables: a race on main when two accounts are created at once (#408), which this PR's extra tests made more likely by changing how the parallel suite is split. Until #408 is fixed, that leg can fail intermittently.

Not run: the CLI migration pytest suites (tests/test_cli_*migrat*.py), because psycopg2 is not installed on the test host. tests/test_put_item_create_race.py needs Python 3.8 and was not run on the 3.7 host.

Not changed here

  • A client that disconnects while its RestoreTableFromBackup is still being authenticated or authorized (SigV4 verification and policy evaluation, including the target table's tag reads, run in the request before the engine handler) cancels the request: nothing is created and no error is reported to anyone. From the engine handler on, the request runs in a task of its own. The fidelity suite runs the server in dev mode, where these steps take almost no time, so its 1 ms disconnect test does not exercise this window.
  • backup.rs maps storage Transient and Connection errors to 500 InternalServerError, where the generic mapping (for example in create_table.rs) gives 503 ServiceUnavailable. Both are retried by the SDKs; worth aligning separately.
  • UpdateTable checks the 100 projected NonKeyAttributes total against a description read before, and outside, the backend's lock, so two concurrent GSI Creates can each pass and together exceed it (whether a backend keeps the table ACTIVE while the first index is CREATING was not checked). A GSI still DELETING is also counted.
  • UpdateTable GSI Create refuses ProvisionedThroughput below 1 on a PAY_PER_REQUEST table, while {5, 5} on the same table is still accepted; the "should not be specified when BillingMode is PAY_PER_REQUEST" rule exists only in CreateTable validation. The ">= 1" rule also has three message texts.
  • Error __type namespaces for ValidationException and AccessDeniedException still differ from the service's.
  • Every ListBackups request reads all of the account's (or table's) backups and pages in memory.
  • A GSI override carrying ProvisionedThroughput {0, 0} for an on-demand backup, which is what DescribeBackup reports, is refused (leave the member out), as on the service. Duplicate NonKeyAttributes in an index override are accepted (compared as a set); whether the service refuses them was not measured. A GSI entry's OnDemandThroughput is accepted and dropped: GsiInput has no field for a per-index on-demand maximum, and adding one touches every backend.
  • GlobalSecondaryIndexOverride and LocalSecondaryIndexOverride are accepted only for indexes the backup has, with their key schema and projection unchanged. The reviewer measured the service treating the override list as a redefinition of the restored index set: it accepts and restores an entry naming an index the backup did not have, and one that re-keys an existing index. ExtendDB refuses both with ValidationException ("is not in the backup", "KeySchema differs"), which is narrower than the service but never produces wrong data. The reviewer is tracking the widening, and the error texts that read differently from the service's while the class is right (among them the TableInUseException text for a different backup restored into a CREATING restore target's name), separately from this PR.
  • /health answers 200 as soon as the listener is up; it does not probe the storage backend (extenddb healthcheck documents it as liveness, not readiness). The condition under which it answered 200 while every request hung (the MongoDB wedge) is removed, and the admission bound keeps half the pool free, but a readiness probe should still issue a DescribeTable or ListTables with a client timeout.
  • UpdateTable on a CREATING restore target is ResourceInUseException on all four backends; the service answers ResourceNotFoundException.
  • ListBackups.TableName as a table ARN is refused with the pattern clause while CreateBackup accepts the ARN form; the service's behaviour for ListBackups was not measured.
  • backup_id_epoch_millis reads the time only from the 14-digit id; the 13-digit ids of earlier builds carry no time for the ListBackups cursor placement.
  • Pre-existing, reported in the round-4 review and left for separate PRs: a write racing a DeleteTable can answer 500; a Cassandra GSI backfill can fail with "Batch too large"; the Cassandra GSI workers under serve (storage-cassandra: asynchronous GSI writes are never applied under serve (default 10 ms delay) #407).
  • MongoDB: a restore whose base copy finished but whose index backfill fails on every attempt stays CREATING; the backfill worker retries it every 5 s with a warning and has no give-up, and while it is CREATING DeleteTable refuses the target and DeleteBackup refuses its source backup. Not reproduced (no item was found that makes a backfill batch fail persistently); the remedy is to fix the cause and let the worker finish, or to remove the target's catalog document by hand.
  • MongoDB: a DeleteBackup that returned an error can still take effect later. One that failed after claiming the backup (for example on finding a restore from it CREATING) and whose claim could then not be released, neither in the request nor by the claim guard on drop, is finished by the stale-claim sweep once no restore from the backup is CREATING. If that restore is one that never becomes ACTIVE (the backfill case above), the claim also blocks new restores of the backup. Not reproduced; it needs two consecutive release failures.
  • MongoDB still uses the fixed database names extenddb_catalog and extenddb_data and has no serve lock. The sweeps only act on work whose heartbeat is stale.
  • MongoDB reports BackupSizeBytes 0.
  • Follow-up issues filed from this round: storage-cassandra: cdrs-tokio drops its only connection under sustained load and never reconnects #403 (cdrs-tokio drops its only connection under sustained load and does not reconnect), storage-cassandra: restore copy writes every item through the LWT put path #404 (the Cassandra restore copy goes through the LWT put path), storage-cassandra: asynchronous GSI writes are never applied under serve (default 10 ms delay) #407 (on main, Cassandra serve never applies asynchronous GSI writes, and the default delay is 10 ms, so served GSIs miss every write; CI sets the delay to 0), and storage-cassandra: concurrent account creation puts one account's tables in another's keyspace #408 (on main, concurrent Cassandra account creation can leave an account without its TTL and GSI-queue tables; this is what failed the FGAC tests in this PR's Cassandra CI run). A fix for the GSI worker lifetime was in an earlier revision of this PR and was taken out: reviving the workers exposes defects that belong in storage-cassandra: asynchronous GSI writes are never applied under serve (default 10 ms delay) #407.
  • Cassandra under heavy load: with the fidelity file's default 60,000-item backup, the Cassandra pytest leg lost its only driver connection about 25 minutes in (Unmatched stream id, then No nodes available in query plan! on every request until the end), and 90 tests failed and 314 errored. cdrs-tokio 9.0.2 reuses stream ids after 32,767 requests without checking that the old one has been answered, and closes the connection when a second response arrives for an id; it then did not reconnect to the single node. That reading is from the driver's code and the server log, not a reproduction in isolation. devtools/run-cassandra-tests now sets EXTENDDB_TEST_BACKUP_ITEMS to 10,000 (unless it is already set), which keeps the copy running past the response; the driver behaviour itself is a production risk for a busy server: storage-cassandra: cdrs-tokio drops its only connection under sustained load and never reconnects #403.
  • Cassandra:
    • It has no startup catalog-version gate; an unmigrated server serves tables and items and refuses only the backup operations (above).
    • UpdateTimeToLive returns an internal error when the backend's TTL change is still blocked by an expiration sweep after its retries (IndexesInUse); SDKs retry it.
    • The generated config always says replication_factor = 1 and datacenter = "datacenter1", whatever --replication-factor was given.
    • The control-plane poller, transaction recovery, GSI delay poller, and GSI backfill recovery workers ignore shutdown, so serve logs that background workers did not drain within 5 s.
    • The restore heartbeat shows the process is alive, not that the copy is progressing; a copy that hangs keeps its target CREATING until the process stops.
  • PostgreSQL:
    • Restore is not supported behind a transaction-mode pooler: the restore lock is a session advisory lock. The admin guide says so; docs/design/11-high-availability.md still suggests transaction-mode pooling for large deployments.
    • The startup shape step's wait for the table lock (at most 5 s) still queues later statements on table_restores (every DescribeTable) behind it on every server while it waits.
    • A restore's copy holds one REPEATABLE READ catalog snapshot, and so one catalog pool connection and the catalog's xmin, from the first item read until the last full batch is written.
  • PostgreSQL and SQLite: the "restore target" predicate (CREATING with no scheduled transition) and the fallback table ARN built in SQL are written in several SQL statements per backend rather than in one place.
  • test_backup_arn_shape_and_account_are_checked runs in dev mode, so its texts and its foreign-account AccessDeniedException come from the engine. With authorization on, main's pre-authorization check answers "Invalid Backup ARN" for a BackupArn that does not parse as a backup ARN at all on DescribeBackup and DeleteBackup, before the engine's texts are reached.
  • UpdateContinuousBackups accepts enabling point-in-time recovery and reports a 35-day restorable window, although nothing is recorded. This is pre-existing and now documented.

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace). Not run as one command over every crate: the workspace run without the PostgreSQL storage crate and the Cassandra live targets, and the PostgreSQL, MongoDB, and Cassandra live runs, are listed above and passed. The pytest legs are reported in Testing done.
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic). cargo +1.97.0 clippy --workspace --all-targets -- -D warnings is clean; pedantic was not re-run this round.
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below
  • If this changes the wire protocol, Storage trait, auth model, on-disk format, or public CLI surface, an RFC has been accepted or is linked below. Not met; see the next line.

ADR / RFC: none accepted. This PR changes the Storage trait (BackupEngine) and the on-disk format (catalog 0.0.4, Cassandra V005, SQLite backup_items and table_restores). docs/rfcs/README.md requires an accepted RFC for both, and none exists for these changes. #396 is a design PR, not an RFC: it revises docs/design/15-backup-restore.md for off-host backup and restore, a series in which it lists this PR as PR 0, to merge first. It describes later changes to BackupEngine (moving create, describe, list, delete, and restore into the engine) and does not specify this PR's change to BackupEngine::restore_table_from_backup. An RFC is required before merge.

Breaking changes

  • Restore overrides that were previously ignored are now applied or checked. SSESpecificationOverride, GlobalSecondaryIndexOverride, LocalSecondaryIndexOverride, and OnDemandThroughputOverride are applied as the service applies them (an empty index list restores no indexes of that kind; a subset restores those); an unknown or changed index definition, a throughput member contradicting the billing mode, {0, 0}, and BillingModeOverride=PROVISIONED with a GSI whose throughput no list entry supplies are ValidationException; VectorIndexOverride is a ValidationException. A restore no longer carries TableClass: the target is STANDARD. ListBackups requests that set BackupType SYSTEM or AWS_BACKUP, or a time range, now return only the matching backups.
  • BackupName and TableName on CreateBackup and ListBackups are validated (3 to 255 characters, [a-zA-Z0-9_.-]+; TableName takes the configured table-name limits); a BackupArn or ExclusiveStartBackupArn that is not a well-formed backup ARN is ValidationException where it was BackupNotFoundException; a cursor from another account, region, or table, a time bound before the epoch, and a lower bound above the upper bound are ValidationException. An ARN of the caller's account in another region now resolves (it was BackupNotFoundException); a statement in a policy must allow both spellings (below).
  • CreateBackup of a table that does not exist or is CREATING is TableNotFoundException "Table not found: " (it was ResourceNotFoundException). DescribeBackup answers an internal error when the definition cannot be read (it answered a description without feature details). DeleteBackup returns the full description with BackupStatus DELETED.
  • Restoring into a name that is a CREATING restore target is TableInUseException (it was TableAlreadyExistsException), and a second restore of a backup while a restore from it is CREATING is BackupInUseException on every backend (PostgreSQL, SQLite, and Cassandra accepted it).
  • CreateBackup on SQLite is bounded to pool_size / 2 - 1 at once (4 by default), with LimitExceededException after 30 s. Restores on MongoDB are bounded to 4 per account and min(16, pool/2) per server (5 with the driver's default pool), with the same refusal, and a registration that takes longer than 60 s answers an internal error while the restore continues. UpdateTable on a MongoDB CREATING restore target is ResourceInUseException (it was accepted).
  • CreateTable of a PROVISIONED table with a GSI ProvisionedThroughput below 1 (including 0/0), and UpdateTable GSI Create with one, are now ValidationException. So are a CreateTable with more than 100 projected NonKeyAttributes across its indexes and an UpdateTable GSI Create that would take the table over 100.
  • Restoring into an existing table name is TableAlreadyExistsException, where it was ResourceInUseException.
  • No table description carries RestoreSummary once the table is not CREATING (DescribeTable used to keep it with RestoreInProgress: false, and UpdateTable returned it too). RestoreDateTime is now the backup's creation time, not the time the restore ran.
  • RestoreTableFromBackup returns before the copy. On PostgreSQL and SQLite the table used to be ACTIVE when the response arrived; now it is CREATING until the copy ends, so callers must wait for ACTIVE.
  • On PostgreSQL a server process runs at most half of pool_size restores in total (capped at 16), besides 4 per account; more wait up to 30 s and then get LimitExceededException. extenddb migrate on PostgreSQL migrates the data database init recorded (or --data-db), not one derived from the catalog database name.
  • On MongoDB, a restore of a backup that another restore or a DeleteBackup holds is BackupInUseException, and so is a DeleteBackup of a backup another delete holds, where both were BackupNotFoundException.
  • On MongoDB, a DeleteBackup that returns an internal error or is cancelled after it claimed the backup can still complete: the backup reads AVAILABLE, DeleteBackup and RestoreTableFromBackup return BackupInUseException, and the stale-claim sweep marks it DELETED about 45 to 80 s later (later still if a restore from it is CREATING). Before, such an error released the claim and left the backup available, with no data if the drop had already run. A client that treats an error from DeleteBackup as "not deleted" is now wrong on MongoDB.
  • The backup ARN id is 14-digit zero-padded epoch milliseconds, then 8 hex digits, on every backend. It used to be 13 digits (with or without the hex suffix); those ids still resolve but cannot place a ListBackups cursor once deleted or filtered out. BackupCreationDateTime has millisecond precision.
  • On MongoDB, a connection string with w=0, a numeric or tagged w, or journal=false is refused at startup.
  • The catalog version is now 0.0.4 for PostgreSQL, SQLite, and Cassandra, and extenddb migrate --yes is required before serving. PostgreSQL and SQLite servers refuse an unmigrated catalog, and a PostgreSQL server also refuses a catalog whose ledger lacks one of its catalog migration files, even at the current version, and refuses to start a pre-release 0.0.4 catalog if its startup shape step cannot lock table_restores within 5 s. SQLite serve also refuses a file database in a pre-release 0.0.4 shape until migrate --yes has run. A Cassandra server on an unmigrated catalog serves tables and items, but CreateBackup, RestoreTableFromBackup, and DeleteBackup fail until it is migrated. A stored catalog version that cannot be parsed is now refused rather than overwritten.
  • The Storage trait changed. BackupEngine::restore_table_from_backup takes a resolved RestoreRequest, backup_restore_source is new, and restore_table_to_point_in_time is removed. BackupSummary gains table_id.
  • The extenddb migrate preview without --yes exits 3 when work is pending; it used to exit 1. It exits 0 when everything is up to date.

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

@robinnsc robinnsc changed the title fix(postgres): back up and restore tables with a sort key fix(backup): restore sort keys, indexes, and throughput; bound memory; recover abandoned restores Oct 5, 2026
robinnsc added a commit that referenced this pull request Oct 6, 2026
Nothing stopped two `extenddb serve` processes from opening the same SQLite
file. The backend serializes writers with a lock inside the server process,
so two servers write concurrently and can hit SQLITE_BUSY mid-transaction;
and startup recovery (control-plane transitions, GSI and vector index
rebuilds, and, with #384, abandoned restores) assumes no other server is
running, so a second server starting up would undo the first one's work in
progress. Nothing stopped `destroy` from unlinking the file under a running
server either, which left that server serving an unlinked inode while a
later `init` created a fresh database at the same path.

`extenddb serve` on a file database now takes an exclusive flock(2) on
`<database>.lock` before opening the database and holds it for the life of
the process (the lock lives in the engine, so it is held while any clone
is). `init` and `migrate` take the same lock for their duration through the
bootstrapper's migration-lock hook, and `destroy` takes it before it
removes the file; each refuses, rather than waits, when a server holds it.
Read-only commands (`settings`, `manage`, `verify`, `status`) take no lock.
A second holder fails with:

  another extenddb process is already using <db> (lock held on <db>.lock);
  stop it first, or point this command at a different database

The lock file is named after the file SQLite actually opens, not the
configured string: the location is parsed with sqlx's own
SqliteConnectOptions (so `sqlite:` URL forms, percent-encoding such as
`a%20b.sqlite`, and `file:` URIs resolve as the engine resolves them), and
the path is canonicalized, so two spellings of one file, a `..` path, or a
symlink and its target all share one lock. A disk file whose name happens
to contain `mode=memory` is a file. In-memory databases take no lock.

The kernel releases the lock when the process exits, including on kill -9,
so there is no stale-lock cleanup. On non-Unix platforms no lock is taken
and a warning is logged. flock is advisory and its behaviour on network
filesystems depends on the server and mount; the docs say to keep SQLite
databases on local disk. The directory holding the database must be
writable so the lock file can be created.

flock through libc rather than std's File::try_lock, which needs Rust 1.89;
the workspace MSRV is 1.88. libc is already a dependency of the workspace
and listed in every license notices file.

Docs: troubleshooting entry for the error with its scope; README and the
deployment and design guides now state that one instance per catalog is
the supported deployment, that SQLite enforces it, and that PostgreSQL and
MongoDB do not (per-instance caches without cross-instance invalidation,
every worker on every instance, a liveness-only /health). The deployment
guide previously said multiple PostgreSQL instances were consistent because
there was no in-process cache, which was not true.

Tests: database_file resolution for plain paths, sqlite: URLs,
percent-encoding, file: URIs, in-memory forms, and a disk file named like
a memory parameter; ServeLock refused on the same file, independent across
files, shared across `..` spellings and across file and directory symlinks;
the server factory refusing a second server on one file; destroy and
migrate refused while a server holds the file and a server refused while
migrate holds it.

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
@robinnsc
robinnsc force-pushed the fix/backup-restore-fidelity branch 3 times, most recently from 13c7f24 to dc980eb Compare October 6, 2026 19:43

@yesyayen yesyayen left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking on two things, both inline:

  1. the Postgres CreateBackup barrier doesn't pin the snapshot the way the code and docs say it does, and
  2. SQLite CreateBackup now holds the write lock for the whole read, which is a server-wide write stall on a big table. The rest are non-blocking.

.execute(&mut *snapshot)
.await
.map_err(db_err)?;
meta.commit().await.map_err(db_err)?;

@yesyayen yesyayen Oct 7, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. This barrier doesn't hold. LOCK TABLE is a utility statement and doesn't take a snapshot in Postgres. The REPEATABLE READ snapshot is taken by the first SELECT, which here runs after meta.commit() has already released the FOR SHARE row. So the definition is captured at one instant and the items at a later one, with UpdateTable free to commit in between. I checked this on PG 15: BEGIN ... REPEATABLE READ; LOCK TABLE t ...; then an insert from another session, then SELECT count(*) sees the new row.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah good catch, swapped that to BEGIN RR RO; LOCK TABLE … ACCESS SHARE; SELECT 1 FROM <data> LIMIT 1; and only then releases the FOR SHARE row

Comment thread crates/storage-sqlite/src/backup.rs Outdated
// instant, including against a concurrent UpdateTable. Items are
// read in rowid batches on the same connection, so memory is
// bounded by the batch size.
let _writer = self.write_lock.lock().await;

@yesyayen yesyayen Oct 7, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. This now holds the engine write lock for the whole read and write of the table. On main the item read ran before the lock, only the inserts were under it. The pool is WAL mode, so a deferred read transaction on a second connection already gives a stable snapshot without blocking writers.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIxed, the lock is now held only to read the definition, insert the backup row as CREATING, and open the deferred read transaction (snapshot fixed under the lock, so definition and items agree), items stream unlocked and are written in batches under short holds

Comment thread crates/engine/src/backup.rs Outdated
"SSESpecificationOverride",
];

fn validate_restore_table_overrides(body: &Value) -> Result<(), DynamoDbError> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that the definition is in hand, BillingModeOverride and ProvisionedThroughputOverride are a few lines on CreateTableInput. Refusing them is a wire regression for clients that pass them (the console does). I'd implement at least those two and refuse only the index and SSE overrides.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread docs/differences-from-dynamodb.md Outdated
| `RestoreTableFromBackup` overrides | `BillingModeOverride`, `GlobalSecondaryIndexOverride`, `LocalSecondaryIndexOverride`, `ProvisionedThroughputOverride`, `SSESpecificationOverride` apply to the restored table | Not supported; a request carrying any of them returns `ValidationException` rather than restoring a table that differs from what was asked for |
| Multi-part base table keys (preview) | Not supported | A backup of a table created with `enable_multipart_keys` is refused at restore with `ValidationException`; the item paths address such tables by their first HASH and RANGE attribute only |
| `DeleteBackup` during a restore from that backup | `BackupInUseException` | Matches: `BackupInUseException` (HTTP 400) until the restore completes or fails |
| `DeleteTable` on a table being restored | `ResourceInUseException` | Matches on a restore target. An ordinary table in `CREATING` can still be deleted during its control-plane delay, where the service refuses |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says "Matches on a restore target" without a backend qualifier, but the MongoDB diff only adds RestoreSummary and the DeleteBackup refusal. DeleteTable during a Mongo restore is not refused, and Mongo has no abandoned-restore sweep. Either qualify these rows or add the behaviour.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Qualified every row by backend, and implemented the Mongo DeleteTable, and clarified that Mongo still has no abandoned restore sweep

## Version History

### Catalog 0.0.3 (Current)
### Catalog 0.0.4 (Current)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a sentence here: any Postgres target the old bug left in CREATING matches the sweep predicate and gets removed on the first control-plane pass after upgrade. Right outcome, but operators will see tables disappear from ListTables and should know why.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

Comment thread crates/storage-sqlite/src/backup.rs Outdated
}
let rows_sql = format!(
"SELECT rowid, item_data FROM {table} \
WHERE {cond} AND rowid > ? AND rowid <= ? ORDER BY rowid"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The restore cursor is the implicit rowid of backup_items, read across separately committed batches. backup_items has no INTEGER PRIMARY KEY, so a VACUUM between batches can renumber rowids and the restore skips or duplicates rows. Nothing in-tree runs VACUUM and #391 keeps a second extenddb out, but not a sqlite3 CLI. Give backup_items an INTEGER PRIMARY KEY or cursor on something stable.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, backup_items has id INTEGER PRIMARY KEY and the restore cursors on it. An existing table is rebuilt in place by migrate, and the fixture test now checks id is present with rows in order.

// This session sits idle for the whole copy. A server-side
// idle_session_timeout would end it and release the lock while the
// restore is still running, so it is disabled for this session only.
sqlx::query("SET idle_session_timeout = 0")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this SET fails (managed instance that rejects it), every restore becomes a 500. Make it best-effort with a warning.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, now best-effort with a warning

// mid-copy leaves its target CREATING with no scheduled transition,
// which nothing above would ever move on. Logged and skipped on
// failure, so it cannot hold up the transitions above.
match self.sweep_abandoned_restores().await {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pass is notify-driven with a 60 s idle timeout, so an abandoned target is removed between 60 and ~120 s, not "after 60 s". Each pass also opens a fresh out-of-pool connection per still-locked candidate. Fine, but say so in the comment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected the comment

.map_err(|e| StorageError::Internal(format!("Database error: {e}")))?;
.map_err(db_err)?;
sqlx::query(&format!(
"LOCK TABLE {} IN ACCESS SHARE MODE",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This lock is now held across the read and the interleaved catalog writes. A DeleteTable issued during the backup reaches the control-plane worker, whose DROP TABLE blocks behind it and stalls the whole worker pass until the backup finishes. Main had the same shape for the read only. Either SET LOCAL lock_timeout in the worker's drop or document it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with using SET LOCAL lock_timeout

Comment thread crates/storage-sqlite/src/backup.rs Outdated
.unwrap_or(i64::MAX);
sqlx::query(
"INSERT INTO backup_items (backup_arn, pk, sk, item_data) \
VALUES (?, '', NULL, ?)",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SQLite stores pk = '' while Postgres stores the real pk. Harmless today but the #348 migration-from-rows will want it. Also COPY_BATCH_ITEMS is imported but this file uses its own COPY_BATCH.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swapped to storing the Real pk

robinnsc added a commit that referenced this pull request Oct 7, 2026
Nothing stopped two `extenddb serve` processes from opening the same SQLite
file. The backend serializes writers with a lock inside the server process,
so two servers write concurrently and can hit SQLITE_BUSY mid-transaction;
and startup recovery (control-plane transitions, GSI and vector index
rebuilds, and, with #384, abandoned restores) assumes no other server is
running, so a second server starting up would undo the first one's work in
progress. Nothing stopped `destroy` from unlinking the file under a running
server either, which left that server serving an unlinked inode while a
later `init` created a fresh database at the same path.

`extenddb serve` on a file database now takes an exclusive flock(2) on
`<database>.lock` before opening the database and holds it for the life of
the process (the lock lives in the engine, so it is held while any clone
is). `init` and `migrate` take the same lock for their duration through the
bootstrapper's migration-lock hook, and `destroy` takes it before it
removes the file; each refuses, rather than waits, when a server holds it.
`init` holds it through the encryption key, default account, and admin
user, not only the schema, so a server cannot start in the window where
the schema exists but the key does not. Read-only commands (`settings`,
`manage`, `verify`, `status`) take no lock. A second holder fails with:

  another extenddb process is already using <db> (lock held on <db>.lock);
  stop it first, or point this command at a different database

The lock file is named after the file SQLite actually opens, not the
configured string: the location is parsed with sqlx's own
SqliteConnectOptions (so `sqlite:` URL forms, percent-encoding such as
`a%20b.sqlite`, and `file:` URIs resolve as the engine resolves them), and
the path is canonicalized, so two spellings of one file, a `..` path, or a
symlink and its target all share one lock. A dangling symlink (the target
not created yet) is followed by hand, since canonicalize refuses it. A disk
file whose name happens to contain `mode=memory` is a file. In-memory
databases take no lock.

The lock file is created 0600 and an existing one is tightened to 0600 on
open, matching the database and its sidecars: flock needs only a read
handle, so a world-readable lock file would let any local user hold the
lock and keep the server from starting.

The kernel releases the lock when the process exits, including on kill -9,
so there is no stale-lock cleanup. On non-Unix platforms no lock is taken
and a warning is logged. flock is advisory and its behaviour on network
filesystems depends on the server and mount; the docs say to keep SQLite
databases on local disk. The directory holding the database must be
writable so the lock file can be created.

flock through libc rather than std's File::try_lock, which needs Rust 1.89;
the workspace MSRV is 1.88. libc is already a dependency of the workspace
and listed in every license notices file.

Docs: troubleshooting entry for the error with its scope; README and the
deployment and design guides now state that one instance per catalog is
the supported deployment, that SQLite enforces it, and that PostgreSQL and
MongoDB do not (per-instance caches without cross-instance invalidation,
every worker on every instance, a liveness-only /health). The deployment
guide previously said multiple PostgreSQL instances were consistent because
there was no in-process cache, which was not true.

Tests: database_file resolution for plain paths, sqlite: URLs,
percent-encoding, file: URIs, in-memory forms, and a disk file named like
a memory parameter; ServeLock refused on the same file, independent across
files, shared across `..` spellings, across file and directory symlinks,
and across a dangling symlink and its future target (one and two links);
the lock file created 0600 and an existing 0644 one tightened; the server
factory refusing a second server on one file; destroy and migrate refused
while a server holds the file and a server refused while migrate holds it.

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
yesyayen pushed a commit to yesyayen/extenddb that referenced this pull request Oct 8, 2026
Nothing stopped two `extenddb serve` processes from opening the same SQLite
file. The backend serializes writers with a lock inside the server process,
so two servers write concurrently and can hit SQLITE_BUSY mid-transaction;
and startup recovery (control-plane transitions, GSI and vector index
rebuilds, and, with ExtendDB#384, abandoned restores) assumes no other server is
running, so a second server starting up would undo the first one's work in
progress. Nothing stopped `destroy` from unlinking the file under a running
server either, which left that server serving an unlinked inode while a
later `init` created a fresh database at the same path.

`extenddb serve` on a file database now takes an exclusive flock(2) on
`<database>.lock` before opening the database and holds it for the life of
the process (the lock lives in the engine, so it is held while any clone
is). `init` and `migrate` take the same lock for their duration through the
bootstrapper's migration-lock hook, and `destroy` takes it before it
removes the file; each refuses, rather than waits, when a server holds it.
`init` holds it through the encryption key, default account, and admin
user, not only the schema, so a server cannot start in the window where
the schema exists but the key does not. Read-only commands (`settings`,
`manage`, `verify`, `status`) take no lock. A second holder fails with:

  another extenddb process is already using <db> (lock held on <db>.lock);
  stop it first, or point this command at a different database

The lock file is named after the file SQLite actually opens, not the
configured string: the location is parsed with sqlx's own
SqliteConnectOptions (so `sqlite:` URL forms, percent-encoding such as
`a%20b.sqlite`, and `file:` URIs resolve as the engine resolves them), and
the path is canonicalized, so two spellings of one file, a `..` path, or a
symlink and its target all share one lock. A dangling symlink (the target
not created yet) is followed by hand, since canonicalize refuses it. A disk
file whose name happens to contain `mode=memory` is a file. In-memory
databases take no lock.

The lock file is created 0600 and an existing one is tightened to 0600 on
open, matching the database and its sidecars: flock needs only a read
handle, so a world-readable lock file would let any local user hold the
lock and keep the server from starting.

The kernel releases the lock when the process exits, including on kill -9,
so there is no stale-lock cleanup. On non-Unix platforms no lock is taken
and a warning is logged. flock is advisory and its behaviour on network
filesystems depends on the server and mount; the docs say to keep SQLite
databases on local disk. The directory holding the database must be
writable so the lock file can be created.

flock through libc rather than std's File::try_lock, which needs Rust 1.89;
the workspace MSRV is 1.88. libc is already a dependency of the workspace
and listed in every license notices file.

Docs: troubleshooting entry for the error with its scope; README and the
deployment and design guides now state that one instance per catalog is
the supported deployment, that SQLite enforces it, and that PostgreSQL and
MongoDB do not (per-instance caches without cross-instance invalidation,
every worker on every instance, a liveness-only /health). The deployment
guide previously said multiple PostgreSQL instances were consistent because
there was no in-process cache, which was not true.

Tests: database_file resolution for plain paths, sqlite: URLs,
percent-encoding, file: URIs, in-memory forms, and a disk file named like
a memory parameter; ServeLock refused on the same file, independent across
files, shared across `..` spellings, across file and directory symlinks,
and across a dangling symlink and its future target (one and two links);
the lock file created 0600 and an existing 0644 one tightened; the server
factory refusing a second server on one file; destroy and migrate refused
while a server holds the file and a server refused while migrate holds it.

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
@robinnsc
robinnsc force-pushed the fix/backup-restore-fidelity branch from dc980eb to 8f83169 Compare October 9, 2026 06:26
@robinnsc
robinnsc requested a review from yesyayen October 9, 2026 22:30
@robinnsc
robinnsc force-pushed the fix/backup-restore-fidelity branch from 8f83169 to ba9f869 Compare October 12, 2026 18:48
@robinnsc

Copy link
Copy Markdown
Collaborator Author

#395 (SnapshotEngine) is branched from this PR and depends on it; its diff shows this PR's commit until this merges.

robinnsc added a commit that referenced this pull request Oct 13, 2026
…t code

Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture, and two review rounds against the companion PRs #393,
#394 and #395. The revision log at the end of the document lists each
change; the ones that alter behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). The items-only signature in
  the draft allowed a GSI change or DeleteTable between the definition
  read and the data snapshot. Indexes are reported in whatever build
  state they had and only an index being deleted is dropped; an
  ACTIVE-only filter would have left a GSI mid-backfill out of the backup
  for good.
- The snapshot is opened in the CreateBackup request so the backup
  represents the request time, as DynamoDB documents;
  backup_creation_date_time is the instant the snapshot pinned.
- Per-job claims with fencing replace the single-instance lease the
  draft assumed; PostgreSQL and MongoDB have no such lease. The claim is
  an advisory lock on a dedicated catalog connection on PostgreSQL (the
  seed #384's restore lock already uses), the row itself on MongoDB, an
  in-process slot on SQLite; no row ever exists unowned. Heartbeats are a
  timer, staleness is measured on the database's clock, and a failed
  heartbeat aborts the job. The reconciler resolves a dead CREATING row
  at once under a fresh generation conditioned on status = CREATING and
  deletes its prefix only after the stale window.
- DeleteBackup tombstones the row as DELETING, deletes the manifest
  first, and marks DELETED only when the prefix is empty; backup sync is
  scoped to the configured store and never adopts a CREATING, DELETING,
  DELETED or FAILED prefix. Failed creations and deleted backups are
  BackupNotFound, as on the service, and share one retention.
- Restore returns CREATING from the request (table and restore_jobs row
  created atomically; ResourceInUseException on a name race,
  LimitExceededException over the job limit), spools and verifies every
  data file before writing any item, and records progress in a way a
  crash cannot split from the data: a data-database row per file on
  PostgreSQL (data migration 005 reserved), the same transaction per
  10k-item batch on SQLite, idempotent re-application of one file on
  MongoDB followed by a document-count check against the manifest.
  Duplicate detection for unordered backups is the target's primary key,
  not an in-memory key set. The ACTIVE flip is conditional; DeleteTable
  on a restore target fails the job.
- The manifest self-checksum is replaced by a sidecar over the stored
  bytes; the draft's canonical form depended on serde_json formatting.
  backup_arn is the backup's identity and source_table must agree with
  it. N key values are normalized before key text or key order is
  computed; the SQL ORDER BY recipe includes COLLATE "C" on PostgreSQL.
- Index population on restore stays per backend via bulk_load; the
  shared restore-backfill worker the draft described exists only on
  MongoDB.
- Format additions: item_order, per-file key_range, md5 and
  uncompressed_bytes, explicit nulls, three-state vector_indexes of
  creation specs, optional etag, dataFileS3Key as the full object key,
  partition-aware BackupArn as the single ARN implementation.
- Filesystem store: owner-only root re-checked by every operation,
  owner-only rule on every descended directory including list and
  delete_prefix, ancestor ownership rule, objects 0600 and directories
  0700 at creation, put returns object metadata; the consequence for
  backup pickup agents is stated. The S3 conformance suite is mandatory
  in CI against moto (MinIO no longer publishes images).
- ListBackups paging and filtering, the restore override contract,
  sqlite-memory behavior, extenddb destroy leaving the store intact, a
  restore spool outside the store root, max_concurrent_jobs,
  heartbeat_interval_secs and a concrete pool-size rule are specified.
- Phase 1 is rewritten as the PR series with acceptance criteria and
  GitHub numbers. Migration numbers are reserved up front so the PITR PR
  is not on the critical path.

Co-authored-by: Lee Hannigan <lhnng@amazon.com>

@LeeroyHannigan LeeroyHannigan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. The storage engineering under this PR held under reproduction: both of yesyayen's blocking items are fixed and each fix fails its test when reverted; restore atomicity against DeleteBackup held on PostgreSQL in both orders and across 100 race iterations on MongoDB; the migration, the sweep predicate, the backup_items cursor, the downgrade gate, and crash cleanup all behaved as described; cross-account access to backups is refused on every backend under every ARN variation tried; the restored data matched DynamoDB item for item on every index and projection. The problems are in the surface customers and operators touch, and four of them block the merge.

Blocking

1. The merged tree does not compile. #338 landed the Cassandra backend on main this morning with the pre-PR four-argument restore_table_from_backup. cargo check --workspace --all-targets on the merge of origin/main and ba9f869 fails in one crate, extenddb-storage-cassandra, E0050 (4 parameters, trait has 5). GitHub reports MERGEABLE because no file overlaps. Adding the parameter alone leaves three defects: the Cassandra restore hardcodes global_secondary_indexes: None and local_secondary_indexes: None, ignores both overrides, and never calls ensure_single_part_base_key or ensure_restorable, because those refusals live in each SQL storage backend rather than in the engine. Either port the definition, the overrides, and both refusals into Cassandra, or add a Cassandra row to docs/differences-from-dynamodb.md saying what its restore does not honour. In both cases move the two refusals into crates/engine/src/backup.rs so a backend cannot skip them.

2. RestoreTableFromBackup copies every item inside the request on PostgreSQL and SQLite, and a client timeout cancels it. Both backends await copy_backup_items and only then return CREATING for a table that is already ACTIVE (the comment at backup_engine.rs:1282 says so). Reproduced with 40,000 items and two GSIs, client read timeout 3 s, no retries. PostgreSQL: the target sat CREATING with zero items, then the sweep removed it 64 s after the disconnect; the restore was lost. SQLite: the target is still CREATING four minutes later, a retry into the same name returns ResourceInUseException, DeleteTable on it returns ResourceInUseException ("Table is being restored"), and DeleteBackup on the source returns BackupInUseException. The customer is locked out of the name and the backup until the process restarts. boto3 and the CLI default to a 60 s read timeout, and with one INSERT per item per index over a network a few hundred thousand items cross it, so large restores cannot succeed with stock clients. SQLite CreateBackup in this PR already detaches its copy with tokio::spawn for this reason (backup.rs:799). Do the same here on both backends: commit the CREATING target and its table_restores row, spawn the copy, return at once. Add a live test that drops the client mid-restore and asserts ACTIVE with the full count. This also makes the DeleteBackup and DeleteTable refusals observable the way the docs describe them.

3. DescribeBackup still returns the pre-PR subset. Against DynamoDB, a backup of a table with a GSI, an LSI, and TTL enabled returns SourceTableFeatureDetails with GlobalSecondaryIndexes, LocalSecondaryIndexes, and TimeToLiveDescription, and SourceTableDetails.ProvisionedThroughput ({0, 0} for on-demand; the member is required in the shape). ExtendDB omits all of them on all three backends, although backup_definitions now holds the data. A customer auditing a backup is told it has no indexes; a strongly typed SDK can fail to deserialize the response. Populate both from the stored definition.

4. The PR body and the docs state behaviour the build does not have. Checked on servers built from ba9f869: "DeleteTable on a table that is being restored returns ResourceInUseException on all three backends" (only MongoDB; see item 2); "SQLite similarly exposes CREATING while it copies" (the caller blocks until AVAILABLE; only a concurrent observer sees CREATING); "a backup is never stuck undeletable" (false after a MongoDB crash during $out, and after item 2 on SQLite); "backups of tables with vector indexes are refused" (only the restore is refused; CreateBackup returns an AVAILABLE backup). Every claim in the body and in differences-from-dynamodb.md needs a live check per backend before the next revision.

Wire behaviour, measured against DynamoDB

Each of these was run on the service and on all three ExtendDB backends.

  • RestoreSummary.RestoreDateTime is the restore wall clock. The service reports the source backup's creation time (measured: RestoreDateTime 1791976431.438 equal to BackupCreationDateTime, restore issued 74 s later). A client reading it to learn which point in time was restored gets the wrong instant.
  • The service drops RestoreSummary from DescribeTable once the table is ACTIVE (absent at ACTIVE and 30 s later). ExtendDB keeps it with RestoreInProgress: false, and tests/test_backup_restore_fidelity.py:252-254 pins that.
  • RestoreSummary.SourceTableArn is never emitted; the service sets it.
  • The CREATING restore response carries GSI IndexStatus: ACTIVE; the service omits the member.
  • A malformed BackupArn returns AccessDeniedException; the service returns ValidationException.
  • Restoring into an existing table name returns ResourceInUseException; the service returns TableAlreadyExistsException.
  • An empty GlobalSecondaryIndexOverride or LocalSecondaryIndexOverride list and an SSESpecificationOverride are refused; the service accepts them as no-ops. The blanket refusal is documented, the no-op case is not.
  • The PROVISIONED-with-GSIs refusal tells the caller to pass GlobalSecondaryIndexOverride, which ExtendDB then refuses. The fidelity test pins that text at line 649.
  • ListBackups ignores Limit and never returns LastEvaluatedBackupArn; every row comes back in one response. The service caps at 100 with a token.

PostgreSQL

  • The restore's ownership is a session advisory lock on a dedicated connection that issues no statement for the whole copy. SET idle_session_timeout = 0 is best-effort and the comment at backup_engine.rs:118-123 says managed services may reject it. On RDS, Aurora, or behind a transaction-mode pooler the idle session can end while the copy continues; after 60 s the sweep flips the target to DELETING and the restore fails with "deleted while it was being restored". Heartbeat the lock session, or gate the sweep on a liveness signal that does not depend on an idle connection. Item 2 does not remove this; a long detached copy has the same exposure.
  • "Bounded memory" bounds the Rust buffer. The copy is one PostgreSQL transaction (begin at line 325, one commit after the loop), and secondary-index rows are written one INSERT per item per index in the nested loop at line 464 while base rows are batched. A million-item table with four GSIs is one transaction with four million round trips, WAL held throughout, target pinned CREATING throughout.
  • A failure to open the lock connection (max_connections reached) maps to an Internal error. Map it to LimitExceeded so clients back off.
  • restore_table_to_point_in_time (lines 1393-1420) is unreachable, uses a fixed backup name, and discards its cleanup error. Delete it.

SQLite and MongoDB

  • CreateBackup on a table with a vector index returns an AVAILABLE backup that RestoreTableFromBackup always refuses. The customer finds out at restore time. Refuse at CreateBackup with the same message.
  • MongoDB: a crash during the base $out copy, before restore_backfill_pending is set, leaves a CREATING target that nothing sweeps or resumes. DeleteTable refuses it and DeleteBackup of the source returns BackupInUse until the catalog is edited by hand. Crashes after $out self-heal. Add a sweep for CREATING targets carrying restore_source_backup_arn with no pending flag and no live operation, or let DeleteTable remove them.
  • MongoDB ignores --data-db and --catalog-db and has no serve lock, so two processes on one replica set share one catalog, and sweep_stale_backup_claims at startup clears the other process's in-flight markers. Pre-existing, but the sweep makes it matter.
  • pool_size = 1 is raised to 2 for a file-backed database with no log line and no mention in the config docs. A file-backed backup also pins one pooled connection for its duration, so pool_size >= 3 is wanted for undisturbed concurrency; say so.
  • BackupMetadataClaim's Drop release only runs when a runtime handle is current; a comment should say the startup sweep is the backstop.
  • The MongoDB backup is not a point-in-time snapshot ($out reads the live collection). Line 93 says so in the general consistency row; the Backup and Restore section should state the per-item mixed-state consequence where an operator choosing MongoDB will read it.

Tenancy and validation

  • BackupTableDefinition::apply_to applies the stored definition without validate_create_table. A definition with 30 GSIs, a GSI on an undefined attribute, 101 projected attributes, or negative throughput restores to an ACTIVE table that CreateTable would reject. Triggering it needs catalog write access; it is still wrong data stored, and the service re-validates.
  • RESTORE_SLOTS is a process-global semaphore of four. One account can hold all four and every other account's restores wait 30 s and get LimitExceededException. Make the limit per account.
  • converge_catalog_version and refuse_newer_catalog treat a stored catalog_version the binary cannot parse as "proceed" and stamp the compiled version over it (older_or_equal_or_absent_is_allowed asserts Some("garbage") is allowed). That defeats the downgrade guard in the one case it exists for. Fail closed.
  • Pre-existing, outside this diff: RestoreTableFromBackup, DescribeBackup, and DeleteBackup are authorized against arn:...:table/* because extract_table_name reads only TableName (crates/server/src/request_helpers.rs:42-46, 289-294; the server log shows resource="...:table/*" on a denial). A policy scoped to a target table or a backup ARN can never match. The service authorizes on the backup ARN and the target table. This PR makes restore a first-class feature on top of that gap; fix it here or file an issue; either way it needs recording.

Upgrade path and docs

Walked literally from docs/manuals/07-upgrade-manual.md on a 0.0.3 deployment created by the 0.1.13 binary.

  • extenddb migrate --config extenddb.toml as written fails: it needs PostgreSQL admin credentials the manual never mentions, and the error is Error: Internal("Internal(\"Cannot connect as admin: error returned from database: password authentication failed for user \"lhnng\"\")"). Document --pg-user and EXTENDDB_PG_PASSWORD in the command, flatten the nested error, and say what to pass.
  • The manual says to run migrate without --yes first to see what is pending; the tool prints the plan and then Error: --yes is required to apply migrations with exit status 1, so a scripted upgrade under set -e stops at the preview. Exit 0 for the preview, or a documented distinct code.
  • On SQLite the upgrade rebuilds backup_items by copying every row into a new table in one write transaction (schema.rs:494-553). No manual mentions it, the dry run does not name it, and the function logs nothing. Document it, name it in the dry run when the old shape is present, and log start and finish with the row count.
  • The sweep that removes targets the old bug left in CREATING logs nothing. Operators will see tables vanish from ListTables with no server record of why. Log each removal by name.
  • The manual's backup and rollback commands hardcode extenddb_catalog, extenddb, and OWNER extenddb; init lets the operator choose all three. There is no SQLite subsection (stop, copy the file, migrate, roll back by restoring the copy).
  • 0.0.4 and migration 003 are also claimed by #368 and #335. The migration ledger keys on filename and the gate compares version numbers, so if #368 merges first and stamps 0.0.4, this binary applies its own 003 and converges with no refusal: two schemas named 0.0.4. Whoever merges second renumbers to 004 and 0.0.5 (and the two assertions in migrations.rs tests, and the manual's 0.0.4 section). The order should be agreed now.
  • differences-from-dynamodb.md names three backends; the merged tree ships four.
  • The Storage trait and the on-disk format changed. CONTRIBUTING.md and docs/rfcs/README.md require an accepted RFC for both; the PR links #348, a design PR, not a numbered accepted RFC.
  • The PR template asks for a ## Breaking changes section; the changes are spread through the prose, and the one customers will hit first (overrides that were silently ignored are now hard errors) deserves its own line.

Minor

Backup ARN timestamp is 13 digits where the service zero-pads to 14; BackupCreationDateTime is whole seconds where the service is sub-second; the restore response adds LastUpdateToPayPerRequestDateTime, which the service omits; ListBackups summaries omit TableId; error __type namespace differs for ValidationException and AccessDeniedException; with two invalid inputs the service checks the ARN account before the target name.

Comment thread crates/storage/src/lib.rs Outdated
account_id: &str,
target_table_name: &str,
backup_arn: &str,
overrides: RestoreTableOverrides,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

main now has crates/storage-cassandra/src/backup_engine.rs (#338, merged this morning) with the old four-argument signature, so the merge fails to compile: cargo check --workspace --all-targets on origin/main plus this branch gives E0050 in extenddb-storage-cassandra and nothing else. Adding the parameter there leaves three defects: the Cassandra restore hardcodes global_secondary_indexes: None and local_secondary_indexes: None, ignores the overrides, and never calls ensure_single_part_base_key or ensure_restorable, which this PR enforces inside the PostgreSQL and SQLite backends rather than in the engine. Port the definition and both refusals into Cassandra, or document in the differences matrix what its restore does not honour, and move the two refusals into the engine so a backend cannot skip them.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The trait now takes a resolved RestoreRequest. The engine runs ensure_restorable, restore_input and validate_create_table for every backend, and Cassandra restores GSIs, LSIs and the overrides through its V005 migration. cargo check --workspace --all-targets is clean on the merge.

// CREATING with no scheduled transition, so nothing else would
// move it on, and the name would stay taken. Deleting the target
// cascades its table_restores row.
let copied = self.copy_backup_items(&desc, &backup_arn).await;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The copy is awaited inside the request future, so the response at line 1282 says CREATING for a table that is already ACTIVE, and a client that times out drops the future mid-copy. Reproduced with 40,000 items and two GSIs, read timeout 3 s, no retries: the target sat CREATING with zero items and the sweep removed it 64 s after the disconnect. The restore was lost. boto3 and the CLI default to 60 s, so any restore whose copy takes longer cannot succeed with a stock client. Commit the CREATING target and its table_restores row, spawn the copy, and return at once, the way SQLite CreateBackup does at backup.rs:799. Add a live test that drops the client and asserts the target reaches ACTIVE with the full count.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Registration and copy run in a spawned task; the request waits only for the CREATING description. Once the request is authenticated and authorized, the whole handler (backup read, validation, registration) also runs in its own task, so a disconnect does not cancel it part-way. Live tests: a 1 ms-read-timeout disconnect and the copy window (DeleteTable/DeleteBackup refused while CREATING) on all four backends, plus storage-level tests that drop the request future.

Comment thread crates/storage-sqlite/src/backup.rs Outdated
// last one; until then the target is CREATING and refuses every
// data-plane request. A failure part-way is cleaned up here, a
// crash part-way by the startup sweep.
let copied = self.copy_backup_items(&desc, &backup_arn).await;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SQLite path has the same shape. With the client gone after 2 s on a 40,000-item restore, the target is still CREATING with RestoreInProgress: true four minutes later; a retry into the same name returns ResourceInUseException, DeleteTable on the target returns ResourceInUseException ("Table is being restored"), and DeleteBackup on the source returns BackupInUseException. SQLite sweeps abandoned restores only at startup, so the name and the backup are locked until the process restarts. create_backup at line 799 already detaches its copy with tokio::spawn and the comment there names this hazard; the restore path needs the same treatment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the same way as PostgreSQL. serve also now refuses a file database left in a pre-release 0.0.4 shape and points to extenddb migrate --yes.

// restore is still running, so disable it for this session when the
// server permits. Some managed PostgreSQL services reject this SET;
// ownership still works there, but remains subject to their idle timeout.
if let Err(e) = sqlx::query("SET idle_session_timeout = 0")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This connection issues no statement after pg_try_advisory_lock for the whole copy. When the SET is refused (the comment says managed services refuse it) or the connection sits behind a transaction-mode pooler, the idle session can end while the copy continues, the lock frees, and after 60 s the sweep flips the target to DELETING; the restore then fails with "deleted while it was being restored". That covers RDS, Aurora, and PgBouncer. Heartbeat the session while the copy runs, or gate the sweep on a liveness signal that does not depend on an idle connection.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lock session now runs SELECT 1 every 15 s and the copy stops if that fails, which covers idle-session reaping on RDS/Aurora. A transaction-mode pooler is not supported for restores (the lock is a session advisory lock): the admin guide says so, and the comments that implied poolers were handled are removed. Replacing the lock with a lease row (as MongoDB and Cassandra use) is the durable fix if transaction pooling matters; I left that out of this PR.

"Backup not found: {backup_arn}"
)));
}
let mut tx = self.data_pool.begin().await.map_err(db_err)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole copy is one transaction: begin here, one commit after the loop. The 500-item / 4 MiB bound limits the Rust buffer only. In write_restore_batch the base rows go in one multi-row INSERT, but the loop at line 464 writes secondary-index rows one INSERT per item per index. A million-item table with four GSIs is one transaction with four million round trips, WAL held for the duration, and the target pinned CREATING throughout, which widens the sweep exposure above. Batch the index rows per index per batch, and either commit per batch with cleanup on failure or document the single-transaction cost for large restores.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: one commit per batch, one multi-row INSERT per index per batch. The catalog side still holds one REPEATABLE READ snapshot for the copy (so the backup rows are read whole); that is documented in the rustdoc.

Comment thread tests/test_backup_restore_fidelity.py Outdated
# DescribeTable keeps reporting where the table came from, finished.
summary = table["RestoreSummary"]
assert summary["SourceBackupArn"] == backup_arn
assert summary["RestoreInProgress"] is False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DynamoDB does not return RestoreSummary on DescribeTable once the table is ACTIVE (checked at ACTIVE and 30 s later). This assertion pins the ExtendDB behaviour as correct. The test should assert the member is absent after ACTIVE, and the engine should stop emitting it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test now asserts the member is absent once ACTIVE, and the engine no longer emits it.

Comment thread docs/differences-from-dynamodb.md Outdated
| What a restore carries across | Key schema, attribute definitions, items, secondary indexes, billing mode and provisioned throughput, SSE settings. Stream settings, TTL, tags, auto scaling, IAM policies, and CloudWatch settings must be reapplied by hand | The same set on PostgreSQL, SQLite, and MongoDB, plus table class and on-demand throughput. Streams, TTL, tags, and deletion protection are not restored. A backup written by a version before catalog 0.0.4 carries no table definition and restores as keys and items only, with 5/5 provisioned throughput |
| `RestoreTableFromBackup` overrides | Current request members are `BillingModeOverride`, `ProvisionedThroughputOverride`, `GlobalSecondaryIndexOverride`, `LocalSecondaryIndexOverride`, `SSESpecificationOverride`, `OnDemandThroughputOverride`, and `VectorIndexOverride`; DynamoDB applies them to the restored table | PostgreSQL, SQLite, and MongoDB support `BillingModeOverride` and `ProvisionedThroughputOverride`. Switching to `PAY_PER_REQUEST` drops table and GSI throughput. `GlobalSecondaryIndexOverride`, `LocalSecondaryIndexOverride`, `SSESpecificationOverride`, `OnDemandThroughputOverride`, and `VectorIndexOverride` are refused with `ValidationException` before the target table is created. Switching an on-demand backup with GSIs to `PROVISIONED` is likewise refused because it requires `GlobalSecondaryIndexOverride` |
| Multi-part base table keys (preview) | Not supported | A backup of a table created with `enable_multipart_keys` is refused at restore with `ValidationException`; the item paths address such tables by their first HASH and RANGE attribute only |
| `DeleteBackup` during a restore from that backup | `BackupInUseException` | `BackupInUseException` (HTTP 400) until the restore completes or fails, on all three backends. PostgreSQL and SQLite register the restore in the same transaction that creates its target, with the backup row held so a concurrent `DeleteBackup` orders strictly before (the restore then sees no backup) or after (it sees the restore). MongoDB has no cross-collection transaction and uses a claim marker on the backup's metadata instead: a restore claims the backup before reading it and releases the claim once its `CREATING` target exists; `DeleteBackup` claims it the same way before dropping data. A claim left by a crashed process is cleared at the next startup, so a backup is never stuck undeletable; until then a restore or delete of that backup gets `BackupInUseException` |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"A backup is never stuck undeletable" is false in two cases on this branch: a MongoDB crash during the base $out copy leaves a CREATING target that blocks DeleteBackup with BackupInUse until the catalog is hand-edited, and on SQLite a restore whose client disconnected leaves the same state until restart. The next row's "PostgreSQL, SQLite, and MongoDB match" is only observable on MongoDB: on the SQL backends the restore completes inside the request, so a sequential client's DeleteTable or DeleteBackup after the response succeeds every time.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten for four backends. Rows that rest on code reading or storage-level tests are marked (from the code), numbers from earlier builds of the branch are marked (earlier build), and the header says so.

Comment thread docs/differences-from-dynamodb.md Outdated
| Restore interrupted by a server crash | Managed by the service | PostgreSQL removes an abandoned `CREATING` restore target on a control-plane pass; SQLite removes one when the next server starts. MongoDB has no abandoned-restore sweep: the target remains `CREATING`, the API continues to refuse `DeleteTable`, and an operator must remove its catalog and data collections by hand |
| Point-in-time recovery | 35-day window | Not supported; `UpdateContinuousBackups` and `RestoreTableToPointInTime` are refused |
| Where backups live | Managed storage, independent of the table | Inside the database the server already uses, so a lost database loses its backups too: on PostgreSQL in the catalog database (`backups`, `backup_items`, `backup_definitions`, `table_restores`), on SQLite in the one database file, on MongoDB as a `backups` metadata collection in the catalog database and one data collection per backup in the data database. Take database-level backups for off-host copies until the backup store program ships |
| Backup creation status | `CreateBackup` returns while the backup is `CREATING`; `ListBackups` and `DescribeBackup` expose that state until it becomes `AVAILABLE` | SQLite similarly exposes `CREATING` while it copies: size and item count remain zero, `DeleteBackup` is refused, and `RestoreTableFromBackup` reports the backup missing. PostgreSQL and MongoDB complete their copy before returning and expose only `AVAILABLE` |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"SQLite similarly exposes CREATING while it copies" reads as if the caller gets a CREATING response. The caller blocks until AVAILABLE on all three backends (1.4 s for 50,000 items; minutes for a large table), and only a concurrent observer sees the CREATING row. Say that, and note that CreateBackup blocks for the copy duration, or return CREATING and let the detached task finish, which the SQLite code already supports.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten: CreateBackup blocks the caller for the copy on all four backends; only a concurrent observer sees CREATING. Detaching CreateBackup is left for a follow-up.

Upgrade sequence, on PostgreSQL and SQLite alike:

```bash
extenddb stop --config extenddb.toml

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Followed literally on a 0.0.3 deployment this fails: migrate connects as a PostgreSQL admin and the manual gives no credentials, so it falls back to the OS user and prints Error: Internal("Internal(\"Cannot connect as admin: ... password authentication failed for user \"lhnng\"\")"). Show --pg-user and EXTENDDB_PG_PASSWORD here, and flatten that nested error in the tool. The preview step on line 203 also exits 1 with Error: --yes is required, so a scripted upgrade under set -e stops at the preview; exit 0 for the preview or say the non-zero exit is intended. Nothing here tells a SQLite operator about the backup_items rebuild, and the backup and rollback steps above hardcode extenddb_catalog, extenddb, and OWNER extenddb.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--pg-user and EXTENDDB_PG_PASSWORD are shown; the error is one plain line naming the role it tried; the preview exits 3 when work is pending and 0 when up to date (documented); the commands are parameterized; and there are SQLite, Cassandra and MongoDB subsections.


CREATE INDEX IF NOT EXISTS idx_table_restores_backup ON table_restores (source_backup_arn);

UPDATE settings SET value = '0.0.4' WHERE key = 'catalog_version';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#368 and #335 also ship a 003_*.sql that writes 0.0.4. The ledger keys on filename and converge_catalog_version compares numbers, so if #368 merges first and stamps 0.0.4, this binary applies its own 003 and converges with no refusal: two different schemas both called 0.0.4, and the startup gate cannot tell them apart. Whoever merges second renumbers to 004 and 0.0.5 (plus the two assertions in the migrations.rs tests and the manual's 0.0.4 section). The order should be agreed before any of the three lands.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not renumbered; I need your call on the order (open question in the PR body: #396 assumes this PR keeps 003/0.0.4 and #335 moves to 004/0.0.5, and does not place #368). Meanwhile migrate applies an unrecorded catalog migration file even when the version already matches, and PostgreSQL serve refuses to start on a catalog missing one of its files, so a second 003 can no longer be skipped silently.

@robinnsc
robinnsc force-pushed the fix/backup-restore-fidelity branch from ba9f869 to 8dab086 Compare October 16, 2026 08:33
robinnsc added a commit that referenced this pull request Oct 26, 2026
…ackend

Adds the one new obligation the engine-owned backup path places on a
backend: open a consistent view of one table and stream its items. The
trait returns the table definition, a snapshot marker, an ordering flag,
and the item stream together, from one operation, so a backup's manifest
and data files always describe the same instant. The in-catalog backup
code keeps its own copy of this logic for now; the engine-side backup job
(next PR) is the first caller. Stacked on #394: SnapshotInfo is the
manifest's SnapshotDescriptor (one definition, with the three KIND_*
constants), and key_schema_is_orderable is KeyOrder::resolve, so a stream
a backend marks ordered is one the manifest parser accepts and a table
whose range key has no attribute definition streams unordered and
complete instead of paging by the hash key alone.

Trait (crates/storage/src/snapshot.rs):
  TableSnapshot { description: TableDescription, info: SnapshotInfo,
                  key_ordered: bool, items: BoxStream<Result<Item>> }
  SnapshotEngine::open_table_snapshot(account_id, table_name)
The stream owns the backend transaction or session; dropping it (inside
the runtime that opened it) releases it. StorageEngine now requires
SnapshotEngine; all four in-tree backends implement it. Nothing here
bounds how many snapshots are open at once (the caller does); the trait
doc names what each backend holds while a stream is open. StorageError gains SnapshotExpired for a snapshot the
server discarded before the stream drained; the engine maps it to a
retryable 503 like Transient.

Indexes: the description keeps every index in whatever build state it
had and drops only one being deleted (drop_deleting_indexes); on
PostgreSQL a GSI whose data table does not exist yet is also left out,
the probe the in-catalog backup already makes (UpdateTable commits the
catalog row before it creates the table). A restore recreates the indexes
in the description and builds them from the items, so a CREATING index
with a data table comes back complete; one left out would not come back
at all.

PostgreSQL: the tables row is held FOR SHARE from the definition read
until the data transaction (REPEATABLE READ READ ONLY, ACCESS SHARE on the
relation) has pinned its snapshot with SELECT pg_current_snapshot(), whose
text is the marker. The definition is read on that same barrier
connection (build_table_description_on), so an open holds exactly one
catalog connection and a pool's worth of concurrent opens cannot deadlock
waiting for a second. UpdateTable and DeleteTable take the row FOR UPDATE,
so neither commits in the gap; index-status workers do not, so an index
may be reported in a slightly older status than the items. Items stream
through a server-side cursor, FETCH 1000 per round trip, ORDER BY pk
COLLATE "C" and sk_s COLLATE "C": tables created before the key columns
were declared with that collation keep the database collation, and
without the clause they would stream in locale order while reporting
key_ordered. SQLSTATE 40001 (recovery conflict on a standby) and 72000
(snapshot_too_old on a primary) map to SnapshotExpired, pinned by a test
that raises each code on the live server.

SQLite: one deferred read transaction covers catalog and data; its first
read pins the WAL snapshot and no lock is taken, so writers never wait on
a snapshot being opened and the WAL grows until the stream ends (#384's
create_backup_from_wal_snapshot holds the writer lock while it pins
because it also inserts the CREATING row; this path writes nothing).
build_table_description_on reads the definition, including #384's
restore_summary from table_restores, on that transaction. The stream
holds a backup slot (backup_server_slots, shared with the in-catalog
backup) for its life, so the pool arithmetic that keeps half the pool free
counts snapshots; with no slot free the open returns LimitExceeded at
once.
Items page through row-value keyset pagination on the primary key index,
so key_ordered is true for single-range-key tables; multi-part keys fall
back to rowid order. In-memory databases refuse with Unsupported: they are
pinned to one connection and cannot hold a snapshot open beside writers.

Cassandra: open_table_snapshot returns Unsupported. Cassandra has no read
that fixes an instant across a table (a token-range scan observes each
range at a different time; LWT serializes single partitions), and its
in-catalog logical backup says so (ADR-0019). Returning that copy under
this trait's contract would make the manifest's snapshot member false, so
the backend refuses, the way an in-memory SQLite database does, and the
in-catalog BackupEngine path stays its backup path. A direct test asserts
the refusal on an existing and a missing table.

MongoDB: a session started with snapshot=true reads the tables document,
the indexes documents (doc_to_table_description_in now takes an optional
session) and the base collection at one cluster time; the marker is that
time. Every read sets readConcern: snapshot explicitly, because the
driver only fills it in when the operation carries none and a URI with
readConcernLevel= would otherwise turn the session into three ordinary
reads with nothing failing; the open fails if the server reports no
snapshot time. SnapshotTooOld (239) and SnapshotUnavailable (246) map to
SnapshotExpired, pinned by a unit test that builds the command error from
a reply document. key_ordered is false: _id order is not DynamoDB key
order for number keys.

Test hooks: one page-size knob and one round-trip counter live in
extenddb_storage::snapshot::paging; every backend reads the page size
there and records each round trip (MongoDB counts one per batch_size
documents, since the driver hides getMore). Both and the conformance
check compile only with the test-hooks feature (new on extenddb-storage,
storage-postgres and storage-sqlite; extended on storage-mongodb). The
backend crates' tests enable it through a dev-dependency on themselves;
the mongodb-test-hooks build of the server binary also carries it.
Production builds carry neither the knob nor the harness.

Tests: extenddb_storage::snapshot_conformance::run is one check every
backend runs against a live database. It sets the shared page size to 50,
opens a snapshot on a 700-item composite-key table with a GSI, reads 51
items and asserts the round-trip counter advanced by at least two (so a
backend that ignored the knob fails here instead of passing vacuously),
then a writer deletes 100 items, inserts 100, and overwrites all 600
remaining items twice before the reader drains.
Asserts: 700 items, no duplicates, every deleted item present, no
inserted item, every version at its pre-open value, ascending key order
when key_ordered, and a second snapshot taken after the writer stops
shows the final round. run_refusals covers a missing and a non-ACTIVE
table on every backend. No-snapshot mutants (fresh transaction per page
on SQLite, OFFSET/LIMIT in READ COMMITTED on PostgreSQL, a cursor outside
the session on MongoDB) fail it. Passes on PostgreSQL 16, file-backed
SQLite, MongoDB 7 (replica set) and Cassandra 4.1. PG:
tests/table_snapshot.rs (consistency, marker and key order, a
legacy-collation table streaming bytewise, ten concurrent opens on a
ten-connection pool with DescribeTable not starved, the SQLSTATE mapping)
with a Drop guard that drops the scratch databases on any exit, added to
the direct-storage step in integration.yml. SQLite: in-module tests
(consistency, key order, in-memory refusal, a damaged attribute
definition streaming unordered and complete, slots) with the same guard
for the scratch files. MongoDB: in-module tests on per-test scratch
databases built without the bootstrapper, as #384's live tests do, dropped
by a guard on any exit, so a URI in the environment never makes cargo
test touch another instance's databases; gated on
EXTENDDB_TEST_MONGODB_URI (failing rather than skipping when
EXTENDDB_TEST_MONGODB_REQUIRED is set, which the CI job does), serialised
on a mutex, run by a new integration-mongodb.yml job that starts a replica
set through devtools/run-mongodb-tests --mongo-only.

Not covered here: a concurrent UpdateTable during open (the PG row lock
blocks it; SQLite and Mongo read catalog and data from one snapshot); a
test for that needs a second task issuing UpdateTable and is left for the
backup job's suite. A synchronous DeleteTable on PostgreSQL blocks behind
the stream's ACCESS SHARE lock for the stream's life (the in-catalog
backup has the same property; delete_table.rs sets no lock_timeout on
that path), which the backup job's design must name.
robinnsc added a commit that referenced this pull request Oct 26, 2026
Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), three review rounds against
the companion PRs #393, #394 and #395, and a reconciliation against main
744ea29 after #384 merged in its final form and the Cassandra backend
landed. The revision log at the
end of the document lists each change; the ones that alter behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). The items-only signature in
  the draft allowed a GSI change or DeleteTable between the definition
  read and the data snapshot. Indexes are reported in whatever build
  state they had and only an index being deleted is dropped; an ACTIVE-
  only filter would have left a GSI mid-backfill out of the backup for
  good. - The snapshot is opened in the CreateBackup request so the
  backup represents the request time (stricter than the service's two-
  minute window). The backup id is minted from the instant the snapshot
  pinned, so backup_creation_date_time, the catalog's created_at and the
  ARN carry one time and the ListBackups cursor is a function of the
  ARN. - Per-job claims with fencing replace the single-instance lease
  the draft assumed; PostgreSQL and MongoDB have no such lease. The
  claim is an advisory lock on a dedicated catalog connection on
  PostgreSQL (the seed #384's restore lock already uses), the row itself
  on MongoDB, an in-process slot on SQLite, a lightweight-transaction
  row on Cassandra (restore targets only); no row ever exists unowned
  (the MongoDB row is born through an upsert pipeline so its first
  heartbeat is the server's clock). Heartbeats are a timer, staleness is
  measured on the database's clock, and any heartbeat that does not
  confirm the claim aborts the job. A job runs the failure path's
  delete_prefix only after its FAILED write matched a row; otherwise the
  claim holder owns the prefix. The reconciler resolves a dead CREATING
  row at once under a fresh generation conditioned on status = CREATING,
  leaves heartbeat_at untouched, and deletes the prefix only after the
  stale window; its duties are listed once, in section 4. A sink error
  fails the backup; there is no per-file restart, which the one-shot
  stream and the memory bound rule out. - DeleteBackup tombstones the
  row as DELETING, deletes the manifest first, and marks DELETED only
  when the prefix is empty; backup sync is scoped to the configured
  store and never adopts a CREATING, DELETING, DELETED or FAILED prefix.
  Failed creations and deleted backups are BackupNotFound, as on the
  service, and share one retention. - Restore returns CREATING from the
  request (table and restore_jobs row created atomically in a detached
  task; TableAlreadyExistsException or TableInUseException on a name
  race, as #384 already returns; LimitExceededException over the job
  limit), spools and verifies every data file before writing any item,
  and records progress in a way a crash cannot split from the data: a
  data-database row per file on PostgreSQL (data migration 005
  reserved), the same transaction per 10k-item batch on SQLite,
  idempotent re-application of one file on MongoDB followed by a
  document-count check against the manifest. Duplicate detection for
  unordered backups is the target's primary key, not an in-memory key
  set. The ACTIVE flip is conditional and precedes the deletion of the
  PostgreSQL restore_progress rows, which live in the data database and
  cannot share its transaction. DeleteTable on a restore target fails
  the job (a change from #384, which refuses it); multi-part-key tables
  are restored (#384 refuses them). The override paragraph now states
  what #384 ships: every override but VectorIndexOverride is applied,
  and the new path keeps that. - The manifest self-checksum is replaced
  by a sidecar over the stored bytes; the draft's canonical form
  depended on serde_json formatting. backup_arn is the backup's identity
  and source_table must agree with it. N key values are normalized
  before key text or key order is computed; #395's PostgreSQL ORDER BY
  carries COLLATE "C" so tables created before #361 stream in the
  format's order. - Index population on restore stays per backend via
  bulk_load; the shared restore-backfill worker the draft described
  exists only on MongoDB. - Format additions: item_order, per-file
  key_range, md5 and uncompressed_bytes, explicit nulls, three-state
  vector_indexes of creation specs, optional etag, dataFileS3Key as the
  full object key, partition-aware BackupArn as the single ARN
  implementation. - Filesystem store: owner-only root re-checked by
  every operation, owner-only rule on every descended directory
  including list and delete_prefix, ancestor ownership rule, objects
  0600 and directories 0700 at creation, put returns object metadata;
  the consequence for backup pickup agents is stated; list and
  delete_prefix resolve their prefix with the same no-follow walk as a
  key and tolerate entries a concurrent deleter removed. The S3
  conformance suite is mandatory in CI against moto, with the
  301/403/abort paths driven through an interceptor. Store settings live
  under [backup.store], matching #393. - ListBackups keeps #384's
  handler (full request already on main) over the catalog index merged
  with the legacy reader; sqlite-memory behavior, extenddb destroy
  leaving the store intact, a restore spool outside the store root,
  max_concurrent_jobs, heartbeat_interval_secs, a memory formula in
  bytes with the item-size term, and a pool-size rule that clamps and
  warns are specified. Restore and Describe never read the store without
  a catalog row; backup sync is the path in. - Reconciled with #384 as
  merged: the restore job record extends #384's table_restores /
  provenance fields rather than adding a table; the backup ARN parser
  and formatter #384 introduced are the one implementation, moved into
  extenddb-core by PR 4 and re-exported by storage (new_backup_id stays
  in storage and calls the shared formatter); the claim names #384's
  RESTORE_LOCK_SEED, lock key, 15 s heartbeat and slot semaphores as
  what it keeps or replaces; the 300 s stale window is new (#384 uses 60
  s). Cassandra (#338) implements SnapshotEngine as Unsupported and
  keeps its logical backup until a token-range snapshot is accepted or
  the backend's backups are documented as unavailable, decided before PR
  14. - Phase 1 is rewritten as the PR series with acceptance criteria
  and GitHub numbers. Migration numbers are reserved up front so the
  PITR PR is not on the critical path (Cassandra V006 to PR 1, V007 to
  PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill points
  for the crash tests are enumerated per backend.

Also adds the missing rows for designs 12 and 13 to
docs/design/README.md.

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
LeeroyHannigan added a commit that referenced this pull request Oct 26, 2026
…m the decoded item

Main's restore (#384) reads each backup_items document as text, parses it
into an Item and writes the text into the target's item_data unchanged. On
this branch that text carries the control-character escape, and the key
columns and index rows are built by helpers that escape their input, so
the parsed item was escaped a second time: a restored table whose items
hold U+0000 or U+0001 got key columns and index rows in a form the read
path does not produce. The item is now decoded with json_to_item before
the key helpers see it, and the key helpers are the escaping ones from
key_text, so every column is escaped exactly once; the item_data text is
still copied as it is. The backup copy sizes each item from the decoded
form for the same reason.

The parallel Scan cross-segment check (#203) hashes the candidate key's
text and compares it with the hash of the stored pk column; it now uses
the escaping pk_to_text so both sides hash the same text for keys with
control characters.

A storage-level test restores a table whose keys, index keys, attribute
names and nested values hold U+0000 and U+0001 and compares the stored
rows of the base table and both index tables with the source's, then
reads each item back.
robinnsc added a commit that referenced this pull request Oct 26, 2026
…ackend

Adds the one new obligation the engine-owned backup path places on a
backend: open a consistent view of one table and stream its items. The
trait returns the table definition, a snapshot marker, an ordering flag,
and the item stream together, from one operation, so a backup's manifest
and data files always describe the same instant. The in-catalog backup
code keeps its own copy of this logic for now; the engine-side backup
job (next PR) is the first caller. Stacked on #394: SnapshotInfo is the
manifest's SnapshotDescriptor (one definition, with the three KIND_*
constants), and key_schema_is_orderable is KeyOrder::resolve, so a
stream a backend marks ordered is one the manifest parser accepts and a
table whose range key has no attribute definition streams unordered and
complete instead of paging by the hash key alone.

Trait (crates/storage/src/snapshot.rs): TableSnapshot { description:
TableDescription, info: SnapshotInfo, key_ordered: bool, items:
BoxStream<Result<Item>> }
SnapshotEngine::open_table_snapshot(account_id, table_name) The stream
owns the backend transaction or session; dropping it (inside the runtime
that opened it) releases it. StorageEngine now requires SnapshotEngine;
all four in-tree backends implement it. The trait doc names what each
backend holds while a stream is open; PostgreSQL and SQLite bound open
snapshots per server (below) and refuse over the bound with
LimitExceeded. StorageError gains SnapshotExpired for a snapshot the
server discarded before the stream drained; the engine maps it to a
retryable 503 like Transient.

Indexes: the description keeps every index in whatever build state it
had and drops only one being deleted (drop_deleting_indexes; no in-tree
backend reports a DELETING index today, and a unit test pins the rule);
on PostgreSQL a GSI row whose data table is missing is also left out,
with the attribute definitions only it used, as the in-catalog backup
does (since #401 UpdateTable creates the data table before committing
the row, so this is a guard, not an expected state). A restore recreates
the indexes in the description and builds them from the items, so a
CREATING index with a data table comes back complete; one left out would
not come back at all.

PostgreSQL: the tables row is held FOR SHARE from the definition read
until the data transaction (REPEATABLE READ READ ONLY, ACCESS SHARE on
the relation) has pinned its snapshot with SELECT pg_current_snapshot(),
whose text is the marker. The definition is read on that same barrier
connection (build_table_description_on), so an open holds one catalog
connection and the stream one data connection. An engine-wide permit is
held per snapshot from the open until the stream's transaction ends: the
pool size with separate catalog and data databases, half of it when they
share one pool, so snapshots alone cannot take every connection while an
open waits for its second. UpdateTable and DeleteTable take the row FOR
UPDATE, so neither commits in the gap; index-status workers do not, so
an index may be reported in a slightly older status than the items.
Items stream through a server-side cursor, FETCH 1000 per round trip,
ORDER BY pk COLLATE "C" and sk_s COLLATE "C": tables created before the
key columns were declared with that collation keep the database
collation, and without the clause they would stream in locale order
while reporting key_ordered. SQLSTATE 40001 (recovery conflict on a
standby) and 72000 (snapshot_too_old on a primary) map to
SnapshotExpired, pinned by a test that raises each code on the live
server. The stream holds ACCESS SHARE on the data table until its last
fetch, so a DROP TABLE waits for it; DeleteTable's synchronous path,
which commits the catalog removal before the drop, now takes the
asynchronous path (row DELETING, worker drop under a lock timeout) when
another session holds ACCESS SHARE on the data table, so a caller
cancelled during the wait cannot leave the row gone and the data table
orphaned. A queue worker's row-level claim is not matched; the drop
still waits for it, as #401 intends.

SQLite: one deferred read transaction covers catalog and data; its first
read pins the WAL snapshot and no lock is taken, so writers never wait
on a snapshot being opened and the WAL grows until the stream ends
(#384's create_backup_from_wal_snapshot holds the writer lock while it
pins because it also inserts the CREATING row; this path writes
nothing). build_table_description_on reads the definition, including
#384's restore_summary from table_restores, on that transaction. The
stream holds a backup slot (backup_server_slots, shared with the
in-catalog backup) for its life, so the pool arithmetic that keeps half
the pool free counts snapshots; with no slot free the open returns
LimitExceeded at once. Items page through row-value keyset pagination on
the primary key index, so key_ordered is true for single-range-key
tables; multi-part keys fall back to rowid order. In-memory databases
refuse with Unsupported: they are pinned to one connection and cannot
hold a snapshot open beside writers.

Cassandra: open_table_snapshot returns Unsupported. Cassandra has no
read that fixes an instant across a table (a token-range scan observes
each range at a different time; LWT serializes single partitions), and
its in-catalog logical backup says so (ADR-0019). Returning that copy
under this trait's contract would make the manifest's snapshot member
false, so the backend refuses, the way an in-memory SQLite database
does, and the in-catalog BackupEngine path stays its backup path. A
direct test asserts the refusal naming the backend on an existing table
and a missing one.

MongoDB: a session started with snapshot=true reads the tables document,
the indexes documents (doc_to_table_description_in now takes an optional
session) and the base collection at one cluster time; the marker is that
time. Every read sets readConcern: snapshot explicitly, because the
driver only fills it in when neither the operation nor its database
carries a read concern, and the production catalog handle carries
readConcern: local (as a URI with readConcernLevel= would); the open
fails if the server reports no snapshot time, naming the read concern as
the cause. The index read in the session maps 239/246 like the others.
SnapshotTooOld (239) and SnapshotUnavailable (246) map to
SnapshotExpired, pinned by a unit test that builds the command error
from a reply document. key_ordered is false: for a composite key _id is
a netstring of the key text, which is not the format's order for any key
type.

Test hooks: one page-size knob and one round-trip counter live in
extenddb_storage::snapshot::paging; every backend reads the page size
there. PostgreSQL and SQLite record each FETCH or page query; for
MongoDB, whose driver hides getMore, the test fixture records each find
and getMore the server receives on the data database (command
monitoring), so the count is what the server did, not what the stream
asked for. Both and the conformance
check compile only with the test-hooks feature (new on extenddb-storage,
storage-postgres and storage-sqlite; extended on storage-mongodb). The
backend crates' tests enable it through a dev-dependency on themselves;
the mongodb-test-hooks build of the server binary also carries it.
Production builds carry neither the knob nor the harness.

Tests: extenddb_storage::snapshot_conformance::run is one check every
backend runs against a live database. It sets the shared page size to
50, opens a snapshot on a 700-item composite-key table with a GSI, reads
51 items and asserts the round-trip counter advanced by at least two
since just before the open (so a backend that ignored the knob, or a
MongoDB cursor without batch_size, fails here instead of passing
vacuously), then a writer deletes 100 items, inserts 100, and overwrites
all 600 remaining items twice before the reader drains. Asserts: 700
items, no duplicates, every deleted item present, no inserted item,
every version at its pre-open value, strictly ascending keys by
KeyOrder::compare (the format's comparator) when key_ordered, and a
second snapshot taken after the writer stops shows the final round. The
check holds a harness-wide lock, so two runs in one binary cannot reset
each other's knob. run_refusals covers a missing and a non-ACTIVE table
on the three backends that implement snapshots. No-snapshot mutants
(fresh transaction per page on SQLite, OFFSET/LIMIT in READ COMMITTED on
PostgreSQL, a cursor outside the session on MongoDB) fail it. The suites
pass on PostgreSQL 16, file-backed SQLite and MongoDB 7 (replica set),
and the Cassandra test on Cassandra 4.1. PG: tests/table_snapshot.rs
(consistency, marker and key order, a legacy-collation table streaming
bytewise, ten concurrent opens on a ten-connection pool with
DescribeTable not starved, ten on one shared pool (five open, five
LimitExceeded, a slot free again after), a GSI without its data table
left out with its attribute, DeleteTable during a stream returning
DELETING and finishing after it, the SQLSTATE mapping) with a Drop guard
that drops the scratch databases on any exit, added to the
direct-storage step in integration.yml. SQLite: in-module tests
(consistency, key order, in-memory refusal, a damaged attribute
definition streaming unordered and complete, slots) with the same guard
for the scratch files. MongoDB: in-module tests on per-test scratch
databases built without the bootstrapper, as #384's live tests do, the
catalog handle with the production option set, dropped by a guard on any
exit, so a URI in the environment never makes cargo test touch another
instance's databases; gated on EXTENDDB_TEST_MONGODB_URI (failing rather
than skipping when EXTENDDB_TEST_MONGODB_REQUIRED is set, which the CI
job does), serialised on a mutex, run by a new integration-mongodb.yml
job that starts a replica set through devtools/run-mongodb-tests
--mongo-only.

Not covered here: a concurrent UpdateTable during open (the PG row lock
blocks it; SQLite and Mongo read catalog and data from one snapshot); a
test for that needs a second task issuing UpdateTable and is left for
the backup job's suite.
robinnsc added a commit that referenced this pull request Oct 26, 2026
Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), four review rounds against
the companion PRs #393, #394 and #395, and a reconciliation against main
abf8082 (#384 in its final form, the Cassandra backend #338, the
PostgreSQL GSI build #401). The revision log at the end of the document
lists each change; the ones that alter behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). Indexes are reported in
  whatever build state they had and only an index being deleted is
  dropped; a GSI without its data table is left out with the attribute
  definitions only it used. PostgreSQL bounds open snapshots per server
  and refuses more with LimitExceeded; DeleteTable takes its
  asynchronous path while a reader holds the data table.
- The snapshot is opened in the CreateBackup request (stricter than the
  service's two-minute window), and the backup id is minted from the
  instant it pinned, so backup_creation_date_time, the catalog's
  created_at and the ARN carry one time and the ListBackups cursor is a
  function of the ARN.
- Per-job claims with fencing replace the single-instance lease the
  draft assumed: an advisory lock on a dedicated catalog connection on
  PostgreSQL (#384's seed and key), the row itself on MongoDB (born
  through an upsert pipeline with $literal), an in-process slot on
  SQLite, a lightweight-transaction row on Cassandra (restore targets).
  Heartbeats are a timer on the database's clock with a deadline; any
  heartbeat that does not confirm the claim aborts the job. Every job
  status write is conditioned on the generation and on the row's state,
  and an ambiguous AVAILABLE update is re-read. A job deletes a prefix,
  or a restore target, only after its FAILED write matched a row. The
  reconciler's duties are listed once, in section 4, and include the
  prefixes of DELETED rows, orphan restore targets and stale spools.
- DeleteBackup tombstones the row as DELETING, deletes the manifest
  first, and marks DELETED only when the prefix is empty. backup sync is
  scoped to the configured store, never adopts a CREATING, DELETING,
  DELETED or FAILED prefix, and marks a row whose manifest head is gone
  DELETING. Restore and Describe never read the store without a catalog
  row; an ARN with no row goes to the legacy reader.
- Restore returns CREATING from the request (TableAlreadyExists or
  TableInUse on a name race, as #384 returns), spools and verifies every
  data file before writing any item, and records progress in a way a
  crash cannot split from the data on each backend. The ACTIVE flip
  precedes the deletion of the PostgreSQL progress rows. DeleteTable on
  a restore target stays refused, as #384 and the service refuse it; the
  restore record is the table's child. Multi-part-key tables are
  restored (#384 refuses them). Table class is not restored, as on the
  service and #384; every override but VectorIndexOverride is applied,
  as #384 applies them.
- The manifest self-checksum is replaced by a sidecar over the stored
  bytes; backup_arn is the backup's identity and source_table must agree
  with it; N key values are normalized before key text or key order is
  computed; #395's PostgreSQL ORDER BY carries COLLATE "C".
- Store (#393): owner-only root and directories re-checked by every
  operation, the prefix resolved with the same no-follow walk as a key,
  listings that report only keys passing the key rule, multipart uploads
  aborted when a put is dropped and swept by delete_prefix (a lifecycle
  rule as backstop), validate by a one-key listing so a 403 keeps S3's
  code, settings under [backup.store].
- Configuration: the default store root is per backend (beside the
  database file on SQLite); PostgreSQL, MongoDB and Cassandra refuse
  CreateBackup until [backup.store] is set, and keep serving legacy
  backups. Cassandra keeps creating in-catalog backups through the
  legacy reader until PR 14. The memory formula counts the fetch buffer
  and caps a restore batch at one file; purge holds every job permit.
- Phase 1 is the PR series with acceptance criteria and GitHub numbers;
  migration numbers are reserved up front (Cassandra V006 to PR 1, V007
  to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill
  points for the crash tests are enumerated per backend and for
  DeleteBackup.

Also adds the missing rows for designs 12 and 13 to
docs/design/README.md and marks 15 as a draft.

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 27, 2026
Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), five review rounds against
the companion PRs #393, #394 and #395, and a reconciliation against main
abf8082 (#384 in its final form, the Cassandra backend #338, the
PostgreSQL GSI build #401). The revision log at the end of the document
lists each change; the ones that alter behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). Indexes are reported in
  whatever build state they had and only an index being deleted is
  dropped; a GSI without its data table is left out with the attribute
  definitions only it used. PostgreSQL bounds open snapshots at half
  the pool and refuses more with LimitExceeded, streams a table whose
  key columns predate COLLATE "C" unordered rather than sort it, and
  DeleteTable takes its asynchronous path while a reader holds any
  relation it would drop. On sqlite-memory the snapshot copies the
  table on the one connection, so CreateBackup keeps working there.
  MongoDB reads without the explicit snapshot level fail, and the
  document says so with the server's error.
- The snapshot is opened in the CreateBackup request (stricter than the
  service's two-minute window), and the backup id is minted from the
  instant it pinned, so backup_creation_date_time, the catalog's
  created_at and the ARN carry one time and the ListBackups cursor is a
  function of the ARN.
- Per-job claims with fencing replace the single-instance lease the
  draft assumed: an advisory lock on a dedicated catalog connection on
  PostgreSQL (#384's seed and key), the row itself on MongoDB (born
  through an upsert pipeline with $literal), an in-process slot on
  SQLite, a lightweight-transaction row on Cassandra (restore targets).
  Heartbeats are a timer on the database's clock with a deadline; any
  heartbeat that does not confirm the claim aborts the job. Every job
  status write is conditioned on the generation and on the row's state,
  and an ambiguous AVAILABLE update is re-read. A job deletes a prefix,
  or a restore target, only after its FAILED write matched a row. The
  reconciler's duties are listed once, in section 4, and include the
  prefixes of DELETED rows, orphan restore targets, stale spools and,
  on Cassandra, restores_by_backup entries with no RUNNING job (that
  entry cannot share a lightweight transaction with the claim, so it
  is written first and is only a hint). Every duty that deletes a
  prefix acts only on rows of the configured store.
- DeleteBackup tombstones the row as DELETING, deletes the manifest
  first, and marks DELETED only when the prefix is empty. backup sync is
  scoped to the configured store (backup sync --move-store re-points
  the rows of a copied store), never adopts a CREATING, DELETING,
  DELETED or FAILED prefix, and marks a row whose manifest head is gone
  DELETING. Restore and Describe never read the store without a catalog
  row; an ARN with no row goes to the legacy reader.
- Restore returns CREATING from the request (TableAlreadyExists or
  TableInUse on a name race, as #384 returns), spools and verifies every
  data file before writing any item, and records progress in a way a
  crash cannot split from the data on each backend. The ACTIVE flip
  precedes the deletion of the PostgreSQL progress rows. DeleteTable on
  a restore target stays refused, as #384 and the service refuse it; the
  restore record is the table's child. Multi-part-key tables are
  restored (#384 refuses them). Table class is not restored, as on the
  service and #384; every override but VectorIndexOverride is applied,
  as #384 applies them.
- The manifest self-checksum is replaced by a sidecar over the stored
  bytes; backup_arn is the backup's identity and source_table must agree
  with it; N key values are normalized before key text or key order is
  computed. The line cap is 32 MiB, sized from the measured worst case,
  with its decode memory stated; the compressed size is enforced while
  reading.
- Store (#393): owner-only root and directories re-checked by every
  operation, the prefix resolved with the same no-follow walk as a key,
  listings that report only keys passing the key rule, multipart uploads
  aborted when a put is dropped and swept by delete_prefix (a lifecycle
  rule as backstop), validate by a one-key listing so a 403 keeps S3's
  code and then by probes of the write grants, a 255-byte component cap
  and U+FFFE/U+FFFF refused by the key rule, settings under
  [backup.store]. Section 8 gains a transport rule (a non-loopback
  http:// endpoint needs allow_insecure_endpoint) and an IAM example
  with the list actions on the bucket.
- Configuration: the default store root is per backend (beside the
  database file on SQLite); PostgreSQL, MongoDB and Cassandra refuse
  CreateBackup until [backup.store] is set, and keep serving legacy
  backups. Cassandra keeps creating in-catalog backups through the
  legacy reader until PR 14. The memory formula counts the fetch buffer
  and caps a restore batch at one file; purge holds every job permit.
- Phase 1 is the PR series with acceptance criteria and GitHub numbers;
  migration numbers are reserved up front (Cassandra V006 to PR 1, V007
  to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill
  points for the crash tests are enumerated per backend and for
  DeleteBackup.

Also adds the missing rows for designs 12 and 13 to
docs/design/README.md and marks 15 as a draft.

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 27, 2026
…ackend

Adds the one new obligation the engine-owned backup path places on a
backend: open a consistent view of one table and stream its items. The
trait returns the table definition, a snapshot marker, an ordering flag,
and the item stream together, from one operation, so a backup's manifest
and data files always describe the same instant. The in-catalog backup
code keeps its own copy of this logic for now; the engine-side backup
job (next PR) is the first caller. Stacked on #394: SnapshotInfo is the
manifest's SnapshotDescriptor (one definition, with the three KIND_*
constants), and key_schema_is_orderable is KeyOrder::resolve, so a
stream a backend marks ordered is one the manifest parser accepts and a
table whose range key has no attribute definition streams unordered and
complete instead of paging by the hash key alone.

Trait (crates/storage/src/snapshot.rs): open_table_snapshot(account_id,
table_name) returns TableSnapshot { description: TableDescription, info:
SnapshotInfo, key_ordered: bool, items: BoxStream<Result<Item>> }. The
stream owns the backend transaction or session; dropping it releases
it. It must be dropped inside a Tokio runtime: the sqlx backends return
their connection through the runtime and panic outside one, MongoDB
does not, and the trait doc says so. The first Err ends the stream on
every backend. StorageEngine now requires SnapshotEngine; all four
in-tree backends implement it. The trait doc names what each backend
holds while a stream is open and which errors each returns (Cassandra
answers Unsupported before it resolves the table); PostgreSQL and SQLite
bound open snapshots per server (below) and refuse over the bound with
LimitExceeded. StorageError gains SnapshotExpired for a snapshot the
server discarded before the stream drained; the engine maps it to a
retryable 503 like Transient.

Indexes: the description keeps every index in whatever build state it
had and drops only one being deleted (drop_deleting_indexes, for GSIs
and vector indexes; no in-tree backend reports a DELETING index today,
and unit tests pin the rule); on PostgreSQL a GSI row whose data table
is missing is also left out, with the attribute definitions only it
used, as the in-catalog backup does (since #401 UpdateTable creates the
data table before committing the row, so this is a guard, not an
expected state). A restore recreates the indexes in the description and
builds them from the items, so a CREATING index with a data table comes
back complete; one left out would not come back at all.

PostgreSQL: the tables row is held FOR SHARE from the definition read
until the data transaction (REPEATABLE READ READ ONLY, ACCESS SHARE on
the relation) has pinned its snapshot with SELECT pg_current_snapshot(),
whose text is the marker. The definition is read on that same barrier
connection (build_table_description_on), so an open holds one catalog
connection and the stream one data connection. An engine-wide permit is
held per snapshot from the open until the stream's transaction ends,
half the pool whether or not the data database is separate, so
snapshots cannot take the connections requests need (with a separate
data database and the whole pool allowed, ten open snapshots made
GetItem wait out the pool timeout). UpdateTable and DeleteTable take the
row FOR UPDATE, so neither commits in the gap; index-status workers do
not, so an index may be reported in a slightly older status than the
items. Items stream through a server-side cursor, FETCH 1000 per round
trip, ordered by the primary key. A table whose text key columns do not
have collation "C" (created before the key columns were declared with
it) is streamed unordered, with a warning: ordering it would turn the
read into a sort of the whole table before the first row. SQLSTATE
40001 (recovery conflict on a standby) and 72000 (snapshot_too_old on a
primary) map to SnapshotExpired, pinned by a test that raises each code
on the live server. The stream holds ACCESS SHARE on the data table
until its last fetch, so a DROP TABLE waits for it; DeleteTable's
synchronous path, which commits the catalog removal before the drop,
now takes the asynchronous path (row DELETING, worker drop under a lock
timeout) when another session holds ACCESS SHARE on any relation it
would drop (the base, GSI, LSI and vector-index data tables), so a
caller cancelled during the wait cannot leave the row gone and a data
table orphaned. A queue worker's row-level claim is not matched; the
drop still waits for it, as #401 intends. When a stream gives up its
transaction (last page, error, or drop) it wakes the control-plane
poller, so a DeleteTable waiting on it finishes at once rather than at
the poller's next idle pass.

SQLite: one deferred read transaction covers catalog and data; its first
read pins the WAL snapshot and no lock is taken, so writers never wait
on a snapshot being opened and the WAL grows until the stream ends (a
TRUNCATE checkpoint waits out the busy timeout and reports busy; the
module doc gives the measured reply). #384's
create_backup_from_wal_snapshot holds the writer lock while it pins
because it also inserts the CREATING row; this path writes nothing.
build_table_description_on reads the definition, including #384's
restore_summary from table_restores, on that transaction. The stream
holds a backup slot (backup_server_slots, shared with the in-catalog
backup) for its life, so the pool arithmetic that keeps half the pool
free counts snapshots; with no slot free the open returns LimitExceeded
at once, and a CreateBackup that waited out its 30 s for a slot says
that backups or table snapshots hold them. The pool_size doc gives the
arithmetic. Items page through row-value keyset pagination on the
primary key index, so key_ordered is true for single-range-key tables;
multi-part keys fall back to rowid order. An in-memory database has one
connection, so the open reads every page in its transaction and returns
a stream over the copy, holding nothing once it returns, as #384's
create_backup_single_connection does for the in-catalog backup.

Cassandra: open_table_snapshot returns Unsupported. Cassandra has no
read that fixes an instant across a table (a token-range scan observes
each range at a different time; LWT serializes single partitions), and
its in-catalog logical backup says so (ADR-0019). Returning that copy
under this trait's contract would make the manifest's snapshot member
false, so the backend refuses and the in-catalog BackupEngine path
stays its backup path. A direct test asserts the refusal naming the
backend on an existing table and a missing one.

MongoDB: a session started with snapshot=true reads the tables document,
the indexes documents (doc_to_table_description_in now takes an optional
session) and the base collection at one cluster time; the marker is that
time. Every read sets readConcern: snapshot explicitly. The production
catalog handle carries readConcern: local (as a URI with
readConcernLevel= would), and on it a read without the level fails: the
first read reports no snapshot time, which the open refuses naming the
read concern, and a later read in the pinned session is refused by the
server with code 72 (InvalidOptions). A server that is not a replica set
member refuses the first read with NotAReplicaSet (123), mapped to
Unsupported. SnapshotTooOld (239) and SnapshotUnavailable (246) map to
SnapshotExpired, pinned by a unit test that builds the command error
from a reply document. Error messages carry the server's code name and
message, not the driver's dump of the reply. A document the stream
cannot decode ends it with that error. key_ordered is false: for a
composite key _id is a netstring of the key text, which is not the
format's order for any key type.

Test hooks (extenddb_storage::snapshot::paging): a page-size knob, a
round-trip counter, and a pause at two points of the open
(AfterDefinition, BeforeItems) that the conformance check holds while it
commits writes. PostgreSQL and SQLite record each FETCH or page query;
for MongoDB, whose driver hides getMore, the test fixture records each
find and getMore the server receives on the data database (command
monitoring), so the count is what the server did, not what the stream
asked for. They and the conformance check compile only with the
test-hooks feature of extenddb-storage, which the test-hooks features of
storage-postgres and storage-sqlite and the new snapshot-test-hooks of
storage-mongodb enable (storage-mongodb's existing test-hooks, the
integration binary's coordination hooks, is unchanged). The backend
crates' tests enable them through a dev-dependency on themselves.
Production builds carry neither the hooks nor the harness. PostgreSQL
also gains a test-hooks spawn_control_plane_poller_for_tests that runs
the production poller.

Tests: extenddb_storage::snapshot_conformance::run is one check every
backend runs against a live database. It sets the shared page size to
50, opens a snapshot on a 700-item composite-key table with a GSI, reads
51 items and asserts the round-trip counter advanced by at least two
since just before the open (so a backend that ignored the knob, or a
MongoDB cursor without batch_size, fails here instead of passing
vacuously), then a writer deletes 100 items, inserts 100, and overwrites
all 600 remaining items twice before the reader drains. Asserts: 700
items, no duplicates, every deleted item present, no inserted item,
every version at its pre-open value, the counter advanced after the
writer committed (a stream that read every page during the open fails),
key_ordered as the backend declares it and strictly ascending keys by
KeyOrder::compare (the format's comparator) when ordered, and a second
snapshot taken after the writer stops shows the final round. Then, once
per pause point, on a fresh table: with the open held, it commits W1,
an UpdateTable adding a GSI, and W2 (in that order; on PostgreSQL the
UpdateTable waits for the row lock and the open resumes after 2 s), and
asserts what one instant implies: a description with the new GSI has
W1, and items with W2 have the GSI in the description. READ COMMITTED
or no FOR SHARE on PostgreSQL, and the data cursor or the index read
outside the session on MongoDB, fail it. Five more key shapes (hash-only
S, N and B; S hash with S or B range) run at a page of 3 with keys whose
byte, collation and base64 orders differ. The check holds a
harness-wide lock, so two runs in one binary cannot reset each other's
hooks. run_refusals covers a missing and a non-ACTIVE table on the three
backends that implement snapshots. The suites pass on PostgreSQL 16,
file-backed SQLite and MongoDB 7 (replica set), and the
Cassandra test on Cassandra 4.1.

PG: tests/table_snapshot.rs (consistency, marker and key order, tables
with legacy-collation key columns streaming unordered and complete on a
scratch database whose collation is checked not to be C, five held
snapshots on a ten-connection pool leaving GetItem and DescribeTable
unblocked with the sixth LimitExceeded and an open succeeding after one
is dropped, on both pool layouts, a GSI without its data table left out
with its attribute, DeleteTable during a stream returning DELETING and
finishing within 5 s of the stream's drop with the poller running,
DeleteTable with only a GSI or vector-index data table locked taking the
async path, an unparseable row ending the stream and freeing its permit,
the SQLSTATE mapping) with a Drop guard that drops the scratch databases
on any exit, added to the direct-storage step in integration.yml.
SQLite: in-module tests (consistency, key order, the in-memory copy
releasing the connection, a damaged attribute definition streaming
unordered and complete, slots shared with CreateBackup and its refusal
text, the WAL checkpoint reply while a snapshot is held) with the same
guard for the scratch files. MongoDB: in-module tests (consistency, an
undecodable document ending the stream, reads without the snapshot level
failing with code 72 and the error text, the code mapping, and a
standalone server answering Unsupported, gated on
EXTENDDB_TEST_MONGODB_STANDALONE_URI) on per-test scratch databases
built without the bootstrapper, as #384's live tests do, the catalog
handle with the production option set, dropped by a guard on any exit,
so a URI in the environment never makes cargo test touch another
instance's databases; gated on EXTENDDB_TEST_MONGODB_URI (failing rather
than skipping when EXTENDDB_TEST_MONGODB_REQUIRED is set, which the CI
job does), serialised on a mutex, run by a new integration-mongodb.yml
job that starts a replica set through devtools/run-mongodb-tests
--mongo-only, which publishes the container on 127.0.0.1 only.

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 28, 2026
…ackend

Adds the one new obligation the engine-owned backup path places on a
backend: open a consistent view of one table and stream its items. The
trait returns the table definition, a snapshot marker, an ordering flag,
and the item stream together, from one operation, so a backup's manifest
and data files always describe the same instant. The in-catalog backup
code keeps its own copy of this logic for now; the engine-side backup
job (next PR) is the first caller. Stacked on #394: SnapshotInfo is the
manifest's SnapshotDescriptor (one definition, with the three KIND_*
constants), and key_schema_is_orderable is KeyOrder::resolve, so a
stream a backend marks ordered is one the manifest parser accepts and a
table whose range key has no attribute definition streams unordered and
complete instead of paging by the hash key alone.

Trait (crates/storage/src/snapshot.rs): open_table_snapshot(account_id,
table_name) returns TableSnapshot { description: TableDescription, info:
SnapshotInfo, key_ordered: bool, items: BoxStream<Result<Item>> }. The
stream owns the backend transaction or session; dropping it releases it.
A stream that has not reached its end (including one never polled) must
be dropped inside a Tokio runtime: the sqlx backends return its
connection through the runtime and panic outside one, MongoDB does not,
and the trait doc says so. The first Err ends the stream on every
backend. StorageEngine now requires SnapshotEngine; all four in-tree
backends implement it. The trait doc names what each backend holds while
a stream is open and which errors each returns (Cassandra answers
Unsupported before it resolves the table); PostgreSQL and SQLite bound
open snapshots per server (below) and refuse over the bound with
LimitExceeded. StorageError gains SnapshotExpired for a snapshot the
server discarded before the stream drained; the engine maps it to a
retryable 503 like Transient.

Indexes: the description keeps every index in whatever build state it
had and drops only one being deleted (drop_deleting_indexes, for GSIs
and vector indexes; no in-tree backend reports a DELETING index today,
and unit tests pin the rule); on PostgreSQL a GSI row whose data table
is missing is also left out, with the attribute definitions only it
used, as the in-catalog backup does (since #401 UpdateTable creates the
data table before committing the row, so this is a guard, not an
expected state). A restore recreates the indexes in the description and
builds them from the items, so a CREATING index with a data table comes
back complete; one left out would not come back at all.

PostgreSQL, one shared database: the open uses one connection and one
REPEATABLE READ READ ONLY transaction whose first statement reads the
tables row, so the definition and the items come from one MVCC snapshot
and no row lock is taken; a DeleteTable that drops the data table
between that snapshot and the open's LOCK TABLE makes the open
TableNotFound. Separate data database: the tables row is held FOR SHARE
on a catalog connection from the definition read until the data
transaction (REPEATABLE READ READ ONLY, ACCESS SHARE on the relation)
has pinned its snapshot with SELECT pg_current_snapshot() and the GSI
data-table probe has run; the definition is read on that barrier
connection (build_table_description_on), so an open holds one catalog
connection and the stream one data connection. UpdateTable and
DeleteTable take the row FOR UPDATE, so neither commits in the gap;
index-status workers do not, so an index may be reported in a slightly
older status than the items. The marker is pg_current_snapshot() text on
both. An engine-wide permit is held per snapshot from the open until the
stream's transaction ends, half the pool on either layout, so open
snapshots leave half of the pool their items come from to requests (with
a separate data database and the whole pool allowed, ten open snapshots
made GetItem wait out the pool timeout); restores' own half-pool slots
are separate, so snapshots and restores together can hold the whole
pool, and the field doc says so. Items stream through a server-side
cursor, FETCH 1000 per round trip, ordered by the primary key. A fetch
is bounded by rows, not bytes (up to 27.7 MB of text per row at the
default item limit); the byte budget is the backup job PR's. A table
whose text key columns do not have a collation named "C" or "POSIX"
(created before the key columns were declared with it; "default" on a C
database also counts as not bytewise, which streams unordered and
complete) is streamed unordered, with a warning: ordering it would turn
the read into a sort of the whole table before the first row. SQLSTATE
40001 (recovery conflict on a standby) and 72000 (snapshot_too_old on a
primary) map to SnapshotExpired, pinned by a test that raises each code
on the live server. The stream holds ACCESS SHARE on the data table
until its last fetch, so a DROP TABLE waits for it; DeleteTable's
synchronous path, which commits the catalog removal before the drop, now
takes the asynchronous path (row DELETING, worker drop under a lock
timeout) when another session holds ACCESS SHARE on any relation it
would drop (the base, GSI, LSI and vector-index data tables:
data::table_storage_relations, the one list drop_table_storage also
drops), probing only when its answer is used (delay < 1 s) and, on the
shared layout, on its own catalog transaction rather than a second
connection from the same pool (twenty concurrent DeleteTables on a
ten-connection shared pool timed out without that), so a caller
cancelled during the wait cannot leave the row gone and a data table
orphaned. On the shared layout a DeleteTable probing between an open's
snapshot and its LOCK still takes the synchronous path and its DROP
waits behind that stream; the module doc states the two-statement
window. A queue worker's row-level claim is not matched; the drop still
waits for it, as #401 intends. The data transaction lives in a guard
from BEGIN on, so every path that ends it after it may have taken ACCESS
SHARE wakes the control-plane poller: the last page (before its commit
is awaited), a fetch error, a stream dropped before its end, and an open
that fails or is cancelled. A DeleteTable left DELETING finishes within
a second (about 160 ms measured) rather than at the poller's next idle
pass.

SQLite: one deferred read transaction covers catalog and data; its first
read pins the WAL snapshot and no lock is taken, so writers never wait
on a snapshot being opened and the WAL grows until the stream ends (a
TRUNCATE checkpoint waits out the busy timeout and reports busy; the
module doc gives the measured reply). #384's
create_backup_from_wal_snapshot holds the writer lock while it pins
because it also inserts the CREATING row; this path writes nothing.
build_table_description_on reads the definition, including #384's
restore_summary from table_restores, on that transaction. The stream
holds a backup slot (backup_server_slots, shared with the in-catalog
backup) for its life, so the pool arithmetic that keeps half the pool
free counts snapshots; with no slot free the open returns LimitExceeded
at once, and a CreateBackup that waited out its 30 s for a slot says
that backups or table snapshots hold them. The pool_size doc gives the
arithmetic. Items page through row-value keyset pagination on the
primary key index, so key_ordered is true for single-range-key tables;
multi-part keys fall back to rowid order. A page is bounded by rows, not
bytes, as on PostgreSQL. An in-memory database has one connection, so
the open reads every page in its transaction and returns a stream over
the copy, holding no connection once it returns; the copy's stream keeps
its backup slot until it ends or is dropped, so the slots bound how many
decoded copies exist, as #384's create_backup_single_connection does for
the in-catalog backup.

Cassandra: open_table_snapshot returns Unsupported. Cassandra has no
read that fixes an instant across a table (a token-range scan observes
each range at a different time; LWT serializes single partitions), and
its in-catalog logical backup says so (ADR-0019). Returning that copy
under this trait's contract would make the manifest's snapshot member
false, so the backend refuses and the in-catalog BackupEngine path stays
its backup path. A direct test asserts the refusal naming the backend on
an existing table and a missing one.

MongoDB: a session started with snapshot=true reads the tables document,
the indexes documents (doc_to_table_description_in now takes an optional
session) and the base collection at one cluster time; the marker is that
time. Every read sets readConcern: snapshot explicitly. The production
catalog handle carries readConcern: local (as a URI with
readConcernLevel= would), and on it a read without the level fails: the
first read reports no snapshot time, which the open refuses naming the
read concern, and a later read in the pinned session is refused by the
server with code 72 (InvalidOptions). A server that is not a replica set
member refuses the first read with NotAReplicaSet (123), mapped to
Unsupported. SnapshotTooOld (239) and SnapshotUnavailable (246) map to
SnapshotExpired, pinned by a unit test that builds the command error
from a reply document. Error messages carry the server's code name and
message, not the driver's dump of the reply. A document the stream
cannot decode ends it with that error. key_ordered is false: for a
composite key _id is a netstring of the key text, which is not the
format's order for any key type.

Test hooks (extenddb_storage::snapshot::paging): a page-size knob, a
round-trip counter, and a pause at two points of the open
(AfterDefinition, BeforeItems) that the conformance check holds while it
commits writes. PostgreSQL and SQLite record each FETCH or page query;
for MongoDB, whose driver hides getMore, the test fixture records,
through command monitoring, each find the server receives on the data
database at readConcern snapshot (the stream's; the GSI backfill's find
reads at majority) and each getMore on a cursor those finds returned, so
the count is the snapshot stream's paging as the server saw it. They and
the conformance check compile only with the test-hooks feature of
extenddb-storage, which the test-hooks features of storage-postgres and
storage-sqlite and the new snapshot-test-hooks of storage-mongodb enable
(storage-mongodb's existing test-hooks, the integration binary's
coordination hooks, is unchanged). The backend crates' tests enable them
through a dev-dependency on themselves. Production builds carry neither
the hooks nor the harness. PostgreSQL also gains a test-hooks
spawn_control_plane_poller_for_tests that runs the production poller.

Tests: extenddb_storage::snapshot_conformance::run is one check every
backend runs against a live database. It sets the shared page size to
50, opens a snapshot on a 700-item composite-key table with a GSI, reads
51 items and asserts the round-trip counter advanced by at least two
since just before the open (so a backend that ignored the knob, or a
MongoDB cursor without batch_size, fails here instead of passing
vacuously), then a writer deletes 100 items, inserts 100, and overwrites
all 600 remaining items twice before the reader drains. Asserts: 700
items, no duplicates, every deleted item present, no inserted item,
every version at its pre-open value, the counter advanced after the
writer committed (a stream that read every page during the open fails),
key_ordered as the backend declares it and strictly ascending keys by
KeyOrder::compare (the format's comparator) when ordered, and a second
snapshot taken after the writer stops shows the final round. Then, once
per pause point, on a fresh table: with the open held, it commits W1, an
UpdateTable adding a GSI, and W2 (in that order; on PostgreSQL with a
separate data database the UpdateTable waits for the row lock and the
open resumes after 2 s), and asserts what one instant implies: a
description with the new GSI has W1, items with W2 have the GSI in the
description, and W2 is not in the items at all, since the pause is
inside the open's read window (a backend whose pauses sat before both
reads passed the two implications). READ COMMITTED or no FOR SHARE on
PostgreSQL, the data cursor or the index read outside the session on
MongoDB, and the pauses moved outside the read window on SQLite or
MongoDB, fail it. Six more key shapes (hash-only S, N and B; S hash with
S, N or B range) run at a page of 3 with keys whose byte, collation and
base64 orders differ and range numbers whose text and numeric orders
differ (negative, fractional, 1E-130, 9.9E125, and 38- and 34-digit
pairs a last digit apart; the check's Expectations say how many digits a
backend stores, and MongoDB's refusal of the 38-digit pair at the put,
Decimal128's 34 digits, is asserted). Every test that opens a snapshot
or touches the hooks holds snapshot_conformance::hooks_lock(), whose
guard run and run_refusals take as an argument, so no other test in the
binary can take the pause, reset the knob or feed the counter during a
check. run_refusals covers a missing and a non-ACTIVE table on the three
backends that implement snapshots. The suites pass on PostgreSQL 16
(both layouts), file-backed SQLite and MongoDB 7 (replica set; the
standalone refusal against a standalone mongod), and the Cassandra test
on Cassandra 4.1.

PG: tests/table_snapshot.rs (consistency, marker and key order, tables
with legacy-collation key columns streaming unordered and complete on a
scratch database whose collation is checked not to be C, five held
snapshots on a ten-connection pool leaving GetItem and DescribeTable
unblocked with the sixth LimitExceeded and an open succeeding after one
is dropped, a GSI without its data table left out with its attribute,
DeleteTable during a snapshot returning DELETING and finishing within 5
s of the snapshot's release with the production poller running, for a
dropped stream, a drained stream, a fetch error and an open cancelled at
BeforeItems, DeleteTable with only a GSI or vector-index data table
locked taking the async path, an unparseable row ending the stream and
freeing its permit, twenty concurrent DeleteTables on a ten-connection
pool, an open completing with nine of ten connections held, an open held
while nine DeleteTables queue, DeleteTable at BeforeItems taking the
async path, a fetch error freeing the permit, a drained stream out of
its transaction before its end is polled, all on both pool layouts, a
table dropped before the shared-layout LOCK being TableNotFound, the
SQLSTATE mapping) with a Drop guard that drops the scratch databases on
success or panic, added to the direct-storage step in integration.yml.
SQLite: in-module tests (consistency, key order, the in-memory copy
releasing the connection, in-memory copies holding their slot, a damaged
attribute definition streaming unordered and complete, the rowid
fallback at a page of seven with no duplicates and at least six fetches,
slots shared with CreateBackup and its refusal text, the WAL checkpoint
reply while a snapshot is held) with the same guard for the scratch
files. MongoDB: in-module tests (consistency, an undecodable document
ending the stream, reads without the snapshot level failing with code 72
and the error text, the code mapping, the round-trip counter counting
only the snapshot stream (a GSI backfill leaves it unchanged), and a
standalone server answering Unsupported, gated on
EXTENDDB_TEST_MONGODB_STANDALONE_URI, which the CI job sets against a
standalone mongod container it starts) on per-test scratch databases
built without the bootstrapper, as #384's live tests do, the catalog
handle with the production option set, dropped by a guard on success or
panic, so a URI in the environment never makes cargo test touch another
instance's databases; gated on EXTENDDB_TEST_MONGODB_URI (failing rather
than skipping when EXTENDDB_TEST_MONGODB_REQUIRED is set, which the CI
job does), holding the snapshot hooks lock, run by a new
integration-mongodb.yml job that starts a replica set through
devtools/run-mongodb-tests --mongo-only and a standalone mongod (both on
127.0.0.1 only).

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 28, 2026
Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), five review rounds (the fifth
on 2026-10-27, Lee's reviews among them) against the companion PRs #393,
#394 and #395, and a reconciliation against main abf8082 (#384 in its
final form, the Cassandra backend #338, the PostgreSQL GSI build #401).
The revision log at the end of the document lists each change; the ones
that alter behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). Indexes are reported in
  whatever build state they had and only an index being deleted is
  dropped; a GSI without its data table is left out with the attribute
  definitions only it used. On PostgreSQL's shared layout the open is
  one connection and one REPEATABLE READ transaction; on the separate
  layout the catalog row is held FOR SHARE until the data snapshot pins,
  and an index that becomes ACTIVE between the definition read and the
  pin is reported CREATING. PostgreSQL bounds open snapshots at half the
  pool and refuses more with LimitExceeded, so open snapshots leave half
  the pool to requests; restores hold their own half-pool slots. It
  streams a table whose key columns predate COLLATE "C" unordered rather
  than sort it, and DeleteTable takes its asynchronous path while a
  reader holds any relation it would drop, so its DROP does not wait
  behind a snapshot or backup, with two stated gaps: a short GetItem,
  Query or Scan between the probe and the DROP, and on the shared layout
  a probe between an open's snapshot and its LOCK. On sqlite-memory the
  snapshot copies the table on the one connection inside the request and
  keeps a backup slot until its stream ends, so CreateBackup keeps
  working there once [backup.store] is set. MongoDB reads without the
  explicit snapshot level fail, and the document says so with the
  server's error.
- The snapshot is opened in the CreateBackup request (stricter than the
  service's two-minute window), and the backup id is minted from the
  instant the open returned, just after the pin, so the backup holds
  every write acknowledged before the request was received, and
  backup_creation_date_time, the catalog's created_at and the ARN carry
  one time and the ListBackups cursor is a function of the ARN.
- Per-job claims with fencing replace the single-instance lease the
  draft assumed: an advisory lock on a dedicated catalog connection on
  PostgreSQL (#384's seed and key), the row itself on MongoDB (born
  through an upsert pipeline with $literal), an in-process slot on
  SQLite, a lightweight-transaction row on Cassandra (restore targets).
  Heartbeats are a timer on the database's clock with a deadline; any
  heartbeat that does not confirm the claim aborts the job. Every job
  status write is conditioned on the generation and on the row's state,
  and an ambiguous AVAILABLE update is re-read. A job deletes a prefix,
  or a restore target, only after its FAILED write matched a row. The
  reconciler's duties are listed once, in section 4, and include the
  prefixes of DELETED rows, orphan restore targets, stale spools and, on
  Cassandra, restores_by_backup entries older than the stale window with
  no RUNNING job (that entry cannot share a lightweight transaction with
  the claim, so it is written first, carries its write time and is only
  a hint). Every duty that reads or writes a backup row acts only on
  rows of the configured store (store_kind, store_root and the store
  id); a CREATING or DELETING row of another store is reported and left
  alone.
- DeleteBackup tombstones the row as DELETING, deletes the manifest
  first, and marks DELETED only when the prefix is empty. backup sync is
  scoped to the configured store and checks the store id in
  extenddb-store.json, so an unmounted volume or a recreated bucket
  fails loudly; it never adopts a CREATING, DELETING, DELETED or FAILED
  prefix, and marks a row whose manifest head is gone DELETING only with
  --prune-missing. --move-store resolves the old root's CREATING and
  DELETING rows, compares every data file, refuses to carry a tombstone
  over a verified manifest and offers --forget-backup; --forget-store
  deletes the rows of a root. purge, --move-store, --forget-store and
  --prune-missing take a catalog maintenance claim that every job
  checks; max_concurrent_jobs is per instance. DeleteBackup and Restore
  of another store's row return a ValidationException that names no
  root. Restore and Describe never read the store without a catalog row;
  an ARN with no row goes to the legacy reader.
- Restore returns CREATING from the request (TableAlreadyExists or
  TableInUse on a name race, as #384 returns), spools and verifies every
  data file before writing any item, and records progress in a way a
  crash cannot split from the data on each backend. A key-ordered
  restore runs #394's per-file key-range check and an unordered one ends
  with a count of the target, which is Cassandra's only duplicate check.
  A PostgreSQL restore is resumed, or its target deleted, only after the
  stale window; the manifest and sidecar are spooled for a resume; a
  legacy restore with a dead owner is failed, not resumed. The ACTIVE
  flip precedes the deletion of the PostgreSQL progress rows.
  DeleteTable on a restore target stays refused, as #384 and the service
  refuse it; the restore record is the table's child. Multi-part-key
  tables are restored (#384 refuses them). Table class is not restored,
  as on the service and #384; every override but VectorIndexOverride is
  applied, as #384 applies them.
- The manifest self-checksum is replaced by a sidecar over the stored
  bytes, accepted in its sha256sum line form only; backup_arn is the
  backup's identity and source_table must agree with it; N key values
  are normalized before key text or key order is computed, and an
  un-normalized key_range endpoint is refused; the version gate accepts
  1 to the current version. The line cap is 32 MiB, sized from the
  measured worst case, and the compressed size is enforced while
  reading; both are format rules in section 1. Section 7 states the
  decode memory from #394's DataFileDecoder doc: about 76 bytes per line
  byte for flat or nested lists and at most about 128 for any shape,
  about 4 GiB at the cap, after #394 stopped the deserializer copying
  nested values.
- Store (#393): owner-only root and directories re-checked by every
  operation, an ancestor writable by group or others without the sticky
  bit refused, a root whose canonical path changed since open refused,
  the prefix resolved with the same no-follow walk as a key, listings
  that report only keys passing the key rule, multipart uploads aborted
  when a put is dropped and swept by delete_prefix (a lifecycle rule as
  backstop), validate by a one-key listing so a 403 keeps S3's code and
  then by probes of the grants, a GetObject of an absent key included, a
  255-byte component cap and U+FFFE/U+FFFF refused by the key rule,
  settings under [backup.store]. Section 8 gains a transport rule (a
  non-loopback http:// endpoint, from the configuration or from the
  SDK's own endpoint sources, needs allow_insecure_endpoint; localhost
  over http needs force_path_style) and an IAM example with the list
  actions on the bucket, the KMS actions for an SSE-KMS bucket, the rule
  that a missing key reads as 404 only under s3:ListBucket, and a
  delete-marker lifecycle rule for versioned buckets.
- Configuration: the default store root is per backend (beside the
  database file on file-backed SQLite); PostgreSQL, MongoDB and
  sqlite-memory refuse CreateBackup until [backup.store] is set, and
  keep serving legacy backups. Cassandra keeps creating in-catalog
  backups through the legacy create until PR 14, whether or not
  [backup.store] is set. The memory formula is in item text bytes, and
  PR 8a adds a byte budget to the PostgreSQL and SQLite snapshot
  streams; the PostgreSQL pool rule is max(MIN_POOL_SIZE, 2 *
  max_concurrent_jobs + 10); the spool default is per backend.
- Phase 1 is the PR series with acceptance criteria and GitHub numbers;
  migration numbers are reserved up front (Cassandra V006 to PR 1, V007
  to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill
  points for the crash tests are enumerated per backend and for
  DeleteBackup. The Phasing section names the companion heads the copied
  values come from.

Also adds the missing rows for designs 12 and 13 to
docs/design/README.md and marks 15 as a draft.

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 28, 2026
…ackend

Adds the one new obligation the engine-owned backup path places on a
backend: open a consistent view of one table and stream its items. The
trait returns the table definition, a snapshot marker, an ordering flag,
and the item stream together, from one operation, so a backup's manifest
and data files always describe the same instant. The in-catalog backup
code keeps its own copy of this logic for now; the engine-side backup
job (next PR) is the first caller. Stacked on #394: SnapshotInfo is the
manifest's SnapshotDescriptor (one definition, with the three KIND_*
constants), and key_schema_is_orderable is KeyOrder::resolve, so a
stream a backend marks ordered is one the manifest parser accepts and a
table whose range key has no attribute definition streams unordered and
complete instead of paging by the hash key alone.

Trait (crates/storage/src/snapshot.rs): open_table_snapshot(account_id,
table_name) returns TableSnapshot { description: TableDescription, info:
SnapshotInfo, key_ordered: bool, items: BoxStream<Result<Item>> }. The
description is the storage-level one at the snapshot instant, not the
API response (restore_summary may be set, table_throughput_mode_summary
is not, the counts are the stats pass's); items are yielded as stored
(valid N normalized, invalid raw, TTL-expired unswept items included),
and any error means start over, SnapshotExpired being the one worth
retrying. The stream owns the backend transaction or session; dropping
it releases it. A stream that has not reached its end (including one
never polled) must be dropped inside a Tokio runtime: the sqlx backends
return its connection through the runtime and panic outside one, MongoDB
does not, and the trait doc says so. The first Err ends the stream on
every backend. StorageEngine now requires SnapshotEngine; all four
in-tree backends implement it. The trait doc names what each backend
holds while a stream is open and which errors each returns (Cassandra
answers Unsupported before it resolves the table), the idle timeouts
that end a stream as Internal, and why the trait has no default body and
StorageError stays exhaustive; PostgreSQL and SQLite bound open
snapshots per server (below) and refuse over the bound with
LimitExceeded. StorageError gains SnapshotExpired for a snapshot the
server discarded before the stream drained; the engine maps it to a
retryable 503 like Transient.

Indexes: the description keeps every index in whatever build state it
had and drops only one being deleted (drop_deleting_indexes, for GSIs
and vector indexes; no in-tree backend reports a DELETING index today,
and unit tests pin the rule); on PostgreSQL a GSI row whose data table
is missing is also left out, with the attribute definitions only it
used, as the in-catalog backup does (since #401 UpdateTable creates the
data table before committing the row, so this is a guard, not an
expected state). A restore recreates the indexes in the description and
builds them from the items, so a CREATING index with a data table comes
back complete; one left out would not come back at all.

PostgreSQL, one shared database: the open uses one connection and one
REPEATABLE READ READ ONLY transaction whose first statement reads the
tables row, so the definition and the items come from one MVCC snapshot
and no row lock is taken; a DeleteTable that drops the data table
between that snapshot and the open's LOCK TABLE makes the open
TableNotFound. Separate data database: the tables row is held FOR SHARE
on a catalog connection from the definition read until the data
transaction (REPEATABLE READ READ ONLY, ACCESS SHARE on the relation)
has pinned its snapshot with SELECT pg_current_snapshot() and the GSI
data-table probe has run; the definition is read on that barrier
connection (build_table_description_on), so an open holds one catalog
connection and the stream one data connection. UpdateTable and
DeleteTable take the row FOR UPDATE, so neither commits in the gap;
index-status workers do not, so an index may be reported in a slightly
older status than the items. The marker is pg_current_snapshot() text on
both. An engine-wide permit is held per snapshot from the open until the
stream's transaction ends, half the pool on either layout, so open
snapshots leave half of the pool their items come from to requests (with
a separate data database and the whole pool allowed, ten open snapshots
made GetItem wait out the pool timeout); restores' own half-pool slots
are separate, so snapshots and restores together can hold the whole
pool, and the field doc says so. Items stream through a server-side
cursor, FETCH 1000 per round trip, ordered by the primary key. A fetch
is bounded by rows, not bytes (up to 27.7 MB of text per row at the
default item limit); the byte budget is the backup job PR's. A table
whose text key columns do not have a collation named "C" or "POSIX"
(created before the key columns were declared with it; "default" on a C
database also counts as not bytewise, which streams unordered and
complete) is streamed unordered, with a warning: ordering it would turn
the read into a sort of the whole table before the first row. SQLSTATE
40001 (recovery conflict on a standby) and 72000 (snapshot_too_old on a
primary) map to SnapshotExpired, pinned by a test that raises each code
on the live server. The stream holds ACCESS SHARE on the data table
until its last fetch, so a DROP TABLE waits for it; DeleteTable's
synchronous path, which commits the catalog removal before the drop, now
takes the asynchronous path (row DELETING, worker drop under a lock
timeout) when another session holds ACCESS SHARE on any relation it
would drop (the base, GSI, LSI and vector-index data tables:
data::table_storage_relations, the one list drop_table_storage also
drops), probing only when its answer is used (delay < 1 s) and, on the
shared layout, on its own catalog transaction rather than a second
connection from the same pool (twenty concurrent DeleteTables on a
ten-connection shared pool timed out without that), so a caller
cancelled during the wait cannot leave the row gone and a data table
orphaned while a snapshot, a backup or a plain read holds the table (a
write transaction, a GSI backfill batch or a queue claim hold ROW
SHARE/ROW EXCLUSIVE/SHARE, which the probe does not match; the drop
still waits behind them with the row gone, for one batch or request). On
the shared layout a DeleteTable probing between an open's snapshot and
its LOCK still takes the synchronous path and its DROP waits behind that
stream; the module doc states the two-statement window. The data
transaction lives in a guard from BEGIN on, so every path that ends it
after it may have taken ACCESS SHARE wakes the control-plane poller: the
last page (before its commit is awaited), a fetch error, a stream
dropped before its end, and an open that fails or is cancelled; the
in-catalog backup's data transaction is held in the same guard and wakes
it too. The poller stays active, polling every second, while a DELETING
row's drop is deferred by a reader, so a DeleteTable left DELETING
finishes within about a second of the reader's release whoever the
reader is (a snapshot, the in-catalog backup, a plain transaction,
pg_dump); the snapshot stream's and the backup's release also wake it,
which makes the common case about 160 ms. The admin guide's
control_plane_delay_seconds row and the troubleshooting page's DELETING
entry state the bound, the same-name CreateTable refusal until then, and
how to find the reader.

SQLite: one deferred read transaction covers catalog and data; its first
read pins the WAL snapshot and no lock is taken, so writers never wait
on a snapshot being opened and the WAL grows until the stream ends (a
TRUNCATE checkpoint waits out the busy timeout and reports busy; the
module doc gives the measured reply). #384's
create_backup_from_wal_snapshot holds the writer lock while it pins
because it also inserts the CREATING row; this path writes nothing.
build_table_description_on reads the definition, including #384's
restore_summary from table_restores, on that transaction. The stream
holds a backup slot (backup_server_slots, shared with the in-catalog
backup) for its life, so the pool arithmetic that keeps half the pool
free counts snapshots; with no slot free the open returns LimitExceeded
at once, and a CreateBackup that waited out its 30 s for a slot says
that backups or table snapshots hold them. The pool_size doc gives the
arithmetic. Items page through row-value keyset pagination on the
primary key index, so key_ordered is true for single-range-key tables;
multi-part keys fall back to rowid order. A page is bounded by rows, not
bytes, as on PostgreSQL. An in-memory database has one connection, so
the open reads every page in its transaction and returns a stream over
the copy, holding no connection once it returns; the copy's stream keeps
its backup slot until it ends or is dropped, so the slots bound how many
decoded copies exist, as #384's create_backup_single_connection does for
the in-catalog backup.

Cassandra: open_table_snapshot returns Unsupported. Cassandra has no
read that fixes an instant across a table (a token-range scan observes
each range at a different time; LWT serializes single partitions), and
its in-catalog logical backup says so (ADR-0019). Returning that copy
under this trait's contract would make the manifest's snapshot member
false, so the backend refuses and the in-catalog BackupEngine path stays
its backup path. A direct test asserts the refusal naming the backend on
an existing table and a missing one.

MongoDB: a session started with snapshot=true reads the tables document,
the indexes documents (doc_to_table_description_in now takes an optional
session) and the base collection at one cluster time; the marker is that
time. Every read sets readConcern: snapshot explicitly. The production
catalog handle carries readConcern: local (as a URI with
readConcernLevel= would), and on it a read without the level fails: the
first read reports no snapshot time, which the open refuses naming the
read concern, and a later read in the pinned session is refused by the
server with code 72 (InvalidOptions). A server that is not a replica set
member refuses the first read with NotAReplicaSet (123), mapped to
Unsupported. SnapshotTooOld (239) and SnapshotUnavailable (246) map to
SnapshotExpired, pinned by a unit test that builds the command error
from a reply document; the server keeps the snapshot for at least
minSnapshotHistoryWindowInSeconds and may discard it after, and an idle
cursor times out after cursorTimeoutMillis (10 minutes, CursorNotFound
mapped to Internal). Error messages carry the server's code name and
message, not the driver's dump of the reply. A document the stream
cannot decode ends it with that error. key_ordered is false: for a
composite key _id is a netstring of the key text, which is not the
format's order for any key type.

Test hooks (extenddb_storage::snapshot::paging): a page-size knob, a
round-trip counter, and a pause at two points of the open
(AfterDefinition, BeforeItems) that the conformance check holds while it
commits writes. PostgreSQL and SQLite record each FETCH or page query;
for MongoDB, whose driver hides getMore, the test fixture records,
through command monitoring, each find the server receives on the data
database at readConcern snapshot (the stream's; the GSI backfill's find
reads at majority) and each getMore on a cursor those finds returned, so
the count is the snapshot stream's paging as the server saw it. They and
the conformance check compile only with the test-hooks feature of
extenddb-storage, which the test-hooks features of storage-postgres and
storage-sqlite and the new snapshot-test-hooks of storage-mongodb enable
(storage-mongodb's existing test-hooks, the integration binary's
coordination hooks, is unchanged). The backend crates' tests enable them
through a dev-dependency on themselves. Production builds carry neither
the hooks nor the harness. PostgreSQL also gains a test-hooks
spawn_control_plane_poller_for_tests that runs the production poller and
test-hooks re-exports of the data relation names and of
apply_all_migrations, so tests/table_snapshot.rs binds the crate's lists
instead of copying them.

Tests: extenddb_storage::snapshot_conformance::run is one check every
backend runs against a live database. It sets the shared page size to
50, opens a snapshot on a 700-item composite-key table with a GSI, reads
51 items and asserts the round-trip counter advanced by at least two
since just before the open (so a backend that ignored the knob, or a
MongoDB cursor without batch_size, fails here instead of passing
vacuously), then a writer deletes 100 items, inserts 100, and overwrites
all 600 remaining items twice before the reader drains. Asserts: 700
items, no duplicates, every deleted item present, no inserted item,
every version at its pre-open value, the counter advanced after the
writer committed (a stream that read every page during the open fails),
key_ordered as the backend declares it and strictly ascending keys by
KeyOrder::compare (the format's comparator) when ordered, and a second
snapshot taken after the writer stops shows the final round. Then, once
per pause point, on a fresh table: with the open held, it commits W1, an
UpdateTable adding a GSI, and W2 (in that order; on PostgreSQL with a
separate data database the UpdateTable waits for the row lock and the
open resumes after 2 s), and asserts what one instant implies: a
description with the new GSI has W1, items with W2 have the GSI in the
description, and W2 is not in the items at all, since the pause is
inside the open's read window (a backend whose pauses sat before both
reads passed the two implications), and which of W2's commit and the 2 s
timeout resumed the open, against the backend's declared
Expectations::update_table_waits_at (PostgreSQL with a separate data
database at AfterDefinition: the timeout; everywhere else: W2), so a
slow UpdateTable cannot make the W2 assertion vacuous. Before the writer
starts the snapshot's description must equal the storage layer's
describe_table with the running counts and the response-only summaries
cleared. Every drain is under a 120 s deadline naming the stage and the
items read, and the hooks lock gives up after 600 s naming the wait.
READ COMMITTED or no FOR SHARE on PostgreSQL, the data cursor or the
index read outside the session on MongoDB, and the pauses moved outside
the read window on SQLite or MongoDB, fail it, as do the pauses moved
outside the read window with a 2.5 s UpdateTable (the branch assertion),
a description built from a second code path (the DescribeTable
comparison), and a stream that never ends (the drain deadline, within
120 s instead of hanging the binary). Six more key shapes (hash-only S,
N and B; S hash with S, N or B range) run at a page of 3 with keys whose
byte, collation and base64 orders differ and range numbers whose text
and numeric orders differ (negative, fractional, 1E-130, 9.9E125, and
38- and 34-digit pairs a last digit apart; the check's Expectations say
how many digits a backend stores, and MongoDB's refusal of the 38-digit
pair at the put, Decimal128's 34 digits, is asserted). Every test that
opens a snapshot or touches the hooks holds
snapshot_conformance::hooks_lock(), whose guard run and run_refusals
take as an argument, so no other test in the binary can take the pause,
reset the knob or feed the counter during a check. run_refusals covers a
missing and a non-ACTIVE table on the three backends that implement
snapshots. The suites pass on PostgreSQL 16 (both layouts), file-backed
SQLite and MongoDB 7 (replica set; the standalone refusal against a
standalone mongod), and the Cassandra test on Cassandra 4.1.

PG: tests/table_snapshot.rs (42 tests: consistency, marker and key
order, tables with legacy-collation key columns streaming unordered and
complete on a scratch database whose collation is checked not to be C,
five held snapshots on a ten-connection pool leaving GetItem and
DescribeTable unblocked with the sixth LimitExceeded and an open
succeeding after one is dropped, a GSI without its data table left out
with its attribute, DeleteTable during a snapshot returning DELETING and
finishing within 5 s of the snapshot's release with the production
poller running, for a dropped stream, a drained stream, a fetch error
and an open cancelled at BeforeItems, and, for a plain reader holding
ACCESS SHARE that sends no wake, held 10 s past the poller's window,
within 3 s of its release, DeleteTable with only a GSI or vector-index
data table locked taking the async path, an unparseable row ending the
stream and freeing its permit, twenty concurrent DeleteTables on a
ten-connection pool, an open completing with nine of ten connections
held, an open held while nine DeleteTables queue, DeleteTable at
BeforeItems taking the async path, a fetch error freeing the permit, a
drained stream out of its transaction before its end is polled, all on
both pool layouts, a table dropped before the shared-layout LOCK being
TableNotFound, the SQLSTATE mapping) with a Drop guard that drops the
scratch databases on success or panic, added to the direct-storage step
in integration.yml; the full storage-postgres suite is 220 tests.
SQLite: in-module tests (consistency, key order, the in-memory copy
releasing the connection, in-memory copies holding their slot, a damaged
attribute definition streaming unordered and complete, the rowid
fallback at a page of seven with no duplicates and at least six fetches,
slots shared with CreateBackup and its refusal text, the WAL checkpoint
reply while a snapshot is held) with the same guard for the scratch
files, created under the test binary's target directory. MongoDB:
in-module tests (consistency, an undecodable document ending the stream,
reads without the snapshot level failing with code 72 and the error
text, the code mapping, the round-trip counter counting only the
snapshot stream (a GSI backfill leaves it unchanged), and a standalone
server answering Unsupported, gated on
EXTENDDB_TEST_MONGODB_STANDALONE_URI, which the CI job sets against a
standalone mongod container it starts (and failing rather than skipping
when EXTENDDB_TEST_MONGODB_REQUIRED is set without it)) on per-test
scratch databases built without the bootstrapper, as #384's live tests
do, the catalog handle with the production option set, dropped by a
guard on success or panic, so a URI in the environment never makes cargo
test touch another instance's databases; gated on
EXTENDDB_TEST_MONGODB_URI (failing rather than skipping when
EXTENDDB_TEST_MONGODB_REQUIRED is set, which the CI job does), holding
the snapshot hooks lock, run by a new integration-mongodb.yml job that
starts a replica set through devtools/run-mongodb-tests --mongo-only and
a standalone mongod (both on 127.0.0.1 only).

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 28, 2026
Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), six review rounds (the sixth
on 2026-10-28, Lee's reviews among them) against the companion PRs #393,
#394 and #395, and a reconciliation against main abf8082 (#384 in its
final form, the Cassandra backend #338, the PostgreSQL GSI build #401).
The revision log at the end of the document lists each change; the ones
that alter behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). Indexes are reported in
  whatever build state they had and only an index being deleted is
  dropped; a GSI without its data table is left out with the attribute
  definitions only it used. On PostgreSQL's shared layout the open is
  one connection and one REPEATABLE READ transaction; on the separate
  layout the catalog row is held FOR SHARE until the data snapshot pins,
  and an index that becomes ACTIVE between the definition read and the
  pin is reported CREATING. PostgreSQL bounds open snapshots at half the
  pool and refuses more with LimitExceeded, so open snapshots leave half
  the pool to requests; from PR 6 on max_concurrent_jobs (default 4)
  bounds backups and restores together under that permit, and #384's
  restore slots go. It streams a table whose key columns predate COLLATE
  "C" unordered rather than sort it, and DeleteTable takes its
  asynchronous path while a reader holds any relation it would drop, so
  its DROP does not wait behind a snapshot or backup, with two stated
  gaps: a short GetItem, Query or Scan between the probe and the DROP,
  and on the shared layout a probe between an open's snapshot and its
  LOCK; the control-plane poller stays active while a drop is deferred,
  so the drop follows any reader's release within about a second. On
  sqlite-memory the snapshot copies the table on the one connection
  inside the request and keeps a backup slot until its stream ends, so
  CreateBackup keeps working there once [backup.store] is set. MongoDB
  reads without the explicit snapshot level fail, and the document says
  so with the server's error.
- The snapshot is opened in the CreateBackup request (stricter than the
  service's two-minute window), and the backup id is minted from the
  instant the open returned, just after the pin, so the backup holds
  every write acknowledged before the request was received, and
  backup_creation_date_time, the catalog's created_at and the ARN carry
  one time and the ListBackups cursor is a function of the ARN.
- Per-job claims with fencing replace the single-instance lease the
  draft assumed: an advisory lock on a dedicated catalog connection on
  PostgreSQL (#384's seed and key), the row itself on MongoDB (born
  through an upsert pipeline with $literal), an in-process slot on
  SQLite, a lightweight-transaction row on Cassandra (restore targets).
  Heartbeats are a timer on the database's clock with a deadline; any
  heartbeat that does not confirm the claim aborts the job. Every job
  status write is conditioned on the generation and on the row's state,
  and an ambiguous AVAILABLE update is re-read. A job deletes a prefix,
  or a restore target, only after its FAILED write matched a row. The
  reconciler's duties are listed once, in section 4, and include the
  prefixes of DELETED rows, orphan restore targets, stale spools and, on
  Cassandra, restores_by_backup entries older than the stale window with
  no RUNNING job (that entry cannot share a lightweight transaction with
  the claim, so it is written first, carries its write time and is only
  a hint). Every duty that reads or writes a backup row acts only on
  rows of the configured store (store_kind, store_root and the store
  id); a CREATING or DELETING row of another store is reported and left
  alone.
- DeleteBackup tombstones the row as DELETING, deletes the manifest
  first, and marks DELETED only when the prefix is empty. backup sync is
  scoped to the configured store and checks the store id in
  extenddb-store.json, so an unmounted volume or a recreated bucket
  fails loudly; it never adopts a CREATING, DELETING, DELETED or FAILED
  prefix, and marks a row whose manifest head is gone DELETING only with
  --prune-missing. --move-store resolves the old root's CREATING and
  DELETING rows, compares every data file, refuses to carry a tombstone
  over a verified manifest and offers --forget-backup; --forget-store
  deletes the rows of a root. purge, --move-store, --forget-store and
  --prune-missing take a catalog maintenance claim that every job
  checks; max_concurrent_jobs is per instance. DeleteBackup and Restore
  of another store's row return a ValidationException that names no
  root. Restore and Describe never read the store without a catalog row;
  an ARN with no row goes to the legacy reader.
- Restore returns CREATING from the request (TableAlreadyExists or
  TableInUse on a name race, as #384 returns), spools and verifies every
  data file before writing any item, and records progress atomically
  with the data on PostgreSQL and SQLite and by idempotent
  re-application of the one unrecorded file or batch on MongoDB and
  Cassandra; on MongoDB the job writes DONE and the backfill flag in one
  transaction and the worker's ACTIVE flip is conditioned on the flag,
  not on the job's generation. A key-ordered restore runs #394's
  per-file key-range check and an unordered one ends with a count of the
  target, which is Cassandra's only duplicate check. A PostgreSQL
  restore is resumed, or its target deleted, only after the stale
  window; the manifest and sidecar are spooled for a resume; a legacy
  restore with a dead owner is failed, not resumed. The ACTIVE flip
  precedes the deletion of the PostgreSQL progress rows. DeleteTable on
  a restore target stays refused, as #384 and the service refuse it; the
  restore record is the table's child. Multi-part-key tables are
  restored (#384 refuses them; bulk_load writes through the backend's
  own item path, so the table is laid out as a natively created one).
  Table class is not restored, as on the service and #384; every
  override but VectorIndexOverride is applied, as #384 applies them.
- The engine binds a BackupCatalog trait (PR 6) with a JobClaim for the
  per-backend claim, heartbeat and conditional status writes; new rows
  live in backup_index (a new table or collection; Cassandra reuses its
  tables with store_kind set and adds backup_index_by_status); MongoDB's
  catalog gets the versioned migration step it lacks; PR 6's migration
  backfills in-flight #384 restores so the first reconciler pass fails
  them; every pre-PR-6 server stops before migrate. Vector tables are
  backed up and restored on the off-host path (three #384 tests change
  in PR 8a/9a). source_table carries stream_specification and
  time_to_live for DescribeBackup. Both requests take the job permit
  last, after every refusal that needs no store access. The format's
  evolution rule is a version bump for any byte change, with v{N}
  fixtures beside v1.
- The manifest self-checksum is replaced by a sidecar over the stored
  bytes, accepted in its sha256sum line form only; backup_arn is the
  backup's identity and source_table must agree with it; N key values
  are normalized before key text or key order is computed, and an
  un-normalized key_range endpoint is refused; the version gate accepts
  1 to the current version. The line cap is 32 MiB, sized from the
  measured worst case, and the compressed size is enforced while
  reading; both are format rules in section 1. Section 7 cites the
  decode memory from #394's DataFileDecoder doc (76 and 128 bytes per
  line byte as of #394, measured without preserve_order; about 4 GiB at
  the cap).
- Store (#393): owner-only root and directories re-checked by every
  operation, an ancestor writable by group or others without the sticky
  bit refused, a root whose canonical path changed since open refused,
  the prefix resolved with the same no-follow walk as a key, listings
  that report only keys passing the key rule, multipart uploads aborted
  when a put is dropped and swept by delete_prefix (a lifecycle rule as
  backstop), validate by a one-key listing so a 403 keeps S3's code and
  then by probes of the grants, a GetObject of an absent key included, a
  255-byte component cap and U+FFFE/U+FFFF refused by the key rule,
  settings under [backup.store]. Section 8 gains a transport rule (a
  non-loopback http:// endpoint, from the configuration or from the
  SDK's own endpoint sources, needs allow_insecure_endpoint; localhost
  over http needs force_path_style) and an IAM example with the list
  actions on the bucket, the KMS actions for an SSE-KMS bucket, the rule
  that a missing key reads as 404 only under s3:ListBucket, and a
  delete-marker lifecycle rule for versioned buckets.
- Configuration: the default store root is per backend (beside the
  database file on file-backed SQLite, where a defaulted root that fails
  validation is a warning, not a startup error); without [backup.store],
  CreateBackup on every backend keeps the legacy in-catalog create with
  a deprecation warning until PR 14 removes it (Cassandra always, having
  no SnapshotEngine), and kind = "disabled" is the opt-out. A store
  transport failure at startup degrades the store component instead of
  stopping the server. The memory formula is in item text bytes, and PR
  8a adds the byte budget SNAPSHOT_FETCH_BYTES (64 MiB) to the
  PostgreSQL and SQLite snapshot streams; the PostgreSQL pool rule is
  max(MIN_POOL_SIZE, 2 * max_concurrent_jobs + 10), 18 at the default 4;
  the spool default is per backend and spool_host is host plus spool
  path.
- Phase 1 is the PR series with acceptance criteria and GitHub numbers;
  migration numbers are reserved up front (Cassandra V006 to PR 1, V007
  to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill
  points for the crash tests are enumerated per backend and for
  DeleteBackup. The Phasing section refers to the companion PRs' heads
  on the revision date rather than naming commits.

Also adds the missing rows for designs 12 and 13 to
docs/design/README.md and marks 15 as a draft.

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 29, 2026
…ackend

Adds the one new obligation the engine-owned backup path places on a
backend: open a consistent view of one table and stream its items. The
trait returns the table definition, a snapshot marker, an ordering flag,
and the item stream together, from one operation, so a backup's manifest
and data files always describe the same instant. The in-catalog backup
code keeps its own copy of this logic for now; the engine-side backup
job (next PR) is the first caller. Stacked on #394: SnapshotInfo is the
manifest's SnapshotDescriptor (one definition, with the three KIND_*
constants), and key_schema_is_orderable is KeyOrder::resolve, so a
stream a backend marks ordered is one the manifest parser accepts and a
table whose range key has no attribute definition streams unordered and
complete instead of paging by the hash key alone.

Trait (crates/storage/src/snapshot.rs): open_table_snapshot(account_id,
table_name) returns TableSnapshot { description: TableDescription, info:
SnapshotInfo, key_ordered: bool, items: BoxStream<Result<Item>> }. The
description is the storage-level one at the snapshot instant, not the
API response (restore_summary may be set, table_throughput_mode_summary
is not, the counts are the stats pass's); items are yielded as stored
(valid N normalized, invalid raw, TTL-expired unswept items included),
and any error means start over, SnapshotExpired being the one worth
retrying. The stream owns the backend transaction or session; dropping
it releases it. A stream that has not reached its end (including one
never polled) must be dropped inside a Tokio runtime: the sqlx backends
return its connection through the runtime and panic outside one, MongoDB
does not, and the trait doc says so. The first Err ends the stream on
every backend. StorageEngine now requires SnapshotEngine; all four
in-tree backends implement it. The trait doc names what each backend
holds while a stream is open and which errors each returns (Cassandra
answers Unsupported before it resolves the table), the idle timeouts
that end a stream as Internal, and why the trait has no default body and
StorageError stays exhaustive; PostgreSQL and SQLite bound open
snapshots per server (below) and refuse over the bound with
LimitExceeded. StorageError gains SnapshotExpired for a snapshot the
server discarded before the stream drained; the engine maps it to a
retryable 503 like Transient.

Indexes: the description keeps every index in whatever build state it
had and drops only one being deleted (drop_deleting_indexes, for GSIs
and vector indexes; no in-tree backend reports a DELETING index today,
and unit tests pin the rule); on PostgreSQL a GSI row whose data table
is missing is also left out, with the attribute definitions only it
used, as the in-catalog backup does (since #401 UpdateTable creates the
data table before committing the row, so this is a guard, not an
expected state). A restore recreates the indexes in the description and
builds them from the items, so a CREATING index with a data table comes
back complete; one left out would not come back at all.

PostgreSQL, one shared database: the open uses one connection and one
REPEATABLE READ READ ONLY transaction whose first statement reads the
tables row, so the definition and the items come from one MVCC snapshot
and no row lock is taken; a DeleteTable that drops the data table
between that snapshot and the open's LOCK TABLE makes the open
TableNotFound. Separate data database: the tables row is held FOR SHARE
on a catalog connection from the definition read until the data
transaction (REPEATABLE READ READ ONLY, ACCESS SHARE on the relation)
has pinned its snapshot with SELECT pg_current_snapshot() and the GSI
data-table probe has run; the definition is read on that barrier
connection (build_table_description_on), so an open holds one catalog
connection and the stream one data connection. UpdateTable and
DeleteTable take the row FOR UPDATE, so neither commits in the gap;
index-status workers do not, so an index may be reported in a slightly
older status than the items. The marker is pg_current_snapshot() text on
both. An engine-wide permit is held per snapshot from the open until the
stream's transaction ends, half the pool on either layout, so open
snapshots leave half of the pool their items come from to requests (with
a separate data database and the whole pool allowed, ten open snapshots
made GetItem wait out the pool timeout); restores' own half-pool slots
are separate, so snapshots and restores together can hold the whole
pool, and the field doc says so. Items stream through a server-side
cursor, FETCH 1000 per round trip, ordered by the primary key. A fetch
is bounded by rows, not bytes (up to 27.7 MB of text per row at the
default item limit); the byte budget is the backup job PR's. A table
whose text key columns do not have a collation named "C" or "POSIX"
(created before the key columns were declared with it; "default" on a C
database also counts as not bytewise, which streams unordered and
complete) is streamed unordered, with a warning: ordering it would turn
the read into a sort of the whole table before the first row. SQLSTATE
40001 (recovery conflict on a standby) and 72000 (snapshot_too_old on a
primary, PostgreSQL 16 and earlier) map to SnapshotExpired, pinned by a
test that raises each code on the live server. The stream holds ACCESS
SHARE on the data table until its last fetch, so a DROP TABLE waits for
it; DeleteTable takes the asynchronous path (row DELETING, worker drop
under a lock timeout) when another session holds ACCESS SHARE on any
relation it would drop (the base, GSI, LSI and vector-index data tables:
data::table_storage_relations, the one list drop_table_storage also
drops), probing only when its answer is used (delay < 1 s) and, on the
shared layout, on its own catalog transaction rather than a second
connection from the same pool (twenty concurrent DeleteTables on a
ten-connection shared pool timed out without that). On both layouts the
synchronous path takes every drop's lock before the catalog removal
commits. It deletes the table's pending queue rows first, waiting for
in-flight claims with no timeout, as #401 intends. It then drops the
base, GSI, LSI and vector-index data tables, base first, within one 1 s
budget for the group (SYNC_DROP_LOCK_TIMEOUT; before each DROP,
lock_timeout is the time left). On the shared layout that runs in the
catalog transaction behind a savepoint. With a separate data database it
runs in a data transaction opened first, and a task the request awaits
commits the catalog and then that transaction, so a cancelled caller
cannot stop between the two. A lock wait that fails (55P03, a deadlock
victim 40P01, or a statement_timeout's 57014) rolls the drops back and
takes the asynchronous path with the row still held. A write
transaction, a SHARE holder, an index build or a reader that starts
after the probe therefore costs the request at most the 1 s, and no data
table is left without its row; the one remaining window is a process
crash between the two commits on the separate layout, one round trip
wide. The base table goes first because a write holds it before it
writes an LSI or a synchronous GSI: with the index tables first, a write
in progress waited about 1 s on the drop, and the drop went
asynchronous. On the shared layout a DeleteTable probing between an
open's snapshot and its LOCK does not see the open; if the LOCK is
granted first, the drop times out after 1 s, is rolled back with the
removal, and DeleteTable returns DELETING (in 80 unaided open-and-delete
pairs, the delete first in half of them, 0 left a data table without a
row on either layout; 39 to 40 deletes ran synchronously and 40 to 41
asynchronously). The data transaction lives in a guard from BEGIN on, so
every path that ends it after it may have taken ACCESS SHARE wakes the
control-plane poller: the last page (before its commit is awaited), a
fetch error, a stream dropped before its end, and an open that fails or
is cancelled; the in-catalog backup's data transaction is held in the
same guard and wakes it too. The poller stays active while a DELETING
row's drop is deferred, also after a restart: startup recovery runs the
same pass and wakes the poller when it deferred a drop. A drop is
deferred when the probe finds another session holding any lock on the
table's relations, or when the drop fails in a lock wait (55P03, 40P01
or 57014); any other failure is logged and left to the normal window and
the 60 s idle pass. Each pass probes a held table and skips it without
waiting, and drops a free one within the same 1 s budget, so every held
table is retried about once a second however many are held, and a
CreateTable that is due is activated on the same cadence (1.1 s with
four drops deferred). A drop that DeleteTable deferred is due at once,
not after control_plane_delay_seconds. The active loop waits on the wake
as well as on its timer, so a snapshot's or an in-catalog backup's
release has the drop retried at once, and any holder's release is
followed within about a second: 108 to 166 ms in the tests, with a 10 s
test-only active poll for the wake cases. The admin guide's
control_plane_delay_seconds row and the troubleshooting page's DELETING
entry state the default (0.25 on every backend), the bound and the retry
cadence, the same-name CreateTable refusal until then, and a two-step
query, over every relation of the table, that finds any session holding
a lock on them and its lock mode. A DeleteTable of a table that is
already DELETING returns its description and changes nothing.

SQLite: one deferred read transaction covers catalog and data; its first
read pins the WAL snapshot and no lock is taken, so writers never wait
on a snapshot being opened and the WAL grows until the stream ends (a
TRUNCATE checkpoint waits out the busy timeout and reports busy; the
module doc gives the measured reply). #384's
create_backup_from_wal_snapshot holds the writer lock while it pins
because it also inserts the CREATING row; this path writes nothing.
build_table_description_on reads the definition, including #384's
restore_summary from table_restores, on that transaction. The stream
holds a backup slot (backup_server_slots, shared with the in-catalog
backup) until its read transaction ends (the short page that shows the
end, or the first error) or it is dropped, so the pool arithmetic that
keeps half the pool free counts snapshots; with no slot free the open
returns LimitExceeded at once, and a CreateBackup that waited out its 30
s for a slot says that backups or table snapshots hold them. The
pool_size doc gives the arithmetic. Items page through row-value keyset
pagination on the primary key index, so key_ordered is true for
single-range-key tables; multi-part keys fall back to rowid order. A
page is bounded by rows, not bytes, as on PostgreSQL. An in-memory
database has one connection, so the open reads every page in its
transaction and returns a stream over the copy, holding no connection
once it returns; the copy's stream keeps its backup slot until it has
yielded its last item or is dropped, so the slots bound how many decoded
copies exist, as #384's create_backup_single_connection does for the
in-catalog backup. SQLite DeleteTable of a DELETING table
(control_plane_delay_seconds of 1 or more) returns its description too.

Cassandra: open_table_snapshot returns Unsupported. Cassandra has no
read that fixes an instant across a table (a token-range scan observes
each range at a different time; LWT serializes single partitions), and
its in-catalog logical backup says so (ADR-0019). Returning that copy
under this trait's contract would make the manifest's snapshot member
false, so the backend refuses and the in-catalog BackupEngine path stays
its backup path. A direct test asserts the refusal naming the backend on
an existing table and a missing one.

MongoDB: a session started with snapshot=true reads the tables document,
the indexes documents (doc_to_table_description_in now takes an optional
session) and the base collection at one cluster time; the marker is that
time. Every read sets readConcern: snapshot explicitly. The production
catalog handle carries readConcern: local (as a URI with
readConcernLevel= would), and on it a read without the level fails: the
first read reports no snapshot time, which the open refuses naming the
read concern, and a later read in the pinned session is refused by the
server with code 72 (InvalidOptions). A server that is not a replica set
member refuses the first read with NotAReplicaSet (123), mapped to
Unsupported; each of the two refusals names its own cause.
SnapshotTooOld (239) and SnapshotUnavailable (246) map to
SnapshotExpired, pinned by a unit test that builds the command error
from a reply document; the server keeps the snapshot for at least
minSnapshotHistoryWindowInSeconds and may discard it after, and an idle
cursor times out after cursorTimeoutMillis (10 minutes, CursorNotFound
mapped to Internal). Error messages carry the server's code name and
message, not the driver's dump of the reply. A document the stream
cannot decode ends it with that error. key_ordered is false: for a
composite key _id is a netstring of the key text, which is not the
format's order for any key type.

Test hooks (extenddb_storage::snapshot::paging): a page-size knob, a
round-trip counter, and a pause at two points of the open
(AfterDefinition, BeforeItems) that the conformance check holds while it
commits writes. PostgreSQL and SQLite record each FETCH or page query;
for MongoDB, whose driver hides getMore, the test fixture records,
through command monitoring, each find the server receives on the data
database at readConcern snapshot (the stream's; the GSI backfill's find
reads at majority) and each getMore on a cursor those finds returned, so
the count is the snapshot stream's paging as the server saw it. They and
the conformance check compile only with the test-hooks feature of
extenddb-storage, which the test-hooks features of storage-postgres and
storage-sqlite and the new snapshot-test-hooks of storage-mongodb enable
(storage-mongodb's existing test-hooks, the integration binary's
coordination hooks, is unchanged). The backend crates' tests enable them
through a dev-dependency on themselves. Production builds carry neither
the hooks nor the harness. PostgreSQL also gains test-hooks entries
spawn_control_plane_poller_for_tests and
spawn_control_plane_poller_with_active_poll_for_tests (the production
poller, the second with its active retry interval as a parameter),
recover_control_plane_at_startup_for_tests,
control_plane_drop_attempts_for_tests (a per-engine count of drop
attempts, a held table the probe skipped counted),
arm_delete_table_pause (holds DeleteTable between its probe and its
drops), and re-exports of the data relation names and of
apply_all_migrations, so tests/table_snapshot.rs binds the crate's lists
instead of copying them. The two pauses (the open's and DeleteTable's)
are one generic type, extenddb_storage::test_pause::Pause, and
snapshot_conformance::PageSize is the one page-size guard the three
backends' tests use. tests/table_snapshot.rs fails instead of skipping
without a connection string when EXTENDDB_TEST_PG_REQUIRED is set, which
integration.yml sets.

Tests: extenddb_storage::snapshot_conformance::run is one check every
backend runs against a live database. It sets the shared page size to
50, opens a snapshot on a 700-item composite-key table with a GSI, reads
51 items and asserts the round-trip counter advanced by at least two
since just before the open (so a backend that ignored the knob, or a
MongoDB cursor without batch_size, fails here instead of passing
vacuously), then a writer deletes 100 items, inserts 100, and overwrites
all 600 remaining items twice before the reader drains. Asserts: 700
items, no duplicates, every deleted item present, no inserted item,
every version at its pre-open value, the counter advanced after the
writer committed (a stream that read every page during the open fails),
key_ordered as the backend declares it and strictly ascending keys by
KeyOrder::compare (the format's comparator) when ordered, and a second
snapshot taken after the writer stops shows the final round. Then, once
per pause point, on a fresh table: with the open held, it commits W1, an
UpdateTable adding a GSI, and W2 (in that order; on PostgreSQL with a
separate data database the UpdateTable waits for the row lock and the
open resumes after 2 s), and asserts what one instant implies: a
description with the new GSI has W1, items with W2 have the GSI in the
description, and W2 is not in the items at all, since the pause is
inside the open's read window (a backend whose pauses sat before both
reads passed the two implications), and which of W2's commit and the 2 s
timeout resumed the open, against the backend's declared
Expectations::update_table_waits_at (PostgreSQL with a separate data
database at AfterDefinition: the timeout; everywhere else: W2), so a
slow UpdateTable cannot make the W2 assertion vacuous. Before the writer
starts the snapshot's description must equal the storage layer's
describe_table with the running counts and table_throughput_mode_summary
cleared; restore_summary is compared, and each backend's suite also
compares a table given a restore record
(assert_snapshot_describes_the_table). Every drain is under a 120 s
deadline naming the stage and the items read, and a test waiting for the
hooks lock gives up when one holder has kept it 600 s, naming the wait.
READ COMMITTED or no FOR SHARE on PostgreSQL, the data cursor or the
index read outside the session on MongoDB, and the pauses moved outside
the read window on SQLite or MongoDB, fail it, as do the pauses moved
outside the read window with a 2.5 s UpdateTable (the branch assertion),
a description built from a second code path (the DescribeTable
comparison), and a stream that never ends (the drain deadline, within
120 s instead of hanging the binary). Six more key shapes (hash-only S,
N and B; S hash with S, N or B range) run at a page of 3 with keys whose
byte, collation and base64 orders differ and range numbers whose text
and numeric orders differ (negative, fractional, 1E-130, 9.9E125, and
38- and 34-digit pairs a last digit apart; the check's Expectations say
how many digits a backend stores, and MongoDB's refusal of the 38-digit
pair at the put, Decimal128's 34 digits, is asserted). Every test that
opens a snapshot or touches the hooks holds
snapshot_conformance::hooks_lock(), whose guard run and run_refusals
take as an argument, so no other test in the binary can take the pause,
reset the knob or feed the counter during a check. run_refusals covers a
missing and a non-ACTIVE table on the three backends that implement
snapshots. The suites pass on PostgreSQL 16 (both layouts), file-backed
SQLite and MongoDB 7 (replica set; the standalone refusal against a
standalone mongod), and the Cassandra test on Cassandra 4.1.

PG: tests/table_snapshot.rs (78 tests and two ignored race measurements:
consistency, marker and key order, tables with legacy-collation key
columns streaming unordered and complete on a scratch database whose
collation is checked not to be C, five held snapshots on a
ten-connection pool leaving GetItem and DescribeTable unblocked with the
sixth LimitExceeded and an open succeeding after one is dropped, a GSI
without its data table left out with its attribute, DeleteTable during a
snapshot returning DELETING, with the production poller at a 10 s active
poll the table gone within 3 s of a release during the pause after the
first drop attempt (for a dropped stream, a drained stream, a fetch
error, an open cancelled at BeforeItems and the in-catalog backup), for
a plain reader holding ACCESS SHARE that sends no wake, held 10 s past
the poller's window, the table gone within 3 s of its release, a reader
across a restart (the table gone within 3 s of its release), a drop
failing on a dependent view making at most 3 attempts in the 15 s after
the window, a restored table's restore_summary in the snapshot
description, DeleteTable with only a GSI or vector-index data table
locked taking the async path, an unparseable row ending the stream and
freeing its permit, twenty concurrent DeleteTables on a ten-connection
pool, an open completing with nine of ten connections held, an open held
while nine DeleteTables queue, DeleteTable at BeforeItems taking the
async path, a fetch error freeing the permit, a drained stream out of
its transaction before its end is polled, a ROW EXCLUSIVE and a SHARE
holder sending DeleteTable down the asynchronous path within about 1 s
with no data table left without its row, a DeleteTable cancelled during
that wait leaving the table ACTIVE with its data, write holders on the
base and three GSI tables released 0.8 s apart taking it there at about
1 s, a write holding the base and then writing the GSI table finishing
first with the table removed in the request, a claimed queue row holding
DeleteTable until it ends, a probed reader sending it asynchronous in
milliseconds, a statement timeout and a deadlock victim taking the
asynchronous path, DeleteTable of a DELETING table returning its
description and changing nothing, a deferred drop due at once, four held
tables probed with no lock wait (the first released gone within 2 s, a
CreateTable ACTIVE within 2 s), all on both pool layouts; on the shared
layout, a snapshot open or a GSI-only reader taking its lock after
DeleteTable's probe sending it down the asynchronous path with the row
and both data tables kept, and a table dropped before the shared-layout
LOCK being TableNotFound; the SQLSTATE mapping; the two ignored race
measurements, which delay the delete or the open in alternate pairs and
assert both outcomes occurred) with a Drop guard that drops the scratch
databases on success or panic, added to the direct-storage step in
integration.yml; the full storage-postgres suite is 256 tests. SQLite:
in-module tests (consistency, key order, the in-memory copy releasing
the connection, in-memory copies holding their slot, a damaged attribute
definition streaming unordered and complete, the rowid fallback at a
page of seven with no duplicates and at least six fetches, slots shared
with CreateBackup and its refusal text, the WAL checkpoint reply while a
snapshot is held, a restored table's restore_summary compared on a
file-backed and an in-memory engine, a drained stream and a stream that
hit an error freeing their slot while still held, and an in-memory copy
freeing its slot with its last item) with the same guard for the scratch
files, created under the test binary's target directory. MongoDB:
in-module tests (consistency, an undecodable document ending the stream,
reads without the snapshot level failing with code 72 and the error
text, the code mapping, a restored table's restore fields compared, the
round-trip counter counting only the snapshot stream (a GSI backfill
leaves it unchanged), and a standalone server answering Unsupported,
gated on EXTENDDB_TEST_MONGODB_STANDALONE_URI, which the CI job sets
against a standalone mongod container it starts (and failing rather than
skipping when EXTENDDB_TEST_MONGODB_REQUIRED is set without it)) on
per-test scratch databases built without the bootstrapper, as #384's
live tests do, the catalog handle with the production option set,
dropped by a guard on success or panic, so a URI in the environment
never makes cargo test touch another instance's databases; gated on
EXTENDDB_TEST_MONGODB_URI (failing rather than skipping when
EXTENDDB_TEST_MONGODB_REQUIRED is set, which the CI job does), holding
the snapshot hooks lock, run by a new integration-mongodb.yml job that
starts a replica set through devtools/run-mongodb-tests --mongo-only and
a standalone mongod (both on 127.0.0.1 only).
tests/test_table_operations.py: DeleteTable of a DELETING table returns
its description, on PostgreSQL and SQLite (the state written into the
catalog), skipped on MongoDB and Cassandra, which never leave a table
DELETING.

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 29, 2026
Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), seven review rounds and the
maintainer's reviews (on 2026-10-28 the sixth round, the maintainer's
review and the seventh round) against the companion PRs #393, #394 and
#395, and a reconciliation against main abf8082 (#384 in its final form,
the Cassandra backend #338, the PostgreSQL GSI build #401). The revision
log at the end of the document lists each change; the ones that alter
behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). Indexes are reported in
  whatever build state they had and only an index being deleted is
  dropped; a GSI without its data table is left out with the attribute
  definitions only it used. On PostgreSQL's shared layout the open is
  one connection and one REPEATABLE READ transaction; on the separate
  layout the catalog row is held FOR SHARE until the data snapshot pins,
  and an index that becomes ACTIVE between the definition read and the
  pin is reported CREATING. PostgreSQL bounds open snapshots at half the
  pool and refuses more with LimitExceeded, so open snapshots leave half
  the pool to requests; from PR 6 on max_concurrent_jobs (default 4)
  bounds backups and restores together under that permit, and #384's
  restore slots go. It streams a table whose key columns predate COLLATE
  "C" unordered rather than sort it, and on both layouts DeleteTable
  commits the catalog removal only after its drops succeed, base table
  first, under one 1 s budget, taking its asynchronous path with the row
  kept when its probe finds a reader or a drop fails in a lock wait
  (lock timeout, deadlock victim or statement timeout), so no DROP waits
  behind a reader with the row gone; on the separate layout a task the
  request awaits commits the catalog and then the data transaction; the
  control-plane poller probes held tables and retries each about once a
  second, at once on a snapshot's or an in-catalog backup's release, a
  drop DeleteTable deferred is due at once, and DeleteTable of a
  DELETING table returns its description on PostgreSQL and SQLite. On
  sqlite-memory the snapshot copies the table on the one connection
  inside the request and keeps a backup slot until its stream ends, so
  CreateBackup keeps working there once [backup.store] is set. MongoDB
  reads without the explicit snapshot level fail, and the document says
  so with the server's error.
- The snapshot is opened in the CreateBackup request (stricter than the
  service's two-minute window), and the backup id is minted from the
  instant the open returned, just after the pin, so the backup holds
  every write acknowledged before the request was received, and
  backup_creation_date_time, the catalog's created_at and the ARN carry
  one time and the ListBackups cursor is a function of the ARN.
- Per-job claims with fencing replace the single-instance lease the
  draft assumed: an advisory lock on a dedicated catalog connection on
  PostgreSQL (#384's seed and key), the row itself on MongoDB (born
  through an upsert pipeline with $literal), an in-process slot on
  SQLite, a lightweight-transaction row on Cassandra (restore targets).
  Heartbeats are a timer on the database's clock with a deadline; any
  heartbeat that does not confirm the claim aborts the job. Every job
  status write is conditioned on the generation and on the row's state,
  and an ambiguous AVAILABLE update is re-read. A job deletes a prefix,
  or a restore target, only after its FAILED write matched a row. The
  reconciler's duties are listed once, in section 4, and include the
  prefixes of DELETED rows, orphan restore targets, stale spools and, on
  Cassandra, active_restores entries whose target is not a CREATING
  restore target of the backup. Every duty that reads or writes a backup
  row acts only on rows of the configured store (store_kind, store_root
  and the store id); a CREATING or DELETING row of another store is
  reported and left alone. The PostgreSQL staleness test also selects a
  NULL heartbeat. While a maintenance claim is held the reconciler only
  finishes DELETING rows, resolves rows whose owner is dead and removes
  a FAILED restore's CREATING target. The PostgreSQL claim connection
  sets TCP keepalives, so the loss of the owner's host frees its lock in
  about a minute. While the configured store is unhealthy the reconciler
  acts on no row.
- DeleteBackup tombstones the row as DELETING, deletes the manifest
  first, and marks DELETED only when the prefix is empty. backup sync is
  scoped to the configured store; extenddb-store.json records the store
  id and the owning catalog's catalog_id, and a root another catalog
  owns, rows without the file, rows with two store ids or a missing
  newest manifest mark the store unhealthy (store operations refused,
  the reconciler idle) instead of stopping startup, so an unmounted
  volume, a recreated bucket or two catalogs on one root are caught;
  backup sync --adopt-store hands a root to another catalog; instances
  that share a catalog must share one store root; it never adopts a
  CREATING, DELETING, DELETED or FAILED prefix, nor one whose listing
  lacks a data file the manifest lists at its recorded size, and marks a
  row whose manifest head is gone DELETING only with --prune-missing.
  --move-store resolves the old root's CREATING and DELETING rows,
  compares every data file, refuses to carry a tombstone over a verified
  manifest and offers --forget-backup; --forget-store deletes the rows
  of a root. purge, --move-store, --forget-store, --prune-missing and
  --adopt-store take a catalog maintenance claim that every job checks;
  max_concurrent_jobs is per instance. DeleteBackup and Restore of
  another store's row return a ValidationException that names no root.
  Restore and Describe never read the store without a catalog row; an
  ARN with no row goes to the legacy reader. The store-id file is
  created with put_if_absent (If-None-Match on S3, O_EXCL and link on
  the filesystem). A backup is in use while a restore target registered
  from it is CREATING, as on main: DeleteBackup and a second restore
  return BackupInUseException. Purge deletes each row's prefix, never a
  whole account prefix. Every backup row is read by account and backup
  id and must match the request's account, table and ARN, and no row
  stores its key prefix. DeleteBackup's in-use check and DELETING write
  run under the backup row's lock, and a restore's registration
  re-checks AVAILABLE and in-use under the same lock, so the two
  serialize as on main (on Cassandra through main's active_restores
  lightweight transactions, now for new rows too).
- Restore returns CREATING from the request (TableAlreadyExists or
  TableInUse on a name race, as #384 returns), spools and verifies every
  data file before writing any item, and records progress atomically
  with the data on PostgreSQL and SQLite and by idempotent
  re-application of the one unrecorded file or batch on MongoDB and
  Cassandra; on MongoDB the job writes DONE and the backfill flag in one
  transaction and the worker's ACTIVE flip is conditioned on the flag,
  not on the job's generation. A key-ordered restore runs #394's
  per-file key-range check and an unordered one ends with a count of the
  target, which is Cassandra's only duplicate check. A PostgreSQL
  restore is resumed, or its target deleted, only after the stale
  window; the manifest and sidecar are spooled for a resume; a legacy
  restore with a dead owner is failed, not resumed. The ACTIVE flip
  precedes the deletion of the PostgreSQL progress rows. DeleteTable on
  a restore target stays refused, as #384 and the service refuse it, and
  the job and the reconciler remove a failed target through the
  backend-internal remove_restore_target; the restore record is the
  table's child. Multi-part-key tables are restored (#384 refuses them;
  bulk_load keys rows with the backend's item-path functions, so the
  table is laid out as a natively created one). On PostgreSQL and SQLite
  bulk_load writes GSI, LSI and vector-index rows in its own
  transaction, and the ACTIVE flip sets every index ACTIVE. Table class
  is not restored, as on the service and #384; every override but
  VectorIndexOverride is applied, as #384 applies them. A second restore
  of a backup in use is refused before the permit and re-checked at
  registration. A restore stops on shutdown leaving its row RUNNING,
  download transport errors are retried for 15 minutes, and a takeover
  rewrites spool_host. Each data file is read through
  ExtenddbManifest::reader_prehashed, which applies the key-range check
  without hashing the spooled bytes again.
- The engine binds a BackupCatalog trait (PR 6) with a JobClaim for the
  per-backend claim, heartbeat and conditional status writes; new rows
  live in backup_index (a new table or collection; Cassandra reuses its
  tables with store_kind set and adds backup_index_by_status); MongoDB's
  catalog gets the versioned migration step it lacks; PR 6's migration
  backfills in-flight #384 restores with heartbeat_at at the epoch so
  the first reconciler pass fails them; every pre-PR-6 server stops
  before migrate. Vector tables are backed up and restored on the
  off-host path, while the legacy create keeps main's refusal (one #384
  engine test is rewritten in PR 9a and the wire test follows the
  configured path). source_table carries stream_specification and
  time_to_live for DescribeBackup. Both requests take the job permit
  last, after every refusal that needs no store access. The format's
  evolution rule is a version bump for any byte change, with v{N}
  fixtures beside v1. The trait gains listings for ListBackups, the
  store-id check and the root-matched commands, and backup_in_use.
  ListBackups accepts a table ARN in TableName from PR 6, as the service
  does. PR 6 adds the [backup] struct with the job keys. Row and
  restore-target methods take account-scoped keys, and the trait gains
  catalog_id and DeleteBackup's lock-holding start_backup_delete.
- The manifest self-checksum is replaced by a sidecar over the stored
  bytes, accepted in its sha256sum line form only; backup_arn is the
  backup's identity and source_table must agree with it; N key values
  are normalized before key text or key order is computed, and an
  un-normalized key_range endpoint is refused; the version gate accepts
  1 to the current version. The line cap is 32 MiB, sized from the
  measured worst case, and the compressed size is enforced while
  reading; both are format rules in section 1. Section 7 cites the
  decode memory from #394's DataFileDecoder doc (76 and 128 bytes per
  line byte as of #394, measured without preserve_order; about 4 GiB at
  the cap). A reader refuses a manifest without stream_specification or
  time_to_live. The backup job validates every item with an item
  validator PR 8a adds to extenddb-core, which the restore binds, and
  fails the backup on a value #618 let in through any of the four write
  paths, a regression from main for tables that hold one; it validates
  the rest of the snapshot first and logs every refused item, and the
  FAILED reason gives the count and the first.
- Store (#393): owner-only root and directories re-checked by every
  operation, an ancestor writable by group or others without the sticky
  bit refused, a root whose canonical path changed since open refused,
  the prefix resolved with the same no-follow walk as a key, listings
  that report only keys passing the key rule, multipart uploads aborted
  when a put is dropped and swept by delete_prefix (a lifecycle rule as
  backstop), validate by a one-key listing so a 403 keeps S3's code and
  then by probes of the grants, a GetObject of an absent key included, a
  255-byte component cap and U+FFFE/U+FFFF refused by the key rule,
  settings under [backup.store]. Section 8 gains a transport rule (a
  non-loopback http:// endpoint, from the configuration or from the
  SDK's own endpoint sources, needs allow_insecure_endpoint; an http://
  S3 endpoint needs force_path_style unless allow_insecure_endpoint is
  set; the credential clients' endpoints (STS, SSO, SSO OIDC and the
  general AWS_ENDPOINT_URL or profile endpoint_url) are held to the
  loopback rule, and a refusal names the client and the setting; S3
  Express directory buckets are refused; runtime shutdown sends no abort
  for in-flight puts, so the server's shutdown awaits its jobs' failure
  paths) and an IAM example with the list actions on the bucket, the KMS
  actions for an SSE-KMS bucket, the rule that a missing key reads as
  404 only under s3:ListBucket, and a delete-marker lifecycle rule for
  versioned buckets. The endpoint is refused with user information, a
  query, a fragment or an IPv6 zone id, from any source; the S3 client
  for an http:// endpoint is built with aws_smithy_http_client's Builder
  and no proxy; a denied AbortMultipartUpload in delete_prefix's sweep
  is logged and the deletion succeeds; a put dropped during the
  temporary's open leaves no temporary. PR 7 adds put_if_absent to the
  store trait.
- Configuration: the default store root is per backend (beside the
  database file on file-backed SQLite, where a defaulted root that fails
  validation is a warning, not a startup error); without [backup.store],
  CreateBackup on every backend keeps the legacy in-catalog create with
  a deprecation warning until PR 14 removes it (Cassandra always, having
  no SnapshotEngine), after which it is a ValidationException naming
  [backup.store], and kind = "disabled" is the opt-out; PR 14's
  migration refuses while legacy backup rows remain unless forced. A
  store transport failure at startup degrades the store component
  instead of stopping the server. The memory formula is in item text
  bytes, and PR 8a bounds each PostgreSQL and SQLite snapshot page by
  SNAPSHOT_FETCH_BYTES (64 MiB) in the page's query, PostgreSQL's cursor
  replaced by a keyset page under the same snapshot, so a page holds at
  most the budget plus one row; the PostgreSQL page returns its key
  columns, orders the outer query and sizes its row limit from the
  previous page's average row, starting at 16; the PostgreSQL pool rule
  is max(MIN_POOL_SIZE, 2 * max_concurrent_jobs + 10), 18 at the default
  4; the spool default is per backend and spool_host is host plus spool
  path.
- Phase 1 is the PR series with acceptance criteria and GitHub numbers;
  migration numbers are reserved up front (Cassandra V006 to PR 1, V007
  to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill
  points for the crash tests are enumerated per backend and for
  DeleteBackup. The Phasing section refers to the companion PRs' heads
  on the revision date rather than naming commits. migrate-legacy
  re-reads and validates what it wrote and refuses, keeping the legacy
  rows, backups it cannot carry.

Also adds the missing rows for designs 12 and 13 to
docs/design/README.md and marks 15 as a draft.

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 29, 2026
…ackend

Adds the one new obligation the engine-owned backup path places on a
backend: open a consistent view of one table and stream its items. The
trait returns the table definition, a snapshot marker, an ordering flag,
and the item stream together, from one operation, so a backup's manifest
and data files always describe the same instant. The in-catalog backup
code keeps its own copy of this logic for now; the engine-side backup
job (next PR) is the first caller. Stacked on #394: SnapshotInfo is the
manifest's SnapshotDescriptor (one definition, with the three KIND_*
constants), and key_schema_is_orderable is KeyOrder::resolve, so a
stream a backend marks ordered is one the manifest parser accepts and a
table whose range key has no attribute definition streams unordered and
complete instead of paging by the hash key alone.

Trait (crates/storage/src/snapshot.rs): open_table_snapshot(account_id,
table_name) returns TableSnapshot { description: TableDescription, info:
SnapshotInfo, key_ordered: bool, items: BoxStream<Result<Item>> }. The
description is the storage-level one at the snapshot instant, not the
API response (restore_summary may be set, table_throughput_mode_summary
is not, the counts are the stats pass's); items are yielded as stored
(valid N normalized, invalid raw, TTL-expired unswept items included),
and any error means start over, SnapshotExpired being the one worth
retrying. The stream owns the backend transaction or session; dropping
it releases it. A stream that has not reached its end (including one
never polled) must be dropped inside a Tokio runtime: the sqlx backends
return its connection through the runtime and panic outside one, MongoDB
does not, and the trait doc says so. The first Err ends the stream on
every backend. StorageEngine now requires SnapshotEngine; all four
in-tree backends implement it. The trait doc names what each backend
holds while a stream is open and which errors each returns (Cassandra
answers Unsupported before it resolves the table), the idle timeouts
that end a stream as Internal, and why the trait has no default body and
StorageError stays exhaustive; PostgreSQL and SQLite bound open
snapshots per server (below) and refuse over the bound with
LimitExceeded. StorageError gains SnapshotExpired for a snapshot the
server discarded before the stream drained; the engine maps it to a
retryable 503 like Transient.

Indexes: the description keeps every index in whatever build state it
had and drops only one being deleted (drop_deleting_indexes, for GSIs
and vector indexes; no in-tree backend reports a DELETING index today,
and unit tests pin the rule); on PostgreSQL a GSI row whose data table
is missing is also left out, with the attribute definitions only it
used, as the in-catalog backup does (since #401 UpdateTable creates the
data table before committing the row, so this is a guard, not an
expected state). A restore recreates the indexes in the description and
builds them from the items, so a CREATING index with a data table comes
back complete; one left out would not come back at all.

PostgreSQL, one shared database: the open uses one connection and one
REPEATABLE READ READ ONLY transaction whose first statement reads the
tables row, so the definition and the items come from one MVCC snapshot
and no row lock is taken; a DeleteTable that drops the data table
between that snapshot and the open's LOCK TABLE makes the open
TableNotFound. Separate data database: the tables row is held FOR SHARE
on a catalog connection from the definition read until the data
transaction (REPEATABLE READ READ ONLY, ACCESS SHARE on the relation)
has pinned its snapshot with SELECT pg_current_snapshot() and the GSI
data-table probe has run; the definition is read on that barrier
connection (build_table_description_on), so an open holds one catalog
connection and the stream one data connection. UpdateTable and
DeleteTable take the row FOR UPDATE, so neither commits in the gap;
index-status workers do not, so an index may be reported in a slightly
older status than the items. The marker is pg_current_snapshot() text on
both. An engine-wide permit is held per snapshot from the open until the
stream's transaction ends, half the pool on either layout, so open
snapshots leave half of the pool their items come from to requests (with
a separate data database and the whole pool allowed, ten open snapshots
made GetItem wait out the pool timeout); restores' own half-pool slots
are separate, so snapshots and restores together can hold the whole
pool, and the field doc says so. Items stream through a server-side
cursor, FETCH 1000 per round trip, ordered by the primary key. A fetch
is bounded by rows, not bytes (up to 27.7 MB of text per row at the
default item limit); the byte budget is the backup job PR's. A table
whose text key columns do not have a collation named "C" or "POSIX"
(created before the key columns were declared with it; "default" on a C
database also counts as not bytewise, which streams unordered and
complete) is streamed unordered, with a warning: ordering it would turn
the read into a sort of the whole table before the first row. SQLSTATE
40001 (recovery conflict on a standby) and 72000 (snapshot_too_old on a
primary, PostgreSQL 16 and earlier) map to SnapshotExpired, pinned by a
test that raises each code on the live server. The stream holds ACCESS
SHARE on the data table until its last fetch, so a DROP TABLE waits for
it; DeleteTable takes the asynchronous path (row DELETING, worker drop
under a lock timeout) when another session holds ACCESS SHARE on any
relation it would drop (the base, GSI, LSI and vector-index data tables:
data::table_storage_relations, the one list drop_table_storage also
drops), probing only when its answer is used (delay < 1 s) and, on the
shared layout, on its own catalog transaction rather than a second
connection from the same pool (twenty concurrent DeleteTables on a
ten-connection shared pool timed out without that). On both layouts the
synchronous path takes every drop's lock before the catalog removal
commits. It deletes the table's pending queue rows first, waiting for
in-flight claims with no timeout, as #401 intends. It then drops the
base, GSI, LSI and vector-index data tables, base first, within one 1 s
budget for the group (SYNC_DROP_LOCK_TIMEOUT; before each DROP,
lock_timeout is the time left). On the shared layout that runs in the
catalog transaction behind a savepoint. With a separate data database it
runs in a data transaction opened first, and a task the request awaits
commits the catalog and then that transaction, so a cancelled caller
cannot stop between the two. A lock wait that fails (55P03, a deadlock
victim 40P01, or a statement_timeout's 57014) rolls the drops back and
takes the asynchronous path with the row still held. A write
transaction, a SHARE holder, an index build or a reader that starts
after the probe therefore costs the request at most the 1 s, and no data
table is left without its row. With a separate data database a data
commit that fails after the catalog commit has the drop retried on a
fresh connection for up to 60 s, so a failover, a dropped connection or
a network blip no longer orphans the data tables; only a process that
dies, or a data database unreachable for that whole window, between the
catalog commit and the last retry leaves them without a row, and then
they stay until an operator drops the relations the error log names
(docs/troubleshooting.md, "Data tables without a catalog row"). The base
table goes first because a write holds it before it writes an LSI or a
synchronous GSI: with the index tables first, a write in progress waited
about 1 s on the drop, and the drop went asynchronous. On the shared
layout a DeleteTable probing between an open's snapshot and its LOCK
does not see the open; if the LOCK is granted first, the drop times out
after 1 s, is rolled back with the removal, and DeleteTable returns
DELETING (in 80 unaided open-and-delete pairs, the delete first in half
of them, 0 left a data table without a row on either layout; 39 to 40
deletes ran synchronously and 40 to 41 asynchronously). The data
transaction lives in a guard from BEGIN on, so every path that ends it
after it may have taken ACCESS SHARE wakes the control-plane poller: the
last page (before its commit is awaited), a fetch error, a stream
dropped before its end, and an open that fails or is cancelled; the
in-catalog backup's data transaction is held in the same guard and wakes
it too. The poller stays active while a DELETING row's drop is deferred,
also after a restart: startup recovery runs the same pass and wakes the
poller when it deferred a drop. A drop is deferred when the probe finds
another session holding any lock on the table's relations, or when the
drop fails in a lock wait (55P03, 40P01 or 57014); any other failure is
logged and left to the normal window and the 60 s idle pass. Each pass
probes a held table and skips it without waiting, and drops a free one
within the same 1 s budget, so every held table is retried about once a
second however many are held, and a CreateTable that is due is activated
on the same cadence (1.1 s with four drops deferred). A drop that
DeleteTable deferred is due at once, not after
control_plane_delay_seconds. The active loop waits on the wake as well
as on its timer, so a snapshot's or an in-catalog backup's release has
the drop retried at once, and any holder's release is followed within
about a second: 108 to 166 ms in the tests, with a 10 s test-only active
poll for the wake cases. The admin guide's control_plane_delay_seconds
row and the troubleshooting page's DELETING entry state the default
(0.25 on every backend), the bound and the retry cadence, the same-name
CreateTable refusal until then, and a two-step query, over every
relation of the table, that finds any session holding a lock on them and
its lock mode. A DeleteTable of a table that is already DELETING returns
its description and changes nothing.

SQLite: one deferred read transaction covers catalog and data; its first
read pins the WAL snapshot and no lock is taken, so writers never wait
on a snapshot being opened and the WAL grows until the stream ends (a
TRUNCATE checkpoint waits out the busy timeout and reports busy; the
module doc gives the measured reply). #384's
create_backup_from_wal_snapshot holds the writer lock while it pins
because it also inserts the CREATING row; this path writes nothing.
build_table_description_on reads the definition, including #384's
restore_summary from table_restores, on that transaction. The stream
holds a backup slot (backup_server_slots, shared with the in-catalog
backup) until its read transaction ends (the short page that shows the
end, or the first error) or it is dropped, so the pool arithmetic that
keeps half the pool free counts snapshots; with no slot free the open
returns LimitExceeded at once, and a CreateBackup that waited out its 30
s for a slot says that backups or table snapshots hold them. The
pool_size doc gives the arithmetic. Items page through row-value keyset
pagination on the primary key index, so key_ordered is true for
single-range-key tables; multi-part keys fall back to rowid order. A
page is bounded by rows, not bytes, as on PostgreSQL. An in-memory
database has one connection, so the open reads every page in its
transaction and returns a stream over the copy, holding no connection
once it returns; the copy's stream keeps its backup slot until it has
yielded its last item or is dropped, so the slots bound how many decoded
copies exist, as #384's create_backup_single_connection does for the
in-catalog backup. SQLite DeleteTable of a DELETING table
(control_plane_delay_seconds of 1 or more) returns its description too.

Cassandra: open_table_snapshot returns Unsupported. Cassandra has no
read that fixes an instant across a table (a token-range scan observes
each range at a different time; LWT serializes single partitions), and
its in-catalog logical backup says so (ADR-0019). Returning that copy
under this trait's contract would make the manifest's snapshot member
false, so the backend refuses and the in-catalog BackupEngine path stays
its backup path. A direct test asserts the refusal naming the backend on
an existing table and a missing one.

MongoDB: a session started with snapshot=true reads the tables document,
the indexes documents (doc_to_table_description_in now takes an optional
session) and the base collection at one cluster time; the marker is that
time. Every read sets readConcern: snapshot explicitly. The production
catalog handle carries readConcern: local (as a URI with
readConcernLevel= would), and on it a read without the level fails: the
first read reports no snapshot time, which the open refuses naming the
read concern, and a later read in the pinned session is refused by the
server with code 72 (InvalidOptions). A server that is not a replica set
member refuses the first read with NotAReplicaSet (123), mapped to
Unsupported; each of the two refusals names its own cause.
SnapshotTooOld (239) and SnapshotUnavailable (246) map to
SnapshotExpired, pinned by a unit test that builds the command error
from a reply document; the server keeps the snapshot for at least
minSnapshotHistoryWindowInSeconds and may discard it after, and an idle
cursor times out after cursorTimeoutMillis (10 minutes, CursorNotFound
mapped to Internal). Error messages carry the server's code name and
message, not the driver's dump of the reply. A document the stream
cannot decode ends it with that error. key_ordered is false: for a
composite key _id is a netstring of the key text, which is not the
format's order for any key type.

Test hooks (extenddb_storage::snapshot::paging): a page-size knob, a
round-trip counter, and a pause at two points of the open
(AfterDefinition, BeforeItems) that the conformance check holds while it
commits writes. PostgreSQL and SQLite record each FETCH or page query;
for MongoDB, whose driver hides getMore, the test fixture records,
through command monitoring, each find the server receives on the data
database at readConcern snapshot (the stream's; the GSI backfill's find
reads at majority) and each getMore on a cursor those finds returned, so
the count is the snapshot stream's paging as the server saw it. They and
the conformance check compile only with the test-hooks feature of
extenddb-storage, which the test-hooks features of storage-postgres and
storage-sqlite and the new snapshot-test-hooks of storage-mongodb enable
(storage-mongodb's existing test-hooks, the integration binary's
coordination hooks, is unchanged). The backend crates' tests enable them
through a dev-dependency on themselves. Production builds carry neither
the hooks nor the harness. PostgreSQL also gains test-hooks entries
spawn_control_plane_poller_for_tests and
spawn_control_plane_poller_with_active_poll_for_tests (the production
poller, the second with its active retry interval as a parameter),
recover_control_plane_at_startup_for_tests,
control_plane_drop_attempts_for_tests (a per-engine count of drop
attempts, a held table the probe skipped counted),
arm_delete_table_pause_at (holds DeleteTable at BeforeDrops, between its
probe and its drops, or at BetweenCommits, between the catalog commit
and the data commit on the separate layout; arm_delete_table_pause is
the BeforeDrops form), shorten_data_drop_retry_bound_for_tests (a
process-global shorter bound for the retried data drop), and re-exports
of the data relation names and of apply_all_migrations, so
tests/table_snapshot.rs binds the crate's lists instead of copying them.
The two pauses (the open's and DeleteTable's) are one generic type,
extenddb_storage::test_pause::Pause, and snapshot_conformance::PageSize
is the one page-size guard the three backends' tests use.
tests/table_snapshot.rs fails instead of skipping without a connection
string when EXTENDDB_TEST_PG_REQUIRED is set, which integration.yml
sets.

Tests: extenddb_storage::snapshot_conformance::run is one check every
backend runs against a live database. It sets the shared page size to
50, opens a snapshot on a 700-item composite-key table with a GSI, reads
51 items and asserts the round-trip counter advanced by at least two
since just before the open (so a backend that ignored the knob, or a
MongoDB cursor without batch_size, fails here instead of passing
vacuously), then a writer deletes 100 items, inserts 100, and overwrites
all 600 remaining items twice before the reader drains. Asserts: 700
items, no duplicates, every deleted item present, no inserted item,
every version at its pre-open value, the counter advanced after the
writer committed (a stream that read every page during the open fails),
key_ordered as the backend declares it and strictly ascending keys by
KeyOrder::compare (the format's comparator) when ordered, and a second
snapshot taken after the writer stops shows the final round. Then, once
per pause point, on a fresh table: with the open held, it commits W1, an
UpdateTable adding a GSI, and W2 (in that order; on PostgreSQL with a
separate data database the UpdateTable waits for the row lock and the
open resumes after 2 s), and asserts what one instant implies: a
description with the new GSI has W1, items with W2 have the GSI in the
description, and W2 is not in the items at all, since the pause is
inside the open's read window (a backend whose pauses sat before both
reads passed the two implications), and which of W2's commit and the 2 s
timeout resumed the open, against the backend's declared
Expectations::update_table_waits_at (PostgreSQL with a separate data
database at AfterDefinition: the timeout; everywhere else: W2), so a
slow UpdateTable cannot make the W2 assertion vacuous. Before the writer
starts the snapshot's description must equal the storage layer's
describe_table with the running counts and table_throughput_mode_summary
cleared; restore_summary is compared, and each backend's suite also
compares a table given a restore record
(assert_snapshot_describes_the_table). Every drain is under a 120 s
deadline naming the stage and the items read, and a test waiting for the
hooks lock gives up when one holder has kept it 600 s, naming the wait.
READ COMMITTED or no FOR SHARE on PostgreSQL, the data cursor or the
index read outside the session on MongoDB, and the pauses moved outside
the read window on SQLite or MongoDB, fail it, as do the pauses moved
outside the read window with a 2.5 s UpdateTable (the branch assertion),
a description built from a second code path (the DescribeTable
comparison), and a stream that never ends (the drain deadline, within
120 s instead of hanging the binary). Six more key shapes (hash-only S,
N and B; S hash with S, N or B range) run at a page of 3 with keys whose
byte, collation and base64 orders differ and range numbers whose text
and numeric orders differ (negative, fractional, 1E-130, 9.9E125, and
38- and 34-digit pairs a last digit apart; the check's Expectations say
how many digits a backend stores, and MongoDB's refusal of the 38-digit
pair at the put, Decimal128's 34 digits, is asserted). Every test that
opens a snapshot or touches the hooks holds
snapshot_conformance::hooks_lock(), whose guard run and run_refusals
take as an argument, so no other test in the binary can take the pause,
reset the knob or feed the counter during a check. run_refusals covers a
missing and a non-ACTIVE table on the three backends that implement
snapshots. The suites pass on PostgreSQL 16 (both layouts), file-backed
SQLite and MongoDB 7 (replica set; the standalone refusal against a
standalone mongod), and the Cassandra test on Cassandra 4.1.

PG: tests/table_snapshot.rs (81 tests and two ignored race measurements:
consistency, marker and key order, tables with legacy-collation key
columns streaming unordered and complete on a scratch database whose
collation is checked not to be C, five held snapshots on a
ten-connection pool leaving GetItem and DescribeTable unblocked with the
sixth LimitExceeded and an open succeeding after one is dropped, a GSI
without its data table left out with its attribute, DeleteTable during a
snapshot returning DELETING, with the production poller at a 10 s active
poll the table gone within 3 s of a release during the pause after the
first drop attempt (for a dropped stream, a drained stream, a fetch
error, an open cancelled at BeforeItems and the in-catalog backup), for
a plain reader holding ACCESS SHARE that sends no wake, held 10 s past
the poller's window, the table gone within 3 s of its release, a reader
across a restart (the table gone within 3 s of its release), a drop
failing on a dependent view making at most 3 attempts in the 15 s after
the window, a restored table's restore_summary in the snapshot
description, DeleteTable with only a GSI or vector-index data table
locked taking the async path, an unparseable row ending the stream and
freeing its permit, twenty concurrent DeleteTables on a ten-connection
pool, an open completing with nine of ten connections held, an open held
while nine DeleteTables queue, DeleteTable at BeforeItems taking the
async path, a fetch error freeing the permit, a drained stream out of
its transaction before its end is polled, a ROW EXCLUSIVE and a SHARE
holder sending DeleteTable down the asynchronous path within about 1 s
with no data table left without its row, a DeleteTable cancelled during
that wait leaving the table ACTIVE with its data, write holders on the
base and three GSI tables released 0.8 s apart taking it there at about
1 s, a write holding the base and then writing the GSI table finishing
first with the table removed in the request, a claimed queue row holding
DeleteTable until it ends, a probed reader sending it asynchronous in
milliseconds, a statement timeout and a deadlock victim taking the
asynchronous path, DeleteTable of a DELETING table returning its
description and changing nothing, a deferred drop due at once, four held
tables probed with no lock wait (the first released gone within 2 s, a
CreateTable ACTIVE within 2 s), all on both pool layouts; on the
separate layout, a data commit that fails after the catalog commit (its
backend terminated between the two) retried until the drop goes through
with the request returning DELETING and no relation left, a data
database refusing connections past a shortened bound returning Internal
naming every leaked relation with the row gone, and a caller cancelled
between the two commits leaving no orphan; on the shared layout, a
snapshot open or a GSI-only reader taking its lock after DeleteTable's
probe sending it down the asynchronous path with the row and both data
tables kept, and a table dropped before the shared-layout LOCK being
TableNotFound; the SQLSTATE mapping; the two ignored race measurements,
which delay the delete or the open in alternate pairs and assert both
outcomes occurred) with a Drop guard that drops the scratch databases on
success or panic, added to the direct-storage step in integration.yml;
the full storage-postgres suite is 259 tests. SQLite: in-module tests
(consistency, key order, the in-memory copy releasing the connection,
in-memory copies holding their slot, a damaged attribute definition
streaming unordered and complete, the rowid fallback at a page of seven
with no duplicates and at least six fetches, slots shared with
CreateBackup and its refusal text, the WAL checkpoint reply while a
snapshot is held, a restored table's restore_summary compared on a
file-backed and an in-memory engine, a drained stream and a stream that
hit an error freeing their slot while still held, and an in-memory copy
freeing its slot with its last item) with the same guard for the scratch
files, created under the test binary's target directory. MongoDB:
in-module tests (consistency, an undecodable document ending the stream,
reads without the snapshot level failing with code 72 and the error
text, the code mapping, a restored table's restore fields compared, the
round-trip counter counting only the snapshot stream (a GSI backfill
leaves it unchanged), and a standalone server answering Unsupported,
gated on EXTENDDB_TEST_MONGODB_STANDALONE_URI, which the CI job sets
against a standalone mongod container it starts (and failing rather than
skipping when EXTENDDB_TEST_MONGODB_REQUIRED is set without it)) on
per-test scratch databases built without the bootstrapper, as #384's
live tests do, the catalog handle with the production option set,
dropped by a guard on success or panic, so a URI in the environment
never makes cargo test touch another instance's databases; gated on
EXTENDDB_TEST_MONGODB_URI (failing rather than skipping when
EXTENDDB_TEST_MONGODB_REQUIRED is set, which the CI job does), holding
the snapshot hooks lock, run by a new integration-mongodb.yml job that
starts a replica set through devtools/run-mongodb-tests --mongo-only and
a standalone mongod (both on 127.0.0.1 only).
tests/test_table_operations.py: DeleteTable of a DELETING table returns
its description, on PostgreSQL and SQLite (the state written into the
catalog), skipped on MongoDB and Cassandra, which never leave a table
DELETING.

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 29, 2026
Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), eight review rounds and the
maintainer's reviews (on 2026-10-28 the sixth round, the maintainer's
review and the seventh round; on 2026-10-29 the eighth, a sentence-level
verification of the revision against the companions' code and main)
against the companion PRs #393, #394 and #395, and a reconciliation
against main abf8082 (#384 in its final form, the Cassandra backend
#338, the PostgreSQL GSI build #401). The revision log at the end of the
document lists each change; the ones that alter behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). Indexes are reported in
  whatever build state they had and only an index being deleted is
  dropped; a GSI without its data table is left out with the attribute
  definitions only it used. On PostgreSQL's shared layout the open is
  one connection and one REPEATABLE READ transaction; on the separate
  layout the catalog row is held FOR SHARE until the data snapshot pins,
  and an index that becomes ACTIVE between the definition read and the
  pin is reported CREATING. PostgreSQL bounds open snapshots at half the
  pool and refuses more with LimitExceeded, so open snapshots leave half
  the pool to requests; from PR 6 on max_concurrent_jobs (default 4)
  bounds backups and restores together under that permit, and #384's
  restore slots go. It streams a table whose key columns predate COLLATE
  "C" unordered rather than sort it, and on both layouts DeleteTable
  commits the catalog removal only after its drops succeed, base table
  first, under one 1 s budget, taking its asynchronous path with the row
  kept when its probe finds a reader or a drop fails in a lock wait
  (lock timeout, deadlock victim or statement timeout), so no DROP waits
  behind a reader with the row gone; on the separate layout a task the
  request awaits commits the catalog and then the data transaction, and
  a data commit that fails after the catalog commit is retried on a
  fresh connection for a bounded time before the request returns (an
  orphan survives only a process death or a data database down past that
  bound, and the troubleshooting page says how to drop it); the
  control-plane poller probes held tables and retries each about once a
  second, at once on a snapshot's or an in-catalog backup's release, a
  drop DeleteTable deferred is due at once, and DeleteTable of a
  DELETING table returns its description on PostgreSQL and SQLite. On
  sqlite-memory the snapshot copies the table on the one connection
  inside the request and keeps a backup slot until its stream ends, so
  CreateBackup keeps working there once [backup.store] is set. MongoDB
  reads without the explicit snapshot level fail, and the document says
  so with the server's error.
- The snapshot is opened in the CreateBackup request (stricter than the
  service's two-minute window), and the backup id is minted from the
  instant the open returned, just after the pin, so the backup holds
  every write acknowledged before the request was received, and
  backup_creation_date_time, the catalog's created_at and the ARN carry
  one time and the ListBackups cursor is a function of the ARN.
- Per-job claims with fencing replace the single-instance lease the
  draft assumed: an advisory lock on a dedicated catalog connection on
  PostgreSQL (#384's seed and key), the row itself on MongoDB (born
  through an upsert pipeline with $literal), an in-process slot on
  SQLite, a lightweight-transaction row on Cassandra (restore targets).
  Heartbeats are a timer on the database's clock with a deadline; any
  heartbeat that does not confirm the claim aborts the job. Every job
  status write is conditioned on the generation and on the row's state,
  and an ambiguous AVAILABLE update is re-read. A job deletes a prefix,
  or a restore target, only after its FAILED write matched a row. The
  reconciler's duties are listed once, in section 4, and include the
  prefixes of DELETED rows, orphan restore targets, stale spools and, on
  Cassandra, active_restores entries whose target is not a CREATING
  restore target of the backup. Every duty that reads or writes a backup
  row acts only on rows of the configured store (store_kind, store_root
  and the store id); a CREATING or DELETING row of another store is
  reported and left alone. The PostgreSQL staleness test also selects a
  NULL heartbeat. While a maintenance claim is held the reconciler only
  finishes DELETING rows, resolves rows whose owner is dead and runs its
  FAILED-target duty, which never removes a target left for reason
  target_not_empty; that one is the operator's, removed with extenddb
  backup remove-target. The PostgreSQL claim connection sets TCP
  keepalives, so the loss of the owner's host frees its lock in about a
  minute. While the configured store is unhealthy the reconciler acts on
  no row of the store; its legacy-row duty, which touches no store,
  still runs.
- DeleteBackup tombstones the row as DELETING, deletes the manifest
  first, and marks DELETED only when the prefix is empty. backup sync is
  scoped to the configured store; extenddb-store.json records the store
  id and the owning catalog's catalog_id, and a root another catalog
  owns, rows without the file, rows with two store ids or a missing
  newest manifest mark the store unhealthy (store operations refused,
  the reconciler idle on the store's rows) instead of stopping startup,
  so an unmounted volume, a recreated bucket or two catalogs on one root
  are caught; backup sync --adopt-store hands a root to another catalog,
  and sqlite-memory's startup sync takes ownership of a root a
  sqlite-memory catalog owned the same way, so one catalog owns a root
  at a time there too; instances that share a catalog must share one
  store root; it never adopts a CREATING, DELETING, DELETED or FAILED
  prefix, nor one whose listing lacks a data file the manifest lists at
  its recorded size, and marks a row whose manifest head is gone
  DELETING only with --prune-missing. --move-store resolves the old
  root's CREATING and DELETING rows, compares every data file, refuses
  to carry a tombstone over a verified manifest and offers
  --forget-backup; --forget-store deletes the rows of a root. purge,
  remove-target, --move-store, --forget-store, --prune-missing and
  --adopt-store take a catalog maintenance claim that every job checks;
  max_concurrent_jobs is per instance. DeleteBackup and Restore of
  another store's row return a ValidationException that names no root.
  Restore and Describe never read the store without a catalog row; an
  ARN with no row goes to the legacy reader. The store-id file is
  created with put_if_absent (If-None-Match on S3, O_EXCL and link on
  the filesystem). A backup is in use while a restore target registered
  from it is CREATING, as on main: DeleteBackup and a second restore
  return BackupInUseException. Purge deletes each row's prefix, never a
  whole account prefix. Every backup row is read by account and backup
  id and must match the request's account, table and ARN, and no row
  stores its key prefix. DeleteBackup's in-use check and DELETING write
  run under the backup row's lock, and a restore's registration
  re-checks AVAILABLE and in-use under the same lock, so the two
  serialize as on main (on Cassandra through main's active_restores
  lightweight transactions, now for new rows too); main's two PostgreSQL
  race tests stay for the legacy path and PR 6 adds the same two
  orderings over an off-host backup.
- Restore returns CREATING from the request (TableAlreadyExists or
  TableInUse on a name race, as #384 returns), spools and verifies every
  data file before writing any item, and records progress atomically
  with the data on PostgreSQL and SQLite and by idempotent
  re-application of the one unrecorded file or batch on MongoDB and
  Cassandra; on MongoDB the job writes DONE and the backfill flag in one
  transaction and the worker's ACTIVE flip is conditioned on the flag,
  not on the job's generation. A key-ordered restore runs #394's
  per-file key-range check and an unordered one ends with a count of the
  target, which is Cassandra's only duplicate check. A PostgreSQL
  restore is resumed, or its target deleted, only after the stale
  window; the manifest and sidecar are spooled for a resume; a legacy
  restore with a dead owner is failed, not resumed. The ACTIVE flip
  precedes the deletion of the PostgreSQL progress rows. DeleteTable on
  a restore target stays refused, as #384 and the service refuse it, and
  the job and the reconciler remove a failed target through the
  backend-internal remove_restore_target; the restore record is the
  table's child. Multi-part-key tables are restored (#384 refuses them;
  bulk_load keys rows with the backend's item-path functions, so the
  table is laid out as a natively created one). On PostgreSQL and SQLite
  bulk_load writes GSI, LSI and vector-index rows in the batch's
  transaction with the base rows, and the ACTIVE flip sets the table and
  every index ACTIVE in one catalog transaction; on Cassandra the index
  rows are created ACTIVE and the lightweight transaction flips the
  table alone. Table class is not restored, as on the service and #384;
  every override but VectorIndexOverride is applied, as #384 applies
  them. A second restore of a backup in use is refused before the permit
  and re-checked at registration. A restore stops on shutdown leaving
  its row RUNNING, download transport errors are retried for 15 minutes,
  and a takeover rewrites spool_host. Each data file is read through
  ExtenddbManifest::reader_prehashed, which applies the key-range check
  without hashing the spooled bytes again.
- The engine binds a BackupCatalog trait (PR 6) with a JobClaim for the
  per-backend claim, heartbeat and conditional status writes; new rows
  live in backup_index (a new table or collection; Cassandra reuses its
  tables with store_kind set and adds backup_index_by_status); MongoDB's
  catalog gets the versioned migration step it lacks; PR 6's migration
  backfills in-flight #384 restores with heartbeat_at at the epoch so
  the first reconciler pass fails them; every pre-PR-6 server stops
  before migrate. Vector tables are backed up and restored on the
  off-host path, while the legacy create keeps main's refusal (one #384
  engine test is rewritten in PR 9a and the wire test follows the
  configured path). source_table carries stream_specification and
  time_to_live for DescribeBackup. Both requests take the job permit
  last, after every refusal that needs no store access. The format's
  evolution rule is a version bump for any byte change, with v{N}
  fixtures beside v1. The trait gains listings for ListBackups, the
  store-id check and the root-matched commands, and backup_in_use.
  ListBackups accepts a table ARN in TableName from PR 6, as the service
  does. PR 6 adds the [backup] struct with the job keys. Row and
  restore-target methods take account-scoped keys, and the trait gains
  catalog_id and DeleteBackup's lock-holding start_backup_delete.
- The manifest self-checksum is replaced by a sidecar over the stored
  bytes, accepted in its sha256sum line form only; backup_arn is the
  backup's identity and source_table must agree with it; N key values
  are normalized before key text or key order is computed, and an
  un-normalized key_range endpoint is refused; the version gate accepts
  1 to the current version. The line cap is 32 MiB, sized from the
  measured worst case, and the compressed size is enforced while
  reading; both are format rules in section 1. Section 7 cites the
  decode memory from #394's DataFileDecoder doc (76 and 128 bytes per
  line byte as of #394, measured without preserve_order; about 4 GiB at
  the cap). A reader refuses a manifest without stream_specification or
  time_to_live. The backup job validates every item with an item
  validator PR 8a adds to extenddb-core, which the restore binds, and
  fails the backup on a value #618 let in through any of the four write
  paths, a regression from main for tables that hold one; it validates
  the rest of the snapshot first and logs every refused item, and the
  FAILED reason gives the count and the first.
- Store (#393): owner-only root and directories re-checked by every
  operation, an ancestor writable by group or others without the sticky
  bit refused, a root whose canonical path changed since open refused,
  the prefix resolved with the same no-follow walk as a key, listings
  that report only keys passing the key rule, multipart uploads aborted
  when a put is dropped and swept by delete_prefix (a lifecycle rule as
  backstop), validate by a one-key listing so a 403 keeps S3's code and
  then by probes of the grants, a GetObject of an absent key included, a
  255-byte component cap and U+FFFE/U+FFFF refused by the key rule,
  settings under [backup.store]. Section 8 gains a transport rule (a
  non-loopback http:// endpoint, from the configuration or from the
  SDK's own endpoint sources, needs allow_insecure_endpoint; an http://
  S3 endpoint needs force_path_style unless allow_insecure_endpoint is
  set; the credential clients' endpoints (STS, SSO, SSO OIDC and the
  general AWS_ENDPOINT_URL or profile endpoint_url) are held to the
  loopback rule, the IMDS endpoint setting may be http:// only to
  loopback or the two IMDS addresses, and a refusal names the client and
  the setting; S3 Express directory buckets and directory-bucket access
  points (--x-s3, --xa-s3) are refused; runtime shutdown sends no abort
  for in-flight puts, so the server's shutdown awaits its jobs' failure
  paths) and an IAM example with the list actions on the bucket, the KMS
  actions for an SSE-KMS bucket, the rule that a missing key reads as
  404 only under s3:ListBucket, and a delete-marker lifecycle rule for
  versioned buckets. The endpoint is refused with user information, a
  query, a fragment or an IPv6 zone id, from any source; the S3 client
  for an http:// endpoint is built with aws_smithy_http_client's Builder
  and no proxy, and the credential chain's clients get the same client
  when a credential endpoint setting, the IMDS endpoint setting or the
  container credentials URI is http://, so the chain's own http://
  requests do not use the proxy; without such a setting the chain uses
  the SDK's default client and NO_PROXY must list 169.254.169.254
  (fd00:ec2::254 in IPv6 mode) and 169.254.170.2; a denied
  AbortMultipartUpload in delete_prefix's sweep is logged and the
  deletion succeeds; a put dropped during the temporary's open leaves no
  temporary. PR 7 adds put_if_absent to the store trait.
- Configuration: the default store root is per backend (beside the
  database file on file-backed SQLite, where a defaulted root that fails
  validation is a warning, not a startup error); without [backup.store],
  CreateBackup on every backend keeps the legacy in-catalog create with
  a deprecation warning until PR 14 removes it (Cassandra always, having
  no SnapshotEngine), after which it is a ValidationException naming
  [backup.store], and kind = "disabled" is the opt-out; PR 14's
  migration refuses while legacy backup rows remain unless forced. A
  store transport failure at startup degrades the store component
  instead of stopping the server. The memory formula is in item text
  bytes, and PR 8a bounds each PostgreSQL and SQLite snapshot page by
  SNAPSHOT_FETCH_BYTES (64 MiB) in the page's query, PostgreSQL's cursor
  replaced by a keyset page under the same snapshot, so a page holds at
  most the budget plus one row; the PostgreSQL page returns its key
  columns, orders the outer query and sizes its row limit from the
  previous page's average row, starting at 16; the PostgreSQL pool rule
  is max(MIN_POOL_SIZE, 2 * max_concurrent_jobs + 10), 18 at the default
  4; the spool default is per backend and spool_host is host plus spool
  path.
- Phase 1 is the PR series with acceptance criteria and GitHub numbers;
  migration numbers are reserved up front (Cassandra V006 to PR 1, V007
  to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill
  points for the crash tests are enumerated per backend and for
  DeleteBackup. The Phasing section refers to the companion PRs' heads
  on the revision date rather than naming commits. migrate-legacy
  re-reads and validates what it wrote and refuses, keeping the legacy
  rows, backups it cannot carry.

Also adds the missing rows for designs 12 and 13 to
docs/design/README.md and marks 15 as a draft.

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 29, 2026
Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), eight review rounds and the
maintainer's reviews (on 2026-10-28 the sixth round, the maintainer's
review and the seventh round; on 2026-10-29 the eighth, a sentence-level
verification of the revision against the companions' code and main)
against the companion PRs #393, #394 and #395, and a reconciliation
against main abf8082 (#384 in its final form, the Cassandra backend
#338, the PostgreSQL GSI build #401). The revision log at the end of the
document lists each change; the ones that alter behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). Indexes are reported in
  whatever build state they had and only an index being deleted is
  dropped; a GSI without its data table is left out with the attribute
  definitions only it used. On PostgreSQL's shared layout the open is
  one connection and one REPEATABLE READ transaction; on the separate
  layout the catalog row is held FOR SHARE until the data snapshot pins,
  and an index that becomes ACTIVE between the definition read and the
  pin is reported CREATING. PostgreSQL bounds open snapshots at half the
  pool and refuses more with LimitExceeded, so open snapshots leave half
  the pool to requests; from PR 6 on max_concurrent_jobs (default 4)
  bounds backups and restores together under that permit, and #384's
  restore slots go. It streams a table whose key columns predate COLLATE
  "C" unordered rather than sort it, and on both layouts DeleteTable
  commits the catalog removal only after its drops succeed, base table
  first, under one 1 s budget, taking its asynchronous path with the row
  kept when its probe finds a reader or a drop fails in a lock wait
  (lock timeout, deadlock victim or statement timeout), so no DROP waits
  behind a reader with the row gone; on the separate layout a task the
  request awaits commits the catalog and then the data transaction, and
  a data commit that fails after the catalog commit is retried on a
  fresh connection for a bounded time before the request returns (an
  orphan survives only a process death or a data database down past that
  bound, and the troubleshooting page says how to drop it); the
  control-plane poller probes held tables and retries each about once a
  second, at once on a snapshot's or an in-catalog backup's release, a
  drop DeleteTable deferred is due at once, and DeleteTable of a
  DELETING table returns its description on PostgreSQL and SQLite. On
  sqlite-memory the snapshot copies the table on the one connection
  inside the request and keeps a backup slot until its stream ends, so
  CreateBackup keeps working there once [backup.store] is set. MongoDB
  reads without the explicit snapshot level fail, and the document says
  so with the server's error.
- The snapshot is opened in the CreateBackup request (stricter than the
  service's two-minute window), and the backup id is minted from the
  instant the open returned, just after the pin, so the backup holds
  every write acknowledged before the request was received, and
  backup_creation_date_time, the catalog's created_at and the ARN carry
  one time and the ListBackups cursor is a function of the ARN.
- Per-job claims with fencing replace the single-instance lease the
  draft assumed: an advisory lock on a dedicated catalog connection on
  PostgreSQL (#384's seed and key), the row itself on MongoDB (born
  through an upsert pipeline with $literal), an in-process slot on
  SQLite, a lightweight-transaction row on Cassandra (restore targets).
  Heartbeats are a timer on the database's clock with a deadline; any
  heartbeat that does not confirm the claim aborts the job. Every job
  status write is conditioned on the generation and on the row's state,
  and an ambiguous AVAILABLE update is re-read. A job deletes a prefix,
  or a restore target, only after its FAILED write matched a row. The
  reconciler's duties are listed once, in section 4, and include the
  prefixes of DELETED rows, orphan restore targets, stale spools and, on
  Cassandra, active_restores entries whose target is not a CREATING
  restore target of the backup. Every duty that reads or writes a backup
  row acts only on rows of the configured store (store_kind, store_root
  and the store id); a CREATING or DELETING row of another store is
  reported and left alone. The PostgreSQL staleness test also selects a
  NULL heartbeat. While a maintenance claim is held the reconciler only
  finishes DELETING rows, resolves rows whose owner is dead and runs its
  FAILED-target duty, which never removes a target left for reason
  target_not_empty; that one is the operator's, removed with extenddb
  backup remove-target. The PostgreSQL claim connection sets TCP
  keepalives, so the loss of the owner's host frees its lock in about a
  minute. While the configured store is unhealthy the reconciler acts on
  no row of the store; its legacy-row duty, which touches no store,
  still runs.
- DeleteBackup tombstones the row as DELETING, deletes the manifest
  first, and marks DELETED only when the prefix is empty. backup sync is
  scoped to the configured store; extenddb-store.json records the store
  id and the owning catalog's catalog_id, and a root another catalog
  owns, rows without the file, rows with two store ids or a missing
  newest manifest mark the store unhealthy (store operations refused,
  the reconciler idle on the store's rows) instead of stopping startup,
  so an unmounted volume, a recreated bucket or two catalogs on one root
  are caught; backup sync --adopt-store hands a root to another catalog,
  and sqlite-memory's startup sync takes ownership of a root a
  sqlite-memory catalog owned the same way, so one catalog owns a root
  at a time there too; instances that share a catalog must share one
  store root; it never adopts a CREATING, DELETING, DELETED or FAILED
  prefix, nor one whose listing lacks a data file the manifest lists at
  its recorded size, and marks a row whose manifest head is gone
  DELETING only with --prune-missing. --move-store resolves the old
  root's CREATING and DELETING rows, compares every data file, refuses
  to carry a tombstone over a verified manifest and offers
  --forget-backup; --forget-store deletes the rows of a root. purge,
  remove-target, --move-store, --forget-store, --prune-missing and
  --adopt-store take a catalog maintenance claim that every job checks;
  max_concurrent_jobs is per instance. DeleteBackup and Restore of
  another store's row return a ValidationException that names no root.
  Restore and Describe never read the store without a catalog row; an
  ARN with no row goes to the legacy reader. The store-id file is
  created with put_if_absent (If-None-Match on S3, O_EXCL and link on
  the filesystem). A backup is in use while a restore target registered
  from it is CREATING, as on main: DeleteBackup and a second restore
  return BackupInUseException. Purge deletes each row's prefix, never a
  whole account prefix. Every backup row is read by account and backup
  id and must match the request's account, table and ARN, and no row
  stores its key prefix. DeleteBackup's in-use check and DELETING write
  run under the backup row's lock, and a restore's registration
  re-checks AVAILABLE and in-use under the same lock, so the two
  serialize as on main (on Cassandra through main's active_restores
  lightweight transactions, now for new rows too); main's two PostgreSQL
  race tests stay for the legacy path and PR 6 adds the same two
  orderings over an off-host backup.
- Restore returns CREATING from the request (TableAlreadyExists or
  TableInUse on a name race, as #384 returns), spools and verifies every
  data file before writing any item, and records progress atomically
  with the data on PostgreSQL and SQLite and by idempotent
  re-application of the one unrecorded file or batch on MongoDB and
  Cassandra; on MongoDB the job writes DONE and the backfill flag in one
  transaction and the worker's ACTIVE flip is conditioned on the flag,
  not on the job's generation. A key-ordered restore runs #394's
  per-file key-range check and an unordered one ends with a count of the
  target, which is Cassandra's only duplicate check. A PostgreSQL
  restore is resumed, or its target deleted, only after the stale
  window; the manifest and sidecar are spooled for a resume; a legacy
  restore with a dead owner is failed, not resumed. The ACTIVE flip
  precedes the deletion of the PostgreSQL progress rows. DeleteTable on
  a restore target stays refused, as #384 and the service refuse it, and
  the job and the reconciler remove a failed target through the
  backend-internal remove_restore_target; the restore record is the
  table's child. Multi-part-key tables are restored (#384 refuses them;
  bulk_load keys rows with the backend's item-path functions, so the
  table is laid out as a natively created one). On PostgreSQL and SQLite
  bulk_load writes GSI, LSI and vector-index rows in the batch's
  transaction with the base rows, and the ACTIVE flip sets the table and
  every index ACTIVE in one catalog transaction; on Cassandra the index
  rows are created ACTIVE and the lightweight transaction flips the
  table alone. Table class is not restored, as on the service and #384;
  every override but VectorIndexOverride is applied, as #384 applies
  them. A second restore of a backup in use is refused before the permit
  and re-checked at registration. A restore stops on shutdown leaving
  its row RUNNING, download transport errors are retried for 15 minutes,
  and a takeover rewrites spool_host. Each data file is read through
  ExtenddbManifest::reader_prehashed, which applies the key-range check
  without hashing the spooled bytes again.
- The engine binds a BackupCatalog trait (PR 6) with a JobClaim for the
  per-backend claim, heartbeat and conditional status writes; new rows
  live in backup_index (a new table or collection; Cassandra reuses its
  tables with store_kind set and adds backup_index_by_status); MongoDB's
  catalog gets the versioned migration step it lacks; PR 6's migration
  backfills in-flight #384 restores with heartbeat_at at the epoch so
  the first reconciler pass fails them; every pre-PR-6 server stops
  before migrate. Vector tables are backed up and restored on the
  off-host path, while the legacy create keeps main's refusal (one #384
  engine test is rewritten in PR 9a and the wire test follows the
  configured path). source_table carries stream_specification and
  time_to_live for DescribeBackup. Both requests take the job permit
  last, after every refusal that needs no store access. The format's
  evolution rule is a version bump for any byte change, with v{N}
  fixtures beside v1. The trait gains listings for ListBackups, the
  store-id check and the root-matched commands, and backup_in_use.
  ListBackups accepts a table ARN in TableName from PR 6, as the service
  does. PR 6 adds the [backup] struct with the job keys. Row and
  restore-target methods take account-scoped keys, and the trait gains
  catalog_id and DeleteBackup's lock-holding start_backup_delete.
- The manifest self-checksum is replaced by a sidecar over the stored
  bytes, accepted in its sha256sum line form only; backup_arn is the
  backup's identity and source_table must agree with it; N key values
  are normalized before key text or key order is computed, and an
  un-normalized key_range endpoint is refused; the version gate accepts
  1 to the current version. The line cap is 32 MiB, sized from the
  measured worst case, and the compressed size is enforced while
  reading; both are format rules in section 1. Section 7 cites the
  decode memory from #394's DataFileDecoder doc (76 and 128 bytes per
  line byte as of #394, measured without preserve_order; about 4 GiB at
  the cap). A reader refuses a manifest without stream_specification or
  time_to_live. The backup job validates every item with an item
  validator PR 8 adds to extenddb-core, which the restore binds, and
  fails the backup on a value #618 let in through any of the four write
  paths, a regression from main for tables that hold one; it validates
  the rest of the snapshot first and logs every refused item, and the
  FAILED reason gives the count and the first.
- Store (#393): owner-only root and directories re-checked by every
  operation, an ancestor writable by group or others without the sticky
  bit refused, a root whose canonical path changed since open refused,
  the prefix resolved with the same no-follow walk as a key, listings
  that report only keys passing the key rule, multipart uploads aborted
  when a put is dropped and swept by delete_prefix (a lifecycle rule as
  backstop), validate by a one-key listing so a 403 keeps S3's code and
  then by probes of the grants, a GetObject of an absent key included, a
  255-byte component cap and U+FFFE/U+FFFF refused by the key rule,
  settings under [backup.store]. Section 8 gains a transport rule (a
  non-loopback http:// endpoint, from the configuration or from the
  SDK's own endpoint sources, needs allow_insecure_endpoint; an http://
  S3 endpoint needs force_path_style unless allow_insecure_endpoint is
  set; the credential clients' endpoints (STS, SSO, SSO OIDC and the
  general AWS_ENDPOINT_URL or profile endpoint_url) are held to the
  loopback rule, the IMDS endpoint setting may be http:// only to
  loopback or the two IMDS addresses, and a refusal names the client and
  the setting; S3 Express directory buckets and directory-bucket access
  points (--x-s3, --xa-s3) are refused; runtime shutdown sends no abort
  for in-flight puts, so the server's shutdown awaits its jobs' failure
  paths) and an IAM example with the list actions on the bucket, the KMS
  actions for an SSE-KMS bucket, the rule that a missing key reads as
  404 only under s3:ListBucket, and a delete-marker lifecycle rule for
  versioned buckets. The endpoint is refused with user information, a
  query, a fragment or an IPv6 zone id, from any source; the S3 client
  for an http:// endpoint is built with aws_smithy_http_client's Builder
  and no proxy, and the credential chain's clients get the same client
  when a credential endpoint setting, the IMDS endpoint setting or the
  container credentials URI is http://, so the chain's own http://
  requests do not use the proxy; without such a setting the chain uses
  the SDK's default client and NO_PROXY must list 169.254.169.254
  (fd00:ec2::254 in IPv6 mode) and 169.254.170.2; a denied
  AbortMultipartUpload in delete_prefix's sweep is logged and the
  deletion succeeds; a put dropped during the temporary's open leaves no
  temporary. PR 7 adds put_if_absent to the store trait.
- Configuration: the default store root is per backend (beside the
  database file on file-backed SQLite, where a defaulted root that fails
  validation is a warning, not a startup error); without [backup.store],
  CreateBackup on every backend keeps the legacy in-catalog create with
  a deprecation warning until PR 14 removes it (Cassandra always, having
  no SnapshotEngine), after which it is a ValidationException naming
  [backup.store], and kind = "disabled" is the opt-out; PR 14's
  migration refuses while legacy backup rows remain unless forced. A
  store transport failure at startup degrades the store component
  instead of stopping the server. The memory formula is in item text
  bytes, and PR 8 bounds each PostgreSQL and SQLite snapshot page by
  SNAPSHOT_FETCH_BYTES (64 MiB) in the page's query, PostgreSQL's cursor
  replaced by a keyset page under the same snapshot, so a page holds at
  most the budget plus one row; the PostgreSQL page returns its key
  columns, orders the outer query and sizes its row limit from the
  previous page's average row, starting at 16; the PostgreSQL pool rule
  is max(MIN_POOL_SIZE, 2 * max_concurrent_jobs + 10), 18 at the default
  4; the spool default is per backend and spool_host is host plus spool
  path.
- Phase 1 is the PR series with acceptance criteria and GitHub numbers;
  migration numbers are reserved up front (Cassandra V006 to PR 1, V007
  to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill
  points for the crash tests are enumerated per backend and for
  DeleteBackup. The Phasing section refers to the companion PRs' heads
  on the revision date rather than naming commits. migrate-legacy
  re-reads and validates what it wrote and refuses, keeping the legacy
  rows, backups it cannot carry.

Also adds the missing rows for designs 12 and 13 to
docs/design/README.md and marks 15 as a draft.

The series after the four open PRs is ten PRs: the reconciler ships
with the CreateBackup job as PR 8 (a job without its reconciler leaves
rows no one resolves), and the CI changes and the documentation ship
together as PR 11, since both finalize after PR 9b. The revision log
(item 24) records the renaming; its earlier items keep the old labels.

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 29, 2026
…ackend

Adds the one new obligation the engine-owned backup path places on a
backend: open a consistent view of one table and stream its items. The
trait returns the table definition, a snapshot marker, an ordering flag,
and the item stream together, from one operation, so a backup's manifest
and data files always describe the same instant. The in-catalog backup
code keeps its own copy of this logic for now; the engine-side backup
job (next PR) is the first caller. Stacked on #394: SnapshotInfo is the
manifest's SnapshotDescriptor (one definition, with the three KIND_*
constants), and key_schema_is_orderable is KeyOrder::resolve, so a
stream a backend marks ordered is one the manifest parser accepts and a
table whose range key has no attribute definition streams unordered and
complete instead of paging by the hash key alone.

Trait (crates/storage/src/snapshot.rs): open_table_snapshot(account_id,
table_name) returns TableSnapshot { description: TableDescription, info:
SnapshotInfo, key_ordered: bool, items: BoxStream<Result<Item>> }. The
description is the storage-level one at the snapshot instant, not the
API response (restore_summary may be set, table_throughput_mode_summary
is not, the counts are the stats pass's); items are yielded as stored
(valid N normalized, invalid raw, TTL-expired unswept items included),
and any error means start over, SnapshotExpired being the one worth
retrying. The stream owns the backend transaction or session; dropping
it releases it. A stream that has not reached its end (including one
never polled) must be dropped inside a Tokio runtime: the sqlx backends
return its connection through the runtime and panic outside one, MongoDB
does not, and the trait doc says so. The first Err ends the stream on
every backend. StorageEngine now requires SnapshotEngine; all four
in-tree backends implement it. The trait doc names what each backend
holds while a stream is open and which errors each returns (Cassandra
answers Unsupported before it resolves the table), the idle timeouts
that end a stream as Internal, and why the trait has no default body and
StorageError stays exhaustive; PostgreSQL and SQLite bound open
snapshots per server (below) and refuse over the bound with
LimitExceeded. StorageError gains SnapshotExpired for a snapshot the
server discarded before the stream drained; the engine maps it to a
retryable 503 like Transient. snapshot.rs's module doc says that the
design overview (docs/design/15-backup-restore.md, #396) states the
rules the trait's callers honour and that this file's and each backend's
snapshot_engine.rs module docs are the record of what the snapshots do;
DEFAULT_FETCH_BATCH's doc says why the size is a constant (over a 1 ms
link the larger fetch would save about a second per million rows), and
the PostgreSQL module doc's pre-#361 paragraph carries the sort's cost
(7 MB on disk for 100k rows at work_mem 4 MB).

Indexes: the description keeps every index in whatever build state it
had and drops only one being deleted (drop_deleting_indexes, for GSIs
and vector indexes; no in-tree backend reports a DELETING index today,
and unit tests pin the rule); on PostgreSQL a GSI row whose data table
is missing is also left out, with the attribute definitions only it
used, as the in-catalog backup does (since #401 UpdateTable creates the
data table before committing the row, so this is a guard, not an
expected state). A restore recreates the indexes in the description and
builds them from the items, so a CREATING index with a data table comes
back complete; one left out would not come back at all.

PostgreSQL, one shared database: the open uses one connection and one
REPEATABLE READ READ ONLY transaction whose first statement reads the
tables row, so the definition and the items come from one MVCC snapshot
and no row lock is taken; a DeleteTable that drops the data table
between that snapshot and the open's LOCK TABLE makes the open
TableNotFound. Separate data database: the tables row is held FOR SHARE
on a catalog connection from the definition read until the data
transaction (REPEATABLE READ READ ONLY, ACCESS SHARE on the relation)
has pinned its snapshot with SELECT pg_current_snapshot() and the GSI
data-table probe has run; the definition is read on that barrier
connection (build_table_description_on), so an open holds one catalog
connection and the stream one data connection. UpdateTable and
DeleteTable take the row FOR UPDATE, so neither commits in the gap;
index-status workers do not, so an index may be reported in a slightly
older status than the items. The marker is pg_current_snapshot() text on
both. An engine-wide permit is held per snapshot from the open until the
stream's transaction ends, half the pool on either layout, so open
snapshots leave half of the pool their items come from to requests (with
a separate data database and the whole pool allowed, ten open snapshots
made GetItem wait out the pool timeout); restores' own half-pool slots
are separate, so snapshots and restores together can hold the whole
pool, and the field doc says so. Items stream through a server-side
cursor, FETCH 1000 per round trip, ordered by the primary key. A fetch
is bounded by rows, not bytes (up to 27.7 MB of text per row at the
default item limit); the byte budget is the backup job PR's. A table
whose text key columns do not have a collation named "C" or "POSIX"
(created before the key columns were declared with it; "default" on a C
database also counts as not bytewise, which streams unordered and
complete) is streamed unordered, with a warning: ordering it would turn
the read into a sort of the whole table before the first row. SQLSTATE
40001 (recovery conflict on a standby) and 72000 (snapshot_too_old on a
primary, PostgreSQL 16 and earlier) map to SnapshotExpired, pinned by a
test that raises each code on the live server. The stream holds ACCESS
SHARE on the data table until its last fetch, so a DROP TABLE waits for
it; DeleteTable takes the asynchronous path (row DELETING, worker drop
under a lock timeout) when another session holds ACCESS SHARE on any
relation it would drop (the base, GSI, LSI and vector-index data tables:
data::table_storage_relations, the one list drop_table_storage also
drops), probing only when its answer is used (delay < 1 s) and, on the
shared layout, on its own catalog transaction rather than a second
connection from the same pool (twenty concurrent DeleteTables on a
ten-connection shared pool timed out without that). On both layouts the
synchronous path takes every drop's lock before the catalog removal
commits. It deletes the table's pending queue rows first, waiting for
in-flight claims with no timeout, as #401 intends. It then drops the
base, GSI, LSI and vector-index data tables, base first, within one 1 s
budget for the group (SYNC_DROP_LOCK_TIMEOUT; before each DROP,
lock_timeout is the time left). On the shared layout that runs in the
catalog transaction behind a savepoint. With a separate data database it
runs in a data transaction opened first, and a task the request awaits
commits the catalog and then that transaction, so a cancelled caller
cannot stop between the two. A lock wait that fails (55P03, a deadlock
victim 40P01, or a statement_timeout's 57014) rolls the drops back and
takes the asynchronous path with the row still held. A write
transaction, a SHARE holder, an index build or a reader that starts
after the probe therefore costs the request at most the 1 s, and no data
table is left without its row. With a separate data database a data
commit that fails after the catalog commit has the drop retried on a
fresh connection for up to 60 s, so a failover, a dropped connection or
a network blip no longer orphans the data tables; only a process that
dies, or a data database unreachable for that whole window, between the
catalog commit and the last retry leaves them without a row, and then
they stay until an operator drops the relations the error log names
(docs/troubleshooting.md, "Data tables without a catalog row"). The base
table goes first because a write holds it before it writes an LSI or a
synchronous GSI: with the index tables first, a write in progress waited
about 1 s on the drop, and the drop went asynchronous. On the shared
layout a DeleteTable probing between an open's snapshot and its LOCK
does not see the open; if the LOCK is granted first, the drop times out
after 1 s, is rolled back with the removal, and DeleteTable returns
DELETING (in 80 unaided open-and-delete pairs, the delete first in half
of them, 0 left a data table without a row on either layout; 39 to 40
deletes ran synchronously and 40 to 41 asynchronously). The data
transaction lives in a guard from BEGIN on, so every path that ends it
after it may have taken ACCESS SHARE wakes the control-plane poller: the
last page (before its commit is awaited), a fetch error, a stream
dropped before its end, and an open that fails or is cancelled; the
in-catalog backup's data transaction is held in the same guard and wakes
it too. The poller stays active while a DELETING row's drop is deferred,
also after a restart: startup recovery runs the same pass and wakes the
poller when it deferred a drop. A drop is deferred when the probe finds
another session holding any lock on the table's relations, or when the
drop fails in a lock wait (55P03, 40P01 or 57014); any other failure is
logged and left to the normal window and the 60 s idle pass. Each pass
probes a held table and skips it without waiting, and drops a free one
within the same 1 s budget, so every held table is retried about once a
second however many are held, and a CreateTable that is due is activated
on the same cadence (1.1 s with four drops deferred). A drop that
DeleteTable deferred is due at once, not after
control_plane_delay_seconds. The active loop waits on the wake as well
as on its timer, so a snapshot's or an in-catalog backup's release has
the drop retried at once, and any holder's release is followed within
about a second: 108 to 166 ms in the tests, with a 10 s test-only active
poll for the wake cases. The admin guide's control_plane_delay_seconds
row and the troubleshooting page's DELETING entry state the default
(0.25 on every backend), the bound and the retry cadence, the same-name
CreateTable refusal until then, and a two-step query, over every
relation of the table, that finds any session holding a lock on them and
its lock mode. A DeleteTable of a table that is already DELETING returns
its description and changes nothing.

SQLite: one deferred read transaction covers catalog and data; its first
read pins the WAL snapshot and no lock is taken, so writers never wait
on a snapshot being opened and the WAL grows until the stream ends (a
TRUNCATE checkpoint waits out the busy timeout and reports busy; the
module doc gives the measured reply). #384's
create_backup_from_wal_snapshot holds the writer lock while it pins
because it also inserts the CREATING row; this path writes nothing.
build_table_description_on reads the definition, including #384's
restore_summary from table_restores, on that transaction. The stream
holds a backup slot (backup_server_slots, shared with the in-catalog
backup) until its read transaction ends (the short page that shows the
end, or the first error) or it is dropped, so the pool arithmetic that
keeps half the pool free counts snapshots; with no slot free the open
returns LimitExceeded at once, and a CreateBackup that waited out its 30
s for a slot says that backups or table snapshots hold them. The
pool_size doc gives the arithmetic. Items page through row-value keyset
pagination on the primary key index, so key_ordered is true for
single-range-key tables; multi-part keys fall back to rowid order. A
page is bounded by rows, not bytes, as on PostgreSQL. An in-memory
database has one connection, so the open reads every page in its
transaction and returns a stream over the copy, holding no connection
once it returns; the copy's stream keeps its backup slot until it has
yielded its last item or is dropped, so the slots bound how many decoded
copies exist, as #384's create_backup_single_connection does for the
in-catalog backup. SQLite DeleteTable of a DELETING table
(control_plane_delay_seconds of 1 or more) returns its description too.

Cassandra: open_table_snapshot returns Unsupported. Cassandra has no
read that fixes an instant across a table (a token-range scan observes
each range at a different time; LWT serializes single partitions), and
its in-catalog logical backup says so (ADR-0019). Returning that copy
under this trait's contract would make the manifest's snapshot member
false, so the backend refuses and the in-catalog BackupEngine path stays
its backup path. A direct test asserts the refusal naming the backend on
an existing table and a missing one.

MongoDB: a session started with snapshot=true reads the tables document,
the indexes documents (doc_to_table_description_in now takes an optional
session) and the base collection at one cluster time; the marker is that
time. Every read sets readConcern: snapshot explicitly. The production
catalog handle carries readConcern: local (as a URI with
readConcernLevel= would), and on it a read without the level fails: the
first read reports no snapshot time, which the open refuses naming the
read concern, and a later read in the pinned session is refused by the
server with code 72 (InvalidOptions). A server that is not a replica set
member refuses the first read with NotAReplicaSet (123), mapped to
Unsupported; each of the two refusals names its own cause.
SnapshotTooOld (239) and SnapshotUnavailable (246) map to
SnapshotExpired, pinned by a unit test that builds the command error
from a reply document; the server keeps the snapshot for at least
minSnapshotHistoryWindowInSeconds and may discard it after, and an idle
cursor times out after cursorTimeoutMillis (10 minutes, CursorNotFound
mapped to Internal). Error messages carry the server's code name and
message, not the driver's dump of the reply. A document the stream
cannot decode ends it with that error. key_ordered is false: for a
composite key _id is a netstring of the key text, which is not the
format's order for any key type.

Test hooks (extenddb_storage::snapshot::paging): a page-size knob, a
round-trip counter, and a pause at two points of the open
(AfterDefinition, BeforeItems) that the conformance check holds while it
commits writes. PostgreSQL and SQLite record each FETCH or page query;
for MongoDB, whose driver hides getMore, the test fixture records,
through command monitoring, each find the server receives on the data
database at readConcern snapshot (the stream's; the GSI backfill's find
reads at majority) and each getMore on a cursor those finds returned, so
the count is the snapshot stream's paging as the server saw it. They and
the conformance check compile only with the test-hooks feature of
extenddb-storage, which the test-hooks features of storage-postgres and
storage-sqlite and the new snapshot-test-hooks of storage-mongodb enable
(storage-mongodb's existing test-hooks, the integration binary's
coordination hooks, is unchanged). The backend crates' tests enable them
through a dev-dependency on themselves. Production builds carry neither
the hooks nor the harness. PostgreSQL also gains test-hooks entries
spawn_control_plane_poller_for_tests and
spawn_control_plane_poller_with_active_poll_for_tests (the production
poller, the second with its active retry interval as a parameter),
recover_control_plane_at_startup_for_tests,
control_plane_drop_attempts_for_tests (a per-engine count of drop
attempts, a held table the probe skipped counted),
arm_delete_table_pause_at (holds DeleteTable at BeforeDrops, between its
probe and its drops, or at BetweenCommits, between the catalog commit
and the data commit on the separate layout; arm_delete_table_pause is
the BeforeDrops form), shorten_data_drop_retry_bound_for_tests (a
process-global shorter bound for the retried data drop), and re-exports
of the data relation names and of apply_all_migrations, so
tests/table_snapshot.rs binds the crate's lists instead of copying them.
The two pauses (the open's and DeleteTable's) are one generic type,
extenddb_storage::test_pause::Pause, and snapshot_conformance::PageSize
is the one page-size guard the three backends' tests use.
tests/table_snapshot.rs fails instead of skipping without a connection
string when EXTENDDB_TEST_PG_REQUIRED is set, which integration.yml
sets.

Tests: extenddb_storage::snapshot_conformance::run is one check every
backend runs against a live database. It sets the shared page size to
50, opens a snapshot on a 700-item composite-key table with a GSI, reads
51 items and asserts the round-trip counter advanced by at least two
since just before the open (so a backend that ignored the knob, or a
MongoDB cursor without batch_size, fails here instead of passing
vacuously), then a writer deletes 100 items, inserts 100, and overwrites
all 600 remaining items twice before the reader drains. Asserts: 700
items, no duplicates, every deleted item present, no inserted item,
every version at its pre-open value, the counter advanced after the
writer committed (a stream that read every page during the open fails),
key_ordered as the backend declares it and strictly ascending keys by
KeyOrder::compare (the format's comparator) when ordered, and a second
snapshot taken after the writer stops shows the final round. Then, once
per pause point, on a fresh table: with the open held, it commits W1, an
UpdateTable adding a GSI, and W2 (in that order; on PostgreSQL with a
separate data database the UpdateTable waits for the row lock and the
open resumes after 2 s), and asserts what one instant implies: a
description with the new GSI has W1, items with W2 have the GSI in the
description, and W2 is not in the items at all, since the pause is
inside the open's read window (a backend whose pauses sat before both
reads passed the two implications), and which of W2's commit and the 2 s
timeout resumed the open, against the backend's declared
Expectations::update_table_waits_at (PostgreSQL with a separate data
database at AfterDefinition: the timeout; everywhere else: W2), so a
slow UpdateTable cannot make the W2 assertion vacuous. Before the writer
starts the snapshot's description must equal the storage layer's
describe_table with the running counts and table_throughput_mode_summary
cleared; restore_summary is compared, and each backend's suite also
compares a table given a restore record
(assert_snapshot_describes_the_table). Every drain is under a 120 s
deadline naming the stage and the items read, and a test waiting for the
hooks lock gives up when one holder has kept it 600 s, naming the wait.
READ COMMITTED or no FOR SHARE on PostgreSQL, the data cursor or the
index read outside the session on MongoDB, and the pauses moved outside
the read window on SQLite or MongoDB, fail it, as do the pauses moved
outside the read window with a 2.5 s UpdateTable (the branch assertion),
a description built from a second code path (the DescribeTable
comparison), and a stream that never ends (the drain deadline, within
120 s instead of hanging the binary). Six more key shapes (hash-only S,
N and B; S hash with S, N or B range) run at a page of 3 with keys whose
byte, collation and base64 orders differ and range numbers whose text
and numeric orders differ (negative, fractional, 1E-130, 9.9E125, and
38- and 34-digit pairs a last digit apart; the check's Expectations say
how many digits a backend stores, and MongoDB's refusal of the 38-digit
pair at the put, Decimal128's 34 digits, is asserted). Every test that
opens a snapshot or touches the hooks holds
snapshot_conformance::hooks_lock(), whose guard run and run_refusals
take as an argument, so no other test in the binary can take the pause,
reset the knob or feed the counter during a check. run_refusals covers a
missing and a non-ACTIVE table on the three backends that implement
snapshots. The suites pass on PostgreSQL 16 (both layouts), file-backed
SQLite and MongoDB 7 (replica set; the standalone refusal against a
standalone mongod), and the Cassandra test on Cassandra 4.1.

PG: tests/table_snapshot.rs (81 tests and two ignored race measurements:
consistency, marker and key order, tables with legacy-collation key
columns streaming unordered and complete on a scratch database whose
collation is checked not to be C, five held snapshots on a
ten-connection pool leaving GetItem and DescribeTable unblocked with the
sixth LimitExceeded and an open succeeding after one is dropped, a GSI
without its data table left out with its attribute, DeleteTable during a
snapshot returning DELETING, with the production poller at a 10 s active
poll the table gone within 3 s of a release during the pause after the
first drop attempt (for a dropped stream, a drained stream, a fetch
error, an open cancelled at BeforeItems and the in-catalog backup), for
a plain reader holding ACCESS SHARE that sends no wake, held 10 s past
the poller's window, the table gone within 3 s of its release, a reader
across a restart (the table gone within 3 s of its release), a drop
failing on a dependent view making at most 3 attempts in the 15 s after
the window, a restored table's restore_summary in the snapshot
description, DeleteTable with only a GSI or vector-index data table
locked taking the async path, an unparseable row ending the stream and
freeing its permit, twenty concurrent DeleteTables on a ten-connection
pool, an open completing with nine of ten connections held, an open held
while nine DeleteTables queue, DeleteTable at BeforeItems taking the
async path, a fetch error freeing the permit, a drained stream out of
its transaction before its end is polled, a ROW EXCLUSIVE and a SHARE
holder sending DeleteTable down the asynchronous path within about 1 s
with no data table left without its row, a DeleteTable cancelled during
that wait leaving the table ACTIVE with its data, write holders on the
base and three GSI tables released 0.8 s apart taking it there at about
1 s, a write holding the base and then writing the GSI table finishing
first with the table removed in the request, a claimed queue row holding
DeleteTable until it ends, a probed reader sending it asynchronous in
milliseconds, a statement timeout and a deadlock victim taking the
asynchronous path, DeleteTable of a DELETING table returning its
description and changing nothing, a deferred drop due at once, four held
tables probed with no lock wait (the first released gone within 2 s, a
CreateTable ACTIVE within 2 s), all on both pool layouts; on the
separate layout, a data commit that fails after the catalog commit (its
backend terminated between the two) retried until the drop goes through
with the request returning DELETING and no relation left, a data
database refusing connections past a shortened bound returning Internal
naming every leaked relation with the row gone, and a caller cancelled
between the two commits leaving no orphan; on the shared layout, a
snapshot open or a GSI-only reader taking its lock after DeleteTable's
probe sending it down the asynchronous path with the row and both data
tables kept, and a table dropped before the shared-layout LOCK being
TableNotFound; the SQLSTATE mapping; the two ignored race measurements,
which delay the delete or the open in alternate pairs and assert both
outcomes occurred) with a Drop guard that drops the scratch databases on
success or panic, added to the direct-storage step in integration.yml;
the full storage-postgres suite is 259 tests. SQLite: in-module tests
(consistency, key order, the in-memory copy releasing the connection,
in-memory copies holding their slot, a damaged attribute definition
streaming unordered and complete, the rowid fallback at a page of seven
with no duplicates and at least six fetches, slots shared with
CreateBackup and its refusal text, the WAL checkpoint reply while a
snapshot is held, a restored table's restore_summary compared on a
file-backed and an in-memory engine, a drained stream and a stream that
hit an error freeing their slot while still held, and an in-memory copy
freeing its slot with its last item) with the same guard for the scratch
files, created under the test binary's target directory. MongoDB:
in-module tests (consistency, an undecodable document ending the stream,
reads without the snapshot level failing with code 72 and the error
text, the code mapping, a restored table's restore fields compared, the
round-trip counter counting only the snapshot stream (a GSI backfill
leaves it unchanged), and a standalone server answering Unsupported,
gated on EXTENDDB_TEST_MONGODB_STANDALONE_URI, which the CI job sets
against a standalone mongod container it starts (and failing rather than
skipping when EXTENDDB_TEST_MONGODB_REQUIRED is set without it)) on
per-test scratch databases built without the bootstrapper, as #384's
live tests do, the catalog handle with the production option set,
dropped by a guard on success or panic, so a URI in the environment
never makes cargo test touch another instance's databases; gated on
EXTENDDB_TEST_MONGODB_URI (failing rather than skipping when
EXTENDDB_TEST_MONGODB_REQUIRED is set, which the CI job does), holding
the snapshot hooks lock, run by a new integration-mongodb.yml job that
starts a replica set through devtools/run-mongodb-tests --mongo-only and
a standalone mongod (both on 127.0.0.1 only).
tests/test_table_operations.py: DeleteTable of a DELETING table returns
its description, on PostgreSQL and SQLite (the state written into the
catalog), skipped on MongoDB and Cassandra, which never leave a table
DELETING.

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 29, 2026
Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), eight review rounds and the
maintainer's reviews against the companion PRs #393, #394 and #395, and
a reconciliation against main abf8082 (#384 in its final form, the
Cassandra backend #338, the PostgreSQL GSI build #401); then split so
that the document is the overview of the series and the companions' own
documentation is the record of what their code does (see the last
paragraph). The revision log at the end lists each change; the ones that
alter behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). Indexes are reported in
  whatever build state they had and only an index being deleted is
  dropped; a GSI without its data table is left out with the attribute
  definitions only it used. On PostgreSQL's shared layout the open is
  one connection and one REPEATABLE READ transaction; on the separate
  layout the catalog row is held FOR SHARE until the data snapshot pins,
  and an index that becomes ACTIVE between the definition read and the
  pin is reported CREATING. PostgreSQL bounds open snapshots at half the
  pool and refuses more with LimitExceeded, so open snapshots leave half
  the pool to requests; from PR 6 on max_concurrent_jobs (default 4)
  bounds backups and restores together under that permit, and #384's
  restore slots go. It streams a table whose key columns predate COLLATE
  "C" unordered rather than sort it, and on both layouts DeleteTable
  commits the catalog removal only after its drops succeed, base table
  first, under one 1 s budget, taking its asynchronous path with the row
  kept when its probe finds a reader or a drop fails in a lock wait
  (lock timeout, deadlock victim or statement timeout), so no DROP waits
  behind a reader with the row gone; on the separate layout a task the
  request awaits commits the catalog and then the data transaction, and
  a data commit that fails after the catalog commit is retried on a
  fresh connection for a bounded time before the request returns (an
  orphan survives only a process death or a data database down past that
  bound, and the troubleshooting page says how to drop it); the
  control-plane poller probes held tables and retries each about once a
  second, at once on a snapshot's or an in-catalog backup's release, a
  drop DeleteTable deferred is due at once, and DeleteTable of a
  DELETING table returns its description on PostgreSQL and SQLite. On
  sqlite-memory the snapshot copies the table on the one connection
  inside the request and keeps a backup slot until its stream ends, so
  CreateBackup keeps working there once [backup.store] is set. MongoDB
  reads without the explicit snapshot level fail (#395's MongoDB module
  doc gives the server's error).
- The snapshot is opened in the CreateBackup request (stricter than the
  service's two-minute window), and the backup id is minted from the
  instant the open returned, just after the pin, so the backup holds
  every write acknowledged before the request was received, and
  backup_creation_date_time, the catalog's created_at and the ARN carry
  one time and the ListBackups cursor is a function of the ARN.
- Per-job claims with fencing replace the single-instance lease the
  draft assumed: an advisory lock on a dedicated catalog connection on
  PostgreSQL (#384's seed and key), the row itself on MongoDB (born
  through an upsert pipeline with $literal), an in-process slot on
  SQLite, a lightweight-transaction row on Cassandra (restore targets).
  Heartbeats are a timer on the database's clock with a deadline; any
  heartbeat that does not confirm the claim aborts the job. Every job
  status write is conditioned on the generation and on the row's state,
  and an ambiguous AVAILABLE update is re-read. A job deletes a prefix,
  or a restore target, only after its FAILED write matched a row. The
  reconciler's duties are listed once, in PR 8's section, and include
  the prefixes of DELETED rows, orphan restore targets, stale spools
  and, on Cassandra, active_restores entries whose target is not a
  CREATING restore target of the backup. Every duty that reads or writes
  a backup row acts only on rows of the configured store (store_kind,
  store_root and the store id); a CREATING or DELETING row of another
  store is reported and left alone. The PostgreSQL staleness test also
  selects a NULL heartbeat. While a maintenance claim is held the
  reconciler only finishes DELETING rows, resolves rows whose owner is
  dead and runs its FAILED-target duty, which never removes a target
  left for reason target_not_empty; that one is the operator's, removed
  with extenddb backup remove-target. The PostgreSQL claim connection
  sets TCP keepalives, so the loss of the owner's host frees its lock in
  about a minute. While the configured store is unhealthy the reconciler
  acts on no row of the store; its legacy-row duty, which touches no
  store, still runs.
- DeleteBackup tombstones the row as DELETING, deletes the manifest
  first, and marks DELETED only when the prefix is empty. backup sync is
  scoped to the configured store; extenddb-store.json records the store
  id and the owning catalog's catalog_id, and a root another catalog
  owns, rows without the file, rows with two store ids or a missing
  newest manifest mark the store unhealthy (store operations refused,
  the reconciler idle on the store's rows) instead of stopping startup,
  so an unmounted volume, a recreated bucket or two catalogs on one root
  are caught; backup sync --adopt-store hands a root to another catalog,
  and sqlite-memory's startup sync takes ownership of a root a
  sqlite-memory catalog owned the same way, so one catalog owns a root
  at a time there too; instances that share a catalog must share one
  store root; it never adopts a CREATING, DELETING, DELETED or FAILED
  prefix, nor one whose listing lacks a data file the manifest lists at
  its recorded size, and marks a row whose manifest head is gone
  DELETING only with --prune-missing. --move-store resolves the old
  root's CREATING and DELETING rows, compares every data file, refuses
  to carry a tombstone over a verified manifest and offers
  --forget-backup; --forget-store deletes the rows of a root. purge,
  remove-target, --move-store, --forget-store, --prune-missing and
  --adopt-store take a catalog maintenance claim that every job checks;
  max_concurrent_jobs is per instance. DeleteBackup and Restore of
  another store's row return a ValidationException that names no root.
  Restore and Describe never read the store without a catalog row; an
  ARN with no row goes to the legacy reader. The store-id file is
  created with put_if_absent (If-None-Match on S3, O_EXCL and link on
  the filesystem). A backup is in use while a restore target registered
  from it is CREATING, as on main: DeleteBackup and a second restore
  return BackupInUseException. Purge deletes each row's prefix, never a
  whole account prefix. Every backup row is read by account and backup
  id and must match the request's account, table and ARN, and no row
  stores its key prefix. DeleteBackup's in-use check and DELETING write
  run under the backup row's lock, and a restore's registration
  re-checks AVAILABLE and in-use under the same lock, so the two
  serialize as on main (on Cassandra through main's active_restores
  lightweight transactions, now for new rows too); main's two PostgreSQL
  race tests stay for the legacy path and PR 6 adds the same two
  orderings over an off-host backup.
- Restore returns CREATING from the request (TableAlreadyExists or
  TableInUse on a name race, as #384 returns), spools and verifies every
  data file before writing any item, and records progress atomically
  with the data on PostgreSQL and SQLite and by idempotent
  re-application of the one unrecorded file or batch on MongoDB and
  Cassandra; on MongoDB the job writes DONE and the backfill flag in one
  transaction and the worker's ACTIVE flip is conditioned on the flag,
  not on the job's generation. A key-ordered restore runs #394's
  per-file key-range check and an unordered one ends with a count of the
  target, which is Cassandra's only duplicate check. A PostgreSQL
  restore is resumed, or its target deleted, only after the stale
  window; the manifest and sidecar are spooled for a resume; a legacy
  restore with a dead owner is failed, not resumed. The ACTIVE flip
  precedes the deletion of the PostgreSQL progress rows. DeleteTable on
  a restore target stays refused, as #384 and the service refuse it, and
  the job and the reconciler remove a failed target through the
  backend-internal remove_restore_target; the restore record is the
  table's child. Multi-part-key tables are restored (#384 refuses them;
  bulk_load keys rows with the backend's item-path functions, so the
  table is laid out as a natively created one). On PostgreSQL and SQLite
  bulk_load writes GSI, LSI and vector-index rows in the batch's
  transaction with the base rows, and the ACTIVE flip sets the table and
  every index ACTIVE in one catalog transaction; on Cassandra the index
  rows are created ACTIVE and the lightweight transaction flips the
  table alone. Table class is not restored, as on the service and #384;
  every override but VectorIndexOverride is applied, as #384 applies
  them. A second restore of a backup in use is refused before the permit
  and re-checked at registration. A restore stops on shutdown leaving
  its row RUNNING, download transport errors are retried for 15 minutes,
  and a takeover rewrites spool_host. Each data file is read through
  ExtenddbManifest::reader_prehashed, which applies the key-range check
  without hashing the spooled bytes again.
- The engine binds a BackupCatalog trait (PR 6) with a JobClaim for the
  per-backend claim, heartbeat and conditional status writes; new rows
  live in backup_index (a new table or collection; Cassandra reuses its
  tables with store_kind set and adds backup_index_by_status); MongoDB's
  catalog gets the versioned migration step it lacks; PR 6's migration
  backfills in-flight #384 restores with heartbeat_at at the epoch so
  the first reconciler pass fails them; every pre-PR-6 server stops
  before migrate. Vector tables are backed up and restored on the
  off-host path, while the legacy create keeps main's refusal (one #384
  engine test is rewritten in PR 9a and the wire test follows the
  configured path). source_table carries stream_specification and
  time_to_live for DescribeBackup. Both requests take the job permit
  last, after every refusal that needs no store access. The format's
  evolution rule is a version bump for any byte change, with v{N}
  fixtures beside v1. The trait gains listings for ListBackups, the
  store-id check and the root-matched commands, and backup_in_use.
  ListBackups accepts a table ARN in TableName from PR 6, as the service
  does. PR 6 adds the [backup] struct with the job keys. Row and
  restore-target methods take account-scoped keys, and the trait gains
  catalog_id and DeleteBackup's lock-holding start_backup_delete.
- The manifest self-checksum is replaced by a sidecar over the stored
  bytes, accepted in its sha256sum line form only; backup_arn is the
  backup's identity and source_table must agree with it; N key values
  are normalized before key text or key order is computed, and an
  un-normalized key_range endpoint is refused; the version gate accepts
  1 to the current version. The line cap is 32 MiB and is the floor of
  data_file_target_bytes; the format's other rules, the decoder's memory
  included, are #394's module docs, which section 1 points at. A reader
  refuses a manifest without stream_specification or time_to_live. The
  backup job validates every item with an item validator PR 8 adds to
  extenddb-core, which the restore binds, and fails the backup on a
  value #618 let in through any of the four write paths, a regression
  from main for tables that hold one; it validates the rest of the
  snapshot first and logs every refused item, and the FAILED reason
  gives the count and the first.
- Store (#393): section 2 points at the crate's README and module docs
  and keeps the three store facts the design's rules rest on (put and
  delete_prefix are unordered, so a caller never puts under a prefix it
  is deleting; runtime shutdown sends no abort, so the server awaits
  each job's failure path; listing has no delimiter form) and names
  put_if_absent as PR 7's addition. Section 8 points at the README's IAM
  example and config.rs's transport and proxy rules. The revision log
  (items 21 to 23) records the store facts the companion rounds settled:
  the endpoint is refused with user information, a query, a fragment or
  an IPv6 zone id, from any source; the S3 client for an http://
  endpoint is built with aws_smithy_http_client's Builder and no proxy,
  and the credential chain's clients get the same client when a
  credential endpoint setting, the IMDS endpoint setting or the
  container credentials URI is http://, so the chain's own http://
  requests do not use the proxy; without such a setting the chain uses
  the SDK's default client and NO_PROXY must list 169.254.169.254
  (fd00:ec2::254 in IPv6 mode) and 169.254.170.2; a denied
  AbortMultipartUpload in delete_prefix's sweep is logged and the
  deletion succeeds; a put dropped during the temporary's open leaves no
  temporary.
- Configuration: the default store root is per backend (beside the
  database file on file-backed SQLite, where a defaulted root that fails
  validation is a warning, not a startup error); without [backup.store],
  CreateBackup on every backend keeps the legacy in-catalog create with
  a deprecation warning until PR 14 removes it (Cassandra always, having
  no SnapshotEngine), after which it is a ValidationException naming
  [backup.store], and kind = "disabled" is the opt-out; PR 14's
  migration refuses while legacy backup rows remain unless forced. A
  store transport failure at startup degrades the store component
  instead of stopping the server. The memory formula is in item text
  bytes, and PR 8 bounds each PostgreSQL and SQLite snapshot page by
  SNAPSHOT_FETCH_BYTES (64 MiB) in the page's query, PostgreSQL's cursor
  replaced by a keyset page under the same snapshot, so a page holds at
  most the budget plus one row; the PostgreSQL page returns its key
  columns, orders the outer query and sizes its row limit from the
  previous page's average row, starting at 16; the PostgreSQL pool rule
  is max(MIN_POOL_SIZE, 2 * max_concurrent_jobs + 10), 18 at the default
  4; the spool default is per backend and spool_host is host plus spool
  path.
- Phase 1 is the PR series with acceptance criteria and GitHub numbers;
  migration numbers are reserved up front (Cassandra V006 to PR 1, V007
  to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill
  points for the crash tests are enumerated per backend and for
  DeleteBackup. The status line names the companion PRs' heads
  (aef17dd, 3f7107d, 069c4b1); the Phasing section refers to them by
  PR number. migrate-legacy re-reads and validates what it wrote and
  refuses, keeping the legacy rows, backups it cannot carry.

Also adds the missing rows for designs 12 and 13 to
docs/design/README.md; the row for 15 describes the document as the
overview of the series and names the crates whose documentation holds
the format, store and snapshot details.

The series after the four open PRs is ten PRs: the reconciler ships with
the CreateBackup job as PR 8 (a job without its reconciler leaves rows
no one resolves), and the CI changes and the documentation ship together
as PR 11, since both finalize after PR 9b. The revision log (item 24)
records the renaming; its earlier items keep the old labels.

The document is then split (revision log item 25): sections 1, 2 and 3
point at the companions' documentation (crates/core/src/backup_format
and its testdata README, #394; crates/backup-store/README.md and its
module docs, #393; crates/storage/src/snapshot.rs and the four
snapshot_engine.rs, #395) and keep only the rules later PRs build on;
sections 4 and 5 keep the invariants, the state machines and the
per-operation contracts; the schema, the per-backend mechanics, the
reconciler's duty list and the maintenance commands move into one
section per later PR under Phasing (PR 1, 6, 7, 8, 9a, 9b, 10, 11, 13,
14), each with its goal, the invariants it honours, the interfaces it
adds with their row keys, the transitions it owns and its acceptance
list kept whole; section 10 is a table of which document each PR
changes. The three companions gain the facts only this document stated
(the filesystem list's whole-root Vec and the empty-prefix IAM example
in #393's README; the fetch-size reason and the pre-#361 sort cost in
#395's module docs) and a sentence each saying this overview states the
rules their callers honour. 279,584 bytes become 187,722.

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 29, 2026
…ackend

Adds the one new obligation the engine-owned backup path places on a
backend: open a consistent view of one table and stream its items. The
trait returns the table definition, a snapshot marker, an ordering flag,
and the item stream together, from one operation, so a backup's manifest
and data files always describe the same instant. The in-catalog backup
code keeps its own copy of this logic for now; the engine-side backup
job (next PR) is the first caller. Stacked on #394: SnapshotInfo is the
manifest's SnapshotDescriptor (one definition, with the three KIND_*
constants), and key_schema_is_orderable is KeyOrder::resolve, so a
stream a backend marks ordered is one the manifest parser accepts and a
table whose range key has no attribute definition streams unordered and
complete instead of paging by the hash key alone.

Trait (crates/storage/src/snapshot.rs): open_table_snapshot(account_id,
table_name) returns TableSnapshot { description: TableDescription, info:
SnapshotInfo, key_ordered: bool, items: BoxStream<Result<Item>> }. The
description is the storage-level one at the snapshot instant, not the
API response (restore_summary may be set, table_throughput_mode_summary
is not, the counts are the stats pass's); items are yielded as stored
(valid N normalized, invalid raw, TTL-expired unswept items included),
and any error means start over, SnapshotExpired being the one worth
retrying. The stream owns the backend transaction or session; dropping
it releases it. A stream that has not reached its end (including one
never polled) must be dropped inside a Tokio runtime: the sqlx backends
return its connection through the runtime and panic outside one, MongoDB
does not, and the trait doc says so. The first Err ends the stream on
every backend. StorageEngine now requires SnapshotEngine; all four
in-tree backends implement it. The trait doc names what each backend
holds while a stream is open and which errors each returns (Cassandra
answers Unsupported before it resolves the table), the idle timeouts
that end a stream as Internal, and why the trait has no default body and
StorageError stays exhaustive; PostgreSQL and SQLite bound open
snapshots per server (below) and refuse over the bound with
LimitExceeded. StorageError gains SnapshotExpired for a snapshot the
server discarded before the stream drained; the engine maps it to a
retryable 503 like Transient. snapshot.rs's module doc says that the
design overview (docs/design/15-backup-restore.md, #396) states the
rules the trait's callers honour and that this file's and each backend's
snapshot_engine.rs module docs are the record of what the snapshots do;
DEFAULT_FETCH_BATCH's doc says why the size is a constant (over a 1 ms
link the larger fetch would save about a second per million rows), and
the PostgreSQL module doc's pre-#361 paragraph carries the sort's cost
(7 MB on disk for 100k rows at work_mem 4 MB).

Indexes: the description keeps every index in whatever build state it
had and drops only one being deleted (drop_deleting_indexes, for GSIs
and vector indexes; no in-tree backend reports a DELETING index today,
and unit tests pin the rule); on PostgreSQL a GSI row whose data table
is missing is also left out, with the attribute definitions only it
used, as the in-catalog backup does (since #401 UpdateTable creates the
data table before committing the row, so this is a guard, not an
expected state). A restore recreates the indexes in the description and
builds them from the items, so a CREATING index with a data table comes
back complete; one left out would not come back at all.

PostgreSQL, one shared database: the open uses one connection and one
REPEATABLE READ READ ONLY transaction whose first statement reads the
tables row, so the definition and the items come from one MVCC snapshot
and no row lock is taken; a DeleteTable that drops the data table
between that snapshot and the open's LOCK TABLE makes the open
TableNotFound. Separate data database: the tables row is held FOR SHARE
on a catalog connection from the definition read until the data
transaction (REPEATABLE READ READ ONLY, ACCESS SHARE on the relation)
has pinned its snapshot with SELECT pg_current_snapshot() and the GSI
data-table probe has run; the definition is read on that barrier
connection (build_table_description_on), so an open holds one catalog
connection and the stream one data connection. UpdateTable and
DeleteTable take the row FOR UPDATE, so neither commits in the gap;
index-status workers do not, so an index may be reported in a slightly
older status than the items. The marker is pg_current_snapshot() text on
both. An engine-wide permit is held per snapshot from the open until the
stream's transaction ends, half the pool on either layout, so open
snapshots leave half of the pool their items come from to requests (with
a separate data database and the whole pool allowed, ten open snapshots
made GetItem wait out the pool timeout); restores' own half-pool slots
are separate, so snapshots and restores together can hold the whole
pool, and the field doc says so. Items stream through a server-side
cursor, FETCH 1000 per round trip, ordered by the primary key. A fetch
is bounded by rows, not bytes (up to 27.7 MB of text per row at the
default item limit); the byte budget is the backup job PR's. A table
whose text key columns do not have a collation named "C" or "POSIX"
(created before the key columns were declared with it; "default" on a C
database also counts as not bytewise, which streams unordered and
complete) is streamed unordered, with a warning: ordering it would turn
the read into a sort of the whole table before the first row. SQLSTATE
40001 (recovery conflict on a standby) and 72000 (snapshot_too_old on a
primary, PostgreSQL 16 and earlier) map to SnapshotExpired, pinned by a
test that raises each code on the live server. The stream holds ACCESS
SHARE on the data table until its last fetch, so a DROP TABLE waits for
it; DeleteTable takes the asynchronous path (row DELETING, worker drop
under a lock timeout) when another session holds ACCESS SHARE on any
relation it would drop (the base, GSI, LSI and vector-index data tables:
data::table_storage_relations, the one list drop_table_storage also
drops), probing only when its answer is used (delay < 1 s) and, on the
shared layout, on its own catalog transaction rather than a second
connection from the same pool (twenty concurrent DeleteTables on a
ten-connection shared pool timed out without that). On both layouts the
synchronous path takes every drop's lock before the catalog removal
commits. It deletes the table's pending queue rows first, waiting for
in-flight claims with no timeout, as #401 intends. It then drops the
base, GSI, LSI and vector-index data tables, base first, within one 1 s
budget for the group (SYNC_DROP_LOCK_TIMEOUT; before each DROP,
lock_timeout is the time left). On the shared layout that runs in the
catalog transaction behind a savepoint. With a separate data database it
runs in a data transaction opened first, and a task the request awaits
commits the catalog and then that transaction, so a cancelled caller
cannot stop between the two. A lock wait that fails (55P03, a deadlock
victim 40P01, or a statement_timeout's 57014) rolls the drops back and
takes the asynchronous path with the row still held. A write
transaction, a SHARE holder, an index build or a reader that starts
after the probe therefore costs the request at most the 1 s, and no data
table is left without its row. With a separate data database a data
commit that fails after the catalog commit has the drop retried on a
fresh connection for up to 60 s, so a failover, a dropped connection or
a network blip no longer orphans the data tables; only a process that
dies, or a data database unreachable for that whole window, between the
catalog commit and the last retry leaves them without a row, and then
they stay until an operator drops the relations the error log names
(docs/troubleshooting.md, "Data tables without a catalog row"). The base
table goes first because a write holds it before it writes an LSI or a
synchronous GSI: with the index tables first, a write in progress waited
about 1 s on the drop, and the drop went asynchronous. On the shared
layout a DeleteTable probing between an open's snapshot and its LOCK
does not see the open; if the LOCK is granted first, the drop times out
after 1 s, is rolled back with the removal, and DeleteTable returns
DELETING (in 80 unaided open-and-delete pairs, the delete first in half
of them, 0 left a data table without a row on either layout; 39 to 40
deletes ran synchronously and 40 to 41 asynchronously). The data
transaction lives in a guard from BEGIN on, so every path that ends it
after it may have taken ACCESS SHARE wakes the control-plane poller: the
last page (before its commit is awaited), a fetch error, a stream
dropped before its end, and an open that fails or is cancelled; the
in-catalog backup's data transaction is held in the same guard and wakes
it too. The poller stays active while a DELETING row's drop is deferred,
also after a restart: startup recovery runs the same pass and wakes the
poller when it deferred a drop. A drop is deferred when the probe finds
another session holding any lock on the table's relations, or when the
drop fails in a lock wait (55P03, 40P01 or 57014); any other failure is
logged and left to the normal window and the 60 s idle pass. Each pass
probes a held table and skips it without waiting, and drops a free one
within the same 1 s budget, so every held table is retried about once a
second however many are held, and a CreateTable that is due is activated
on the same cadence (1.1 s with four drops deferred). A drop that
DeleteTable deferred is due at once, not after
control_plane_delay_seconds. The active loop waits on the wake as well
as on its timer, so a snapshot's or an in-catalog backup's release has
the drop retried at once, and any holder's release is followed within
about a second: 108 to 166 ms in the tests, with a 10 s test-only active
poll for the wake cases. The admin guide's control_plane_delay_seconds
row and the troubleshooting page's DELETING entry state the default
(0.25 on every backend), the bound and the retry cadence, the same-name
CreateTable refusal until then, and a two-step query, over every
relation of the table, that finds any session holding a lock on them and
its lock mode. A DeleteTable of a table that is already DELETING returns
its description and changes nothing.

SQLite: one deferred read transaction covers catalog and data; its first
read pins the WAL snapshot and no lock is taken, so writers never wait
on a snapshot being opened and the WAL grows until the stream ends (a
TRUNCATE checkpoint waits out the busy timeout and reports busy; the
module doc gives the measured reply). #384's
create_backup_from_wal_snapshot holds the writer lock while it pins
because it also inserts the CREATING row; this path writes nothing.
build_table_description_on reads the definition, including #384's
restore_summary from table_restores, on that transaction. The stream
holds a backup slot (backup_server_slots, shared with the in-catalog
backup) until its read transaction ends (the short page that shows the
end, or the first error) or it is dropped, so the pool arithmetic that
keeps half the pool free counts snapshots; with no slot free the open
returns LimitExceeded at once, and a CreateBackup that waited out its 30
s for a slot says that backups or table snapshots hold them. The
pool_size doc gives the arithmetic. Items page through row-value keyset
pagination on the primary key index, so key_ordered is true for
single-range-key tables; multi-part keys fall back to rowid order. A
page is bounded by rows, not bytes, as on PostgreSQL. An in-memory
database has one connection, so the open reads every page in its
transaction and returns a stream over the copy, holding no connection
once it returns; the copy's stream keeps its backup slot until it has
yielded its last item or is dropped, so the slots bound how many decoded
copies exist, as #384's create_backup_single_connection does for the
in-catalog backup. SQLite DeleteTable of a DELETING table
(control_plane_delay_seconds of 1 or more) returns its description too.

Cassandra: open_table_snapshot returns Unsupported. Cassandra has no
read that fixes an instant across a table (a token-range scan observes
each range at a different time; LWT serializes single partitions), and
its in-catalog logical backup says so (ADR-0019). Returning that copy
under this trait's contract would make the manifest's snapshot member
false, so the backend refuses and the in-catalog BackupEngine path stays
its backup path. A direct test asserts the refusal naming the backend on
an existing table and a missing one.

MongoDB: a session started with snapshot=true reads the tables document,
the indexes documents (doc_to_table_description_in now takes an optional
session) and the base collection at one cluster time; the marker is that
time. Every read sets readConcern: snapshot explicitly. The production
catalog handle carries readConcern: local (as a URI with
readConcernLevel= would), and on it a read without the level fails: the
first read reports no snapshot time, which the open refuses naming the
read concern, and a later read in the pinned session is refused by the
server with code 72 (InvalidOptions). A server that is not a replica set
member refuses the first read with NotAReplicaSet (123), mapped to
Unsupported; each of the two refusals names its own cause.
SnapshotTooOld (239) and SnapshotUnavailable (246) map to
SnapshotExpired, pinned by a unit test that builds the command error
from a reply document; the server keeps the snapshot for at least
minSnapshotHistoryWindowInSeconds and may discard it after, and an idle
cursor times out after cursorTimeoutMillis (10 minutes, CursorNotFound
mapped to Internal). Error messages carry the server's code name and
message, not the driver's dump of the reply. A document the stream
cannot decode ends it with that error. key_ordered is false: for a
composite key _id is a netstring of the key text, which is not the
format's order for any key type.

Test hooks (extenddb_storage::snapshot::paging): a page-size knob, a
round-trip counter, and a pause at two points of the open
(AfterDefinition, BeforeItems) that the conformance check holds while it
commits writes. PostgreSQL and SQLite record each FETCH or page query;
for MongoDB, whose driver hides getMore, the test fixture records,
through command monitoring, each find the server receives on the data
database at readConcern snapshot (the stream's; the GSI backfill's find
reads at majority) and each getMore on a cursor those finds returned, so
the count is the snapshot stream's paging as the server saw it. They and
the conformance check compile only with the test-hooks feature of
extenddb-storage, which the test-hooks features of storage-postgres and
storage-sqlite and the new snapshot-test-hooks of storage-mongodb enable
(storage-mongodb's existing test-hooks, the integration binary's
coordination hooks, is unchanged). The backend crates' tests enable them
through a dev-dependency on themselves. Production builds carry neither
the hooks nor the harness. PostgreSQL also gains test-hooks entries
spawn_control_plane_poller_for_tests and
spawn_control_plane_poller_with_active_poll_for_tests (the production
poller, the second with its active retry interval as a parameter),
recover_control_plane_at_startup_for_tests,
control_plane_drop_attempts_for_tests (a per-engine count of drop
attempts, a held table the probe skipped counted),
arm_delete_table_pause_at (holds DeleteTable at BeforeDrops, between its
probe and its drops, or at BetweenCommits, between the catalog commit
and the data commit on the separate layout; arm_delete_table_pause is
the BeforeDrops form), shorten_data_drop_retry_bound_for_tests (a
process-global shorter bound for the retried data drop), and re-exports
of the data relation names and of apply_all_migrations, so
tests/table_snapshot.rs binds the crate's lists instead of copying them.
The two pauses (the open's and DeleteTable's) are one generic type,
extenddb_storage::test_pause::Pause, and snapshot_conformance::PageSize
is the one page-size guard the three backends' tests use.
tests/table_snapshot.rs fails instead of skipping without a connection
string when EXTENDDB_TEST_PG_REQUIRED is set, which integration.yml
sets.

Tests: extenddb_storage::snapshot_conformance::run is one check every
backend runs against a live database. It sets the shared page size to
50, opens a snapshot on a 700-item composite-key table with a GSI, reads
51 items and asserts the round-trip counter advanced by at least two
since just before the open (so a backend that ignored the knob, or a
MongoDB cursor without batch_size, fails here instead of passing
vacuously), then a writer deletes 100 items, inserts 100, and overwrites
all 600 remaining items twice before the reader drains. Asserts: 700
items, no duplicates, every deleted item present, no inserted item,
every version at its pre-open value, the counter advanced after the
writer committed (a stream that read every page during the open fails),
key_ordered as the backend declares it and strictly ascending keys by
KeyOrder::compare (the format's comparator) when ordered, and a second
snapshot taken after the writer stops shows the final round. Then, once
per pause point, on a fresh table: with the open held, it commits W1, an
UpdateTable adding a GSI, and W2 (in that order; on PostgreSQL with a
separate data database the UpdateTable waits for the row lock and the
open resumes after 2 s), and asserts what one instant implies: a
description with the new GSI has W1, items with W2 have the GSI in the
description, and W2 is not in the items at all, since the pause is
inside the open's read window (a backend whose pauses sat before both
reads passed the two implications), and which of W2's commit and the 2 s
timeout resumed the open, against the backend's declared
Expectations::update_table_waits_at (PostgreSQL with a separate data
database at AfterDefinition: the timeout; everywhere else: W2), so a
slow UpdateTable cannot make the W2 assertion vacuous. Before the writer
starts the snapshot's description must equal the storage layer's
describe_table with the running counts and table_throughput_mode_summary
cleared; restore_summary is compared, and each backend's suite also
compares a table given a restore record
(assert_snapshot_describes_the_table). Every drain is under a 120 s
deadline naming the stage and the items read, and a test waiting for the
hooks lock gives up when one holder has kept it 600 s, naming the wait.
READ COMMITTED or no FOR SHARE on PostgreSQL, the data cursor or the
index read outside the session on MongoDB, and the pauses moved outside
the read window on SQLite or MongoDB, fail it, as do the pauses moved
outside the read window with a 2.5 s UpdateTable (the branch assertion),
a description built from a second code path (the DescribeTable
comparison), and a stream that never ends (the drain deadline, within
120 s instead of hanging the binary). Six more key shapes (hash-only S,
N and B; S hash with S, N or B range) run at a page of 3 with keys whose
byte, collation and base64 orders differ and range numbers whose text
and numeric orders differ (negative, fractional, 1E-130, 9.9E125, and
38- and 34-digit pairs a last digit apart; the check's Expectations say
how many digits a backend stores, and MongoDB's refusal of the 38-digit
pair at the put, Decimal128's 34 digits, is asserted). Every test that
opens a snapshot or touches the hooks holds
snapshot_conformance::hooks_lock(), whose guard run and run_refusals
take as an argument, so no other test in the binary can take the pause,
reset the knob or feed the counter during a check. run_refusals covers a
missing and a non-ACTIVE table on the three backends that implement
snapshots. The suites pass on PostgreSQL 16 (both layouts), file-backed
SQLite and MongoDB 7 (replica set; the standalone refusal against a
standalone mongod), and the Cassandra test on Cassandra 4.1.

PG: tests/table_snapshot.rs (81 tests and two ignored race measurements:
consistency, marker and key order, tables with legacy-collation key
columns streaming unordered and complete on a scratch database whose
collation is checked not to be C, five held snapshots on a
ten-connection pool leaving GetItem and DescribeTable unblocked with the
sixth LimitExceeded and an open succeeding after one is dropped, a GSI
without its data table left out with its attribute, DeleteTable during a
snapshot returning DELETING, with the production poller at a 10 s active
poll the table gone within 3 s of a release during the pause after the
first drop attempt (for a dropped stream, a drained stream, a fetch
error, an open cancelled at BeforeItems and the in-catalog backup), for
a plain reader holding ACCESS SHARE that sends no wake, held 10 s past
the poller's window, the table gone within 3 s of its release, a reader
across a restart (the table gone within 3 s of its release), a drop
failing on a dependent view making at most 3 attempts in the 15 s after
the window, a restored table's restore_summary in the snapshot
description, DeleteTable with only a GSI or vector-index data table
locked taking the async path, an unparseable row ending the stream and
freeing its permit, twenty concurrent DeleteTables on a ten-connection
pool, an open completing with nine of ten connections held, an open held
while nine DeleteTables queue, DeleteTable at BeforeItems taking the
async path, a fetch error freeing the permit, a drained stream out of
its transaction before its end is polled, a ROW EXCLUSIVE and a SHARE
holder sending DeleteTable down the asynchronous path within about 1 s
with no data table left without its row, a DeleteTable cancelled during
that wait leaving the table ACTIVE with its data, write holders on the
base and three GSI tables released 0.8 s apart taking it there at about
1 s, a write holding the base and then writing the GSI table finishing
first with the table removed in the request, a claimed queue row holding
DeleteTable until it ends, a probed reader sending it asynchronous in
milliseconds, a statement timeout and a deadlock victim taking the
asynchronous path, DeleteTable of a DELETING table returning its
description and changing nothing, a deferred drop due at once, four held
tables probed with no lock wait (the first released gone within 2 s, a
CreateTable ACTIVE within 2 s), all on both pool layouts; on the
separate layout, a data commit that fails after the catalog commit (its
backend terminated between the two) retried until the drop goes through
with the request returning DELETING and no relation left, a data
database refusing connections past a shortened bound returning Internal
naming every leaked relation with the row gone, and a caller cancelled
between the two commits leaving no orphan; on the shared layout, a
snapshot open or a GSI-only reader taking its lock after DeleteTable's
probe sending it down the asynchronous path with the row and both data
tables kept, and a table dropped before the shared-layout LOCK being
TableNotFound; the SQLSTATE mapping; the two ignored race measurements,
which delay the delete or the open in alternate pairs and assert both
outcomes occurred) with a Drop guard that drops the scratch databases on
success or panic, added to the direct-storage step in integration.yml;
the full storage-postgres suite is 259 tests. SQLite: in-module tests
(consistency, key order, the in-memory copy releasing the connection,
in-memory copies holding their slot, a damaged attribute definition
streaming unordered and complete, the rowid fallback at a page of seven
with no duplicates and at least six fetches, slots shared with
CreateBackup and its refusal text, the WAL checkpoint reply while a
snapshot is held, a restored table's restore_summary compared on a
file-backed and an in-memory engine, a drained stream and a stream that
hit an error freeing their slot while still held, and an in-memory copy
freeing its slot with its last item) with the same guard for the scratch
files, created under the test binary's target directory. MongoDB:
in-module tests (consistency, an undecodable document ending the stream,
reads without the snapshot level failing with code 72 and the error
text, the code mapping, a restored table's restore fields compared, the
round-trip counter counting only the snapshot stream (a GSI backfill
leaves it unchanged), and a standalone server answering Unsupported,
gated on EXTENDDB_TEST_MONGODB_STANDALONE_URI, which the CI job sets
against a standalone mongod container it starts (and failing rather than
skipping when EXTENDDB_TEST_MONGODB_REQUIRED is set without it)) on
per-test scratch databases built without the bootstrapper, as #384's
live tests do, the catalog handle with the production option set,
dropped by a guard on success or panic, so a URI in the environment
never makes cargo test touch another instance's databases; gated on
EXTENDDB_TEST_MONGODB_URI (failing rather than skipping when
EXTENDDB_TEST_MONGODB_REQUIRED is set, which the CI job does), holding
the snapshot hooks lock, run by a new integration-mongodb.yml job that
starts a replica set through devtools/run-mongodb-tests --mongo-only and
a standalone mongod (both on 127.0.0.1 only).
tests/test_table_operations.py: DeleteTable of a DELETING table returns
its description, on PostgreSQL and SQLite (the state written into the
catalog), skipped on MongoDB and Cassandra, which never leave a table
DELETING.

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 29, 2026
…ackend

Adds the one new obligation the engine-owned backup path places on a
backend: open a consistent view of one table and stream its items. The
trait returns the table definition, a snapshot marker, an ordering flag,
and the item stream together, from one operation, so a backup's manifest
and data files always describe the same instant. The in-catalog backup
code keeps its own copy of this logic for now; the engine-side backup
job (next PR) is the first caller. Stacked on #394: SnapshotInfo is the
manifest's SnapshotDescriptor (one definition, with the three KIND_*
constants), and key_schema_is_orderable is KeyOrder::resolve, so a
stream a backend marks ordered is one the manifest parser accepts and a
table whose range key has no attribute definition streams unordered and
complete instead of paging by the hash key alone.

Trait (crates/storage/src/snapshot.rs): open_table_snapshot(account_id,
table_name) returns TableSnapshot { description: TableDescription, info:
SnapshotInfo, key_ordered: bool, items: BoxStream<Result<Item>> }. The
description is the storage-level one at the snapshot instant, not the
API response (restore_summary may be set, table_throughput_mode_summary
is not, the counts are the stats pass's); items are yielded as stored
(valid N normalized, invalid raw, TTL-expired unswept items included),
and any error means start over, SnapshotExpired being the one worth
retrying. The stream owns the backend transaction or session; dropping
it releases it. A stream that has not reached its end (including one
never polled) must be dropped inside a Tokio runtime: the sqlx backends
return its connection through the runtime and panic outside one, MongoDB
does not, and the trait doc says so. The first Err ends the stream on
every backend. StorageEngine now requires SnapshotEngine; all four
in-tree backends implement it. The trait doc names what each backend
holds while a stream is open and which errors each returns (Cassandra
answers Unsupported before it resolves the table), the idle timeouts
that end a stream as Internal, and why the trait has no default body and
StorageError stays exhaustive; PostgreSQL and SQLite bound open
snapshots per server (below) and refuse over the bound with
LimitExceeded. StorageError gains SnapshotExpired for a snapshot the
server discarded before the stream drained; the engine maps it to a
retryable 503 like Transient. snapshot.rs's module doc says that the
design overview (docs/design/15-backup-restore.md, #396) states the
rules the trait's callers honour and that this file's and each backend's
snapshot_engine.rs module docs are the record of what the snapshots do;
DEFAULT_FETCH_BATCH's doc says why the size is a constant (over a 1 ms
link the larger fetch would save about a second per million rows), and
the PostgreSQL module doc's pre-#361 paragraph carries the sort's cost
(7 MB on disk for 100k rows at work_mem 4 MB).

Indexes: the description keeps every index in whatever build state it
had and drops only one being deleted (drop_deleting_indexes, for GSIs
and vector indexes; no in-tree backend reports a DELETING index today,
and unit tests pin the rule); on PostgreSQL a GSI row whose data table
is missing is also left out, with the attribute definitions only it
used, as the in-catalog backup does (since #401 UpdateTable creates the
data table before committing the row, so this is a guard, not an
expected state). A restore recreates the indexes in the description and
builds them from the items, so a CREATING index with a data table comes
back complete; one left out would not come back at all.

PostgreSQL, one shared database: the open uses one connection and one
REPEATABLE READ READ ONLY transaction whose first statement reads the
tables row, so the definition and the items come from one MVCC snapshot
and no row lock is taken; a DeleteTable that drops the data table
between that snapshot and the open's LOCK TABLE makes the open
TableNotFound. Separate data database: the tables row is held FOR SHARE
on a catalog connection from the definition read until the data
transaction (REPEATABLE READ READ ONLY, ACCESS SHARE on the relation)
has pinned its snapshot with SELECT pg_current_snapshot() and the GSI
data-table probe has run; the definition is read on that barrier
connection (build_table_description_on), so an open holds one catalog
connection and the stream one data connection. UpdateTable and
DeleteTable take the row FOR UPDATE, so neither commits in the gap;
index-status workers do not, so an index may be reported in a slightly
older status than the items. The marker is pg_current_snapshot() text on
both. An engine-wide permit is held per snapshot from the open until the
stream's transaction ends, half the pool on either layout, so open
snapshots leave half of the pool their items come from to requests (with
a separate data database and the whole pool allowed, ten open snapshots
made GetItem wait out the pool timeout); restores' own half-pool slots
are separate, so snapshots and restores together can hold the whole
pool, and the field doc says so. Items stream through a server-side
cursor, FETCH 1000 per round trip, ordered by the primary key. A fetch
is bounded by rows, not bytes (up to 27.7 MB of text per row at the
default item limit); the byte budget is the backup job PR's. A table
whose text key columns do not have a collation named "C" or "POSIX"
(created before the key columns were declared with it; "default" on a C
database also counts as not bytewise, which streams unordered and
complete) is streamed unordered, with a warning: ordering it would turn
the read into a sort of the whole table before the first row. SQLSTATE
40001 (recovery conflict on a standby) and 72000 (snapshot_too_old on a
primary, PostgreSQL 16 and earlier) map to SnapshotExpired, pinned by a
test that raises each code on the live server. The stream holds ACCESS
SHARE on the data table until its last fetch, so a DROP TABLE waits for
it; DeleteTable takes the asynchronous path (row DELETING, worker drop
under a lock timeout) when another session holds ACCESS SHARE on any
relation it would drop (the base, GSI, LSI and vector-index data tables:
data::table_storage_relations, the one list drop_table_storage also
drops), probing only when its answer is used (delay < 1 s) and, on the
shared layout, on its own catalog transaction rather than a second
connection from the same pool (twenty concurrent DeleteTables on a
ten-connection shared pool timed out without that). On both layouts the
synchronous path takes every drop's lock before the catalog removal
commits. It deletes the table's pending queue rows first, waiting for
in-flight claims with no timeout, as #401 intends. It then drops the
base, GSI, LSI and vector-index data tables, base first, within one 1 s
budget for the group (SYNC_DROP_LOCK_TIMEOUT; before each DROP,
lock_timeout is the time left). On the shared layout that runs in the
catalog transaction behind a savepoint. With a separate data database it
runs in a data transaction opened first, and a task the request awaits
commits the catalog and then that transaction, so a cancelled caller
cannot stop between the two. A lock wait that fails (55P03, a deadlock
victim 40P01, or a statement_timeout's 57014) rolls the drops back and
takes the asynchronous path with the row still held. A write
transaction, a SHARE holder, an index build or a reader that starts
after the probe therefore costs the request at most the 1 s, and no data
table is left without its row. With a separate data database a data
commit that fails after the catalog commit has the drop retried on a
fresh connection for up to 60 s, so a failover, a dropped connection or
a network blip no longer orphans the data tables; only a process that
dies, or a data database unreachable for that whole window, between the
catalog commit and the last retry leaves them without a row, and then
they stay until an operator drops the relations the error log names
(docs/troubleshooting.md, "Data tables without a catalog row"). The base
table goes first because a write holds it before it writes an LSI or a
synchronous GSI: with the index tables first, a write in progress waited
about 1 s on the drop, and the drop went asynchronous. On the shared
layout a DeleteTable probing between an open's snapshot and its LOCK
does not see the open; if the LOCK is granted first, the drop times out
after 1 s, is rolled back with the removal, and DeleteTable returns
DELETING (in 80 unaided open-and-delete pairs, the delete first in half
of them, 0 left a data table without a row on either layout; 39 to 40
deletes ran synchronously and 40 to 41 asynchronously). The data
transaction lives in a guard from BEGIN on, so every path that ends it
after it may have taken ACCESS SHARE wakes the control-plane poller: the
last page (before its commit is awaited), a fetch error, a stream
dropped before its end, and an open that fails or is cancelled; the
in-catalog backup's data transaction is held in the same guard and wakes
it too. The poller stays active while a DELETING row's drop is deferred,
also after a restart: startup recovery runs the same pass and wakes the
poller when it deferred a drop. A drop is deferred when the probe finds
another session holding any lock on the table's relations, or when the
drop fails in a lock wait (55P03, 40P01 or 57014); any other failure is
logged and left to the normal window and the 60 s idle pass. Each pass
probes a held table and skips it without waiting, and drops a free one
within the same 1 s budget, so every held table is retried about once a
second however many are held, and a CreateTable that is due is activated
on the same cadence (1.1 s with four drops deferred). A drop that
DeleteTable deferred is due at once, not after
control_plane_delay_seconds. The active loop waits on the wake as well
as on its timer, so a snapshot's or an in-catalog backup's release has
the drop retried at once, and any holder's release is followed within
about a second: 108 to 166 ms in the tests, with a 10 s test-only active
poll for the wake cases. The admin guide's control_plane_delay_seconds
row and the troubleshooting page's DELETING entry state the default
(0.25 on every backend), the bound and the retry cadence, the same-name
CreateTable refusal until then, and a two-step query, over every
relation of the table, that finds any session holding a lock on them and
its lock mode. A DeleteTable of a table that is already DELETING returns
its description and changes nothing.

SQLite: one deferred read transaction covers catalog and data; its first
read pins the WAL snapshot and no lock is taken, so writers never wait
on a snapshot being opened and the WAL grows until the stream ends (a
TRUNCATE checkpoint waits out the busy timeout and reports busy; the
module doc gives the measured reply). #384's
create_backup_from_wal_snapshot holds the writer lock while it pins
because it also inserts the CREATING row; this path writes nothing.
build_table_description_on reads the definition, including #384's
restore_summary from table_restores, on that transaction. The stream
holds a backup slot (backup_server_slots, shared with the in-catalog
backup) until its read transaction ends (the short page that shows the
end, or the first error) or it is dropped, so the pool arithmetic that
keeps half the pool free counts snapshots; with no slot free the open
returns LimitExceeded at once, and a CreateBackup that waited out its 30
s for a slot says that backups or table snapshots hold them. The
pool_size doc gives the arithmetic. Items page through row-value keyset
pagination on the primary key index, so key_ordered is true for
single-range-key tables; multi-part keys fall back to rowid order. A
page is bounded by rows, not bytes, as on PostgreSQL. An in-memory
database has one connection, so the open reads every page in its
transaction and returns a stream over the copy, holding no connection
once it returns; the copy's stream keeps its backup slot until it has
yielded its last item or is dropped, so the slots bound how many decoded
copies exist, as #384's create_backup_single_connection does for the
in-catalog backup. SQLite DeleteTable of a DELETING table
(control_plane_delay_seconds of 1 or more) returns its description too.

Cassandra: open_table_snapshot returns Unsupported. Cassandra has no
read that fixes an instant across a table (a token-range scan observes
each range at a different time; LWT serializes single partitions), and
its in-catalog logical backup says so (ADR-0019). Returning that copy
under this trait's contract would make the manifest's snapshot member
false, so the backend refuses and the in-catalog BackupEngine path stays
its backup path. A direct test asserts the refusal naming the backend on
an existing table and a missing one.

MongoDB: a session started with snapshot=true reads the tables document,
the indexes documents (doc_to_table_description_in now takes an optional
session) and the base collection at one cluster time; the marker is that
time. Every read sets readConcern: snapshot explicitly. The production
catalog handle carries readConcern: local (as a URI with
readConcernLevel= would), and on it a read without the level fails: the
first read reports no snapshot time, which the open refuses naming the
read concern, and a later read in the pinned session is refused by the
server with code 72 (InvalidOptions). A server that is not a replica set
member refuses the first read with NotAReplicaSet (123), mapped to
Unsupported; each of the two refusals names its own cause.
SnapshotTooOld (239) and SnapshotUnavailable (246) map to
SnapshotExpired, pinned by a unit test that builds the command error
from a reply document; the server keeps the snapshot for at least
minSnapshotHistoryWindowInSeconds and may discard it after, and an idle
cursor times out after cursorTimeoutMillis (10 minutes, CursorNotFound
mapped to Internal). Error messages carry the server's code name and
message, not the driver's dump of the reply. A document the stream
cannot decode ends it with that error. key_ordered is false: for a
composite key _id is a netstring of the key text, which is not the
format's order for any key type.

Test hooks (extenddb_storage::snapshot::paging): a page-size knob, a
round-trip counter, and a pause at two points of the open
(AfterDefinition, BeforeItems) that the conformance check holds while it
commits writes. PostgreSQL and SQLite record each FETCH or page query;
for MongoDB, whose driver hides getMore, the test fixture records,
through command monitoring, each find the server receives on the data
database at readConcern snapshot (the stream's; the GSI backfill's find
reads at majority) and each getMore on a cursor those finds returned, so
the count is the snapshot stream's paging as the server saw it. They and
the conformance check compile only with the test-hooks feature of
extenddb-storage, which the test-hooks features of storage-postgres and
storage-sqlite and the new snapshot-test-hooks of storage-mongodb enable
(storage-mongodb's existing test-hooks, the integration binary's
coordination hooks, is unchanged). The backend crates' tests enable them
through a dev-dependency on themselves. Production builds carry neither
the hooks nor the harness. PostgreSQL also gains test-hooks entries
spawn_control_plane_poller_for_tests and
spawn_control_plane_poller_with_active_poll_for_tests (the production
poller, the second with its active retry interval as a parameter),
recover_control_plane_at_startup_for_tests,
control_plane_drop_attempts_for_tests (a per-engine count of drop
attempts, a held table the probe skipped counted),
arm_delete_table_pause_at (holds DeleteTable at BeforeDrops, between its
probe and its drops, or at BetweenCommits, between the catalog commit
and the data commit on the separate layout; arm_delete_table_pause is
the BeforeDrops form), shorten_data_drop_retry_bound_for_tests (a
process-global shorter bound for the retried data drop), and re-exports
of the data relation names and of apply_all_migrations, so
tests/table_snapshot.rs binds the crate's lists instead of copying them.
The two pauses (the open's and DeleteTable's) are one generic type,
extenddb_storage::test_pause::Pause, and snapshot_conformance::PageSize
is the one page-size guard the three backends' tests use.
tests/table_snapshot.rs fails instead of skipping without a connection
string when EXTENDDB_TEST_PG_REQUIRED is set, which integration.yml
sets.

Tests: extenddb_storage::snapshot_conformance::run is one check every
backend runs against a live database. It sets the shared page size to
50, opens a snapshot on a 700-item composite-key table with a GSI, reads
51 items and asserts the round-trip counter advanced by at least two
since just before the open (so a backend that ignored the knob, or a
MongoDB cursor without batch_size, fails here instead of passing
vacuously), then a writer deletes 100 items, inserts 100, and overwrites
all 600 remaining items twice before the reader drains. Asserts: 700
items, no duplicates, every deleted item present, no inserted item,
every version at its pre-open value, the counter advanced after the
writer committed (a stream that read every page during the open fails),
key_ordered as the backend declares it and strictly ascending keys by
KeyOrder::compare (the format's comparator) when ordered, and a second
snapshot taken after the writer stops shows the final round. Then, once
per pause point, on a fresh table: with the open held, it commits W1, an
UpdateTable adding a GSI, and W2 (in that order; on PostgreSQL with a
separate data database the UpdateTable waits for the row lock and the
open resumes after 2 s), and asserts what one instant implies: a
description with the new GSI has W1, items with W2 have the GSI in the
description, and W2 is not in the items at all, since the pause is
inside the open's read window (a backend whose pauses sat before both
reads passed the two implications), and which of W2's commit and the 2 s
timeout resumed the open, against the backend's declared
Expectations::update_table_waits_at (PostgreSQL with a separate data
database at AfterDefinition: the timeout; everywhere else: W2), so a
slow UpdateTable cannot make the W2 assertion vacuous. Before the writer
starts the snapshot's description must equal the storage layer's
describe_table with the running counts and table_throughput_mode_summary
cleared; restore_summary is compared, and each backend's suite also
compares a table given a restore record
(assert_snapshot_describes_the_table). Every drain is under a 120 s
deadline naming the stage and the items read, and a test waiting for the
hooks lock gives up when one holder has kept it 600 s, naming the wait.
READ COMMITTED or no FOR SHARE on PostgreSQL, the data cursor or the
index read outside the session on MongoDB, and the pauses moved outside
the read window on SQLite or MongoDB, fail it, as do the pauses moved
outside the read window with a 2.5 s UpdateTable (the branch assertion),
a description built from a second code path (the DescribeTable
comparison), and a stream that never ends (the drain deadline, within
120 s instead of hanging the binary). Six more key shapes (hash-only S,
N and B; S hash with S, N or B range) run at a page of 3 with keys whose
byte, collation and base64 orders differ and range numbers whose text
and numeric orders differ (negative, fractional, 1E-130, 9.9E125, and
38- and 34-digit pairs a last digit apart; the check's Expectations say
how many digits a backend stores, and MongoDB's refusal of the 38-digit
pair at the put, Decimal128's 34 digits, is asserted). Every test that
opens a snapshot or touches the hooks holds
snapshot_conformance::hooks_lock(), whose guard run and run_refusals
take as an argument, so no other test in the binary can take the pause,
reset the knob or feed the counter during a check. run_refusals covers a
missing and a non-ACTIVE table on the three backends that implement
snapshots. The suites pass on PostgreSQL 16 (both layouts), file-backed
SQLite and MongoDB 7 (replica set; the standalone refusal against a
standalone mongod), and the Cassandra test on Cassandra 4.1.

PG: tests/table_snapshot.rs (81 tests and two ignored race measurements:
consistency, marker and key order, tables with legacy-collation key
columns streaming unordered and complete on a scratch database whose
collation is checked not to be C, five held snapshots on a
ten-connection pool leaving GetItem and DescribeTable unblocked with the
sixth LimitExceeded and an open succeeding after one is dropped, a GSI
without its data table left out with its attribute, DeleteTable during a
snapshot returning DELETING, with the production poller at a 10 s active
poll the table gone within 3 s of a release during the pause after the
first drop attempt (for a dropped stream, a drained stream, a fetch
error, an open cancelled at BeforeItems and the in-catalog backup), for
a plain reader holding ACCESS SHARE that sends no wake, held 10 s past
the poller's window, the table gone within 3 s of its release, a reader
across a restart (the table gone within 3 s of its release), a drop
failing on a dependent view making at most 3 attempts in the 15 s after
the window, a restored table's restore_summary in the snapshot
description, DeleteTable with only a GSI or vector-index data table
locked taking the async path, an unparseable row ending the stream and
freeing its permit, twenty concurrent DeleteTables on a ten-connection
pool, an open completing with nine of ten connections held, an open held
while nine DeleteTables queue, DeleteTable at BeforeItems taking the
async path, a fetch error freeing the permit, a drained stream out of
its transaction before its end is polled, a ROW EXCLUSIVE and a SHARE
holder sending DeleteTable down the asynchronous path within about 1 s
with no data table left without its row, a DeleteTable cancelled during
that wait leaving the table ACTIVE with its data, write holders on the
base and three GSI tables released 0.8 s apart taking it there at about
1 s, a write holding the base and then writing the GSI table finishing
first with the table removed in the request, a claimed queue row holding
DeleteTable until it ends, a probed reader sending it asynchronous in
milliseconds, a statement timeout and a deadlock victim taking the
asynchronous path, DeleteTable of a DELETING table returning its
description and changing nothing, a deferred drop due at once, four held
tables probed with no lock wait (the first released gone within 2 s, a
CreateTable ACTIVE within 2 s), all on both pool layouts; on the
separate layout, a data commit that fails after the catalog commit (its
backend terminated between the two) retried until the drop goes through
with the request returning DELETING and no relation left, a data
database refusing connections past a shortened bound returning Internal
naming every leaked relation with the row gone, and a caller cancelled
between the two commits leaving no orphan; on the shared layout, a
snapshot open or a GSI-only reader taking its lock after DeleteTable's
probe sending it down the asynchronous path with the row and both data
tables kept, and a table dropped before the shared-layout LOCK being
TableNotFound; the SQLSTATE mapping; the two ignored race measurements,
which delay the delete or the open in alternate pairs and assert both
outcomes occurred) with a Drop guard that drops the scratch databases on
success or panic, added to the direct-storage step in integration.yml;
the full storage-postgres suite is 259 tests. SQLite: in-module tests
(consistency, key order, the in-memory copy releasing the connection,
in-memory copies holding their slot, a damaged attribute definition
streaming unordered and complete, the rowid fallback at a page of seven
with no duplicates and at least six fetches, slots shared with
CreateBackup and its refusal text, the WAL checkpoint reply while a
snapshot is held, a restored table's restore_summary compared on a
file-backed and an in-memory engine, a drained stream and a stream that
hit an error freeing their slot while still held, and an in-memory copy
freeing its slot with its last item) with the same guard for the scratch
files, created under the test binary's target directory. MongoDB:
in-module tests (consistency, an undecodable document ending the stream,
reads without the snapshot level failing with code 72 and the error
text, the code mapping, a restored table's restore fields compared, the
round-trip counter counting only the snapshot stream (a GSI backfill
leaves it unchanged), and a standalone server answering Unsupported,
gated on EXTENDDB_TEST_MONGODB_STANDALONE_URI, which the CI job sets
against a standalone mongod container it starts (and failing rather than
skipping when EXTENDDB_TEST_MONGODB_REQUIRED is set without it)) on
per-test scratch databases built without the bootstrapper, as #384's
live tests do, the catalog handle with the production option set,
dropped by a guard on success or panic, so a URI in the environment
never makes cargo test touch another instance's databases; gated on
EXTENDDB_TEST_MONGODB_URI (failing rather than skipping when
EXTENDDB_TEST_MONGODB_REQUIRED is set, which the CI job does), holding
the snapshot hooks lock, run by a new integration-mongodb.yml job that
starts a replica set through devtools/run-mongodb-tests --mongo-only and
a standalone mongod (both on 127.0.0.1 only).
tests/test_table_operations.py: DeleteTable of a DELETING table returns
its description, on PostgreSQL and SQLite (the state written into the
catalog), skipped on MongoDB and Cassandra, which never leave a table
DELETING.

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
LeeroyHannigan added a commit that referenced this pull request Oct 29, 2026
…m the decoded item

Main's restore (#384) reads each backup_items document as text, parses it
into an Item and writes the text into the target's item_data unchanged. On
this branch that text carries the control-character escape, and the key
columns and index rows are built by helpers that escape their input, so
the parsed item was escaped a second time: a restored table whose items
hold U+0000 or U+0001 got key columns and index rows in a form the read
path does not produce. The item is now decoded with json_to_item before
the key helpers see it, and the key helpers are the escaping ones from
key_text, so every column is escaped exactly once; the item_data text is
still copied as it is. The backup copy sizes each item from the decoded
form for the same reason.

The parallel Scan cross-segment check (#203) hashes the candidate key's
text and compares it with the hash of the stored pk column; it now uses
the escaping pk_to_text so both sides hash the same text for keys with
control characters.

A storage-level test restores a table whose keys, index keys, attribute
names and nested values hold U+0000 and U+0001 and compares the stored
rows of the base table and both index tables with the source's, then
reads each item back.
LeeroyHannigan added a commit to yesyayen/extenddb that referenced this pull request Oct 30, 2026
…de (ExtendDB#396)

Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs ExtendDB#384, ExtendDB#335, ExtendDB#336, ExtendDB#337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), eight review rounds and the
maintainer's reviews against the companion PRs ExtendDB#393, ExtendDB#394 and ExtendDB#395, and
a reconciliation against main abf8082 (ExtendDB#384 in its final form, the
Cassandra backend ExtendDB#338, the PostgreSQL GSI build ExtendDB#401); then split so
that the document is the overview of the series and the companions' own
documentation is the record of what their code does (see the last
paragraph). The revision log at the end lists each change; the ones that
alter behavior:

- SnapshotEngine returns the table definition together with the items
  from one fenced operation (TableSnapshot). Indexes are reported in
  whatever build state they had and only an index being deleted is
  dropped; a GSI without its data table is left out with the attribute
  definitions only it used. On PostgreSQL's shared layout the open is
  one connection and one REPEATABLE READ transaction; on the separate
  layout the catalog row is held FOR SHARE until the data snapshot pins,
  and an index that becomes ACTIVE between the definition read and the
  pin is reported CREATING. PostgreSQL bounds open snapshots at half the
  pool and refuses more with LimitExceeded, so open snapshots leave half
  the pool to requests; from PR 6 on max_concurrent_jobs (default 4)
  bounds backups and restores together under that permit, and ExtendDB#384's
  restore slots go. It streams a table whose key columns predate COLLATE
  "C" unordered rather than sort it, and on both layouts DeleteTable
  commits the catalog removal only after its drops succeed, base table
  first, under one 1 s budget, taking its asynchronous path with the row
  kept when its probe finds a reader or a drop fails in a lock wait
  (lock timeout, deadlock victim or statement timeout), so no DROP waits
  behind a reader with the row gone; on the separate layout a task the
  request awaits commits the catalog and then the data transaction, and
  a data commit that fails after the catalog commit is retried on a
  fresh connection for a bounded time before the request returns (an
  orphan survives only a process death or a data database down past that
  bound, and the troubleshooting page says how to drop it); the
  control-plane poller probes held tables and retries each about once a
  second, at once on a snapshot's or an in-catalog backup's release, a
  drop DeleteTable deferred is due at once, and DeleteTable of a
  DELETING table returns its description on PostgreSQL and SQLite. On
  sqlite-memory the snapshot copies the table on the one connection
  inside the request and keeps a backup slot until its stream ends, so
  CreateBackup keeps working there once [backup.store] is set. MongoDB
  reads without the explicit snapshot level fail (ExtendDB#395's MongoDB module
  doc gives the server's error).
- The snapshot is opened in the CreateBackup request (stricter than the
  service's two-minute window), and the backup id is minted from the
  instant the open returned, just after the pin, so the backup holds
  every write acknowledged before the request was received, and
  backup_creation_date_time, the catalog's created_at and the ARN carry
  one time and the ListBackups cursor is a function of the ARN.
- Per-job claims with fencing replace the single-instance lease the
  draft assumed: an advisory lock on a dedicated catalog connection on
  PostgreSQL (ExtendDB#384's seed and key), the row itself on MongoDB (born
  through an upsert pipeline with $literal), an in-process slot on
  SQLite, a lightweight-transaction row on Cassandra (restore targets).
  Heartbeats are a timer on the database's clock with a deadline; any
  heartbeat that does not confirm the claim aborts the job. Every job
  status write is conditioned on the generation and on the row's state,
  and an ambiguous AVAILABLE update is re-read. A job deletes a prefix,
  or a restore target, only after its FAILED write matched a row. The
  reconciler's duties are listed once, in PR 8's section, and include
  the prefixes of DELETED rows, orphan restore targets, stale spools
  and, on Cassandra, active_restores entries whose target is not a
  CREATING restore target of the backup. Every duty that reads or writes
  a backup row acts only on rows of the configured store (store_kind,
  store_root and the store id); a CREATING or DELETING row of another
  store is reported and left alone. The PostgreSQL staleness test also
  selects a NULL heartbeat. While a maintenance claim is held the
  reconciler only finishes DELETING rows, resolves rows whose owner is
  dead and runs its FAILED-target duty, which never removes a target
  left for reason target_not_empty; that one is the operator's, removed
  with extenddb backup remove-target. The PostgreSQL claim connection
  sets TCP keepalives, so the loss of the owner's host frees its lock in
  about a minute. While the configured store is unhealthy the reconciler
  acts on no row of the store; its legacy-row duty, which touches no
  store, still runs.
- DeleteBackup tombstones the row as DELETING, deletes the manifest
  first, and marks DELETED only when the prefix is empty. backup sync is
  scoped to the configured store; extenddb-store.json records the store
  id and the owning catalog's catalog_id, and a root another catalog
  owns, rows without the file, rows with two store ids or a missing
  newest manifest mark the store unhealthy (store operations refused,
  the reconciler idle on the store's rows) instead of stopping startup,
  so an unmounted volume, a recreated bucket or two catalogs on one root
  are caught; backup sync --adopt-store hands a root to another catalog,
  and sqlite-memory's startup sync takes ownership of a root a
  sqlite-memory catalog owned the same way, so one catalog owns a root
  at a time there too; instances that share a catalog must share one
  store root; it never adopts a CREATING, DELETING, DELETED or FAILED
  prefix, nor one whose listing lacks a data file the manifest lists at
  its recorded size, and marks a row whose manifest head is gone
  DELETING only with --prune-missing. --move-store resolves the old
  root's CREATING and DELETING rows, compares every data file, refuses
  to carry a tombstone over a verified manifest and offers
  --forget-backup; --forget-store deletes the rows of a root. purge,
  remove-target, --move-store, --forget-store, --prune-missing and
  --adopt-store take a catalog maintenance claim that every job checks;
  max_concurrent_jobs is per instance. DeleteBackup and Restore of
  another store's row return a ValidationException that names no root.
  Restore and Describe never read the store without a catalog row; an
  ARN with no row goes to the legacy reader. The store-id file is
  created with put_if_absent (If-None-Match on S3, O_EXCL and link on
  the filesystem). A backup is in use while a restore target registered
  from it is CREATING, as on main: DeleteBackup and a second restore
  return BackupInUseException. Purge deletes each row's prefix, never a
  whole account prefix. Every backup row is read by account and backup
  id and must match the request's account, table and ARN, and no row
  stores its key prefix. DeleteBackup's in-use check and DELETING write
  run under the backup row's lock, and a restore's registration
  re-checks AVAILABLE and in-use under the same lock, so the two
  serialize as on main (on Cassandra through main's active_restores
  lightweight transactions, now for new rows too); main's two PostgreSQL
  race tests stay for the legacy path and PR 6 adds the same two
  orderings over an off-host backup.
- Restore returns CREATING from the request (TableAlreadyExists or
  TableInUse on a name race, as ExtendDB#384 returns), spools and verifies every
  data file before writing any item, and records progress atomically
  with the data on PostgreSQL and SQLite and by idempotent
  re-application of the one unrecorded file or batch on MongoDB and
  Cassandra; on MongoDB the job writes DONE and the backfill flag in one
  transaction and the worker's ACTIVE flip is conditioned on the flag,
  not on the job's generation. A key-ordered restore runs ExtendDB#394's
  per-file key-range check and an unordered one ends with a count of the
  target, which is Cassandra's only duplicate check. A PostgreSQL
  restore is resumed, or its target deleted, only after the stale
  window; the manifest and sidecar are spooled for a resume; a legacy
  restore with a dead owner is failed, not resumed. The ACTIVE flip
  precedes the deletion of the PostgreSQL progress rows. DeleteTable on
  a restore target stays refused, as ExtendDB#384 and the service refuse it, and
  the job and the reconciler remove a failed target through the
  backend-internal remove_restore_target; the restore record is the
  table's child. Multi-part-key tables are restored (ExtendDB#384 refuses them;
  bulk_load keys rows with the backend's item-path functions, so the
  table is laid out as a natively created one). On PostgreSQL and SQLite
  bulk_load writes GSI, LSI and vector-index rows in the batch's
  transaction with the base rows, and the ACTIVE flip sets the table and
  every index ACTIVE in one catalog transaction; on Cassandra the index
  rows are created ACTIVE and the lightweight transaction flips the
  table alone. Table class is not restored, as on the service and ExtendDB#384;
  every override but VectorIndexOverride is applied, as ExtendDB#384 applies
  them. A second restore of a backup in use is refused before the permit
  and re-checked at registration. A restore stops on shutdown leaving
  its row RUNNING, download transport errors are retried for 15 minutes,
  and a takeover rewrites spool_host. Each data file is read through
  ExtenddbManifest::reader_prehashed, which applies the key-range check
  without hashing the spooled bytes again.
- The engine binds a BackupCatalog trait (PR 6) with a JobClaim for the
  per-backend claim, heartbeat and conditional status writes; new rows
  live in backup_index (a new table or collection; Cassandra reuses its
  tables with store_kind set and adds backup_index_by_status); MongoDB's
  catalog gets the versioned migration step it lacks; PR 6's migration
  backfills in-flight ExtendDB#384 restores with heartbeat_at at the epoch so
  the first reconciler pass fails them; every pre-PR-6 server stops
  before migrate. Vector tables are backed up and restored on the
  off-host path, while the legacy create keeps main's refusal (one ExtendDB#384
  engine test is rewritten in PR 9a and the wire test follows the
  configured path). source_table carries stream_specification and
  time_to_live for DescribeBackup. Both requests take the job permit
  last, after every refusal that needs no store access. The format's
  evolution rule is a version bump for any byte change, with v{N}
  fixtures beside v1. The trait gains listings for ListBackups, the
  store-id check and the root-matched commands, and backup_in_use.
  ListBackups accepts a table ARN in TableName from PR 6, as the service
  does. PR 6 adds the [backup] struct with the job keys. Row and
  restore-target methods take account-scoped keys, and the trait gains
  catalog_id and DeleteBackup's lock-holding start_backup_delete.
- The manifest self-checksum is replaced by a sidecar over the stored
  bytes, accepted in its sha256sum line form only; backup_arn is the
  backup's identity and source_table must agree with it; N key values
  are normalized before key text or key order is computed, and an
  un-normalized key_range endpoint is refused; the version gate accepts
  1 to the current version. The line cap is 32 MiB and is the floor of
  data_file_target_bytes; the format's other rules, the decoder's memory
  included, are ExtendDB#394's module docs, which section 1 points at. A reader
  refuses a manifest without stream_specification or time_to_live. The
  backup job validates every item with an item validator PR 8 adds to
  extenddb-core, which the restore binds, and fails the backup on a
  value ExtendDB#618 let in through any of the four write paths, a regression
  from main for tables that hold one; it validates the rest of the
  snapshot first and logs every refused item, and the FAILED reason
  gives the count and the first.
- Store (ExtendDB#393): section 2 points at the crate's README and module docs
  and keeps the three store facts the design's rules rest on (put and
  delete_prefix are unordered, so a caller never puts under a prefix it
  is deleting; runtime shutdown sends no abort, so the server awaits
  each job's failure path; listing has no delimiter form) and names
  put_if_absent as PR 7's addition. Section 8 points at the README's IAM
  example and config.rs's transport and proxy rules. The revision log
  (items 21 to 23) records the store facts the companion rounds settled:
  the endpoint is refused with user information, a query, a fragment or
  an IPv6 zone id, from any source; the S3 client for an http://
  endpoint is built with aws_smithy_http_client's Builder and no proxy,
  and the credential chain's clients get the same client when a
  credential endpoint setting, the IMDS endpoint setting or the
  container credentials URI is http://, so the chain's own http://
  requests do not use the proxy; without such a setting the chain uses
  the SDK's default client and NO_PROXY must list 169.254.169.254
  (fd00:ec2::254 in IPv6 mode) and 169.254.170.2; a denied
  AbortMultipartUpload in delete_prefix's sweep is logged and the
  deletion succeeds; a put dropped during the temporary's open leaves no
  temporary.
- Configuration: the default store root is per backend (beside the
  database file on file-backed SQLite, where a defaulted root that fails
  validation is a warning, not a startup error); without [backup.store],
  CreateBackup on every backend keeps the legacy in-catalog create with
  a deprecation warning until PR 14 removes it (Cassandra always, having
  no SnapshotEngine), after which it is a ValidationException naming
  [backup.store], and kind = "disabled" is the opt-out; PR 14's
  migration refuses while legacy backup rows remain unless forced. A
  store transport failure at startup degrades the store component
  instead of stopping the server. The memory formula is in item text
  bytes, and PR 8 bounds each PostgreSQL and SQLite snapshot page by
  SNAPSHOT_FETCH_BYTES (64 MiB) in the page's query, PostgreSQL's cursor
  replaced by a keyset page under the same snapshot, so a page holds at
  most the budget plus one row; the PostgreSQL page returns its key
  columns, orders the outer query and sizes its row limit from the
  previous page's average row, starting at 16; the PostgreSQL pool rule
  is max(MIN_POOL_SIZE, 2 * max_concurrent_jobs + 10), 18 at the default
  4; the spool default is per backend and spool_host is host plus spool
  path.
- Phase 1 is the PR series with acceptance criteria and GitHub numbers;
  migration numbers are reserved up front (Cassandra V006 to PR 1, V007
  to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill
  points for the crash tests are enumerated per backend and for
  DeleteBackup. The status line names the companion PRs' heads
  (aef17dd, 3f7107d, 069c4b1); the Phasing section refers to them by
  PR number. migrate-legacy re-reads and validates what it wrote and
  refuses, keeping the legacy rows, backups it cannot carry.

Also adds the missing rows for designs 12 and 13 to
docs/design/README.md; the row for 15 describes the document as the
overview of the series and names the crates whose documentation holds
the format, store and snapshot details.

The series after the four open PRs is ten PRs: the reconciler ships with
the CreateBackup job as PR 8 (a job without its reconciler leaves rows
no one resolves), and the CI changes and the documentation ship together
as PR 11, since both finalize after PR 9b. The revision log (item 24)
records the renaming; its earlier items keep the old labels.

The document is then split (revision log item 25): sections 1, 2 and 3
point at the companions' documentation (crates/core/src/backup_format
and its testdata README, ExtendDB#394; crates/backup-store/README.md and its
module docs, ExtendDB#393; crates/storage/src/snapshot.rs and the four
snapshot_engine.rs, ExtendDB#395) and keep only the rules later PRs build on;
sections 4 and 5 keep the invariants, the state machines and the
per-operation contracts; the schema, the per-backend mechanics, the
reconciler's duty list and the maintenance commands move into one
section per later PR under Phasing (PR 1, 6, 7, 8, 9a, 9b, 10, 11, 13,
14), each with its goal, the invariants it honours, the interfaces it
adds with their row keys, the transitions it owns and its acceptance
list kept whole; section 10 is a table of which document each PR
changes. The three companions gain the facts only this document stated
(the filesystem list's whole-root Vec and the empty-prefix IAM example
in ExtendDB#393's README; the fetch-size reason and the pre-ExtendDB#361 sort cost in
ExtendDB#395's module docs) and a sentence each saying this overview states the
rules their callers honour. 279,584 bytes become 187,722.

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
Co-authored-by: Lee Hannigan <lhnng@amazon.com>
LeeroyHannigan pushed a commit to yesyayen/extenddb that referenced this pull request Oct 30, 2026
…ackend (ExtendDB#395)

Adds the one new obligation the engine-owned backup path places on a
backend: open a consistent view of one table and stream its items. The
trait returns the table definition, a snapshot marker, an ordering flag,
and the item stream together, from one operation, so a backup's manifest
and data files always describe the same instant. The in-catalog backup
code keeps its own copy of this logic for now; the engine-side backup
job (next PR) is the first caller. Stacked on ExtendDB#394: SnapshotInfo is the
manifest's SnapshotDescriptor (one definition, with the three KIND_*
constants), and key_schema_is_orderable is KeyOrder::resolve, so a
stream a backend marks ordered is one the manifest parser accepts and a
table whose range key has no attribute definition streams unordered and
complete instead of paging by the hash key alone.

Trait (crates/storage/src/snapshot.rs): open_table_snapshot(account_id,
table_name) returns TableSnapshot { description: TableDescription, info:
SnapshotInfo, key_ordered: bool, items: BoxStream<Result<Item>> }. The
description is the storage-level one at the snapshot instant, not the
API response (restore_summary may be set, table_throughput_mode_summary
is not, the counts are the stats pass's); items are yielded as stored
(valid N normalized, invalid raw, TTL-expired unswept items included),
and any error means start over, SnapshotExpired being the one worth
retrying. The stream owns the backend transaction or session; dropping
it releases it. A stream that has not reached its end (including one
never polled) must be dropped inside a Tokio runtime: the sqlx backends
return its connection through the runtime and panic outside one, MongoDB
does not, and the trait doc says so. The first Err ends the stream on
every backend. StorageEngine now requires SnapshotEngine; all four
in-tree backends implement it. The trait doc names what each backend
holds while a stream is open and which errors each returns (Cassandra
answers Unsupported before it resolves the table), the idle timeouts
that end a stream as Internal, and why the trait has no default body and
StorageError stays exhaustive; PostgreSQL and SQLite bound open
snapshots per server (below) and refuse over the bound with
LimitExceeded. StorageError gains SnapshotExpired for a snapshot the
server discarded before the stream drained; the engine maps it to a
retryable 503 like Transient. snapshot.rs's module doc says that the
design overview (docs/design/15-backup-restore.md, ExtendDB#396) states the
rules the trait's callers honour and that this file's and each backend's
snapshot_engine.rs module docs are the record of what the snapshots do;
DEFAULT_FETCH_BATCH's doc says why the size is a constant (over a 1 ms
link the larger fetch would save about a second per million rows), and
the PostgreSQL module doc's pre-ExtendDB#361 paragraph carries the sort's cost
(7 MB on disk for 100k rows at work_mem 4 MB).

Indexes: the description keeps every index in whatever build state it
had and drops only one being deleted (drop_deleting_indexes, for GSIs
and vector indexes; no in-tree backend reports a DELETING index today,
and unit tests pin the rule); on PostgreSQL a GSI row whose data table
is missing is also left out, with the attribute definitions only it
used, as the in-catalog backup does (since ExtendDB#401 UpdateTable creates the
data table before committing the row, so this is a guard, not an
expected state). A restore recreates the indexes in the description and
builds them from the items, so a CREATING index with a data table comes
back complete; one left out would not come back at all.

PostgreSQL, one shared database: the open uses one connection and one
REPEATABLE READ READ ONLY transaction whose first statement reads the
tables row, so the definition and the items come from one MVCC snapshot
and no row lock is taken; a DeleteTable that drops the data table
between that snapshot and the open's LOCK TABLE makes the open
TableNotFound. Separate data database: the tables row is held FOR SHARE
on a catalog connection from the definition read until the data
transaction (REPEATABLE READ READ ONLY, ACCESS SHARE on the relation)
has pinned its snapshot with SELECT pg_current_snapshot() and the GSI
data-table probe has run; the definition is read on that barrier
connection (build_table_description_on), so an open holds one catalog
connection and the stream one data connection. UpdateTable and
DeleteTable take the row FOR UPDATE, so neither commits in the gap;
index-status workers do not, so an index may be reported in a slightly
older status than the items. The marker is pg_current_snapshot() text on
both. An engine-wide permit is held per snapshot from the open until the
stream's transaction ends, half the pool on either layout, so open
snapshots leave half of the pool their items come from to requests (with
a separate data database and the whole pool allowed, ten open snapshots
made GetItem wait out the pool timeout); restores' own half-pool slots
are separate, so snapshots and restores together can hold the whole
pool, and the field doc says so. Items stream through a server-side
cursor, FETCH 1000 per round trip, ordered by the primary key. A fetch
is bounded by rows, not bytes (up to 27.7 MB of text per row at the
default item limit); the byte budget is the backup job PR's. A table
whose text key columns do not have a collation named "C" or "POSIX"
(created before the key columns were declared with it; "default" on a C
database also counts as not bytewise, which streams unordered and
complete) is streamed unordered, with a warning: ordering it would turn
the read into a sort of the whole table before the first row. SQLSTATE
40001 (recovery conflict on a standby) and 72000 (snapshot_too_old on a
primary, PostgreSQL 16 and earlier) map to SnapshotExpired, pinned by a
test that raises each code on the live server. The stream holds ACCESS
SHARE on the data table until its last fetch, so a DROP TABLE waits for
it; DeleteTable takes the asynchronous path (row DELETING, worker drop
under a lock timeout) when another session holds ACCESS SHARE on any
relation it would drop (the base, GSI, LSI and vector-index data tables:
data::table_storage_relations, the one list drop_table_storage also
drops), probing only when its answer is used (delay < 1 s) and, on the
shared layout, on its own catalog transaction rather than a second
connection from the same pool (twenty concurrent DeleteTables on a
ten-connection shared pool timed out without that). On both layouts the
synchronous path takes every drop's lock before the catalog removal
commits. It deletes the table's pending queue rows first, waiting for
in-flight claims with no timeout, as ExtendDB#401 intends. It then drops the
base, GSI, LSI and vector-index data tables, base first, within one 1 s
budget for the group (SYNC_DROP_LOCK_TIMEOUT; before each DROP,
lock_timeout is the time left). On the shared layout that runs in the
catalog transaction behind a savepoint. With a separate data database it
runs in a data transaction opened first, and a task the request awaits
commits the catalog and then that transaction, so a cancelled caller
cannot stop between the two. A lock wait that fails (55P03, a deadlock
victim 40P01, or a statement_timeout's 57014) rolls the drops back and
takes the asynchronous path with the row still held. A write
transaction, a SHARE holder, an index build or a reader that starts
after the probe therefore costs the request at most the 1 s, and no data
table is left without its row. With a separate data database a data
commit that fails after the catalog commit has the drop retried on a
fresh connection for up to 60 s, so a failover, a dropped connection or
a network blip no longer orphans the data tables; only a process that
dies, or a data database unreachable for that whole window, between the
catalog commit and the last retry leaves them without a row, and then
they stay until an operator drops the relations the error log names
(docs/troubleshooting.md, "Data tables without a catalog row"). The base
table goes first because a write holds it before it writes an LSI or a
synchronous GSI: with the index tables first, a write in progress waited
about 1 s on the drop, and the drop went asynchronous. On the shared
layout a DeleteTable probing between an open's snapshot and its LOCK
does not see the open; if the LOCK is granted first, the drop times out
after 1 s, is rolled back with the removal, and DeleteTable returns
DELETING (in 80 unaided open-and-delete pairs, the delete first in half
of them, 0 left a data table without a row on either layout; 39 to 40
deletes ran synchronously and 40 to 41 asynchronously). The data
transaction lives in a guard from BEGIN on, so every path that ends it
after it may have taken ACCESS SHARE wakes the control-plane poller: the
last page (before its commit is awaited), a fetch error, a stream
dropped before its end, and an open that fails or is cancelled; the
in-catalog backup's data transaction is held in the same guard and wakes
it too. The poller stays active while a DELETING row's drop is deferred,
also after a restart: startup recovery runs the same pass and wakes the
poller when it deferred a drop. A drop is deferred when the probe finds
another session holding any lock on the table's relations, or when the
drop fails in a lock wait (55P03, 40P01 or 57014); any other failure is
logged and left to the normal window and the 60 s idle pass. Each pass
probes a held table and skips it without waiting, and drops a free one
within the same 1 s budget, so every held table is retried about once a
second however many are held, and a CreateTable that is due is activated
on the same cadence (1.1 s with four drops deferred). A drop that
DeleteTable deferred is due at once, not after
control_plane_delay_seconds. The active loop waits on the wake as well
as on its timer, so a snapshot's or an in-catalog backup's release has
the drop retried at once, and any holder's release is followed within
about a second: 108 to 166 ms in the tests, with a 10 s test-only active
poll for the wake cases. The admin guide's control_plane_delay_seconds
row and the troubleshooting page's DELETING entry state the default
(0.25 on every backend), the bound and the retry cadence, the same-name
CreateTable refusal until then, and a two-step query, over every
relation of the table, that finds any session holding a lock on them and
its lock mode. A DeleteTable of a table that is already DELETING returns
its description and changes nothing.

SQLite: one deferred read transaction covers catalog and data; its first
read pins the WAL snapshot and no lock is taken, so writers never wait
on a snapshot being opened and the WAL grows until the stream ends (a
TRUNCATE checkpoint waits out the busy timeout and reports busy; the
module doc gives the measured reply). ExtendDB#384's
create_backup_from_wal_snapshot holds the writer lock while it pins
because it also inserts the CREATING row; this path writes nothing.
build_table_description_on reads the definition, including ExtendDB#384's
restore_summary from table_restores, on that transaction. The stream
holds a backup slot (backup_server_slots, shared with the in-catalog
backup) until its read transaction ends (the short page that shows the
end, or the first error) or it is dropped, so the pool arithmetic that
keeps half the pool free counts snapshots; with no slot free the open
returns LimitExceeded at once, and a CreateBackup that waited out its 30
s for a slot says that backups or table snapshots hold them. The
pool_size doc gives the arithmetic. Items page through row-value keyset
pagination on the primary key index, so key_ordered is true for
single-range-key tables; multi-part keys fall back to rowid order. A
page is bounded by rows, not bytes, as on PostgreSQL. An in-memory
database has one connection, so the open reads every page in its
transaction and returns a stream over the copy, holding no connection
once it returns; the copy's stream keeps its backup slot until it has
yielded its last item or is dropped, so the slots bound how many decoded
copies exist, as ExtendDB#384's create_backup_single_connection does for the
in-catalog backup. SQLite DeleteTable of a DELETING table
(control_plane_delay_seconds of 1 or more) returns its description too.

Cassandra: open_table_snapshot returns Unsupported. Cassandra has no
read that fixes an instant across a table (a token-range scan observes
each range at a different time; LWT serializes single partitions), and
its in-catalog logical backup says so (ADR-0019). Returning that copy
under this trait's contract would make the manifest's snapshot member
false, so the backend refuses and the in-catalog BackupEngine path stays
its backup path. A direct test asserts the refusal naming the backend on
an existing table and a missing one.

MongoDB: a session started with snapshot=true reads the tables document,
the indexes documents (doc_to_table_description_in now takes an optional
session) and the base collection at one cluster time; the marker is that
time. Every read sets readConcern: snapshot explicitly. The production
catalog handle carries readConcern: local (as a URI with
readConcernLevel= would), and on it a read without the level fails: the
first read reports no snapshot time, which the open refuses naming the
read concern, and a later read in the pinned session is refused by the
server with code 72 (InvalidOptions). A server that is not a replica set
member refuses the first read with NotAReplicaSet (123), mapped to
Unsupported; each of the two refusals names its own cause.
SnapshotTooOld (239) and SnapshotUnavailable (246) map to
SnapshotExpired, pinned by a unit test that builds the command error
from a reply document; the server keeps the snapshot for at least
minSnapshotHistoryWindowInSeconds and may discard it after, and an idle
cursor times out after cursorTimeoutMillis (10 minutes, CursorNotFound
mapped to Internal). Error messages carry the server's code name and
message, not the driver's dump of the reply. A document the stream
cannot decode ends it with that error. key_ordered is false: for a
composite key _id is a netstring of the key text, which is not the
format's order for any key type.

Test hooks (extenddb_storage::snapshot::paging): a page-size knob, a
round-trip counter, and a pause at two points of the open
(AfterDefinition, BeforeItems) that the conformance check holds while it
commits writes. PostgreSQL and SQLite record each FETCH or page query;
for MongoDB, whose driver hides getMore, the test fixture records,
through command monitoring, each find the server receives on the data
database at readConcern snapshot (the stream's; the GSI backfill's find
reads at majority) and each getMore on a cursor those finds returned, so
the count is the snapshot stream's paging as the server saw it. They and
the conformance check compile only with the test-hooks feature of
extenddb-storage, which the test-hooks features of storage-postgres and
storage-sqlite and the new snapshot-test-hooks of storage-mongodb enable
(storage-mongodb's existing test-hooks, the integration binary's
coordination hooks, is unchanged). The backend crates' tests enable them
through a dev-dependency on themselves. Production builds carry neither
the hooks nor the harness. PostgreSQL also gains test-hooks entries
spawn_control_plane_poller_for_tests and
spawn_control_plane_poller_with_active_poll_for_tests (the production
poller, the second with its active retry interval as a parameter),
recover_control_plane_at_startup_for_tests,
control_plane_drop_attempts_for_tests (a per-engine count of drop
attempts, a held table the probe skipped counted),
arm_delete_table_pause_at (holds DeleteTable at BeforeDrops, between its
probe and its drops, or at BetweenCommits, between the catalog commit
and the data commit on the separate layout; arm_delete_table_pause is
the BeforeDrops form), shorten_data_drop_retry_bound_for_tests (a
process-global shorter bound for the retried data drop), and re-exports
of the data relation names and of apply_all_migrations, so
tests/table_snapshot.rs binds the crate's lists instead of copying them.
The two pauses (the open's and DeleteTable's) are one generic type,
extenddb_storage::test_pause::Pause, and snapshot_conformance::PageSize
is the one page-size guard the three backends' tests use.
tests/table_snapshot.rs fails instead of skipping without a connection
string when EXTENDDB_TEST_PG_REQUIRED is set, which integration.yml
sets.

Tests: extenddb_storage::snapshot_conformance::run is one check every
backend runs against a live database. It sets the shared page size to
50, opens a snapshot on a 700-item composite-key table with a GSI, reads
51 items and asserts the round-trip counter advanced by at least two
since just before the open (so a backend that ignored the knob, or a
MongoDB cursor without batch_size, fails here instead of passing
vacuously), then a writer deletes 100 items, inserts 100, and overwrites
all 600 remaining items twice before the reader drains. Asserts: 700
items, no duplicates, every deleted item present, no inserted item,
every version at its pre-open value, the counter advanced after the
writer committed (a stream that read every page during the open fails),
key_ordered as the backend declares it and strictly ascending keys by
KeyOrder::compare (the format's comparator) when ordered, and a second
snapshot taken after the writer stops shows the final round. Then, once
per pause point, on a fresh table: with the open held, it commits W1, an
UpdateTable adding a GSI, and W2 (in that order; on PostgreSQL with a
separate data database the UpdateTable waits for the row lock and the
open resumes after 2 s), and asserts what one instant implies: a
description with the new GSI has W1, items with W2 have the GSI in the
description, and W2 is not in the items at all, since the pause is
inside the open's read window (a backend whose pauses sat before both
reads passed the two implications), and which of W2's commit and the 2 s
timeout resumed the open, against the backend's declared
Expectations::update_table_waits_at (PostgreSQL with a separate data
database at AfterDefinition: the timeout; everywhere else: W2), so a
slow UpdateTable cannot make the W2 assertion vacuous. Before the writer
starts the snapshot's description must equal the storage layer's
describe_table with the running counts and table_throughput_mode_summary
cleared; restore_summary is compared, and each backend's suite also
compares a table given a restore record
(assert_snapshot_describes_the_table). Every drain is under a 120 s
deadline naming the stage and the items read, and a test waiting for the
hooks lock gives up when one holder has kept it 600 s, naming the wait.
READ COMMITTED or no FOR SHARE on PostgreSQL, the data cursor or the
index read outside the session on MongoDB, and the pauses moved outside
the read window on SQLite or MongoDB, fail it, as do the pauses moved
outside the read window with a 2.5 s UpdateTable (the branch assertion),
a description built from a second code path (the DescribeTable
comparison), and a stream that never ends (the drain deadline, within
120 s instead of hanging the binary). Six more key shapes (hash-only S,
N and B; S hash with S, N or B range) run at a page of 3 with keys whose
byte, collation and base64 orders differ and range numbers whose text
and numeric orders differ (negative, fractional, 1E-130, 9.9E125, and
38- and 34-digit pairs a last digit apart; the check's Expectations say
how many digits a backend stores, and MongoDB's refusal of the 38-digit
pair at the put, Decimal128's 34 digits, is asserted). Every test that
opens a snapshot or touches the hooks holds
snapshot_conformance::hooks_lock(), whose guard run and run_refusals
take as an argument, so no other test in the binary can take the pause,
reset the knob or feed the counter during a check. run_refusals covers a
missing and a non-ACTIVE table on the three backends that implement
snapshots. The suites pass on PostgreSQL 16 (both layouts), file-backed
SQLite and MongoDB 7 (replica set; the standalone refusal against a
standalone mongod), and the Cassandra test on Cassandra 4.1.

PG: tests/table_snapshot.rs (81 tests and two ignored race measurements:
consistency, marker and key order, tables with legacy-collation key
columns streaming unordered and complete on a scratch database whose
collation is checked not to be C, five held snapshots on a
ten-connection pool leaving GetItem and DescribeTable unblocked with the
sixth LimitExceeded and an open succeeding after one is dropped, a GSI
without its data table left out with its attribute, DeleteTable during a
snapshot returning DELETING, with the production poller at a 10 s active
poll the table gone within 3 s of a release during the pause after the
first drop attempt (for a dropped stream, a drained stream, a fetch
error, an open cancelled at BeforeItems and the in-catalog backup), for
a plain reader holding ACCESS SHARE that sends no wake, held 10 s past
the poller's window, the table gone within 3 s of its release, a reader
across a restart (the table gone within 3 s of its release), a drop
failing on a dependent view making at most 3 attempts in the 15 s after
the window, a restored table's restore_summary in the snapshot
description, DeleteTable with only a GSI or vector-index data table
locked taking the async path, an unparseable row ending the stream and
freeing its permit, twenty concurrent DeleteTables on a ten-connection
pool, an open completing with nine of ten connections held, an open held
while nine DeleteTables queue, DeleteTable at BeforeItems taking the
async path, a fetch error freeing the permit, a drained stream out of
its transaction before its end is polled, a ROW EXCLUSIVE and a SHARE
holder sending DeleteTable down the asynchronous path within about 1 s
with no data table left without its row, a DeleteTable cancelled during
that wait leaving the table ACTIVE with its data, write holders on the
base and three GSI tables released 0.8 s apart taking it there at about
1 s, a write holding the base and then writing the GSI table finishing
first with the table removed in the request, a claimed queue row holding
DeleteTable until it ends, a probed reader sending it asynchronous in
milliseconds, a statement timeout and a deadlock victim taking the
asynchronous path, DeleteTable of a DELETING table returning its
description and changing nothing, a deferred drop due at once, four held
tables probed with no lock wait (the first released gone within 2 s, a
CreateTable ACTIVE within 2 s), all on both pool layouts; on the
separate layout, a data commit that fails after the catalog commit (its
backend terminated between the two) retried until the drop goes through
with the request returning DELETING and no relation left, a data
database refusing connections past a shortened bound returning Internal
naming every leaked relation with the row gone, and a caller cancelled
between the two commits leaving no orphan; on the shared layout, a
snapshot open or a GSI-only reader taking its lock after DeleteTable's
probe sending it down the asynchronous path with the row and both data
tables kept, and a table dropped before the shared-layout LOCK being
TableNotFound; the SQLSTATE mapping; the two ignored race measurements,
which delay the delete or the open in alternate pairs and assert both
outcomes occurred) with a Drop guard that drops the scratch databases on
success or panic, added to the direct-storage step in integration.yml;
the full storage-postgres suite is 259 tests. SQLite: in-module tests
(consistency, key order, the in-memory copy releasing the connection,
in-memory copies holding their slot, a damaged attribute definition
streaming unordered and complete, the rowid fallback at a page of seven
with no duplicates and at least six fetches, slots shared with
CreateBackup and its refusal text, the WAL checkpoint reply while a
snapshot is held, a restored table's restore_summary compared on a
file-backed and an in-memory engine, a drained stream and a stream that
hit an error freeing their slot while still held, and an in-memory copy
freeing its slot with its last item) with the same guard for the scratch
files, created under the test binary's target directory. MongoDB:
in-module tests (consistency, an undecodable document ending the stream,
reads without the snapshot level failing with code 72 and the error
text, the code mapping, a restored table's restore fields compared, the
round-trip counter counting only the snapshot stream (a GSI backfill
leaves it unchanged), and a standalone server answering Unsupported,
gated on EXTENDDB_TEST_MONGODB_STANDALONE_URI, which the CI job sets
against a standalone mongod container it starts (and failing rather than
skipping when EXTENDDB_TEST_MONGODB_REQUIRED is set without it)) on
per-test scratch databases built without the bootstrapper, as ExtendDB#384's
live tests do, the catalog handle with the production option set,
dropped by a guard on success or panic, so a URI in the environment
never makes cargo test touch another instance's databases; gated on
EXTENDDB_TEST_MONGODB_URI (failing rather than skipping when
EXTENDDB_TEST_MONGODB_REQUIRED is set, which the CI job does), holding
the snapshot hooks lock, run by a new integration-mongodb.yml job that
starts a replica set through devtools/run-mongodb-tests --mongo-only and
a standalone mongod (both on 127.0.0.1 only).
tests/test_table_operations.py: DeleteTable of a DELETING table returns
its description, on PostgreSQL and SQLite (the state written into the
catalog), skipped on MongoDB and Cassandra, which never leave a table
DELETING.

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants