Repository navigation
feat(backup-store): store trait with filesystem and S3 implementations [backup/restore 2/4] - #393
Conversation
954b1b3 to
97512f1
Compare
…t code Revision of docs/design/15-backup-restore.md after reviewing the draft against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read, compression, and bulk-load paths on PostgreSQL 16 and SQLite with a 1M-item fixture, and two review rounds against the companion PRs #393, #394 and #395. The revision log at the end of the document lists each change; the ones that alter behavior: - SnapshotEngine returns the table definition together with the items from one fenced operation (TableSnapshot). The items-only signature in the draft allowed a GSI change or DeleteTable between the definition read and the data snapshot. Indexes are reported in whatever build state they had and only an index being deleted is dropped; an ACTIVE-only filter would have left a GSI mid-backfill out of the backup for good. - The snapshot is opened in the CreateBackup request so the backup represents the request time, as DynamoDB documents; backup_creation_date_time is the instant the snapshot pinned. - Per-job claims with fencing replace the single-instance lease the draft assumed; PostgreSQL and MongoDB have no such lease. The claim is an advisory lock on a dedicated catalog connection on PostgreSQL (the seed #384's restore lock already uses), the row itself on MongoDB, an in-process slot on SQLite; no row ever exists unowned. Heartbeats are a timer, staleness is measured on the database's clock, and a failed heartbeat aborts the job. The reconciler resolves a dead CREATING row at once under a fresh generation conditioned on status = CREATING and deletes its prefix only after the stale window. - DeleteBackup tombstones the row as DELETING, deletes the manifest first, and marks DELETED only when the prefix is empty; backup sync is scoped to the configured store and never adopts a CREATING, DELETING, DELETED or FAILED prefix. Failed creations and deleted backups are BackupNotFound, as on the service, and share one retention. - Restore returns CREATING from the request (table and restore_jobs row created atomically; ResourceInUseException on a name race, LimitExceededException over the job limit), spools and verifies every data file before writing any item, and records progress in a way a crash cannot split from the data: a data-database row per file on PostgreSQL (data migration 005 reserved), the same transaction per 10k-item batch on SQLite, idempotent re-application of one file on MongoDB followed by a document-count check against the manifest. Duplicate detection for unordered backups is the target's primary key, not an in-memory key set. The ACTIVE flip is conditional; DeleteTable on a restore target fails the job. - The manifest self-checksum is replaced by a sidecar over the stored bytes; the draft's canonical form depended on serde_json formatting. backup_arn is the backup's identity and source_table must agree with it. N key values are normalized before key text or key order is computed; the SQL ORDER BY recipe includes COLLATE "C" on PostgreSQL. - Index population on restore stays per backend via bulk_load; the shared restore-backfill worker the draft described exists only on MongoDB. - Format additions: item_order, per-file key_range, md5 and uncompressed_bytes, explicit nulls, three-state vector_indexes of creation specs, optional etag, dataFileS3Key as the full object key, partition-aware BackupArn as the single ARN implementation. - Filesystem store: owner-only root re-checked by every operation, owner-only rule on every descended directory including list and delete_prefix, ancestor ownership rule, objects 0600 and directories 0700 at creation, put returns object metadata; the consequence for backup pickup agents is stated. The S3 conformance suite is mandatory in CI against moto (MinIO no longer publishes images). - ListBackups paging and filtering, the restore override contract, sqlite-memory behavior, extenddb destroy leaving the store intact, a restore spool outside the store root, max_concurrent_jobs, heartbeat_interval_secs and a concrete pool-size rule are specified. - Phase 1 is rewritten as the PR series with acceptance criteria and GitHub numbers. Migration numbers are reserved up front so the PITR PR is not on the critical path. Co-authored-by: Lee Hannigan <lhnng@amazon.com>
97512f1 to
3fa24dd
Compare
…t code Revision of docs/design/15-backup-restore.md after reviewing the draft against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read, compression, and bulk-load paths on PostgreSQL 16 and SQLite with a 1M-item fixture, two review rounds against the companion PRs #393, #394 and #395, and a reconciliation against main 744ea29 after #384 merged in its final form and the Cassandra backend landed. The revision log at the end of the document lists each change; the ones that alter behavior: - SnapshotEngine returns the table definition together with the items from one fenced operation (TableSnapshot). The items-only signature in the draft allowed a GSI change or DeleteTable between the definition read and the data snapshot. Indexes are reported in whatever build state they had and only an index being deleted is dropped; an ACTIVE-only filter would have left a GSI mid-backfill out of the backup for good. - The snapshot is opened in the CreateBackup request so the backup represents the request time, as DynamoDB documents; backup_creation_date_time is the instant the snapshot pinned. - Per-job claims with fencing replace the single-instance lease the draft assumed; PostgreSQL and MongoDB have no such lease. The claim is an advisory lock on a dedicated catalog connection on PostgreSQL (the seed #384's restore lock already uses), the row itself on MongoDB, an in-process slot on SQLite; no row ever exists unowned. Heartbeats are a timer, staleness is measured on the database's clock, and a failed heartbeat aborts the job. The reconciler resolves a dead CREATING row at once under a fresh generation conditioned on status = CREATING and deletes its prefix only after the stale window. - DeleteBackup tombstones the row as DELETING, deletes the manifest first, and marks DELETED only when the prefix is empty; backup sync is scoped to the configured store and never adopts a CREATING, DELETING, DELETED or FAILED prefix. Failed creations and deleted backups are BackupNotFound, as on the service, and share one retention. - Restore returns CREATING from the request (table and restore_jobs row created atomically; ResourceInUseException on a name race, LimitExceededException over the job limit), spools and verifies every data file before writing any item, and records progress in a way a crash cannot split from the data: a data-database row per file on PostgreSQL (data migration 005 reserved), the same transaction per 10k-item batch on SQLite, idempotent re-application of one file on MongoDB followed by a document-count check against the manifest. Duplicate detection for unordered backups is the target's primary key, not an in-memory key set. The ACTIVE flip is conditional; DeleteTable on a restore target fails the job. - The manifest self-checksum is replaced by a sidecar over the stored bytes; the draft's canonical form depended on serde_json formatting. backup_arn is the backup's identity and source_table must agree with it. N key values are normalized before key text or key order is computed; the SQL ORDER BY recipe includes COLLATE "C" on PostgreSQL. - Index population on restore stays per backend via bulk_load; the shared restore-backfill worker the draft described exists only on MongoDB. - Format additions: item_order, per-file key_range, md5 and uncompressed_bytes, explicit nulls, three-state vector_indexes of creation specs, optional etag, dataFileS3Key as the full object key, partition-aware BackupArn as the single ARN implementation. - Filesystem store: owner-only root re-checked by every operation, owner-only rule on every descended directory including list and delete_prefix, ancestor ownership rule, objects 0600 and directories 0700 at creation, put returns object metadata; the consequence for backup pickup agents is stated. The S3 conformance suite is mandatory in CI against moto (MinIO no longer publishes images). - ListBackups paging and filtering, the restore override contract, sqlite-memory behavior, extenddb destroy leaving the store intact, a restore spool outside the store root, max_concurrent_jobs, heartbeat_interval_secs and a concrete pool-size rule are specified. - Reconciled with #384 as merged: the restore job record extends #384's table_restores / provenance fields rather than adding a table; the backup id (14-digit, new_backup_id) and ARN parser #384 introduced are the one implementation, moved into extenddb-core by PR 4 and re-exported by storage; the claim names #384's RESTORE_LOCK_SEED, 15 s heartbeat and slot semaphores as what it replaces; defaults follow it (15 s heartbeat, 300 s stale window). Cassandra (#338) implements SnapshotEngine as Unsupported and keeps its logical backup until a token-range snapshot is accepted or the backend's backups are documented as unavailable, decided before PR 14. - Phase 1 is rewritten as the PR series with acceptance criteria and GitHub numbers. Migration numbers are reserved up front so the PITR PR is not on the critical path. Co-authored-by: Lee Hannigan <lhnng@amazon.com>
3fa24dd to
6921116
Compare
Revision of docs/design/15-backup-restore.md after reviewing the draft against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read, compression, and bulk-load paths on PostgreSQL 16 and SQLite with a 1M-item fixture (localhost; ratios only), three review rounds against the companion PRs #393, #394 and #395, and a reconciliation against main 744ea29 after #384 merged in its final form and the Cassandra backend landed. The revision log at the end of the document lists each change; the ones that alter behavior: - SnapshotEngine returns the table definition together with the items from one fenced operation (TableSnapshot). The items-only signature in the draft allowed a GSI change or DeleteTable between the definition read and the data snapshot. Indexes are reported in whatever build state they had and only an index being deleted is dropped; an ACTIVE- only filter would have left a GSI mid-backfill out of the backup for good. - The snapshot is opened in the CreateBackup request so the backup represents the request time (stricter than the service's two- minute window). The backup id is minted from the instant the snapshot pinned, so backup_creation_date_time, the catalog's created_at and the ARN carry one time and the ListBackups cursor is a function of the ARN. - Per-job claims with fencing replace the single-instance lease the draft assumed; PostgreSQL and MongoDB have no such lease. The claim is an advisory lock on a dedicated catalog connection on PostgreSQL (the seed #384's restore lock already uses), the row itself on MongoDB, an in-process slot on SQLite, a lightweight-transaction row on Cassandra (restore targets only); no row ever exists unowned (the MongoDB row is born through an upsert pipeline so its first heartbeat is the server's clock). Heartbeats are a timer, staleness is measured on the database's clock, and any heartbeat that does not confirm the claim aborts the job. A job runs the failure path's delete_prefix only after its FAILED write matched a row; otherwise the claim holder owns the prefix. The reconciler resolves a dead CREATING row at once under a fresh generation conditioned on status = CREATING, leaves heartbeat_at untouched, and deletes the prefix only after the stale window; its duties are listed once, in section 4. A sink error fails the backup; there is no per-file restart, which the one-shot stream and the memory bound rule out. - DeleteBackup tombstones the row as DELETING, deletes the manifest first, and marks DELETED only when the prefix is empty; backup sync is scoped to the configured store and never adopts a CREATING, DELETING, DELETED or FAILED prefix. Failed creations and deleted backups are BackupNotFound, as on the service, and share one retention. - Restore returns CREATING from the request (table and restore_jobs row created atomically in a detached task; TableAlreadyExistsException or TableInUseException on a name race, as #384 already returns; LimitExceededException over the job limit), spools and verifies every data file before writing any item, and records progress in a way a crash cannot split from the data: a data-database row per file on PostgreSQL (data migration 005 reserved), the same transaction per 10k-item batch on SQLite, idempotent re-application of one file on MongoDB followed by a document-count check against the manifest. Duplicate detection for unordered backups is the target's primary key, not an in-memory key set. The ACTIVE flip is conditional and precedes the deletion of the PostgreSQL restore_progress rows, which live in the data database and cannot share its transaction. DeleteTable on a restore target fails the job (a change from #384, which refuses it); multi-part-key tables are restored (#384 refuses them). The override paragraph now states what #384 ships: every override but VectorIndexOverride is applied, and the new path keeps that. - The manifest self-checksum is replaced by a sidecar over the stored bytes; the draft's canonical form depended on serde_json formatting. backup_arn is the backup's identity and source_table must agree with it. N key values are normalized before key text or key order is computed; #395's PostgreSQL ORDER BY carries COLLATE "C" so tables created before #361 stream in the format's order. - Index population on restore stays per backend via bulk_load; the shared restore-backfill worker the draft described exists only on MongoDB. - Format additions: item_order, per-file key_range, md5 and uncompressed_bytes, explicit nulls, three-state vector_indexes of creation specs, optional etag, dataFileS3Key as the full object key, partition-aware BackupArn as the single ARN implementation. - Filesystem store: owner-only root re-checked by every operation, owner-only rule on every descended directory including list and delete_prefix, ancestor ownership rule, objects 0600 and directories 0700 at creation, put returns object metadata; the consequence for backup pickup agents is stated; list and delete_prefix resolve their prefix with the same no-follow walk as a key and tolerate entries a concurrent deleter removed. The S3 conformance suite is mandatory in CI against moto, with the 301/403/abort paths driven through an interceptor. Store settings live under [backup.store], matching #393. - ListBackups keeps #384's handler (full request already on main) over the catalog index merged with the legacy reader; sqlite-memory behavior, extenddb destroy leaving the store intact, a restore spool outside the store root, max_concurrent_jobs, heartbeat_interval_secs, a memory formula in bytes with the item-size term, and a pool-size rule that clamps and warns are specified. Restore and Describe never read the store without a catalog row; backup sync is the path in. - Reconciled with #384 as merged: the restore job record extends #384's table_restores / provenance fields rather than adding a table; the backup ARN parser and formatter #384 introduced are the one implementation, moved into extenddb-core by PR 4 and re-exported by storage (new_backup_id stays in storage and calls the shared formatter); the claim names #384's RESTORE_LOCK_SEED, lock key, 15 s heartbeat and slot semaphores as what it keeps or replaces; the 300 s stale window is new (#384 uses 60 s). Cassandra (#338) implements SnapshotEngine as Unsupported and keeps its logical backup until a token-range snapshot is accepted or the backend's backups are documented as unavailable, decided before PR 14. - Phase 1 is rewritten as the PR series with acceptance criteria and GitHub numbers. Migration numbers are reserved up front so the PITR PR is not on the critical path (Cassandra V006 to PR 1, V007 to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill points for the crash tests are enumerated per backend. Also adds the missing rows for designs 12 and 13 to docs/design/README.md. Co-authored-by: Lee Hannigan <lhnng@amazon.com> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
6921116 to
9cbdbee
Compare
Revision of docs/design/15-backup-restore.md after reviewing the draft against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read, compression, and bulk-load paths on PostgreSQL 16 and SQLite with a 1M-item fixture (localhost; ratios only), four review rounds against the companion PRs #393, #394 and #395, and a reconciliation against main abf8082 (#384 in its final form, the Cassandra backend #338, the PostgreSQL GSI build #401). The revision log at the end of the document lists each change; the ones that alter behavior: - SnapshotEngine returns the table definition together with the items from one fenced operation (TableSnapshot). Indexes are reported in whatever build state they had and only an index being deleted is dropped; a GSI without its data table is left out with the attribute definitions only it used. PostgreSQL bounds open snapshots per server and refuses more with LimitExceeded; DeleteTable takes its asynchronous path while a reader holds the data table. - The snapshot is opened in the CreateBackup request (stricter than the service's two-minute window), and the backup id is minted from the instant it pinned, so backup_creation_date_time, the catalog's created_at and the ARN carry one time and the ListBackups cursor is a function of the ARN. - Per-job claims with fencing replace the single-instance lease the draft assumed: an advisory lock on a dedicated catalog connection on PostgreSQL (#384's seed and key), the row itself on MongoDB (born through an upsert pipeline with $literal), an in-process slot on SQLite, a lightweight-transaction row on Cassandra (restore targets). Heartbeats are a timer on the database's clock with a deadline; any heartbeat that does not confirm the claim aborts the job. Every job status write is conditioned on the generation and on the row's state, and an ambiguous AVAILABLE update is re-read. A job deletes a prefix, or a restore target, only after its FAILED write matched a row. The reconciler's duties are listed once, in section 4, and include the prefixes of DELETED rows, orphan restore targets and stale spools. - DeleteBackup tombstones the row as DELETING, deletes the manifest first, and marks DELETED only when the prefix is empty. backup sync is scoped to the configured store, never adopts a CREATING, DELETING, DELETED or FAILED prefix, and marks a row whose manifest head is gone DELETING. Restore and Describe never read the store without a catalog row; an ARN with no row goes to the legacy reader. - Restore returns CREATING from the request (TableAlreadyExists or TableInUse on a name race, as #384 returns), spools and verifies every data file before writing any item, and records progress in a way a crash cannot split from the data on each backend. The ACTIVE flip precedes the deletion of the PostgreSQL progress rows. DeleteTable on a restore target stays refused, as #384 and the service refuse it; the restore record is the table's child. Multi-part-key tables are restored (#384 refuses them). Table class is not restored, as on the service and #384; every override but VectorIndexOverride is applied, as #384 applies them. - The manifest self-checksum is replaced by a sidecar over the stored bytes; backup_arn is the backup's identity and source_table must agree with it; N key values are normalized before key text or key order is computed; #395's PostgreSQL ORDER BY carries COLLATE "C". - Store (#393): owner-only root and directories re-checked by every operation, the prefix resolved with the same no-follow walk as a key, listings that report only keys passing the key rule, multipart uploads aborted when a put is dropped and swept by delete_prefix (a lifecycle rule as backstop), validate by a one-key listing so a 403 keeps S3's code, settings under [backup.store]. - Configuration: the default store root is per backend (beside the database file on SQLite); PostgreSQL, MongoDB and Cassandra refuse CreateBackup until [backup.store] is set, and keep serving legacy backups. Cassandra keeps creating in-catalog backups through the legacy reader until PR 14. The memory formula counts the fetch buffer and caps a restore batch at one file; purge holds every job permit. - Phase 1 is the PR series with acceptance criteria and GitHub numbers; migration numbers are reserved up front (Cassandra V006 to PR 1, V007 to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill points for the crash tests are enumerated per backend and for DeleteBackup. Also adds the missing rows for designs 12 and 13 to docs/design/README.md and marks 15 as a draft. Co-authored-by: Lee Hannigan <lhnng@amazon.com> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
LeeroyHannigan
left a comment
There was a problem hiding this comment.
I built the crate on stable and 1.88.0 and ran the suite in a container against a tempdir and moto 5.2.4: 18 unit and 72 conformance tests pass, as the description says. I ran the S3 tests and direct probes against Amazon S3 in us-east-1, drove the filesystem store under SIGKILL, injected I/O errors, a full tmpfs and a same-uid symlink racer, and ran mutants of fs.rs and s3.rs against the suite. The gates, the licence notice checks and both CI legs pass, with all 25 S3 tests run.
The owner-only rule, the modes at creation, crash safety at every kill point, concurrent deletes and byte-order listing matched the description. One Blocking item and five more are inline. Smaller items:
- The uid cells can run in a root CI job that chowns a root, a subdirectory and an ancestor to another uid. Mutants that accept a foreign owner pass today.
delete_prefix("")removes some objects before it refuses a foreign-owned subdirectory.- A put dropped while CreateMultipartUpload is in flight leaves an empty upload open (8 or 9 of 16 on S3).
openaccepts an empty bucket, a part size over 5 GiB and anhttp://endpoint to a host that is not loopback.aws-configis built withoutsso, so an SSO profile fails at the first request.400 InvalidTokenand400 ExpiredTokenmap toOther, not PermissionDenied.- On a versioned bucket
delete_prefixleaves noncurrent versions. The README should name the lifecycle rule. ensure_bucketcalls CreateBucket in every test. Amazon S3 answers parallel calls on an existing bucket with409 OperationAborted, and 15 to 20 of the 25 S3 tests fail there.
Once the six inline items are fixed, I will approve.
9cbdbee to
b420ea2
Compare
Revision of docs/design/15-backup-restore.md after reviewing the draft against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read, compression, and bulk-load paths on PostgreSQL 16 and SQLite with a 1M-item fixture (localhost; ratios only), five review rounds against the companion PRs #393, #394 and #395, and a reconciliation against main abf8082 (#384 in its final form, the Cassandra backend #338, the PostgreSQL GSI build #401). The revision log at the end of the document lists each change; the ones that alter behavior: - SnapshotEngine returns the table definition together with the items from one fenced operation (TableSnapshot). Indexes are reported in whatever build state they had and only an index being deleted is dropped; a GSI without its data table is left out with the attribute definitions only it used. PostgreSQL bounds open snapshots at half the pool and refuses more with LimitExceeded, streams a table whose key columns predate COLLATE "C" unordered rather than sort it, and DeleteTable takes its asynchronous path while a reader holds any relation it would drop. On sqlite-memory the snapshot copies the table on the one connection, so CreateBackup keeps working there. MongoDB reads without the explicit snapshot level fail, and the document says so with the server's error. - The snapshot is opened in the CreateBackup request (stricter than the service's two-minute window), and the backup id is minted from the instant it pinned, so backup_creation_date_time, the catalog's created_at and the ARN carry one time and the ListBackups cursor is a function of the ARN. - Per-job claims with fencing replace the single-instance lease the draft assumed: an advisory lock on a dedicated catalog connection on PostgreSQL (#384's seed and key), the row itself on MongoDB (born through an upsert pipeline with $literal), an in-process slot on SQLite, a lightweight-transaction row on Cassandra (restore targets). Heartbeats are a timer on the database's clock with a deadline; any heartbeat that does not confirm the claim aborts the job. Every job status write is conditioned on the generation and on the row's state, and an ambiguous AVAILABLE update is re-read. A job deletes a prefix, or a restore target, only after its FAILED write matched a row. The reconciler's duties are listed once, in section 4, and include the prefixes of DELETED rows, orphan restore targets, stale spools and, on Cassandra, restores_by_backup entries with no RUNNING job (that entry cannot share a lightweight transaction with the claim, so it is written first and is only a hint). Every duty that deletes a prefix acts only on rows of the configured store. - DeleteBackup tombstones the row as DELETING, deletes the manifest first, and marks DELETED only when the prefix is empty. backup sync is scoped to the configured store (backup sync --move-store re-points the rows of a copied store), never adopts a CREATING, DELETING, DELETED or FAILED prefix, and marks a row whose manifest head is gone DELETING. Restore and Describe never read the store without a catalog row; an ARN with no row goes to the legacy reader. - Restore returns CREATING from the request (TableAlreadyExists or TableInUse on a name race, as #384 returns), spools and verifies every data file before writing any item, and records progress in a way a crash cannot split from the data on each backend. The ACTIVE flip precedes the deletion of the PostgreSQL progress rows. DeleteTable on a restore target stays refused, as #384 and the service refuse it; the restore record is the table's child. Multi-part-key tables are restored (#384 refuses them). Table class is not restored, as on the service and #384; every override but VectorIndexOverride is applied, as #384 applies them. - The manifest self-checksum is replaced by a sidecar over the stored bytes; backup_arn is the backup's identity and source_table must agree with it; N key values are normalized before key text or key order is computed. The line cap is 32 MiB, sized from the measured worst case, with its decode memory stated; the compressed size is enforced while reading. - Store (#393): owner-only root and directories re-checked by every operation, the prefix resolved with the same no-follow walk as a key, listings that report only keys passing the key rule, multipart uploads aborted when a put is dropped and swept by delete_prefix (a lifecycle rule as backstop), validate by a one-key listing so a 403 keeps S3's code and then by probes of the write grants, a 255-byte component cap and U+FFFE/U+FFFF refused by the key rule, settings under [backup.store]. Section 8 gains a transport rule (a non-loopback http:// endpoint needs allow_insecure_endpoint) and an IAM example with the list actions on the bucket. - Configuration: the default store root is per backend (beside the database file on SQLite); PostgreSQL, MongoDB and Cassandra refuse CreateBackup until [backup.store] is set, and keep serving legacy backups. Cassandra keeps creating in-catalog backups through the legacy reader until PR 14. The memory formula counts the fetch buffer and caps a restore batch at one file; purge holds every job permit. - Phase 1 is the PR series with acceptance criteria and GitHub numbers; migration numbers are reserved up front (Cassandra V006 to PR 1, V007 to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill points for the crash tests are enumerated per backend and for DeleteBackup. Also adds the missing rows for designs 12 and 13 to docs/design/README.md and marks 15 as a draft. Co-authored-by: Lee Hannigan <lhnng@amazon.com> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
b420ea2 to
fbf5cd3
Compare
Revision of docs/design/15-backup-restore.md after reviewing the draft against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read, compression, and bulk-load paths on PostgreSQL 16 and SQLite with a 1M-item fixture (localhost; ratios only), five review rounds (the fifth on 2026-10-27, Lee's reviews among them) against the companion PRs #393, #394 and #395, and a reconciliation against main abf8082 (#384 in its final form, the Cassandra backend #338, the PostgreSQL GSI build #401). The revision log at the end of the document lists each change; the ones that alter behavior: - SnapshotEngine returns the table definition together with the items from one fenced operation (TableSnapshot). Indexes are reported in whatever build state they had and only an index being deleted is dropped; a GSI without its data table is left out with the attribute definitions only it used. On PostgreSQL's shared layout the open is one connection and one REPEATABLE READ transaction; on the separate layout the catalog row is held FOR SHARE until the data snapshot pins, and an index that becomes ACTIVE between the definition read and the pin is reported CREATING. PostgreSQL bounds open snapshots at half the pool and refuses more with LimitExceeded, so open snapshots leave half the pool to requests; restores hold their own half-pool slots. It streams a table whose key columns predate COLLATE "C" unordered rather than sort it, and DeleteTable takes its asynchronous path while a reader holds any relation it would drop, so its DROP does not wait behind a snapshot or backup, with two stated gaps: a short GetItem, Query or Scan between the probe and the DROP, and on the shared layout a probe between an open's snapshot and its LOCK. On sqlite-memory the snapshot copies the table on the one connection inside the request and keeps a backup slot until its stream ends, so CreateBackup keeps working there once [backup.store] is set. MongoDB reads without the explicit snapshot level fail, and the document says so with the server's error. - The snapshot is opened in the CreateBackup request (stricter than the service's two-minute window), and the backup id is minted from the instant the open returned, just after the pin, so the backup holds every write acknowledged before the request was received, and backup_creation_date_time, the catalog's created_at and the ARN carry one time and the ListBackups cursor is a function of the ARN. - Per-job claims with fencing replace the single-instance lease the draft assumed: an advisory lock on a dedicated catalog connection on PostgreSQL (#384's seed and key), the row itself on MongoDB (born through an upsert pipeline with $literal), an in-process slot on SQLite, a lightweight-transaction row on Cassandra (restore targets). Heartbeats are a timer on the database's clock with a deadline; any heartbeat that does not confirm the claim aborts the job. Every job status write is conditioned on the generation and on the row's state, and an ambiguous AVAILABLE update is re-read. A job deletes a prefix, or a restore target, only after its FAILED write matched a row. The reconciler's duties are listed once, in section 4, and include the prefixes of DELETED rows, orphan restore targets, stale spools and, on Cassandra, restores_by_backup entries older than the stale window with no RUNNING job (that entry cannot share a lightweight transaction with the claim, so it is written first, carries its write time and is only a hint). Every duty that reads or writes a backup row acts only on rows of the configured store (store_kind, store_root and the store id); a CREATING or DELETING row of another store is reported and left alone. - DeleteBackup tombstones the row as DELETING, deletes the manifest first, and marks DELETED only when the prefix is empty. backup sync is scoped to the configured store and checks the store id in extenddb-store.json, so an unmounted volume or a recreated bucket fails loudly; it never adopts a CREATING, DELETING, DELETED or FAILED prefix, and marks a row whose manifest head is gone DELETING only with --prune-missing. --move-store resolves the old root's CREATING and DELETING rows, compares every data file, refuses to carry a tombstone over a verified manifest and offers --forget-backup; --forget-store deletes the rows of a root. purge, --move-store, --forget-store and --prune-missing take a catalog maintenance claim that every job checks; max_concurrent_jobs is per instance. DeleteBackup and Restore of another store's row return a ValidationException that names no root. Restore and Describe never read the store without a catalog row; an ARN with no row goes to the legacy reader. - Restore returns CREATING from the request (TableAlreadyExists or TableInUse on a name race, as #384 returns), spools and verifies every data file before writing any item, and records progress in a way a crash cannot split from the data on each backend. A key-ordered restore runs #394's per-file key-range check and an unordered one ends with a count of the target, which is Cassandra's only duplicate check. A PostgreSQL restore is resumed, or its target deleted, only after the stale window; the manifest and sidecar are spooled for a resume; a legacy restore with a dead owner is failed, not resumed. The ACTIVE flip precedes the deletion of the PostgreSQL progress rows. DeleteTable on a restore target stays refused, as #384 and the service refuse it; the restore record is the table's child. Multi-part-key tables are restored (#384 refuses them). Table class is not restored, as on the service and #384; every override but VectorIndexOverride is applied, as #384 applies them. - The manifest self-checksum is replaced by a sidecar over the stored bytes, accepted in its sha256sum line form only; backup_arn is the backup's identity and source_table must agree with it; N key values are normalized before key text or key order is computed, and an un-normalized key_range endpoint is refused; the version gate accepts 1 to the current version. The line cap is 32 MiB, sized from the measured worst case, and the compressed size is enforced while reading; both are format rules in section 1. Section 7 states the decode memory from #394's DataFileDecoder doc: about 76 bytes per line byte for flat or nested lists and at most about 128 for any shape, about 4 GiB at the cap, after #394 stopped the deserializer copying nested values. - Store (#393): owner-only root and directories re-checked by every operation, an ancestor writable by group or others without the sticky bit refused, a root whose canonical path changed since open refused, the prefix resolved with the same no-follow walk as a key, listings that report only keys passing the key rule, multipart uploads aborted when a put is dropped and swept by delete_prefix (a lifecycle rule as backstop), validate by a one-key listing so a 403 keeps S3's code and then by probes of the grants, a GetObject of an absent key included, a 255-byte component cap and U+FFFE/U+FFFF refused by the key rule, settings under [backup.store]. Section 8 gains a transport rule (a non-loopback http:// endpoint, from the configuration or from the SDK's own endpoint sources, needs allow_insecure_endpoint; localhost over http needs force_path_style) and an IAM example with the list actions on the bucket, the KMS actions for an SSE-KMS bucket, the rule that a missing key reads as 404 only under s3:ListBucket, and a delete-marker lifecycle rule for versioned buckets. - Configuration: the default store root is per backend (beside the database file on file-backed SQLite); PostgreSQL, MongoDB and sqlite-memory refuse CreateBackup until [backup.store] is set, and keep serving legacy backups. Cassandra keeps creating in-catalog backups through the legacy create until PR 14, whether or not [backup.store] is set. The memory formula is in item text bytes, and PR 8a adds a byte budget to the PostgreSQL and SQLite snapshot streams; the PostgreSQL pool rule is max(MIN_POOL_SIZE, 2 * max_concurrent_jobs + 10); the spool default is per backend. - Phase 1 is the PR series with acceptance criteria and GitHub numbers; migration numbers are reserved up front (Cassandra V006 to PR 1, V007 to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill points for the crash tests are enumerated per backend and for DeleteBackup. The Phasing section names the companion heads the copied values come from. Also adds the missing rows for designs 12 and 13 to docs/design/README.md and marks 15 as a draft. Co-authored-by: Lee Hannigan <lhnng@amazon.com> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
fbf5cd3 to
013835b
Compare
Revision of docs/design/15-backup-restore.md after reviewing the draft against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read, compression, and bulk-load paths on PostgreSQL 16 and SQLite with a 1M-item fixture (localhost; ratios only), six review rounds (the sixth on 2026-10-28, Lee's reviews among them) against the companion PRs #393, #394 and #395, and a reconciliation against main abf8082 (#384 in its final form, the Cassandra backend #338, the PostgreSQL GSI build #401). The revision log at the end of the document lists each change; the ones that alter behavior: - SnapshotEngine returns the table definition together with the items from one fenced operation (TableSnapshot). Indexes are reported in whatever build state they had and only an index being deleted is dropped; a GSI without its data table is left out with the attribute definitions only it used. On PostgreSQL's shared layout the open is one connection and one REPEATABLE READ transaction; on the separate layout the catalog row is held FOR SHARE until the data snapshot pins, and an index that becomes ACTIVE between the definition read and the pin is reported CREATING. PostgreSQL bounds open snapshots at half the pool and refuses more with LimitExceeded, so open snapshots leave half the pool to requests; from PR 6 on max_concurrent_jobs (default 4) bounds backups and restores together under that permit, and #384's restore slots go. It streams a table whose key columns predate COLLATE "C" unordered rather than sort it, and DeleteTable takes its asynchronous path while a reader holds any relation it would drop, so its DROP does not wait behind a snapshot or backup, with two stated gaps: a short GetItem, Query or Scan between the probe and the DROP, and on the shared layout a probe between an open's snapshot and its LOCK; the control-plane poller stays active while a drop is deferred, so the drop follows any reader's release within about a second. On sqlite-memory the snapshot copies the table on the one connection inside the request and keeps a backup slot until its stream ends, so CreateBackup keeps working there once [backup.store] is set. MongoDB reads without the explicit snapshot level fail, and the document says so with the server's error. - The snapshot is opened in the CreateBackup request (stricter than the service's two-minute window), and the backup id is minted from the instant the open returned, just after the pin, so the backup holds every write acknowledged before the request was received, and backup_creation_date_time, the catalog's created_at and the ARN carry one time and the ListBackups cursor is a function of the ARN. - Per-job claims with fencing replace the single-instance lease the draft assumed: an advisory lock on a dedicated catalog connection on PostgreSQL (#384's seed and key), the row itself on MongoDB (born through an upsert pipeline with $literal), an in-process slot on SQLite, a lightweight-transaction row on Cassandra (restore targets). Heartbeats are a timer on the database's clock with a deadline; any heartbeat that does not confirm the claim aborts the job. Every job status write is conditioned on the generation and on the row's state, and an ambiguous AVAILABLE update is re-read. A job deletes a prefix, or a restore target, only after its FAILED write matched a row. The reconciler's duties are listed once, in section 4, and include the prefixes of DELETED rows, orphan restore targets, stale spools and, on Cassandra, restores_by_backup entries older than the stale window with no RUNNING job (that entry cannot share a lightweight transaction with the claim, so it is written first, carries its write time and is only a hint). Every duty that reads or writes a backup row acts only on rows of the configured store (store_kind, store_root and the store id); a CREATING or DELETING row of another store is reported and left alone. - DeleteBackup tombstones the row as DELETING, deletes the manifest first, and marks DELETED only when the prefix is empty. backup sync is scoped to the configured store and checks the store id in extenddb-store.json, so an unmounted volume or a recreated bucket fails loudly; it never adopts a CREATING, DELETING, DELETED or FAILED prefix, and marks a row whose manifest head is gone DELETING only with --prune-missing. --move-store resolves the old root's CREATING and DELETING rows, compares every data file, refuses to carry a tombstone over a verified manifest and offers --forget-backup; --forget-store deletes the rows of a root. purge, --move-store, --forget-store and --prune-missing take a catalog maintenance claim that every job checks; max_concurrent_jobs is per instance. DeleteBackup and Restore of another store's row return a ValidationException that names no root. Restore and Describe never read the store without a catalog row; an ARN with no row goes to the legacy reader. - Restore returns CREATING from the request (TableAlreadyExists or TableInUse on a name race, as #384 returns), spools and verifies every data file before writing any item, and records progress atomically with the data on PostgreSQL and SQLite and by idempotent re-application of the one unrecorded file or batch on MongoDB and Cassandra; on MongoDB the job writes DONE and the backfill flag in one transaction and the worker's ACTIVE flip is conditioned on the flag, not on the job's generation. A key-ordered restore runs #394's per-file key-range check and an unordered one ends with a count of the target, which is Cassandra's only duplicate check. A PostgreSQL restore is resumed, or its target deleted, only after the stale window; the manifest and sidecar are spooled for a resume; a legacy restore with a dead owner is failed, not resumed. The ACTIVE flip precedes the deletion of the PostgreSQL progress rows. DeleteTable on a restore target stays refused, as #384 and the service refuse it; the restore record is the table's child. Multi-part-key tables are restored (#384 refuses them; bulk_load writes through the backend's own item path, so the table is laid out as a natively created one). Table class is not restored, as on the service and #384; every override but VectorIndexOverride is applied, as #384 applies them. - The engine binds a BackupCatalog trait (PR 6) with a JobClaim for the per-backend claim, heartbeat and conditional status writes; new rows live in backup_index (a new table or collection; Cassandra reuses its tables with store_kind set and adds backup_index_by_status); MongoDB's catalog gets the versioned migration step it lacks; PR 6's migration backfills in-flight #384 restores so the first reconciler pass fails them; every pre-PR-6 server stops before migrate. Vector tables are backed up and restored on the off-host path (three #384 tests change in PR 8a/9a). source_table carries stream_specification and time_to_live for DescribeBackup. Both requests take the job permit last, after every refusal that needs no store access. The format's evolution rule is a version bump for any byte change, with v{N} fixtures beside v1. - The manifest self-checksum is replaced by a sidecar over the stored bytes, accepted in its sha256sum line form only; backup_arn is the backup's identity and source_table must agree with it; N key values are normalized before key text or key order is computed, and an un-normalized key_range endpoint is refused; the version gate accepts 1 to the current version. The line cap is 32 MiB, sized from the measured worst case, and the compressed size is enforced while reading; both are format rules in section 1. Section 7 cites the decode memory from #394's DataFileDecoder doc (76 and 128 bytes per line byte as of #394, measured without preserve_order; about 4 GiB at the cap). - Store (#393): owner-only root and directories re-checked by every operation, an ancestor writable by group or others without the sticky bit refused, a root whose canonical path changed since open refused, the prefix resolved with the same no-follow walk as a key, listings that report only keys passing the key rule, multipart uploads aborted when a put is dropped and swept by delete_prefix (a lifecycle rule as backstop), validate by a one-key listing so a 403 keeps S3's code and then by probes of the grants, a GetObject of an absent key included, a 255-byte component cap and U+FFFE/U+FFFF refused by the key rule, settings under [backup.store]. Section 8 gains a transport rule (a non-loopback http:// endpoint, from the configuration or from the SDK's own endpoint sources, needs allow_insecure_endpoint; localhost over http needs force_path_style) and an IAM example with the list actions on the bucket, the KMS actions for an SSE-KMS bucket, the rule that a missing key reads as 404 only under s3:ListBucket, and a delete-marker lifecycle rule for versioned buckets. - Configuration: the default store root is per backend (beside the database file on file-backed SQLite, where a defaulted root that fails validation is a warning, not a startup error); without [backup.store], CreateBackup on every backend keeps the legacy in-catalog create with a deprecation warning until PR 14 removes it (Cassandra always, having no SnapshotEngine), and kind = "disabled" is the opt-out. A store transport failure at startup degrades the store component instead of stopping the server. The memory formula is in item text bytes, and PR 8a adds the byte budget SNAPSHOT_FETCH_BYTES (64 MiB) to the PostgreSQL and SQLite snapshot streams; the PostgreSQL pool rule is max(MIN_POOL_SIZE, 2 * max_concurrent_jobs + 10), 18 at the default 4; the spool default is per backend and spool_host is host plus spool path. - Phase 1 is the PR series with acceptance criteria and GitHub numbers; migration numbers are reserved up front (Cassandra V006 to PR 1, V007 to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill points for the crash tests are enumerated per backend and for DeleteBackup. The Phasing section refers to the companion PRs' heads on the revision date rather than naming commits. Also adds the missing rows for designs 12 and 13 to docs/design/README.md and marks 15 as a draft. Co-authored-by: Lee Hannigan <lhnng@amazon.com> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
LeeroyHannigan
left a comment
There was a problem hiding this comment.
I re-ran the items from my review against this head in the shapes they were measured in (a full 4 MiB tmpfs for the filesystem store, Amazon S3 for the upload sweep, the key rule and the token codes, moto with IAM enforcement for the grants), with 37 mutants of the code that answers them, and found no item open. On the full disk the put fails with ENOSPC and the previous object is byte-identical; delete_prefix("sib/x") on Amazon S3 aborts the uploads under sib/x/ and leaves sib/xy/, sib/x-other and sibling/; a 256-byte component and U+FFFE are refused before either store is touched; validate names each of the six grants it needs and a delete_prefix retry with the upload listing denied succeeds; the S3 suite on Amazon S3 has no 409 left. Of the eight filesystem claims that had no test, three now have one and five are listed as untested defence in depth, which the item allowed. I also ran validate's request sequence under a role carrying the README's policy as printed: the read of a missing key under the prefix answers 404 NoSuchKey, so that policy passes validate.
The code added since that review got the same reading: 31 unit and 103 conformance tests pass on stable and 1.88.0 against a tempdir and moto, clippy 1.97.0 is clean, CI runs the three uid tests as root, and the extenddb binary's dependency graph has neither this crate nor the SDK. Of 17 mutants of the fixes added since, 16 fail a test. Three inline items need code: an endpoint with a query component or an IPv6 zone id passes open, and validate then fails with the SDK's error carrying the value, password included; the clear-text rule does not see an HTTP proxy, so a signed request to a loopback http:// endpoint went in clear text to the listener HTTP_PROXY named; and the configured endpoint's precedence over the SDK's sources has no test, and the mutant that drops it passes all 134. Six smaller items are inline: a put dropped during its open leaves a hidden temporary; the loopback refusal names a config key the operator may not have set; the child-process tests inherit proxy variables; the README's checksum sentence; four of the new S3 tests fail rather than skip against an https:// endpoint; and a denied AbortMultipartUpload still fails every delete_prefix retry after the objects are gone, which is the last clause of the grants item.
Once the inline items are fixed, I will approve.
013835b to
f59ccd1
Compare
Revision of docs/design/15-backup-restore.md after reviewing the draft against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read, compression, and bulk-load paths on PostgreSQL 16 and SQLite with a 1M-item fixture (localhost; ratios only), seven review rounds and the maintainer's reviews (on 2026-10-28 the sixth round, the maintainer's review and the seventh round) against the companion PRs #393, #394 and #395, and a reconciliation against main abf8082 (#384 in its final form, the Cassandra backend #338, the PostgreSQL GSI build #401). The revision log at the end of the document lists each change; the ones that alter behavior: - SnapshotEngine returns the table definition together with the items from one fenced operation (TableSnapshot). Indexes are reported in whatever build state they had and only an index being deleted is dropped; a GSI without its data table is left out with the attribute definitions only it used. On PostgreSQL's shared layout the open is one connection and one REPEATABLE READ transaction; on the separate layout the catalog row is held FOR SHARE until the data snapshot pins, and an index that becomes ACTIVE between the definition read and the pin is reported CREATING. PostgreSQL bounds open snapshots at half the pool and refuses more with LimitExceeded, so open snapshots leave half the pool to requests; from PR 6 on max_concurrent_jobs (default 4) bounds backups and restores together under that permit, and #384's restore slots go. It streams a table whose key columns predate COLLATE "C" unordered rather than sort it, and on both layouts DeleteTable commits the catalog removal only after its drops succeed, base table first, under one 1 s budget, taking its asynchronous path with the row kept when its probe finds a reader or a drop fails in a lock wait (lock timeout, deadlock victim or statement timeout), so no DROP waits behind a reader with the row gone; on the separate layout a task the request awaits commits the catalog and then the data transaction; the control-plane poller probes held tables and retries each about once a second, at once on a snapshot's or an in-catalog backup's release, a drop DeleteTable deferred is due at once, and DeleteTable of a DELETING table returns its description on PostgreSQL and SQLite. On sqlite-memory the snapshot copies the table on the one connection inside the request and keeps a backup slot until its stream ends, so CreateBackup keeps working there once [backup.store] is set. MongoDB reads without the explicit snapshot level fail, and the document says so with the server's error. - The snapshot is opened in the CreateBackup request (stricter than the service's two-minute window), and the backup id is minted from the instant the open returned, just after the pin, so the backup holds every write acknowledged before the request was received, and backup_creation_date_time, the catalog's created_at and the ARN carry one time and the ListBackups cursor is a function of the ARN. - Per-job claims with fencing replace the single-instance lease the draft assumed: an advisory lock on a dedicated catalog connection on PostgreSQL (#384's seed and key), the row itself on MongoDB (born through an upsert pipeline with $literal), an in-process slot on SQLite, a lightweight-transaction row on Cassandra (restore targets). Heartbeats are a timer on the database's clock with a deadline; any heartbeat that does not confirm the claim aborts the job. Every job status write is conditioned on the generation and on the row's state, and an ambiguous AVAILABLE update is re-read. A job deletes a prefix, or a restore target, only after its FAILED write matched a row. The reconciler's duties are listed once, in section 4, and include the prefixes of DELETED rows, orphan restore targets, stale spools and, on Cassandra, active_restores entries whose target is not a CREATING restore target of the backup. Every duty that reads or writes a backup row acts only on rows of the configured store (store_kind, store_root and the store id); a CREATING or DELETING row of another store is reported and left alone. The PostgreSQL staleness test also selects a NULL heartbeat. While a maintenance claim is held the reconciler only finishes DELETING rows, resolves rows whose owner is dead and removes a FAILED restore's CREATING target. The PostgreSQL claim connection sets TCP keepalives, so the loss of the owner's host frees its lock in about a minute. While the configured store is unhealthy the reconciler acts on no row. - DeleteBackup tombstones the row as DELETING, deletes the manifest first, and marks DELETED only when the prefix is empty. backup sync is scoped to the configured store; extenddb-store.json records the store id and the owning catalog's catalog_id, and a root another catalog owns, rows without the file, rows with two store ids or a missing newest manifest mark the store unhealthy (store operations refused, the reconciler idle) instead of stopping startup, so an unmounted volume, a recreated bucket or two catalogs on one root are caught; backup sync --adopt-store hands a root to another catalog; instances that share a catalog must share one store root; it never adopts a CREATING, DELETING, DELETED or FAILED prefix, nor one whose listing lacks a data file the manifest lists at its recorded size, and marks a row whose manifest head is gone DELETING only with --prune-missing. --move-store resolves the old root's CREATING and DELETING rows, compares every data file, refuses to carry a tombstone over a verified manifest and offers --forget-backup; --forget-store deletes the rows of a root. purge, --move-store, --forget-store, --prune-missing and --adopt-store take a catalog maintenance claim that every job checks; max_concurrent_jobs is per instance. DeleteBackup and Restore of another store's row return a ValidationException that names no root. Restore and Describe never read the store without a catalog row; an ARN with no row goes to the legacy reader. The store-id file is created with put_if_absent (If-None-Match on S3, O_EXCL and link on the filesystem). A backup is in use while a restore target registered from it is CREATING, as on main: DeleteBackup and a second restore return BackupInUseException. Purge deletes each row's prefix, never a whole account prefix. Every backup row is read by account and backup id and must match the request's account, table and ARN, and no row stores its key prefix. DeleteBackup's in-use check and DELETING write run under the backup row's lock, and a restore's registration re-checks AVAILABLE and in-use under the same lock, so the two serialize as on main (on Cassandra through main's active_restores lightweight transactions, now for new rows too). - Restore returns CREATING from the request (TableAlreadyExists or TableInUse on a name race, as #384 returns), spools and verifies every data file before writing any item, and records progress atomically with the data on PostgreSQL and SQLite and by idempotent re-application of the one unrecorded file or batch on MongoDB and Cassandra; on MongoDB the job writes DONE and the backfill flag in one transaction and the worker's ACTIVE flip is conditioned on the flag, not on the job's generation. A key-ordered restore runs #394's per-file key-range check and an unordered one ends with a count of the target, which is Cassandra's only duplicate check. A PostgreSQL restore is resumed, or its target deleted, only after the stale window; the manifest and sidecar are spooled for a resume; a legacy restore with a dead owner is failed, not resumed. The ACTIVE flip precedes the deletion of the PostgreSQL progress rows. DeleteTable on a restore target stays refused, as #384 and the service refuse it, and the job and the reconciler remove a failed target through the backend-internal remove_restore_target; the restore record is the table's child. Multi-part-key tables are restored (#384 refuses them; bulk_load keys rows with the backend's item-path functions, so the table is laid out as a natively created one). On PostgreSQL and SQLite bulk_load writes GSI, LSI and vector-index rows in its own transaction, and the ACTIVE flip sets every index ACTIVE. Table class is not restored, as on the service and #384; every override but VectorIndexOverride is applied, as #384 applies them. A second restore of a backup in use is refused before the permit and re-checked at registration. A restore stops on shutdown leaving its row RUNNING, download transport errors are retried for 15 minutes, and a takeover rewrites spool_host. Each data file is read through ExtenddbManifest::reader_prehashed, which applies the key-range check without hashing the spooled bytes again. - The engine binds a BackupCatalog trait (PR 6) with a JobClaim for the per-backend claim, heartbeat and conditional status writes; new rows live in backup_index (a new table or collection; Cassandra reuses its tables with store_kind set and adds backup_index_by_status); MongoDB's catalog gets the versioned migration step it lacks; PR 6's migration backfills in-flight #384 restores with heartbeat_at at the epoch so the first reconciler pass fails them; every pre-PR-6 server stops before migrate. Vector tables are backed up and restored on the off-host path, while the legacy create keeps main's refusal (one #384 engine test is rewritten in PR 9a and the wire test follows the configured path). source_table carries stream_specification and time_to_live for DescribeBackup. Both requests take the job permit last, after every refusal that needs no store access. The format's evolution rule is a version bump for any byte change, with v{N} fixtures beside v1. The trait gains listings for ListBackups, the store-id check and the root-matched commands, and backup_in_use. ListBackups accepts a table ARN in TableName from PR 6, as the service does. PR 6 adds the [backup] struct with the job keys. Row and restore-target methods take account-scoped keys, and the trait gains catalog_id and DeleteBackup's lock-holding start_backup_delete. - The manifest self-checksum is replaced by a sidecar over the stored bytes, accepted in its sha256sum line form only; backup_arn is the backup's identity and source_table must agree with it; N key values are normalized before key text or key order is computed, and an un-normalized key_range endpoint is refused; the version gate accepts 1 to the current version. The line cap is 32 MiB, sized from the measured worst case, and the compressed size is enforced while reading; both are format rules in section 1. Section 7 cites the decode memory from #394's DataFileDecoder doc (76 and 128 bytes per line byte as of #394, measured without preserve_order; about 4 GiB at the cap). A reader refuses a manifest without stream_specification or time_to_live. The backup job validates every item with an item validator PR 8a adds to extenddb-core, which the restore binds, and fails the backup on a value #618 let in through any of the four write paths, a regression from main for tables that hold one; it validates the rest of the snapshot first and logs every refused item, and the FAILED reason gives the count and the first. - Store (#393): owner-only root and directories re-checked by every operation, an ancestor writable by group or others without the sticky bit refused, a root whose canonical path changed since open refused, the prefix resolved with the same no-follow walk as a key, listings that report only keys passing the key rule, multipart uploads aborted when a put is dropped and swept by delete_prefix (a lifecycle rule as backstop), validate by a one-key listing so a 403 keeps S3's code and then by probes of the grants, a GetObject of an absent key included, a 255-byte component cap and U+FFFE/U+FFFF refused by the key rule, settings under [backup.store]. Section 8 gains a transport rule (a non-loopback http:// endpoint, from the configuration or from the SDK's own endpoint sources, needs allow_insecure_endpoint; an http:// S3 endpoint needs force_path_style unless allow_insecure_endpoint is set; the credential clients' endpoints (STS, SSO, SSO OIDC and the general AWS_ENDPOINT_URL or profile endpoint_url) are held to the loopback rule, and a refusal names the client and the setting; S3 Express directory buckets are refused; runtime shutdown sends no abort for in-flight puts, so the server's shutdown awaits its jobs' failure paths) and an IAM example with the list actions on the bucket, the KMS actions for an SSE-KMS bucket, the rule that a missing key reads as 404 only under s3:ListBucket, and a delete-marker lifecycle rule for versioned buckets. The endpoint is refused with user information, a query, a fragment or an IPv6 zone id, from any source; the S3 client for an http:// endpoint is built with aws_smithy_http_client's Builder and no proxy; a denied AbortMultipartUpload in delete_prefix's sweep is logged and the deletion succeeds; a put dropped during the temporary's open leaves no temporary. PR 7 adds put_if_absent to the store trait. - Configuration: the default store root is per backend (beside the database file on file-backed SQLite, where a defaulted root that fails validation is a warning, not a startup error); without [backup.store], CreateBackup on every backend keeps the legacy in-catalog create with a deprecation warning until PR 14 removes it (Cassandra always, having no SnapshotEngine), after which it is a ValidationException naming [backup.store], and kind = "disabled" is the opt-out; PR 14's migration refuses while legacy backup rows remain unless forced. A store transport failure at startup degrades the store component instead of stopping the server. The memory formula is in item text bytes, and PR 8a bounds each PostgreSQL and SQLite snapshot page by SNAPSHOT_FETCH_BYTES (64 MiB) in the page's query, PostgreSQL's cursor replaced by a keyset page under the same snapshot, so a page holds at most the budget plus one row; the PostgreSQL page returns its key columns, orders the outer query and sizes its row limit from the previous page's average row, starting at 16; the PostgreSQL pool rule is max(MIN_POOL_SIZE, 2 * max_concurrent_jobs + 10), 18 at the default 4; the spool default is per backend and spool_host is host plus spool path. - Phase 1 is the PR series with acceptance criteria and GitHub numbers; migration numbers are reserved up front (Cassandra V006 to PR 1, V007 to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill points for the crash tests are enumerated per backend and for DeleteBackup. The Phasing section refers to the companion PRs' heads on the revision date rather than naming commits. migrate-legacy re-reads and validates what it wrote and refuses, keeping the legacy rows, backups it cannot carry. Also adds the missing rows for designs 12 and 13 to docs/design/README.md and marks 15 as a draft. Co-authored-by: Lee Hannigan <lhnng@amazon.com> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
f59ccd1 to
aef17dd
Compare
Revision of docs/design/15-backup-restore.md after reviewing the draft against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read, compression, and bulk-load paths on PostgreSQL 16 and SQLite with a 1M-item fixture (localhost; ratios only), eight review rounds and the maintainer's reviews (on 2026-10-28 the sixth round, the maintainer's review and the seventh round; on 2026-10-29 the eighth, a sentence-level verification of the revision against the companions' code and main) against the companion PRs #393, #394 and #395, and a reconciliation against main abf8082 (#384 in its final form, the Cassandra backend #338, the PostgreSQL GSI build #401). The revision log at the end of the document lists each change; the ones that alter behavior: - SnapshotEngine returns the table definition together with the items from one fenced operation (TableSnapshot). Indexes are reported in whatever build state they had and only an index being deleted is dropped; a GSI without its data table is left out with the attribute definitions only it used. On PostgreSQL's shared layout the open is one connection and one REPEATABLE READ transaction; on the separate layout the catalog row is held FOR SHARE until the data snapshot pins, and an index that becomes ACTIVE between the definition read and the pin is reported CREATING. PostgreSQL bounds open snapshots at half the pool and refuses more with LimitExceeded, so open snapshots leave half the pool to requests; from PR 6 on max_concurrent_jobs (default 4) bounds backups and restores together under that permit, and #384's restore slots go. It streams a table whose key columns predate COLLATE "C" unordered rather than sort it, and on both layouts DeleteTable commits the catalog removal only after its drops succeed, base table first, under one 1 s budget, taking its asynchronous path with the row kept when its probe finds a reader or a drop fails in a lock wait (lock timeout, deadlock victim or statement timeout), so no DROP waits behind a reader with the row gone; on the separate layout a task the request awaits commits the catalog and then the data transaction, and a data commit that fails after the catalog commit is retried on a fresh connection for a bounded time before the request returns (an orphan survives only a process death or a data database down past that bound, and the troubleshooting page says how to drop it); the control-plane poller probes held tables and retries each about once a second, at once on a snapshot's or an in-catalog backup's release, a drop DeleteTable deferred is due at once, and DeleteTable of a DELETING table returns its description on PostgreSQL and SQLite. On sqlite-memory the snapshot copies the table on the one connection inside the request and keeps a backup slot until its stream ends, so CreateBackup keeps working there once [backup.store] is set. MongoDB reads without the explicit snapshot level fail, and the document says so with the server's error. - The snapshot is opened in the CreateBackup request (stricter than the service's two-minute window), and the backup id is minted from the instant the open returned, just after the pin, so the backup holds every write acknowledged before the request was received, and backup_creation_date_time, the catalog's created_at and the ARN carry one time and the ListBackups cursor is a function of the ARN. - Per-job claims with fencing replace the single-instance lease the draft assumed: an advisory lock on a dedicated catalog connection on PostgreSQL (#384's seed and key), the row itself on MongoDB (born through an upsert pipeline with $literal), an in-process slot on SQLite, a lightweight-transaction row on Cassandra (restore targets). Heartbeats are a timer on the database's clock with a deadline; any heartbeat that does not confirm the claim aborts the job. Every job status write is conditioned on the generation and on the row's state, and an ambiguous AVAILABLE update is re-read. A job deletes a prefix, or a restore target, only after its FAILED write matched a row. The reconciler's duties are listed once, in section 4, and include the prefixes of DELETED rows, orphan restore targets, stale spools and, on Cassandra, active_restores entries whose target is not a CREATING restore target of the backup. Every duty that reads or writes a backup row acts only on rows of the configured store (store_kind, store_root and the store id); a CREATING or DELETING row of another store is reported and left alone. The PostgreSQL staleness test also selects a NULL heartbeat. While a maintenance claim is held the reconciler only finishes DELETING rows, resolves rows whose owner is dead and runs its FAILED-target duty, which never removes a target left for reason target_not_empty; that one is the operator's, removed with extenddb backup remove-target. The PostgreSQL claim connection sets TCP keepalives, so the loss of the owner's host frees its lock in about a minute. While the configured store is unhealthy the reconciler acts on no row of the store; its legacy-row duty, which touches no store, still runs. - DeleteBackup tombstones the row as DELETING, deletes the manifest first, and marks DELETED only when the prefix is empty. backup sync is scoped to the configured store; extenddb-store.json records the store id and the owning catalog's catalog_id, and a root another catalog owns, rows without the file, rows with two store ids or a missing newest manifest mark the store unhealthy (store operations refused, the reconciler idle on the store's rows) instead of stopping startup, so an unmounted volume, a recreated bucket or two catalogs on one root are caught; backup sync --adopt-store hands a root to another catalog, and sqlite-memory's startup sync takes ownership of a root a sqlite-memory catalog owned the same way, so one catalog owns a root at a time there too; instances that share a catalog must share one store root; it never adopts a CREATING, DELETING, DELETED or FAILED prefix, nor one whose listing lacks a data file the manifest lists at its recorded size, and marks a row whose manifest head is gone DELETING only with --prune-missing. --move-store resolves the old root's CREATING and DELETING rows, compares every data file, refuses to carry a tombstone over a verified manifest and offers --forget-backup; --forget-store deletes the rows of a root. purge, remove-target, --move-store, --forget-store, --prune-missing and --adopt-store take a catalog maintenance claim that every job checks; max_concurrent_jobs is per instance. DeleteBackup and Restore of another store's row return a ValidationException that names no root. Restore and Describe never read the store without a catalog row; an ARN with no row goes to the legacy reader. The store-id file is created with put_if_absent (If-None-Match on S3, O_EXCL and link on the filesystem). A backup is in use while a restore target registered from it is CREATING, as on main: DeleteBackup and a second restore return BackupInUseException. Purge deletes each row's prefix, never a whole account prefix. Every backup row is read by account and backup id and must match the request's account, table and ARN, and no row stores its key prefix. DeleteBackup's in-use check and DELETING write run under the backup row's lock, and a restore's registration re-checks AVAILABLE and in-use under the same lock, so the two serialize as on main (on Cassandra through main's active_restores lightweight transactions, now for new rows too); main's two PostgreSQL race tests stay for the legacy path and PR 6 adds the same two orderings over an off-host backup. - Restore returns CREATING from the request (TableAlreadyExists or TableInUse on a name race, as #384 returns), spools and verifies every data file before writing any item, and records progress atomically with the data on PostgreSQL and SQLite and by idempotent re-application of the one unrecorded file or batch on MongoDB and Cassandra; on MongoDB the job writes DONE and the backfill flag in one transaction and the worker's ACTIVE flip is conditioned on the flag, not on the job's generation. A key-ordered restore runs #394's per-file key-range check and an unordered one ends with a count of the target, which is Cassandra's only duplicate check. A PostgreSQL restore is resumed, or its target deleted, only after the stale window; the manifest and sidecar are spooled for a resume; a legacy restore with a dead owner is failed, not resumed. The ACTIVE flip precedes the deletion of the PostgreSQL progress rows. DeleteTable on a restore target stays refused, as #384 and the service refuse it, and the job and the reconciler remove a failed target through the backend-internal remove_restore_target; the restore record is the table's child. Multi-part-key tables are restored (#384 refuses them; bulk_load keys rows with the backend's item-path functions, so the table is laid out as a natively created one). On PostgreSQL and SQLite bulk_load writes GSI, LSI and vector-index rows in the batch's transaction with the base rows, and the ACTIVE flip sets the table and every index ACTIVE in one catalog transaction; on Cassandra the index rows are created ACTIVE and the lightweight transaction flips the table alone. Table class is not restored, as on the service and #384; every override but VectorIndexOverride is applied, as #384 applies them. A second restore of a backup in use is refused before the permit and re-checked at registration. A restore stops on shutdown leaving its row RUNNING, download transport errors are retried for 15 minutes, and a takeover rewrites spool_host. Each data file is read through ExtenddbManifest::reader_prehashed, which applies the key-range check without hashing the spooled bytes again. - The engine binds a BackupCatalog trait (PR 6) with a JobClaim for the per-backend claim, heartbeat and conditional status writes; new rows live in backup_index (a new table or collection; Cassandra reuses its tables with store_kind set and adds backup_index_by_status); MongoDB's catalog gets the versioned migration step it lacks; PR 6's migration backfills in-flight #384 restores with heartbeat_at at the epoch so the first reconciler pass fails them; every pre-PR-6 server stops before migrate. Vector tables are backed up and restored on the off-host path, while the legacy create keeps main's refusal (one #384 engine test is rewritten in PR 9a and the wire test follows the configured path). source_table carries stream_specification and time_to_live for DescribeBackup. Both requests take the job permit last, after every refusal that needs no store access. The format's evolution rule is a version bump for any byte change, with v{N} fixtures beside v1. The trait gains listings for ListBackups, the store-id check and the root-matched commands, and backup_in_use. ListBackups accepts a table ARN in TableName from PR 6, as the service does. PR 6 adds the [backup] struct with the job keys. Row and restore-target methods take account-scoped keys, and the trait gains catalog_id and DeleteBackup's lock-holding start_backup_delete. - The manifest self-checksum is replaced by a sidecar over the stored bytes, accepted in its sha256sum line form only; backup_arn is the backup's identity and source_table must agree with it; N key values are normalized before key text or key order is computed, and an un-normalized key_range endpoint is refused; the version gate accepts 1 to the current version. The line cap is 32 MiB, sized from the measured worst case, and the compressed size is enforced while reading; both are format rules in section 1. Section 7 cites the decode memory from #394's DataFileDecoder doc (76 and 128 bytes per line byte as of #394, measured without preserve_order; about 4 GiB at the cap). A reader refuses a manifest without stream_specification or time_to_live. The backup job validates every item with an item validator PR 8a adds to extenddb-core, which the restore binds, and fails the backup on a value #618 let in through any of the four write paths, a regression from main for tables that hold one; it validates the rest of the snapshot first and logs every refused item, and the FAILED reason gives the count and the first. - Store (#393): owner-only root and directories re-checked by every operation, an ancestor writable by group or others without the sticky bit refused, a root whose canonical path changed since open refused, the prefix resolved with the same no-follow walk as a key, listings that report only keys passing the key rule, multipart uploads aborted when a put is dropped and swept by delete_prefix (a lifecycle rule as backstop), validate by a one-key listing so a 403 keeps S3's code and then by probes of the grants, a GetObject of an absent key included, a 255-byte component cap and U+FFFE/U+FFFF refused by the key rule, settings under [backup.store]. Section 8 gains a transport rule (a non-loopback http:// endpoint, from the configuration or from the SDK's own endpoint sources, needs allow_insecure_endpoint; an http:// S3 endpoint needs force_path_style unless allow_insecure_endpoint is set; the credential clients' endpoints (STS, SSO, SSO OIDC and the general AWS_ENDPOINT_URL or profile endpoint_url) are held to the loopback rule, the IMDS endpoint setting may be http:// only to loopback or the two IMDS addresses, and a refusal names the client and the setting; S3 Express directory buckets and directory-bucket access points (--x-s3, --xa-s3) are refused; runtime shutdown sends no abort for in-flight puts, so the server's shutdown awaits its jobs' failure paths) and an IAM example with the list actions on the bucket, the KMS actions for an SSE-KMS bucket, the rule that a missing key reads as 404 only under s3:ListBucket, and a delete-marker lifecycle rule for versioned buckets. The endpoint is refused with user information, a query, a fragment or an IPv6 zone id, from any source; the S3 client for an http:// endpoint is built with aws_smithy_http_client's Builder and no proxy, and the credential chain's clients get the same client when a credential endpoint setting, the IMDS endpoint setting or the container credentials URI is http://, so the chain's own http:// requests do not use the proxy; without such a setting the chain uses the SDK's default client and NO_PROXY must list 169.254.169.254 (fd00:ec2::254 in IPv6 mode) and 169.254.170.2; a denied AbortMultipartUpload in delete_prefix's sweep is logged and the deletion succeeds; a put dropped during the temporary's open leaves no temporary. PR 7 adds put_if_absent to the store trait. - Configuration: the default store root is per backend (beside the database file on file-backed SQLite, where a defaulted root that fails validation is a warning, not a startup error); without [backup.store], CreateBackup on every backend keeps the legacy in-catalog create with a deprecation warning until PR 14 removes it (Cassandra always, having no SnapshotEngine), after which it is a ValidationException naming [backup.store], and kind = "disabled" is the opt-out; PR 14's migration refuses while legacy backup rows remain unless forced. A store transport failure at startup degrades the store component instead of stopping the server. The memory formula is in item text bytes, and PR 8a bounds each PostgreSQL and SQLite snapshot page by SNAPSHOT_FETCH_BYTES (64 MiB) in the page's query, PostgreSQL's cursor replaced by a keyset page under the same snapshot, so a page holds at most the budget plus one row; the PostgreSQL page returns its key columns, orders the outer query and sizes its row limit from the previous page's average row, starting at 16; the PostgreSQL pool rule is max(MIN_POOL_SIZE, 2 * max_concurrent_jobs + 10), 18 at the default 4; the spool default is per backend and spool_host is host plus spool path. - Phase 1 is the PR series with acceptance criteria and GitHub numbers; migration numbers are reserved up front (Cassandra V006 to PR 1, V007 to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill points for the crash tests are enumerated per backend and for DeleteBackup. The Phasing section refers to the companion PRs' heads on the revision date rather than naming commits. migrate-legacy re-reads and validates what it wrote and refuses, keeping the legacy rows, backups it cannot carry. Also adds the missing rows for designs 12 and 13 to docs/design/README.md and marks 15 as a draft. Co-authored-by: Lee Hannigan <lhnng@amazon.com> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
Revision of docs/design/15-backup-restore.md after reviewing the draft against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read, compression, and bulk-load paths on PostgreSQL 16 and SQLite with a 1M-item fixture (localhost; ratios only), eight review rounds and the maintainer's reviews (on 2026-10-28 the sixth round, the maintainer's review and the seventh round; on 2026-10-29 the eighth, a sentence-level verification of the revision against the companions' code and main) against the companion PRs #393, #394 and #395, and a reconciliation against main abf8082 (#384 in its final form, the Cassandra backend #338, the PostgreSQL GSI build #401). The revision log at the end of the document lists each change; the ones that alter behavior: - SnapshotEngine returns the table definition together with the items from one fenced operation (TableSnapshot). Indexes are reported in whatever build state they had and only an index being deleted is dropped; a GSI without its data table is left out with the attribute definitions only it used. On PostgreSQL's shared layout the open is one connection and one REPEATABLE READ transaction; on the separate layout the catalog row is held FOR SHARE until the data snapshot pins, and an index that becomes ACTIVE between the definition read and the pin is reported CREATING. PostgreSQL bounds open snapshots at half the pool and refuses more with LimitExceeded, so open snapshots leave half the pool to requests; from PR 6 on max_concurrent_jobs (default 4) bounds backups and restores together under that permit, and #384's restore slots go. It streams a table whose key columns predate COLLATE "C" unordered rather than sort it, and on both layouts DeleteTable commits the catalog removal only after its drops succeed, base table first, under one 1 s budget, taking its asynchronous path with the row kept when its probe finds a reader or a drop fails in a lock wait (lock timeout, deadlock victim or statement timeout), so no DROP waits behind a reader with the row gone; on the separate layout a task the request awaits commits the catalog and then the data transaction, and a data commit that fails after the catalog commit is retried on a fresh connection for a bounded time before the request returns (an orphan survives only a process death or a data database down past that bound, and the troubleshooting page says how to drop it); the control-plane poller probes held tables and retries each about once a second, at once on a snapshot's or an in-catalog backup's release, a drop DeleteTable deferred is due at once, and DeleteTable of a DELETING table returns its description on PostgreSQL and SQLite. On sqlite-memory the snapshot copies the table on the one connection inside the request and keeps a backup slot until its stream ends, so CreateBackup keeps working there once [backup.store] is set. MongoDB reads without the explicit snapshot level fail, and the document says so with the server's error. - The snapshot is opened in the CreateBackup request (stricter than the service's two-minute window), and the backup id is minted from the instant the open returned, just after the pin, so the backup holds every write acknowledged before the request was received, and backup_creation_date_time, the catalog's created_at and the ARN carry one time and the ListBackups cursor is a function of the ARN. - Per-job claims with fencing replace the single-instance lease the draft assumed: an advisory lock on a dedicated catalog connection on PostgreSQL (#384's seed and key), the row itself on MongoDB (born through an upsert pipeline with $literal), an in-process slot on SQLite, a lightweight-transaction row on Cassandra (restore targets). Heartbeats are a timer on the database's clock with a deadline; any heartbeat that does not confirm the claim aborts the job. Every job status write is conditioned on the generation and on the row's state, and an ambiguous AVAILABLE update is re-read. A job deletes a prefix, or a restore target, only after its FAILED write matched a row. The reconciler's duties are listed once, in section 4, and include the prefixes of DELETED rows, orphan restore targets, stale spools and, on Cassandra, active_restores entries whose target is not a CREATING restore target of the backup. Every duty that reads or writes a backup row acts only on rows of the configured store (store_kind, store_root and the store id); a CREATING or DELETING row of another store is reported and left alone. The PostgreSQL staleness test also selects a NULL heartbeat. While a maintenance claim is held the reconciler only finishes DELETING rows, resolves rows whose owner is dead and runs its FAILED-target duty, which never removes a target left for reason target_not_empty; that one is the operator's, removed with extenddb backup remove-target. The PostgreSQL claim connection sets TCP keepalives, so the loss of the owner's host frees its lock in about a minute. While the configured store is unhealthy the reconciler acts on no row of the store; its legacy-row duty, which touches no store, still runs. - DeleteBackup tombstones the row as DELETING, deletes the manifest first, and marks DELETED only when the prefix is empty. backup sync is scoped to the configured store; extenddb-store.json records the store id and the owning catalog's catalog_id, and a root another catalog owns, rows without the file, rows with two store ids or a missing newest manifest mark the store unhealthy (store operations refused, the reconciler idle on the store's rows) instead of stopping startup, so an unmounted volume, a recreated bucket or two catalogs on one root are caught; backup sync --adopt-store hands a root to another catalog, and sqlite-memory's startup sync takes ownership of a root a sqlite-memory catalog owned the same way, so one catalog owns a root at a time there too; instances that share a catalog must share one store root; it never adopts a CREATING, DELETING, DELETED or FAILED prefix, nor one whose listing lacks a data file the manifest lists at its recorded size, and marks a row whose manifest head is gone DELETING only with --prune-missing. --move-store resolves the old root's CREATING and DELETING rows, compares every data file, refuses to carry a tombstone over a verified manifest and offers --forget-backup; --forget-store deletes the rows of a root. purge, remove-target, --move-store, --forget-store, --prune-missing and --adopt-store take a catalog maintenance claim that every job checks; max_concurrent_jobs is per instance. DeleteBackup and Restore of another store's row return a ValidationException that names no root. Restore and Describe never read the store without a catalog row; an ARN with no row goes to the legacy reader. The store-id file is created with put_if_absent (If-None-Match on S3, O_EXCL and link on the filesystem). A backup is in use while a restore target registered from it is CREATING, as on main: DeleteBackup and a second restore return BackupInUseException. Purge deletes each row's prefix, never a whole account prefix. Every backup row is read by account and backup id and must match the request's account, table and ARN, and no row stores its key prefix. DeleteBackup's in-use check and DELETING write run under the backup row's lock, and a restore's registration re-checks AVAILABLE and in-use under the same lock, so the two serialize as on main (on Cassandra through main's active_restores lightweight transactions, now for new rows too); main's two PostgreSQL race tests stay for the legacy path and PR 6 adds the same two orderings over an off-host backup. - Restore returns CREATING from the request (TableAlreadyExists or TableInUse on a name race, as #384 returns), spools and verifies every data file before writing any item, and records progress atomically with the data on PostgreSQL and SQLite and by idempotent re-application of the one unrecorded file or batch on MongoDB and Cassandra; on MongoDB the job writes DONE and the backfill flag in one transaction and the worker's ACTIVE flip is conditioned on the flag, not on the job's generation. A key-ordered restore runs #394's per-file key-range check and an unordered one ends with a count of the target, which is Cassandra's only duplicate check. A PostgreSQL restore is resumed, or its target deleted, only after the stale window; the manifest and sidecar are spooled for a resume; a legacy restore with a dead owner is failed, not resumed. The ACTIVE flip precedes the deletion of the PostgreSQL progress rows. DeleteTable on a restore target stays refused, as #384 and the service refuse it, and the job and the reconciler remove a failed target through the backend-internal remove_restore_target; the restore record is the table's child. Multi-part-key tables are restored (#384 refuses them; bulk_load keys rows with the backend's item-path functions, so the table is laid out as a natively created one). On PostgreSQL and SQLite bulk_load writes GSI, LSI and vector-index rows in the batch's transaction with the base rows, and the ACTIVE flip sets the table and every index ACTIVE in one catalog transaction; on Cassandra the index rows are created ACTIVE and the lightweight transaction flips the table alone. Table class is not restored, as on the service and #384; every override but VectorIndexOverride is applied, as #384 applies them. A second restore of a backup in use is refused before the permit and re-checked at registration. A restore stops on shutdown leaving its row RUNNING, download transport errors are retried for 15 minutes, and a takeover rewrites spool_host. Each data file is read through ExtenddbManifest::reader_prehashed, which applies the key-range check without hashing the spooled bytes again. - The engine binds a BackupCatalog trait (PR 6) with a JobClaim for the per-backend claim, heartbeat and conditional status writes; new rows live in backup_index (a new table or collection; Cassandra reuses its tables with store_kind set and adds backup_index_by_status); MongoDB's catalog gets the versioned migration step it lacks; PR 6's migration backfills in-flight #384 restores with heartbeat_at at the epoch so the first reconciler pass fails them; every pre-PR-6 server stops before migrate. Vector tables are backed up and restored on the off-host path, while the legacy create keeps main's refusal (one #384 engine test is rewritten in PR 9a and the wire test follows the configured path). source_table carries stream_specification and time_to_live for DescribeBackup. Both requests take the job permit last, after every refusal that needs no store access. The format's evolution rule is a version bump for any byte change, with v{N} fixtures beside v1. The trait gains listings for ListBackups, the store-id check and the root-matched commands, and backup_in_use. ListBackups accepts a table ARN in TableName from PR 6, as the service does. PR 6 adds the [backup] struct with the job keys. Row and restore-target methods take account-scoped keys, and the trait gains catalog_id and DeleteBackup's lock-holding start_backup_delete. - The manifest self-checksum is replaced by a sidecar over the stored bytes, accepted in its sha256sum line form only; backup_arn is the backup's identity and source_table must agree with it; N key values are normalized before key text or key order is computed, and an un-normalized key_range endpoint is refused; the version gate accepts 1 to the current version. The line cap is 32 MiB, sized from the measured worst case, and the compressed size is enforced while reading; both are format rules in section 1. Section 7 cites the decode memory from #394's DataFileDecoder doc (76 and 128 bytes per line byte as of #394, measured without preserve_order; about 4 GiB at the cap). A reader refuses a manifest without stream_specification or time_to_live. The backup job validates every item with an item validator PR 8 adds to extenddb-core, which the restore binds, and fails the backup on a value #618 let in through any of the four write paths, a regression from main for tables that hold one; it validates the rest of the snapshot first and logs every refused item, and the FAILED reason gives the count and the first. - Store (#393): owner-only root and directories re-checked by every operation, an ancestor writable by group or others without the sticky bit refused, a root whose canonical path changed since open refused, the prefix resolved with the same no-follow walk as a key, listings that report only keys passing the key rule, multipart uploads aborted when a put is dropped and swept by delete_prefix (a lifecycle rule as backstop), validate by a one-key listing so a 403 keeps S3's code and then by probes of the grants, a GetObject of an absent key included, a 255-byte component cap and U+FFFE/U+FFFF refused by the key rule, settings under [backup.store]. Section 8 gains a transport rule (a non-loopback http:// endpoint, from the configuration or from the SDK's own endpoint sources, needs allow_insecure_endpoint; an http:// S3 endpoint needs force_path_style unless allow_insecure_endpoint is set; the credential clients' endpoints (STS, SSO, SSO OIDC and the general AWS_ENDPOINT_URL or profile endpoint_url) are held to the loopback rule, the IMDS endpoint setting may be http:// only to loopback or the two IMDS addresses, and a refusal names the client and the setting; S3 Express directory buckets and directory-bucket access points (--x-s3, --xa-s3) are refused; runtime shutdown sends no abort for in-flight puts, so the server's shutdown awaits its jobs' failure paths) and an IAM example with the list actions on the bucket, the KMS actions for an SSE-KMS bucket, the rule that a missing key reads as 404 only under s3:ListBucket, and a delete-marker lifecycle rule for versioned buckets. The endpoint is refused with user information, a query, a fragment or an IPv6 zone id, from any source; the S3 client for an http:// endpoint is built with aws_smithy_http_client's Builder and no proxy, and the credential chain's clients get the same client when a credential endpoint setting, the IMDS endpoint setting or the container credentials URI is http://, so the chain's own http:// requests do not use the proxy; without such a setting the chain uses the SDK's default client and NO_PROXY must list 169.254.169.254 (fd00:ec2::254 in IPv6 mode) and 169.254.170.2; a denied AbortMultipartUpload in delete_prefix's sweep is logged and the deletion succeeds; a put dropped during the temporary's open leaves no temporary. PR 7 adds put_if_absent to the store trait. - Configuration: the default store root is per backend (beside the database file on file-backed SQLite, where a defaulted root that fails validation is a warning, not a startup error); without [backup.store], CreateBackup on every backend keeps the legacy in-catalog create with a deprecation warning until PR 14 removes it (Cassandra always, having no SnapshotEngine), after which it is a ValidationException naming [backup.store], and kind = "disabled" is the opt-out; PR 14's migration refuses while legacy backup rows remain unless forced. A store transport failure at startup degrades the store component instead of stopping the server. The memory formula is in item text bytes, and PR 8 bounds each PostgreSQL and SQLite snapshot page by SNAPSHOT_FETCH_BYTES (64 MiB) in the page's query, PostgreSQL's cursor replaced by a keyset page under the same snapshot, so a page holds at most the budget plus one row; the PostgreSQL page returns its key columns, orders the outer query and sizes its row limit from the previous page's average row, starting at 16; the PostgreSQL pool rule is max(MIN_POOL_SIZE, 2 * max_concurrent_jobs + 10), 18 at the default 4; the spool default is per backend and spool_host is host plus spool path. - Phase 1 is the PR series with acceptance criteria and GitHub numbers; migration numbers are reserved up front (Cassandra V006 to PR 1, V007 to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill points for the crash tests are enumerated per backend and for DeleteBackup. The Phasing section refers to the companion PRs' heads on the revision date rather than naming commits. migrate-legacy re-reads and validates what it wrote and refuses, keeping the legacy rows, backups it cannot carry. Also adds the missing rows for designs 12 and 13 to docs/design/README.md and marks 15 as a draft. The series after the four open PRs is ten PRs: the reconciler ships with the CreateBackup job as PR 8 (a job without its reconciler leaves rows no one resolves), and the CI changes and the documentation ship together as PR 11, since both finalize after PR 9b. The revision log (item 24) records the renaming; its earlier items keep the old labels. Co-authored-by: Lee Hannigan <lhnng@amazon.com> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
New crate extenddb-backup-store: one trait, BackupStore, with put, get, head, list, delete_prefix and validate, and two implementations. The backup job and the restore path (later PRs) write and read portable backups through it. The README and the lib.rs module doc say that the design overview (docs/design/15-backup-restore.md, #396) states the rules the store's callers honour and that this crate's docs are the record of what the store does. Keys are /-separated component paths bounded at 1024 bytes, each component at most 255 bytes (the filesystem's NAME_MAX); empty components, ".", "..", backslashes, control characters and U+FFFE/U+FFFF (which S3's XML listings cannot carry) are rejected. That rule is the upper bound both stores accept; the key module doc is the one list of where each store narrows it, and a backup reads through either store only if its keys avoid those. Prefix matching is component-aligned (prefix a/b does not match key a/bc). put() returns ObjectMeta (bytes written, ETag when the store has one) so the manifest writer does not need a second head() per data file. After a failed put the key holds nothing, the previous object, or the new complete object (a put that stored the object and then failed: the filesystem's directory sync, an S3 complete that timed out client-side), never a partial one. Filesystem store (FilesystemStore): - Every operation resolves its path with one component walk (symlink_metadata per component, owner-only check on each directory, a symlink in an intermediate component refused). put, get and head also refuse a symlink at the final component; for list and delete_prefix a symlink at the prefix itself is nothing there, and symlinks below the prefix are skipped by list and removed as links (never followed) by delete_prefix. list reports only names that pass the key rule; a non-UTF-8 name, a backslash or a temporary is skipped and still deleted. - The symlink checks are path-based and can be raced by a process that can write under the root, so the store requires that only the owner can reach the tree and enforces it on Unix: open() and validate() refuse a root that grants any permission to group or others or is owned by another uid, and a root whose ancestor is owned by a third user or is writable by group or others without the sticky bit. Every operation re-checks the root, refuses it when its canonical path has changed since open (an ancestor moved or replaced by a symlink), and applies the same owner-only rule to every existing directory it descends into, on the way to the prefix as well as below it. Directories are created 0700 and objects 0600 at creation. Errors name the path and the fix (chown / chmod 0700). - Writes go to a temporary sibling (.put-<uuid>.tmp), flush, fsync, rename, fsync of the directory and of each ancestor up to the root. The flush is what surfaces the last write's error: tokio's sync_all waits for the last background write but does not return its error (tokio keeps only its kind for a later write or flush), so without a flush before the drop a short file would be renamed into place. A put that does not reach the rename (an error, its future dropped by a caller's timeout or a cancelled job at any point before the rename, the temporary's open on the blocking pool included, a panicking body) removes the temporary from a drop guard created with the file inside the blocking open; a failure after the rename leaves the new, complete object. Only a process that ends without running the guard (killed, or exiting without waiting for the runtime's blocking threads) leaves a .put-*.tmp; validate's probe has the same guard. Directories a failed put created stay (empty, invisible to list, removed by delete_prefix): removing them raced a concurrent put into the same directory. A put onto a key that is a directory is refused before the body is read. get() opens with O_NOFOLLOW and reports a file removed under it as NotFound. - delete_prefix checks every directory under the prefix against the owner-only rule before it removes anything, so a refusal leaves the prefix whole; it then removes the prefix directory and everything under it, leaves directories above the prefix, and treats an entry another deleter removed first as already gone (not counted), so two reconcilers retrying the same prefix do not fail each other. A delete_prefix whose future is dropped stops before its next entry removal (the blocking walk checks a flag the dropped future sets), as the S3 store's stops at its next request. The trait defines the count once: objects in the call's own listing it removed or found already gone; concurrent filesystem counts partition the objects, concurrent S3 counts may overlap. - validate() re-applies the owner-only and ancestor rules, checks the root's canonical path, and writes, flushes, syncs and removes a probe under the root (a probe a concurrent delete_prefix("") removed first counts as removed). - Paths only a race or a crash reaches (a file removed between the walk and the open, an entry vanishing mid-listing, O_NOFOLLOW after the walk, the canonical check in prepare_parent, the re-checks of directories a concurrent process created or changed, a symlink ancestor in check_root_ownership, the fsyncs of the temporary and of the ancestors) are kept as defence in depth; the module doc lists them as untested. The redundant prefix filter in list and a repeated owner check in delete_prefix("") are removed. S3 store (S3Store), on aws-sdk-s3 with credentials from the SDK chain: - open() refuses an empty bucket, an S3 Express directory bucket or directory-bucket access point name (--x-s3, --xa-s3; the two names the SDK routes to S3 Express), a part size outside 5 MiB to 5 GiB, an endpoint that is not an absolute http:// or https:// URI or that carries user information, a query, a fragment or an IPv6 zone id, or that http::Uri or url::Url refuses (the message names the source the endpoint came from and the problem class, never the value), and an http:// endpoint whose host is not a recognised loopback form (127.x.y.z, an IPv6 address equal to ::1, or localhost) or that is used without force_path_style (the SDK picks virtual-hosted addressing by bucket name: a virtual-hostable name on localhost, an access point ARN on any custom endpoint; on an IP host every plain name is path-style, so the rule is keyed on the scheme because that choice is the SDK's and may change), unless allow_insecure_endpoint is set, which logs a warning. The client is built with S3 Express session auth disabled, so a client that reaches an S3 Express name sends no CreateSession (tested through a test-util open that skips the name refusal). The rules apply to every endpoint setting the SDK reads for a client that sends requests for the store: the S3 client's (the config file's, or one the SDK reads from AWS_ENDPOINT_URL, AWS_ENDPOINT_URL_S3 or the shared config, which open reads and sets on the client explicitly), those of the STS, SSO and SSO OIDC clients the credential providers build (AWS_ENDPOINT_URL_STS, _SSO, _SSO_OIDC, AWS_ENDPOINT_URL, the profile's endpoint_url and services entries), which are checked whether or not the configured credentials use them, and the IMDS endpoint setting (AWS_EC2_METADATA_SERVICE_ENDPOINT or the profile's ec2_metadata_service_endpoint), whose http:// form may name only loopback, 169.254.169.254 or [fd00:ec2::254]; a refusal names the client and the setting. The IMDS and general settings are read from the environment and the selected profile directly, as the SDK's IMDS client and endpoint provider read them (the service-key lookup is kept for S3, STS, SSO and SSO OIDC, whose service-specific variables the SDK does read), so a variable the SDK gives no meaning to, such as AWS_EC2_METADATA_SERVICE_ENDPOINT_ with a trailing underscore, cannot shadow the one it reads. AWS_CONTAINER_CREDENTIALS_FULL_URI keeps the SDK's own rule and is read only for the proxy rule. The settings are read from a first load given a fixed region, so a refused configuration sends nothing; the second load resolves the region (IMDS, off EC2 without region or AWS_EC2_METADATA_DISABLED) and builds the clients. Without the override an interceptor also refuses any http:// request the S3 client would send to another host before it is sent; the credential providers' requests are checked only through their endpoints at open. An http:// endpoint is reached through an HTTP client built with aws-smithy-http-client's Builder (rustls with aws-lc, as the SDK's default) and no proxy, so HTTP_PROXY, http_proxy, ALL_PROXY and all_proxy cannot carry a signed clear-text S3 request off the host; https:// keeps the SDK's proxy behaviour (HTTP CONNECT only, so a socks5:// ALL_PROXY fails it). When any endpoint of the credential chain is http:// (an STS, SSO, SSO OIDC or general setting, AWS_CONTAINER_CREDENTIALS_FULL_URI, or the IMDS endpoint setting), the chain's clients get the same proxy-less client, so a credential request to a loopback emulator never reaches the proxy and its https:// requests bypass HTTPS_PROXY (the S3 client's do not). Without such a setting the chain uses the SDK's default client, and the docs say NO_PROXY must then list 169.254.169.254 (fd00:ec2::254 in IPv6 mode) and 169.254.170.2. - Multipart upload above the configured part size (default 8 MiB) with bounded concurrency, at most 10,000 parts; a failure anywhere after CreateMultipartUpload, including a failed CompleteMultipartUpload, aborts the upload, and a drop guard, armed until the inline abort has returned, aborts it when the put's future is dropped while its runtime runs (a cancelled job). Runtime shutdown drops the guard's spawned abort unpolled, an in-flight CreateMultipartUpload task included, so uploads of puts in flight at shutdown, parts included, stay until a delete_prefix covering them or the lifecycle rule removes them; the trait doc says a caller that must leave nothing awaits its puts first. CreateMultipartUpload runs in a spawned task that arms the guard before handing it back, so a put dropped while that request is in flight still aborts the upload it created; validate's probe create runs the same way. A CreateMultipartUpload the SDK retried after the server created the first leaves an empty upload only the lifecycle rule removes (README). delete_prefix also aborts in-progress uploads under the prefix (component-aligned, as for objects), which sweeps what a killed process left; a denied ListMultipartUploads, or a denied AbortMultipartUpload of one upload (the sweep goes on to the next), is logged and left to the lifecycle rule, so a retry succeeds once the objects are gone; any other listing or abort error fails the call. Requests carry a 60 s attempt and 300 s operation timeout; the attempt includes sending a part, so the docs size upload_part_size_bytes at about the link's bytes/s × 60. A GetObject body, which the SDK streams after those timeouts have ended, is bounded per chunk (60 s between chunks fails the read as a transport error naming the key), beside the SDK's own stalled-stream protection (under 1 B/s for 5 s). Resolving credentials (the whole chain, every AssumeRole hop and an SSO refresh included) is bounded separately by the SDK's 5 s identity-cache load timeout and fails the request with "identity resolver timed out after 5s"; the SDK's retry settings reach the S3 client only, the provider clients keep the standard three attempts. - Paginated listing that skips keys failing the key rule (console folder markers); prefix deletion in DeleteObjects batches of 1000 over the raw listing, with per-key errors surfaced (AccessDenied as PermissionDenied, keys reported without the store prefix). - A key that fits the shared rule but exceeds S3's 1024-byte limit once the configured bucket prefix is added is refused as InvalidKey; a bucket prefix over 512 bytes is refused at open. - Service errors, denials included, are reduced to code and service message in Display (no request or credential material; Debug of a transport error carries the SDK error's Debug, headers included), and a response without an S3 code carries its HTTP status instead ("s3 UploadPart failed: HTTP 500 (no error code in the response)"); NoSuchBucket is a bucket problem and NoSuchUpload an upload problem, neither NotFound; InvalidToken, ExpiredToken and TokenRefreshRequired are PermissionDenied. Transport errors display their source chain, so "dispatch failure" carries its cause (no region, no credentials, connection refused). validate() lists one key under the prefix rather than calling HeadBucket, because a HEAD error has no body: the 403 keeps S3's code (AccessDenied, InvalidAccessKeyId, SignatureDoesNotMatch), a 404 names the missing bucket, a 301 names the bucket's region. It then probes each other grant on a random key under the prefix (DeleteObject and GetObject of the absent key, CreateMultipartUpload then AbortMultipartUpload, ListMultipartUploads); a denial names the missing grant, the create's names kms:GenerateDataKey too, and the GetObject one explains that S3 answers a missing key with 403 when s3:ListBucket does not cover it. A probe abort answered NoSuchUpload passes (a concurrent delete_prefix("") sweeps the probe upload). kms:Decrypt is not exercised. The probe list is documented once, on S3Store's validate. head() on a denied bucket can only report the status ("HTTP 403 (no error code in the response)"), as the README states. The README covers the lifecycle rules a bucket needs (AbortIncompleteMultipartUpload, and on a versioned or Object Lock bucket NoncurrentVersionExpiration and Expiration with ExpiredObjectDeleteMarker, since delete_prefix and each validate leave delete markers there), and its IAM section says the example drops the s3:prefix condition when the prefix is empty. - open_with_interceptor and open_accepting_s3_express_names are test-util-gated seams; test-util also exposes the client config, settable part and page limits, and on the filesystem store a hook run before each removal of a delete_prefix walk, so the tests can hold the walk at a chosen entry. On both stores, a put that overlaps a delete_prefix covering its key may fail or may leave its complete object after the deletion returns; the store gives no ordering. Configuration: BackupStoreConfig models the [backup.store] table (kind = "filesystem" | "s3", default filesystem) with [backup.store.filesystem] and [backup.store.s3] sub-tables. The server will nest it under its own key rather than flatten it, because deny_unknown_fields does not survive serde(flatten). Only the section matching kind is read; open() fails at startup when it is missing or a value is unusable. The struct has no Default: the server supplies the default root. Tests: 33 unit tests (key rule, configuration and open refusals including every endpoint shape the syntax rule refuses, refused without echoing it (the scheme included) and naming its client and source, the clear-text rule including an http:// endpoint without path style (localhost, an access point ARN, and a dotted bucket on an IP host, which the SDK addresses path-style and the rule refuses all the same), Debug of the config without the endpoint's user information, the request-time check, a table pinning map_sdk_error with and without an S3 code) and a conformance suite run against both stores (round trip, overwrite, empty body, listing and prefix rules, list order by key bytes, delete counts, invalid keys, multipart-sized bodies, failed-put cleanup, put/head/list metadata agreement). Filesystem tests (62) cover the ownership and symlink rules (root modes with any group or other bit refused, 0700 and 0500 accepted, world-writable parent and grandparent refused, sticky parent accepted, permissive subdirectory refused through and below the prefix and before delete_prefix removes anything, root widened after open refused by every operation, in-root and escaping symlinks at and above the prefix, symlinks below the prefix removed without following, an ancestor moved and replaced by a symlink refused by every operation), a full disk (a child process with a lowered RLIMIT_FSIZE: the put fails with EFBIG and the previous object survives; validate fails), concurrent delete_prefix, temporaries hidden, delete_prefix("") keeps the root, delete_prefix keeps the directories above the prefix, directory keys not found, a put onto a directory refused before the body is read, failed puts leave only empty directories, a directory-sync failure after the rename keeps the new object, names failing the key rule (non-UTF-8 file and directory names among them) hidden and deleted, a root replaced by a symlink, a dropped delete_prefix removing at most one entry after the drop (the walk held at a chosen removal through the test hook: a file with a sibling after it, the last file of a directory, a directory), validate beside delete_prefix(""), and no temporary left by a put dropped mid-body once its temporary exists, a put whose body panics, or 200 puts and validates aborted after 0 to 400 µs (most during their open). Three of them (a root, subdirectory or ancestor owned by another uid) need chown and skip unless run as root; with EXTENDDB_TEST_REQUIRE_ROOT set they fail instead. S3 tests (54) against moto cover the multipart abort paths (a failed part, a failed complete, NoSuchUpload at complete, a dropped put, a put or a validate dropped while CreateMultipartUpload is in flight (dropped once moto lists the upload, while a delaying TCP proxy holds the response), a put dropped during its inline abort and a validate dropped during its probe abort, through a proxy that swallows the abort, each dropped once the proxy reports the abort held, and an orphaned upload), the part-count limit, the concurrency bound, pagination of objects, and of uploads through an interceptor that cuts pages (moto never truncates ListMultipartUploads), the upload sweep's component-aligned filter, per-key DeleteObjects errors, foreign keys hidden and deleted, every validate probe (GetObject included) and the grant each denial names, a denied upload listing or upload abort in delete_prefix succeeding, and a failing one (500, ExpiredToken) failing it, 301 and 403 (responses rewritten through an interceptor, since moto never produces them), a GetObject body cut after its headers by a TCP proxy failing within the body idle timeout, a transport failure's cause, missing-bucket errors, the prefix and prefix-plus-key length caps, the client's timeouts, region and addressing style (those three run in a child with the suite's own credentials and no proxy variables, so no S3 test needs anything from the environment), an SSO profile reaching the SSO provider, an endpoint from AWS_ENDPOINT_URL, AWS_ENDPOINT_URL_S3 or the shared config refused at open naming its source, endpoint winning over each of them, and eight refused endpoint shapes through endpoint and each SDK source (51 child processes, two of them under AWS_IGNORE_CONFIGURED_ENDPOINT_URLS, which neither lifts a refusal nor drops a loopback setting), an http:// endpoint reached without the environment's proxy and an https:// one through HTTPS_PROXY, a validate whose probe abort answers NoSuchUpload passing, runtime shutdown with puts in flight leaving uploads that delete_prefix sweeps, the credential providers' endpoints refused at open (nine child cells, one with AWS_ENDPOINT_URL shadowed by an https AWS_ENDPOINT_URL_ under https variables for every credential client; a listener on a non-loopback address of the host receives nothing), the IMDS endpoint setting (seven child cells: a non-loopback http:// one from the variable, from the variable beside an https AWS_EC2_METADATA_SERVICE_ENDPOINT_, or from the profile refused with nothing sent, user information refused without the secret, loopback and the two IMDS addresses accepted), the credential providers' requests reaching a loopback http:// endpoint and never the proxy (seven child cells: STS through its own setting and through AWS_ENDPOINT_URL on localhost, the container URI, SSO, an SSO OIDC refresh, the IMDS setting, and an https:// S3 endpoint still through HTTPS_PROXY beside an http:// STS setting), both S3 Express names refused at open, the client sending no CreateSession to an S3 Express name, and an http:// request to another host refused before it is sent. The five tests that put a TCP proxy in front of the endpoint skip, with a printed reason, when it is not http://. 89 hand mutants of the rules fixed in review (34 in round 4, 14 in round 5, 4 in round 6, 13 for the maintainer's review of 2026-10-28, 15 in round 7, 7 in round 8, 2 in round 9); 88 are each killed by the suite. One is not: the one that lets a child inherit the parent's proxy variables fails two child tests only together with the mutant that leaves the proxy enabled. CI: test.yml installs moto from requirements-moto.txt (moto 5.2.4 and its direct dependencies pinned at the versions the last green run resolved; the pip cache is keyed on the file), starts it in server mode, runs the workspace with the store crate excluded, then runs the store crate with the S3 variables scoped to that step and EXTENDDB_TEST_S3_REQUIRED set so the S3 suite fails rather than skips when the endpoint is missing. Both steps use --locked. On the stable leg a further step runs the already-built conformance binary's filesystem tests as root with EXTENDDB_TEST_REQUIRE_ROOT set, so the uid cells run and fail rather than skip if the step is not root; the step sets pipefail. A final step prints moto.log when the job fails. Dependencies: aws-config and aws-sdk-s3 are pinned to the newest releases that build on the workspace rust-version (1.88); their transitive aws-* and aws-smithy-* crates are held in Cargo.lock at 1.88-compatible versions, and .cargo/config.toml sets the resolver's incompatible-rust-versions fallback so a plain cargo update keeps them there. aws-config enables the sso feature (adding aws-sdk-sso and aws-sdk-ssooidc, and linking ring, hex and zeroize, a second cryptography library beside aws-lc-rs) so a server whose credentials come from an SSO profile can reach the bucket. http 1 (already in the graph) is a direct dependency, to parse the endpoint at open; it adds no package. aws-types (=1.3.11, already in the graph through aws-config) is named directly so the store can read the S3-specific endpoint setting; it adds no package. aws-runtime (=1.6.0, already in the graph through aws-config) is named directly so the store can read the selected profile with the SDK's own parser and file set; it adds no package. url (=2.5.8) and aws-smithy-http-client (=1.1.9, rustls-aws-lc), both already in the graph through the SDK, are direct dependencies, to apply the SDK's URL parser at open and to build the proxy-less client for an http:// endpoint; they add no package. One shipped crate moves: crc 3.4.0 to 3.3.0, because crc-fast 1.9.0 (1.10.0 needs Rust 1.89) requires crc ~3.3 and the workspace has one crc (sqlx-core uses it). License notices regenerated for the four feature sets; the crc line is the only content change. Cargo.lock gains duplicate versions none of which is avoidable at this SDK version: foldhash 0.1 and 0.2, hashbrown 0.16 (a fourth version, via lru), http 0.2 and http-body 0.4 beside their 1.x (the SDK's legacy http dependency), sha1 0.10 beside 0.11 (aws-smithy-checksums), spin 0.9 and 0.10 (crc-fast). Not done: list() collects the filesystem walk into a Vec before streaming. The trait promises lexicographic order and a directory walk cannot provide that incrementally; listings under the backup layout are small. The README's list/delete_prefix bullet and the BackupStore::list doc state it, with the cost (about 72 bytes plus the key per object) and that the S3 store's listing is paged. The three bare `cargo build --release` jobs in integration.yml now build the SDK into the workspace build (Build release 5:03 -> 7:51, 4:14 -> 8:07, 4:54 -> 5:56); building `-p extenddb` there, as the Dockerfile does, is a workspace-wide CI change outside this crate. Co-authored-by: Lee Hannigan <lhnng@amazon.com> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
aef17dd to
b3e702f
Compare
Revision of docs/design/15-backup-restore.md after reviewing the draft against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read, compression, and bulk-load paths on PostgreSQL 16 and SQLite with a 1M-item fixture (localhost; ratios only), eight review rounds and the maintainer's reviews against the companion PRs #393, #394 and #395, and a reconciliation against main abf8082 (#384 in its final form, the Cassandra backend #338, the PostgreSQL GSI build #401); then split so that the document is the overview of the series and the companions' own documentation is the record of what their code does (see the last paragraph). The revision log at the end lists each change; the ones that alter behavior: - SnapshotEngine returns the table definition together with the items from one fenced operation (TableSnapshot). Indexes are reported in whatever build state they had and only an index being deleted is dropped; a GSI without its data table is left out with the attribute definitions only it used. On PostgreSQL's shared layout the open is one connection and one REPEATABLE READ transaction; on the separate layout the catalog row is held FOR SHARE until the data snapshot pins, and an index that becomes ACTIVE between the definition read and the pin is reported CREATING. PostgreSQL bounds open snapshots at half the pool and refuses more with LimitExceeded, so open snapshots leave half the pool to requests; from PR 6 on max_concurrent_jobs (default 4) bounds backups and restores together under that permit, and #384's restore slots go. It streams a table whose key columns predate COLLATE "C" unordered rather than sort it, and on both layouts DeleteTable commits the catalog removal only after its drops succeed, base table first, under one 1 s budget, taking its asynchronous path with the row kept when its probe finds a reader or a drop fails in a lock wait (lock timeout, deadlock victim or statement timeout), so no DROP waits behind a reader with the row gone; on the separate layout a task the request awaits commits the catalog and then the data transaction, and a data commit that fails after the catalog commit is retried on a fresh connection for a bounded time before the request returns (an orphan survives only a process death or a data database down past that bound, and the troubleshooting page says how to drop it); the control-plane poller probes held tables and retries each about once a second, at once on a snapshot's or an in-catalog backup's release, a drop DeleteTable deferred is due at once, and DeleteTable of a DELETING table returns its description on PostgreSQL and SQLite. On sqlite-memory the snapshot copies the table on the one connection inside the request and keeps a backup slot until its stream ends, so CreateBackup keeps working there once [backup.store] is set. MongoDB reads without the explicit snapshot level fail (#395's MongoDB module doc gives the server's error). - The snapshot is opened in the CreateBackup request (stricter than the service's two-minute window), and the backup id is minted from the instant the open returned, just after the pin, so the backup holds every write acknowledged before the request was received, and backup_creation_date_time, the catalog's created_at and the ARN carry one time and the ListBackups cursor is a function of the ARN. - Per-job claims with fencing replace the single-instance lease the draft assumed: an advisory lock on a dedicated catalog connection on PostgreSQL (#384's seed and key), the row itself on MongoDB (born through an upsert pipeline with $literal), an in-process slot on SQLite, a lightweight-transaction row on Cassandra (restore targets). Heartbeats are a timer on the database's clock with a deadline; any heartbeat that does not confirm the claim aborts the job. Every job status write is conditioned on the generation and on the row's state, and an ambiguous AVAILABLE update is re-read. A job deletes a prefix, or a restore target, only after its FAILED write matched a row. The reconciler's duties are listed once, in PR 8's section, and include the prefixes of DELETED rows, orphan restore targets, stale spools and, on Cassandra, active_restores entries whose target is not a CREATING restore target of the backup. Every duty that reads or writes a backup row acts only on rows of the configured store (store_kind, store_root and the store id); a CREATING or DELETING row of another store is reported and left alone. The PostgreSQL staleness test also selects a NULL heartbeat. While a maintenance claim is held the reconciler only finishes DELETING rows, resolves rows whose owner is dead and runs its FAILED-target duty, which never removes a target left for reason target_not_empty; that one is the operator's, removed with extenddb backup remove-target. The PostgreSQL claim connection sets TCP keepalives, so the loss of the owner's host frees its lock in about a minute. While the configured store is unhealthy the reconciler acts on no row of the store; its legacy-row duty, which touches no store, still runs. - DeleteBackup tombstones the row as DELETING, deletes the manifest first, and marks DELETED only when the prefix is empty. backup sync is scoped to the configured store; extenddb-store.json records the store id and the owning catalog's catalog_id, and a root another catalog owns, rows without the file, rows with two store ids or a missing newest manifest mark the store unhealthy (store operations refused, the reconciler idle on the store's rows) instead of stopping startup, so an unmounted volume, a recreated bucket or two catalogs on one root are caught; backup sync --adopt-store hands a root to another catalog, and sqlite-memory's startup sync takes ownership of a root a sqlite-memory catalog owned the same way, so one catalog owns a root at a time there too; instances that share a catalog must share one store root; it never adopts a CREATING, DELETING, DELETED or FAILED prefix, nor one whose listing lacks a data file the manifest lists at its recorded size, and marks a row whose manifest head is gone DELETING only with --prune-missing. --move-store resolves the old root's CREATING and DELETING rows, compares every data file, refuses to carry a tombstone over a verified manifest and offers --forget-backup; --forget-store deletes the rows of a root. purge, remove-target, --move-store, --forget-store, --prune-missing and --adopt-store take a catalog maintenance claim that every job checks; max_concurrent_jobs is per instance. DeleteBackup and Restore of another store's row return a ValidationException that names no root. Restore and Describe never read the store without a catalog row; an ARN with no row goes to the legacy reader. The store-id file is created with put_if_absent (If-None-Match on S3, O_EXCL and link on the filesystem). A backup is in use while a restore target registered from it is CREATING, as on main: DeleteBackup and a second restore return BackupInUseException. Purge deletes each row's prefix, never a whole account prefix. Every backup row is read by account and backup id and must match the request's account, table and ARN, and no row stores its key prefix. DeleteBackup's in-use check and DELETING write run under the backup row's lock, and a restore's registration re-checks AVAILABLE and in-use under the same lock, so the two serialize as on main (on Cassandra through main's active_restores lightweight transactions, now for new rows too); main's two PostgreSQL race tests stay for the legacy path and PR 6 adds the same two orderings over an off-host backup. - Restore returns CREATING from the request (TableAlreadyExists or TableInUse on a name race, as #384 returns), spools and verifies every data file before writing any item, and records progress atomically with the data on PostgreSQL and SQLite and by idempotent re-application of the one unrecorded file or batch on MongoDB and Cassandra; on MongoDB the job writes DONE and the backfill flag in one transaction and the worker's ACTIVE flip is conditioned on the flag, not on the job's generation. A key-ordered restore runs #394's per-file key-range check and an unordered one ends with a count of the target, which is Cassandra's only duplicate check. A PostgreSQL restore is resumed, or its target deleted, only after the stale window; the manifest and sidecar are spooled for a resume; a legacy restore with a dead owner is failed, not resumed. The ACTIVE flip precedes the deletion of the PostgreSQL progress rows. DeleteTable on a restore target stays refused, as #384 and the service refuse it, and the job and the reconciler remove a failed target through the backend-internal remove_restore_target; the restore record is the table's child. Multi-part-key tables are restored (#384 refuses them; bulk_load keys rows with the backend's item-path functions, so the table is laid out as a natively created one). On PostgreSQL and SQLite bulk_load writes GSI, LSI and vector-index rows in the batch's transaction with the base rows, and the ACTIVE flip sets the table and every index ACTIVE in one catalog transaction; on Cassandra the index rows are created ACTIVE and the lightweight transaction flips the table alone. Table class is not restored, as on the service and #384; every override but VectorIndexOverride is applied, as #384 applies them. A second restore of a backup in use is refused before the permit and re-checked at registration. A restore stops on shutdown leaving its row RUNNING, download transport errors are retried for 15 minutes, and a takeover rewrites spool_host. Each data file is read through ExtenddbManifest::reader_prehashed, which applies the key-range check without hashing the spooled bytes again. - The engine binds a BackupCatalog trait (PR 6) with a JobClaim for the per-backend claim, heartbeat and conditional status writes; new rows live in backup_index (a new table or collection; Cassandra reuses its tables with store_kind set and adds backup_index_by_status); MongoDB's catalog gets the versioned migration step it lacks; PR 6's migration backfills in-flight #384 restores with heartbeat_at at the epoch so the first reconciler pass fails them; every pre-PR-6 server stops before migrate. Vector tables are backed up and restored on the off-host path, while the legacy create keeps main's refusal (one #384 engine test is rewritten in PR 9a and the wire test follows the configured path). source_table carries stream_specification and time_to_live for DescribeBackup. Both requests take the job permit last, after every refusal that needs no store access. The format's evolution rule is a version bump for any byte change, with v{N} fixtures beside v1. The trait gains listings for ListBackups, the store-id check and the root-matched commands, and backup_in_use. ListBackups accepts a table ARN in TableName from PR 6, as the service does. PR 6 adds the [backup] struct with the job keys. Row and restore-target methods take account-scoped keys, and the trait gains catalog_id and DeleteBackup's lock-holding start_backup_delete. - The manifest self-checksum is replaced by a sidecar over the stored bytes, accepted in its sha256sum line form only; backup_arn is the backup's identity and source_table must agree with it; N key values are normalized before key text or key order is computed, and an un-normalized key_range endpoint is refused; the version gate accepts 1 to the current version. The line cap is 32 MiB and is the floor of data_file_target_bytes; the format's other rules, the decoder's memory included, are #394's module docs, which section 1 points at. A reader refuses a manifest without stream_specification or time_to_live. The backup job validates every item with an item validator PR 8 adds to extenddb-core, which the restore binds, and fails the backup on a value #618 let in through any of the four write paths, a regression from main for tables that hold one; it validates the rest of the snapshot first and logs every refused item, and the FAILED reason gives the count and the first. - Store (#393): section 2 points at the crate's README and module docs and keeps the three store facts the design's rules rest on (put and delete_prefix are unordered, so a caller never puts under a prefix it is deleting; runtime shutdown sends no abort, so the server awaits each job's failure path; listing has no delimiter form) and names put_if_absent as PR 7's addition. Section 8 points at the README's IAM example and config.rs's transport and proxy rules. The revision log (items 21 to 23) records the store facts the companion rounds settled: the endpoint is refused with user information, a query, a fragment or an IPv6 zone id, from any source; the S3 client for an http:// endpoint is built with aws_smithy_http_client's Builder and no proxy, and the credential chain's clients get the same client when a credential endpoint setting, the IMDS endpoint setting or the container credentials URI is http://, so the chain's own http:// requests do not use the proxy; without such a setting the chain uses the SDK's default client and NO_PROXY must list 169.254.169.254 (fd00:ec2::254 in IPv6 mode) and 169.254.170.2; a denied AbortMultipartUpload in delete_prefix's sweep is logged and the deletion succeeds; a put dropped during the temporary's open leaves no temporary. - Configuration: the default store root is per backend (beside the database file on file-backed SQLite, where a defaulted root that fails validation is a warning, not a startup error); without [backup.store], CreateBackup on every backend keeps the legacy in-catalog create with a deprecation warning until PR 14 removes it (Cassandra always, having no SnapshotEngine), after which it is a ValidationException naming [backup.store], and kind = "disabled" is the opt-out; PR 14's migration refuses while legacy backup rows remain unless forced. A store transport failure at startup degrades the store component instead of stopping the server. The memory formula is in item text bytes, and PR 8 bounds each PostgreSQL and SQLite snapshot page by SNAPSHOT_FETCH_BYTES (64 MiB) in the page's query, PostgreSQL's cursor replaced by a keyset page under the same snapshot, so a page holds at most the budget plus one row; the PostgreSQL page returns its key columns, orders the outer query and sizes its row limit from the previous page's average row, starting at 16; the PostgreSQL pool rule is max(MIN_POOL_SIZE, 2 * max_concurrent_jobs + 10), 18 at the default 4; the spool default is per backend and spool_host is host plus spool path. - Phase 1 is the PR series with acceptance criteria and GitHub numbers; migration numbers are reserved up front (Cassandra V006 to PR 1, V007 to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill points for the crash tests are enumerated per backend and for DeleteBackup. The status line names the companion PRs' heads (aef17dd, 3f7107d, 069c4b1); the Phasing section refers to them by PR number. migrate-legacy re-reads and validates what it wrote and refuses, keeping the legacy rows, backups it cannot carry. Also adds the missing rows for designs 12 and 13 to docs/design/README.md; the row for 15 describes the document as the overview of the series and names the crates whose documentation holds the format, store and snapshot details. The series after the four open PRs is ten PRs: the reconciler ships with the CreateBackup job as PR 8 (a job without its reconciler leaves rows no one resolves), and the CI changes and the documentation ship together as PR 11, since both finalize after PR 9b. The revision log (item 24) records the renaming; its earlier items keep the old labels. The document is then split (revision log item 25): sections 1, 2 and 3 point at the companions' documentation (crates/core/src/backup_format and its testdata README, #394; crates/backup-store/README.md and its module docs, #393; crates/storage/src/snapshot.rs and the four snapshot_engine.rs, #395) and keep only the rules later PRs build on; sections 4 and 5 keep the invariants, the state machines and the per-operation contracts; the schema, the per-backend mechanics, the reconciler's duty list and the maintenance commands move into one section per later PR under Phasing (PR 1, 6, 7, 8, 9a, 9b, 10, 11, 13, 14), each with its goal, the invariants it honours, the interfaces it adds with their row keys, the transitions it owns and its acceptance list kept whole; section 10 is a table of which document each PR changes. The three companions gain the facts only this document stated (the filesystem list's whole-root Vec and the empty-prefix IAM example in #393's README; the fetch-size reason and the pre-#361 sort cost in #395's module docs) and a sentence each saying this overview states the rules their callers honour. 279,584 bytes become 187,722. Co-authored-by: Lee Hannigan <lhnng@amazon.com> Signed-off-by: Scott Robinson <robinnsc@amazon.com>
LeeroyHannigan
left a comment
There was a problem hiding this comment.
I re-ran the items from my last review against b3e702f in the shapes I measured them in. The endpoint shapes that carried a password are refused at open from the config file and the four SDK sources, with the source named and the value left out of the message. With HTTP_PROXY, http_proxy, ALL_PROXY or all_proxy set, the signed request to a loopback http:// endpoint went to the endpoint and the proxy listener got nothing, and the child tests pass with a proxy in the parent's environment. With effective_endpoint skipping its is_client_config branch the new endpoint cells fail; 3000 puts dropped after 0 to 400 µs left no temporary in two runs; the five proxy tests skip with their reason against an https:// endpoint; the captured PutObject, UploadPart and DeleteObjects requests carry x-amz-checksum-crc32 as the README says; and with AbortMultipartUpload denied, delete_prefix returned Ok on the first attempt and on the two retries.
On Amazon S3 at this head the 54 s3:: tests pass, with the five proxy tests skipping and naming the http:// endpoint they need.
…de (ExtendDB#396) Revision of docs/design/15-backup-restore.md after reviewing the draft against main 9f43d03 and PRs ExtendDB#384, ExtendDB#335, ExtendDB#336, ExtendDB#337, measuring the read, compression, and bulk-load paths on PostgreSQL 16 and SQLite with a 1M-item fixture (localhost; ratios only), eight review rounds and the maintainer's reviews against the companion PRs ExtendDB#393, ExtendDB#394 and ExtendDB#395, and a reconciliation against main abf8082 (ExtendDB#384 in its final form, the Cassandra backend ExtendDB#338, the PostgreSQL GSI build ExtendDB#401); then split so that the document is the overview of the series and the companions' own documentation is the record of what their code does (see the last paragraph). The revision log at the end lists each change; the ones that alter behavior: - SnapshotEngine returns the table definition together with the items from one fenced operation (TableSnapshot). Indexes are reported in whatever build state they had and only an index being deleted is dropped; a GSI without its data table is left out with the attribute definitions only it used. On PostgreSQL's shared layout the open is one connection and one REPEATABLE READ transaction; on the separate layout the catalog row is held FOR SHARE until the data snapshot pins, and an index that becomes ACTIVE between the definition read and the pin is reported CREATING. PostgreSQL bounds open snapshots at half the pool and refuses more with LimitExceeded, so open snapshots leave half the pool to requests; from PR 6 on max_concurrent_jobs (default 4) bounds backups and restores together under that permit, and ExtendDB#384's restore slots go. It streams a table whose key columns predate COLLATE "C" unordered rather than sort it, and on both layouts DeleteTable commits the catalog removal only after its drops succeed, base table first, under one 1 s budget, taking its asynchronous path with the row kept when its probe finds a reader or a drop fails in a lock wait (lock timeout, deadlock victim or statement timeout), so no DROP waits behind a reader with the row gone; on the separate layout a task the request awaits commits the catalog and then the data transaction, and a data commit that fails after the catalog commit is retried on a fresh connection for a bounded time before the request returns (an orphan survives only a process death or a data database down past that bound, and the troubleshooting page says how to drop it); the control-plane poller probes held tables and retries each about once a second, at once on a snapshot's or an in-catalog backup's release, a drop DeleteTable deferred is due at once, and DeleteTable of a DELETING table returns its description on PostgreSQL and SQLite. On sqlite-memory the snapshot copies the table on the one connection inside the request and keeps a backup slot until its stream ends, so CreateBackup keeps working there once [backup.store] is set. MongoDB reads without the explicit snapshot level fail (ExtendDB#395's MongoDB module doc gives the server's error). - The snapshot is opened in the CreateBackup request (stricter than the service's two-minute window), and the backup id is minted from the instant the open returned, just after the pin, so the backup holds every write acknowledged before the request was received, and backup_creation_date_time, the catalog's created_at and the ARN carry one time and the ListBackups cursor is a function of the ARN. - Per-job claims with fencing replace the single-instance lease the draft assumed: an advisory lock on a dedicated catalog connection on PostgreSQL (ExtendDB#384's seed and key), the row itself on MongoDB (born through an upsert pipeline with $literal), an in-process slot on SQLite, a lightweight-transaction row on Cassandra (restore targets). Heartbeats are a timer on the database's clock with a deadline; any heartbeat that does not confirm the claim aborts the job. Every job status write is conditioned on the generation and on the row's state, and an ambiguous AVAILABLE update is re-read. A job deletes a prefix, or a restore target, only after its FAILED write matched a row. The reconciler's duties are listed once, in PR 8's section, and include the prefixes of DELETED rows, orphan restore targets, stale spools and, on Cassandra, active_restores entries whose target is not a CREATING restore target of the backup. Every duty that reads or writes a backup row acts only on rows of the configured store (store_kind, store_root and the store id); a CREATING or DELETING row of another store is reported and left alone. The PostgreSQL staleness test also selects a NULL heartbeat. While a maintenance claim is held the reconciler only finishes DELETING rows, resolves rows whose owner is dead and runs its FAILED-target duty, which never removes a target left for reason target_not_empty; that one is the operator's, removed with extenddb backup remove-target. The PostgreSQL claim connection sets TCP keepalives, so the loss of the owner's host frees its lock in about a minute. While the configured store is unhealthy the reconciler acts on no row of the store; its legacy-row duty, which touches no store, still runs. - DeleteBackup tombstones the row as DELETING, deletes the manifest first, and marks DELETED only when the prefix is empty. backup sync is scoped to the configured store; extenddb-store.json records the store id and the owning catalog's catalog_id, and a root another catalog owns, rows without the file, rows with two store ids or a missing newest manifest mark the store unhealthy (store operations refused, the reconciler idle on the store's rows) instead of stopping startup, so an unmounted volume, a recreated bucket or two catalogs on one root are caught; backup sync --adopt-store hands a root to another catalog, and sqlite-memory's startup sync takes ownership of a root a sqlite-memory catalog owned the same way, so one catalog owns a root at a time there too; instances that share a catalog must share one store root; it never adopts a CREATING, DELETING, DELETED or FAILED prefix, nor one whose listing lacks a data file the manifest lists at its recorded size, and marks a row whose manifest head is gone DELETING only with --prune-missing. --move-store resolves the old root's CREATING and DELETING rows, compares every data file, refuses to carry a tombstone over a verified manifest and offers --forget-backup; --forget-store deletes the rows of a root. purge, remove-target, --move-store, --forget-store, --prune-missing and --adopt-store take a catalog maintenance claim that every job checks; max_concurrent_jobs is per instance. DeleteBackup and Restore of another store's row return a ValidationException that names no root. Restore and Describe never read the store without a catalog row; an ARN with no row goes to the legacy reader. The store-id file is created with put_if_absent (If-None-Match on S3, O_EXCL and link on the filesystem). A backup is in use while a restore target registered from it is CREATING, as on main: DeleteBackup and a second restore return BackupInUseException. Purge deletes each row's prefix, never a whole account prefix. Every backup row is read by account and backup id and must match the request's account, table and ARN, and no row stores its key prefix. DeleteBackup's in-use check and DELETING write run under the backup row's lock, and a restore's registration re-checks AVAILABLE and in-use under the same lock, so the two serialize as on main (on Cassandra through main's active_restores lightweight transactions, now for new rows too); main's two PostgreSQL race tests stay for the legacy path and PR 6 adds the same two orderings over an off-host backup. - Restore returns CREATING from the request (TableAlreadyExists or TableInUse on a name race, as ExtendDB#384 returns), spools and verifies every data file before writing any item, and records progress atomically with the data on PostgreSQL and SQLite and by idempotent re-application of the one unrecorded file or batch on MongoDB and Cassandra; on MongoDB the job writes DONE and the backfill flag in one transaction and the worker's ACTIVE flip is conditioned on the flag, not on the job's generation. A key-ordered restore runs ExtendDB#394's per-file key-range check and an unordered one ends with a count of the target, which is Cassandra's only duplicate check. A PostgreSQL restore is resumed, or its target deleted, only after the stale window; the manifest and sidecar are spooled for a resume; a legacy restore with a dead owner is failed, not resumed. The ACTIVE flip precedes the deletion of the PostgreSQL progress rows. DeleteTable on a restore target stays refused, as ExtendDB#384 and the service refuse it, and the job and the reconciler remove a failed target through the backend-internal remove_restore_target; the restore record is the table's child. Multi-part-key tables are restored (ExtendDB#384 refuses them; bulk_load keys rows with the backend's item-path functions, so the table is laid out as a natively created one). On PostgreSQL and SQLite bulk_load writes GSI, LSI and vector-index rows in the batch's transaction with the base rows, and the ACTIVE flip sets the table and every index ACTIVE in one catalog transaction; on Cassandra the index rows are created ACTIVE and the lightweight transaction flips the table alone. Table class is not restored, as on the service and ExtendDB#384; every override but VectorIndexOverride is applied, as ExtendDB#384 applies them. A second restore of a backup in use is refused before the permit and re-checked at registration. A restore stops on shutdown leaving its row RUNNING, download transport errors are retried for 15 minutes, and a takeover rewrites spool_host. Each data file is read through ExtenddbManifest::reader_prehashed, which applies the key-range check without hashing the spooled bytes again. - The engine binds a BackupCatalog trait (PR 6) with a JobClaim for the per-backend claim, heartbeat and conditional status writes; new rows live in backup_index (a new table or collection; Cassandra reuses its tables with store_kind set and adds backup_index_by_status); MongoDB's catalog gets the versioned migration step it lacks; PR 6's migration backfills in-flight ExtendDB#384 restores with heartbeat_at at the epoch so the first reconciler pass fails them; every pre-PR-6 server stops before migrate. Vector tables are backed up and restored on the off-host path, while the legacy create keeps main's refusal (one ExtendDB#384 engine test is rewritten in PR 9a and the wire test follows the configured path). source_table carries stream_specification and time_to_live for DescribeBackup. Both requests take the job permit last, after every refusal that needs no store access. The format's evolution rule is a version bump for any byte change, with v{N} fixtures beside v1. The trait gains listings for ListBackups, the store-id check and the root-matched commands, and backup_in_use. ListBackups accepts a table ARN in TableName from PR 6, as the service does. PR 6 adds the [backup] struct with the job keys. Row and restore-target methods take account-scoped keys, and the trait gains catalog_id and DeleteBackup's lock-holding start_backup_delete. - The manifest self-checksum is replaced by a sidecar over the stored bytes, accepted in its sha256sum line form only; backup_arn is the backup's identity and source_table must agree with it; N key values are normalized before key text or key order is computed, and an un-normalized key_range endpoint is refused; the version gate accepts 1 to the current version. The line cap is 32 MiB and is the floor of data_file_target_bytes; the format's other rules, the decoder's memory included, are ExtendDB#394's module docs, which section 1 points at. A reader refuses a manifest without stream_specification or time_to_live. The backup job validates every item with an item validator PR 8 adds to extenddb-core, which the restore binds, and fails the backup on a value ExtendDB#618 let in through any of the four write paths, a regression from main for tables that hold one; it validates the rest of the snapshot first and logs every refused item, and the FAILED reason gives the count and the first. - Store (ExtendDB#393): section 2 points at the crate's README and module docs and keeps the three store facts the design's rules rest on (put and delete_prefix are unordered, so a caller never puts under a prefix it is deleting; runtime shutdown sends no abort, so the server awaits each job's failure path; listing has no delimiter form) and names put_if_absent as PR 7's addition. Section 8 points at the README's IAM example and config.rs's transport and proxy rules. The revision log (items 21 to 23) records the store facts the companion rounds settled: the endpoint is refused with user information, a query, a fragment or an IPv6 zone id, from any source; the S3 client for an http:// endpoint is built with aws_smithy_http_client's Builder and no proxy, and the credential chain's clients get the same client when a credential endpoint setting, the IMDS endpoint setting or the container credentials URI is http://, so the chain's own http:// requests do not use the proxy; without such a setting the chain uses the SDK's default client and NO_PROXY must list 169.254.169.254 (fd00:ec2::254 in IPv6 mode) and 169.254.170.2; a denied AbortMultipartUpload in delete_prefix's sweep is logged and the deletion succeeds; a put dropped during the temporary's open leaves no temporary. - Configuration: the default store root is per backend (beside the database file on file-backed SQLite, where a defaulted root that fails validation is a warning, not a startup error); without [backup.store], CreateBackup on every backend keeps the legacy in-catalog create with a deprecation warning until PR 14 removes it (Cassandra always, having no SnapshotEngine), after which it is a ValidationException naming [backup.store], and kind = "disabled" is the opt-out; PR 14's migration refuses while legacy backup rows remain unless forced. A store transport failure at startup degrades the store component instead of stopping the server. The memory formula is in item text bytes, and PR 8 bounds each PostgreSQL and SQLite snapshot page by SNAPSHOT_FETCH_BYTES (64 MiB) in the page's query, PostgreSQL's cursor replaced by a keyset page under the same snapshot, so a page holds at most the budget plus one row; the PostgreSQL page returns its key columns, orders the outer query and sizes its row limit from the previous page's average row, starting at 16; the PostgreSQL pool rule is max(MIN_POOL_SIZE, 2 * max_concurrent_jobs + 10), 18 at the default 4; the spool default is per backend and spool_host is host plus spool path. - Phase 1 is the PR series with acceptance criteria and GitHub numbers; migration numbers are reserved up front (Cassandra V006 to PR 1, V007 to PR 6); PR 5 is stacked on PR 4 and PR 6 depends on both. Kill points for the crash tests are enumerated per backend and for DeleteBackup. The status line names the companion PRs' heads (aef17dd, 3f7107d, 069c4b1); the Phasing section refers to them by PR number. migrate-legacy re-reads and validates what it wrote and refuses, keeping the legacy rows, backups it cannot carry. Also adds the missing rows for designs 12 and 13 to docs/design/README.md; the row for 15 describes the document as the overview of the series and names the crates whose documentation holds the format, store and snapshot details. The series after the four open PRs is ten PRs: the reconciler ships with the CreateBackup job as PR 8 (a job without its reconciler leaves rows no one resolves), and the CI changes and the documentation ship together as PR 11, since both finalize after PR 9b. The revision log (item 24) records the renaming; its earlier items keep the old labels. The document is then split (revision log item 25): sections 1, 2 and 3 point at the companions' documentation (crates/core/src/backup_format and its testdata README, ExtendDB#394; crates/backup-store/README.md and its module docs, ExtendDB#393; crates/storage/src/snapshot.rs and the four snapshot_engine.rs, ExtendDB#395) and keep only the rules later PRs build on; sections 4 and 5 keep the invariants, the state machines and the per-operation contracts; the schema, the per-backend mechanics, the reconciler's duty list and the maintenance commands move into one section per later PR under Phasing (PR 1, 6, 7, 8, 9a, 9b, 10, 11, 13, 14), each with its goal, the invariants it honours, the interfaces it adds with their row keys, the transitions it owns and its acceptance list kept whole; section 10 is a table of which document each PR changes. The three companions gain the facts only this document stated (the filesystem list's whole-root Vec and the empty-prefix IAM example in ExtendDB#393's README; the fetch-size reason and the pre-ExtendDB#361 sort cost in ExtendDB#395's module docs) and a sentence each saying this overview states the rules their callers honour. 279,584 bytes become 187,722. Signed-off-by: Scott Robinson <robinnsc@amazon.com> Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Backup/restore series (design: #396): #384 restore fidelity (merged) · #393 store · #394 format · #395 SnapshotEngine (stacked on #394) · next: PITR surface (#335 rebased), catalog index, backup job, restore job.
Closes #626 (part of #624).
What
New crate
crates/backup-store: one trait,BackupStore(put,get,head,list,delete_prefix,validate), with a filesystem implementation and an S3 implementation onaws-sdk-s3. The backup job and the restore path (later PRs) write and read portable backups through it; #396 section 2 (docs/design/15-backup-restore.md) states the rules those callers honour and points at this crate's docs for what the store does. The README andlib.rsnow say that the design overview states the rules the store's callers honour and this crate's docs are the record of what the store does; the README'slist/delete_prefixbullet and theBackupStore::listdoc state that the filesystem store returns the whole listing as oneVecbefore the stream yields (about 72 bytes plus the key per object) while the S3 listing is paged, and the IAM section says the example drops thes3:prefixcondition when the prefix is empty. One commit, squashed with #336's work (Lee is co-author); supersedes #336. Rebased onto main after #384 and #338 merged.Keys are
/-separated component paths bounded at 1024 bytes, each component at most 255 bytes (the filesystem'sNAME_MAX); empty components,.,.., backslashes, control characters and U+FFFE/U+FFFF (which S3's XML listings cannot carry) are rejected. That rule is the upper bound both stores accept; thekeymodule doc is the one list of where each narrows it. Prefix matching is component-aligned.putreturnsObjectMeta(bytes written, ETag where the store has one) so the manifest writer needs no secondhead. After a failed put the key holds nothing, the previous object, or the new complete object; never a partial one.Filesystem store. Every key is resolved one component at a time with
symlink_metadata; any symlink, in-root or escaping, is refused onput,get(which also opens withO_NOFOLLOW) andhead.listanddelete_prefixresolve their prefix with the same walk, so a symlink above the final prefix component is refused too; a symlink at the prefix itself is nothing there; below the prefix,listskips symlinks anddelete_prefixremoves them as links without following.Those checks are path-based and can be raced by a process that can write under the root, so the store requires that only the owner can reach the tree, and enforces it on Unix:
open()andvalidate()refuse a root that grants any permission to group or others (read bits included) or is owned by another uid, and a root whose ancestor is owned by a third user or is writable by group or others without the sticky bit. Every operation re-checks the root, refuses it if its canonical path changed sinceopen(an ancestor moved or replaced by a symlink), and applies the owner-only rule to every existing directory it descends into, on the way to alist/delete_prefixprefix as well as below it. Directories are created0700and objects0600at creation. Every refusal names the path and the fix.Writes go to a temporary sibling, flush,
fsync, rename,fsyncof the directory and its ancestors. The flush surfaces the last write's error: tokio'ssync_allwaits for the last background write but does not return its error (tokio keeps only its kind for a later write or flush), so without a flush before the drop a short file would be renamed into place. A put that does not reach the rename (an error, a future dropped at any point before the rename, the temporary's open on the blocking pool included, a panicking body) removes the temporary from a drop guard, as the S3 store aborts its upload; a failure after it (the directory sync) leaves the new, complete object rather than deleting an object that replaced the previous one. Only a process that ends without running the guard (killed, or exiting without waiting for the runtime's blocking threads) leaves a.put-*.tmp. Directories a failed put created stay, empty and invisible tolist, untildelete_prefixremoves them (removing them raced a concurrent put into the same directory, and missed failures inside the creation walk). Listings report only names that pass the key rule; temporaries, non-UTF-8 names and names with a backslash are skipped and still deleted. A put onto a key that is a directory is refused before the body is read. Every operation resolves its path with one component walk.delete_prefixchecks every directory under the prefix against the owner-only rule before it removes anything, so a refusal leaves the prefix whole; it leaves the directories above the prefix and treats an entry another deleter removed first as already gone (not counted; the trait defines the count, and concurrent S3 counts may overlap), so two reconcilers retrying the same prefix do not fail each other. Adelete_prefixwhose future is dropped stops before its next entry removal (the blocking walk checks a flag the dropped future sets), as the S3 store's stops at its next request.validate()re-applies the owner-only and ancestor rules, checks the root's canonical path, and writes, flushes, syncs and removes a probe (a probe a concurrentdelete_prefix("")removed first counts as removed). Paths only a race or a crash reaches (a file removed between the walk and the open, an entry vanishing mid-listing,O_NOFOLLOWafter the walk, the canonical check inprepare_parent, re-checks of directories a concurrent process created or changed, a symlink ancestor, the fsyncs) are kept as defence in depth and listed in the module doc as untested.Consequence for operators, stated in the README and #396: a backup pickup agent (rsync, restic, borg) cannot read the tree as a member of the server's group; it runs as the server's user or as root. A root under a directory owned by a storage administrator or an NFS mount owned by
nobody, or under a group-writable parent such as aroot:staff 0775/srv, is refused at startup.S3 store.
openrefuses an empty bucket, a part size outside 5 MiB to 5 GiB, an endpoint that is not an absolutehttp://orhttps://URI or carries user information, a query, a fragment or an IPv6 zone id, or thathttp::Uriorurl::Urlrefuses (refused with a message that names the source the endpoint came from and the problem class, never the value), and anhttp://endpoint whose host is not a recognised loopback form (127.x.y.z,::1,localhost) or that is used withoutforce_path_style(the SDK picks virtual-hosted addressing by bucket name: a virtual-hostable name onlocalhost, an access point ARN on any custom endpoint; on an IP host every plain name is path-style, so the rule is keyed on the scheme because that choice is the SDK's and may change), unlessallow_insecure_endpointis set, which logs a warning; the error names the client, the host and the endpoint's source.openalso refuses the two names the SDK routes to S3 Express, a directory bucket (--x-s3) and a directory-bucket access point (--xa-s3), and the client has S3 Express session auth disabled, so a client that reaches such a name sends noCreateSession(a test-onlyopenskips the refusal to check that). The rules apply to every endpoint setting the SDK reads for a client that sends requests for the store: the S3 client's (the config file's, orAWS_ENDPOINT_URL,AWS_ENDPOINT_URL_S3or the shared config's, whichopenreads and sets explicitly), those of the STS, SSO and SSO OIDC clients the credential providers build (AWS_ENDPOINT_URL_STS,_SSO,_SSO_OIDC,AWS_ENDPOINT_URL, the profile'sendpoint_urlandservicesentries), which are checked whether or not the configured credentials use them, and the IMDS endpoint setting (AWS_EC2_METADATA_SERVICE_ENDPOINT, the profile'sec2_metadata_service_endpoint), whosehttp://form may name only loopback,169.254.169.254or[fd00:ec2::254]; a refusal names the client and the setting. The IMDS and general settings are read from the environment and the selected profile directly, as the SDK's IMDS client and endpoint provider read them, so a variable the SDK gives no meaning to (AWS_EC2_METADATA_SERVICE_ENDPOINT_,AWS_ENDPOINT_URL_, a trailing underscore) cannot shadow the one it reads; the service-key lookup is kept for S3, STS, SSO and SSO OIDC.AWS_CONTAINER_CREDENTIALS_FULL_URIkeeps the SDK's own rule and is read only for the proxy rule. The settings are read from a load that sends nothing, so a refused configuration sends no request; the load that builds the clients follows, and resolves the region through IMDS when none is configured (off EC2, setregionorAWS_EC2_METADATA_DISABLED=true). Without the override an interceptor also refuses anyhttp://request the S3 client would send to another host before it is sent; the credential providers' requests are checked only through their endpoints atopen. Anhttp://endpoint is reached through an HTTP client built withaws-smithy-http-client'sBuilder(rustls with aws-lc, as the SDK's default) and no proxy, soHTTP_PROXY,http_proxy,ALL_PROXYandall_proxycannot carry a signed clear-text S3 request off the host;https://keeps the SDK's proxy behaviour (HTTPCONNECTonly, so asocks5://ALL_PROXYfails it). When any endpoint of the credential chain ishttp://(an STS, SSO, SSO OIDC or general setting,AWS_CONTAINER_CREDENTIALS_FULL_URI, or the IMDS endpoint setting) the chain's clients get the same proxy-less client, so a credential request to a loopback emulator or sidecar never reaches the proxy (itshttps://requests then bypassHTTPS_PROXYtoo; the S3 client's do not); otherwise the chain uses the SDK's default client and the README saysNO_PROXYmust list169.254.169.254(fd00:ec2::254in IPv6 mode) and169.254.170.2. Multipart above the configured part size (default 8 MiB) with bounded concurrency and at most 10,000 parts; a failure anywhere afterCreateMultipartUpload, including a failedCompleteMultipartUpload, aborts the upload, and a drop guard, armed until the inline abort returns, aborts it when the put's future is dropped while its runtime runs (a cancelled job). Runtime shutdown drops the guard's spawned abort unpolled, an in-flightCreateMultipartUploadtask included, so uploads of puts in flight at shutdown, parts included, stay until adelete_prefixcovering them or the lifecycle rule removes them; the trait doc says a caller that must leave nothing awaits its puts first.CreateMultipartUploadruns in a spawned task that arms the guard and hands it back, so a put dropped while that request is in flight still aborts the upload. A create the SDK retried after the server created the first leaves an empty upload that only the lifecycle rule removes;validate's probe create runs in a spawned task that arms the guard, as a put's does.delete_prefixalso aborts in-progress uploads under the prefix (component-aligned, as for objects), which sweeps what a killed process left; a deniedListMultipartUploadsorAbortMultipartUploadthere is logged and the call succeeds once the objects are gone, and any other listing or abort error fails it; the README recommends anAbortIncompleteMultipartUploadlifecycle rule as the backstop, and on a versioned or Object Lock bucketNoncurrentVersionExpirationplusExpiration { ExpiredObjectDeleteMarker: true }. Requests carry a 60 s attempt and 300 s operation timeout; each part must upload within the attempt, and the docs give the bandwidth per part size. Resolving credentials is bounded separately by the SDK's 5 s identity-cache load timeout (identity resolver timed out after 5s); the SDK's retry settings reach the S3 client only. AGetObjectbody is streamed after those timeouts end and is bounded per chunk (60 s) beside the SDK's stalled-stream protection. Paginated listing that skips keys failing the key rule (console folder markers);DeleteObjectsbatches of 1000 over the raw listing, per-key errors surfaced (AccessDeniedasPermissionDenied, keys without the store prefix). A key that fits the shared rule but exceeds S3's 1024-byte limit once the bucket prefix is added is refused asInvalidKey; a bucket prefix over 512 bytes is refused atopen. Errors are reduced to code and service message inDisplay(no request or credential material;Debugof a transport error carries the SDK's), denials included, so a KMS or explicit-deny message reaches the operator, and a response without an S3 code carries its HTTP status (HTTP 500 (no error code in the response));NoSuchBucketandNoSuchUploadare notNotFound;InvalidToken,ExpiredTokenandTokenRefreshRequiredarePermissionDenied; transport errors display their source chain, so "dispatch failure" carries its cause.validate()lists one key under the prefix rather than callingHeadBucket, because a HEAD error has no body: a 403 keeps S3's code (AccessDenied,InvalidAccessKeyId,SignatureDoesNotMatch), a 404 names the missing bucket, a 301 the bucket's region. It then probes each other grant on a random key under the prefix (DeleteObjectandGetObjectof that absent key,CreateMultipartUploadthenAbortMultipartUpload,ListMultipartUploads), leaving no object or upload (on a versioned bucket, one delete marker), and a denial names the missing grant (kms:GenerateDataKeyfor the create;kms:Decryptis not exercised). A probe abort answeredNoSuchUpload(a concurrentdelete_prefix("")swept the probe upload) passes. The README's IAM section gives the two-resource shape (s3:ListBucketands3:ListBucketMultipartUploadson the bucket ARN, the four object actions onbucket/<prefix>*; a statement on the object ARN alone grants neither list action andvalidatefails with "lack s3:ListBucket"), and notes for S3-compatible servers (only moto has been run; the SDK's default CRC32 request checksums and theWHEN_REQUIREDknobs; TLS trust from the system store, or fromSSL_CERT_FILE/SSL_CERT_DIRalone when either is set (an empty one fails everyhttps://request and abortsopenin a debug build); general-purpose buckets only, since directory buckets return unordered listings).open_with_interceptorandopen_accepting_s3_express_namesaretest-util-gated seams;test-utilalso exposes the client config, settable part and page limits, and a removal hook on the filesystem store'sdelete_prefixwalk.On both stores, a put that overlaps a
delete_prefixcovering its key may fail or may leave its complete object after the deletion returns; the store gives no ordering.Configuration.
BackupStoreConfigmodels the[backup.store]table (kind = "filesystem" | "s3", default filesystem) with[backup.store.filesystem]and[backup.store.s3](which gainsallow_insecure_endpoint, default false). The server will hold it under its own key rather than flatten it, becausedeny_unknown_fieldsdoes not surviveserde(flatten); only the section matchingkindis read. The struct has noDefault; the server supplies the default root.Dependencies and CI.
aws-configandaws-sdk-s3are pinned to the newest releases that build on the workspacerust-version(1.88); their transitiveaws-*/aws-smithy-*crates are held inCargo.lockat 1.88-compatible versions, and.cargo/config.tomlsets the resolver'sincompatible-rust-versions = "fallback"so a plaincargo updatekeeps them there.aws-configenablessso(addingaws-sdk-sso1.93.0 andaws-sdk-ssooidc1.95.0, both 1.88-compatible), so a server whose credentials come from an SSO profile can reach the bucket; the feature also linksring,hexandzeroize, a second cryptography library besideaws-lc-rs.http1 (already in the graph) is a direct dependency for the endpoint check and adds no package.aws-types1.3.11 (already in the graph) is a direct dependency, to read the S3-specific endpoint setting; it adds no package.aws-runtime1.6.0 (already in the graph) is a direct dependency, to name the SDK's shared config file set when reading the selected profile; it adds no package.url(=2.5.8) andaws-smithy-http-client(=1.1.9,rustls-aws-lc), both already in the graph through the SDK, are direct dependencies, to apply the SDK's URL parser atopenand to build the proxy-less client for anhttp://endpoint; they add no package.test.ymlinstalls moto fromrequirements-moto.txt(moto 5.2.4 and its direct dependencies pinned at the versions the last green run resolved, keying the pip cache), starts it in server mode, runs the workspace with this crate excluded, then runs this crate with the S3 variables scoped to that step andEXTENDDB_TEST_S3_REQUIREDset, so the S3 suite fails rather than skips when the endpoint is missing, and printsmoto.logwhen the job fails. One shipped crate moves with them:crc3.4.0 to 3.3.0, becausecrc-fast1.9.0 (1.10.0 needs Rust 1.89) requirescrc ~3.3andsqlx-coreshares the onecrc. The four licence notice files are regenerated; thecrcline is the only content change. Both CI test steps use--locked. On the stable leg a further step runs the already-built conformance binary'sfilesystem::tests as root (sudo env EXTENDDB_TEST_REQUIRE_ROOT=1), so the uid cells run in CI and fail rather than skip if the step is not root; the step setspipefail; nothing is compiled as root.Why
#336 was approved with the filesystem store creating world-readable files under a permissive umask and with its S3 tests never executing in CI (design review of #348). Three adversarial review rounds on this PR then found: a traversable root still exposed permissive subdirectories;
listanddelete_prefixskipped the ownership check and followed a symlink planted above the prefix (an in-root alias madedelete_prefixdestroy another backup; an escaping link made it delete outside the root); a second deleter racing on the same prefix failed with ENOENT, which the design's reconciler contract forbids; the post-rename cleanup deleted an object that had replaced an older one; the S3 abort test never started a multipart upload; the 301 and 403 branches were untested;NoSuchBucketread as a missing object; and the lockfile did not build on the 1.88 toolchain CI tests. A fourth round found: a dropped or crashed put left its multipart upload open, and nothing could abort it;validatereported every misconfiguration as a bare "dispatch failure"; the 403 code the docs promised is not in a HEAD response; the failed-put skeleton cleanup raced a sibling put and missed failures inside the creation walk; listings could yield keysgetrefuses; the uid rule and the post-rename failure path had no test; pagination and per-key delete errors were untested; a sub-tabledeny_unknown_fieldstest tested the top level. The maintainer's review of the fifth revision found: a full disk let a short file replace an object (tokio drops the last write's error atsync_all); the upload sweep matched by string prefix and aborted a sibling's upload; a 256-byte component passed the key rule and failed on the filesystem, and U+FFFE broke S3 listings;validatepassed on credentials that could not delete or abort; a denied upload listing faileddelete_prefixforever;delete_prefix("")deleted entries before refusing a permissive directory; a put dropped duringCreateMultipartUploadleft an upload; an empty bucket, a part over 5 GiB and a plain-HTTP remote endpoint were accepted; SSO profiles failed for want of a feature; token errors were not permission errors; and several claims had no test. The fifth review round found: anhttp://endpoint fromAWS_ENDPOINT_URL,AWS_ENDPOINT_URL_S3or the shared config bypassed the loopback rule, andlocalhostwithout path style sent requests to<bucket>.localhost, a name the resolver may look up in DNS;validatedid not probes3:GetObject; a put dropped during its inline abort left an upload; denials dropped S3's message; on a versioned bucketvalidateanddelete_prefixleave delete markers the docs did not mention; an ancestor moved and replaced by a symlink was refused byputonly; the list of untested paths was incomplete; the root CI step could skip its uid cells silently. A sixth round found: a filesystem put dropped mid-body or whose body panicked left its temporary behind (the S3 half of the same PR had the drop guard, the filesystem half did not); the three S3 tests that send a request without an endpoint failed on a machine without AWS credentials and passed with real ones by using them; a scheme-less endpoint passedopenand failed atvalidatewith the SDK quoting the configured value, password included; a response without an S3 code read asunknownwith its status dropped; theGetObjectbody had no bound of the store's own; the per-keyDeleteObjectstest shared one fixed prefix between processes; moto's dependency tree floated in CI; and the README's IAM paragraph hid the bucket/object resource split that decides whethervalidatepasses and said nothing about the SDK's default request checksums. The maintainer's review of 2026-10-28 found: an endpoint with user information, a query or an IPv6 zone id passedopenand the SDK then quoted it, password included, invalidate's error; a signed clear-text request to a loopbackhttp://endpoint went throughHTTP_PROXY; the configured endpoint's precedence over the SDK's sources had no test; a put dropped while its temporary's open was in flight left the temporary; the loopback refusal named a key the operator may not have set; the child-process tests inherited proxy variables; the README described the request checksum asaws-chunkedtrailers; four proxy-based tests failed rather than skipped against anhttps://endpoint; and a deniedAbortMultipartUploadfailed everydelete_prefixretry. A seventh round found: the SDK's STS/SSO clients followed a clear-textAWS_ENDPOINT_URLor profileendpoint_urlthat the S3 check never looked at, carrying a web identity token off the host;CreateSessionfor a directory bucket bypassed the request check; a dotted bucket on an IPhttp://endpoint passedopen; the docs did not say the credential chain uses the proxy or thattraceSDK logs carry the session token; runtime shutdown sends no abort, which the docs denied; a put racingdelete_prefixcan succeed, which the docs denied; a dropped filesystemdelete_prefixkept deleting;validatefailed besidedelete_prefix(""); and two proxy tests failed under load. An eighth round found: a loopbackhttp://credential endpoint passedopenwhileHTTP_PROXYorALL_PROXYcarried the credential request, token included, to the proxy (the round-7 fix covered the S3 client only);--xa-s3names, which the SDK also routes to S3 Express, opened and were operated as S3 Express targets, so the session-auth flag's "no observable effect" claim was false; the IMDS endpoint setting was unchecked and the README contradicted the config doc about it;SSL_CERT_FILE/SSL_CERT_DIRreplace the trust store, which the README did not say; the dropped-delete_prefixtest did not pin the per-entry stop (two mutants survived); and the docs misdescribed the credential chain,open's IMDS lookup, and the SDK's addressing of a dotted bucket on an IP host. A ninth round found: the IMDS and general endpoint checks read their settings through a service key with an empty service id, which triesAWS_EC2_METADATA_SERVICE_ENDPOINT_andAWS_ENDPOINT_URL_(a trailing underscore, which the SDK never reads) before the real variables, so anhttps://value there hid a non-loopbackhttp://IMDS endpoint from the check while the SDK's client sent to it; and the docs did not state the SDK's 5 s credential-resolution bound, that the retry settings do not reach the provider clients,AWS_DEFAULT_REGION,fd00:ec2::23, the IMDS mode profile key, legacy SSO orlogin_session. The format PR (#394) needs the ETag and size fromput.Testing done
cargo test -p extenddb-backup-store: 33 unit tests and 116 conformance tests (62 filesystem, 54 S3) pass against a tempdir and, withEXTENDDB_TEST_S3_REQUIRED=1and the endpoint pointed at moto 5.2.4, against moto; the same oncargo +1.88.0 test --locked. Thirteen S3 tests need no endpoint (the twoopenlength refusals, client configuration, transport errors against a closed port, SSO, endpoints from the SDK environment, the request-time cleartext refusal, anhttp://endpoint reached without the environment's proxy, the credential providers' endpoints, the IMDS endpoint setting, the credential providers' requests bypassing the proxy, both S3 Express names refused at open, noCreateSessionto an S3 Express name). None needs anything from the environment: the tests that run in a child, every one that sends a request among them, get test credentials (testingkeys, a web identity token file, an SSO token cache or a container token),AWS_REGIONandAWS_EC2_METADATA_DISABLED=true(except the cells whose IMDS endpoint is a loopback listener), and none of the parent's proxy variables; the crate passes with no endpoint, no credentials and an emptyHOME. Three filesystem tests need root and print a skip reason otherwise, or fail whenEXTENDDB_TEST_REQUIRE_ROOTis set; as root all 55 of the previous revision passed in a container, and the CI step runs them as root; the 62 of this revision have not been run as root here (no usable sudo). 89 hand mutants of the rules fixed in review (34 in round 4, 14 in round 5, 4 in round 6, 13 for the maintainer's review of 2026-10-28, 15 in round 7, 7 in round 8, 2 in round 9); 88 are each killed by the suite. One is not: the one that lets a child inherit the parent's proxy variables fails two child tests only together with the mutant that leaves the proxy enabled. The suite also passes withHTTP_PROXYset in the parent, and it passed three times concurrently against one moto with 32 busy loops on 16 cores.Unit tests: the key rule (255-byte components counted in bytes, U+FFFE/U+FFFF and their neighbours, a 1024-byte key made of valid components), configuration and
openrefusals (empty bucket, 5 GiB part ceiling, seven non-loopbackhttp://endpoints refused (including127.0.0.1.evil.example), seven loopback orhttps://ones accepted, anhttp://endpoint refused without path style (localhost, a dotted bucket on an IP host, an access point ARN), every class the syntax rule refuses (empty, scheme-less,ftp://, a stray space, user information, a query, a fragment (?@and#@among them), an IPv6 zone id, a hosturl::Urlrefuses) refused without echoing the value (the scheme included) and naming the client and the source, and the configuration'sDebugwithout the endpoint's user information), the request-time refusal of anhttp://request to another host, and a table pinningmap_sdk_errorfor every code and status cell, with and without an S3 code.Filesystem tests cover: shared conformance (round trip, overwrite, empty body, listing and prefix rules, list order by key bytes, delete counts, invalid keys including a 256-byte component and U+FFFE/U+FFFF, multipart-sized bodies, failed-put cleanup, metadata agreement between put, head and list); 0600/0700 at creation; ten root modes with any group or other bit refused and 0700/0500 accepted; a world-writable parent and grandparent refused, a sticky one accepted; a permissive subdirectory refused by put, get, head, list and delete_prefix whether it is the prefix or above it, and
delete_prefixrefusing before it deletes anything; as root, a root, subdirectory or ancestor owned by uid 65534 refused as the module doc says; a full disk (a child process with a loweredRLIMIT_FSIZE: the put fails with EFBIG and the previous object survives;validatefails); a root widened after open refused by every operation; symlinks (dangling at the destination, dangling in the parent chain, relative, chained, symlinked root, in-root aliases, aliases and escaping links above a prefix, links below the prefix removed without following); two concurrentdelete_prefixcalls both succeed and count every object once; temporaries hidden and still deleted;delete_prefix("")keeps the root usable; a put onto a directory refused before the body is polled; failed puts leave only empty directories; a directory-sync failure after the rename keeps the new object; names failing the key rule (non-UTF-8 file and directory names among them) hidden and deleted; the root replaced by a symlink refused; an ancestor moved and replaced by a symlink refused by every operation;delete_prefixkeeps the directories above the prefix; a put dropped mid-body once its temporary exists, a put whose body panics, and 200 puts and validates aborted after 0 to 400 µs leave no temporary; a droppeddelete_prefixremoves at most one entry after the drop, the walk held at a chosen removal (a file with a sibling after it, the last file of a directory, a directory) through the test hook;validatesucceeds besidedelete_prefix("").S3 tests against moto cover: the shared conformance; the multipart abort paths (a body failing inside the third part, a failed complete,
NoSuchUploadat complete, a dropped put, a put or avalidatedropped whileCreateMultipartUploadis in flight (dropped once moto lists the upload, while a delaying TCP proxy holds the response; a sleep in an interceptor blocked the put's own poll), and an orphaned upload swept); the part-count limit and the in-flight part bound; pagination of objects, and of uploads through an interceptor that cuts the pages (moto never truncatesListMultipartUploads); the sweep's component-aligned filter; a per-keyDeleteObjectsdenial (injected; moto has no IAM); foreign keys hidden and deleted; everyvalidateprobe (GetObjectincluded), the grant each denial names, and no object or upload left behind; a denied upload listing or upload abort indelete_prefixsucceeding, and a 500 orExpiredTokenlisting or abort failing it; a put dropped during its inline abort and avalidatedropped during its probe abort (a proxy swallows the abort, and the drop waits until it has); a 301 and a 403 via a response-rewriting interceptor; aGetObjectbody cut after its headers by a TCP proxy failing within the body idle timeout (moto cannot produce any of these); a transport failure's cause; a missing bucket onvalidate,get,listandput; the length caps; the client's timeouts, region and addressing style; an SSO profile reaching the SSO provider; endpoints fromAWS_ENDPOINT_URL,AWS_ENDPOINT_URL_S3and the shared config refused at open, naming their source, and with the override used by the client;endpointwinning over each SDK source; eight refused endpoint shapes throughendpointand each SDK source (51 child processes, two of them underAWS_IGNORE_CONFIGURED_ENDPOINT_URLS, which neither lifts a refusal nor drops a loopback setting); anhttp://endpoint reached without the environment's proxy and anhttps://one throughHTTPS_PROXY; avalidatewhose probe abort answersNoSuchUploadpassing; runtime shutdown with puts in flight leaving uploads thatdelete_prefixsweeps; the credential providers' endpoints refused at open (nine child cells, one withAWS_ENDPOINT_URLshadowed by anhttpsAWS_ENDPOINT_URL_; a listener on a non-loopback address of the host receives nothing); the IMDS endpoint setting (seven child cells: a non-loopbackhttp://one from the variable, from the variable beside anhttpsAWS_EC2_METADATA_SERVICE_ENDPOINT_, or from the profile refused with nothing sent, user information refused without the secret, loopback and the two IMDS addresses accepted); the credential providers' requests reaching a loopbackhttp://endpoint and never the proxy (seven child cells: STS through its own setting and throughAWS_ENDPOINT_URLonlocalhost, the container URI, SSO, an SSO OIDC refresh, the IMDS setting, and anhttps://S3 endpoint still throughHTTPS_PROXYbeside anhttp://STS setting); both S3 Express names refused at open; the client sending noCreateSessionto an S3 Express name; anhttp://request to another host refused before it is sent. The five proxy-based tests skip against anhttps://endpoint.The test harness's
ensure_bucketnow doesHeadBucketfirst, creates once per process and retriesOperationAborted; moto never answers 409, so that retry is untested.Not changed here
list()collects the filesystem walk into a Vec before streaming. The trait promises lexicographic order, which a directory walk cannot provide incrementally, and listings under the backup layout are small.openat2(RESOLVE_BENEATH), which std does not expose. The ancestor rule is checked atopenandvalidateonly; every operation checks that the root's canonical path has not changed.headon a missing bucket reads as the key being absent and on a denied one asHTTP 403 (no error code in the response): HEAD responses have no body;validateis the check that names the problem..put-*.tmpleft by a process that ended without running the guard (killed, or exiting without waiting for the runtime's blocking threads), or by a guard whose unlink failed, is removed only by adelete_prefixcovering it; the reconciler's prefix deletion is that sweep.cargo build --releasejobs inintegration.ymlbuild the SDK into the workspace build and feature-unifyhyper/client,hyper-util/{client,client-legacy,client-proxy},md-5/stdandnum-integer/defaultinto theextenddbbinary (Build release 5:03 -> 7:51, 4:14 -> 8:07, 4:54 -> 5:56 against abf8082); building-p extenddbthere as the Dockerfile does is a workspace-wide CI change outside this crate.Cargo.lockgains duplicate versions none of which is avoidable at this SDK version:foldhash0.1/0.2,hashbrown0.16 (a fourth version, vialru),http0.2 +http-body0.4 beside their 1.x,sha10.10 beside 0.11,spin0.9/0.10; today they compile only for this crate's tests and the workspace-wide builds.[backup.store]into the server and the backup job PR calls the trait. The licence notices change materially then.Checklist
cargo test --workspace)cargo fmt --check)cargo clippy -- -W clippy::pedantic)Storagetrait, auth model, on-disk format, or public CLI surface, an RFC has been accepted or is linked below. Otherwise, an ADR captures the decision (link below).ADR / RFC: #396, section 2. The
BackupStoretrait is internal to this series and has no implementors outside the crate.Breaking changes
None for users; the crate is not yet consumed. Operators who adopt the filesystem store later need a 0700 root under root- or server-owned ancestors; the README and #396 state the pickup-agent consequence.
.cargo/config.tomlis new to the repository and changes howcargo updatepicks versions for every crate (it prefers versions that build on 1.88).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.