Repository navigation
feat(backup): RestoreTableFromBackup job and cross-backend matrix [backup/restore 9] - #740
Conversation
23c1b71 to
38c7b00
Compare
4c35319 to
d27c9bb
Compare
09c881f to
ec4eb8d
Compare
d27c9bb to
a3e6c12
Compare
8d44bd8 to
22bfb5b
Compare
210165a to
19e4459
Compare
22bfb5b to
6bdd117
Compare
There was a problem hiding this comment.
I built the server at this head for SQLite, PostgreSQL, MongoDB and Cassandra, and at the base for SQLite. Each ran in a container with a filesystem store. I sent the same RestoreTableFromBackup requests, and the calls around a running restore, to each backend and to Amazon DynamoDB in us-east-1. I sent each error case twice. I killed, froze and stopped servers (SIGKILL, SIGSTOP, SIGTERM) at the pause points in this PR and at two I added. The two are right after the registration's commit and, on MongoDB, between the claim check and the insert_many. For the takeover cases a second server ran on the same catalog. I ran the engine's backup:: module (236 tests), the SQLite and PostgreSQL restore job tests (11 each), and the MongoDB and Cassandra restore cells. I ran 51 mutants of the code those tests guard, and for 45 a test failed on an assertion about the change. Clippy 1.97.0 with the test-hook features, which CI does not lint, passes.
The restore response and DescribeTable matched the service on RestoreSummary, on what a restore carries and drops, and on the tables restored with accepted overrides. They also matched on the errors for a missing backup, another account's backup, an ACTIVE target name and data-plane calls on a restoring table. On SQLite, PostgreSQL and MongoDB a restore of a backup in use answers BackupInUseException before any override error, as on the service. The fenced DONE write, the shutdown path, and remove-target on a target_not_empty record behaved as described. So did SQLite's takeover at restart and the job's first beat after a 75 s registration. Main has no store-backed restore, and the base answers ValidationException once a restore reads a store-backed backup, so each store-backed answer here is new. Two Blocking items, twelve Major and nine Minor ones are inline, and the Minor ones are marked. Smaller items:
- From #671:
backup_in_use_messagegivesBackup is being used to restore table <name>: <arn>, where Amazon DynamoDB givesBackup is being used to restore another table: <arn>(eight requests). This PR's registrations raise that text on all four backends. a_feeder_whose_timer_stopped_ends_the_stream_with_the_claim_lost_reasonfailed once in 40 runs of thebackup::module on a loaded host, at the 30 s deadline inwait_for_items_seen. It passed in the other 39 runs and in three reruns. Is 30 s enough on a loaded CI machine?
Once the Blocking and Major items are fixed, I will approve.
| /// the CREATING target with its provenance and its RUNNING `kind = | ||
| /// manifest` record under a fresh generation, the store and the | ||
| /// claimant's spool, with the server's clock on its heartbeat. The | ||
| /// transaction commits right after the insert, before the target's |
There was a problem hiding this comment.
Blocking: this commits the RUNNING record before the target's data collection, its unique key index and its GSI and LSI metadata exist. I killed the server right after this commit and restarted it, and the reconciler resumed the record into a collection indexed on _id only. The table went ACTIVE with no GSIs or LSIs, and Query on by-g answered ValidationException: The table does not have the specified index: by-g where the source returned 15 items. Please make the record resumable only after the last index metadata is written. Also have the resume check the target's indexes against the manifest.
| reapplying: bool, | ||
| batch: &mut Vec<Document>, | ||
| ) -> Result<u64, StorageError> { | ||
| self.confirm_restore_claim(target, generation).await?; |
There was a problem hiding this comment.
Blocking: this read checks the claim, and the insert_many that follows carries no condition, so a frozen writer can write into a finished restore. With two servers on one catalog, I froze the owner with SIGSTOP after this call, and the other server took the record over and finished. I removed 10 items through it and resumed the owner, and 20 s later all 10 were readable again with ConsistentRead and missing from by-g. Please put the fence in the write here, as the PostgreSQL and SQLite loads do. The Cassandra batch at crates/storage-cassandra/src/restore_engine.rs line 182 needs the fence too.
There was a problem hiding this comment.
On MongoDB I froze the owner inside its batch transaction, the other server finished, and the 10 items I deleted stayed deleted. On Cassandra the batch writes still follow a separate check, and the module doc says a stalled writer can write one batch after a takeover. Please fence those writes too, for example by writing each batch at a timestamp read before the check, so that later writes and deletes win.
There was a problem hiding this comment.
Fenced. The Cassandra batch now writes each item's base row and its GSI and LSI rows in one logged batch USING TIMESTAMP a reading of the coordinator's clock taken before the batch's claim check (put_restored_item_at, replacing the put path's lightweight transaction for the store-backed load; a CREATING target has no other writer). A writer that stalls between the check and its writes and lands them after a takeover lands them below every write and delete the successor made, so Cassandra keeps the successor's cells and tombstones and a key deleted through the ACTIVE table stays deleted; the base row's version is the timestamp, so a later ordinary write's version is above it. test_a_batch_landed_after_a_takeover_loses_to_the_successors_writes_and_deletes (direct_integration, test-hooks) pauses the owner after its check, takes the record over, has the successor load the batch, flip ACTIVE and delete two items, then releases the owner: the two items stay deleted, their GSI and LSI rows stay gone, the survivors carry the successor's versions, and the owner's progress write reports the lost claim. With the timestamp read after the pause instead (when the writes land) the cell fails with 4 items in place of 2; it passes after the revert. The module doc states the rule and that it needs the hosts' clocks to agree, as the version scheme already does.
| ) | ||
| .await?; | ||
| } | ||
| restore_pause_point(RESTORE_MONGODB_AFTER_INSERT_MANY).await; |
There was a problem hiding this comment.
Progress is recorded once per data file, so a one-file restore records none until its load ends, and data files are 64 MiB by default. A SIGTERM in the middle of a 5,000-item one-file restore left the record RUNNING, and 39 s after the restart it was FAILED target_not_empty. The table then stays CREATING, with DeleteTable ResourceInUseException and DeleteBackup BackupInUseException, until an operator runs remove-target. But the differences row says a SIGTERM resumes to ACTIVE. Please mark the record before any insert_many and resume a marked record with reapplying (upserts on Cassandra). Keep target_not_empty for a count above the total.
There was a problem hiding this comment.
On MongoDB a SIGKILL after the first batch and a SIGTERM inside it both resumed to ACTIVE with every item and the full GSI. On Cassandra a crash before the first progress record still ends target_not_empty, as the differences row now says. Please resume that case with upserts, as my comment asked.
There was a problem hiding this comment.
Done. The reconciler's no-progress rule now compares the target's count with the backup's total (the backup row's item_count, exceeds_backup_total): above it is target_not_empty (the dump-restored catalog); at or below it the record is resumed and the first file re-applied with reapplying set, which on Cassandra is the same idempotent upserts. The engine cell a_half_applied_first_file_with_no_progress_is_resumed_with_upserts resumes one landed item of two to DONE with 2; the storage cell test_a_first_batch_applied_without_progress_resumes_to_the_exact_count lands 3 of 7 with no progress (two lost progress writes) and resumes to 7 with both index-row counts exact. The differences row, 5.3 step 7, the PR 9a load paragraph, the admin guide and the kill-point script's first-reach assertion (now ACTIVE with the exact count) are updated; the Cassandra point's first reach runs in the nightly job.
| Ok(count) => count, | ||
| Err(e) => { | ||
| tracing::error!( | ||
| "{what} was taken over but its target could not be counted; the next cycle \ |
There was a problem hiding this comment.
When the target's tables do not exist, this returns after claim_restore_dead refreshed the heartbeat, so the record is retaken every stale window and never fails. I killed SQLite and PostgreSQL servers after the catalog commit, before the data DDL, and each record was retaken every 45 s for three minutes. The table stayed CREATING, and DeleteTable, DeleteBackup and a restore into the name answered ResourceInUseException, BackupInUseException and TableInUseException. Please fail the record and call remove_restore_target when the target's tables are missing, and bound the takeovers of one record.
There was a problem hiding this comment.
A target without data tables now fails in one takeover on SQLite and PostgreSQL. Takeovers stay unbounded otherwise: when a resume fails on a read error or stops with LeftRunning, the reconciler claims it again every stale window. Please count the takeovers of a record and fail it after a small number.
There was a problem hiding this comment.
Bounded. ReconcilerState counts this instance's takeovers of each record by table id (RESTORE_TAKEOVERS = 5); the takeover after the fifth fails the record owner dead: taken over 5 times by one instance without the restore ending and removes its target as for OWNER_DEAD. The count is in process rather than a column: a takeovers column would reach every backend's record and PostgreSQL's catalog table_restores, which this PR's migration does not touch, so the count is per instance and a restart starts it over (stated in the module doc, the design and the differences row). the_takeover_after_the_fifth_fails_a_record_that_never_ends: the count fails on every attempt, five cycles take the record over and leave it RUNNING, the sixth fails and removes it. With the bound check disabled the sixth cycle resumes again and the cell fails; it passes after the revert.
| "{what} has no live owner and reads a store other than the configured one; the \ | ||
| record is FAILED and its target removed once the record is stale: {OTHER_STORE}" | ||
| ); | ||
| return match storage.fail_restore_job(target, OTHER_STORE).await { |
There was a problem hiding this comment.
fail_restore_job matches any RUNNING record, and an instance without [backup.store] takes this arm for every dead store-backed record it lists. So this write can fail a record another instance took over after the listing, and on PostgreSQL I queued such a takeover ahead of it. The record ended FAILED under the new generation, the new owner's DONE write for its 50 items matched no record, and the target was removed. Please condition this write and those at lines 689 and 719 on the listed owner_generation and a stale heartbeat. Also leave other stores' records alone.
| let index = | ||
| u32::try_from(index).map_err(|_| Stop::Fail("too many data files".to_owned()))?; | ||
| if let Dest::Spool(spool) = dest | ||
| && spool.is_verified(index, &entry.sha256) |
There was a problem hiding this comment.
Minor: a spooled file with a .verified marker is skipped here and later read as prehashed (load.rs line 118). So the restore loads a file changed in the spool between two runs without checking its digest again. Please hash spooled files at load, as the in-place path does.
| dest: &Dest<'_>, | ||
| ) -> Result<u64, Stop> { | ||
| // The retry bound's clock: one for the job, started here. | ||
| let started = Instant::now(); |
There was a problem hiding this comment.
Minor: download_all fetches every data file before the progress skip in restore.rs (line 674). So a resume on a host without the spool entry also downloads the files its progress records as applied. Please download only the files the progress leaves to load.
| }; | ||
| println!( | ||
| "removed the restore target {table_name} (account {account_id}, table id {}) and its \ | ||
| record: its {} restore from {} was {state}", |
There was a problem hiding this comment.
Minor: this prints its manifest restore from ... was FAILED (target_not_empty), the internal record kind, where the docs call these store-backed and in-catalog restores. Please print the documented terms.
| }) | ||
| } | ||
|
|
||
| fn claim_restore<'a>( |
There was a problem hiding this comment.
Minor: on Cassandra the engine's in-use pre-check, backup_in_use at line 1571, reads only the legacy backups row, so it misses the restores this function registers. While one restore of a backup ran, a second with a GlobalSecondaryIndexOverride naming an unknown index answered ValidationException: One or more parameter values were invalid: global secondary index nope is not in the backup on Cassandra. SQLite, PostgreSQL and MongoDB answered BackupInUseException, as Amazon DynamoDB did (Backup is being used to restore another table: <arn>) to six such requests. Please have backup_in_use read the store-backed registration as well, and add a Cassandra cell to the in-use precedence test.
There was a problem hiding this comment.
With a restore of the backup running, Cassandra answers BackupInUseException to the unknown GSI override, the throughput override and a plain restore, twice each. No test covers this on Cassandra yet. Please add the cell.
There was a problem hiding this comment.
Added test_a_running_store_backed_restore_keeps_its_backup_in_use_until_active to the Cassandra direct_integration suite: with a store-backed restore RUNNING, backup_in_use names the target and a second claim_restore is BackupInUse with the service's text, twice; with the backup's active_restores entry deleted (the registration's window before it lands) the target rows alone keep the backup in use; after finish_restore it is free. The order of the engine's checks (the in-use rule before the override validation) is pinned by the engine's cells; this is the Cassandra input to it.
| // The restored table's size is the source table's as the manifest | ||
| // records it (the statistics pass rewrites it once ACTIVE), not the | ||
| // compressed size of the backup's files. | ||
| let table_size_bytes = u64::try_from(manifest.source_table.table_size_bytes).unwrap_or(0); |
There was a problem hiding this comment.
Minor: this writes the manifest's item total and the source's size at ACTIVE, so a restored 20-item table showed ItemCount 20 on every backend. TableSizeBytes was 0 on SQLite and MongoDB, 49152 on PostgreSQL and 1385 on Cassandra, and 1385 on the in-catalog path of the same SQLite build. Amazon DynamoDB showed ItemCount 0 and TableSizeBytes 0 on all 15 tables I restored there, one of them 10 minutes after it went ACTIVE. Please write 0 and 0 here. Otherwise, add the service's 0 and 0 to the DynamoDB column of the "What a restore carries across" row.
1c7cc29 to
57cfe20
Compare
0773f03 to
0763a19
Compare
LeeroyHannigan
left a comment
There was a problem hiding this comment.
At 0763a19, the handle and the restore decoder check the claim through ClaimCheck, which calls in_flight_confirms with self.parks. When I replace self.parks with true, the two clause cells from #700 fail on parks = false. Without the clause, they fail on parks = true.
I stopped a test process for 1.5 s between two beats while a thread polled a ClaimCheck like the decoder's. No check passed after the resume in three runs. With true for self.parks, checks passed for 6.2 ms.
restore_progress is 8 in the registry, the SQL file, the MongoDB counts and the upgrade manual. Each backend has one arm for 7 and one for 8. CI shows 43 of 43 and ran the MongoDB, Cassandra and PostgreSQL migration tests and restore_job.rs against servers. The engine's 273 backup tests pass on 1.88.0. Rustfmt and clippy are clean.
The design doc still gives restore_progress id 7 in 4(i)'s first parenthetical (docs/design/15-backup-restore.md:109) and at line 476. The MongoDB test every_body_is_code_and_three_four_and_seven_do_not_apply names 7, and its list holds 3, 4 and 8. Neither is a condition for approval.
With a configured `[backup.store]`, RestoreTableFromBackup of a store-backed backup is the restore job of the design's 5.3 on PostgreSQL, SQLite, MongoDB and Cassandra: the request checks the `backup_index` row and the ARN, requires a healthy store, takes a job permit, resolves the key schema, and spawns a detached task that registers the CREATING target and its RUNNING restore record under the backend's own owner generation (`claim_restore`, atomically with the backup row's lock the in-use and delete checks take), answers CREATING with a RestoreSummary through a one-shot channel, and then runs the job: the manifest and sidecar verified (each read bounded, 4 KiB and 16 MiB), every data file downloaded and its digests checked before any is loaded (spooled under `spool_path` for an S3 store, the spooled manifest and sidecar written `.partial`, synced and renamed like the files; read twice in place for a filesystem store), each file applied by `bulk_load` with its secondary- and vector-index rows and its progress recorded in the same write, the target counted against the manifest's item total, and the ACTIVE flip. A backup restores into the backend that took it (another backend once a later release's `backup sync --adopt-store` has indexed it); a vector backup restores into PostgreSQL or SQLite and is refused before any table into MongoDB or Cassandra; a backup of a multi-part-key table restores with its item set; the restored table's size is the source table's. Progress is recorded inside `bulk_load`, in the write that applied the items: a `restore_progress` row in the file's data transaction on PostgreSQL (data database, migration 7; a plain INSERT for a file applied from its start, an upsert for one resumed past it), a `tables`-row update per batch on SQLite and Cassandra, a `restore_job.file_index` update per file on MongoDB. The memory bounds are in bytes of text: the load's chunk closes at 32 MiB of data-file line text, the SQLite and Cassandra batch at 32 MiB of serialized item text (10,000 items when smaller), PostgreSQL's at 4 MiB per INSERT. A dead owner's restore is resumed, not discarded: the reconciler of any instance with the configured store takes the record over once its heartbeat is stale (`claim_restore_dead`, on SQLite at once after a restart by the record's slot, on PostgreSQL once the owner's claim session has ended), stamping a fresh heartbeat in the same write so a second taker finds the record live, and resumes from the recorded progress, re-downloading to its own spool when the dead owner's `spool_host` differs; the resumed job's first confirmed beat is the takeover's return, and a fresh job's is the one beat it sends right after `claim_restore` returns and before the reply (on PostgreSQL `now()` is the transaction's start, so a registration stalled on the `backup_index` lock or the DDL would otherwise commit a stale heartbeat; a first beat that finds the claim lost fails the record and answers InternalServerError). Once the server's shutdown has begun the reconciler takes nothing over and writes no FAILED, since a resume spawned on the way out would stop at once and leave the record a second stale window; once its own worker set is stopping (a lost serving registration) it spawns no resume either, a resume being a job that the workers' stop would not stop. A resume with no progress into a non-empty target is FAILED `target_not_empty` and left for `extenddb backup remove-target --account <id> <table>`, which ships here (pulled forward from PR 10), under a maintenance claim as well, where the reconciler had failed such a record `OWNER_DEAD` and removed its target: on MongoDB and Cassandra, which record progress after the write, an ordinary crash before the first progress record (the whole first data file on MongoDB, the first 10,000 items or 32 MiB on Cassandra) ends in that state, and nothing else removes a target DeleteTable refuses while the backup it holds in use refuses DeleteBackup and a second restore. The command takes the maintenance claim, removes a FAILED record's target whatever the reason, a RUNNING record's dead target (dead by the backend's `list_dead_restore_jobs` on the database's clock, never by the command host's) or a legacy record's target with its record, and refuses a live RUNNING restore, a DONE restore and a table no record names; it opens the catalog without creating the default root or an identity file; on PostgreSQL, MongoDB and Cassandra its serving registration is named `extenddb backup remove-target` and released when it exits, and its refusals of a catalog under migration or behind the binary exit 6 and 3, as `serve`'s do. `backup purge` on SQLite runs its own reconciler cycle as the claim's holder (`ReconcilerState::for_maintenance_holder`), so a dead store-backed restore it meets is failed and its target removed, never resumed inside the command. The feeder checks the claim at every chunk boundary; MongoDB and Cassandra read the record before each write batch. A duplicate key is caught by the base table's plain INSERT on PostgreSQL and SQLite, by the unique `_id` on MongoDB (suppressed only on the one unrecorded file a resume re-applies, `BulkLoadFile.reapplying`), and by the final count on Cassandra, whose upserts collapse it; a key-ordered file that leaves its `key_range` fails the file. An `N` sort key past Decimal128 fails a MongoDB restore with the write path's text. MongoDB's `bulk_load` holds the GSI backfill test gate (a `test-hooks` build only) before the first insert of the restore's first file, as the in-catalog copy does before its `$out`, so the integration test that no index completes before the base load runs on the store path. MongoDB's snapshot open, which a store-backed CreateBackup takes, waits up to 2 s for the majority-committed snapshot to show a table a `local` read shows ACTIVE, so a CreateBackup sent as soon as DescribeTable says ACTIVE is not refused as a missing table. A `finish_restore` error is an unknown outcome re-read through the claim (DONE under the job's generation is the restore complete, RUNNING one more write, anything else the DONE write that matched no record); PostgreSQL's post-commit progress delete is logged and swept, never the restore's failure. An unknown outcome of Cassandra's progress lightweight transaction is re-read at LOCAL_SERIAL (applied, one retry, or the lost claim; a second unknown outcome fails the restore), and a Cassandra registration whose record a reconciler took over during its DDL removes nothing and answers the lost claim. PostgreSQL's `remove_restore_target` drops a vector index's `_ddb_vec_` relation with the data table. The lost-claim stop line names the heartbeat timer's reason. A resumed Cassandra restore re-applies at most one batch through the put path, which since #424 rewrites an unchanged index row by its INSERT alone (#742) and stamps every index row with its base row's version; the re-apply test checks each GSI and LSI row's write time against its base row's version after the load and after each re-apply. Two concurrent registrations of one backup leave one target on Cassandra as on MongoDB (the registration LWT's `active_restores = null` is the fence). The download retry bound (15 min) counts from the job's first attempt. `.verified` markers hold the file's digest; a stale spool entry is removed before use; a job whose record became another's leaves its entry to the sweep, which lists the spool before the records so a restore registered between the two reads keeps its entry. A restore stopped by shutdown leaves its row RUNNING with its spool and progress and writes no status; a lost claim or a DONE write that matched no record ends the job with no table access. Metrics: `RestoreJobs`, `RestoreJobDuration`, `RestoreBytesDownloaded`, `BackupStoreErrors{get}` on every `get` the store failed. The feature chain `extenddb-app/backup-test-hooks` now pulls `extenddb-storage/test-hooks` so the backends' restore pause points compile into a hooks build. The cross-backend matrix is a committed `devtools/backup-restore-matrix` and a nightly `.github/workflows/matrix-nightly.yml`: each cell copies a source's backup prefix under a root the target owns and seeds the target's `backup_index` row as `backup sync --adopt-store` will (PR 10), so a cell proves the restore and not the adoption. Its twelve plain, four duplicate-key and eight vector cells passed on the final binaries; the nightly installs `postgresql-client` for the host `psql` the script's PostgreSQL seed and teardown call and checks it and the two drivers before the cells, and has had no run yet (`workflow_dispatch` needs the file on the default branch). The restore kill points ran on every backend they apply to, each ACTIVE outcome asserting the base table's and the GSI's counts, the two first-reach points ending in `target_not_empty` and then `remove-target`. Harness and CI: the 24 restore cells of `tests/test_backup_restore_fidelity.py` run on a server with a store, `tests/test_auth_permissions.py`'s restore cell runs, `tests/rust`'s backup suite restores the store's backups, `tests/test_backup_job.py` gains the tamper, SIGTERM, store outage, duplicate-key, key-range and multi-part-key cells on both the PostgreSQL and SQLite deployments and the `remove-target` cell on SQLite; `tests/test_mongodb_backup_restore.py` gains the vector-manifest refusal and the Decimal128 rules, `devtools/run-mongodb-tests` serves a filesystem store (or none, with `--backup-store none`, for the legacy LSI fallback cell) and starts from an empty root, and the PostgreSQL CI job starts moto for the outage cell; the nightly's cache step saves only on main, and its actions are pinned to the commits main pins. The replaced-file engine test runs at a 1 s heartbeat (its hashing pass shares the test's one runtime thread with the timer, and under load outlasted two 50 ms periods), `Progress::from_records` has a cell for a record and a progress row naming one file, the in-memory SQLite cell restores its store-backed backup, the SQLite job suite gains the purge cell above and the store-config suite a `remove-target` cell that leaves no default root, and the PostgreSQL exit-code cell covers `remove-target`. The MongoDB `--ignored live_` cells pass with `--test-threads=1`; in parallel three legacy copy cells can time out behind the backfill worker. Docs: the differences, limits, usage and upgrade documents lose the "restore is a later release's" refusal, the admin guide gains the spool's contents, the restore's measured memory with the shape and input order it was measured on, the two-GSI and the unordered-backup figures beside it (`docs/design/15-backup-restore-bench-pr9.md`; a table with indexes costs SQLite's page cache above the no-index figure, an unordered backup 1.5 to 1.8 times the key-ordered figure, and the final COUNT(*) a page cache bounded by `cache_size`, the same at 1M and 3M rows) and the `remove-target` command, the differences document labels its restore timings as the in-catalog restore's beside the store-backed ones, the test guide and CONTRIBUTING name the job suite and the matrix, and design revision item 30 records PR 9 as implemented. The admin guide's `stale_job_after_secs` row and purge paragraph and the upgrade manual's SQLite startup sentence say a dead store-backed restore is resumed by a server and failed by a purge; the upgrade manual says migration 7 is declared breaking on PostgreSQL and does not apply on SQLite, MongoDB and Cassandra; design 4(e) and the reconciler's module doc say what a lost serving registration does: the reconciler stops with the server's workers, as `registration.rs` says, and the running jobs go on (#755); CONTRIBUTING's worked example becomes migration 8; the heartbeat and SQLite restore-engine module docs say what holds a SQLite beat; and the PR 9a load paragraph gives PostgreSQL's batch as 500 rows or 4 MiB. Rebased onto main through PR 8: main merged #335, #666 and #681 under the stack, so `restore_progress` is migration 7 (5 is main's `drop_continuous_backups`, 6 PR 6's), declared not applicable in #681's MongoDB and Cassandra registries as on SQLite, and main's migration tests that count the ids count it; the reconciler's resumes meet #666's restartable worker set (above) and PR 8's job drain, which a restore job joins as a backup job does; `remove-target` releases its registration on exit and is named on every backend that registers; and Cassandra's `claim_restore` no longer calls the catalog check #681 removed. Moved with PRs 6 to 8 onto main at e8ca7df: #424 fixed #742 on main (an unchanged re-put keeps its GSI and LSI rows, through the put path and the GSI worker), so this PR's change to `sync_indexes` and `gsi_apply_index` and its two index tests are dropped, main's tests covering the case; and main's #463 stream-clock live test sits beside this PR's MongoDB restore tests. Co-authored-by: Lee <leeroyhannigan@yahoo.ie> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
0763a19 to
15f38c7
Compare
|
The notes with your approvals of #700 and #740 are fixed in this head (
Engine |
.github/workflows/integration.yml: main (#740) set timeout-minutes to 55 for run-integration and run-integration-sqlite, with comments that give its measured durations. This branch set both to 60 for the PartiQL wire tests. The result keeps 60, and each comment gives the durations from both changes. Main added crates/storage-sqlite/tests/restore_job_resume.rs, which builds an OperationContext. The test now sets item_authorizer, the field this branch adds, to None.
The merge of #740 adds its restore suites to the jobs that also run this branch's PartiQL tests. Each estimate below starts from the slowest run measured on this branch. It adds #740's increase outside the build step, measured on its merge queue run. It also adds the extra build time of a cold cache. - run-integration: 48:04 + 2:19 = about 50 min, against 60. The bound goes to 70. - run-rust-integration: 29:39 + 4:29 + 1:44 = about 36 min, against 40. The bound goes to 50. - cassandra-pytest: 42:10 + 1:59 = about 44 min, against 45. #740 added no time to this job outside the build. The bound goes to 60. - mongodb-rust: the slowest run on main took 38:42, against 40. Neither change adds time to it. The bound goes to 55. Each new bound is about 1.4 times its estimate. #740 used the same ratio when it set 55 for a 40-minute run. run-integration-sqlite (about 45 min, against 60) and run-tests (about 12 min, against 15) keep their bounds.
…e test Main adds the SQLite restore test (#740), which calls dispatch with four arguments, and this branch adds the raw_body argument to dispatch. The call now passes an empty raw body. Only Scan reads the raw body, for the request order of the members of ExclusiveStartKey, ExpressionAttributeValues and ScanFilter. With an empty raw body, the Scan checks use the order of the parsed body. The order sets which fault the Scan reports first. The Scan calls in this test send no faults. Each one sends TableName and Select, with IndexName for an index and, after the first page, the LastEvaluatedKey of the previous page as ExclusiveStartKey.
Main added catalog migration 8 (restore_progress) in #740, so the warm_throughput migration moves from 8 to 9. The registry entry, each backend's registration, the migration tests, the getting-started guide and the manuals now name 9. So do the SQLite module m0009_warm_throughput.rs with its golden test and fixture, the PostgreSQL and Cassandra files 0009_warm_throughput.sql and 0009_warm_throughput.cql, devtools/check-postgres-catalog-lock and tests/test_resource_tag_migration.py. Since #740 restores a backup from a backup store, this merge removes the skip of S6c and S6d in tests/python/test_warm_throughput_backup.py. Main's restore_input_from_manifest and its manifest_from_source test helper convert between the manifest's BackupGsi and BackupLsi entries and GsiInput and LsiInput. A GSI that a store-backed restore creates gets no warm throughput. The GsiInput entries of two restore tests from #740 set warm_throughput to None. The MongoDB migration tests count migration 9 in their step counts, ids and not-applicable lists, and the id of the later binary is now 10. The upgrade manual keeps main's text on migration 8 and names migrations 1 to 7 and 9 as breaking. The migration example in CONTRIBUTING.md moves to id 10, the next free id. Signed-off-by: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com>
crates/engine/src/lib.rs: main (#411) added the partiql module behind its feature where this branch added per_table_capacity. The result keeps both modules, in name order. This branch replaced parse_condition_expr, parse_update_expr and parse_optional_filter in expression_helpers.rs with parse_condition_checked and parse_update_expr_checked. These run the grammar pass and then the semantic pass against the request's expression maps. parse_optional_filter stays for the parser tests only. The PartiQL code from main called the replaced functions in three files. base_join.rs parses the filter of an index read that reaches back to the base table with parse_condition_checked. It keeps its two operand checks after the parse. item_depth.rs works out the answer of a statement or transaction that sets a value past the item nesting limit. It parses the conditions and the update with the checked functions, the condition first, as UpdateItem checks it. The condition and filter calls pass include_length as true, so an over-size expression keeps the " expression size: N" suffix that main's functions gave it. Three PartiQL unit tests check rendered text against the grammar. They now call parse_condition_expr_with and parse_optional_filter with the same argument. write/update.rs reads the actions of an UPDATE for its key and index checks before the statement runs as UpdateItem. For it, the merge keeps parse_update_expr as main has it, with the grammar checks and the repeated-section rule only. The semantic refusals then come from UpdateItem, in its order, and pass through bare_expression_error. A checked parse there would make BatchExecuteStatement answer a list_append operand of the wrong type with the "Invalid UpdateExpression: " prefix. The batch would also report that defect ahead of a condition defect, which ExecuteStatement reports first. This branch's grammar accepts any call, so the translation no longer refuses size(m) in a SET value. The statement checks refuse it before the translation. The batch unit test a_refusal_of_the_translated_request_is_the_statements_own drops that row, and checks that a list_append operand of the wrong type translates. On this branch PutItem, UpdateItem and DeleteItem put "1 validation error detected: " in front of their expression-stage messages. PartiQL INSERT, UPDATE and DELETE run through these handlers. partiql::bare_expression_error stripped only the "Invalid ...Expression: " prefix, so a statement's message would have kept both the wrapper and the prefix. strip_single_item_write_prefix in expression_helpers.rs undoes single_item_write_prefix, and both functions now read the wrapped message starts from one constant. bare_expression_error calls it first. A unit test covers a wrapped message, and an enum refusal that keeps its "1 validation error detected: ". This branch added creation_date_time to TableKeyInfo. The Cassandra restore engine from main (#740) builds a TableKeyInfo for its restore target. It sets the field to 0.0, as the legacy restore copy in backup_engine.rs does, because only the transaction capacity order reads it.
…cuteTransaction (ExtendDB#411) * feat(partiql): parse PartiQL statements and plan their key reads Adds crates/core/src/partiql, compiled only with the new default-on `partiql` Cargo feature. It turns a statement string into an AST and then into a plan made of the operations the engine already has. Nothing else in core depends on it. The lexer and parser take SELECT, INSERT, UPDATE (also in its FROM-first form), DELETE and, inside ExecuteTransaction, EXISTS, with positional parameters. They refuse what the service refuses, with its messages: a statement longer than 8192 characters, a WHERE of more than 300 operators, an IN list of more than 100 members, a value nested inside 32 maps or lists, a number with more than 38 significant digits or outside the service's magnitude range, and reserved words used as names. A statement that nests deeper than 32 levels is parsed on a thread with a 64 MiB stack, and nesting past the service's limits (1000 groups, 1000 literal levels) is refused. The planner reads a WHERE the way the service does. OR and IN on key attributes expand into disjuncts, and each disjunct is a point read, a Query of one partition, or part of a scan. Overlapping key conditions, key values of the wrong type and paths into key attributes are refused in the order the service checks them. DuplicateItemException is a new error variant. Only a PartiQL INSERT of a key that already exists returns it. Each rule and message was measured against Amazon DynamoDB in us-east-1 between 2026-10-19 and 2026-11-03, and the code cites the measurement where it implements the rule. * feat(partiql): run ExecuteStatement, BatchExecuteStatement and ExecuteTransaction Adds crates/engine/src/partiql behind the same `partiql` feature. It translates each statement onto the handlers the engine already has (get, query, scan, put, update and delete, and their batch and transaction forms) and changes no storage backend and no classic handler. The dispatch gains three arms and is_known_operation three entries, each behind the feature. With the feature off the server answers the three operations with UnknownOperationException as before, and removing PartiQL is deleting the two partiql modules, those arms and entries, the DuplicateItemException variant and the feature. SELECT reads by key when every disjunct of its WHERE is keyed, and scans the table or index otherwise. A read by key that splits into more than 50 disjuncts is refused with "Too many decomposed read operations for a given query." A page visits at most 10 disjuncts, the first page in key order (or the ORDER BY order) and later pages in the order the service uses, and stops at Limit items or at 1 MB of item bytes read. A page of a read by key has a LastEvaluatedKey only when it holds Limit items, and every page reports the capacity of its own reads. An index read takes one Query for each disjunct. A disjunct that fails a check made when it is read (a key over the size limit, a key attribute in its filter) is held back: it takes a visit and reads nothing, and the request whose page would visit only held-back disjuncts gets its refusal. The NextToken carries where the next page goes on from and digests of the statement and its parameters, and a token sent with another statement or other parameter values is refused. INSERT creates an item and answers DuplicateItemException for a key that exists. UPDATE needs the full key, does not create a missing item, and supports SET (with arithmetic, list_append, set_add and set_delete), REMOVE and the RETURNING modes. DELETE needs the full key, succeeds on a missing item and allows RETURNING ALL OLD *. Every other term of a write's WHERE becomes its condition expression. BatchExecuteStatement takes 1 to 25 statements, all reads or all writes, and answers each statement in its own response entry. ExecuteTransaction takes 1 to 100 statements, all reads or all writes and EXISTS checks, cancels as a whole with positional cancellation reasons, and honours ClientRequestToken. The requests a statement is translated into run with the classic expression size and token limits raised to 131,072 (16 for each of the 8192 characters a statement may hold), so the translation of a statement the service accepts is not refused for its length. docs/differences-from-dynamodb.md lists the three operations as supported and adds a PartiQL section. The difference it records: a PartiQL request is not throttled by table, because the throttle reads the request's top-level TableName and a PartiQL request names its table in the statement. Behaviour was measured against Amazon DynamoDB in us-east-1 between 2026-10-19 and 2026-11-03. * test(partiql): add dual-target wire tests measured against DynamoDB Adds 30 files, tests/test_partiql_*.py, with 1,883 test cases. Each case runs against an ExtendDB server when EXTENDDB_TEST_ENDPOINT is set and against Amazon DynamoDB otherwise, with the same assertions. Every expected value was measured on the service in us-east-1, and on 2026-11-03 the whole set ran against the service: 1,880 passed and 3 were skipped. Where the service gives more than one answer to the same request, the tests accept each answer it gave: whether a LastEvaluatedKey or an empty page follows the last item of a partition, which depends on where the partition sits in storage, and the two forms in which a validation message echoes a statement list (its fields, or the name of the service's request object). A test that reads an index first waits until the index shows every item it wrote. Expected failures on ExtendDB: - The order in which parameter map members are checked: request order when the build includes the MongoDB crate, name order otherwise. Not strict. - Two divergences in code PartiQL shares with the classic operations, to be fixed there: set_add and set_delete on a list element (classic ADD and DELETE), and the 32-level nesting check on parameter values a statement does not store. Strict. * feat(partiql): authorize each statement with the action of its type Until this commit the server authorized the three PartiQL operations once, before dispatch, with the operation's action against table/*. A Deny on one table did not apply, a dynamodb:LeadingKeys condition had no values to check, and a policy that allows dynamodb:PartiQLSelect and the other PartiQL actions allowed nothing. The service authorizes each statement by itself, with dynamodb:PartiQLSelect, PartiQLInsert, PartiQLUpdate or PartiQLDelete against the table it reads or writes, or against the index alone for a read of an index. It needs no action for the operation itself, and the classic actions grant nothing. The engine gains an ItemAuthorizer trait, carried in OperationContext. The server's implementation evaluates the caller's policies with check_authorization against this deployment's table or index ARN, with the request-level condition keys every other operation carries and the item-level keys of the statement. In developer mode it allows every access, as authorize_request does for the other operations. authorize_request no longer authorizes the three operations, and the engine refuses every statement when its context has no authorizer. The trait is not behind the partiql feature, and only PartiQL uses it. Each executor makes its checks in the order a permitted caller gets them and authorizes the statement before it reads or writes an item. The checks of the request and the refusals made at parse time come first, as before. Among the checks of a statement, these also come before authorization: a parameter shortfall, SELECT COUNT(*), ConsistentRead on a global secondary index, a path into a key attribute, overlapping disjuncts, more than 50 disjuncts, a NextToken that does not decode or that another statement gave, a DELETE RETURNING clause other than ALL OLD *, a BatchExecuteStatement read without the full primary key, and an EXISTS member of the wrong form or on an index. Every other check comes after it, so a caller with no permission gets AccessDeniedException for a missing table, a missing index, a key of the wrong type or a duplicate INSERT. A check made before authorization is answered first only when no check ahead of it fails. Otherwise the statement is authorized first. Three refusals are made before authorization by analogy with the measured refusals beside them, and none is measured for a caller with no permission: a read transaction member that reads an index, one that does not name the full primary key, and two batch statements on one item. The module documentation says so where each is made. A read by key authorizes each disjunct with its own partition key value, at most 50 values, the MAX_DECOMPOSED_READS limit. A refused disjunct is held back like one that fails a check made when it is read: it takes a visit and reads nothing, and the request whose page would visit only held-back disjuncts gets AccessDeniedException. Across the disjuncts of one read, an authorization refusal ranks below the refusals of the other checks. A refused statement in BatchExecuteStatement is its own response entry, {"Error": {"Code": "AccessDenied", ...}, "TableName": ...}. A refused member of ExecuteTransaction cancels the transaction before any condition is evaluated, with AccessDenied for each refused member and None for the others. The condition keys: - dynamodb:LeadingKeys holds the partition key value of each disjunct or written item. A number is compared in the form Java's new BigDecimal(s).toString() prints for the text the statement or the parameter gives it (NumberForm::Decimal): under ForAllValues ["10"], pk = 10 and pk = 010 run and pk = 10.0 is refused. - dynamodb:FullTableScan is "true" for a scan and "false" for a read by key in ExecuteStatement, and absent in BatchExecuteStatement, ExecuteTransaction and every write. RequestParams in the auth crate gains full_table_scan to carry "false". - dynamodb:EnclosingOperation is BatchExecuteStatement or ExecuteTransaction inside those operations, and absent in ExecuteStatement. - dynamodb:Attributes holds the table's key attributes and every top-level name the statement uses: projection, WHERE, SET values, REMOVE targets and conditions. - dynamodb:Select is ALL_ATTRIBUTES for * on a table, ALL_PROJECTED_ATTRIBUTES for * on an index, and SPECIFIC_ATTRIBUTES for a projection list. - dynamodb:ReturnValues is NONE for a write without RETURNING and follows RETURNING otherwise. An EXISTS member is authorized as a read. An index read of a missing table is authorized against the table ARN. Two refinements outside authorization, measured with a permitted caller: - A map literal that names a key twice is refused at parse time with "Duplicate keys in tuple. Key: <name> under <path> at <line>:<column>:<length>". At the top level of an INSERT VALUE the position is the second occurrence of the name, with the length of the quoted name. In any other map it is that map's opening brace, with length 1. Before this commit ExtendDB accepted the literal and kept the last value. common::insert_values is the one place that reads an INSERT's values, for authorization, the write and the duplicate check of a batch or transaction. - A SELECT makes its key checks in the service's order: a path into a key attribute, an ORDER BY term that is not a key attribute, the key values, the hash-key ORDER BY rule, overlapping disjuncts, and then the number of disjuncts. ExtendDB checked the key values and overlap first, so 8 measured statements got another refusal, for example (pk = 'a' OR pk = 'a') ORDER BY n. Outside the two partiql modules this commit changes 10 files, with 346 lines added and 3 removed: - crates/engine/src/item_authorizer.rs, new: the trait and ItemAccess, with the field and re-export in crates/engine/src/lib.rs. - crates/server/src/item_authz.rs, new: the server's authorizer, with its module line in crates/server/src/lib.rs and its construction in crates/server/src/handler.rs. - crates/server/src/request_helpers.rs: authorize_request returns early for the three operations. - crates/auth/src/policy/context.rs: RequestParams.full_table_scan, with a unit test. - crates/server/src/leading_keys.rs: extract_typed_value is pub(crate). - crates/engine/Cargo.toml and Cargo.lock: extenddb-storage-sqlite as a dev-dependency, for the unit tests that build an OperationContext. Removing PartiQL now also means deleting item_authz.rs and its lines in handler.rs and lib.rs, the early return in authorize_request, and the trait module, field and re-export in the engine. full_table_scan and the visibility of extract_typed_value change no other operation and can stay. Each rule was measured against Amazon DynamoDB in us-east-1 on 2026-11-03. * test(partiql): test the authorization of each statement Adds ExtendDB-only wire tests for the authorization of each statement, and dual-target tests for the two refinements of the previous commit. The authorization tests, 57 cases in three files, are ExtendDB-only. They create users, roles and policies through the management API, so they need the builtin auth provider and the admin login in EXTENDDB_ADMIN_USER and EXTENDDB_ADMIN_PASSWORD. Each test gives an IAM user the permissions the measured role session had, in its identity policy, and expects the answer the service gave that session on 2026-11-03. - test_partiql_authorization.py: the action of each statement type, table and index resources, the answers of the three operations, dynamodb:LeadingKeys for each disjunct and each write, dynamodb:FullTableScan, dynamodb:EnclosingOperation and session policies. Two tests send every statement path, and a NextToken a permitted caller got, from a caller without a PartiQL action. - test_partiql_authorization_keys.py: EXISTS members, dynamodb:ReturnValues, dynamodb:Attributes, dynamodb:Select, number partition keys, writes without dynamodb:FullTableScan, and an index read of attributes the index does not hold. - test_partiql_authorization_order.py: the checks on each side of authorization, and the pages of a read by key with refused disjuncts. - partiql_authz_helpers.py holds the principals, policies and refusal checks the three files share. One case is a strict expected failure: the refusal of a role session. The shared authorization message names the session with an arn:aws:iam:: ARN and says that no identity-based policy allows the action. The service names it with an arn:aws:sts:: ARN and names the session policy. Every operation uses that message, so the fix belongs there. The dual-target cases, 23 in two files, ran against Amazon DynamoDB in us-east-1 on 2026-11-03, and all 23 passed. - test_partiql_repeated_names.py: a map literal that names a key twice, at the top level of a VALUE, in a nested map, a list, a SET value and a WHERE literal. Also names that differ only in case, a syntax error after the value, and the refusal in BatchExecuteStatement and ExecuteTransaction. - test_partiql_order_by.py, 12 new cases: the ORDER BY refusals among the key checks, with ORDER BY on a key and on another attribute. * style(partiql): clear the clippy pedantic warnings of the new code `cargo clippy -- -W clippy::pedantic` reported 144 warnings in the code this PR adds that main does not have. This commit clears the 24 that are not doc_markdown, with no change in behaviour: - single_match_else, manual_let_else, if_not_else, match_same_arms, needless_for_each and many_single_char_names take the form clippy suggests. - next_prefix in plan/ranges.rs returns Option<Vec<u8>> instead of Option<Option<Bound>>, and the BeginsWith arm maps the result back to the type of its bound. - shape_response and write::reshape return DispatchResult instead of a Result that was always Ok. The internal helpers of base_join.rs and returning.rs take the serde_json error by reference. - detect_duplicate_keys keeps a HashSet instead of a HashMap with unit values. - The server boxes the check_authorization future, as large_futures suggests. - split in plan/split.rs moves the cross product of an AND into its own function. lex, batch::run and select::run keep their length, and Parser keeps its six flags, each with an allow and its reason. The 120 doc_markdown warnings stay. They flag names such as PartiQL and NextToken in doc comments, which main also leaves unquoted: main has 542 such warnings. * fix(partiql): size an UPDATE by its finished item, as the service does Since #416, the SQLite and PostgreSQL storage layers size a standalone UpdateItem by its statement: a base of 3, 18 per SET clause, and the written paths with their values, checked against the limit beside the finished item. A PartiQL UPDATE runs as an UpdateItem, so it inherited that rule. At a three-byte key it refused a finished item of 409,600 bytes, which the service writes. The service sizes a PartiQL UPDATE by its finished item alone, as it sizes a transacted Update (measured in us-east-1 on 2026-11-04): - UPDATE ... SET p = ? writes a finished item of exactly 409,600 bytes at a key where UpdateItem with SET p = :p stops 17 bytes short. - One byte more is refused with "Item size to update has exceeded the maximum allowed size". - A second SET clause or an if_not_exists adds nothing to the size. - BatchExecuteStatement gives the refusal as the statement's ValidationError entry. ExecuteTransaction cancels with a ValidationError reason with the same message. - A WHERE term that fails, and the implicit check that the item exists, are answered first: an UPDATE over the limit that would not run gets ConditionalCheckFailedException. ExpressionMaps gains finished_item_sizing. When it is set, validate_update_item_size checks only the finished item, as validate_transact_update_item_size does. handle_update_item now calls handle_update_item_with(body, ctx, false), and the PartiQL UPDATE executor calls handle_update_item_with with true, in ExecuteStatement and BatchExecuteStatement. ExecuteTransaction already ran through the transacted Update and its sizing. Outside the two partiql modules this commit changes: - crates/core/src/expression/resolver.rs: the finished_item_sizing field. - crates/core/src/validation/mod.rs: the branch in validate_update_item_size, with a unit test. - crates/engine/src/update_item.rs: handle_update_item_with. - docs/differences-from-dynamodb.md: the Item Size section names the PartiQL UPDATE beside the transacted Update. Removing PartiQL now also means deleting the field, the branch and handle_update_item_with. tests/test_partiql_item_size.py adds 13 dual-target cases. All 13 passed against the service on 2026-11-04. The cases that need a refusal skip on MongoDB and Cassandra, whose update sizing differs from the service's, as docs/differences-from-dynamodb.md records. * fix(partiql): sort a batch by the statements that translate The service translates each statement of a BatchExecuteStatement into the read or the write it runs as, then sorts the statements that translate into reads and writes. A statement whose translation fails gets its own error entry and counts neither as a read nor as a write, nor in the check for two statements on one item: a missing table or index, a key value of the wrong type, an INSERT without a key attribute, a write whose WHERE does not give the full key, a ? with no parameter, and a DELETE returning clause other than RETURNING ALL OLD *. A statement that translates and then fails the check of its request counts as a read or a write, but not in the duplicate check: a read that is not of one item by its full primary key, an UPDATE that sets or removes a key attribute, and a write that gives a secondary index key a value of the wrong type. The batch counted every statement that parsed, so one statement that failed refused the whole request with the mixed read and write refusal or the duplicate key refusal. The errors of the item operation a statement runs as now carry the statement's TableName: an UPDATE of a key attribute, a secondary index key of the wrong type, an update the item cannot take (an operand of the wrong type, a document path the item does not have), a failed condition and a duplicate key. The errors of reading and translating a statement carry none. Before, a key value past its size limit, a failed condition and a duplicate key were the only errors that named the table. Measured against Amazon DynamoDB in us-east-1 on 2026-11-05. The tests are the new cases in tests/test_partiql_batch.py, on a table with a global secondary index from tests/partiql_gsi_table.py. * fix(partiql): count index units and failed writes in a batch A BatchExecuteStatement sums the ConsumedCapacity of its statements per table. At INDEXES the sum now keeps the units of each global and local secondary index, as it kept the Table units. The batch's per-table sum dropped GlobalSecondaryIndexes and LocalSecondaryIndexes, so an UPDATE that moved an item in its index reported a total of 3 with a Table arm of 1 and no index arm. A write that fails at its item is charged the write units of the item stored under its key, or one unit when there is none: a failed condition, a duplicate INSERT, and an update the item cannot take (an operand of the wrong type, a document path the item does not have). Those units count in the CapacityUnits of the write's table alone, and only when another statement on that table succeeded; a table on which every write failed reports nothing. An UPDATE of a key attribute and a secondary index key of the wrong type are refused before the item is read and are not charged. The batch counted the statements that succeeded alone, so an UPDATE of a missing item beside one that succeeded reported 2 where the service reports 3. ExecuteTransaction still reports no index units. ExtendDB's TransactWriteItems has no old or new item of an Update or Delete member to rate an index write from, for the classic operation as for PartiQL. Two tests in tests/test_partiql_transaction.py hold the service's answer as expected failures against ExtendDB. Measured against Amazon DynamoDB in us-east-1 on 2026-11-05. The tests are the ConsumedCapacity cases in tests/test_partiql_batch.py and the unit tests of the capacity list. * fix(partiql): check each transaction member in order The service checks the members of an ExecuteTransaction in order and stops at the first one that fails, so a refusal of a member wins over a refusal of any later member. For each member it reads and parses the statement, checks its parameters, then checks the member against its table, and then refuses a read beside a write. The executor parsed every member, then checked every RETURNING clause, then the mix of reads and writes, then the form of each member, so a syntax error in a later member, or the mix, could win over a refusal of an earlier member. The checks of a member against its table, each with the member's position: a read of an index, existing or not, then a read whose WHERE does not give the full primary key; an EXISTS through an index, ahead of a missing table, then an EXISTS that reads one item with no further condition; a RETURNING clause. A member on a missing table gets none of them and cancels the request once every member is checked. A `?` with no parameter is checked with the statement, ahead of the mix. In a read transaction a missing table and a key value of the wrong type now cancel the request together, each member with its own reason, in either order. The missing table was reported alone, with None for the other members. Measured against Amazon DynamoDB in us-east-1 on 2026-11-05. The tests are test_the_first_member_that_fails_is_reported and test_a_read_cancellation_gives_every_member_its_reason in tests/test_partiql_transaction.py. * fix(partiql): honour the projection of a transaction read A read member of an ExecuteTransaction returned the whole item whatever its projection list named: the TransactGetItems member it was translated into carried only the table and the key. The executor now reads each item whole and gives it the member's projection, keeping the shape of each path as a ProjectionExpression gives it, so SELECT a, m.k returns {a, m: {k}}. An item that has none of the paths is returned empty, and a member whose item is missing returns no item. ExtendDB's TransactGetItems leaves out an item that its projection leaves empty, where the service returns it empty in a PartiQL transaction, so the projection is applied here. A projection list that names an output identifier twice, or whose paths overlap, is refused on all three operations, the identifier refusal first. A transaction refuses it with the member's position, with the checks of the statement, ahead of a missing table, a partial key and an index read. A batch statement gets it as its own error and counts as neither a read nor a write. ExecuteStatement refused a repeated identifier and now also refuses two paths that overlap, which it returned items for. The overlap message names the later path first, as an UPDATE's does, and the two share the check. Measured against Amazon DynamoDB in us-east-1 on 2026-11-05. The tests are the projection cases in tests/test_partiql_transaction.py and tests/test_partiql_batch.py, and the unit tests of check_projection. * fix(partiql): refuse a statement of only whitespace as empty A BatchExecuteStatement member whose statement holds only whitespace now gets the ValidationError "The statement was empty." in its entry, and an ExecuteTransaction with such a member fails with "ExecuteTransaction can not have empty statement." and no member position. The check comes before the member's parameters are converted and before its statement is parsed, so a transaction reports a syntax error of an earlier member first. ExecuteStatement keeps its syntax error for such a statement. The whitespace set follows the service: tab to carriage return, the information separators, space, the line and paragraph separators and the Unicode spaces other than the no-break ones. A comment, U+0085 and U+00A0 are not whitespace here. The dual-target tests in tests/test_partiql_requests.py pin each case. * fix(partiql): check ORDER BY on an index and refuse a path in its list An ORDER BY on an index skipped the checks of the WHERE it reads, so a read of an index with no WHERE, or with no equality on the index partition key, ran as a scan. The index now gets the refusals a table gets: no WHERE, a WHERE that does not read by key, and more than one partition key value without the partition key in the list. A path with steps in the list, such as m.x, m[0] or pk.x, was not checked and the statement ran. It is now refused with "Argument to ORDER BY must be of type VariableReference. Type: PATH at" the position of the path's first token. The parser keeps that position on each term. The terms are checked in list order together with the rule that a name must be a key attribute. A statement with no WHERE is refused for it ahead of the list, so ORDER BY n with no WHERE gets the missing WHERE refusal on a table and on an index. The dual-target tests in tests/test_partiql_order_by.py pin each case. * fix(partiql): refuse a path into a key attribute in a write's WHERE A path into a key attribute in the WHERE of an UPDATE or a DELETE, such as pk.c = 1 or sk[0] = 'z', went into the condition the write carries, so the statement failed its condition or ran. The service refuses the statement in two places, and ExtendDB now does the same. A condition that tests the path against values alone (a comparison with a value on either side, BETWEEN, IN with values, begins_with, contains, attribute_type, an IS test of a type, or one of these compared with a boolean) is refused while the WHERE is read for the key, with "Key attributes must be scalars; ...: Key pk". The refusal and the one of two values for one key attribute come in statement order, ahead of the full key and the key types. A transaction member reports the key schema mismatch reason for it, and an EXISTS check fails the request naming its position. Any other condition with a path into a key attribute (OR, NOT, a function of the path, IS MISSING, IS NOT, the path compared with a path) is refused after the key types and after the refusal of a SET of a key attribute, also for an item that does not exist. Its message has "Key:" before the name, which is the sort key when a path into it is among the conditions. A batch entry for it names the table, and a transaction member has it as its cancellation reason. The dual-target tests in tests/test_partiql_key_conditions.py pin each form. * fix(partiql): read a string or a name in brackets as a map member A path step in brackets took only a number, so m['k'] and m[k] were refused with "Unexpected path component". A string, a quoted name or a name in brackets is now a map member, as after a dot: m['k'], m["k"] and m[k] are m.k in a condition, a SET target, a REMOVE, a SET value and the select list, and m['my key'] names a member a dot cannot. An empty string gets "Path component cannot be an empty string". Other contents of brackets parse and are refused once the statement parses, at their first token: a parameter, a number that is not a whole number, a literal word and a path. A minus before a whole number is refused as an index out of range. A reserved word in brackets is a syntax error, and so is a bracket left open, which now reads "Expected RIGHT_BRACKET" with the syntax error prefix. The steps after a path root are read by a function of their own, which the next change uses for a path in parentheses. The dual-target tests in tests/test_partiql_grammar.py pin each case. * fix(partiql): read one value or path in parentheses as grouping Parentheses around one operand were refused: pk = ('a') and pk = (?) got "Unexpected term", and (pk) = 'a' got "Expected a comparison operator". They now only group what they hold, as the service reads them. In an operand position, (x) is x: a value, a parameter, a path, a call or an arithmetic operand. A path in parentheses takes the steps after them, so (m).k is m.k, and a comma of their own makes a list, so SET x = ('a', 'b') stores a list. Signs before a number fold through parentheses: SET x = -(5) stores -5 and a = -(-1) compares with 1. A sign before anything else in parentheses keeps its refusal. INSERT values and literal elements take parentheses the same way. Where a condition starts, a group that holds one operand and nothing else is that operand, and the predicate goes on after the group: (pk) = 'a' reads by key, and (a) BETWEEN 0 AND 2, (a) IN [1] and NOT (a) = 2 run. An operand alone in parentheses is refused as the operand alone is. That refusal now also applies inside a group and after a NOT, as the service gives it: (a AND b = 1) is refused for the arguments of AND, and NOT a gets "The argument to NOT must be a Type or Expression". Before anything else such an operand keeps its syntax error. The dual-target tests in tests/test_partiql_grammar.py pin each case. * fix(partiql): refuse a negative zero decimal literal A zero written with a decimal point or an exponent and a minus sign before it, such as -0.0, -0e0 or -.0, was stored or compared as 0. The service refuses such a literal with "Negative zero decimal is not supported under key root at 1:46:3", which names where the number sits in its value (root, root[0], root.y) and the span of the number after its signs. ExtendDB now gives the same answer. The parser reads the signs before a number literal, also through parentheses, so -(0.0), (-0.0) and +-0.0 are refused, and two minus signs that cancel (- -0.0) keep the refusal. A zero written as an integer (-0), +0.0 and a parameter whose N value is -0.0 are still stored as 0, as the service stores them. The refusal comes when the statement is read, before the table is resolved and before the key type checks. In a condition, the walk meets it where it meets a number that breaks an item number rule. In an INSERT value or a SET clause it comes in the order the statement is written with those numbers and with a parameter inside a literal: for {'x':1e126,'y':-0.0} the overflow is reported, and for {'x':{'b':-0.0,'a':1e126}} the zero. It comes ahead of a sign before a SET value that is not a number, and after what the condition meets. The dual-target tests in tests/test_partiql_grammar.py pin each case. * style(partiql): apply rustfmt to the PartiQL changes The rustfmt check of toolchain 1.97.0 failed on eight files that the six commits before this one change. This commit applies rustfmt to them, with no change in behaviour. * fix(partiql): end an operand at a token that does not go on with it An operand in a condition followed by a token other than a comparison, AND, OR, the end of the statement or a ) that closes a group got "Expected a comparison operator", which the service does not answer. The service ends the operand there and reads the token in the part of the statement around the operand, and ExtendDB now does the same. WHERE a b and WHERE pk = 'a' AND a b get "Unexpected token after expression", in a SELECT and in a DELETE. (a b) and (pk = 'a' AND a LIMIT 1) get "Expected RIGHT_PAREN". WHERE a LIMIT 1 is refused for the LIMIT clause at its value, and WHERE a ORDER BY pk is refused for the operand with "WHERE clause must contain an expression that resolves to a boolean". A syntax error after the operand still comes ahead of the operand's own refusal. A size call with no comparison after it keeps "Expected a comparison operator", which is not measured. The dual-target tests in tests/test_partiql_grammar.py pin each form. * fix(partiql): refuse an index key type ahead of a path into a key ExecuteStatement refused an UPDATE that sets a secondary index key to a value of the wrong type for a path into a key attribute among the WHERE's other conditions, such as AND NOT pk.c = 1, when the statement had both. DynamoDB refuses it for the index key type first, as it does for a SET or REMOVE of a key attribute, whether or not the item exists, and BatchExecuteStatement answers the same way. run_update now calls update_refusal in place of targets_a_key, and leaves the statement to UpdateItem when update_refusal finds either refusal. An index key given the value of another attribute is checked on the written item, so the path refusal still comes first for it. update_refusal and insert_refusal move from batch/translation.rs to write/update.rs and write/insert.rs, with index_keys and from_wire, and take the translated request instead of building it. The batch translation calls them and answers as before. In ExecuteTransaction an INSERT or UPDATE member that gives an index key a value of the wrong type had no reason of its own. Its refusal came from TransactWriteItems, which the request reaches only when no member has a reason, and which names the first such member alone. DynamoDB gives each such member the index key refusal as its reason, beside the reasons of the other members, and an UPDATE gets it after a SET of a key attribute and ahead of a path into a key attribute. member_reason now gives it in that order, through update_index_key_refusal and insert_refusal. Measured against Amazon DynamoDB in us-east-1 on 2026-11-05. The dual-target tests are in tests/test_partiql_key_conditions.py, tests/test_partiql_transaction.py and tests/test_partiql_batch.py. * fix(partiql): fail only the batch statement whose translation fails Since 6c7e67c16 a batch translates every write before any statement runs. translate_write first called write::check_translation, which builds the request of the write and makes an error of that build the entry of the statement. It then built the request again and called update_refusal or insert_refusal. An error of these two returned with the ? operator and failed the whole request, so the other statements did not run. translate_write now builds the request once. It makes an error of the build, of update_refusal or of insert_refusal the entry of its own statement. That statement counts as neither a read nor a write, and the other statements run. An InternalServerError is a fault of the server, and now it fails the request. Before, check_translation made such an error of the build, or of a DELETE, the entry of its statement. A SET value that calls a function got a syntax error from the update expression parser in all three operations. The new check_written_values in crates/core/src/partiql/values.rs refuses each call with the message DynamoDB gives. A condition function gets "The function is not allowed in an update expression". An aggregate, nullif and coalesce get "Unsupported token in expression". Any other name gets "Unrecognized function" with the position of the name. if_not_exists and list_append take two operands, and the first operand of if_not_exists must be a document path. The check reads the arguments of a call before the call, and the values in written order. The parsed statement keeps neither the position of a call nor the order of the set_add and set_delete assignments. The check reads both from the tokens of the statement text. check_statement takes the statement text and runs the written values after the operand types of the WHERE's ordering comparisons and ahead of overlapping SET paths. A number in a SET value that breaks a number rule also comes after the operand types, which is the order DynamoDB reports them in. The expression builder puts a nested arithmetic operand or a call argument in parentheses, so SET a = (a + 1) + 1 runs. Measured against Amazon DynamoDB in us-east-1 on 2026-11-06. The dual-target tests are in tests/test_partiql_write.py, tests/test_partiql_batch.py and tests/test_partiql_transaction.py. * fix(partiql): refuse empty bags and duplicate set members Since 6c7e67c16 a batch reads back the item or the value map that it builds for each write before any statement runs. An empty bag, <<>>, does not read back as an attribute value, and from_wire in write.rs turned the failure into an InternalServerError. That failed the whole request with HTTP 500, so no statement in the batch ran. ExecuteStatement answered the classic message for an empty string set, and ExecuteTransaction gave the same message without a TransactStatements position. The statement checks in crates/core/src/partiql/values.rs refuse these values with the messages DynamoDB gives, ahead of every check against the table. An empty bag gets "Empty bags are not supported" in an INSERT value, a SET value and a WHERE condition. The bag can be the whole value or sit inside a map, a list or a call. A number set with two members of one value, such as <<1, 1.0>>, gets "Input collection [1, 1.0] contains duplicates!" with the path of the set. The message lists the members as the decimals they read as, such as 1E+2 for 1e2. The checks read the literals of the WHERE condition ahead of the operand types of its ordering comparisons, and the written values after them. from_wire in write.rs and in item_depth.rs gives the error that a classic request with the same value gets, through deserialize_error. The batch makes that error the entry of its own statement. The client values that reached from_wire were an empty bag and a number set with two members of one value. The statement checks refuse both before the translation. A parameter that DynamoDB refuses for its content failed the whole request when the server read the parameters. Such a parameter is an empty set, a set with a member twice, or a number set with two members of one value. It can also be a NULL that is not true, or an attribute value with no type or with two. check_parameters refuses these first, with DynamoDB's messages. In a batch, such a parameter fails only its own statement. In a transaction, it fails the request without a statement position. parse_parameters now maps errors through deserialize_error. {"BOOL": "x"} or {"S": 1} got a ValidationException that began "invalid parameter:". Each gets a SerializationException, which fails a whole batch, as DynamoDB answers. The order changes too. ExecuteStatement runs check_parameters first, and a transaction member checks for blank text first. Thus a content refusal or blank statement precedes an earlier wrong JSON type. DynamoDB reads every JSON type first. A later fix restores that. Measured against Amazon DynamoDB in us-east-1 on 2026-11-06. The dual-target tests are in tests/test_partiql_write.py, tests/test_partiql_batch.py and tests/test_partiql_transaction.py. * fix(partiql): refuse a path in brackets at its first step Since 94e693675 the parser read a path in brackets by calling the path reader again for each level of its brackets. No depth cap in limits.rs applied on that route. A debug build of the server stopped with a stack overflow on m[ followed by 1000 levels of k[. The refusal named the innermost path. The service refuses a path in brackets at its first token, whatever follows that token and however deep the brackets nest. ExtendDB now gives the same answer. The parser keeps that refusal first and reads the rest of the path in a loop. The loop counts the brackets it opens, so the stack the parse uses does not grow with their depth. The parser reads anything else in an inner bracket as an expression, which may be a condition, within the depth caps. The service reads it the same way. Only a syntax error in that expression changes the answer. The statements m[k[1 + 1]], m[k[a = 1]] and m[k['']] get the refusal at the k. m[k[1 2]] gets "Expected RIGHT_BRACKET" and m[k[]] gets "Unexpected term". After a path with steps, the parser reads the rest of the expression with the path as its first operand. So m[k[j.x = 1]] gets the refusal at the k, and m[k[j.x y]] gets "Expected RIGHT_BRACKET". After an operand, a NOT that does not start NOT IN, NOT BETWEEN or NOT LIKE is a syntax error of what encloses the condition. Inside brackets the error is now "Expected RIGHT_BRACKET", as the service gives it for m[k[a NOT = 1]]. In a group it stays "Expected RIGHT_PAREN". In brackets EXEC starts its form only before a parenthesis, so m[k[EXEC x]] gets "Unexpected term". A token that closes what encloses an IN list or a map key now gets "Unexpected term". The service gives that answer for a IN ], (a IN ), a = {] and {'a': 1,}, in a condition and in brackets. Before, they got the IN operands refusal and the refusal of a name as a map key. In a SET or REMOVE target, brackets that hold a path, a parameter or an expression now get a syntax error. The message is "Invalid path component for simple path", the message the service gives. The error comes once the brackets close, ahead of anything later in the statement. Before this change the target got the refusal a condition gets. The parser refuses nothing in the brackets of an ORDER BY term. The term gets the service's "Argument to ORDER BY must be of type VariableReference" whatever its brackets hold. Empty brackets get "Unexpected term" wherever the parser reads a path. The unit tests parse each form a statement can nest in at the length limit of 8,192 characters. Each parse runs on a thread with a stack of 1 MiB, so a recursion with no bound fails a debug test run. The tests also walk the deepest statements that parse on such a stack. The dual-target tests in tests/test_partiql_grammar.py and tests/test_partiql_limits.py check each answer. * fix(partiql): fail the request for a parameter of the wrong JSON type check_parameters decided whether a parameter error belonged to its statement by the words of the attribute value deserializer's message. A batch failed as a whole for a refusal that no word matched, such as a string set with a null member. It ran the other statements for a number set with a null member. DynamoDB reads the parameters in two passes. The first pass reads the JSON of the whole request. A value of the wrong JSON type in any statement fails the request with a SerializationException. Examples are {"BOOL": "x"}, {"SS": [1]}, {"NS": [1]} and {"BS": [1]}. In all three operations this refusal comes ahead of the request checks. It also comes ahead of a statement of only whitespace and of the content of every other parameter. In a batch no statement runs. The first pass ignores a member that the attribute value model does not name, and a member whose value is null. So {"X": "1"} and {"S": null} are values with no type, and {"X": "1", "S": "a"} is the string a. The second pass checks the content of each statement's parameters in order. Its refusal is the statement's own. The new check_parameter_types in crates/core/src/partiql/params.rs makes the first pass. Each operation runs it over the parameters of every statement before its request checks. check_parameter_values makes the second pass. It reads only the members that the model names. It refuses a null member of a set, a map or a list with the message that DynamoDB gives for each. parse_parameters reads each value without the members that the model ignores. So {"SS": ["a", null]}, {"NS": ["1", null]} and {"X": "1"} fail only their statement, and {"SS": [1]} fails the request. The SerializationException keeps the wording of the deserializer, which differs from the wording of DynamoDB. Measured against Amazon DynamoDB in us-east-1 on 2026-11-06. The dual-target tests are in tests/test_partiql_write.py, tests/test_partiql_batch.py and tests/test_partiql_transaction.py. * test(partiql): reach the statement's own entry in translate_write No test reached the path that translate_write takes when update_refusal or insert_refusal fails. The statement checks refuse a call in a SET value and an empty bag before the translation, so no batch request reaches that path. The unit test a_refusal_of_the_translated_request_is_the_statements_own in crates/engine/src/partiql/batch.rs translates three writes without those checks. One UPDATE sets a value that calls size, one UPDATE sets an empty bag, and one INSERT writes an item that holds an empty bag. Each one gets the statement's own ValidationException, and the request does not fail. * fix(partiql): fail a batch for every fault of a statement translation untranslated made a translation error the entry of its statement for every error but InternalServerError. A transient fault or a lost connection to the storage maps to ServiceUnavailable (HTTP 503), which a client retries. Such a fault became an entry with the code ValidationError, which a client does not retry. untranslated now fails the request for every error with an HTTP status of 500 or more. No statement reaches this path. The batch reads every table from the catalog before it translates the statements, and update_refusal and insert_refusal read nothing. * docs(partiql): describe the errors of update_refusal and insert_refusal The doc comments of update_refusal, update_index_key_refusal and insert_refusal said that their errors were a fault of reading back the request. from_wire returns the error that a classic request gets for the value, through deserialize_error. update_refusal also returns the refusal of the classic update expression parser. The comments now describe these errors. * fix(partiql): refuse a closing token where an INSERT item key starts The item map of an INSERT answered a closing token where a key starts with "Tuple keys must be of type String. Actual type: Id under root at L:C:N". So INSERT INTO t VALUE {'pk': 'a', 'sk': 's',} and INSERT INTO t VALUE {] got that message. The same tokens in a nested map got "Unexpected term". DynamoDB answers "Unexpected term" for both, in ExecuteStatement, in a batch and in a transaction. Measured 2026-11-06 with {], {), {>>, {,}, {'pk': 'a', ] and the trailing comma with and without a space. item_map now takes the same closing token arm as map_members. A name as a key keeps the tuple key message at the name. Tests: a_closing_token_where_an_item_key_starts_is_an_unexpected_term in crates/core/src/partiql/parser/tests/statements.rs, and in tests/test_partiql_grammar.py the dual-target tests test_a_closing_token_where_an_insert_item_key_starts_is_an_unexpected_term and test_a_name_as_an_insert_item_key_keeps_its_refusal. * refactor(partiql): drop the number walk the server no longer runs check_statement runs check_condition_values and check_written_values over a parsed statement. Nothing outside the tests called check_literal_numbers since those two took over the number rules. The stack test the_deepest_statements_that_parse_are_walked_on_a_small_stack still ran check_literal_numbers and not the two walks the server runs. So a recursion in them had no test on a small stack. The test now runs check_condition_values and check_written_values with each statement's text, on the same 1 MiB thread. The condition walk over a list literal 1000 levels deep needs 870 KB of it on a debug build (the number walk needed 747 KB). This commit deletes check_literal_numbers and where_literals. The number tests run the two walks in the server's order instead. The comments in values.rs and number.rs that pointed at check_literal_numbers now describe literal_number_refusal, which both walks and the parser use. * test(partiql): require HTTP 400 from a local server at the length limit test_a_statement_nested_to_the_length_limit_is_refused_and_the_server_keeps_serving accepted any ClientError, so an InternalServerError from the local server passed it. DynamoDB answers some of the shapes with InternalFailure, so the dual-target test cannot require HTTP 400 on both targets. On the local target it now requires HTTP 400 and the code ValidationException for each of the 42 shapes. The live target keeps the broad check. * test(partiql): pin the place of the arithmetic operand type check The SET value walk in values.rs reads the values in written order. It does not check the operand type of an arithmetic literal ('x' + 1). The translation of the statement does, after the walk. DynamoDB puts that refusal in the same place. It comes after every number rule, empty bag and call refusal in the SET values, in whatever order the statement gives them. Measured 2026-11-06 in ExecuteStatement, a batch and a transaction. SET a = 'x' + 1, b = size(m) and SET b = size(m), a = 'x' + 1 get the size message. SET a = 'x' + 1, b = <<>> gets the bag message. SET a = 'x' + 1e126 and SET b = 1e126, a = 'x' + 1 get the number message. SET a = 'x' + 1, b = 2 - true names the + and the S. The module comment in values.rs records this. The dual-target tests test_the_operand_type_of_an_arithmetic_literal_comes_after_the_other_set_value_checks and test_the_operand_type_of_an_arithmetic_literal_comes_after_a_call_in_a_batch_and_a_transaction in tests/test_partiql_grammar.py hold the measured answers. * test(partiql): walk the deepest statements on a worker's stack the_deepest_statements_that_parse_are_walked_on_a_small_stack ran the server's walks of a parsed statement on a thread with a 1 MiB stack. The deepest walk, the literal checks of a condition with a value 1000 levels deep, needs 870 KB on a Linux x86_64 debug build. Debug frames are larger on macOS arm64, where the same walk needs about 1.1 MB. The thread overflowed there and aborted the whole extenddb-core test binary. The server runs these walks on a tokio worker thread, whose stack is tokio's default of 2 MiB. The test now gives the walks that stack, so it checks the bound that the server depends on. * ci: give the two long integration jobs 60 minutes run-integration and run-integration-sqlite build a release server and run the Python suite of devtools/run-tests against it. This branch adds the PartiQL wire tests to that suite. On main the two jobs take 30 to 38 minutes. On this branch they took 37:55 and 38:24 at 0e4b4a8df. At ba6f64263 both hit the 40-minute limit twice, and GitHub cancelled them with no test failed. Their timeout-minutes goes from 40 to 60. The other jobs keep 40. * ci: raise four job bounds that the merged suites come near The merge of #740 adds its restore suites to the jobs that also run this branch's PartiQL tests. Each estimate below starts from the slowest run measured on this branch. It adds #740's increase outside the build step, measured on its merge queue run. It also adds the extra build time of a cold cache. - run-integration: 48:04 + 2:19 = about 50 min, against 60. The bound goes to 70. - run-rust-integration: 29:39 + 4:29 + 1:44 = about 36 min, against 40. The bound goes to 50. - cassandra-pytest: 42:10 + 1:59 = about 44 min, against 45. #740 added no time to this job outside the build. The bound goes to 60. - mongodb-rust: the slowest run on main took 38:42, against 40. Neither change adds time to it. The bound goes to 55. Each new bound is about 1.4 times its estimate. #740 used the same ratio when it set 55 for a 40-minute run. run-integration-sqlite (about 45 min, against 60) and run-tests (about 12 min, against 15) keep their bounds.
This resolves the conflict in the upgrade manual. #474 added the migration 9 paragraph, and #740 rewrote the backup store paragraph after it. The merge keeps both paragraphs as main has them. In the backup store paragraph, it applies the one change of this branch: a later major release refuses CreateBackup without a store. Signed-off-by: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com>
Backup/restore series (design: #396): #384 restore fidelity (merged) · #393 store · #394 format · #395 SnapshotEngine · #666 catalog migration framework (merged) · PR 6 the catalog index · PR 7 configuration, the store identity file and
backup purge· #700 the CreateBackup job · this PR: the restore job from the store, every backend, the cross-backend matrix andextenddb backup remove-target(the design's PR 9a and PR 9b as one PR, with PR 10'sremove-targetpulled forward) · next: PR 10backup sync.Closes #739 (part of #624).
On
mainat3033e090, which carries #671 (merged asff6f74eb), #672 (4a42c5e0) and #700 (d35d8601). The rebase after #700 merged had no conflicts; it adds main's #847 and #868. The rebase onto #700's head after #672 merged renumbersrestore_progressto migration 8: main's #819 took 7 for PostgreSQL'sstream_pending. The SQL file is0008_restore_progress.sql. Each backend'sRestoreProgressarm followsStreamPending,NotApplicableon SQLite, MongoDB and Cassandra as before. CONTRIBUTING's worked example (table_note) is now migration 9. The sample outputs say "migration 8". MongoDB's migration tests count one more not-applicable step and one more pending migration, and their later-binary row is id 9. The rebase conflicted in 13 files: the registry and the four backends' migration tables; SQLite's two migration tests and Cassandra's pending-migration cell, where main's general form is kept; CONTRIBUTING, the upgrade manual and design item 32 (#819's text and #700's round-four text beside this PR's); the admin guide; andheartbeat.rs. Inheartbeat.rs,ClaimChecknow holds #700's flag and callsin_flight_confirms(in_flight, parks), so a restore's pre-write check takes #700's rule: a beat in flight confirms the claim on SQLite only. #700's new between-beats cell takes this PR'sHeartbeatMetric::Noneargument. The rebase after #671's merge conflicted only incrates/storage-sqlite/src/lib.rs's module list, where main's #704 addedmod password_hash_store;beside this PR'smod restore_engine;(both kept). (Rebased for this review round onto #700's current head;crates/storage/src/lib.rstook main's #776pub mod secret_cipher;beside this PR'spub mod restore_engine;.) This PR addsrestore_progressto the PostgreSQL data database (migration 8, not applicable on SQLite, MongoDB and Cassandra);docs/schema/postgres.txtcarries the table and the other three schema files are unchanged, each checked against a freshinit. It binds PR 7's store, spool and health and PR 6's claim, row and reconciler to the restore:check_catalog_row_for_restoreno longer refuses a store-backed backup; the request takesrequire_healthy_storeand the job permit and registers the target under the backup row's lock; the reconciler's cycle gains the dead-restore resume and therestore_progressand spool sweeps beside #700's backup duties; the backup commands' catalog refusals and named registrations are PR 7's, whichremove-targetuses. Since the last push main merged #335, #666 and #681 under the stack:restore_progressis migration 7 (#335 took 5, PR 6 took 6) and is declared not applicable in #681's MongoDB and Cassandra registries, and main's migration tests that count the ids now count it; the reconciler runs in #666's restartable worker set, which a lost serving registration stops while the jobs go on, so a reconciler whose own token has fired spawns no resume (a resume is a job); a restore job meets #700's drain in #666's shutdown path as a backup job does; andremove-targetreleases its serving registration on exit and carries its name on MongoDB and Cassandra as well as PostgreSQL. The rebase onto #700's head conflicted in twelve files:cmd_backup.rs(PR 7's registration release on this PR's split of the catalog opening from the store's), the Cassandra crate'sCargo.tomland backup-engine imports, the MongoDB backup live tests' imports and the GSI backfill worker's doc, the PostgreSQL migration table (migration 7 beside main's 5), and the design, differences, limits, usage, admin and upgrade documents, each resolved by keeping both sides' intent, main's text where #335 removed point-in-time recovery; Cassandra'sclaim_restoreno longer calls the catalog check #681 removed. The rebase onto the review rounds of #671, #672 and #700 conflicted in 23 files, resolved so that the three lower PRs' review changes stand beside this PR's: the threefail_restore_jobwrites of the resume arm (OWNER_DEAD,TARGET_NOT_EMPTYunder the claim,OTHER_STORE) now pass theowner_generationandheartbeat_atthe listing read, #671's fence, so a heartbeat that lands after the listing keeps the record its owner's (the engine'sFakeapplies the fence to its restore records); the restore request takestake_permit().await, #671's 30 s wait, before the manifest read; the target name is checked after the catalog row's state (and, on the legacy path, after the source resolves), #671's measured order, and before the backup's in-use rule and the manifest read (check_catalog_row_status, thencheck_restore_target_name, thencheck_catalog_row_for_restore;the_manifest_request_refuses_in_order_before_the_registrationreads one row read and no store read before a taken name's refusal), so a restore of a backup into its own CREATING target isTableInUseException, as the fidelity suite measured on DynamoDB, and the same backup into another nameBackupInUseException(a_backup_being_restored_is_in_use_until_its_target_is_activecarries the three cases, with another backup into the name being restored into;a_store_backed_restore_checks_the_target_name_after_the_row_and_before_the_storeis #700's cell adapted: the free name goes on to the manifest read). The first CI run of this head had the in-use rule before the name on the store-backed path and failedtest_restore_into_a_creating_target_and_a_second_restore_of_its_backup_are_refusedon the three store-backed jobs; the in-use text is #671's service text naming the backup (backup_in_use_message), which the SQLite, PostgreSQL and Cassandra registration cells assert;HeartbeatHandlekeeps #700'sheartbeat_withinandrecheckwith this PR'sHeartbeatMetricandClaimCheck; the reconciler's state is theArc<Mutex<..>>#671'sserve.rsshares, its store check carries #672's defaulted-root rule, and the resume arm takes the configured store as theArc#700'sJobEnv::newneeds (BackupRuntime::configured_store_arc); SQLite's in-catalog restore slot (#671, by table id) islegacy_restore_slot_keybeside this PR'srestore_slot_keyon the target name, and the dead listing skips a record whose slot of either kind a task holds; every backend'slist_running_restore_jobs(#672) reads the restore record through this PR's wider row reader, andlist_dead_restore_jobskeeps #671's skip-and-log of a record this build cannot read;cmd_backup.rsopens the catalog forremove-targetahead of #672's store-requiring open, under the same signal listener;devtools/run-mongodb-testskeeps one store mechanism, this PR's--backup-store KIND(filesystem by default), with #700's--backup-store-root DIRnaming the root of that store (a named root is created and made owner-only, never cleared);tests/test_backup_job.pykeeps #700's MongoDB deployment, on which this PR's seven restore cells skip (_restore_cell), and the cell that seeds atarget_not_emptyrecord at the catalog edits SQLite's and PostgreSQL's as before;MAINTENANCEis back injob.rsfor the restore job's reason (the backup job, since #700's round, checks the claim before its row exists). #671's review item took revision item 28, #672's are 29 and 30 and #700's 31 and 32, so this PR's is 33 (every reference in the tree renumbered).What
Core (
crates/core). No new rule;validate_restorable_item,RestorableLimitsand the manifest reader (ExtenddbManifest::reader,reader_prehashed, the per-fileKeyRangecheck) are PR 4's and PR 8's, bound by the restore.DataFileDecoderandDataFileReaderexposebytes_decoded(), the uncompressed bytes decoded so far, which the load's chunk bound counts.Storage trait (
crates/storage). A newRestoreEnginetrait inStorageEngine's bound:bulk_load(target, claim, BulkLoadFile { file_index, start_at, reapplying }, items) -> applied,finish_restore(target, claim, item_count, size_bytes) -> Finished { Active, Pending, NotMatched },count_restore_target,read_restore_progress(default[]),sweep_restore_progress(default 0);RESTORE_BATCH_ITEMS(10,000) andRESTORE_BATCH_BYTES(32 MiB), the SQLite and Cassandra batch bounds, live beside it.BackupCataloggainsclaim_restore(account_id, input, &RestoreRegistrationRow) -> (TableDescription, Box<dyn JobClaim>)(the registration creates the CREATING target and the RUNNING record under the backend's own generation),claim_restore_dead(key, stale_after, spool_host)(the restore analogue ofclaim_dead_backup; it writes a freshheartbeat_aton the database's clock in the same conditional write as the generation andspool_host, so a second taker finds the record live at once, where the backup takeover leaves the heartbeat for its FAILED-prefix clock),read_restore_jobandlist_restore_jobs;RestoreJobRowcarries the store,spool_host,file_index,items_applied,failure_reasonandcompleted_at.restore_input_from_manifestbuilds aCreateTableInputfrom the manifest'ssource_tableand applies the overrides, one implementation for both restore paths.restore_pause_point(name)is apub async fnundertest-hooks, a no-op without it, and the four backend pause-point names arepub consts beside it (RESTORE_SQLITE_MID_BATCH,RESTORE_MONGODB_AFTER_INSERT_MANY,RESTORE_CASSANDRA_BEFORE_PROGRESS,RESTORE_POSTGRES_BEFORE_PROGRESS_DELETE), one spelling each: the engine'sKillPointholds the engine's five points alone.JOB_HEARTBEAT_INTERVAL's doc says the store-backed jobs run atmax(heartbeat_interval_secs, JOB_HEARTBEAT_INTERVAL)and the in-catalog paths and the maintenance claim at the constant.PostgreSQL (
crates/storage-postgres).restore_engine.rs:bulk_loadopens one data transaction per file, multi-row INSERTs of 500 base and index rows (or 4 MiB of item text) throughinsert_restore_rows, writes therestore_progressrow in the same transaction (a plain INSERT for a file applied from its start, an upsert on(table_id, file_index)for a file resumed past it, so a row a decoder panic left short is completed by the resume rather than failing on the row's key), and fails a duplicate on the base table; nothing in it conditions on the generation (the base table's key and the claim's liveness are its fence).finish_restoreflips the table and every index ACTIVE in one catalog transaction and then deletes the progress rows on the data pool (the pause pointrestore_postgres_before_progress_deletebetween the two); that delete's error is logged and swept by the reconciler, never the restore's failure, since the table is ACTIVE and the record DONE once the flip commits.claim_restorelocksbackup_index FOR UPDATE(the lockstart_backup_deletetakes; its comment says so);claim_restore_deadtakes the name's advisory lock and a stale RUNNING manifest record, refreshingheartbeat_atwith the generation andspool_host.sweep_restore_progressdeletes from a positive list (the table ids the progress rows name, read first, minus the RUNNING records read after and the caller's list), so a registration that commits between the two reads is never a candidate. Migration 8 addsrestore_progress(table_id, file_index, items_applied).remove_restore_target'sto_regclass('restore_progress')check and itsDELETEnow run on the data pool; on 09c881f both ran on the catalog pool, so with the table in a separate data database the PR 6 code found no table and deleted nothing.remove_restore_targetdrops the vector index's_ddb_vec_relation with the data and index tables (table_storage_relations); the record'sheartbeat_atis the registration transaction'snow(), refreshed by the engine's first beat before the reply.SQLite (
crates/storage-sqlite).bulk_loadwrites batches of at most 10,000 items or 32 MiB of serialized item text, each aBEGIN IMMEDIATEholding the base, index and vector rows and the record's(file_index, items_applied), conditioned on the generation (the pause pointrestore_sqlite_mid_batchbetween batches);claim_restoretakes the name's slot;claim_restore_deadtakes over a record whose slot no task holds, whatever its heartbeat (a restart frees every slot and ages every RUNNING record), refreshingheartbeat_at. The startup line for an aged RUNNING record names the reconciler's duty per kind (a store-backed restore is resumed; an in-catalog one is removed).remove_restore_targetno longer probes for arestore_progresstable (SQLite never has one; a store-backed restore's progress is on itstable_restoresrow,file_indexanditems_applied). The flip'sbackfilling = NULLon the vector rows is now observed by a fixture that sets it before the flip. A table with secondary indexes restores in more resident memory than the no-index figures the docs quote, and the excess is SQLite's own per-connection page cache for the index b-trees plus the allocator's arenas, not the restore's buffers, which stay bounded by the batch's 32 MiB (the module doc, the admin guide and the record state the shape beside the figures; a smaller batch for an indexed table was built, measured ineffective and not kept). A job's heartbeat queues behind the engine write lock like any writer, so the restore engine's and the heartbeat's module docs state what holds a beat, the batch times the bench record measured, and what a beat late by a period costs (the job stops as a lost claim and the reconciler resumes it from its last batch).MongoDB (
crates/storage-mongodb).bulk_loadwrites each batch of up to 1,000 base documents in one session transaction with the record's progress write: the transaction firstfind_one_and_updates the record (RUNNING under the claim's generation, settingrestore_job.file_index,restore_job.items_appliedto the file's running total andheartbeat_atto$$NOW) and ends the file as a lost claim when that write matches nothing, theninsert_manys the batch and commits, so a frozen owner whose record a takeover moved can write no batch after the takeover (the Blocking fence, [Lee 4468802456]: the fence is in the write). Because the progress commits atomically with each batch, a crash mid-file leaves no documents the record does not account for and a resume continues fromitems_appliedwith nothing re-applied;reapplyingis therefore vestigial on MongoDB (a transaction cannot doordered=falseduplicate suppression), andtarget_not_emptyarises only from the engine's count-above-total check, not from a mid-file crash. A duplicate key (E11000, the unique_id) is the backup's and fails the file. The pause pointrestore_mongodb_after_insert_manynow fires after each batch's transaction commits.finish_restorewrites DONE andrestore_backfill_pendingand returnsPending, the backfill worker flipping the table ACTIVE. A record carriesrestore_job.ddl_done, setfalseat the registration's insert andtrueonce the target's collections and index metadata exist; the reconciler'srestore_target_readyrefuses a record left unmarked (its owner died between the commit and the data DDL) and fails it rather than resuming into a target missing its indexes (the Blocking registration-order fix, [Lee 4468802443]).claim_restore_deadrefreshesheartbeat_atwith$$NOW, so a second taker right after the first is refused; the registration stamps the record's heartbeat from the server's clock inside its transaction. AnNsort key past Decimal128 fails the file with the write path's text.bulk_loadholds the GSI backfill test gate (test-hooksbuilds only) before the first insert of the restore's first file, as the in-catalog copy does before its$out, sorestore_worker_waits_for_base_copy_before_backfilling_indexesruns on the store path. The snapshot open that a store-backed CreateBackup takes reads the majority-committed snapshot, which can trail DescribeTable'slocalread, and a CreateBackup sent as soon as DescribeTable said ACTIVE was refusedTableNotFoundException(5 of 80 in a probe beside a write load; 0 of 80 on the in-catalog path); the open now waits, at most 2 s, while alocalread shows the table ACTIVE (0 of 150 after), pinned byan_open_waits_for_the_snapshot_to_show_a_table_a_local_read_shows_active, which writes the flip unjournaled so the snapshot trails it.Cassandra (
crates/storage-cassandra).bulk_loadwrites each item's base row and every GSI and LSI row in one logged batchUSING TIMESTAMPa reading of the coordinator's clock taken before the batch's claim check (put_restored_item_at, no lightweight transaction, eight in flight), in batches of at most 10,000 items or 32 MiB of serialized item text; the claim check before each batch is a lightweight transaction on the record (confirm_restore_claim), and the timestamp is the fence against a batch a stalled writer lands after a takeover: it carries a timestamp below every write and delete the successor made, so Cassandra keeps the successor's cells and tombstones and a key a client deleted stays deleted (the second round's thread item;test_a_batch_landed_after_a_takeover_loses_to_the_successors_writes_and_deletespauses a writer after its check, lets the successor write, flip ACTIVE and delete two items, and releases it); it records progress with a lightweight transaction per batch (the pause pointrestore_cassandra_before_progress);finish_restoreis one LWT (Active, neverPending; index rows created ACTIVE);count_restore_targetchecks the catalog first, as the other three do. The count is the only duplicate check. An unknown outcome of the progress lightweight transaction is re-read atLOCAL_SERIAL(applied, one retry, or the lost claim; a second unknown outcome fails the restore); a registration whose record was taken over during its DDL removes nothing and answers the lost claim; thetest-hooksfeature gainsfail_progress_writes(Landed,Lost).claim_restore_deadrefreshesrestore_heartbeat_atin its LWT and re-applies the backup'sactive_restoresentry for the target when a crash between the target's DDL and the registration left none, so the in-use rule holds through the resume.bulk_load's index rows carry the batch's timestamp, which is also the base row'sversion, so ADR-0027's rule holds (a later ordinary write's version is above it) and a re-applied restore batch keeps every GSI and LSI row under a later stamp (#424's in-place rewrite of an unchanged index row is what the legacy copy'sput_restored_itemrelies on; the store-backed load no longer goes through it).restore_target_readyreads each GSI's and LSI's table insystem_schema.tablesas well as the data table, since the registration creates them in separate statements after the data table (test_restore_target_ready_requires_every_index_tabledrops the GSI's table). A crash before the first batch's progress record leaves the batch's items with no progress; the engine resumes that record with the first file re-applied (upserts) when the target holds no more than the backup's total (test_a_first_batch_applied_without_progress_resumes_to_the_exact_count: the progress write's outcome unknown twice, the load failed with 3 of 7 items landed and no progress, the resume to 7 with both index-row counts exact). Two concurrent registrations of one backup leave one target: the registration's LWT (IF backup_status = 'AVAILABLE' AND active_restores = null) is the fence, linearized on the backup's partition; the in-use pre-read is advisory. New index-row tests: a re-appliedbulk_loadbatch, whose GSI and LSI rows keep their count and carry their base rows' versions as write times, and the two-registration race.Engine (
crates/engine/src/backup/).restore_table_from_backupand the reconciler take&Arc<S>(the manifest branch and the resume spawn tasks).restore.rsandrestore/{spool,download,load}.rs: the request verifies the sidecar and manifest (each read bounded, 4 KiB and 16 MiB; an object over its bound is aValidationExceptionnaming a corrupt prefix), the vector gate, the overrides, the key schema and the target, registers undertimeout(RESTORE_REGISTRATION_TIMEOUT)(60 s, every backend), and runs the job (download with a 15-minute transport retry counted from the job's first attempt, per-filebulk_load, the count,finish_restore). The load's chunk bound (LOAD_CHUNK_BYTES, 32 MiB) counts the decoded line's bytes, and the feeder checks the claim at every chunk boundary beside the shutdown token; a decoder that dies fails the file..verifiedmarkers hold the file's sha256 and a stale entry (a record that was another host's) is removed before use; a job whose record is another's (a lost claim, a DONE or FAILED write that matched no record) leaves its spool entry to the sweep. The spool sweep lists the spool before the records and keeps the entry of a restore registered between the two reads; the spooled manifest and sidecar are written.partial→ sync → rename and a spooled pair that fails verification yields to the store's; afinish_restoreerror is re-read through the claim (DONE under the job's generation is the restore complete, RUNNING one more write, anything else the DONE write that matched no record); the resumed job's first beat is the takeover's return, taken by the reconciler besideclaim_restore_dead;BackupStoreErrors{get}on everygetthe store failed; the restored table'sItemCountandTableSizeBytesare 0 at ACTIVE, as the service reports them (measured: 0 and 0 on 15 restored tables, one of them ten minutes after ACTIVE; [Lee 4468802685]), the statistics pass rewriting them later where the backend has one; a registration that fails after the request's 60 s bound is logged with the ARN, the target and the account. The job sends its first heartbeat right afterclaim_restorereturns and before the reply, andclaimed_atis that beat's return, so a slow registration does not end the job at its first check and the record'sheartbeat_atis fresh at the reply (on PostgreSQLnow()is the transaction's start, so a registration stalled on thebackup_indexlock or the DDL would otherwise commit a stale stamp); a first beat that finds the claim lost fails the record ("the claim was lost before the job started") and answersInternalServerError; the maintenance refusal recordsRestoreJobs{failed}.remove-targetrefuses or removes a RUNNING record's target bylist_dead_restore_jobson the database's clock (no host clock), and the purge's wait reads the backend's heartbeat listing the same way; the reconciler takes nothing over once the shutdown began, spawns no resume once its own token has fired while the jobs' token has not (the server stopping its workers while it holds no serving registration;a_stopping_reconciler_takes_nothing_over), and applies thetarget_not_emptyguard under a maintenance claim (a dead record with no progress over a target that holds more items than the backup's row counts is failedtarget_not_emptyand left forremove-target, notOWNER_DEADand removed); the lost-claim stop line carries the heartbeat timer's reason, which the timer also logs at WARN; the spool entry is removed on a failed spool document write and on a resume that fails before it runs; a DONE write whose answer was lost and whose record re-reads DONE ends withcomplete (DONE); the twotarget_not_emptylines printextenddb backup remove-target --account <account> <table>. The reconciler checks the store first in its cycle (a store whose first check has not recorded isnot yet checked, not unhealthy), resumes a dead store-backed restore from its progress underclaim_restore_dead, fails one of another store (OTHER_STORE) or no progress into a target holding more items than the backup's total (target_not_empty; at or below the total the first file is re-applied withreapplying,exceeds_backup_totalreading the backup row'sitem_count), re-reads the maintenance claim after the takeover and leaves the record unresumed when a command took the claim during the cycle, bounds its own takeovers of one record (RESTORE_TAKEOVERS, 5, counted per table id inReconcilerState; the sixth takeover fails the recordTAKEOVERS_EXHAUSTEDand removes its target asOWNER_DEADdoes, so a record whose every resume ends RUNNING again is not retaken every stale window for as long as the server runs; per instance, reset by a restart), counts a maintenance-held dead record in a field of its own, and sweepsrestore_progressrows and spool entries. The final count is attempted three times with a wait between; three failures stop the job with the record RUNNING and no FAILED write (a_count_that_fails_three_times_leaves_the_record_running). MetricsRestoreJobs,RestoreJobDuration,RestoreBytesDownloaded(each data-file download attempt's bytes);KillPointgains five restore variants (RestoreAfterDownload,RestoreAfterFileCommit,RestoreMongodbBeforeWorkerFlip,RestoreBetweenFailedAndRemoval,RestoreFencedOut; the engine's list; the engine's points logjob reached kill point <name>and the backends'restore job reached kill point <name>).crates/server/Cargo.toml'sbackup-test-hooksaddsextenddb-storage/test-hooksso the backends' pause points compile in.App (
crates/app).extenddb backup remove-target --account <id> <table>(PR 10's command, pulled forward): under the maintenance claim it removes a FAILED restore's CREATING target and record (target_not_emptyincluded, the state a catalog restored from a dump leaves when it names a restore that finished and was written to since), a RUNNING restore's target whose owner is dead, or a legacy record's target, throughremove_restore_target; it refuses a live RUNNING restore, a DONE restore, a table no record names, and, on PostgreSQL, a CREATING restore target whose owner's session still holds the target's advisory lock, reported as its own case (the record is unchanged after the backend refused the removal: "its owner's lock is still held ... wait for the owner to stop, or stop it, and run the command again";remove_target_reports_a_held_owner_lock_as_its_own_case). It needs no store and opens the catalog without creating the default root or an identity file, prints the claim it took and one line naming what it removed, and logs the removal underextenddb::audit; on PostgreSQL, MongoDB and Cassandra its serving registration is namedextenddb backup remove-target(PR 7's per-backend name) and released when the command exits, whatever the outcome, and a catalog under migration or behind the binary ends it with exit 6 or 3, aslistandpurgedo.backup purgeon SQLite fails a dead store-backed restore and removes its target rather than resuming it inside the command, since its cycle runs as the claim's holder. The command's logic and its tests are the engine's (maintenance::remove_target).Harness and CI. The 24 restore cells of
tests/test_backup_restore_fidelity.pyrun on a server with a store, andtest_create_backup_of_a_vector_table_is_refusedistest_vector_table_backup_refused_in_catalog_and_restored_from_store, since it asserts the round trip withSearchVectorson a server with a store;tests/test_auth_permissions.py's restore cell andtests/rust's backup suite run against the store;tests/test_backup_job.pygains the tamper, SIGTERM, store-outage, duplicate-key, key-range and multi-part-key cells on both the PostgreSQL and SQLite deployments (the multi-part cell enablesallow_multipart_table_keyson its own deployment and compares the restored item set with the source'sScan; the hand-built prefixes rewrite the two DynamoDB export manifests too, so they are whole backups) and, on SQLite, thebackup remove-targetcell: a finished restore, with one item written to the target since, is seeded at the catalog back into thetarget_not_emptystate (a RUNNING manifest record with no progress over a CREATING target that holds more items than the backup, the shape a catalog restored from a dump leaves), the restarted server's reconciler fails ittarget_not_emptyandDeleteTable,DeleteBackupand a second restore are refused, the command removes the target and its record with the documented output, and the restarted server answersResourceNotFoundExceptionfor the name,DeleteBackupsucceeds and a new restore runs;tests/test_mongodb_backup_restore.pygains the vector-manifest refusal, the 35-digit Decimal128 failure and the 38-digit intact cells, and its hand-built prefixes rewrite the two export manifests as the job suite's do;devtools/run-mongodb-testsserves a filesystem store and exports its root, its log andEXTENDDB_TEST_BACKUP_STORE, and its--backup-store noneserves no store for the legacy LSI fallback cell (the one wire cell of the in-catalog restore path on MongoDB, which no CI job runs; the test guide and the cell's docstring give the command); the runner starts from an empty store root, since a reused--outputdirectory'sextenddb-store.jsonnames the previous run's catalog. The PostgreSQL CI job starts moto, so the store-outage cell runs there as on the SQLite S3 job, whose workflow comments now say that cell is the one S3 restore the job runs. The matrix isdevtools/backup-restore-matrixand.github/workflows/matrix-nightly.yml, which also runs every restore kill point of every backend in fourrestore-kill-points-<backend>jobs (the round-two section): one target directory and four sequential builds, the artifact action pinned, one run at a time,requirements-matrix.txtforpymongoandcassandra-driver, anapt-get install postgresql-clientstep for the hostpsqlthe script's PostgreSQL seed and teardown call, and a step that checkspsql --versionand the two drivers' import before the cells; the SQLite Rust-integration job's comment inintegration.ymlsays the backup tests restore the store-backed backups (it had said they expect the restore's refusal); its duplicate-key prefix writer chmods its files0600ascopy_prefixdoes, and its docstring's exit codes are the script's (1 on an assertion failure or a setup error); the nightly'srust-cachestep saves only on main, as #680 made every other step, and its four actions are pinned to the commits main pins (#752). The job suite gains a SQLite purge cell (a stopped server's RUNNING store-backed restore record with progress:purge --confirmfails it and removes the target, and notaken over by this instanceline is logged), and the store-config suite aremove-targetcell that leaves no default root and no identity file; the PostgreSQL exit-code cell coversremove-target. The engine'sa_file_replaced_between_the_two_passes_fails_in_the_loadruns at a 1 s heartbeat: its hashing pass over both files runs on the test's one runtime thread before the first load's claim check, and under load it outlasted two 50 ms periods.Docs. The differences document (vector, multi-part-key, how-restore-runs with its timings labelled as the in-catalog restore's beside the store-backed figures, concurrent-restore lock with Cassandra's two-registration guarantee, restore-interruption resume with the
target_not_emptystate, its remedy and the measured first-reach outcomes, a restored table'sTableSizeBytes, a new Decimal128 row), the limits document (concurrent-restore spool and batch bound and Cassandra COUNT, backup contents), the usage guide and upgrade manual (the restore lands this release; migrations 1 to 7 breaking and 8 declared so on PostgreSQL for want of the additive-declaration job, not applicable on the other backends; the rollback paragraph;backup remove-targetfor thetarget_not_emptystate), the admin guide (the spool's contents and sweep, the restore's memory terms and measured figures with the shape and the input order they were measured on, the two-GSI and the unordered figures beside them,backup listlists no restore, thebackup remove-targetcommand with its output and refusals, the owner's liveness on the database's clock, and the[backup.store]paragraph no longer saying a store-backed restore is a later release's), the developer test guide and CONTRIBUTING (the job suite, the MongoDB cells' variables and the runner's--backup-store none, the moto cells' credentials, the matrix and the nightly),docs/design/15-backup-restore-bench-pr9.md(the restore memory and duration record, with the two-GSI and the store-backed 60,000-item figures) and design revision item 33 with the body sentences it corrects. With the rebase: the admin guide'sstale_job_after_secsrow (a dead store-backed restore is resumed) and purge paragraph (under the serve lock such a restore is failed and its target removed), the upgrade manual's SQLite startup sentence (a store-backed restore is resumed) and itsrestore_progresssentence (migration 8, declared breaking on PostgreSQL, not applicable on SQLite, MongoDB and Cassandra), design 4(e) and the reconciler's module doc (what a lost serving registration does, citingregistration.rs's module doc: the reconciler stops with the server's workers and spawns no resume once stopping, the running jobs go on until they end or the server stops; #755), CONTRIBUTING's worked example (migration 8, 7 beingrestore_progress), the PR 9a load paragraph (500 rows or 4 MiB), and the differences document's consistency row (the MongoDB snapshot open's wait).Why
#396 section 5.3 and the PR 9a and 9b sections. PR 9a and 9b ship as one PR by the maintainer's decision (item 30). Decisions, each in the module doc of the code it concerns and in item 30:
bulk_loadis aRestoreEnginemethod, not aStorageEngineone; progress is recorded insidebulk_loadin the write that applied the items, so there is norecord_progressand PostgreSQL's progress row (in the data database, no job record) is fenced by the base table's key and the claim's liveness rather than the generation;reapplyingmakes MongoDB's duplicate suppression explicit; the manifest registration locksbackup_index(the lockstart_backup_deletetakes) so a store-backed restore and aDeleteBackupof its backup order wholly;RESTORE_REGISTRATION_TIMEOUTis 60 s on every backend; a filesystem store is verified in place (twogetpasses), an S3 store spooled; the restore takeover refreshes the heartbeat where the backup takeover leaves it; the memory bounds are in bytes of text (the chunk in line bytes, the SQLite and Cassandra batch in serialized item bytes); the download retry bound is the job's; the matrix seed stands in forbackup sync --adopt-storeuntil PR 10.extenddb backup remove-targetships here rather than in PR 10 because thetarget_not_emptyrule, written for a catalog restored from a dump, has as its common trigger on MongoDB and Cassandra an ordinary crash before the first progress record (both record progress after the write), and without the command this release had no way to remove such a target. The spool sweep lists the spool before the records, as the progress sweep lists its rows first, so neither removes what a registration between its two reads created. Afinish_restoreerror is an unknown outcome re-read through the claim, as the status writes are, since on PostgreSQL the reachable case (the progress delete after the flip's commit) is a finished restore.Testing done
After the rebase onto the review rounds of #671, #672 and #700, on this head:
cargo clippy --all-targetswithextenddb-storage-cassandra/test-hooks,extenddb-storage-mongodb/test-hooks,extenddb-engine/backup-test-hooksunder-D warnings, clean;cargo test -p extenddb-engine --lib --features backup-test-hooks -- backup::262 passed (the cells adjusted to #671's order and text named above); theextenddb-core(797),extenddb-storage(233),extenddb-storage-sqlite(276, therestore_job_testsamong them),extenddb-server(307),extenddb-app(47) andextenddb-config(24) lib suites;backup_restore(56) andrestore_job(12) on PostgreSQL at127.0.0.1:55432;backup_engine::live_tests::withtest-hookson the MongoDB replica set at127.0.0.1:27117, 42 passed;direct_integration -- backup_engine::withtest-hookson a fresh Cassandra 4.1 node, 60 passed (test_the_catalog_listings_are_scoped_and_orderedfailed once on a keyspace the node had not agreed on yet, "Keyspace ... doesn't exist" inside the migration runner, and passed when rerun). The wire suites (tests/test_backup_job.py's restore cells among them), the kill points and the memory runs below are from the previous heads and were not repeated; CI runs the wire suites.The first bullets, down to the restore kill points' SIGTERM cells, are measured on this tree after the move onto
mainat e8ca7df, except the parts a bullet scopes to03baa321(the tree before the move: #700's head42c4f6d8plus this commit); the bullets after them are scoped "Before the rebase" and are the tree on #700's head before the stack's two rebases (09c881f4), where "new" is against that head.cargo fmt --all -- --checkclean;cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings, the same--features extenddb-engine/backup-test-hooks, and-p extenddb-storage-cassandraand-p extenddb-storage-mongodbwith--all-targets --features test-hooks, all clean;cargo +1.88.0 check --locked --workspace --all-targetsclean with no warning; on03baa321,cargo doc --no-depsof the engine, app and storage crates warned in none of this PR's files and the licence notices (default, MongoDB, Cassandra, dev) were current (the move changes noCargo.lockorCargo.toml: main changed neither between03baa321ande8ca7df3).cargo test --locked --workspace --exclude extenddb-storage-cassandra --exclude extenddb-storage-mongodb --exclude extenddb-backup-storewith PostgreSQL required: bin 3, app 47, auth 274, cache 27, config 23, core 796 (1 ignored), engine 389, server 305, storage 220, PostgreSQL lib 78 and every test binary green (backup_restore50,restore_job11, themigrations_*binaries,table_snapshot89 with 2 ignored among them), SQLite 267 (1 ignored) withrestore_job_resume1 andbackup_job_panic1.-p extenddb-backup-storewith moto: lib 37, conformance 123. MongoDB: lib 169 (62 ignored),interpreter_selftests18,migrations_fresh8,migrations_guards7,migrations_holders10,migrations_locks11,migrations_released5,pushdown_parity20,--lib -- --ignored --test-threads=1 live_62, and thetest-hooksfence test 1. Cassandra under the shared container's lock: lib 115,migrations22,migrations_stopped8,--features test-hooks --test direct_integration219 (three parts of 72, 74 and 73 tests named--exact; in the first part's first run two cells timed out on the node, one with "no answer within 60 s" on a keyspace's baseline file and one with "Connection timeout after 10s", and passed when rerun),ttl_integration26 passed, 1 ignored andtest_ttl_enable_rejects_asynchronous_gsifailing as it does onmain.not_applicable, its "later binary" rows move to id 8, its lock test counts eight steps and its released-catalog test eight pending; MongoDB's and Cassandra's registry tests list 7 as not applicable; Cassandra's backfill cell allows 7 pending beside 6.03baa321: the engine'sbackup::tests20 times undertaskset -c 0-1 nice -n 5beside one busy loop: 20 of 20 green (214 tests, 33 to 34 s each). Mutant:a_stopping_reconciler_takes_nothing_over(new) fails withmanifest_restores_resumed: ["MusicRestored"]when the guard on the reconciler's own token is disabled.main's head (release builds ofe8ca7df3) on SQLite and PostgreSQL:migrate --listshows 1 to 7 (7not applicableon SQLite,catalog, dataon PostgreSQL);serve,backup listandbackup remove-targeton the catalog behind exit 3 naming 6 and 7;migrate --yestakes it from 5 to 7 (PostgreSQL:restore_progressin the data database and 7 in both ledgers; SQLite: 7 recordednot_applicable);verifyHEALTHY;remove-targetof an unknown table exits 1, leaves nobackups/on SQLite and noextenddb backup%session on PostgreSQL;servestarts,extenddb stopreturns in 0.26 s, andmain'sserverefuses the migrated catalog with exit 4. On03baa321, on MongoDB and Cassandra (a replica set of its own; the shared node),list, the dry run andremove-targetleave noextenddb_serversentry,migrate --yesright after them does not wait, under a planted migration lock each exits 6 naming the holder, and on a catalogmigrate --forget 6put behind each exits 3 with themigrate --yesadvice.d27c9bb9first.test.yml:cargo test --locked --workspace --exclude extenddb-backup-storewith no service environment, as on its runner, 59 binaries and 3,264 tests passed (the Cassandra contact point pointed at a closed port, so the Cassandra suites found no node and returned as on the runner), the 1.88 row'scheckabove, and the store crate with moto.integration-mongodb.yml:mongodb-rust(devtools/run-mongodb-tests --rust-live -- --rust-integrationwithfilesystemon themongodb-test-hooksbuild): records 6, the migration binaries,tests/rust514 withrestore_worker_waits_for_base_copy_before_backfilling_indexesamong them on the store path, live 19;mongodb-pytest(-- --pytest --comprehensive --parallel): 1638 passed, 51 skipped, 8 xfailed, 4 xpassed, comprehensive 502,test_abac.pyandtest_rfc0003_concurrency.pyamong the passes;mongodb-catalog: the schema check,check-mongodb-catalog-lock29,-auth16,-released53,-older-binary28, each "all checks passed".integration.yml:run-integration'scheck-postgres-catalog-lock16 andcheck-postgres-older-binary26, and its backup suites step with moto (17 passed, 15 skipped);run-integration-sqlite: both lock checks, the older-binary check, the schema check,run-tests --pytest --comprehensive --parallel --filter "not import_export"(1666 passed, 24 skipped, 1 xfailed; comprehensive 502) and the by-name suites (26 passed, 6 skipped);run-rust-integration-sqlite'stests/rust514;run-integration-sqlite-s3: the S3 store on moto,--filter "backup"65 passed, 6 skipped, the by-name suites 28 passed, 4 skipped.integration-cassandra.yml:cassandra-storage(the Cassandra rows above),cassandra-rusttests/rust514,cassandra-catalog-checks(the schema check;check-cassandra-catalog-lock42 andcheck-cassandra-older-binary33 against v0.1.14 built from its tag, each on a node of its own),cassandra-two-nodesmigrations22.docs/schema/*.txtagainst a freshinit: SQLite 48 lines, PostgreSQL 416, Cassandra 320, identical; MongoDB's check passed. Not run:cassandra-pytestandcassandra-authorizer(this rebase changes no Cassandra request or auth path; the pytest leg is host-limited on this machine), the PostgreSQL pytest and Rust-integration jobs (their defaultextenddb_catalogexists on the shared PostgreSQL), and the nightly matrix.03baa321: the restore kill points' SIGTERM cell on PostgreSQL and SQLite (24,000 items,backup-test-hooksbuilds): the restore stopped by the server's shutdown inside feat(backup): CreateBackup job, worker and reconciler duties [backup/restore 8] #700's job drain, no FAILED written, 16 workers drained, the record RUNNING across the restart and the restore resumed to ACTIVE with the base table's and the GSI's counts exact.cargo fmt --all -- --checkclean;cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings, the same--features extenddb-engine/backup-test-hooks, and-p extenddb-storage-cassandra --features test-hooks --all-targetsall clean;cargo check --locked -p extenddb-storage --libwith and withouttest-hooksclean;cargo check --locked --workspace --all-targetsclean.extenddb-engine336 passed (62 new test attributes against 09c881f, one removed, 61 net; 7 of them added last: the maintenance-heldtarget_not_emptyguard, no takeover once the shutdown began, the loss raised inside a step named by the next check, the first beat refreshing the registration's heartbeat before the reply, the request stopping at the 60 s bound with the task going on, a resume that fails before it runs removing its spool entry, a spool document write failure removing the entry; the 28 before them: the slow registration, the stopped timer before each write and before a download attempt, the DONE write'sNotMatched, the lost-claim entry left,Pending, the job-wide retry bound, a decoder that dies, the same-host spool resume, a marker with another digest, a crash's spool states, a file replaced between the passes (the replacement matching the original in compressed length, uncompressed length and item count and failing on the sha256 alone), a file over one chunk by bytes, the feeder's claim check, an unhealthy or unchecked store with a known identity, SSE on both paths, a restore registered between the sweep's two reads, a failed DONE write re-read, the resume's first beat, a truncated spooled manifest, a resume whose store documents fail verification,Progress::from_recordson each backend's shape, a record gone after the takeover, a registration that fails after the request stopped waiting, the bounded document reads,remove_targetover a FAILED, a dead RUNNING, a live RUNNING, a DONE and a legacy record);extenddb-core728,extenddb-server246,extenddb-app44.extenddb-storage200.extenddb-storage-postgreswith the PG env: 353 passed over every binary before the last two cells were added, and thenrestore_job.rs10 passed (the takeover test asserting the refreshed heartbeat, the progress delete failing after the flip, the resumed file's upsert) with every other binary clean up to the slowtable_snapshot.rs, which the run's timeout cut short without a failure.extenddb-storage-sqlite: lib and tests 245 passed (restore_job_tests10,a_batch_closes_at_the_byte_bound_before_the_count_capnew, the flip test settingbackfillingbefore the flip).extenddb-storage-mongodb: unit 182 (the lib binary's 144 rerun after the last change; no MongoDB code changed in it),--lib -- --ignored live_43 in the earlier run (37 backuplive_cells,live_bulk_load_stops_at_a_lost_claim_between_two_insert_many_callsnew), and thetest-hooksfence testa_takeover_during_bulk_load_fences_the_file_index_write(--features test-hooks -- --ignored --test-threads=1) 1; the 43live_cells pass under the shared replica set with--test-threads=1: run in parallel, three legacy-path copy cells can time out waiting for a GSI-backfill worker within the 90 s deadline (an environment limit, not a logic defect).extenddb-storage-cassandra: lib 78,--features test-hooks --test direct_integration213 passed in the full run before the last three cells were added (tests/direct/backup_engine.rs48 tests, five new: theactive_restoresre-apply, the delete between the registration's pre-read and its fence, the composite-key resume with index rows and an empty file, the byte bound, the lost claim between two batches), and the three new cells and two changed ones ran twice and passed (tests/direct/backup_engine.rs: two concurrent registrations leaving one target, the re-applied batch keeping every index row over a composite GSI+LSI target;tests/direct/index.rs: an unchanged item's re-put keeping its GSI and LSI rows, the GSI worker keeping the row), the full binary not rerun end to end on the shared container;--test ttl_integration25 passed, 1 ignored,test_ttl_enable_rejects_asynchronous_gsithe known pre-existing failure andtest_backfill_cursor_resumepassing alone.finish_restore's error as a failure, the resume's instant at its entry, the spooled documents written in place, the fallback to the store removed, no metric on a refusedget, the unlogged registration failure, theCancelledarm recording a worker error, a zero duration, the read bound removed, the in-place pass not hashing again,from_recordsignoring the MongoDB and PostgreSQL shapes, the sidecar and ARN re-checks dropped, the gone record as an error, the compressed size as the table's, and four onremove_target: the DONE refusal skipped, every owner live, the claim dropped unreleased, a zero window not read as every owner dead), 21 across the storage trait and the four backends (five added last: the Cassandra index-row skip removed, the GSI worker bypassing the shared writer, PostgreSQL's progress delete propagated, the plain INSERT for a resumed file, the SQLite flip'sbackfillingclause dropped).--features postgres|sqlite|mongodb|cassandra,-p extenddb), the four hooks binaries (<backend>,extenddb-app/backup-test-hooks) and themongodb-test-hooksbuild built on the final tree;CARGO_TARGET_DIR=target/t188 cargo +1.88.0 check --locked --workspace --all-targetsclean.docs/schema/postgres.txtandsqlite.txtmatch a freshinitof the release builds byte for byte (no schema changed since);cargo test -p extenddb-storage-postgres --test doc_version_literalspasses with the record in the tree.tests/test_backup_restore_fidelity.py test_auth_permissions.py(the 24 restore cells and the vector round trip withSearchVectors) — SQLite 100 passed, PostgreSQL 100 passed, no skip (the multi-part-key cell moved to the job suite); andtests/test_backup_store_config.py test_backup_job.pyby name withEXTENDDB_BINARYand the moto variables on both builds — SQLite 25 passed, 3 skipped (the PostgreSQL-only store cells skipped;test_backup_job.py14 of 14, theremove-targetcell included), PostgreSQL 16 passed, 12 skipped (the SQLite-only store cells and the SQLite-onlyremove-targetcell skipped;test_backup_job.py13 of 14), the outage cell against moto on both. The multi-part-key cell runs on both (the restored item set equals the source'sScan; on SQLite the preview's item paths keep one item per first HASH key value in the source itself, so the set is 5 of the 60 items written). The MongoDB cells throughdevtools/run-mongodb-tests: the CI way (filesystem)tests/test_mongodb_backup_restore.py5 passed, 1 skipped (the legacy LSI fallback cell skipped), and with--backup-store none3 passed, 3 skipped (the legacy LSI fallback cell and the two throughput cells passed; the three store-backed cells skipped).docs/design/15-backup-restore-bench-pr9.md; the admin guide'smax_concurrent_jobsrow and the design's section 7 quote it): release binaries, filesystem store,VmHWMover idle from a fresh start, 24 items of 27.7 MB (the worst shape) restore with a peak of 848 MiB on SQLite and 778 MiB on PostgreSQL under the default allocator (848 and 1,013 MiB at 12 and 48 such items on SQLite) and 392 and 427 MiB underMALLOC_ARENA_MAX=1; 1,200 items of 350 KB in one data file 270 MiB on SQLite and 156 MiB on PostgreSQL; 1M items of 200 bytes under a composite key and no secondary index, the backup's lines in key order as the server's own backups are, 89 MiB on SQLite and 16 MiB on PostgreSQL (100k: 38 and 10.7 MiB), restored in 26.8 s and 9.5 s; the same 1M items as an unordered backup (every MongoDB-sourced backup and every multi-part-key table) 144 to 164 MiB on SQLite in 66 to 90 s (scattered inserts dirty the whole base b-tree; the restore's buffers are the same) and 16.4 MiB on PostgreSQL in 17.5 s; at 3M rows SQLite 109 to 116 MiB key-ordered and 171 to 174 MiB unordered, the finalCOUNT(*)'s share 22 to 28 MiB at both sizes, bounded by the counting connection'scache_size(32 MiB) rather than growing with the table, as the record had said from one measurement. The same 200-byte items with two full-projection GSIs, measured on the pushed head's and on the final binaries: SQLite 143, 225 and 143 MiB over idle at 100k in three runs (10.2 to 13.9 s to ACTIVE) and 165 and 172 MiB at 300k (51.7 and 51.9 s), PostgreSQL 29.3 and 22.9 MiB at 100k (9.9 and 6.2 s) and 30.8 and 33.5 MiB at 300k (19.7 and 21.4 s); the excess on SQLite is the backend's per-connection page cache for the index b-trees (cache_size, 32 MiB) plus the allocator's arenas, not the restore's buffers, and a smaller batch for indexed tables was measured ineffective (91.7 against 92.7 MiB) and not kept; every GSI'sScancount equalled the item count after ACTIVE. The 60,000-item shape the differences document's in-catalog timings were measured on, restored from the store on the final binaries: response 0.051 s (PostgreSQL), 0.016 s (SQLite), 0.055 s (MongoDB), ACTIVE after 0.67 s, 1.85 s and 3.74 s (the MongoDB job's DONE at 1.8 s, the backfill worker's flip after), 9.3, 38.3 and 30.1 MiB over idle; the differences document's in-catalog timings for that shape now carry their label. The record carries the terms the code bounds and the sizing sentence (about 450 MiB per running restore of large items under one arena, timesmax_concurrent_jobs).devtools/backup-restore-matrix, one invocation per cell, on the final binaries: the twelve plain cells (PostgreSQL, SQLite, MongoDB sources into all four targets) PASSED (items, description, GSI and LSI queries match the source; 10 to 44 s each, the Cassandra targets the slowest), with the seed's timestamps as the job writes them; the four--duplicate-keycells and the eight--vectorcells PASSED on the final binaries too (the--duplicate-keycells into Cassandra by the count alone,restored 12 items, the manifest lists 13; the--vectorcells into PostgreSQL and SQLite withSearchVectorsanswering, into MongoDB and Cassandra refused before any table). The nightly workflow has had no run:workflow_dispatchneeds the workflow file on the default branch, so it cannot be dispatched until this PR merges, and its first run (the schedule's, or a dispatch after the merge) is the proof of the runner image. The workflow installspostgresql-clientand checkspsql --versionandpython3 -c "import pymongo, cassandra"before the cells, since the matrix script seeds and tears down its PostgreSQL deployments through the host'spsql, which no other workflow uses.kill-points.py, posted with this PR; every ACTIVE outcome asserts the base table'sScancount and the GSIby-g'sScancount both equal the item count), each killed or signalled at the point, restarted and asserted, on the final hooks binaries where the row says so and on the pushed head's otherwise (nothing on those paths changed between the two):restore_after_downloadrestore_after_file_commitrestore_sqlite_mid_batch(24,000 items, one file)restore_postgres_before_progress_deleterestore_mongodb_after_insert_many, first reachtarget_not_empty, the target left CREATING (DeleteTableResourceInUseException);extenddb backup remove-target --account 123456789012 <table>printed the four lines and removed it; DescribeTableResourceNotFoundException, DeleteBackup allowedrestore_mongodb_after_insert_many, second reach (--second-reach, 24,000 items in three files)insert_many; resumed from file 1 underreapplying, ACTIVE 24,000, GSI 24,000restore_mongodb_before_worker_fliprestore_cassandra_before_progress, first reach (12,000 items, one file)target_not_empty, the target left CREATING;remove-targetremoved it, DescribeTableResourceNotFoundException, DeleteBackup allowedrestore_cassandra_before_progress, second reachrestore_between_failed_and_removal(a tampered file)--vector(a vector index on the source) gone with its data table and_ddb_vec_relation (rerun on the rebuilt binary; the pushed head's build left the relation)restore_fenced_outpg_terminate_backend(the job paused atrestore_after_file_commit)t, the last beat 1.1 s old); released 20 s later, the job stopped at its next check as a lost claim naming the terminated session, the record RUNNING with its(0, 8228)progress row; the same server's reconciler took it over 45.6 s after the termination (46.7 s after the last beat) and finished it: 24,000, GSI 24,000 (rerun on the rebuilt binary)sigtermEvery cell above ran on the pushed head's hooks binaries (4c35319); the cells marked "rerun on the rebuilt binary" ran again on the binaries built after the last corrections (the first beat before the reply, the Cassandra progress re-read and registration fence, the PostgreSQL vector-relation drop), the
pg_terminate_backendcell as reworked (the shipped cell slept 2 s, terminated and asserted the final count alone, which a 2.6 s restore passed without the case running). The Cassandra points ran against a freshly recreated single-node container withreplication_factor = 1(noGCInspector,StatusLoggeror "No nodes available" line in its log after the runs). Two MongoDB cells (restore_between_failed_and_removal,sigterm) failed once at setup withResourceNotFoundExceptionon the fixture's first write when a second deployment on the same replica set dropped the fixed databases under them, and passed when rerun alone.Review round (2026-11-04): changes addressing Lee's review
Measured on this stack's head after rebase onto #700's current head. Counts here are re-measured on this head; the counts above are carried from the pre-rebase runs unless noted.
Code changes:
insert_manyand its(file_index, items_applied)record now commit in one session transaction whosefind_one_and_updatefence requires the record RUNNING under the claim's generation, so a frozen owner writes nothing after a takeover, and a mid-file crash resumes fromitems_appliedto ACTIVE rather thantarget_not_empty.reapplyingis vestigial on MongoDB (a transaction cannot doordered=falseduplicate suppression).restore_job.ddl_done, set true only after the target's collections and index metadata exist; the reconciler's newrestore_target_readyrefuses and fails an unmarked record instead of resuming into a target missing its GSIs/LSIs.restore_heartbeat_atrefreshedIFRUNNING under the generation) before its upserts. Round two completes the fence: the batch's writes carry a timestamp read before that check (the round-two section).restore_target_ready, not retaken every stale window; the reconciler re-checks the maintenance claim afterclaim_restore_dead(Minor, 4468802609).OTHER_STORE. The threefail_restore_jobwrites are conditioned on theowner_generationandheartbeat_atthe listing read (feat(backup): catalog index, BackupCatalog, JobClaim, legacy reader [backup/restore 6] #671's signature), so a record whose owner heartbeated after the listing keeps its record.CREATINGdescription; a different backup into the same name isTableInUseException.ExtenddbManifest::reader, notreader_prehashed), so a file changed in the spool between runs is caught.remove-target(Minors): the command prints the documented terms "store-backed"/"in-catalog" (4468802662); on PostgreSQL it takes the target name's advisory lock for the removal via a command-onlyremove_restore_target_exclusive, so a frozen owner keeps its target (4468802600); the account-scopedfind, the maintenance-claim-lost pre-removal check, and the count-above-total path are pinned by new engine cells (4468802541, 4468802553, 4468802532).ItemCount/TableSizeBytes0 at ACTIVE (Minor, 4468802685).Tests run this round (re-measured on this head):
cargo test -p extenddb-engine backup::withextenddb-engine/backup-test-hooks: 241 passed (236 carried plus five new: the count-above-total cell, the account-scopedremove-targetcell, the claim-lostremove-targetcell, the dead-restore-with-no-tables cell, and the registration-retry cell).cargo test -p extenddb-storage-postgres --test restore_jobagainst PostgreSQL: 12 passed (11 carried plus the new sweep-reads-order cell).cargo test -p extenddb-storage-sqlite --test restore_job_resumeand--test backup_job_panic: 1 + 1 passed; SQLite--lib restore57 passed.--features test-hooks, replica set atmongodb://127.0.0.1:27117): the rewritten fence, lost-claim and resume cells pluslive_finish_restore_hands_over_and_the_worker_flips_active,live_bulk_load_refuses_a_long_sort_key_and_keeps_a_long_number,live_restore_returns_creating_and_the_detached_copy_finishes— all passed.!=→<(4468802532), theremove-targetaccount comparison (4468802541), theclaim.lost()pre-removal check (4468802553), therestore_target_readyguard (4468802481), the in-flight registration dedup (4468802521), PostgreSQL'ssweep_restore_progresstwo-read order (4468802562), and the reconciler's spool/record sweep order (4468802548).cargo fmt --allclean;cargo clippywith-D warningson the engine, storage, PostgreSQL, SQLite, MongoDB (test-hooks) and Cassandra (test-hooks) crates clean;cargo check --workspace --testsclean;shellcheckondevtools/run-mongodb-testsreports only the pre-existing SC2174 the MongoDB runner already carried.Not run this round (no environment / CI only): the Cassandra live suite (shared container; the fence is compile-checked with
test-hooks), the nightly matrix, and the newmatrix-nightly.ymlkill-points job (itsdevtools/backup-kill-points.pyis committed by #700 and arrives by rebase).CI wiring:
devtools/run-mongodb-testsgained atest-hooksstep running the restore fence cells..github/workflows/matrix-nightly.ymlgained arestore-kill-pointsjob that builds the SQLite binary withbackup-test-hooksand runs the four SQLite restore points throughdevtools/backup-kill-points.py(its call did not match the script's CLI; round two corrects it and adds the other backends' jobs).legacy_create_only(it cannot take a store-backed backup), so serving a filesystem store in its Rust job would not give store-backed-restore coverage; the nightly matrix (SQLite/PostgreSQL/MongoDB source → Cassandra target via adopt-store) is that coverage. See the reply.Review round two (2026-11-05): Lee's second review
Measured on this head (the round-one head plus these changes). Counts marked re-measured were counted on this head; the rest are carried from the sections above.
Code changes:
devtools/backup-kill-points.py --backend sqlite --point <p>, which the script's parser refuses (exit 2). The call is nowrestore <backend> <point> --bin target/release/extenddb --work kill-point-logs/<backend>-<point>; the jobs' exact calls were parsed locally (--help, and runs with a bogus--binthat get past the parser intoinit), and two SQLite points (restore_after_file_commit,restore_sqlite_mid_batch) ran end to end with the job's exact call on abackup-test-hooksrelease build: both resumed and ACTIVE with 12,000 items, base and GSI exact.restore-kill-points-{sqlite,postgres,mongodb,cassandra}, each building its backend's binary withextenddb-app/backup-test-hooksand starting its backend as the matrix job does (the PostgreSQL job installspostgresql-clientfor the script'spsql; the MongoDB job starts the replica set and installspymongo; the Cassandra job configuresPasswordAuthenticator, installscassandra-driver, and builds a plain SQLite binary as the--source-binwhose backup the Cassandra target restores). Each runs every point of the script'sRESTORE_POINTSfor its backend, plusrestore_mongodb_after_insert_many --second-reach(24,000 items),restore_cassandra_before_progress --second-reachandrestore_between_failed_and_removal --vectoron PostgreSQL; logs are uploaded on failure. The script's first-reach assertion for the two no-progress points changed fromtarget_not_emptyto ACTIVE with the exact count (MongoDB since round one, Cassandra since this round), and its--remove-targetflag is gone (thetarget_not_emptystate is seeded bytests/test_backup_job.py's cell).a_count_that_fails_three_times_leaves_the_record_running: the fake's count fails three times, the job stops with the record RUNNING at(file 1, 2 items), no FAILED or DONE write, the loaded target kept,RestoreJobs{failed}recorded. Mutant: with the first count error failing the restore at once, the cell fails (count_calls1, not 3); passes after the revert.a_claim_taken_during_the_cycle_leaves_the_taken_over_record_unresumed: the fake's newtakeover_hooksets the claim held insideclaim_restore_dead, after the cycle's first read; the record is taken over (new generation) but not resumed (nobulk_load, RUNNING). Mutant: with the second read replaced by a constant "not held", the cell fails (manifest_restores_resumed: ["MusicRestored"]); passes after the revert.put_restored_item_atwrites each item's base row and index rows in one logged batchUSING TIMESTAMPa reading taken before the batch'sconfirm_restore_claim; a stalled writer's batch lands below the successor's writes and deletes.test_a_batch_landed_after_a_takeover_loses_to_the_successors_writes_and_deletes(direct_integration,test-hooks, the newpause_after_restore_claim_checkhook): the owner paused after its check, the record taken over, the successor loads, flips ACTIVE and deletes two items (GSI and LSI rows gone), the owner released: its progress write reports the lost claim, the two items stay deleted with their index rows gone, the survivors carry the successor's versions. Mutant: with the timestamp read after the pause (when the writes land), the cell fails (4 items, not 2: the deleted items resurrected); passes after the revert.item_count(exceeds_backup_total): above ittarget_not_empty(the dump-restored catalog), at or below it the resume re-applies the first file withreapplyingset (the resume already set it for the first file it loads). Engine cella_half_applied_first_file_with_no_progress_is_resumed_with_upserts(one of two items landed, no progress: resumed,reapplyingtrue, DONE with 2); storage celltest_a_first_batch_applied_without_progress_resumes_to_the_exact_count(3 of 7 landed with no progress through two lost progress writes, resumed to 7 with 4 GSI and 3 LSI rows). Thetarget_not_emptycells now seed three items against a backup of two. The differences row, design 5.3 step 7 and the PR 9a load paragraph, the admin guide and the kill-point description are updated.ReconcilerState.restore_takeoverscounts this instance's takeovers per table id; the sixth (RESTORE_TAKEOVERS= 5) fails the recordowner dead: taken over 5 times by one instance without the restore endingand removes the target as forOWNER_DEAD. In-process rather than a column: atakeoverscolumn would touch every backend's record and PostgreSQL's catalogtable_restores(not this PR's migration 8, a data-database table), and the count per instance is what the thread allowed; stated in the module doc, the design and the differences row (a restart starts the count over). Cellthe_takeover_after_the_fifth_fails_a_record_that_never_ends: the count fails on every attempt, five cycles take the record over and leave it RUNNING, the sixth fails and removes it with the reason in the FAILED write. Mutant: with the bound check disabled, the sixth cycle resumes again and the cell fails; passes after the revert.claim_restoredoc (thread): no longer "in one transaction": the catalog rows commit first, the data tables (MongoDB: collections and index metadata) after, in the orderrestore_target_ready's doc gives, and the resume checksrestore_target_ready.test_a_running_store_backed_restore_keeps_its_backup_in_use_until_active(direct_integration): with a store-backed restore RUNNING,backup_in_usenames the target and a secondclaim_restoreisBackupInUsewith the service's text, twice; with theactive_restoresmap entry deleted (the registration's window before the entry lands) the target rows alone keep it in use; afterfinish_restorethe backup is free. The engine's order (the in-use rule before the override validation) is the engine's cells'; this cell is the Cassandra input to it.wait_for_items_seendeadline: 120 s, with the reason in the comment (the decoder's hashing pass and first chunk on the test's one runtime thread under a loaded CI machine).(Lee 4468...)citations in the diff replaced with descriptions in the code's terms (engine tests and reconciler, Cassandrabackup_catalog.rsandbackup_engine.rs, PostgreSQLbackup_catalog.rsandtests/restore_job.rs);grep 4468over the tree finds none.remove-targetre-reads the record after the backend refuses the removal; an unchanged record means the table is still its CREATING target and the refusal was the lock, reported as "the restore into T (account A) from is a CREATING restore target (table id ) whose owner's lock is still held: its owner's session is alive though its heartbeat is stale; wait for the owner to stop, or stop it, and run the command again"; a changed or gone record is "not a restore target: it is no longer a CREATING restore target with table id ".remove_target_reports_a_held_owner_lock_as_its_own_case(the fake'sowner_lock_held), the legacy cell's text adjusted; the admin guide lists the line.restore_target_readychecks each GSI's and LSI's table insystem_schema.tablesbeside the data table;test_restore_target_ready_requires_every_index_tabledrops the GSI's table and seesfalse.RestoreTableFromBackupruns" row says the in-flight list is per server process, so a retry waits only when it reaches the server that holds the registration, and what a retry on another server sees.tests/test_backup_job.py'sremove-targetcell expected "its manifest restore from", where the command prints "its store-backed restore from" since round one; the cell and the admin guide's sample output now carry the printed text (the cell ran green on the hooks build).Tests run this round (re-measured on this head):
cargo test -p extenddb-engine --lib --features backup-test-hooks -- backup::267 passed (--listcount 267; the five new cells above plus the held-lock cell);cargo test -p extenddb-storage-cassandra --features test-hooks --test direct_integration -- backup_engine::on the shared Cassandra 4.1 node 64 passed (60 carried plus the four new cells; the fence cell run alone three more times, once with the mutant);cargo test -p extenddb-storage-postgres --test restore_jobagainst PostgreSQL at127.0.0.1:5543212 passed;tests/test_backup_job.py -k remove_target_removes_a_target_not_emptyon the SQLitebackup-test-hooksrelease build 1 passed;devtools/backup-kill-points.py restore sqlite restore_after_file_commitandrestore_sqlite_mid_batchwith the job's exact call on that build, both ACTIVE with 12,000 items, base and GSI exact;cargo check --workspace --testsclean;cargo fmt --all -- --checkclean;cargo +1.97.0 clippy --all-targets -- -D warningson the engine, storage, Cassandra (test-hooks) and PostgreSQL crates withextenddb-engine/backup-test-hooks, and on the Cassandra crate withouttest-hooks, clean;shellcheckon the four jobs' run steps clean. Mutants verified (mutate, run the one cell, revert): the count error failing at once, the second maintenance read skipped, the takeover bound disabled, the Cassandra batch timestamp read after the check. Not run: the MongoDB live suite (no MongoDB code changed), the nightly workflow (it needs the workflow file on the default branch), and the PostgreSQL, MongoDB and Cassandra kill-point jobs' points (their calls were parsed locally; the SQLite points ran).Review round three (2026-11-05): Lee's third review
Three inline Minors, the review body's request to bound the restore handle's in-flight clause with #700's, and one CI change.
ClaimCheckcarriesparks(JobClaim::heartbeat_parks_before_its_bound, feat(backup): CreateBackup job, worker and reconciler duties [backup/restore 8] #700's new provided method) and passes it toin_flight_confirms, so the restore handle's check is feat(backup): CreateBackup job, worker and reconciler duties [backup/restore 8] #700's: a beat in flight confirms the claim for as long as it waits on SQLite, where the beat parks on this process's lock, and within one period of its start on PostgreSQL, MongoDB and Cassandra. feat(backup): CreateBackup job, worker and reconciler duties [backup/restore 8] #700's cella_beat_in_flight_past_its_bound_confirms_the_claim_only_where_the_beat_parksruns on this head too.fetch_update(Minor):count_errorsin the engine tests' fake is a compare-exchange loop, ascrates/storage-cassandra/src/migrations/backend.rsdoes, with the reason (try_updateis not on 1.88) in its comment.restore_engine.rs's module doc names the stamp's clock (the ExtendDB host's,data::version::now_micros) and the write times it must lose to (lightweight transactions and the deletes through the ACTIVE table, stamped by the Cassandra coordinator), so the rule needs the ExtendDB hosts' and the Cassandra nodes' clocks to agree.crates/storage-cassandra/tests/direct/backup_engine.rsname the cases their cells cover (the manifest total,remove-target's scope and lost claim, a target without tables, the registration retry; the count retry, the maintenance claim taken mid-cycle, the takeover bound, the first file resumed with upserts; the batch's timestamp fence, the first batch resumed without progress, the readiness check's index tables, the in-use rule of a running restore) instead of the review round they came from.run-integrationandrun-integration-sqlitewere cancelled at their 40-minutetimeout-minutesafter the backup store and job suites step, which this stack adds to the end of each job; both are now 55 minutes, with the reason in a comment. The other jobs keep 40.Tests run this round on this head:
cargo test -p extenddb-engine --lib --features backup-test-hooks -- backup::272 passed;extenddb-storage-sqlitewrite_lock_tests15;cargo +1.97.0 clippy --all-targetswith the Cassandra and MongoDBtest-hooksand the engine'sbackup-test-hooksfeatures,-D warnings, clean;cargo check --workspace --testsclean.Review round four (2026-11-06): Lee's notes with the approvals of #700 and #740
None was a condition of either approval; all are fixed here.
restore_progressis id 8 in both, with fix(storage-postgres): number stream records after their writes commit #819'sstream_pendingat 7.every_body_is_code_and_three_four_seven_and_eight_do_not_apply. Its list now holdsStreamPending(7) as well asRestoreProgress(8).restore_engine.rs:16): the batch reads the ExtendDB host's clock for its write timestamp, not the coordinator's. The module doc's first sentence on it now says so, as the later sentence on the clocks did.run-integration), and 40 min 31 s with 4 min 16 s (run-integration-sqlite).Tests run this round on this head: engine
backup::withbackup-test-hooks273 passed; storage-sqlite lib 281; storage-mongodbmigrations::tests5; storage-cassandra lib 129 anddirect_integrationbackup_engine::64; fmt and clippy 1.97 clean with the three test-hooks features.Not changed here
PR 10's
backup sync --adopt-store,--move-store,--forget-backupandmigrate-legacy; PR 11's CI finalization and documentation, with the MongoDB and Cassandra 1M restores and the vector cell still to be measured; PR 13; PR 14's drop of the in-catalog format. The matrix seeds the target'sbackup_indexrow by hand in place ofbackup sync --adopt-store. MongoDB'sinsert_manyunit stays 1,000 documents with no byte bound of its own (the data file bounds it:bulk_loadis per file). SQLite's restore memory for a table with indexes stays SQLite's page cache (cache_size) above the no-index figure; a smaller batch for indexed tables was measured ineffective and not kept.Breaking changes
kind = "disabled"is no longer needed to keep a deployment able to restore the backups it takes. Migration 8 addsrestore_progressto the PostgreSQL data database: runextenddb migrate --yesbefore starting the new binary. An S3 store needsspool_pathon PostgreSQL, MongoDB and Cassandra for restores. The restore of a store-backed backup is asynchronous on every backend with a 60 s registration bound, as on the service.DCO
Signed-off-by: Scott Robinson robinnsc@amazon.com