Skip to content

feat(backup-store): store trait with filesystem and S3 implementations [backup/restore 2/4] - #393

Merged
robinnsc merged 1 commit into
mainfrom
feat/backup-store-hardening
Oct 29, 2026
Merged

robinnsc merged 1 commit into
mainfrom
feat/backup-store-hardening

Conversation

@robinnsc

@robinnsc robinnsc commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

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 on aws-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 and lib.rs now 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's list/delete_prefix bullet and the BackupStore::list doc state that the filesystem store returns the whole listing as one Vec before 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 the s3:prefix condition 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'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 narrows it. Prefix matching is component-aligned. put returns ObjectMeta (bytes written, ETag where the store has one) so the manifest writer needs no second head. 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 on put, get (which also opens with O_NOFOLLOW) and head. list and delete_prefix resolve 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, list skips symlinks and delete_prefix removes 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() and validate() 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 since open (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 a list/delete_prefix prefix as well as below it. Directories are created 0700 and objects 0600 at creation. Every refusal names the path and the fix.

Writes go to a temporary sibling, flush, fsync, rename, fsync of the directory and its ancestors. The flush 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, 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 to list, until delete_prefix removes 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_prefix checks 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. 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. 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 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, 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 a root:staff 0775 /srv, is refused at startup.

S3 store. open refuses an empty bucket, a part size outside 5 MiB to 5 GiB, an endpoint that is not an absolute http:// or https:// URI or carries user information, a query, a fragment or an IPv6 zone id, or that http::Uri or url::Url refuses (refused with a message that 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, ::1, 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 error names the client, the host and the endpoint's source. open also 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 no CreateSession (a test-only open skips 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, or AWS_ENDPOINT_URL, AWS_ENDPOINT_URL_S3 or the shared config's, which open reads 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'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, 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, 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_URI keeps 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, set region or AWS_EC2_METADATA_DISABLED=true). 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 or sidecar never reaches the proxy (its https:// requests then bypass HTTPS_PROXY too; the S3 client's do not); otherwise the chain uses the SDK's default client and the README says NO_PROXY must list 169.254.169.254 (fd00:ec2::254 in IPv6 mode) and 169.254.170.2. Multipart above the configured part size (default 8 MiB) with bounded concurrency and 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 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-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 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_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 AbortMultipartUpload there is logged and the call succeeds once the objects are gone, and any other listing or abort error fails it; the README recommends an AbortIncompleteMultipartUpload lifecycle rule as the backstop, and on a versioned or Object Lock bucket NoncurrentVersionExpiration plus Expiration { 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. A GetObject body 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); DeleteObjects batches of 1000 over the raw listing, per-key errors surfaced (AccessDenied as PermissionDenied, 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 as InvalidKey; a bucket prefix over 512 bytes is refused at open. Errors are reduced to code and service message in Display (no request or credential material; Debug of 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)); NoSuchBucket and NoSuchUpload are not NotFound; InvalidToken, ExpiredToken and TokenRefreshRequired are PermissionDenied; transport errors display their source chain, so "dispatch failure" carries its cause. validate() lists one key under the prefix rather than calling HeadBucket, 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 (DeleteObject and GetObject of that absent key, CreateMultipartUpload then AbortMultipartUpload, ListMultipartUploads), leaving no object or upload (on a versioned bucket, one delete marker), and a denial names the missing grant (kms:GenerateDataKey for the create; kms:Decrypt is not exercised). A probe abort answered NoSuchUpload (a concurrent delete_prefix("") swept the probe upload) passes. The README's IAM section gives the two-resource shape (s3:ListBucket and s3:ListBucketMultipartUploads on the bucket ARN, the four object actions on bucket/<prefix>*; a statement on the object ARN alone grants neither list action and validate fails with "lack s3:ListBucket"), and notes for S3-compatible servers (only moto has been run; the SDK's default CRC32 request checksums and the WHEN_REQUIRED knobs; TLS trust from the system store, or from SSL_CERT_FILE/SSL_CERT_DIR alone when either is set (an empty one fails every https:// request and aborts open in a debug build); general-purpose buckets only, since directory buckets return unordered listings). 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 a removal hook on the filesystem store's delete_prefix walk.

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] (which gains allow_insecure_endpoint, default false). The server will hold 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. The struct has no Default; the server supplies the default root.

Dependencies and CI. aws-config and aws-sdk-s3 are pinned to the newest releases that build on the workspace rust-version (1.88); their transitive aws-*/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 sso (adding aws-sdk-sso 1.93.0 and aws-sdk-ssooidc 1.95.0, both 1.88-compatible), so a server whose credentials come from an SSO profile can reach the bucket; the feature also links ring, hex and zeroize, a second cryptography library beside aws-lc-rs. http 1 (already in the graph) is a direct dependency for the endpoint check and adds no package. aws-types 1.3.11 (already in the graph) is a direct dependency, to read the S3-specific endpoint setting; it adds no package. aws-runtime 1.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) 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. 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, 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 and EXTENDDB_TEST_S3_REQUIRED set, so the S3 suite fails rather than skips when the endpoint is missing, and prints moto.log when the job fails. One shipped crate moves with them: 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 sqlx-core shares the one crc. The four licence notice files are regenerated; the crc line is the only content change. Both CI test steps use --locked. On the stable leg a further step runs the already-built conformance binary's filesystem:: 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 sets pipefail; 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; list and delete_prefix skipped the ownership check and followed a symlink planted above the prefix (an in-root alias made delete_prefix destroy 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; NoSuchBucket read 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; validate reported 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 keys get refuses; the uid rule and the post-rename failure path had no test; pagination and per-key delete errors were untested; a sub-table deny_unknown_fields test 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 at sync_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; validate passed on credentials that could not delete or abort; a denied upload listing failed delete_prefix forever; delete_prefix("") deleted entries before refusing a permissive directory; a put dropped during CreateMultipartUpload left 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: an http:// endpoint from AWS_ENDPOINT_URL, AWS_ENDPOINT_URL_S3 or the shared config bypassed the loopback rule, and localhost without path style sent requests to <bucket>.localhost, a name the resolver may look up in DNS; validate did not probe s3:GetObject; a put dropped during its inline abort left an upload; denials dropped S3's message; on a versioned bucket validate and delete_prefix leave delete markers the docs did not mention; an ancestor moved and replaced by a symlink was refused by put only; 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 passed open and failed at validate with the SDK quoting the configured value, password included; a response without an S3 code read as unknown with its status dropped; the GetObject body had no bound of the store's own; the per-key DeleteObjects test 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 whether validate passes 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 passed open and the SDK then quoted it, password included, in validate's error; a signed clear-text request to a loopback http:// endpoint went through HTTP_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 as aws-chunked trailers; four proxy-based tests failed rather than skipped against an https:// endpoint; and a denied AbortMultipartUpload failed every delete_prefix retry. A seventh round found: the SDK's STS/SSO clients followed a clear-text AWS_ENDPOINT_URL or profile endpoint_url that the S3 check never looked at, carrying a web identity token off the host; CreateSession for a directory bucket bypassed the request check; a dotted bucket on an IP http:// endpoint passed open; the docs did not say the credential chain uses the proxy or that trace SDK logs carry the session token; runtime shutdown sends no abort, which the docs denied; a put racing delete_prefix can succeed, which the docs denied; a dropped filesystem delete_prefix kept deleting; validate failed beside delete_prefix(""); and two proxy tests failed under load. An eighth round found: a loopback http:// credential endpoint passed open while HTTP_PROXY or ALL_PROXY carried the credential request, token included, to the proxy (the round-7 fix covered the S3 client only); --xa-s3 names, 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_DIR replace the trust store, which the README did not say; the dropped-delete_prefix test 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 tries AWS_EC2_METADATA_SERVICE_ENDPOINT_ and AWS_ENDPOINT_URL_ (a trailing underscore, which the SDK never reads) before the real variables, so an https:// value there hid a non-loopback http:// 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 or login_session. The format PR (#394) needs the ETag and size from put.

Testing done

cargo test -p extenddb-backup-store: 33 unit tests and 116 conformance tests (62 filesystem, 54 S3) pass against a tempdir and, with EXTENDDB_TEST_S3_REQUIRED=1 and the endpoint pointed at moto 5.2.4, against moto; the same on cargo +1.88.0 test --locked. Thirteen S3 tests need no endpoint (the two open length refusals, client configuration, transport errors against a closed port, SSO, endpoints from the SDK environment, the request-time cleartext refusal, an http:// 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, no CreateSession to 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 (testing keys, a web identity token file, an SSO token cache or a container token), AWS_REGION and AWS_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 empty HOME. Three filesystem tests need root and print a skip reason otherwise, or fail when EXTENDDB_TEST_REQUIRE_ROOT is 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 with HTTP_PROXY set 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 open refusals (empty bucket, 5 GiB part ceiling, seven non-loopback http:// endpoints refused (including 127.0.0.1.evil.example), seven loopback or https:// ones accepted, an http:// 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 host url::Url refuses) refused without echoing the value (the scheme included) and naming the client and the source, and the configuration's Debug without the endpoint's user information), the request-time refusal of an http:// request to another host, and a table pinning map_sdk_error for 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_prefix refusing 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 lowered RLIMIT_FSIZE: the put fails with EFBIG and the previous object survives; validate fails); 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 concurrent delete_prefix calls 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_prefix keeps 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 dropped delete_prefix removes 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; validate succeeds beside delete_prefix("").

S3 tests against moto cover: the shared conformance; the multipart abort paths (a body failing inside the third 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 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 truncates ListMultipartUploads); the sweep's component-aligned filter; a per-key DeleteObjects denial (injected; moto has no IAM); foreign keys hidden and deleted; every validate probe (GetObject included), the grant each denial names, and no object or upload left behind; a denied upload listing or upload abort in delete_prefix succeeding, and a 500 or ExpiredToken listing or abort failing it; a put dropped during its inline abort and a validate dropped 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; a GetObject body 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 on validate, get, list and put; the length caps; the client's timeouts, region and addressing style; an SSO profile reaching the SSO provider; endpoints from AWS_ENDPOINT_URL, AWS_ENDPOINT_URL_S3 and the shared config refused at open, naming their source, and with the override used by the client; endpoint winning over each SDK source; 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_; 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; an http:// request to another host refused before it is sent. The five proxy-based tests skip against an https:// endpoint.

cargo fmt --all -- --check
cargo clippy -p extenddb-backup-store --all-targets -- -D warnings -D clippy::pedantic   # 0 findings
cargo clippy -p extenddb-backup-store --lib -- -D warnings -D clippy::pedantic             # 0 findings, without test-util
cargo clippy --locked --workspace --all-targets -- -D warnings                            # 0 findings
cargo test --locked -p extenddb-backup-store                                              # 33 + 116 (S3 vs moto)
HTTP_PROXY=http://127.0.0.1:1 cargo test --locked -p extenddb-backup-store                # 33 + 116
cargo +1.88.0 test --locked -p extenddb-backup-store                                      # 33 + 116
cargo +1.88.0 check --locked --workspace --all-targets
cargo check --locked --workspace --all-targets                                            # stable
./devtools/generate-software-license-notices --check [--backend mongodb|cassandra]; ./devtools/generate-dev-license-notices --check

The test harness's ensure_bucket now does HeadBucket first, creates once per process and retries OperationAborted; 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.
  • The symlink TOCTOU is bounded by the ownership requirement, not closed; closing it needs openat2(RESOLVE_BENEATH), which std does not expose. The ancestor rule is checked at open and validate only; every operation checks that the root's canonical path has not changed.
  • head on a missing bucket reads as the key being absent and on a denied one as HTTP 403 (no error code in the response): HEAD responses have no body; validate is the check that names the problem.
  • Race-only and crash-only paths have no test; the filesystem module doc lists them.
  • Nothing here runs against Amazon S3, a versioned or Object Lock bucket, or SSE-KMS (moto creates no delete marker for an absent key); the delete-marker and KMS statements rest on the AWS documentation. No S3-compatible server other than moto has been run; the README names the SDK's default request checksums and the knob that turns them off for a server that lacks them.
  • A .put-*.tmp left 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 a delete_prefix covering it; the reconciler's prefix deletion is that sweep.
  • The three bare cargo build --release jobs in integration.yml build the SDK into the workspace build and feature-unify hyper/client, hyper-util/{client,client-legacy,client-proxy}, md-5/std and num-integer/default into the extenddb binary (Build release 5:03 -> 7:51, 4:14 -> 8:07, 4:54 -> 5:56 against abf8082); building -p extenddb there as the Dockerfile does is a workspace-wide CI change outside this crate.
  • Cargo.lock gains duplicate versions none of which is avoidable at this SDK version: foldhash 0.1/0.2, hashbrown 0.16 (a fourth version, via lru), http 0.2 + http-body 0.4 beside their 1.x, sha1 0.10 beside 0.11, spin 0.9/0.10; today they compile only for this crate's tests and the workspace-wide builds.
  • No consumer of the crate yet; the configuration PR wires [backup.store] into the server and the backup job PR calls the trait. The licence notices change materially then.

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic)
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk format, or public CLI surface, an RFC has been accepted or is linked below. Otherwise, an ADR captures the decision (link below).

ADR / RFC: #396, section 2. The BackupStore trait 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.toml is new to the repository and changes how cargo update picks 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.

@robinnsc robinnsc changed the title fix(backup-store): private file modes, root ownership check, put metadata, S3 suite in CI fix(backup-store): private file modes, root ownership check, put metadata, S3 suite in CI [backup/restore 2/4] Oct 13, 2026
@robinnsc
robinnsc force-pushed the feat/backup-store-hardening branch from 954b1b3 to 97512f1 Compare October 13, 2026 06:50
robinnsc added a commit that referenced this pull request Oct 13, 2026
…t code

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

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

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
@robinnsc robinnsc changed the title fix(backup-store): private file modes, root ownership check, put metadata, S3 suite in CI [backup/restore 2/4] fix(backup-store): private file modes, owner-only root, put metadata, S3 suite in CI [backup/restore 2/4] Oct 13, 2026
@robinnsc
robinnsc force-pushed the feat/backup-store-hardening branch from 97512f1 to 3fa24dd Compare October 26, 2026 02:38
robinnsc added a commit that referenced this pull request Oct 26, 2026
…t code

Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture, 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>
@robinnsc
robinnsc force-pushed the feat/backup-store-hardening branch from 3fa24dd to 6921116 Compare October 26, 2026 06:49
robinnsc added a commit that referenced this pull request Oct 26, 2026
Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), three review rounds against
the companion PRs #393, #394 and #395, and a reconciliation against main
744ea29 after #384 merged in its final form and the Cassandra backend
landed. The revision log at the
end of the document lists each change; the ones that alter behavior:

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

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

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
@robinnsc robinnsc changed the title fix(backup-store): private file modes, owner-only root, put metadata, S3 suite in CI [backup/restore 2/4] feat(backup-store): store trait with filesystem and S3 implementations [backup/restore 2/4] Oct 26, 2026
@robinnsc
robinnsc force-pushed the feat/backup-store-hardening branch from 6921116 to 9cbdbee Compare October 26, 2026 20:02
robinnsc added a commit that referenced this pull request Oct 26, 2026
Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), four review rounds against
the companion PRs #393, #394 and #395, and a reconciliation against main
abf8082 (#384 in its final form, the Cassandra backend #338, the
PostgreSQL GSI build #401). The revision log at the end of the document
lists each change; the ones that alter behavior:

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

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

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>

@LeeroyHannigan LeeroyHannigan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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).
  • open accepts an empty bucket, a part size over 5 GiB and an http:// endpoint to a host that is not loopback.
  • aws-config is built without sso, so an SSO profile fails at the first request.
  • 400 InvalidToken and 400 ExpiredToken map to Other, not PermissionDenied.
  • On a versioned bucket delete_prefix leaves noncurrent versions. The README should name the lifecycle rule.
  • ensure_bucket calls CreateBucket in every test. Amazon S3 answers parallel calls on an existing bucket with 409 OperationAborted, and 15 to 20 of the 25 S3 tests fail there.

Once the six inline items are fixed, I will approve.

Comment thread crates/backup-store/src/fs.rs Resolved
Comment thread crates/backup-store/src/s3.rs Resolved
Comment thread crates/backup-store/src/key.rs Resolved
Comment thread crates/backup-store/src/s3.rs Resolved
@robinnsc
robinnsc force-pushed the feat/backup-store-hardening branch from 9cbdbee to b420ea2 Compare October 27, 2026 20:50
robinnsc added a commit that referenced this pull request Oct 27, 2026
Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), five review rounds against
the companion PRs #393, #394 and #395, and a reconciliation against main
abf8082 (#384 in its final form, the Cassandra backend #338, the
PostgreSQL GSI build #401). The revision log at the end of the document
lists each change; the ones that alter behavior:

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

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

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

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

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

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

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

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

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
@robinnsc robinnsc self-assigned this Oct 28, 2026

@LeeroyHannigan LeeroyHannigan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread crates/backup-store/src/s3.rs Resolved
Comment thread crates/backup-store/src/s3.rs Resolved
Comment thread crates/backup-store/src/fs.rs Resolved
Comment thread crates/backup-store/src/s3.rs Resolved
Comment thread crates/backup-store/README.md Resolved
Comment thread crates/backup-store/src/s3.rs Resolved
@LeeroyHannigan LeeroyHannigan added the v1 Needed for the 1.0 release label Oct 28, 2026
@robinnsc
robinnsc force-pushed the feat/backup-store-hardening branch from 013835b to f59ccd1 Compare October 29, 2026 02:48
robinnsc added a commit that referenced this pull request Oct 29, 2026
Revision of docs/design/15-backup-restore.md after reviewing the draft
against main 9f43d03 and PRs #384, #335, #336, #337, measuring the read,
compression, and bulk-load paths on PostgreSQL 16 and SQLite with a
1M-item fixture (localhost; ratios only), seven review rounds and the
maintainer's reviews (on 2026-10-28 the sixth round, the maintainer's
review and the seventh round) against the companion PRs #393, #394 and
#395, and a reconciliation against main abf8082 (#384 in its final form,
the Cassandra backend #338, the PostgreSQL GSI build #401). The revision
log at the end of the document lists each change; the ones that alter
behavior:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>

@LeeroyHannigan LeeroyHannigan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@LeeroyHannigan LeeroyHannigan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM 🚀

@robinnsc
robinnsc added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 169928d Oct 29, 2026
34 checks passed
@robinnsc
robinnsc deleted the feat/backup-store-hardening branch October 29, 2026 17:59
LeeroyHannigan added a commit to yesyayen/extenddb that referenced this pull request Oct 30, 2026
…de (ExtendDB#396)

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

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

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

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

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

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

Labels

v1 Needed for the 1.0 release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backup/restore: backup store crate (filesystem and S3)

2 participants