Skip to content

feat(core): backup format v1: streaming data files, sidecar manifest hash, typed backup ARN [backup/restore 3/4] - #394

Merged
robinnsc merged 1 commit into
mainfrom
feat/backup-format-streaming
Oct 29, 2026
Merged

robinnsc merged 1 commit into
mainfrom
feat/backup-format-streaming

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 this PR) · next: PITR surface (#335 rebased), catalog index, backup job, restore job.
Closes #627 (part of #624).

What

Adds crates/core/src/backup_format, the on-disk format for off-host backups, built from #337's draft with the changes the design review, eight adversarial review rounds and the maintainer's reviews asked for before v1 freezes. It also changes main's wire deserializer for AttributeValue (crates/core/src/types/attribute_value.rs, below). One commit, squashed with #337's work (Lee is co-author); supersedes #337. Rebased onto main after #384 merged, which moved the backup id and ARN parser it introduced into this module (below). backup_format's module doc gains one sentence: the design overview states the rules the format's writers and readers honour; this module's docs and the testdata README are the record of the format itself.

Data files. DataFileEncoder<W: Write> hashes compressed bytes as they leave the compressor, so a writer streaming to a file or an upload holds the gzip window and one serialized line, not the whole file. encode_item_line_into is the one encoding of the {"Item": ...} line, into a buffer the caller keeps; encode_item_line, which the engine's ExportTableToPointInTime writer now calls, and write_item, which reuses one buffer per encoder, both call it. write_item refuses a line over MAX_LINE_BYTES (32 MiB) with the encoder still usable, so a backup cannot contain a line the decoder would refuse at restore. The cap is sized from the measured worst case at the default 400 KB item limit, a list of number sets: 67.6x, a 27.7 MB line, a 1.21x margin (operator-raised item limits above about 496 KB can exceed it). The doc carries the measured table of shapes and a test pins the worst case. A write error poisons the encoder (every later call is Encode); try_finish keeps the sink so an upload can be aborted and finish returns it. Only a successful finish writes the gzip trailer: dropping an encoder in any other state (fresh, open, poisoned) takes the sink out first, so flate2's Drop cannot complete a valid file of the items so far in a sink the caller abandoned. A sink that accepts zero bytes is WriteZero, not a spin; Interrupted is retried on every sink write and flush and on the decoder's source reads. GZIP_LEVEL = 6 is pinned, and a test pins the length and sha256 of a fixed input's output, so two runs of one build produce the same bytes.

DataFileDecoder<R: Read> hashes the compressed bytes it consumes. The recorded compressed and uncompressed sizes are enforced while reading (a source longer than its recorded compressed size fails with the new CompressedSizeMismatch one byte past it); digests and the item count are checked at end of stream; lines are capped at MAX_LINE_BYTES and read into one buffer reused across items (it keeps the longest line's capacity, at most the cap). Decoding one line peaks at about 76 bytes of heap per line byte for a list of empty strings, and at most about 128 for any shape measured (115 for the worst item, lists of one-member map chains; 127 for nested objects that are refused once built), whatever the nesting depth: about 4 GiB at the cap (measured without serde_json's preserve_order, the default binary and cargo test -p extenddb-core; a build with the MongoDB backend enables it through bson and measures about two thirds, 51 for the flat list). DataFileDecoder's doc states the bound once. Any error is sticky and the Iterator impl is fused on top, so error-dropping consumers terminate. Line-level errors name the 1-based line in the file, and serde's column is labelled as within the line when serde's line is 1; a type descriptor or name ending in at line 3 column 4 sets serde's position, and such a message is shown as serde rendered it, with no column of its own. Items are checked for JSON shape only; the decoder does not apply the target's item limits, which the restore applies to each item before writing it. A line ends in \n, except that the last line's may be absent (that line is an item, counted in uncompressed_bytes without a newline), and holds one object with the one member Item (an unknown member or a repeated top-level attribute is refused; a repeated type descriptor at the top of an attribute value is refused on this path and stays last-wins on the request path; nested maps keep JSON's last-wins); JSON whitespace, a \r before the newline included, is accepted as JSON accepts it. Lines are UTF-8 without BOM; the writer emits one gzip member, and the reader accepts concatenated members as one stream, a line spanning members included. verified() keeps the slice path that checks digests before the first item and then decodes without hashing again. CompressedHasher is the incremental hasher a restore runs while spooling a download; DataFileEntry::decoder_prehashed and ExtenddbManifest::reader_prehashed then read the spooled copy, or a file in place under an owner-only store root, without hashing it again (about 0.43 µs per 318-byte item, 9% of decoding, on a CPU without SHA extensions) and keep every size, count and line check; the digests are not compared again, so a replaced file of another length is refused by the compressed-size check and a same-length replacement is not detected.

AttributeValue deserializer (crates/core/src/types/attribute_value.rs, main's wire deserializer). The L and M arms now consume the serde_json::Value they own instead of cloning and re-deserializing it at each level. That copy had made an item PutItem accepts, 30 L wrappers around a list of 409,470 empty strings (31 containers, the maximum), cost 8.6 GB and 12.7 s to decode; the request path shares the deserializer, so this also bounds PutItem (measured in-process: 8,581 MB before, 301 MB after, on a 3.69 MB body). Results and every API error text are unchanged: a nested error is rendered once per level through serde_json::Error::custom, as main's per-level from_value did, so a message ending in at line 0 column 0 (an unknown descriptor of that name inside an L or M) loses one such suffix per level as on main; a reviewer-run differential over 600,000 text inputs and the Value path agreed (not committed), and nested_error_text_is_unchanged pins thirteen nested error texts, seven of that class taken from main's build, one with a suffix more than the levels strip. data_file_nested_decode_memory, which is not ignored, bounds a 32-level item's decode at 120 bytes of heap per line byte.

validation/number.rs (main's code, reached by every format parser and the request path): the exponent arithmetic is done in i64. An exponent at either end of i32 combined with the digits overflowed: a debug build panicked on 10E2147483647, 49E2147483647 and .00E-2147483647, and a release build reported overflow as underflow and the reverse, which is the ValidationException text the API returns. It is fixed in this PR because the format's key checks call it, so every format parser reaches it: each N key of a key_range endpoint (from_json_bytes) and each data-file item's N key (the writer's KeyRangeTracker and the reader's per-file check). Tests pin both reasons at the ends, the zero forms, and a key range and a data-file key carrying 10E2147483647 refused with the overflow reason. An invalid N key's InvalidKey message now carries that reason.

Manifest. Integrity is a sidecar, extenddb-manifest.sha256, in sha256sum format over the stored bytes, replacing an in-document digest over a re-serialized struct that no other implementation could reproduce. verify_manifest_checksum takes the sidecar as the bytes the store returned and accepts one shape: the line the writer stores (BackupDocuments::sidecar), <64 lowercase hex> extenddb-manifest.json, with or without its final \n; bytes that are not UTF-8 and a bare digest, which sha256sum -c refuses too, are refused. from_json_bytes requires a JSON document whose format_version is a u32, gates on it (check_format_version: 1 to CURRENT_FORMAT_VERSION; UnsupportedFormatVersion names the accepted range) before any other member is interpreted, parses, then runs ExtenddbManifest::validate, which BackupDocuments::build also runs so a writer cannot produce bytes the reader refuses: the gate; the source table (item_count and table_size_bytes not negative; table_creation_date_time finite; the key schema has at least one HASH, HASH before RANGE, each key attribute named once and defined exactly once, on every manifest, a multi-part key included, which is backed up unordered); identity: backup_arn is in canonical form and parses, backup_creation_date_time is exactly the f64 its id's instant gives, backup_name is 3–255 of the table-name alphabet, source_table.table_name is the ARN's table, and source_table.table_arn (read with core::arn::parse) is a table ARN in the same partition for the same account and table. Then the data-file list: keys valid and unique, sha256 64 lowercase hex, md5 the standard base64 of 16 bytes, every file has items and non-zero sizes (entry errors are InvalidDataFileEntry, key errors InvalidDataFileKey), backup_size_bytes equal to the sum without overflow, a key_range per file on a key-ordered manifest (key-only endpoints of the schema's shape, first <= last, ranges ascending without overlap against every earlier file; S/B endpoints non-empty and N endpoints refused unless written normalized) and none on an unordered one; an empty table is a manifest with no files. A key_range endpoint that names an attribute twice, or whose value repeats its type descriptor ({"S":"0","S":"A"}) or names two, is refused at parse time, as a data file line of that shape is. Non-overlapping ranges do not by themselves rule out a key in two files: ExtenddbManifest::reader (and reader_prehashed) runs KeyRangeCheck over each file's items when the manifest is key-ordered, without the caller constructing it, and runs its finish after the decoder's end-of-stream checks. The check refuses a first or last item that is not the endpoint, an item not strictly after the one before, an item past last, an empty file (KeyOrderViolation) and an item whose key KeyOrder::endpoint_key refuses (InvalidKey); with the ranges, that rules duplicates out. KeyRangeCheck is a KeyRangeTracker plus the recorded range. The writer runs one tracker over every file it encodes (next_file closes a file with its KeyRange and keeps its last key, so a boundary that does not ascend is refused at the next file's first item, naming the previous file's last key, with nothing of that file encoded; finish closes the last file), so the endpoint check and strict ascent exist once, within and across files. The tracker checks keys in place with an N range key parsed once, and the recorded endpoints are parsed once: per 318-byte item 0.66 µs and 5 allocations, from 1.07 µs and 11.5 (one partition: 0.69 µs and 5.3, from 1.64 µs and 19.3). Error messages carry a bounded slice of any untrusted field: at most 80 bytes of its escaped rendering, otherwise a 48-byte prefix and the total length. FormatError::Json holds a JsonError that bounds serde's text (which quotes the input) to 120 bytes in Display and Debug alike, and does not expose the inner error, as source() or otherwise; ChecksumMismatch prints its expected value with {:?}.

key_order.rs is the one definition of base-table key order: hash key by hash_key_text (S as is, N as held, B standard padded base64) compared as UTF-8 bytes; range key S as UTF-8 bytes, B as raw bytes, N by exact decimal value. The module doc states the N normalization rules, the base64 alphabet, the string encoding and the precision another implementation must reproduce, and a test pins its examples. N endpoints must be valid DynamoDB numbers written in normalized form; the parser reads them as written and refuses 5.0 or 6E0. KeyOrder::resolve refuses multi-part keys, duplicate attribute definitions, a range key listed before the hash key and a range key with the hash key's name; #395 uses it as its orderability test. SnapshotDescriptor carries the three KIND_* constants the backends report, so the manifest and the storage layer share the strings; kind is a free string the reader does not validate and a restore must not branch on. The storage layer's pk_to_text delegates to hash_key_text. The module doc gives the per-backend ORDER BY recipe, including COLLATE "C" on PostgreSQL text keys, and states what main's writers do with an invalid N: PutItem refuses it, BatchWriteItem and TransactWriteItems store it as written (#618), UpdateItem on an absent item and ImportTable run no number check on the key. A key-ordered writer that meets such a stored key refuses the backup naming the key; KeyRangeTracker::push is its one call per item and makes the check KeyOrder::endpoint_key makes, and KeyRangeCheck is that tracker plus the recorded range. The N rules state the exponent's range (a signed 32-bit integer).

Optional source_table members serialize as explicit null. global_secondary_indexes, local_secondary_indexes, vector_indexes, stream_specification and time_to_live are required members (the last three may be null); stream_specification and time_to_live record what DescribeTable and DescribeTimeToLive reported, so DescribeBackup can report SourceTableFeatureDetails as #384 does; neither is restored. OnDemandThroughput writes null for an absent member, as on the wire, and the module doc records it; vector_indexes is three-state (null / [] / list) and holds the creation shape; sse_description, item_order, per-file md5 and uncompressed_bytes. Golden fixtures testdata/extenddb-manifest/v1.json (every optional member set, a stream specification and a TTL description included), v1-minimal.json (every optional member absent, every list empty) and v1-unordered.json ("key_range":null files and the absent state of every nested optional member v1.json sets: OnDemandThroughput with both limits null, an SseDescription without SSEType or KMSMasterKeyArn, a GSI without ProvisionedThroughput, a disabled StreamSpecification without StreamViewType, a disabled TTL without AttributeName; and a vector index with SearchSchema and Projection, an LSI with INCLUDE), each with its .sha256, compared byte for byte; a non-ignored test binds the writer's three constructors to the checked-in bytes, so the two copies cannot drift. They are permanent read tests: the #[ignore]d writer (EXTENDDB_WRITE_GOLDEN) writes v{CURRENT} fixtures with create_new and refuses to overwrite an existing file. The evolution rule, stated on ExtenddbManifest and in the testdata README: any change to the bytes written for the fixtures' inputs is a version bump, a new member included; a new member omitted when absent does not change those bytes, so nested_types_have_the_pinned_members pins the members every manifest type reads and writes; a new or renamed enum value no fixture uses changes no byte either, so enum_values_are_pinned pins every manifest enum's values (the derived ones through serde's variant list; KeyType, ScalarAttributeType, DistanceFunction and AttributeValue's ten type descriptors, read by hand-written code, through tables filled from a match with no wildcard, the descriptors also checked to be the only ones accepted among every string of one to four upper-case letters); a new member gets #[serde(default)] and v{N} fixtures beside the v1 ones; readers accept 1..=CURRENT, so a bump excludes older readers from newer backups whatever they contain. Unknown members are tolerated at every level; one test injects one into every object of two fixtures.

DynamoDB export manifest. ExportFileEntry.etag is Option<String>; dataFileS3Key is the full store key, <prefix>/data/<file> (for a full export to a prefix the service writes <s3Prefix>/AWSDynamoDB/<export id>/data/<file>, measured; the documentation's full-export example omits the prefix; the module doc states both). DataFileEntry::from_checksums builds each entry from the encoder's result, and BackupDocuments::build(header, data_files, store_key_prefix, bucket), each data file paired with the ETag its put returned, assembles the manifest, its sidecar, manifest-files.json and manifest-summary.json from one input. It derives backup_size_bytes, backup_creation_date_time and the key prefix (the store's key prefix, every leading and trailing / dropped, then BackupArn::store_prefix; the store normalizes its configured prefix itself and refuses an empty component, so the two agree on every prefix it accepts) once and validates once. Each manifest-files.json line comes from the manifest's DataFileEntry, so the two manifests carry one md5 and one item_count, and the module doc tables every summary member's value: exportArn the backup ARN; the three instants the snapshot instant in RFC 3339 UTC with milliseconds; s3Prefix the backup prefix with every leading and trailing / dropped once (a// and /a/ are a); manifestFilesS3Key <s3Prefix>/manifest-files.json, built by the function that builds dataFileS3Key, so an empty prefix gives manifest-files.json; s3SseAlgorithm null; billedSizeBytes the compressed size; itemCount the sum. s3SseAlgorithm is Option<String>. The type documents the write order: documents_before_manifest() (the two export manifests and the sidecar), the job's re-check of its claim, then manifest_document(); documents() is the four in that order. for_backup, from_data_file, write_files_manifest, to_json_bytes and manifest_checksum are private to backup_format.

Layout: one backup id and ARN implementation. #384 added a backup id and ARN parser in extenddb_storage::util::backup_id; this PR moves it into extenddb_core::backup_format::layout unchanged in behaviour (format_backup_id, is_backup_id, backup_id_epoch_millis, backup_creation_date_time, check_backup_arn, parse_backup_arn, BackupArnParts, BackupArnError), on top of core::arn::fields (the split authorization uses) and validation::validate_table_name_chars (the alphabet CreateTable uses; the engine's validate_name_member and validate_index_name still spell it themselves). format_backup_arn is the one backup-ARN formatter (storage's backup_arn(), BackupArn and the server's canonical resource ARN call it) and trim_backup_arn the one whitespace rule (the parser and both authorization sites call it); it trims both ends as main did, and the doc notes that the service resolves a trailing newline but refuses a leading space or tab. extenddb_storage::util re-exports the rest so the engine and the four backends keep their import path, and keeps new_backup_id (needs rand), backup_arn and is_backup_resource_arn. BackupArn is the owned form the format uses: check_backup_arn plus the two store-prefix rules (table name at most 255 bytes and not only dots). The 255-byte bound is narrower than max_table_name_length when an operator raises it; the layout doc says so, and the design has CreateBackup refuse such tables.

Why

Design review of #348 against #337 (#396, section 1): the encoder buffered each whole compressed file and hashed it in a second pass; the manifest checksum had an undocumented canonical form; etag was required but the store cannot supply one; optional fields were omitted rather than null; vector indexes were serialized as runtime descriptions; the ARN parser hardcoded arn:aws. The review rounds on this PR then found the sticky decoder made the Iterator impl non-terminating, a dropped poisoned encoder still wrote into its sink, non-DynamoDB numbers passed as range endpoints, backup_arn and source_table were never cross-checked, the encoder could write a line the decoder refuses, the streaming decoder's digest path and the base64 shape of md5 were untested, backup_creation_date_time had two definitions (the id's instant and a separate field), and the crate carried a second copy of #384's ARN parser. The fourth round found the cap's sizing claim wrong (9x, not 7.5x), a dropped open encoder completing a valid file in its sink, Interrupted fatal on flush and on the decoder's source, the time equality a half-millisecond window, unbounded untrusted fields in error messages, zero-item files accepted, duplicate attributes in a key_range kept last-wins, and a dozen validation rules no test distinguished from a weaker one. An earlier review by the maintainer then found: the cap refused items at the default limit (a list of N of 1E-130 is 47x; number sets are worse); the compressed size was unchecked until end of stream, so an oversized source was read in full; error messages measured raw length rather than the escaped form, and serde's messages were unbounded; the md5 and sidecar shape rules accepted strings that are not what they claim; overlap was checked only between neighbours and size sums could wrap; a repeated top-level attribute was kept last-wins; and several tests could not tell the rule from a weaker one. The fifth round found: the decode-memory bound was false by a factor of the nesting depth, because the AttributeValue deserializer copied each nested value at every level, so a 32-level item PutItem accepts at the default limits needed 8.6 GB to decode, in a restore and on the request path; non-overlapping key ranges did not rule out a key in two files, and nothing tied a file's items to its range; un-normalized N endpoints were read silently as normalized, so the verified bytes had two readings; KeyOrder::resolve accepted one attribute as both hash and range key; the golden fixtures missed shapes the docs said they pin, and OnDemandThroughput's null contradicted the module doc; the version rule and the fixture-regeneration rule could not both hold after a version bump; ChecksumMismatch printed its untrusted expected unescaped and FormatError::Json's Debug printed serde's whole message; data-line errors named "line 1" for every line; and several facts had more than one copy. The sixth round found that the format's evolution rules contradicted each other (the design allowed an additive member without a bump while the fixtures refused any byte change), that the writer's fixture constructors were an unbound second copy of the frozen files, that unknown-member tolerance was tested at the top level only, that the writer could serialize a manifest the reader refuses, that the N normalization rules, base64 alphabet, string encoding and comparison precision a non-Rust reader needs were stated nowhere, that the summary manifest's values for a backup were undefined, that md5 and item_count existed in two manifests with no binding, that multi-member gzip acceptance was unstated, and that the manifest could not carry the stream and TTL descriptions #384's DescribeBackup reports (#396 M2). The maintainer's review of 2026-10-28 found that the key-order doc named BatchWriteItem and TransactWriteItems among the writers that refuse an invalid N key, which they store (#618), and stated no rule for a key-ordered writer that meets one; that the doc did not state the measured dataFileS3Key shape; that one class of nested error text differed from main; that KeyRangeCheck's equal case was not equal; that an endpoint repeating its type descriptor was read last-wins; that a non-finite timestamp passed the writer and failed the reader; that no fixture held the absent state of four nested members and a new omitted member changed no bytes; that manifestFilesS3Key had a leading / for an empty prefix; and that the N rules did not state the exponent's range. The seventh round found that no test pinned the manifest's enum values, so a new TimeToLiveStatus or a renamed AES256 changed the format unnoticed; that main's number.rs overflowed on exponents at the ends of i32, reachable from every format parser; that the reader contract did not state the unterminated last line the reader accepts; that the over-strip direction of nested error texts was untested; that a// gave the summary a different prefix from the data keys; that a descriptor name could forge a data-line error's column; that the writer had no ascent check, so a snapshot out of order would publish a backup every restore refuses; that KeyRangeCheck cost 11 to 19 allocations per item; that a restore hashed every compressed byte twice; that the caller assembled four derived facts and two prefixes the format then re-checked; and that nothing made a restore run the key range check. The eighth round found that the format and the store disagreed on a leading / in the configured prefix; that AttributeValue's descriptors were the one manifest enum no test pinned; that reader_prehashed's not hashing, the summary's itemCount rule and build's own size check had no test that told them from a weaker rule; that a key-order violation at a file boundary was found only at build, after every file was uploaded; that decoder_prehashed's safety condition excluded the design's in-place read; that the decoder allocated a line buffer per item; and that the sidecar check took &str, build took a parallel etag slice and documents() did not bind the write order's split around the claim re-check. All of these are format decisions that are expensive to change after v1 ships.

Testing done

cargo test -p extenddb-core: 695 pass, 1 ignored (the fixture writer); 175 of them under backup_format (174 run). The same 695 pass in cargo test --workspace --lib, where bson turns preserve_order on. Three integration binaries in crates/core/tests: data_file_line_bomb (a 256 MiB single-line bomb stops within twice the cap's memory), data_file_nested_decode_memory (a 32-level item decodes within 120 bytes of heap per line byte; it measures about 1,200 if either the L or the M arm copies its subtree; 76 in the default build, 51 in CI's --workspace run where bson turns preserve_order on) and data_file_decode_peak (ignored; measures the decode RSS at the cap: 2.44 GiB in release). cargo test -p extenddb-storage 130, -p extenddb-engine 178, -p extenddb-server 202 pass (the re-exports, pk_to_text delegation, encode_item_line in the export writer, the authorization sites' trim_backup_arn, and the request path through the changed deserializer); cargo test -p extenddb-storage-sqlite --lib 144 pass, 1 ignored, including the test binding encode_orderable_number's byte order to KeyOrder::compare over a fixed pseudo-random corpus of DynamoDB numbers. The same on cargo +1.88.0 test --locked -p extenddb-core. 32 hand mutants of the rules fixed in round 4 and 39 of the round-5 fixes were each killed by the suite; 20 of the round-6 fixes were killed, one was equivalent (a dead branch in number.rs) and was replaced by three that were killed. 13 mutants of the fixes for the maintainer's review of 2026-10-28 were each killed, among them the five that review found surviving (KeyRangeCheck's equal case, a repeated endpoint descriptor, and the fixtures' absent states, the MaxReadRequestUnits case included) and a new optional member with skip_serializing_if. 15 mutants of the round-7 fixes were each killed, among them the three enum mutants that survived before that round, the per-level strip applied twice, a one-slash store_key, the tracker without its ascent check and the reader without its check. 18 mutants of the round-8 fixes were each killed at the test it targets, among them the four that survived that round (an AttributeValue descriptor alias, reader_prehashed hashing anyway, the summary's itemCount from source_table, build's size sum wrapping). The request-path memory figure is from an in-process run of the handler's two parse steps, not from the server binary.

Tests in the module cover: the streaming encoder through a short-write sink matches the in-memory encoder byte for byte; determinism and the pinned output digest; a sink error poisons the encoder and dropping it writes nothing more; unfinished-encoder drops write nothing; try_finish failure poisons; an Ok(0) sink is an error within a deadline; Interrupted on every sink and source call; the line cap refuses with the encoder usable, and the worst-case item at the default limit fits it; incremental CompressedHasher equals one-shot; the streaming decoder's end-of-stream digest check; DataFileEntry::decoder carrying every expectation; the uncompressed and compressed size caps firing early; wrong item counts in both directions; a tampered stream never completes cleanly; trailing bytes; invalid UTF-8; an unterminated final line; sticky errors and the fused iterator; a repeated top-level attribute; verified() rejects before the first item; sidecar round trip, tampering and 27 refused shapes, the bare digest and four non-UTF-8 byte strings among them; the version gate runs first and accepts 1 to CURRENT_FORMAT_VERSION only and names the range in its message; the writer's constructors equal the fixtures; unknown members at every level of two fixtures (48 injections); key-schema shape rules on an unordered manifest and a multi-part key accepted; to_json_bytes refusing an un-normalized endpoint, an invalid stored N key as an endpoint, a size off by one, a 13-digit id, a later version and a non-finite timestamp; from_checksums and from_data_file agreeing with the encoder and the entry; for_backup's members and the millisecond timestamp; the key-order doc's normalization examples; identity rules (ARN form, creation time, backup_name, source_table table, account and partition); negative source counts; required index members; key_range rules (unordered manifests, overlap against any earlier file, unsorted, non-key, un-normalized, empty S/B or wrong-type endpoints in first and last, duplicate attributes, a repeated or second type descriptor); the per-file key-range check's seven refusals (a repeated key and an invalid N key among them; a first item that is not first through reader and a file with no items through reader added) and two files with non-overlapping ranges each holding every key; the same through ExtenddbManifest::reader, sticky and fused; the writer's KeyRangeTracker refusing an out-of-order or repeated key and agreeing with KeyOrder::compare on S, N and B keys and, across files through next_file, refusing a boundary that does not ascend at the next file's first item with nothing encoded; the prehashed decoder keeping every check but the digest, its uncompressed count one too high caught at the end of the stream, and reader_prehashed reading a wrong-digest file reader refuses; BackupDocuments' four documents agreeing on one prefix and reproducing v1.json and its sidecar, each etag on its file, the three-then-one write order, a leading / dropped, the summary's itemCount as the sum when source_table.item_count differs; the pinned values of every manifest enum, AttributeValue's ten descriptors included; N exponents at both ends of i32; a forged position in a data-line error; a reused encoder line buffer; resolve refusing RANGE before HASH and a shared name; B hash keys in base64 order; bytewise-versus-length-first range-key pairs; N range keys differing beyond 17 digits; md5 shape cells; size-sum overflow; zero-item files; bounded messages at field lengths from 81 bytes to 2 MB, for keys, names, ARNs and serde's text; bounded Debug of FormatError::Json; an escaped ChecksumMismatch; line numbers in data-line errors; explicit nulls (v1-minimal.json for the source table's optional members, v1-unordered.json for "key_range":null and OnDemandThroughput's two absent members; v1.json has none); every optional nested member set in v1.json and absent in v1-unordered.json; the pinned member lists of the 18 manifest types; three-state vector_indexes; the golden manifests (three fixtures) re-serialize byte for byte; nested deserializer error texts pinned; the layout parser's id-shape grid and its two divergences from BackupArn; a differential run of the moved parser against #384's over 5.9M inputs found no difference (reviewer-run, not committed). The DynamoDB-layout fixture is constructed from the documented export shape, not captured from the service; the module doc says so.

cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo clippy -p extenddb-core --all-targets -- -D warnings -D clippy::pedantic   # 0 findings under backup_format/, crates/core/tests/ and the changed lines of attribute_value.rs and validation/number.rs (clippy 1.98; 1.97's doc_markdown flags backend names in some unchanged prose)
cargo test -p extenddb-core                                                      # 695 passed, 1 ignored
cargo test -p extenddb-storage -p extenddb-engine -p extenddb-server
cargo test -p extenddb-storage-sqlite --lib
cargo +1.88.0 test --locked -p extenddb-core

Not changed here

  • No writer or reader of the format exists yet outside tests and the export writer's shared line encoder; the backup job and restore PRs are the first consumers.
  • The item validator (number normalization, size limits, key types against the manifest schema) is a PR 8 deliverable in extenddb-core that the restore binds (docs(backup): design revision for off-host backup and restore [backup/restore 1/4] #396); the DataFileDecoder doc states what the decoder checks and that it does not apply the target's item limits.
  • The engine's account check on backup ARNs still goes through check_backup_arn (now in core) rather than the owned BackupArn; the catalog PR adopts BackupArn in the engine.
  • KeyOrder::compare parses N range keys on every call; it serves validate_data_files and feat(storage): SnapshotEngine, a consistent table snapshot on every backend [backup/restore 4/4] #395. The per-item checks (KeyRangeTracker, KeyRangeCheck) parse each item's N once and the recorded endpoints once.
  • A live DynamoDB ImportTable compatibility check of data/ is Phase 2 in the design.
  • The engine's ImportTable reader (read_dynamodb_json) is a second, laxer reader of the data-file line; the data_file module doc lists the differences, and binding it is a follow-up.
  • Copies of format facts in the engine and server (the name alphabet in validate_name_member and validate_index_name, the partition constants, the table-ARN prefix in Rust and SQL, is_backup_resource_arn's own split) are main's code and stay.
  • LineTooLong carries no line number; the variant is shared with the encoder.
  • The deserializer still builds a serde_json::Value of each top-level attribute's value before converting it; that is the remaining 76 bytes per line byte.
  • ExtenddbManifest::check_files_manifest (a cross-check of manifest-files.json against the manifest) is not added; BackupDocuments::build writes both manifests from one input and nothing in ExtendDB reads manifest-files.json.
  • The backup job must gather the stream specification and the TTL description at snapshot time (feat(storage): SnapshotEngine, a consistent table snapshot on every backend [backup/restore 4/4] #395's TableSnapshot.description says streams and TTL may be absent); that is the job PR's work.

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) for the files this PR adds
  • 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 1. On-disk format: this is format version 1 before any backup has been written with it; the golden fixtures (three) are the frozen shape and the writer's constructors are bound to them by a test.

Breaking changes

None for users; no backup has been written in this format, and the moved ARN parser accepts exactly what #384's did. Relative to #337's draft API: DataFileDecoder::new takes any Read and defers verification to end of stream (use verified() for the old behavior); ExtenddbManifest loses manifest_sha256, seal, verify_manifest_sha256; BackupArnParts / parse_backup_arn are the moved #384 functions and BackupArn is the owned form; sse_specification is renamed sse_description; FormatError::Json holds a JsonError instead of a serde_json::Error, and FormatError gains KeyOrderViolation and InvalidDataFileEntry (the entry-shape reasons move there from InvalidDataFileKey); KeyRangeCheck, KeyRangeTracker, DataFileReader, BackupDocuments, ManifestHeader, encode_item_line_into, JsonError, check_format_version and KeyOrder::endpoint_key are new exports, with ExtenddbManifest::reader/reader_prehashed and DataFileEntry::decoder_prehashed; global_secondary_indexes, local_secondary_indexes and vector_indexes are required manifest members; the sidecar has a single accepted form (no bare digest); the version gate accepts 1 to CURRENT_FORMAT_VERSION; a third golden fixture, v1-unordered.json, joins the frozen shape; SourceTableSchema gains the required members stream_specification and time_to_live (both nullable; a v1 document without them is refused); ExportSummaryManifest.s3_sse_algorithm is Option<String>; validate_source_table refuses a key schema with no HASH, a HASH after a RANGE, a repeated key attribute or one without exactly one definition; UnsupportedFormatVersion's text names the range; ExtenddbManifest::validate and DataFileEntry::from_checksums are new; to_json_bytes, manifest_checksum, write_files_manifest, ExportFileEntry::from_data_file and ExportSummaryManifest::for_backup are private to backup_format, and BackupDocuments::build, which runs validate, is the writer's one entry point; KeyOrderViolation's text is "data file items break key order"; the three golden fixtures are regenerated (new bytes, new sidecars) because format version 1 is not yet written by any release. On main, crates/core/src/types/attribute_value.rs's wire deserializer is restructured (the L and M arms consume the owned Value) with identical results and error text, so no API response changes. Relative to the round-7 API: BackupDocuments::build takes Vec<(DataFileEntry, Option<String>)> and no etag slice; verify_manifest_checksum takes the sidecar as &[u8]; KeyRangeTracker is one per backup with next_file; BackupDocuments::documents_before_manifest and manifest_document are new.


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 refactor(core): stream backup data files, hash the stored manifest, type the backup ARN refactor(core): stream backup data files, hash the stored manifest, type the backup ARN [backup/restore 3/4] Oct 13, 2026
@robinnsc
robinnsc force-pushed the feat/backup-format-streaming branch from 580dd05 to 8211247 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 refactor(core): stream backup data files, hash the stored manifest, type the backup ARN [backup/restore 3/4] feat(core): backup format v1: streaming data files, sidecar manifest hash, typed backup ARN [backup/restore 3/4] Oct 13, 2026
@robinnsc
robinnsc force-pushed the feat/backup-format-streaming branch from 8211247 to 8799306 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-format-streaming branch from 8799306 to d157769 Compare October 26, 2026 06:49
robinnsc added a commit that referenced this pull request Oct 26, 2026
…ackend

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Lee Hannigan <lhnng@amazon.com>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
@robinnsc
robinnsc force-pushed the feat/backup-format-streaming branch from d157769 to bab52ff Compare October 26, 2026 20:02
robinnsc added a commit that referenced this pull request Oct 26, 2026
…ackend

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

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

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

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

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

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

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

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

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

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

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

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

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

@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 ran the core, storage, engine and server suites on stable and 1.88.0 (633, 130, 178 and 202, as the description says), 80 mutants of the guarded rules in backup_format/, and a hostile-input harness against the decoder and the manifest parser. I compared the moved backup id and ARN code with main's over 7.8 million generated inputs and 289 wire requests and found no difference. The key order matches what PostgreSQL's ORDER BY returns with the documented recipe and what a DynamoDB Query returns, and an ExportTableToPointInTime output captured from the service parses and round-trips.

Seven items are inline. Smaller items:

  • A data line that names one attribute twice is kept last-wins. ImportTable refuses the same bytes with ItemValidationError (measured), and this PR refuses duplicates in key_range and in the manifest.
  • is_md5_base64 accepts 24 = characters. The sidecar check accepts a line that names a different file, a second line and upper-case hex, where the manifest requires lower-case.
  • ddb_export.rs:17: the service writes the export prefix first, <s3Prefix>/AWSDynamoDB/<export id>/data/<file>.
  • layout.rs:192 says the service tolerates surrounding whitespace. Measured: a trailing newline or CRLF resolves, and a leading space or tab is refused with Invalid Backup ARN. Main's parser trims both, so only the sentence is this PR's.
  • KeyOrder::compare takes a stored N hash key as written, while validate_key_item refuses one that is not normalized. Say which writer guarantees the normal form, or normalize in hash_text.
  • No test pins GZIP_LEVEL or the bytes it produces: level 9 and a timestamp in the gzip header both pass. A fixed input with its expected sha256 would.
  • The bomb test reads the process-wide VmHWM, so an earlier test that allocates 1 GiB hides the mutant that materializes the line.

validation/number.rs overflows on an exponent such as 10E2147483647 (a panic in test builds), and the manifest parser now reaches it. That code is unchanged from main, where PutItem reaches it too, so I will file it against main.

Once the inline items are fixed, I will approve.

@robinnsc
robinnsc force-pushed the feat/backup-format-streaming branch from bab52ff to 5500411 Compare October 27, 2026 20:50
robinnsc added a commit that referenced this pull request Oct 27, 2026
…ackend

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc added a commit that referenced this pull request Oct 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-format-streaming branch from 5500411 to 4d731aa Compare October 28, 2026 00:02
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>
…hash, typed backup ARN

Adds crates/core/src/backup_format, the on-disk format for off-host
backups, built from #337's draft with the changes the design review
asked for before v1 freezes. The module doc says that the design
overview (docs/design/15-backup-restore.md, #396) states the rules the
format's writers and readers honour, and that this module's docs and the
testdata README are the record of the format itself.

Data files:
- DataFileEncoder<W: Write> hashes compressed bytes as they leave the
  compressor, so a writer streaming to a file or an upload holds the
  gzip window and one line, not the whole file. encode_data_file()
  remains as a convenience over it. encode_item_line_into is the one
  encoding of the {"Item": ...} line, into a buffer the caller keeps;
  encode_item_line, which the engine's ExportTableToPointInTime writer
  calls, and write_item, which reuses one buffer per encoder, both call
  it. write_item refuses a line over MAX_LINE_BYTES (32 MiB) with the
  encoder still usable, so a backup cannot contain a line the decoder
  would refuse at restore. The worst case at the default 400 KB item
  limit is a list of number sets, 67.6x (a 27.7 MB line, a 1.21x
  margin); operator-raised item limits above about 496 KB can exceed the
  cap. The doc carries the measured table and a test pins the worst
  case. A write error poisons the encoder (every later call is Encode);
  try_finish keeps the sink so an upload can be aborted, finish returns
  it. Only a successful finish writes the gzip trailer: dropping an
  encoder in any other state (fresh, open, poisoned) takes the sink out
  first, so flate2's Drop cannot complete a valid file of the items so
  far in a sink the caller abandoned. A sink that accepts zero bytes is
  WriteZero, not a spin; Interrupted is retried on every sink write and
  flush, and on the decoder's source reads. is_finished and is_poisoned
  are distinct. GZIP_LEVEL = 6 is pinned, and a test pins the output
  length and sha256 of a fixed input, so two runs of one build produce
  the same bytes. The writer emits one gzip member; the reader accepts
  concatenated members as one stream (a line may span members), which
  the reader contract states together with UTF-8 without BOM.
- DataFileDecoder<R: Read> hashes the compressed bytes it consumes. The
  recorded compressed size and uncompressed size are enforced while
  reading (a source longer than its recorded compressed size fails with
  CompressedSizeMismatch after one byte past it); digests and the item
  count are checked at end of stream. Lines are capped at MAX_LINE_BYTES
  and read into one buffer the decoder reuses across items, as the
  encoder reuses its own; it keeps the longest line's capacity, at most
  the cap. Decoding one line peaks at about 76 bytes of heap per line
  byte for a list of empty strings, and at most about 128 for any shape
  measured (115 for the worst item, lists of one-member map chains; 127
  for nested objects that are refused once built), whatever the nesting
  depth: about 4 GiB at the cap (measured without serde_json's
  preserve_order, the default binary; a build with the MongoDB backend
  enables it and measures about two thirds). DataFileDecoder's doc
  states the bound once. Any error is sticky (later next_item calls
  return Decode naming the earlier failure, never Ok(None)); the
  Iterator impl is fused on top so error-dropping consumers terminate.
  Line-level errors name the 1-based line in the file, and serde's
  column is labelled as within the line when serde's line is 1; a type
  descriptor or name ending in ` at line 3 column 4` sets serde's
  position, and such a message is shown as serde rendered it, with no
  column of its own. Items are checked for JSON shape only; the decoder
  does not apply the target's item limits, which the restore applies to
  each item before writing it. verified() keeps the slice path that
  checks digests before the first item, and then decodes without hashing
  again. A line ends in \n, except that the last line's may be absent
  (that line is an item, counted in uncompressed_bytes without a
  newline), and holds one object with the one member Item (an unknown
  member or a repeated top-level attribute is refused; a repeated type
  descriptor at the top of an attribute value is refused on this path
  and stays last-wins on the request path; nested maps keep JSON's
  last-wins); JSON whitespace, including a \r before the newline, is
  accepted as JSON accepts it. CompressedHasher is the incremental
  hasher a restore runs while spooling a download or over a file in
  place under an owner-only store root; DataFileEntry::decoder_prehashed
  and ExtenddbManifest::reader_prehashed then read that source without
  hashing it again (about 0.43 µs per 318-byte item, 9% of decoding) and
  keep every size, count and line check; the digests are not compared
  again, so a file replaced by one of another length is refused by the
  compressed-size check and a same-length replacement is not detected.

AttributeValue deserializer (crates/core/src/types/attribute_value.rs,
main's wire deserializer): the L and M arms now consume the
serde_json::Value they own instead of cloning and re-deserializing it at
each level. That copy had made an item PutItem accepts, 30 L wrappers
around a list of 409,470 empty strings (31 containers, the maximum),
cost 8.6 GB and 12.7 s to decode, and the request path, which shares the
deserializer, the same. Results and every API error text are unchanged:
a nested error is rendered once per level through
serde_json::Error::custom, as main's per-level from_value rendered it,
so a message ending in ` at line 0 column 0` loses one such suffix per
level as before (a reviewer-run differential over 600k inputs, not
committed; nested_error_text_is_unchanged pins thirteen nested texts,
seven of that class at one to three levels with main's output, one of
them with a suffix more than the levels strip).
data_file_nested_decode_memory, which is not ignored, bounds a 32-level
item's decode at 120 bytes per line byte.

validation/number.rs (main's code, which every format parser and the
request path reach) does its exponent arithmetic in i64. An exponent at
either end of i32 combined with the digits no longer overflows: a debug
build panicked on 10E2147483647, 49E2147483647 and .00E-2147483647, and
a release build reported overflow as underflow and the reverse. An
invalid N key's InvalidKey message carries the number validation's
reason.

Manifest:
- Integrity is a sidecar, extenddb-manifest.sha256, in sha256sum format
  over the stored bytes (written by BackupDocuments::build, checked by
  verify_manifest_checksum), replacing an in-document digest over a
  re-serialized struct that no other implementation could reproduce.
  verify_manifest_checksum takes the sidecar as the bytes the store
  returned and accepts one shape only, the line the writer stores, "<64
  lowercase hex>  extenddb-manifest.json", with or without its final \n.
  Bytes that are not UTF-8 (refused with their own reason, the bytes not
  shown), a bare digest, which sha256sum -c refuses too, and anything
  else is InvalidManifest. The sidecar is written before the manifest,
  so the manifest's presence still marks a complete backup.
- from_json_bytes requires a JSON document whose format_version is a
  u32, gates on it (check_format_version: 1 to CURRENT_FORMAT_VERSION; 0
  and later versions are refused with a message naming the range this
  build reads) before any other member is interpreted, parses the
  document, then runs ExtenddbManifest::validate: the version gate,
  validate_source_table (item_count and table_size_bytes not negative;
  table_creation_date_time finite, since JSON has no NaN or infinity and
  serde_json writes them as null; the key schema has at least one HASH,
  HASH before RANGE, each key attribute named once and defined exactly
  once, on every manifest; the count of HASH and RANGE parts is left to
  the restore's CreateTable validation, and a multi-part key is backed
  up unordered), validate_identity (backup_arn is in canonical form and
  parses; backup_creation_date_time is exactly the f64 its id's instant
  gives; backup_name is 3-255 of the table-name alphabet;
  source_table.table_name is the ARN's table; source_table.table_arn,
  read with core::arn::parse, is a table ARN in the same partition for
  the same account and table) and validate_data_files (keys valid and
  unique, sha256 64 lowercase hex, md5 the standard base64 of 16 bytes,
  every file has items and non-zero sizes (entry errors are
  InvalidDataFileEntry, key errors InvalidDataFileKey),
  backup_size_bytes equals the sum without overflow, key_ordered
  manifests carry a key_range per file whose endpoints are key-only
  items of the schema's shape (S and B non-empty, N written in
  normalized form; the parser reads N endpoints as written, so 5.0 and
  6E0 are refused) with first <= last and ranges ascending without
  overlap against every earlier file; unordered manifests carry none; an
  empty table is a manifest with no files). BackupDocuments::build runs
  validate too, so a writer cannot produce bytes the reader refuses. The
  document is parsed twice (once for the version, under 10% of the cost,
  once for the members); the doc states the cost per data file: about 6
  to 12 µs and 1.6 KB retained per file with short keys. A key_range
  endpoint naming an attribute twice, or whose value repeats its type
  descriptor ({"S":"0","S":"A"}) or names two, is refused at parse time,
  as a data file line of that shape is. Non-overlapping ranges do not by
  themselves rule out a key in two files. ExtenddbManifest::reader (and
  reader_prehashed) runs KeyRangeCheck over each file's items when the
  manifest is key-ordered, without the caller constructing it, and its
  finish after the decoder's end-of-stream checks. The check refuses a
  first or last item that is not the endpoint, an item not strictly
  after the one before, an item past last, an item whose key
  KeyOrder::endpoint_key refuses, and an empty file; with the ranges,
  that rules duplicates out. KeyRangeCheck is a KeyRangeTracker plus the
  recorded range. The writer runs one tracker over the items of every
  file it encodes, so the endpoint check and the strict-ascent rule
  exist once: next_file closes a file with its KeyRange and keeps its
  last key, so a boundary that does not ascend (the next file starting
  with that key or one before it) is refused at the next file's first
  item, naming the previous file's last key, with nothing of that file
  encoded; finish closes the last file. With one tracker per file the
  same boundary is found only at build, after every file is stored. The
  tracker checks each key in place and keeps the first and latest keys,
  with an N range key parsed once; the check parses the recorded
  endpoints once. Per 318-byte item the check went from 1.07 µs and 11.5
  allocations to 0.66 µs and 5 (one partition: 1.64 µs and 19.3 to 0.69
  µs and 5.3).
- Error messages carry a bounded slice of any untrusted field: at most
  80 bytes of its escaped rendering, otherwise a 48-byte prefix and the
  total length. FormatError::Json holds a JsonError that bounds serde's
  text (which quotes the input) to 120 bytes in Display and Debug alike,
  and does not expose the inner error, as source() or otherwise;
  ChecksumMismatch prints its expected value with {:?}.
- key_order.rs is the one definition of base-table key order: hash key
  by hash_key_text (S as is, N as held, B standard padded base64)
  compared as UTF-8 bytes, range key S as UTF-8 bytes, B as raw bytes
  and N by exact decimal value; the module doc states the N
  normalization rules (the exponent a signed 32-bit integer), the base64
  alphabet and the string encoding another implementation must
  reproduce, and a test pins its examples. It states which writers store
  an invalid N key as written (BatchWriteItem and TransactWriteItems,
  #618; UpdateItem on an absent item and ImportTable run no number check
  on the key) and the rule for a key-ordered writer that meets one: the
  backup is refused with an error naming the key, through
  KeyRangeTracker::push, the one call per item, which checks the key as
  KeyOrder::endpoint_key does. N endpoints must be valid DynamoDB
  numbers written in normalized form; the parser refuses any other
  spelling, and items built in code are checked the same way.
  SnapshotDescriptor carries the three KIND_* constants the backends
  report, so the manifest and the storage layer share the strings; kind
  is a free string the reader does not validate and a restore must not
  branch on. KeyOrder::resolve refuses multi-part keys, duplicate
  attribute definitions, a range key listed before the hash key, and a
  range key with the hash key's name. The storage layer's pk_to_text
  delegates to hash_key_text. The module doc gives the per-backend ORDER
  BY recipe, including COLLATE "C" on Postgres text keys.
- Optional source_table members serialize as explicit null.
  global_secondary_indexes, local_secondary_indexes, vector_indexes,
  stream_specification and time_to_live are required members (the last
  three may be null); stream_specification and time_to_live record what
  DescribeTable and DescribeTimeToLive reported, for DescribeBackup's
  SourceTableFeatureDetails, and are not restored. OnDemandThroughput
  writes null for an absent member, as on the wire, and the module doc
  records it; vector_indexes is three-state (null / [] / list) and holds
  the creation shape; sse_description, item_order, per-file md5 and
  uncompressed_bytes; the per-file compressed size feeds the decoder's
  expectations.
- Golden fixtures testdata/extenddb-manifest/v1.json (every optional
  member set, a stream specification and a TTL description included),
  v1-minimal.json (every optional member absent, every list empty) and
  v1-unordered.json ("key_range":null files and the absent state of
  every nested optional member v1.json sets: an OnDemandThroughput with
  both limits null, an SseDescription without SSEType or
  KMSMasterKeyArn, a GSI without ProvisionedThroughput, a disabled
  stream specification without StreamViewType, a disabled TTL without
  AttributeName; and a vector index with SearchSchema and Projection, an
  LSI with INCLUDE), each with its .sha256, compared byte for byte, and
  a non-ignored test binds the writer's three constructors to the
  checked-in bytes. They are permanent read tests: the #[ignore]d writer
  (EXTENDDB_WRITE_GOLDEN) writes v{CURRENT} fixtures with create_new and
  refuses to overwrite an existing file. Any change to the bytes written
  for the fixtures' inputs is a version bump; a new member gets
  serde(default) and v{N} fixtures beside the v1 ones. A new member
  omitted when absent leaves the bytes unchanged, so
  nested_types_have_the_pinned_members pins the members every manifest
  type reads and writes, and fails it until the list changes. A new or
  renamed enum value that no fixture uses changes no byte either, so
  enum_values_are_pinned pins the values of every enum in the manifest:
  the derived ones through serde's variant list, and KeyType,
  ScalarAttributeType and DistanceFunction, whose readers are
  hand-written, through tables filled from a match with no wildcard, and
  AttributeValue, the value type of every key_range endpoint and
  data-file line, through its ten type descriptors the same way, plus a
  check that the descriptors its reader accepts among every string of
  one to four upper-case letters are exactly those ten. Unknown members
  are tolerated at every level.

DynamoDB export manifests: ExportFileEntry.etag and s3SseAlgorithm are
Option<String>; dataFileS3Key is the full store key,
<prefix>/data/<file> (the service writes <s3Prefix>/AWSDynamoDB/<export
id>/data/<file> for a full export to a prefix, measured; the
documentation's full-export example omits the prefix; the module doc
states both and what ExtendDB writes). dataFileS3Key and
manifestFilesS3Key are built by one function, so an empty prefix gives
data/<file> and manifest-files.json, without a leading /. Every leading
and trailing / of the prefix is dropped once, so a// and /a/ give
a/data/<file> and a/manifest-files.json, as a does; the store drops a
leading /, one trailing / and refuses an empty component, so the two
rules agree on every prefix the store accepts.
DataFileEntry::from_checksums builds each entry from the encoder's
result. BackupDocuments::build(header, data_files, store_key_prefix,
bucket), with each data file paired with the ETag its put returned (None
on a filesystem store), assembles the manifest, its sidecar,
manifest-files.json and manifest-summary.json from one input. It derives
backup_size_bytes, backup_creation_date_time and the key prefix (the
store's key prefix with leading and trailing / dropped, then
BackupArn::store_prefix) once and runs validate once. It builds each
manifest-files.json line from the manifest's DataFileEntry, so the two
manifests carry one md5 and one item_count, and the summary from the
manifest (exportArn = the backup ARN, the three instants = the snapshot
instant in RFC 3339 UTC with milliseconds, s3SseAlgorithm null,
itemCount the sum; the module doc has the table). Its doc gives the
write order: documents_before_manifest() returns manifest-files.json,
manifest-summary.json and the sidecar, the job re-checks its claim, and
manifest_document() is the manifest, written last; documents() is the
four in that order. for_backup, from_data_file, write_files_manifest,
to_json_bytes and manifest_checksum are private to backup_format.

Layout: the backup id and ARN implementation moves from
extenddb_storage::util::backup_id into
extenddb_core::backup_format::layout (format_backup_id, is_backup_id,
backup_id_epoch_millis, backup_creation_date_time, check_backup_arn,
parse_backup_arn, BackupArnParts, BackupArnError, MAX_BACKUP_ARN_LEN),
unchanged in behaviour (verified by a reviewer's differential run over
5.9M inputs); the field split is core::arn::fields and the table-name
alphabet is validation::validate_table_name_chars, so authorization,
CreateTable and this parser share them. The engine's
validate_name_member and validate_index_name still spell the alphabet
themselves. format_backup_arn is the one backup-ARN formatter
(backup_arn() in storage, BackupArn, and the server's canonical resource
ARN call it) and trim_backup_arn the one whitespace rule
(check_backup_arn and the two authorization sites call it). It trims
both ends as main did; the doc notes that the service resolves a
trailing newline but refuses a leading space or tab.
extenddb_storage::util re-exports the rest so the engine and the four
backends keep their import path, and keeps new_backup_id (needs rand),
backup_arn, and is_backup_resource_arn (the authorization predicate).
BackupArn is the owned form the format uses: check_backup_arn plus the
two store-prefix rules (table name at most 255 bytes and not only dots),
with the ARN string and the store prefix; a test enumerates both
divergences and the id-shape grid. The 255-byte bound is narrower than
the engine's max_table_name_length when an operator raises it; the
layout doc says so. Other copies of ARN facts in the server and storage
crates (is_backup_resource_arn's own split, a partition constant)
predate this PR and are not changed here.

Tests: 175 in the module (174 run, 1 ignored fixture writer), three
integration binaries in crates/core/tests (data_file_line_bomb: a 256
MiB single-line bomb stops within twice the cap's memory;
data_file_nested_decode_memory: a 32-level item decodes within 120 bytes
of heap per line byte; data_file_decode_peak: the ignored RSS
measurement), a storage-sqlite test binding encode_orderable_number's
byte order to KeyOrder::compare over a fixed pseudo-random corpus of
DynamoDB numbers, so a change to either side fails, and a row-key
contract test in storage pinning pk_to_text's literal output (standard
base64, "+/+/") so a change to hash_key_text cannot silently re-key B
rows. The ddb-export fixture is constructed from the documented output
format, not captured from the service. Coverage includes the streaming
end-of-stream digest check, every expectation a DataFileEntry carries,
the uncompressed and compressed size caps firing early, wrong item
counts in both directions, try_finish failure poisoning, Interrupted on
every sink and source call, invalid UTF-8 as Decode, an unterminated
final line as an item, dropped encoders writing nothing, line numbers in
data-line errors, range-key pairs that tell bytewise from length-first
and from base64 order, B hash keys in base64 order, N range keys
differing beyond 17 digits, overlap between later files, last-endpoint
validation, the per-file key-range check's refusals (a repeated key and
an invalid stored N key among them) and two non-overlapping files each
holding every key, through ExtenddbManifest::reader as well, a first
item that is not the range's first and a file with no items through
reader, reader_prehashed reading a wrong-digest file that reader
refuses, the tracker refusing a boundary that does not ascend at the
next file's first item with nothing encoded, the summary's itemCount as
the sum when source_table.item_count differs, a non-UTF-8 sidecar, each
etag on its file, the three documents before the manifest and the
manifest document, the size sum refused at 2^63, the prehashed decoder's
uncompressed count one too high, AttributeValue's ten descriptors and no
other, the writer's tracker refusing an out-of-order or repeated key and
agreeing with KeyOrder::compare for S, N and B keys, the prehashed
decoder keeping every check but the digest, BackupDocuments' four
documents agreeing on one prefix, the pinned enum values, N exponents at
both ends of i32, a forged position in a data-line error, un-normalized
and empty endpoints, resolve's key-order rules, required index members,
negative source counts, the version gate's range and the gate's message
naming the range 1 to CURRENT, unknown members injected at every level
of two fixtures, the key-schema shape rules on an unordered manifest,
to_json_bytes refusing what from_json_bytes refuses (non-finite
timestamps included), endpoints repeating a type descriptor, the pinned
member list of every manifest type, the two manifest constructors
agreeing with the encoder and the manifest entry, the summary manifest's
documented values, the key-order doc's normalization examples, size-sum
overflow, md5 and sidecar shapes, identity rules, explicit nulls,
bounded messages at field lengths from 81 bytes to 2 MB, a bounded Debug
of FormatError::Json, an escaped ChecksumMismatch, and nested
deserializer error texts. 32 hand mutants of these rules, and 39 more of
the round-5 fixes, were each killed by the suite; 20 of the round-6
fixes were killed and one was equivalent (a dead branch in number.rs,
replaced by three others, killed); 13 of the fixes for the maintainer's
review of 2026-10-28 were killed, none equivalent; 15 of the round-7
fixes were killed, none equivalent; 18 of the round-8 fixes were killed
at the test each targets, among them the four that survived round 8 (an
AttributeValue descriptor alias, reader_prehashed hashing anyway, the
summary's itemCount taken from source_table, build's size sum wrapping),
none equivalent.

Co-authored-by: Lee <leeroyhannigan@yahoo.ie>
Signed-off-by: Scott Robinson <robinnsc@amazon.com>
@robinnsc
robinnsc force-pushed the feat/backup-format-streaming branch from 3f7107d to 0132dc4 Compare October 29, 2026 11:27
robinnsc added a commit that referenced this pull request Oct 29, 2026
…ackend

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@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 nine items from my last review at 0132dc4. PutItem refuses 1E126, abc and 1E-131 as N partition keys on this branch and on main while BatchWriteItem, TransactWriteItems and UpdateItem on an absent item store them, as the corrected paragraph says, and KeyRangeTracker and KeyRangeCheck refuse such a key with an error naming it. The deserializer gives main's values and error texts for 1,002,175 generated inputs (1,237 differed before) and on 328 wire cells; the repeated endpoint descriptor, a non-finite creation time and 0E2147483648 are refused, and an empty prefix gives manifest-files.json. I rebuilt the mutants that passed at the last head and nine more against the new fixes (15 built, 15 fail a test), and the suites pass at the description's counts on stable and 1.88.0.

@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 34aa2df Oct 29, 2026
34 checks passed
@robinnsc
robinnsc deleted the feat/backup-format-streaming branch October 29, 2026 19:35
robinnsc added a commit that referenced this pull request Oct 29, 2026
…ackend

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
LeeroyHannigan added a commit that referenced this pull request Oct 30, 2026
validate_and_normalize_number parsed the exponent as an i32. #394 moved
its exponent sums to saturating i64 arithmetic, which fixed the overflow
panics of #616, but an exponent outside the i32 range was still refused
as malformed.

The exponent is now parsed as an i64 and the sums are done in i128
instead of saturating i64, so none of them can overflow. DynamoDB reads
the exponent the same way: 1E2147483648 is a Number overflow and
1E9223372036854775808 cannot be converted. A zero whose exponent does
not fit in an i32 is range checked too; main refused those as malformed.

A zero whose exponent fits in an i32 stays accepted as 0, although
DynamoDB refuses 0E126 and 0.0E-130. A refused zero keeps its raw
spelling in AttributeValue, and only the PutItem item and UpdateItem's
update values are validated afterwards, so UpdateItem's key and the
other operations would store 0E126 as a key separate from 0. #618
covers both.

Closes #616
LeeroyHannigan added a commit to yesyayen/extenddb that referenced this pull request Oct 30, 2026
…de (ExtendDB#396)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Scott Robinson <robinnsc@amazon.com>
@navareamol navareamol added this to the 1.0 milestone Nov 3, 2026
@navareamol navareamol added the release-blocker Must ship for its milestone's release label Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-blocker Must ship for its milestone's release v1 Needed for the 1.0 release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backup/restore: backup format v1

3 participants