fix(backup): restore sort keys, indexes, and throughput; bound memory; recover abandoned restores - #384
Open
robinnsc wants to merge 3 commits into
Open
fix(backup): restore sort keys, indexes, and throughput; bound memory; recover abandoned restores#384robinnsc wants to merge 3 commits into
robinnsc wants to merge 3 commits into
Conversation
CreateBackup decided whether a table had a sort key by looking for a column named `sk`. Data tables name their sort key columns by type (`sk_s`, `sk_n`, `sk_b`), so the check was false for every table with a sort key: the backup recorded no sort key, and RestoreTableFromBackup then failed its first insert on the NOT NULL sort key column. The failure came after the target had been created CREATING with no scheduled transition, so the target stayed CREATING forever and its name could not be reused. `item_data` holds the whole item, key attributes included, so neither side needs the physical key columns: - CreateBackup records `pk` and `item_data` only. - RestoreTableFromBackup derives every key column from the item (all HASH parts into `pk`, each RANGE part into its typed column) and writes it with a plain INSERT inside one data transaction. Two backup rows for one key fail the restore instead of collapsing into one item. - The restored table reports its item count and size when it turns ACTIVE, instead of 0 bytes until the size worker runs. - The flip to ACTIVE happens only from CREATING, so a DeleteTable issued while the copy runs is not undone. - If the copy fails, the target is removed by table id, synchronously, whatever the control-plane delay, and the error is returned. A failed restore leaves no CREATING or DELETING table and frees the name. Backups written by earlier binaries already carry the full item in `item_data`, so they restore correctly with this change; no migration is needed. Tests: - tests/test_backup_restore_fidelity.py round-trips a hash-only table and S, N, and B sort-key tables and compares key schema, attribute definitions, and every item attribute for attribute, by Scan and by point read. It waits for the backup to be AVAILABLE so it also holds against the service. On main the three composite-key cases fail on PostgreSQL; all four pass on PostgreSQL and SQLite with this change. - crates/storage-postgres/tests/backup_restore.rs: a backup in the pre-fix row shape restores with its sort keys and size; a restore that fails mid-copy removes its target under a non-zero control-plane delay; a backup with two rows for one key fails; a two-HASH, two-RANGE key backup restores every key part. Added to the PostgreSQL storage-level CI step. Readiness assessment P0-2 (sort-key defect). GSI and LSI restore follow separately. Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc
requested review from
LeeroyHannigan,
amrith,
c33howard,
jcshepherd,
pdf-amzn and
yesyayen
as code owners
October 5, 2026 02:45
…andoned restores Follows the sort-key fix. Restores on PostgreSQL and SQLite now reproduce what the service reproduces, and the copy is bounded and crash-safe. Restored, from a new per-backup table definition: - global and local secondary indexes, with their key schemas, projections, and GSI throughput, filled during the copy; - billing mode and provisioned throughput (was always 5/5); - table class, SSE specification, on-demand throughput. Not restored, as on the service: streams, TTL, tags, deletion protection. Catalog 0.0.4. PostgreSQL migration 003 and the SQLite schema add `backup_definitions`, one row per backup holding that definition as JSON in the wire's shape behind a version marker (`extenddb_storage::backup_definition`). Backups taken before the upgrade have no row and restore as before: keys and items, no secondary indexes, 5/5 for a provisioned table. The migration runner now writes the compiled catalog version after every walk, as #335 also does, so a replayed earlier migration cannot leave the version behind the schema. Consistency: - PostgreSQL CreateBackup holds the source table row FOR SHARE from its first catalog read until its data snapshot is taken, and locks the source data table before releasing it. UpdateTable and DeleteTable take the row FOR UPDATE, so the definition recorded is the one in force when the items were read. A GSI whose catalog row exists but whose data table is not yet built is left out, with any attribute definition only it used. - SQLite CreateBackup reads the table, its definition, and its items in one write transaction. - DeleteBackup removes a backup's rows and marks it DELETED in one transaction; a restore reads from a snapshot (PostgreSQL) or re-checks under the write lock per batch (SQLite), so it copies all of a backup or fails with BackupNotFoundException. - DeleteTable on a restore target returns ResourceInUseException, as the service does for a table it is still creating, instead of deleting it out from under the copy. Memory: items are streamed and buffered up to 500 items or 4 MiB of stored JSON, whichever comes first, on both sides of both backends. Measured on PostgreSQL: 100,000 items of 4 KB (about 400 MB), peak server RSS 87 MB during the backup and 109 MB during the restore; 150 items of about 2.9 MB JSON each (435 MB), 147 MB and 168 MB with MALLOC_ARENA_MAX=1 (glibc's per-thread arenas otherwise retain freed memory and RSS reads higher). Concurrency: SQLite restores commit per batch, so other writers wait for a batch rather than the whole restore (PutItem p50 137 ms, max 276 ms during a 60,000-item restore). SQLite backups still hold the write lock for their duration, as they must to be consistent. PostgreSQL allows four concurrent restores per process; a fifth waits up to 30 s and is then refused with LimitExceededException. Failure and crash: - A failed copy removes the target by table id, synchronously: it is claimed CREATING -> DELETING in one statement (so removal and the flip to ACTIVE are mutually exclusive), its data and index tables are dropped, and its row deleted. If a drop fails the row stays DELETING and the control plane finishes the job. - PostgreSQL: a restore holds a session advisory lock keyed by account and target name from before the target is created until the copy ends, on a dedicated connection with idle_session_timeout disabled. The control-plane pass removes targets that are CREATING with no scheduled transition, older than 60 s, and whose lock is free. Verified with kill -9 mid-restore: the target is removed about 60 s later and the name can be restored into again. - SQLite: the same targets are removed at startup, before serving; this assumes one server process per database file, which the backend's in-process write lock already requires. Verified with kill -9. Refused rather than restored wrongly: backups of tables with vector indexes (as before), backups of multi-part-key tables (a preview the item paths address only by their first parts), and definitions with an unknown version. RestoreTableFromBackup now validates TargetTableName. Tests: - tests/test_backup_restore_fidelity.py: hash-only tables with S, N, and B keys; composite keys with S, N, and B sort keys; a provisioned table with four GSIs (ALL, INCLUDE, KEYS_ONLY; S and N hash keys, N and B range keys; sparse) and an LSI, comparing definitions, throughput, IndexStatus, every index's contents, projected attribute sets against the projection definitions, and ordered ranged index reads in both directions. Waits for backups and restores as the service needs. 7/7 on PostgreSQL and SQLite; the index test fails on the previous commit. - crates/storage-postgres/tests/backup_restore.rs (14): legacy row shape, pre-0.0.4 backup after replaying migration 003, failed-copy cleanup under a non-zero control-plane delay, duplicate rows, multi-part refusal, indexes and throughput, table class/SSE/on-demand, >1,000 rows with oversized items, the abandoned-restore sweep (grace, held lock, unowned), a name whose lock is held, the backup barrier against an uncommitted UpdateTable (fails on the previous approach), a half-built GSI, and DeleteTable during a restore. Run one at a time, and scratch databases are dropped even when a test panics. - crates/storage-sqlite/src/backup.rs restore_tests (9): indexes and throughput, the startup sweep, failed-copy cleanup, multi-part refusal, table class/SSE/on-demand, 730 rows across batches with physical row and index-row equality against the write path, a database with the exact 0.0.3 schema (testdata/schema_0_0_3.sql) holding a 0.0.3-format backup, byte-bounded batch cuts, and DeleteTable during a restore. - tests/test_cli_vector_catalog_migration.py covers 0.0.2 -> 0.0.4 and the replay case. Not changed here: RestoreSummary appears only in the RestoreTableFromBackup response and not in later DescribeTable output (pre-existing; needs a persisted column). DeleteBackup during a restore fails the restore with BackupNotFoundException rather than refusing with BackupInUseException. Vector index restore is still refused. Readiness assessment P0-2. Signed-off-by: Scott Robinson <robinnsc@amazon.com>
| } | ||
|
|
||
| async fn count(engine: &SqliteEngine, table: &str) -> i64 { | ||
| sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}")) |
…ackup during a restore RestoreSummary was only fabricated on the RestoreTableFromBackup response; DescribeTable never reported it. And DeleteBackup during a restore deleted the backup and failed the restore, where the service refuses with BackupInUseException. Catalog 0.0.4 (same migration as the previous commit; nothing extra to migrate) adds `table_restores`: one row per restored table with the source backup ARN and the restore time. Not a foreign key to `backups`, because the summary keeps naming a backup that is deleted later, as on the service. - PostgreSQL and SQLite DescribeTable report RestoreSummary for a restored table, RestoreInProgress true while it is CREATING and false once ACTIVE. The restore response and later DescribeTable calls carry the same RestoreDateTime. Tables restored before the upgrade have no row and no summary. - DeleteBackup returns BackupInUseException (HTTP 400) while a table still CREATING names the backup. On PostgreSQL the backup row is locked FOR UPDATE by DeleteBackup and FOR SHARE by the restore while it records itself; on SQLite both run under the write lock. Once the restore is done the backup can be deleted. - New DynamoDbError::BackupInUseException and StorageError::BackupInUse. Tests: restore_summary_and_backup_in_use on both backends (summary absent on an ordinary table, in progress while CREATING, done when ACTIVE, delete refused then allowed, summary kept after the backup is deleted), and the wire fidelity test checks RestoreSummary on the response and on DescribeTable. By hand, DeleteBackup during a 60,000-item (SQLite) and 100,000-item (PostgreSQL) restore returned BackupInUseException 400 and the restore completed. Signed-off-by: Scott Robinson <robinnsc@amazon.com>
8 tasks done
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
PostgreSQL and SQLite backup and restore now reproduce the table that was backed up, with bounded memory and crash recovery. Three commits:
1.
fix(postgres): back up and restore tables with a sort key(unchanged from the first version of this PR)create_backupandrestore_table_from_backupchecked for a column namedsk, which data tables do not have (they usesk_s,sk_n,sk_b). Backups of every table with a sort key lost their sort keys, and restoring one left the target in CREATING for good. Backups now storepkanditem_dataonly; restore derives every key column from the item and writes it with a plain INSERT. Backups written by earlier binaries carry the whole item initem_dataand restore correctly.2.
fix(backup): restore indexes and throughput, bound memory, recover abandoned restoresWhat a restore now carries across, from a new per-backup table definition:
Streams, TTL, tags, and deletion protection are not restored, as on the service.
Catalog 0.0.4:
crates/storage-postgres/migrations/003_backup_definitions.sqland the SQLite schema addbackup_definitions, one row per backup. It holds the definition as JSON in the wire's shape behind a version marker; seecrates/storage/src/backup_definition.rs.SET_CATALOG_VERSION_SQL), the same change fix(backup): report point-in-time recovery as unsupported #335 makes. Without it, a replayed earlier migration can leave the stored version behind the schema.Consistency:
FOR SHAREfrom its first catalog read until its data snapshot is taken. It locks the source data tableACCESS SHAREbefore releasing the row.FOR UPDATE, so the definition recorded is the one in force when the items were read.BackupNotFoundException.ResourceInUseException, as the service does for a table it is still creating.Memory and concurrency:
LimitExceededException.Failure and crash:
idle_session_timeoutdisabled. The control-plane pass removes targets that are CREATING with no scheduled transition, are older than 60 s, and whose lock is free.3.
fix(backup): report RestoreSummary from DescribeTable, refuse DeleteBackup during a restoretable_restores: one row per restored table, with the source backup ARN and the restore time. It is not a foreign key tobackups, so the summary keeps naming a backup that is deleted later, as on the service.RestoreSummaryfor a restored table.RestoreInProgressis true while the table is CREATING and false once it is ACTIVE. The restore response and later DescribeTable calls carry the sameRestoreDateTime.BackupInUseException(HTTP 400) while a restore from that backup is still running, and succeeds once the restore is done.FOR UPDATEand the restore holds itFOR SHAREwhile it records itself.DynamoDbError::BackupInUseExceptionandStorageError::BackupInUse.Refused rather than restored wrongly:
enable_multipart_keysthat the item read and write paths address only by their first HASH and RANGE attribute;RestoreTableFromBackup now validates
TargetTableName.Docs: the upgrade manual has a 0.0.4 section, and the version literals in the install and quickstart guides are updated.
Why
No issue filed. This is the 1.0 readiness review's P0-2, which found:
The backup-store design in #348 replaces this path. Until it lands, this is the path users have.
Testing done
tests/test_backup_restore_fidelity.py(7 tests, any backend):IndexStatus, and every index's contents;RestoreSummaryis checked on the restore response and on the later DescribeTable.crates/storage-postgres/tests/backup_restore.rs(15 tests, added to the PostgreSQL storage-level CI step):crates/storage-sqlite/src/backup.rsrestore_tests(10):testdata/schema_0_0_3.sql) holding a backup in the 0.0.3 row format.By hand, against servers built from this branch:
kill -9during a 100,000-item restore:MALLOC_ARENA_MAX=1; glibc's per-thread arenas otherwise retain freed memory and RSS reads higher.BackupInUseException400, and the restore completed.extenddb migrate(0.0.3 → 0.0.4) and its backups restored.Full pytest (
tests/, import/export excluded as in CI) and the comprehensive suite (tests/python), on PostgreSQL and SQLite, against this branch and main:TestGSIOnHashOnlyBaseTablepagination tests fail intermittently under parallel load on both builds and pass alone.devtools/run-tests, so the CLI lifecycle and GSI-queue suites errored the same way on both builds. The CLI migration suites pass when run with a PostgreSQL connection string (7/7).Review: five rounds of independent adversarial review, two reviewers each (one per backend). Every round's findings were fixed or are listed below.
Not changed here:
ResourceNotFoundException; the claim and the activation cannot both succeed, so no wrong table results.extenddb servetake an exclusive lock on the file so a second server cannot start.Checklist
cargo test --workspace)cargo fmt --check)cargo clippy -- -W clippy::pedantic)Storagetrait, auth model, on-diskformat, or public CLI surface, an RFC has been accepted or is linked
below. Otherwise, an ADR captures the decision (link below).
ADR / RFC: #348 (backup and restore design) describes the target this moves toward. The on-disk change is one new catalog table; there are no wire, trait, auth, or CLI changes.
Breaking changes
extenddb migrate; the server refuses to start against 0.0.3. fix(backup): report point-in-time recovery as unsupported #335 also claims 0.0.4 and migration 003, so whichever of the two merges second must renumber to 0.0.5 and 004.ResourceInUseException.BackupInUseException.By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.