diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8746f44..b712765 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -499,11 +499,44 @@ jobs: grep -q '^test result: ok. 1 passed' /tmp/guard.log \ || { echo "::error::every_metadata_leg_is_a_line_and_the_intercepts_are_named did not run"; exit 1; } - - name: A projection holds nothing for an object under no hold + - name: A hold reaches the digest and an object under none spells zeros run: | set -o pipefail cargo test --locked --lib \ - tests::a_projection_holds_nothing_for_an_object_under_no_hold -- --exact \ + tests::a_hold_reaches_the_digest_and_an_object_under_none_spells_zeros -- --exact \ | tee /tmp/guard.log grep -q '^test result: ok. 1 passed' /tmp/guard.log \ - || { echo "::error::a_projection_holds_nothing_for_an_object_under_no_hold did not run"; exit 1; } + || { echo "::error::a_hold_reaches_the_digest_and_an_object_under_none_spells_zeros did not run"; exit 1; } + + - name: A digest equals the text it is a digest of and refuses a corrupt tag + run: | + set -o pipefail + cargo test --locked --lib \ + tests::a_digest_equals_the_text_it_is_a_digest_of_and_refuses_a_corrupt_tag -- --exact \ + | tee /tmp/guard.log + grep -q '^test result: ok. 1 passed' /tmp/guard.log \ + || { echo "::error::a_digest_equals_the_text_it_is_a_digest_of_and_refuses_a_corrupt_tag did not run"; exit 1; } + + - name: The snapshot decoder accepts the version before this one and refuses the one after + run: | + set -o pipefail + cargo test --locked --lib \n tests::the_snapshot_decoder_accepts_the_version_before_this_one_and_refuses_the_one_after -- --exact \n | tee /tmp/guard.log + grep -q '^test result: ok. 1 passed' /tmp/guard.log \n || { echo "::error::the_snapshot_decoder_accepts_the_version_before_this_one_and_refuses_the_one_after did not run"; exit 1; } + + - name: Two records spelling the same thing in different shapes hash differently + run: | + set -o pipefail + cargo test --locked --lib \ + tests::two_records_spelling_the_same_thing_in_different_shapes_hash_differently -- --exact \ + | tee /tmp/guard.log + grep -q '^test result: ok. 1 passed' /tmp/guard.log \ + || { echo "::error::two_records_spelling_the_same_thing_in_different_shapes_hash_differently did not run"; exit 1; } + + - name: An object record owns no heap before or after a snapshot load + run: | + set -o pipefail + cargo test --locked --lib \ + tests::an_object_record_owns_no_heap_before_or_after_a_snapshot_load -- --exact \ + | tee /tmp/guard.log + grep -q '^test result: ok. 1 passed' /tmp/guard.log \ + || { echo "::error::an_object_record_owns_no_heap_before_or_after_a_snapshot_load did not run"; exit 1; } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f7ac9c..3d45a35 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,9 +55,9 @@ Two jobs, both on a pinned toolchain: | job | what it does | |---|---| | **build & docs** | `cargo build --all-targets --locked` and `--all-features`, then `cargo doc --no-deps --locked` and `--all-features`, all with warnings denied | -| **format and rollback guards** | 47 individual tests, each run by name with `--exact` | +| **format and rollback guards** | 51 individual tests, each run by name with `--exact` | -**CI does not run the suite.** It compiles everything and runs those 47 guards. A green +**CI does not run the suite.** It compiles everything and runs those 51 guards. A green tick means the tree builds and those guards hold; it does not mean the suite passed. Run `tools/suite_gate.sh` yourself. diff --git a/README.md b/README.md index b0d4f79..4ac44d0 100644 --- a/README.md +++ b/README.md @@ -237,19 +237,25 @@ At three replicas with the metadata counted, measured at 26-character keys: | object size | objects | amplification | data-only figure | |---:|---:|---:|---:| | 64 KiB | 1,000 | **3.01x** | 3.00x | -| 4 KiB | 4,000 | **3.11x** | 3.00x | -| 1 KiB | 8,000 | **3.44x** | 3.02x | +| 4 KiB | 4,000 | **3.09x** | 3.00x | +| 1 KiB | 8,000 | **3.38x** | 3.02x | The data term is exactly **3.00x** -- the replication floor for a store that copies every page to every replica -- so everything above it is metadata, which -is **410 bytes an object plus 164,256 fixed** whatever the object size. +is **346 bytes an object plus 164,256 fixed** whatever the object size. Those bytes are not all in one place. **218 of them are a storage node's own** -- the -object's entry in its map, and its key in the payload index -- and **192 are the metadata +object's entry in its map, and its key in the payload index -- and **128 are the metadata service's**, which keeps its own record of every object with its own versioning, deletion markers and change log. This crate models both services in one process, so the audit counts both; a deployment runs them on different machines, where a storage node carries 218 bytes -an object rather than 410. +an object rather than 346. + +The metadata service's record is **a name, two stamps and a digest**. It does not restate the +object: it holds an eight-byte digest of the object's rendered metadata, so it can prove a +record matches the object without carrying a second copy of it. What that costs is a restore +that is no longer self-sufficient -- a snapshot carries the digest, not the characters, so +reproducing a value needs the object. `every_metadata_leg_is_a_line_and_the_intercepts_are_named` measures both shares and fails if either stops being what this file publishes. `footprint_audit()` names every resident byte and its breakdown sums to its own @@ -269,9 +275,9 @@ which is more hardware rather than less space. replicas to eight and fails if any of them undercuts that. Counted with the metadata, a thousand objects of 1 KiB measure -**2.09x striped against 3.58x replicated**, both read out of `footprint_audit()` on a +**2.03x striped against 3.51x replicated**, both read out of `footprint_audit()` on a store nothing has read back yet. What separates the total from the 1.5x data term is the -metadata line above, which striping does not change: the same 410 bytes an object either +metadata line above, which striping does not change: the same 346 bytes an object either way. Striping moves the payload and nothing else. Both numbers are for the same object count, so the comparison is between two @@ -346,12 +352,12 @@ cargo test --release --lib measure_what_a_mutation_batch_snapshot_costs -- --ign #### A delete does not reclaim, it trades Writing 500 objects of 1 KiB and then deleting every one leaves the store -**25.6% larger** than when it held them: +**26.0% larger** than when it held them: | | bytes | |---|---:| -| holding 500 objects | 1,831,649 | -| after deleting all 500 | 2,300,662 | +| holding 500 objects | 1,799,649 | +| after deleting all 500 | 2,268,662 | The payload index is given back in full. What replaces it is **822 bytes of idempotency receipt per delete** across three replicas, bounded only by diff --git a/docs/footprint.md b/docs/footprint.md index 6cde5c5..de7888a 100644 --- a/docs/footprint.md +++ b/docs/footprint.md @@ -21,8 +21,8 @@ Measured at three replicas, 26-character keys, with the metadata counted: | object size | objects | amplification | data-only figure | |---:|---:|---:|---:| | 64 KiB | 1,000 | **3.01x** | 3.00x | -| 4 KiB | 4,000 | **3.11x** | 3.00x | -| 1 KiB | 8,000 | **3.44x** | 3.02x | +| 4 KiB | 4,000 | **3.09x** | 3.00x | +| 1 KiB | 8,000 | **3.38x** | 3.02x | Reproduce any row with the measurement harness: @@ -67,14 +67,15 @@ are bounded rather than per object: | objects | 100 | 1,000 | 4,000 | 8,000 | |---:|---:|---:|---:|---:| -| metadata per object | 618 B | 574 B | 451 B | **430 B** | +| metadata per object | 554 B | 510 B | 387 B | **366 B** | -Both change feeds cap at 1,024 records, so together they are 30.0% of metadata -at a hundred objects and 4.1% at eight thousand. Everything else is exactly -linear, which makes the total a line once the feeds are full: +Both change feeds cap at 1,024 records, so together they are **163,866 bytes** +once full -- the two intercepts in the table below, which is where that figure is +checked rather than derived from a percentage of a moving total. Everything else +is exactly linear, which makes the total a line once the feeds are full: ``` -metadata = 410 bytes an object + 164,256 bytes fixed (remainder: zero) +metadata = 346 bytes an object + 164,256 bytes fixed (remainder: zero) ``` reproducing every total above exactly, at every count where the feeds have @@ -102,40 +103,46 @@ three store sizes and fails unless every one is a line and the fixed terms sum to the total's — so a leg that starts depending on the object count, or a fixed byte that stops being named, is caught rather than absorbed. -That 410 is two items and a rounding error: +That 346 is two items and a rounding error: | | per object | share | |---|---:|---:| -| `objects` — the structured metadata | 210 B | 51.2% | -| `meta.records` — the metadata projection record | 192 B | 46.8% | -| payload index keys | 8 B | 2.0% | - -Those two halves hold the **same facts twice**. `objects` is a binary struct — -144 bytes inline, with the object id, length, etag, content type, storage class -and extent list in fixed-width fields. `meta.records` is a projection of the same -object: it **spells** 227 characters and **holds** an 88-byte struct of twelve -values, rendering the text on demand. - -That distinction is the whole of the saving the record has already taken, and it -is also why the format question below is still open. The text is durable — three -proofs hash it — so what the record spells cannot change without a migration, -whatever it stores. Measured against what those characters actually distinguish: - -| | per record | share | | -|---|---:|---:|---| -| field names and separators | 151 B | 70% | `retention_until_ms`, `metadata_count`, … spelled out for every object | -| values that never differ | 24 B | 11% | 9 of the 12 fields have **one** distinct value across every object | -| values that differ | 42 B | 19% | etag, length, content type | - -So **175 of 217 characters carry no information that varies between objects** — -81% of the text the record spells. Issue #40 has the measurement and what closing -it would require; it is a durable format, so the cost is a migration rather than -an edit. - -What the record *stores* no longer pays for those characters: 88 bytes of struct -against 227 of text is the conversion already made, and it is free in the worst -case — a record decoded from a snapshot carries characters, so it holds exactly -what it spells and never more. +| `objects` — the structured metadata | 210 B | 60.7% | +| `meta.records` — the metadata digest record | 128 B | 37.0% | +| payload index keys | 8 B | 2.3% | + +Those two halves no longer hold the same facts twice. `objects` is a binary struct — +136 bytes inline, with the object id, length, etag, content type, storage class +and extent list in fixed-width fields, and it is the **authority** on what the +object is. `meta.records` holds a name, two stamps and an **eight-byte digest** of +what `objects` would render: enough to prove the two agree, not enough to +reconstruct one from the other. + +What a record spells is durable — three proofs and a snapshot hash it — so the +digest's own spelling, `fnv1a64:` and sixteen hex digits, is now part of the +format. The 203 characters that used to sit in front of it are gone, and with +them the question of how to compress them: + +| | per record | | +|---|---:|---| +| what it spells | 24 B | `fnv1a64:` and sixteen hex digits | +| what it occupies | 8 B | inline, in a niche the boxed-string variant leaves free | +| what it owns on the heap | 0 B | nothing — `an_object_record_owns_no_heap_before_or_after_a_snapshot_load` | + +That question used to be the open one here: 175 of the 217 characters a record +spelled carried no information that varied between objects, and compressing them +meant a durable format change. **The digest is that change.** The characters are +not compressed, they are not stored, and the object renders them on demand. + +What it cost is the restore. A snapshot used to carry the characters, so a +restored store could serve a record's value without consulting anything; it now +carries the digest, so a restored metaserver can prove a record matches an object +and cannot reproduce the value without the object. + +What it gained beyond the bytes is that the saving no longer evaporates on load. +The packed projection shrank a live store and went back to text when a snapshot +was installed, so the figure above was a live-store figure. A digest survives the +round-trip, and the occupancy is asserted EQUAL on both sides of one. So if you store small objects, three replicas do not cost 3x. Any amplification figure published for this store before the metadata was counted was the @@ -157,7 +164,7 @@ shared between holders is charged once per allocation — the characters and the two counts in front of them — so `mapkey_capacity` against `mapkey_len` is that header rather than slack. -### What the 410 bytes are, and what each remaining part would cost to remove +### What the 346 bytes are, and what each remaining part would cost to remove The slope is not one thing. Every leg below is fitted from the same two object counts the published line is fitted from, and they sum to it exactly: @@ -170,9 +177,9 @@ objects.metadata_inline 136 ObjectMetadata itself, one extent held inline objects.extents_bytes 0 the one extent is INLINE above, so it owns no heap meta.records.struct_inline 72 the map key's holder, plus MetadataRecord meta.records.mapkey_capacity 56 the record key's characters: ONE allocation, shared -meta.records.value_capacity 64 the projection, its three holds behind a pointer +meta.records.value_capacity 0 the value is an eight-byte digest, held INLINE above payloads.key_bytes 8 the payload index's key -METADATA LINE TOTAL 410 +METADATA LINE TOTAL 346 ``` `the_published_metadata_decomposition_sums_to_the_line` measures each one and fails if this @@ -192,7 +199,7 @@ characters, sixteen bytes once per allocation. #### The reductions that are left, and what each one costs -Each is priced against the 410. Two rows have come **off** this list, and both came off the +Each is priced against the 346. Two rows have come **off** this list, and both came off the same way -- by being measured instead of argued. Narrowing an extent's offset and length to 32 bits was priced at 8 bytes an object and a migration, until the migration turned out not to exist: the snapshot still writes two `u64` and only the in-memory form is narrow. Interning @@ -202,8 +209,8 @@ decision and one representation. | | saves | what it costs | |---|---:|---| -| The metadata records restate the objects map | **216** | It is the whole of `meta.records`: a separate metadata service's own store, with its own versioning, deletion markers and change log, modelled in this process. Removing the restatement means collapsing two services into one, which is a design decision and not a footprint one. | -| A one-word text handle | **48**, or **32** without unsafe code | `SharedText` is `Arc`, a fat pointer, so every holder spends two words to name one allocation. Eight holders an object carry it -- two keying `objects`, three inside `ObjectMetadata`, two around a metadata record and one in the projection -- so one word each is **64 bytes of inline saving**, measured by construction rather than multiplied, because a narrowed field can leave its padding behind instead of closing up. The allocation pays part of it back by carrying the length the handle stops carrying: one word per allocation, two allocations an object, **net 48**. The shape that needs no unsafe code is `Arc>`, which puts the length in a second allocation instead and nets **32**. Both add a dependent load to a string read, and THAT LOAD IS NOW PRICED. Measured in release over 200,000 names visited in a shuffled order -- shuffled because a constant stride is the pattern a prefetcher exists to hide -- the thin handle costs **2.6x to 3.1x on a length read** and **1.1x to 1.3x on a character read**, four to nine nanoseconds either way. The length case is the whole story: `Arc` carries the length IN THE HANDLE and touches no heap at all, where `Arc>` must reach the box first. The comparison favours the thin handle, whose index is half the size and so has the better locality, and it still loses. `measure_what_a_one_word_text_handle_costs_to_read` prints it. So the trade is 32 bytes an object -- 7.5% of the metadata line -- against roughly tripling the cost of asking a name its length, and the saving is largest for the small objects that do the most string reads per stored byte. | +| The metadata records are a second store of the same objects | **128** | It is the whole of `meta.records`. They no longer RESTATE the objects map — a record holds a name, two stamps and a digest, and the digest is what lets it prove agreement without a second copy. What is left is a separate metadata service's own store, with its own versioning, deletion markers and change log, modelled in this process; removing it means collapsing two services into one, which is a design decision and not a footprint one. | +| A one-word text handle | **40**, or **24** without unsafe code | `SharedText` is `Arc`, a fat pointer, so every holder spends two words to name one allocation. Seven holders an object carry it -- two keying `objects`, three inside `ObjectMetadata` and two around a metadata record -- so one word each is **56 bytes of inline saving**, measured by construction rather than multiplied, because a narrowed field can leave its padding behind instead of closing up. The allocation pays part of it back by carrying the length the handle stops carrying: one word per allocation, two allocations an object, **net 40**. The shape that needs no unsafe code is `Arc>`, which puts the length in a second allocation instead and nets **24**. Both add a dependent load to a string read, and THAT LOAD IS NOW PRICED. Measured in release over 200,000 names visited in a shuffled order -- shuffled because a constant stride is the pattern a prefetcher exists to hide -- the thin handle costs **2.6x to 3.1x on a length read** and **1.1x to 1.3x on a character read**, four to nine nanoseconds either way. The length case is the whole story: `Arc` carries the length IN THE HANDLE and touches no heap at all, where `Arc>` must reach the box first. The comparison favours the thin handle, whose index is half the size and so has the better locality, and it still loses. `measure_what_a_one_word_text_handle_costs_to_read` prints it. So the trade is 24 bytes an object -- 6.9% of the metadata line -- against roughly tripling the cost of asking a name its length, and the saving is largest for the small objects that do the most string reads per stored byte. | **Both figures above are measurements now, and the difference has mattered three times.** The extent row was priced at 8 bytes and a migration, and the migration turned out not to diff --git a/src/internal/codec/control_storage_proofs/root_usage_projection.rs b/src/internal/codec/control_storage_proofs/root_usage_projection.rs index 283f8e5..4394e3c 100644 --- a/src/internal/codec/control_storage_proofs/root_usage_projection.rs +++ b/src/internal/codec/control_storage_proofs/root_usage_projection.rs @@ -259,15 +259,6 @@ impl ObjectProjection { self.holds.as_deref().is_some_and(|holds| holds.legal_hold) } - /// What the holds own on the heap, which the audit needs: an object under none of the - /// three owes nothing for them. - pub(crate) fn holds_heap_bytes(&self) -> u64 { - match self.holds { - Some(_) => std::mem::size_of::() as u64, - None => 0, - } - } - /// The same bytes `metadata_projection_value` produces, which is the whole /// requirement: `the_packed_projection_spells_the_same_text` asserts it /// against a seeded store rather than against a reading of the format. @@ -297,23 +288,27 @@ impl ObjectProjection { /// What a metadata record holds as its value. /// -/// A record at an object key holds a projection of that object; a record at a -/// bucket, lifecycle or multipart key holds arbitrary text, and so does any -/// record a metadata transaction wrote directly. Both shapes are BOXED, which -/// is what keeps this sixteen bytes inline -- the same as the `Box` it -/// replaces, because a `Box` is non-null and the niche absorbs the -/// discriminant. So a record that still holds text costs exactly what it cost -/// before, and one holding a projection costs 112 bytes instead of 243. +/// A record at an object key holds a DIGEST of the object's rendered metadata; +/// a record at a bucket, lifecycle or multipart key holds the text itself, and +/// so does any record a metadata transaction wrote directly. +/// +/// # WHY AN OBJECT'S VALUE CAN BE A DIGEST AND A BUCKET'S CANNOT +/// +/// The characters at an object key are a RENDERING of the object, and the +/// object is the authority: anything that needs them can ask it. The characters +/// at a bucket key are the only copy there is, so a digest would destroy them. +/// That asymmetry is the whole design, and it is why this is an enum rather +/// than one representation. /// -/// An inline projection would be 96 bytes inline and look better: 96 against -/// 243 for a packed record. It would also be 323 for a text one, so a store -/// that had just loaded a snapshot would be worse off until every record was -/// rewritten. `measure_what_each_record_value_representation_would_cost` -/// prints all three. +/// Sixteen bytes inline either way. A `Box` is a non-null fat pointer, so +/// the niche absorbs both the discriminant and the digest's own eight bytes -- +/// measured, not assumed, by `measure_the_route_to_eighty_bytes_a_metadata_record`, +/// which is also what ruled out a hand-rolled tagged pointer: it is the same +/// sixteen and needs `unsafe` to read back. #[derive(Clone, Debug)] pub enum RecordValue { Text(Box), - Projection(Box), + Digest(u64), } /// Equality is on the CHARACTERS, not on the variant. @@ -337,8 +332,15 @@ impl PartialEq for RecordValue { fn eq(&self, other: &Self) -> bool { match (self, other) { (RecordValue::Text(left), RecordValue::Text(right)) => left == right, - (RecordValue::Projection(left), RecordValue::Projection(right)) => left == right, - _ => self.as_text() == other.as_text(), + (RecordValue::Digest(left), RecordValue::Digest(right)) => left == right, + // A DIGEST EQUALS THE TEXT IT IS A DIGEST OF. Not the digest's own + // spelling -- comparing `as_text` to `as_text` would make a stored + // digest differ from the freshly rendered projection it was taken + // from, which is the exact drift this impl exists to prevent. + (RecordValue::Digest(digest), RecordValue::Text(text)) + | (RecordValue::Text(text), RecordValue::Digest(digest)) => { + *digest == fnv1a64(text.as_bytes()) + } } } } @@ -355,8 +357,46 @@ impl RecordValue { RecordValue::Text(Box::from("")) } + /// The digest of an object's rendered metadata. + /// + /// `ObjectProjection` is still the renderer -- it is what defines the + /// characters, and `the_packed_projection_spells_the_same_text` is what + /// keeps that definition honest. It is no longer what gets STORED. pub(crate) fn of_object(metadata: &ObjectMetadata) -> Self { - RecordValue::Projection(Box::new(ObjectProjection::from_metadata(metadata))) + RecordValue::Digest(Self::digest_of_object(metadata)) + } + + pub(crate) fn digest_of_object(metadata: &ObjectMetadata) -> u64 { + fnv1a64(ObjectProjection::from_metadata(metadata).to_text().as_bytes()) + } + + /// Which shape the value is, for a durable encoding that must not guess. + /// + /// The key cannot be used to tell them apart: `meta_object_key` returns a + /// BUCKET key for an object whose key is empty, so an object-keyed test + /// would misread those. The encoding states the shape instead. + pub(crate) fn kind_tag(&self) -> u64 { + match self { + RecordValue::Text(_) => 0, + RecordValue::Digest(_) => 1, + } + } + + /// Rebuild a value from what a snapshot or journal stated. + pub(crate) fn from_kind_tag(tag: u64, text: &str) -> Result { + match tag { + 0 => Ok(RecordValue::Text(text.into())), + 1 => parse_etag_digest(text) + .map(RecordValue::Digest) + .ok_or_else(|| { + ObjectError::Corruption(format!( + "metadata record value is tagged as a digest and spells {text}" + )) + }), + other => Err(ObjectError::Corruption(format!( + "metadata record value kind {other} is not a shape this build knows" + ))), + } } /// The text the record has always carried. Borrowed when it is stored and @@ -365,8 +405,10 @@ impl RecordValue { pub fn as_text(&self) -> std::borrow::Cow<'_, str> { match self { RecordValue::Text(text) => std::borrow::Cow::Borrowed(text), - RecordValue::Projection(projection) => { - std::borrow::Cow::Owned(projection.to_text()) + RecordValue::Digest(digest) => { + let mut spelled = String::with_capacity(24); + append_etag_of_digest(&mut spelled, *digest); + std::borrow::Cow::Owned(spelled) } } } @@ -374,30 +416,24 @@ impl RecordValue { pub fn len(&self) -> usize { match self { RecordValue::Text(text) => text.len(), - RecordValue::Projection(projection) => projection.to_text().len(), + RecordValue::Digest(_) => self.as_text().len(), } } - /// What the value owns on the heap. A projection's box is its own size; a - /// text's is its characters. Neither has spare capacity. + /// What the value owns on the heap. A text's is its characters, with no + /// spare capacity; a digest owns NOTHING -- it is eight bytes inline. pub fn heap_bytes(&self) -> u64 { match self { RecordValue::Text(text) => text.len() as u64, - // PLUS WHAT THE HOLDS OWN. An object under a restore, a retention or a legal - // hold keeps them behind a pointer, and charging only the struct would report a - // saving those objects did not get. - RecordValue::Projection(projection) => { - (std::mem::size_of::() as u64) - .saturating_add(projection.holds_heap_bytes()) - } + RecordValue::Digest(_) => 0, } } pub fn is_empty(&self) -> bool { match self { RecordValue::Text(text) => text.is_empty(), - // A projection always formats twelve fields, so it is never empty. - RecordValue::Projection(_) => false, + // A digest always spells twenty-four characters, so it is never empty. + RecordValue::Digest(_) => false, } } } diff --git a/src/internal/codec/metadata_object_proofs/transaction_proofs.rs b/src/internal/codec/metadata_object_proofs/transaction_proofs.rs index 8466015..9592144 100644 --- a/src/internal/codec/metadata_object_proofs/transaction_proofs.rs +++ b/src/internal/codec/metadata_object_proofs/transaction_proofs.rs @@ -10,6 +10,12 @@ pub(crate) fn append_metadata_mutation(out: &mut Vec, mutation: &MetadataMut pub(crate) fn append_metadata_record(out: &mut Vec, record: &MetadataRecord) { append_string(out, &record.key); + // THE SHAPE, THEN THE CHARACTERS, and `read_metadata_record` reads them in that + // order. This function is both the durable encoding of a receipt's embedded records + // and the material a proof hashes, so the tag lands in both -- which is the point: a + // digest and a text that spelled the same thing are different records, and a proof + // that could not tell them apart would be hashing less than the record. + append_u64(out, record.value.kind_tag()); append_string(out, &record.value.as_text()); append_u64(out, record.version); append_u64(out, record.update_time_ms); diff --git a/src/internal/codec/root_chunk_page_proofs/object_gc_compaction/object_integrity.rs b/src/internal/codec/root_chunk_page_proofs/object_gc_compaction/object_integrity.rs index 2e65542..0066b0b 100644 --- a/src/internal/codec/root_chunk_page_proofs/object_gc_compaction/object_integrity.rs +++ b/src/internal/codec/root_chunk_page_proofs/object_gc_compaction/object_integrity.rs @@ -146,6 +146,20 @@ pub(crate) fn evaluate_object_read_preconditions( pub(crate) const FNV1A64_OFFSET_BASIS: u64 = 0xcbf29ce484222325; +/// Read back what [`append_etag_of_digest`] spelled. +/// +/// `None` for anything else, which is how a durable decoder tells a corrupt +/// value from a digest rather than silently reading one as zero. A spelling +/// with a writer and no reader is a one-way door, and this one now opens both +/// ways. +pub(crate) fn parse_etag_digest(spelled: &str) -> Option { + let hex = spelled.strip_prefix("fnv1a64:")?; + if hex.len() != 16 { + return None; + } + u64::from_str_radix(hex, 16).ok() +} + pub(crate) fn fnv1a64(data: &[u8]) -> u64 { fnv1a64_resumed(FNV1A64_OFFSET_BASIS, data) } diff --git a/src/internal/codec/snapshot_journal_codec/snapshot_read_metadata.rs b/src/internal/codec/snapshot_journal_codec/snapshot_read_metadata.rs index aca0287..556732f 100644 --- a/src/internal/codec/snapshot_journal_codec/snapshot_read_metadata.rs +++ b/src/internal/codec/snapshot_journal_codec/snapshot_read_metadata.rs @@ -2,10 +2,14 @@ // Copyright 2026 MatrixArkAI pub(crate) fn read_metadata_record( cursor: &mut SnapshotByteCursor<'_>, + has_value_kind: bool, ) -> Result { + let key = cursor.read_string()?; + let value_kind = if has_value_kind { cursor.read_u64()? } else { 0 }; + let value_text = cursor.read_string()?; Ok(MetadataRecord { - key: cursor.read_string()?.into(), - value: cursor.read_string()?.into(), + key: key.into(), + value: RecordValue::from_kind_tag(value_kind, &value_text)?, version: cursor.read_u64()?, update_time_ms: cursor.read_u64()?, deleted: cursor.read_bool()?, @@ -109,6 +113,7 @@ pub(crate) fn read_coordinator_session_receipt( pub(crate) fn read_metadata_batch_receipt( cursor: &mut SnapshotByteCursor<'_>, + has_value_kind: bool, ) -> Result { let idempotency_key = cursor.read_string()?; let request_checksum = cursor.read_string()?; @@ -131,7 +136,7 @@ pub(crate) fn read_metadata_batch_receipt( let record_count = cursor.read_count("metadata batch receipt record count")?; let mut records = Vec::with_capacity(record_count); for _ in 0..record_count { - records.push(read_metadata_record(cursor)?); + records.push(read_metadata_record(cursor, has_value_kind)?); } Ok(MetadataBatchReceipt { idempotency_key, @@ -186,6 +191,7 @@ pub(crate) fn read_metadata_transaction( pub(crate) fn read_metadata_transaction_receipt( cursor: &mut SnapshotByteCursor<'_>, + has_value_kind: bool, ) -> Result { let idempotency_key = cursor.read_string()?; let operation = cursor.read_string()?; @@ -194,7 +200,7 @@ pub(crate) fn read_metadata_transaction_receipt( let record_count = cursor.read_count("metadata transaction receipt record count")?; let mut records = Vec::with_capacity(record_count); for _ in 0..record_count { - records.push(read_metadata_record(cursor)?); + records.push(read_metadata_record(cursor, has_value_kind)?); } Ok(MetadataTransactionReceipt { idempotency_key, diff --git a/src/protocol/metadata/snapshot_decode.inc.rs b/src/protocol/metadata/snapshot_decode.inc.rs index 0a44dff..ab54818 100644 --- a/src/protocol/metadata/snapshot_decode.inc.rs +++ b/src/protocol/metadata/snapshot_decode.inc.rs @@ -7,7 +7,7 @@ impl MetaSnapshot { let version = magic .strip_prefix("MOSRSMETA") .and_then(|suffix| suffix.parse::().ok()) - .filter(|version| (1..=16).contains(version)) + .filter(|version| (1..=17).contains(version)) .ok_or_else(|| { ObjectError::Corruption("metadata snapshot byte magic mismatch".to_string()) })?; @@ -30,6 +30,11 @@ impl MetaSnapshot { let has_qos = version >= 13; let has_coordinator_config = version >= 14; let has_coordinator_config_receipts = version >= 15; + // A RECORD'S VALUE STATES ITS SHAPE from 17 on. Before that every value was text -- + // an object record carried the whole rendering -- so an older snapshot reads as text + // and is EQUAL to the digest a live write produces, which is what makes this + // readable rather than a migration a deployment has to perform. + let has_record_value_kind = version >= 17; let next_commit_index = cursor.read_u64()?; let next_change_sequence = cursor.read_u64()?; let compacted_tombstone_count = if has_tombstone_compaction_counters { @@ -101,9 +106,15 @@ impl MetaSnapshot { // it and the record names it again, and both now do so through one // allocation. Building each separately would rebuild the copy. let key: SharedText = cursor.read_string()?.into(); + let value_kind = if has_record_value_kind { + cursor.read_u64()? + } else { + 0 + }; + let value_text = cursor.read_string()?; let record = MetadataRecord { key: key.clone(), - value: cursor.read_string()?.into(), + value: RecordValue::from_kind_tag(value_kind, &value_text)?, version: cursor.read_u64()?, update_time_ms: cursor.read_u64()?, deleted: cursor.read_bool()?, @@ -182,7 +193,10 @@ impl MetaSnapshot { for _ in 0..cursor.read_count("metadata snapshot batch receipt count")? { let idempotency_key = cursor.read_string()?; batch_request_receipts - .insert(idempotency_key, read_metadata_batch_receipt(&mut cursor)?); + .insert( + idempotency_key, + read_metadata_batch_receipt(&mut cursor, has_record_value_kind)?, + ); } } let mut transactions = BTreeMap::new(); @@ -228,7 +242,7 @@ impl MetaSnapshot { let idempotency_key = cursor.read_string()?; transaction_receipts.insert( idempotency_key, - read_metadata_transaction_receipt(&mut cursor)?, + read_metadata_transaction_receipt(&mut cursor, has_record_value_kind)?, ); } } diff --git a/src/protocol/metadata/snapshot_encode.inc.rs b/src/protocol/metadata/snapshot_encode.inc.rs index 5544606..acfa481 100644 --- a/src/protocol/metadata/snapshot_encode.inc.rs +++ b/src/protocol/metadata/snapshot_encode.inc.rs @@ -10,7 +10,7 @@ impl MetaSnapshot { pub(crate) fn append_to_bytes(&self, mut out: &mut Vec) { let snapshot_start = out.len(); - append_string(&mut out, "MOSRSMETA16"); + append_string(&mut out, "MOSRSMETA17"); append_u64(&mut out, self.next_commit_index); append_u64(&mut out, self.next_change_sequence); append_u64(&mut out, self.compacted_tombstone_count); @@ -32,6 +32,9 @@ impl MetaSnapshot { append_u64(&mut out, self.records.len() as u64); for (key, record) in &self.records { append_string(&mut out, key); + // THE SHAPE, THEN THE CHARACTERS. A value's text alone cannot say + // whether it is a digest or a text that happens to spell like one. + append_u64(&mut out, record.value.kind_tag()); append_string(&mut out, &record.value.as_text()); append_u64(&mut out, record.version); append_u64(&mut out, record.update_time_ms); @@ -285,7 +288,7 @@ fn metadata_record_encoded_size(record: &MetadataRecord) -> usize { fn metadata_record_value_encoded_size(record: &MetadataRecord) -> usize { encoded_string_len(&record.value.as_text()) - .saturating_add(3 * std::mem::size_of::()) + .saturating_add(4 * std::mem::size_of::()) } fn metadata_lease_encoded_size(lease: &MetadataLease) -> usize { @@ -353,7 +356,7 @@ fn metadata_transaction_value_encoded_size(transaction: &MetadataTransaction) -> fn metadata_change_checkpoint_encoded_size(checkpoint: &MetadataChangeCheckpoint) -> usize { encoded_string_len(&checkpoint.consumer_id) .saturating_add(encoded_string_len(&checkpoint.prefix)) - .saturating_add(3 * std::mem::size_of::()) + .saturating_add(4 * std::mem::size_of::()) .saturating_add(encoded_string_len(&checkpoint.owner)) .saturating_add(encoded_string_len(&checkpoint.note)) } @@ -376,7 +379,7 @@ fn metadata_lease_receipt_encoded_size(receipt: &MetadataLeaseReceipt) -> usize .saturating_add(encoded_string_len(&receipt.operation)) .saturating_add(encoded_string_len(&receipt.request_checksum)) .saturating_add(metadata_lease_encoded_size(&receipt.lease)) - .saturating_add(3 * std::mem::size_of::()) + .saturating_add(4 * std::mem::size_of::()) } fn coordinator_session_receipt_encoded_size(receipt: &CoordinatorSessionReceipt) -> usize { @@ -384,7 +387,7 @@ fn coordinator_session_receipt_encoded_size(receipt: &CoordinatorSessionReceipt) .saturating_add(encoded_string_len(&receipt.operation)) .saturating_add(encoded_string_len(&receipt.request_checksum)) .saturating_add(coordinator_session_encoded_size(&receipt.session)) - .saturating_add(3 * std::mem::size_of::()) + .saturating_add(4 * std::mem::size_of::()) } fn metadata_shard_receipt_encoded_size(receipt: &MetadataShardReceipt) -> usize { @@ -392,7 +395,7 @@ fn metadata_shard_receipt_encoded_size(receipt: &MetadataShardReceipt) -> usize encoded_string_len(&receipt.idempotency_key) .saturating_add(encoded_string_len(&receipt.operation)) .saturating_add(encoded_string_len(&receipt.request_checksum)) - .saturating_add(3 * std::mem::size_of::()), + .saturating_add(4 * std::mem::size_of::()), |total, shard| total.saturating_add(metadata_shard_encoded_size(shard)), ) } @@ -416,7 +419,7 @@ fn metadata_transaction_receipt_encoded_size(receipt: &MetadataTransactionReceip .saturating_add(encoded_string_len(&receipt.operation)) .saturating_add(encoded_string_len(&receipt.request_checksum)) .saturating_add(metadata_transaction_encoded_size(&receipt.transaction)) - .saturating_add(3 * std::mem::size_of::()), + .saturating_add(4 * std::mem::size_of::()), |total, record| total.saturating_add(metadata_record_encoded_size(record)), ) } diff --git a/src/tests.rs b/src/tests.rs index d7fbdbc..8106fd6 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -13264,8 +13264,22 @@ fn raft_backed_store_exports_and_installs_chunked_snapshots() { .store() .metadata_record("object/bucket/snapshot-object") .unwrap(); - assert!(restored_record.value.as_text().contains("version=1")); - assert!(restored_record.value.as_text().contains("length=192")); + // THE RESTORED VALUE IS A DIGEST, NOT THE OBJECT'S FIELDS. What a restored record can + // still do is agree with the object: the digest it carries must be the digest of the + // object's rendered metadata, which is what makes a restored metaserver able to VERIFY a + // value it can no longer reproduce. Taken from the FOLLOWER's own object, so this also + // says the follower's two copies of the object agree after an install. + let restored_metadata = follower + .store() + .head_object("bucket", "snapshot-object") + .unwrap(); + assert_eq!(restored_metadata.version, 1); + assert_eq!(restored_metadata.length, 192); + assert_eq!( + restored_record.value, + RecordValue::Digest(RecordValue::digest_of_object(&restored_metadata)), + "a restored object record must carry the digest of the object it names" + ); } #[test] @@ -13543,14 +13557,17 @@ fn raft_backed_store_replays_object_tag_updates() { follower.get_object_tags("bucket", "tagged").unwrap(), tags(&[("team", "infra")]) ); + // The replayed record must agree with the replayed object. Taken from the FOLLOWER's own + // object, so a replay that updated one copy and not the other fails here. assert_eq!( follower .metadata_record(&meta_object_key(&ObjectId::new("bucket", "tagged"))) .unwrap() - .value - .as_text() - .contains("tag_count=1"), - true + .value, + RecordValue::Digest(RecordValue::digest_of_object( + &follower.head_object("bucket", "tagged").unwrap() + )), + "a replayed object record must carry the digest of the replayed object" ); assert_eq!(follower.last_applied_index(), 2); } @@ -30481,8 +30498,14 @@ fn metaserver_projects_object_metadata_and_enforces_leases() { let record = store .metadata_record(&meta_object_key(&metadata.object_id)) .unwrap(); - assert!(record.value.as_text().contains("version=1")); - assert!(record.value.as_text().contains("length=6")); + // THE RECORD AGREES WITH THE OBJECT, which is a stronger claim than the one that stood + // here: `contains("version=1")` also passes for a record describing a DIFFERENT object + // that happens to be at version one. The digest is of the whole rendering. + assert_eq!( + record.value, + RecordValue::Digest(RecordValue::digest_of_object(&metadata)), + "the record at an object key must carry the digest of that object's metadata" + ); let clean_audit = store .audit_object_metadata("bucket", "", None, 10, 1024, MetadataReadOptions::default()) .unwrap(); @@ -33231,7 +33254,7 @@ fn metaserver_raft_snapshot_chunks_restore_followers() { } let snapshot_bytes = assembled.expect("final metadata snapshot image"); let mut snapshot_cursor = SnapshotByteCursor::new(&snapshot_bytes); - assert_eq!(snapshot_cursor.read_string().unwrap(), "MOSRSMETA16"); + assert_eq!(snapshot_cursor.read_string().unwrap(), "MOSRSMETA17"); let snapshot = MetaSnapshot::from_bytes(&snapshot_bytes).unwrap(); assert_eq!( snapshot.next_commit_index.saturating_sub(1), @@ -33969,7 +33992,7 @@ fn every_durable_format_constant_is_accounted_for() { "MOSRSMETA-CONTROL12", "MOSRSMETA-FINGERPRINT1", "MOSRSMETA-INTEGRITY1", - "MOSRSMETA16", + "MOSRSMETA17", "MOSRSMODULESTATUS32", "MOSRSOBJECTREADY77", "MOSRSROOT", @@ -34369,124 +34392,6 @@ fn measure_what_a_metadata_record_value_actually_holds() { } } -#[test] -#[ignore = "a measurement, not an assertion: cargo test -- --ignored --nocapture"] -fn measure_how_much_of_a_record_value_is_scaffolding() { - // At 1 KiB objects the store sits at 3.93x where three-way replication - // allows 3.00x, and every byte of the difference is metadata: ~933 an - // object. The largest single piece is the metadata record's value, 227 - // bytes of formatted text restating fields the `objects` map already holds - // structured. - // - // Before proposing anything, split those bytes into the part that VARIES - // between objects and the part that is the same characters every time -- - // field names, separators and values that do not differ. The second part is - // what a structured representation would remove, and it is the only part - // worth a design change. - fn env_usize(name: &str, fallback: usize) -> usize { - std::env::var(name) - .ok() - .and_then(|raw| raw.parse().ok()) - .unwrap_or(fallback) - } - let objects = env_usize("MEASURE_OBJECTS", 200); - let mut store = MatrixObjectStore::new(StoreOptions { - volume_count: 2, - ..StoreOptions::default() - }) - .unwrap(); - store.create_bucket("bucket").unwrap(); - // The objects have to DIFFER from each other or this measures nothing: a - // run of identical payloads gives every record the same length and the same - // etag, and the split between "the same characters every time" and "varies - // per object" then reports the fixture rather than the system. The first - // version of this measurement did exactly that and found zero varying - // bytes, which is impossible for a store holding different objects. - let content_types = [ - "application/octet-stream", - "text/plain", - "image/png", - "application/json", - ]; - for index in 0..objects { - let size = 512 + (index % 97) * 37; - store - .put_object( - "bucket", - &format!("prefix/{index:08}/kkkkkkkkkk"), - vec![b'a' + (index % 26) as u8; size], - content_types[index % content_types.len()], - ) - .unwrap(); - } - - // Every record value is a run of `name=value` pairs separated by spaces. - // The names and the separators are the same in all of them; so is a value - // that never differs between objects. - let mut total = 0u64; - let mut names_and_separators = 0u64; - let mut values_that_never_differ = 0u64; - let mut values_that_differ = 0u64; - let mut seen: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - let mut records = 0u64; - for (_, record) in store.meta_service.records.iter() { - if !record.value.as_text().contains('=') { - continue; - } - records += 1; - total += record.value.len() as u64; - for (position, field) in record.value.as_text().split(' ').enumerate() { - let Some((name, value)) = field.split_once('=') else { - continue; - }; - // the name, the '=', and the space before every field but the first - names_and_separators += name.len() as u64 + 1 + u64::from(position > 0); - seen.entry(name.to_string()) - .or_default() - .insert(value.to_string()); - } - } - for (_, record) in store.meta_service.records.iter() { - if !record.value.as_text().contains('=') { - continue; - } - for field in record.value.as_text().split(' ') { - let Some((name, value)) = field.split_once('=') else { - continue; - }; - if seen.get(name).map(|set| set.len()) == Some(1) { - values_that_never_differ += value.len() as u64; - } else { - values_that_differ += value.len() as u64; - } - } - } - println!("RECORDSHAPE records={records} total={total} per_record={}", total / records.max(1)); - println!( - "RECORDSHAPE names_and_separators={names_and_separators} per_record={}", - names_and_separators / records.max(1) - ); - println!( - "RECORDSHAPE values_that_never_differ={values_that_never_differ} per_record={}", - values_that_never_differ / records.max(1) - ); - println!( - "RECORDSHAPE values_that_differ={values_that_differ} per_record={}", - values_that_differ / records.max(1) - ); - println!( - "RECORDSHAPE removable_per_record={}", - (names_and_separators + values_that_never_differ) / records.max(1) - ); - for (name, values) in seen.iter() { - println!( - "RECORDSHAPE field {name} distinct_values={} example={}", - values.len(), - values.iter().next().map(|v| v.as_str()).unwrap_or("") - ); - } -} #[test] #[ignore = "a measurement, not an assertion: cargo test -- --ignored --nocapture"] fn measure_what_the_object_metadata_struct_is_made_of() { @@ -35600,13 +35505,250 @@ fn the_audit_counts_what_the_metadata_service_holds() { } #[test] -fn the_record_value_conversion_is_free_after_a_snapshot_load_and_better_before_it() { - // A record built by a write holds a projection; a record decoded from a snapshot holds the - // text the snapshot carried, because that is all a snapshot carries. So the saving applies to - // a live store and goes away on load, which is exactly the trade the representation was - // chosen for -- and the half that needs checking is that the loaded form is no WORSE than - // the `Box` it replaced. If it were, this would be a regression hidden behind an - // improvement. +fn the_snapshot_decoder_accepts_the_version_before_this_one_and_refuses_the_one_after() { + // This change added a `u64` in front of every record's value in a metadata snapshot. That + // is only safe because the decoder reads the version FIRST and gates the field on it -- + // without the stamp bump that came with it, a snapshot written before this change would be + // accepted and the string length in front of the value read as the new field. + // + // What is checkable here is the RANGE, which is the part this change touched. A build that + // narrowed it would refuse every snapshot written before this one; a build that widened it + // too far would read a FUTURE version's field as one of its own. + // + // It does NOT prove an old snapshot decodes: there is no stored snapshot fixture in this + // tree, for this version or for any of the sixteen before it, so a round-trip test writes + // and reads with the same build and both halves are wrong together. Saying so is the point + // -- the gap is in the fixtures, not in the reasoning. + fn header(stamp: &str) -> Vec { + let mut bytes = Vec::new(); + append_string(&mut bytes, stamp); + bytes + } + fn magic_rejected(stamp: &str) -> bool { + match MetaSnapshot::from_bytes(&header(stamp)) { + Err(ObjectError::Corruption(message)) => message.contains("magic mismatch"), + _ => false, + } + } + + assert!( + !magic_rejected("MOSRSMETA17"), + "the version this build writes must not be refused by its own decoder" + ); + assert!( + !magic_rejected("MOSRSMETA16"), + "the version before this one must still get past the magic, or this change made every \ + snapshot written before it unreadable" + ); + assert!( + magic_rejected("MOSRSMETA18"), + "a version after this one must be refused, or a field this build does not know would be \ + read as one of its own" + ); + assert!(magic_rejected("MOSRSMETA0"), "zero is not a version this format ever had"); + assert!( + magic_rejected("MOSRSMETA017"), + "a stamp must match the version it parses as, or `MOSRSMETA017` reads as seventeen" + ); + assert!( + magic_rejected("MOSRSROOT17"), + "another format's stamp at the same version is not this format's" + ); + + // NOT VACUOUS. The two accepted stamps above are accepted at the MAGIC and must still fail + // on the body, which is what says those assertions are about the version check rather than + // about an empty snapshot being tolerated. + for stamp in ["MOSRSMETA17", "MOSRSMETA16"] { + assert!( + MetaSnapshot::from_bytes(&header(stamp)).is_err(), + "a header with no body is not a snapshot, whatever its version: {stamp}" + ); + } +} + +#[test] +fn a_digest_equals_the_text_it_is_a_digest_of_and_refuses_a_corrupt_tag() { + // A record at an object key holds a DIGEST; a record anywhere else holds TEXT. Two things + // have to hold for that split to be safe, and neither is checked by anything that merely + // reads a byte figure. + // + // FIRST, a digest must EQUAL the text it is a digest of. The listing audit renders a + // projection from the live object and compares it to the stored record; if that comparison + // went through `as_text`, a correctly written object would read as drifted, because the + // stored value spells `fnv1a64:...` and the rendered one spells twelve fields. That exact + // bug happened once already, when this equality was derived rather than written. + // + // SECOND, the digest's spelling is now part of the durable format, and a format with a + // writer and no reader is a one-way door. `from_kind_tag` is the reader, and what it does + // with bytes that are NOT a digest decides whether a corrupt snapshot is caught or read as + // the digest zero. + let mut store = MatrixObjectStore::new(test_options()).unwrap(); + store.create_bucket("bucket").unwrap(); + let metadata = store + .put_object("bucket", "object", b"payload".to_vec(), "text/plain") + .unwrap(); + let record = store + .metadata_record(&meta_object_key(&metadata.object_id)) + .unwrap(); + + // The stored value is a digest, and the object is what it is a digest OF. + let rendered = metadata_projection_value(&metadata); + assert_eq!( + record.value, + RecordValue::Digest(RecordValue::digest_of_object(&metadata)), + "the record at an object key should hold the digest of that object" + ); + assert_eq!( + record.value, + RecordValue::from(rendered.as_str()), + "a digest must equal the text it is a digest of, or every audit that renders a \ + projection and compares it reads a correct object as drifted" + ); + assert_eq!( + RecordValue::from(rendered.as_str()), + record.value, + "and the equality must hold in both directions, which a hand-written impl can get \ + wrong one way round" + ); + + // NOT VACUOUS: a value that is not that text must NOT be equal. Without this the clause + // above would pass for an equality that returned true for everything. + let mut other = rendered.clone(); + other.push('x'); + assert_ne!( + record.value, + RecordValue::from(other.as_str()), + "a digest must not equal text it is not a digest of" + ); + + // THE SPELLING IS THE DIGEST'S, NOT THE OBJECT'S. This is the trade stated as a test: a + // record can no longer be read for the object's fields, which is why the restore is not + // self-sufficient. + let spelled = record.value.as_text().into_owned(); + assert!( + spelled.starts_with("fnv1a64:") && spelled.len() == 24, + "a digest should spell `fnv1a64:` and sixteen hex digits: {spelled}" + ); + assert!( + !spelled.contains("version="), + "a stored object record no longer spells the object's fields, and a test asserting it \ + does is asserting the old representation: {spelled}" + ); + assert_eq!(record.value.heap_bytes(), 0, "a digest owns nothing on the heap"); + assert!(!record.value.is_empty(), "a digest is never empty"); + + // THE TAG ROUND-TRIPS, for both shapes, through exactly the pair the encoders write. + for value in [ + record.value.clone(), + RecordValue::from("bucket/bucket versioning=off"), + RecordValue::empty(), + ] { + let text = value.as_text().into_owned(); + let back = RecordValue::from_kind_tag(value.kind_tag(), &text) + .expect("a value this tree wrote must read back"); + assert_eq!( + back, value, + "a value must survive its own tag and spelling: {text}" + ); + // AND THE SHAPE SURVIVES, not merely the equality. A digest that read back as text + // would still compare EQUAL -- by the clause above -- while owning its characters on + // the heap, which is the whole saving given away silently. + assert_eq!( + back.heap_bytes(), + value.heap_bytes(), + "the shape must survive too, not just the comparison: {text}" + ); + } + + // AND IT REFUSES WHAT IT CANNOT READ, rather than reading it as zero. + for (tag, text, why) in [ + (1u64, "not-a-digest", "no prefix"), + (1, "fnv1a64:", "no digits"), + (1, "fnv1a64:0123", "too few digits"), + (1, "fnv1a64:zzzzzzzzzzzzzzzz", "not hexadecimal"), + (2, "fnv1a64:0123456789abcdef", "a shape this build does not know"), + ] { + assert!( + RecordValue::from_kind_tag(tag, text).is_err(), + "tag {tag} spelling {text} should be refused -- {why}" + ); + } +} + +#[test] +fn two_records_spelling_the_same_thing_in_different_shapes_hash_differently() { + // `append_metadata_record` is a PURE FUNCTION OF A RECORD: no store is in reach, so the + // material a proof hashes cannot depend on a replica's local state. That is the property + // the stored value exists to keep, and it is why an object record could not render its text + // by asking the storage node. + // + // The digest keeps it, and adds a consequence worth writing down: a record holding a digest + // and a record holding the digest's SPELLING as text are EQUAL -- same characters -- and + // hash DIFFERENTLY, because the material states the shape first. That is correct. They are + // different records, and a proof that could not tell them apart would be hashing less than + // the record contains. It is also safe: within a replica set the shape is decided by the + // entry being applied, so two replicas applying the same entry hold the same shape. + // + // Written as a test because the obvious "fix" -- dropping the tag so equal values hash + // alike -- would silently make a corrupt snapshot indistinguishable from a valid one. + let digest = RecordValue::Digest(0x0123_4567_89ab_cdef); + let spelling = digest.as_text().into_owned(); + let as_text = RecordValue::from(spelling.as_str()); + assert_eq!( + digest.as_text(), + as_text.as_text(), + "the two shapes must spell the same thing, or this test is about nothing" + ); + + let material = |value: &RecordValue| -> Vec { + let mut out = Vec::new(); + append_metadata_record( + &mut out, + &MetadataRecord { + key: "object/bucket/object".into(), + value: value.clone(), + version: 7, + update_time_ms: 11, + deleted: false, + }, + ); + out + }; + + // A FUNCTION OF THE RECORD ALONE: the same record twice is the same bytes. + assert_eq!( + material(&digest), + material(&digest), + "the material for one record must not depend on anything outside it" + ); + assert_ne!( + material(&digest), + material(&as_text), + "the material must state the shape, or a digest and its own spelling as text would be \ + indistinguishable in every proof and every snapshot" + ); + // And the difference is the TAG, not the characters: everything after the first eight + // bytes of the value field is the same text. + assert_ne!( + digest.kind_tag(), + as_text.kind_tag(), + "the two shapes must carry different tags, which is what makes the material differ" + ); +} + +#[test] +fn an_object_record_owns_no_heap_before_or_after_a_snapshot_load() { + // A record at an object key holds a DIGEST of the object's rendered metadata: eight bytes + // inline, nothing on the heap. The half that needs proving is the LOADED half. A snapshot + // carries the digest's SPELLING -- `fnv1a64:` and sixteen hex digits -- and a decoder that + // read those twenty-four characters back as text would rebuild a boxed string and give the + // whole saving away on every restore, silently, while every byte figure in a live store + // still looked right. + // + // So the occupied bytes are asserted EQUAL before and after a round-trip. The previous + // representation could not make that claim: a projection shrank a live store and went back + // to text on load, and the test that stood here asserted only that the loaded form was no + // worse than what it replaced. fn records_value_legs(store: &MatrixObjectStore) -> (u64, u64) { let audit: std::collections::BTreeMap = store.footprint_audit().into_iter().collect(); @@ -35625,16 +35767,24 @@ fn the_record_value_conversion_is_free_after_a_snapshot_load_and_better_before_i // and a hundred objects cost seven minutes. The facts this test establishes are structural, // so they hold at any size and the smallest honest one is the right one. const OBJECTS: u64 = 25; - - // A ZERO-OBJECT BASELINE, because not every record is an object projection: creating the - // bucket files a record of its own, and it is text. Subtracted rather than tolerated -- a - // tolerance would hide a second text record appearing later, which is the drift this is - // written to catch. + // `fnv1a64:` and sixteen hex digits. Spelled out rather than read off the type, so a change + // to the spelling fails here instead of being absorbed. + const SPELLED: u64 = 24; + + // A ZERO-OBJECT BASELINE, because not every record is an object: creating the bucket files a + // record of its own, and a bucket's value is TEXT -- it is the only copy there is, so it + // cannot be a digest. Subtracted rather than tolerated; a tolerance would hide a second text + // record appearing later, which is the drift this is written to catch. let baseline = { let mut empty = MatrixObjectStore::new(test_options()).unwrap(); empty.create_bucket("bucket").unwrap(); records_value_legs(&empty) }; + assert!( + baseline.1 > 0, + "the bucket's own record should own heap, or the baseline is not measuring text: {}", + baseline.1 + ); println!( "RECORDVALUESTATES baseline len={} capacity={}", baseline.0, baseline.1 @@ -35665,60 +35815,42 @@ fn the_record_value_conversion_is_free_after_a_snapshot_load_and_better_before_i loaded len={loaded_len} capacity={loaded_capacity}" ); - // THE TEXT IS THE SAME IN BOTH STATES, which is the invariant everything else rests on: the - // snapshot carries the characters, so a loaded store spells exactly what the live one did. + // THE SPELLING SURVIVES THE ROUND-TRIP, which is what every proof and every checksum over a + // set of records rests on. assert_eq!( live_len, loaded_len, "a snapshot round-trip changed what the records spell, which would mean the encoding \ - and the projection disagree" - ); - - // LIVE: the projection occupies less than it spells. - assert!( - live_capacity < live_len, - "a live store should hold projections, which occupy less than they spell: {live_capacity} \ - against {live_len}" + and the value disagree" ); - // LOADED: text, so it occupies exactly what it spells -- no slack, because the decoded value - // is a boxed string. That is the `Box` figure this conversion replaced, so the worst - // case is EQUAL and not worse. + // AND SO DOES THE OCCUPANCY. Both halves are asserted against the BASELINE rather than + // against each other: two stores that had both rebuilt text would also agree with each + // other, and the comparison alone would pass. assert_eq!( - loaded_capacity, loaded_len, - "a loaded store holds text, which has no spare capacity, so capacity must equal len: \ - {loaded_capacity} against {loaded_len}" + live_capacity, baseline.1, + "an object record's value should own no heap at all, so a live store should owe exactly \ + what the bucket's own text record owes: {live_capacity} against {}", + baseline.1 ); - assert!( - loaded_capacity >= live_capacity, - "the loaded form must not be cheaper than the live one, or the claim is the wrong way \ - round: {loaded_capacity} against {live_capacity}" - ); - - // STRUCTURAL, so it holds at any probe size: a live record's value occupies exactly the - // projection box and nothing else. Pinning the SAVING as a constant would pin the probe -- - // these records average 229 characters where a canonical projection spells 227, so the - // figure moves with the key lengths and the version digits. - let projection_box = std::mem::size_of::() as u64; assert_eq!( - live_capacity - baseline.1, - OBJECTS * projection_box, - "every record an object added should occupy one projection box of {projection_box} \ - bytes: {live_capacity} less the {} the bucket's own record costs, against {}", + loaded_capacity, baseline.1, - OBJECTS * projection_box + "a loaded store should owe the same, and a decoder that read the digest's spelling back \ + as text would owe {} more: {loaded_capacity} against {}", + OBJECTS * SPELLED, + baseline.1 ); - // The saving is PRINTED with its denominator named, not asserted as a constant. - let saved_per_record = (loaded_capacity - live_capacity) / OBJECTS; - let spelled_per_record = (loaded_len - baseline.0) / OBJECTS; + // WHAT A RECORD SPELLS, asserted rather than printed: the digest's spelling is part of the + // durable format now, and a change to it moves every snapshot and every proof. + let spelled_per_record = (live_len - baseline.0) / OBJECTS; + assert_eq!( + spelled_per_record, SPELLED, + "an object record should spell a digest: {spelled_per_record} characters against {SPELLED}" + ); println!( "RECORDVALUESTATES objects={OBJECTS} spelled_per_record={spelled_per_record} \ - occupied_live={projection_box} saved_per_record={saved_per_record}" - ); - assert_eq!( - saved_per_record, - spelled_per_record - projection_box, - "the saving is what a record spells minus the box it occupies" + occupied_per_record=0" ); } @@ -36855,173 +36987,168 @@ fn measure_what_a_one_word_text_handle_costs_to_read() { #[test] #[ignore = "a measurement, not an assertion: cargo test -- --ignored --nocapture"] -fn measure_the_route_to_under_a_hundred_bytes_a_metadata_record() { - // The metaserver's share of the line is 200 bytes an object, against a per-key record of - // TWENTY-FOUR in the time-series engine beside it. The difference is what the record IS: - // there it locates the data -- an address, a size, a deadline, flags -- and here it - // RESTATES the object, thirteen fields of it, behind a box. +fn measure_the_route_to_eighty_bytes_a_metadata_record() { + // The boxed projection is GONE -- a record at an object key holds an eight-byte digest + // inline -- so what is left is three parts, and two of them are the object's own name: // - // 16 map key handle + 16 duplicate key handle + 16 RecordValue + 24 scalars - // + 56 key characters + 72 boxed projection = 200 + // 16 the map key's handle + // 56 the record struct: 16 key handle + 16 value + 8 version + 8 time + 8 for a bool + // 56 the key's characters: 40 of them behind a 16-byte Arc header + // --- + // 128 // - // Mirrors sized by the compiler, controls asserted equal to the real types first. + // Every candidate below is a mirror sized by the compiler, and every control is asserted + // equal to the real type FIRST -- arithmetic has lost to the compiler three times in this + // file, and a mirror measured against a stale baseline reads as a finding when it is a + // drift. use std::mem::size_of; + assert_eq!(size_of::(), 16, "a shared handle should be a fat pointer"); assert_eq!( - size_of::() + size_of::(), - 72, - "the record plus the map key's handle should be the 72 the document publishes as \ - meta.records.struct_inline" + size_of::(), + 16, + "the value should be the 16 its own doc claims: a boxed string or a digest, and the \ + niche absorbs both the discriminant and the digest's own eight bytes" ); assert_eq!( - size_of::(), - 64, - "and the boxed projection should be the 64 published as value_capacity -- the three - holds moved behind a pointer" + size_of::(), + 56, + "the record struct should be 56: two handles, two stamps and a bool in its own word" ); #[allow(dead_code)] - struct ProjectionControl { + struct RecordControl { + key: SharedText, + value: RecordValue, version: u64, - length: u64, - etag_digest_bits: u64, - has_etag: bool, - content_type: SharedText, - metadata_count: u32, - storage_class: StorageClass, + update_time_ms: u64, deleted: bool, - extent_count: u32, - tag_count: u32, - holds: Option>, } - let control = size_of::(); assert_eq!( - control, - size_of::(), - "the projection control has drifted, so nothing below means anything" + size_of::(), + size_of::(), + "the record control has drifted, so nothing below means anything" ); - // (a) THE CONTENT TYPE AS AN INDEX. Already interned behind a table bounded by a count, - // and still held as a fat pointer. + // (a) THE BOOL HAS ITS OWN EIGHT-BYTE WORD. `deleted` is one bit and `version` is a + // counter that will not reach 2^63. The wire writes the flag as its own `u64` and reads + // it back with `read_bool`, so an accessor returning the bit keeps both byte-identical. #[allow(dead_code)] - struct IndexedContentType { - version: u64, - length: u64, - etag_digest_bits: u64, - has_etag: bool, - content_type: u16, - metadata_count: u32, - storage_class: StorageClass, - holds: Option>, - deleted: bool, - extent_count: u32, - tag_count: u32, + struct FoldedFlag { + key: SharedText, + value: RecordValue, + version_and_deleted: u64, + update_time_ms: u64, } - // (b) THE THREE COUNTS ARE NOT 32-BIT QUANTITIES. A user-metadata entry count, a tag - // count and an extent count are each bounded far below 65,536 by the store's own limits. + // (b) THE VALUE AS TEXT OR A DIGEST, AS AN ENUM. A record at an object key could hold a + // digest of the text instead of the text, because the object itself is the authority. A + // record at a bucket, lifecycle or multipart key could NOT -- its text is the only copy + // there is -- so the value has to be able to hold either, and the question is whether an + // enum over the two still fits in sixteen bytes or grows to twenty-four. #[allow(dead_code)] - struct NarrowCounts { - version: u64, - length: u64, - etag_digest_bits: u64, - has_etag: bool, - content_type: SharedText, - metadata_count: u16, - storage_class: StorageClass, - holds: Option>, - deleted: bool, - extent_count: u16, - tag_count: u16, + enum TextOrDigest { + Text(Box), + Digest(u64), } - // (c) THE TWO RETENTION STAMPS AND THE HOLD ARE RARE. An object under neither a - // restore nor a retention nor a legal hold pays sixteen bytes to say so -- the same - // shape `ObjectLock` already uses on the object side, whose doc says an object under - // none of the three should pay eight rather than twenty-five. + // (c) THE SAME THING AS A TAGGED PAIR OF WORDS, which cannot grow: a digest with the low + // bit of the first word set, a boxed string otherwise. Sized here only to say what (b) + // costs by comparison -- it needs `unsafe` to read back, and this file is not where that + // is decided. #[allow(dead_code)] - struct BoxedRetention { - version: u64, - length: u64, - etag_digest_bits: u64, - has_etag: bool, - content_type: SharedText, - metadata_count: u32, - storage_class: StorageClass, - deleted: bool, - extent_count: u32, - tag_count: u32, - retention: Option>, + struct TaggedValue { + tag_or_pointer: u64, + length_or_digest: u64, } - // (d) ALL THREE. + // (d) THE RECORD WITHOUT ITS KEY. The map it lives in is keyed by the same characters + // through the same allocation, so the record's handle is a second holder of one + // allocation. #[allow(dead_code)] - struct Everything { - version: u64, - length: u64, - etag_digest_bits: u64, - has_etag: bool, - content_type: u16, - metadata_count: u16, - storage_class: StorageClass, - deleted: bool, - extent_count: u16, - tag_count: u16, - retention: Option>, + struct KeylessRecord { + value: RecordValue, + version_and_deleted: u64, + update_time_ms: u64, } - // (e) THE CLEAN SUBSET: (b) and (c), without the content type. (a) needs the shared - // content-type table to be addressable by index, and it is bounded by a COUNT of 256 - // with an object past the bound owning its own string -- which a `u16` cannot - // represent, and an enum over the two cases is sixteen bytes again. So (a) is not - // available, and this is what is. + // (e) ALL OF IT: no key, the flag folded, the value a tagged pair. #[allow(dead_code)] - struct CleanSubset { - version: u64, - length: u64, - etag_digest_bits: u64, - has_etag: bool, - content_type: SharedText, - metadata_count: u16, - storage_class: StorageClass, - deleted: bool, - extent_count: u16, - tag_count: u16, - retention: Option>, - } - let rows: [(&str, usize); 5] = [ - ("a. the content type as an index (BLOCKED: the table is bounded by a count)", - size_of::()), - ("b. the three counts as 16-bit", size_of::()), - ("c. the retention stamps behind a pointer", size_of::()), - ("d. all three together", size_of::()), - ("e. the CLEAN subset, b and c only", size_of::()), - ]; - println!("METAREC control={control} record={} handle={}", - size_of::(), size_of::()); - for (what, size) in rows { - println!("METAREC {what}: {control} -> {size}, saves {}", control as i64 - size as i64); - } - - // WHERE THE METASERVER LANDS. The projection is the only part of the 200 that is not a - // migration: the duplicate key handle is encoded into the snapshot and the record travels - // without its map, and the Arc header exists only to serve that duplicate. - let now = 16 + 16 + 16 + 24 + 56 + control; - // The holds fold has LANDED, so the control is the struct after it and (c) is no - // longer a candidate -- it is the baseline. - assert_eq!(control, 64, "the control should be the projection after the holds fold"); - let after = 16 + 16 + 16 + 24 + 56 + size_of::(); - println!("METAREC metaserver now={now} after_the_projection={after}"); - assert_eq!(now, 192, "the metaserver share should be the 192 published, and it is {now}"); - // AND WHAT IT WOULD TAKE TO REACH A HUNDRED, stated so the next reader does not have to - // re-derive it: dropping the duplicate handle and its Arc header is -32 and a migration, - // which would leave 168 - (control - Everything). + struct Everything { + value: TaggedValue, + version_and_deleted: u64, + update_time_ms: u64, + } + + let record = size_of::(); + println!( + "EIGHTY control record={record} value={} folded={} \ + text_or_digest={} tagged={} keyless={} everything={}", + size_of::(), + size_of::(), + size_of::(), + size_of::(), + size_of::(), + size_of::(), + ); + // THE DIGEST HAS LANDED, so `TextOrDigest` is no longer a candidate -- it is a mirror of + // the real `RecordValue`, and it is kept only to say that the sixteen bytes are a + // measured fact rather than a hope about niche layout, and that the `unsafe` tagged pair + // beside it buys nothing. + assert_eq!( + size_of::(), + size_of::(), + "the value mirror has drifted from the real type, so nothing below means anything" + ); + + // THE KEY'S CHARACTERS. `object/{bucket}/{key}` at the published fixture's shape is 40 + // characters. Shared, they cost a 16-byte Arc header once however many holders name them; + // owned by one holder, they cost nothing beyond themselves. + const CHARS: usize = 40; + const SHARED_HEADER: usize = 16; + let shared_chars = CHARS + SHARED_HEADER; + + // THE LADDER. Each rung is the per-object total, and each is the SUM OF MEASURED PARTS + // rather than a figure carried forward by hand. + let map_handle = size_of::(); + let now = map_handle + record + shared_chars; + assert_eq!(now, 128, "the rung we are standing on should be the published 128, not {now}"); + + let folded = map_handle + size_of::() + shared_chars; + let keyless = map_handle + size_of::() + shared_chars; + // A key nothing else holds wants no header. What that costs is a copy of the characters + // for every OTHER resident holder -- the change log holds one per retained change, and it + // is bounded by `change_log_retention_limit`, so the cost is a fixed term and the saving + // is a slope. That is the one rung below that carries a trade rather than being free. + let owned_key = map_handle + size_of::() + CHARS; + + println!("EIGHTY rung now={now}"); + println!("EIGHTY rung the_flag_folded={folded} saves={}", now - folded); + println!("EIGHTY rung and_the_key_dropped={keyless} saves={}", now - keyless); + println!("EIGHTY rung and_the_header_gone={owned_key} saves={}", now - owned_key); + // AND WHAT IS LEFT AT THE BOTTOM is the object's own name and one handle to it. Eighty + // needs the records map SPLIT BY KIND so an object record's value slot can be eight bytes + // rather than sixteen: the slot is wide enough for a boxed string because a bucket record + // shares the map and its text is the only copy there is. println!( - "METAREC with the migration too={}", - after - 32 + "EIGHTY floor name_chars={CHARS} one_handle={map_handle} two_stamps={} \ + value_slot={} -- and a split map would make the slot {}", + 2 * size_of::(), + size_of::(), + size_of::(), ); -} + // WHAT THE FIXED TERM PAYS FOR THE HEADER'S REMOVAL, stated so the trade is a number + // rather than a judgement. One copy of the characters per retained change. + const RETENTION: usize = 1024; + println!( + "EIGHTY the header trades {} bytes an object against {} bytes of fixed term, \ + so it pays above {} objects", + SHARED_HEADER, + RETENTION * CHARS, + (RETENTION * CHARS) / SHARED_HEADER.max(1), + ); +} #[test] #[ignore = "a measurement, not an assertion: cargo test -- --ignored --nocapture"] fn measure_the_route_to_under_a_hundred_bytes_an_object() { @@ -37342,35 +37469,6 @@ fn measure_what_a_one_word_text_handle_would_save() { deleted: bool, } - #[allow(dead_code)] - struct ProjectionControl { - version: u64, - length: u64, - etag_digest_bits: u64, - has_etag: bool, - content_type: SharedText, - metadata_count: u32, - storage_class: StorageClass, - holds: Option>, - deleted: bool, - extent_count: u32, - tag_count: u32, - } - #[allow(dead_code)] - struct ProjectionThin { - version: u64, - length: u64, - etag_digest_bits: u64, - has_etag: bool, - content_type: OneWord, - metadata_count: u32, - storage_class: StorageClass, - holds: Option>, - deleted: bool, - extent_count: u32, - tag_count: u32, - } - let size = std::mem::size_of::(); assert_eq!(size, 8, "the candidate handle is one word, or this measures nothing"); assert_eq!( @@ -37397,14 +37495,15 @@ fn measure_what_a_one_word_text_handle_would_save() { std::mem::size_of::(), "the MetadataRecord mirror has drifted from MetadataRecord" ); - assert_eq!( - std::mem::size_of::(), - std::mem::size_of::(), - "the ObjectProjection mirror has drifted from ObjectProjection" - ); - // The four legs of the metadata line that hold text handles, named as the + // The three legs of the metadata line that hold text handles, named as the // footprint document names them. + // + // THERE WERE FOUR. The fourth was the handle inside the stored projection, and a record + // stores an eight-byte digest now -- no handle, so no saving available to anyone. Leaving + // the leg in would have published eight bytes an object that cannot be collected, which is + // precisely the stale-baseline mistake a mirror makes when the type it mirrors stops being + // resident. let legs = [ ( "objects.mapkey_inline", @@ -37421,11 +37520,6 @@ fn measure_what_a_one_word_text_handle_would_save() { std::mem::size_of::() + std::mem::size_of::(), std::mem::size_of::() + std::mem::size_of::(), ), - ( - "meta.records.value_capacity", - std::mem::size_of::(), - std::mem::size_of::(), - ), ]; let mut saved = 0usize; @@ -37434,8 +37528,8 @@ fn measure_what_a_one_word_text_handle_would_save() { saved += control - thin; } - // Padding is why this had to be measured rather than multiplied. Eight - // handles at eight bytes each is sixty-four, and the structs do not + // Padding is why this had to be measured rather than multiplied. Seven + // handles at eight bytes each is fifty-six, and the structs do not // necessarily give all of it back: a narrower field can leave padding // behind it instead of closing up. println!("HANDLE inline saving per object = {saved}"); @@ -37468,119 +37562,99 @@ fn measure_what_a_one_word_text_handle_would_save() { } #[test] -#[ignore = "a measurement, not an assertion: cargo test -- --ignored --nocapture"] -fn measure_whether_a_projection_can_be_parsed_back() { - // The packed projection spells the text exactly, which is half of what a - // conversion needs. The other half is the DECODE: a snapshot stores the - // text, so a restored record can only be packed again if the text can be - // read back into twelve values. +fn a_hold_reaches_the_digest_and_an_object_under_none_spells_zeros() { + // A record at an object key holds a digest of the object's RENDERED metadata, and the + // three holds -- a restore, a retention, a legal hold -- are three of the twelve fields + // that get rendered. So they have to reach the digest. // - // It cannot in general, and nothing stops it. There is no - // validate_content_type -- storage_class is a closed set of four and the - // content type is whatever the caller passed -- so a content type carrying - // a space puts an unquoted space inside a space-separated format, and one - // carrying `=` puts a second `k=v` where a parser expects a value. - let mut store = MatrixObjectStore::new(StoreOptions { - volume_count: 2, - ..StoreOptions::default() - }) - .unwrap(); - store.create_bucket("bucket").unwrap(); - for (index, content_type) in [ - "application/octet-stream", - "text/plain; charset=utf-8", - "x metadata_count=9 storage_class=GLACIER", - ] - .iter() - .enumerate() - { - let key = format!("object{index}"); - store - .put_object("bucket", &key, vec![b'x'; 8], *content_type) - .unwrap(); - let metadata = store - .objects - .get(&ObjectId::new("bucket", &key)) - .expect("the object was just written"); - let text = metadata_projection_value(metadata); - // The field count a naive parser would see, against the twelve there are. - let fields = text.split(' ').filter(|part| part.contains('=')).count(); - println!("PARSEBACK content_type={content_type:?} fields_seen={fields} of 12"); - println!("PARSEBACK {text}"); - assert_eq!( - ObjectProjection::from_metadata(metadata).to_text(), - text, - "packing still spells it exactly, whatever the content type" - ); - } - // And the pair that matters for a proof: two DIFFERENT states cannot - // produce the same text, because an injected copy of the literals adds - // length that the real fields still follow. So the format is unparseable - // without being ambiguous -- it is safe to hash and unsafe to read back. - let a = store - .objects - .get(&ObjectId::new("bucket", "object1")) - .unwrap(); - let b = store - .objects - .get(&ObjectId::new("bucket", "object2")) - .unwrap(); - assert_ne!( - metadata_projection_value(a), - metadata_projection_value(b), - "two states must not share a projection" - ); -} - -#[test] -fn a_projection_holds_nothing_for_an_object_under_no_hold() { - // The three holds sit behind a pointer now, and `RecordValue` compares `Projection` to - // `Projection` with a DERIVED `PartialEq` -- so a `None` box and an all-zero box would - // be different values while spelling identical text, which the type's own doc forbids: - // two values that spell the same thing are the same value. + // If `legal_hold()` ever returned a constant, placing a legal hold would leave the + // record's value UNCHANGED and the metadata service could not see the change at all. + // Nothing else would fail: the object's own copy would be right, every byte figure would + // be right, and the two services would silently disagree about an object under a hold. // - // So the constructor must never build an all-zero box, and this asserts that rather - // than trusting the `.then(..)` to keep saying so. + // The guard that stood here asserted the holds owed no HEAP for an object under none of + // the three, because a stored projection charged the audit for its box. Nothing stores a + // projection now, so that figure does not exist -- but this property does, and it is the + // more important of the two. let mut store = MatrixObjectStore::new(test_options()).unwrap(); store.create_bucket("bucket").unwrap(); store .put_object("bucket", "plain", b"x".to_vec(), "text/plain") .unwrap(); - let plain = ObjectProjection::from_metadata(&store.head_object("bucket", "plain").unwrap()); - assert_eq!( - plain.holds_heap_bytes(), - 0, - "an object under no restore, no retention and no legal hold owes nothing for them" - ); + let plain_metadata = store.head_object("bucket", "plain").unwrap(); + let plain = ObjectProjection::from_metadata(&plain_metadata); + + // AN OBJECT UNDER NONE OF THE THREE STILL SPELLS THE ZEROS. The rendered text is what is + // digested, so a field that stopped being spelled would change every object's digest. assert_eq!(plain.restore_until_ms(), 0); assert_eq!(plain.retention_until_ms(), 0); assert!(!plain.legal_hold()); + for spelled in ["restore_until_ms=0", "legal_hold=false", "retention_until_ms=0"] { + assert!( + plain.to_text().contains(spelled), + "a projection with no holds still spells {spelled}: {}", + plain.to_text() + ); + } - // AND THE TEXT IS THE SAME EITHER WAY, which is the property the box must not break: - // the rendered characters are what every proof and every snapshot carries. - assert!( - plain.to_text().contains("restore_until_ms=0"), - "a projection with no holds still spells the zeros: {}", - plain.to_text() - ); - assert!(plain.to_text().contains("legal_hold=false")); - assert!(plain.to_text().contains("retention_until_ms=0")); + // AND FLIPPING ONE OF THEM CHANGES THE DIGEST. One field, on a clone of the same object, + // so nothing else can account for the difference -- not the version, not the length, not + // the etag. The rendered text carries no object name, which is what makes this isolation + // exact: these two metadata values differ in the legal hold and in nothing else. + for (what, mutate) in [ + ( + "a legal hold", + Box::new(|m: &mut ObjectMetadata| m.lock.legal_hold = true) + as Box, + ), + ( + "a retention", + Box::new(|m: &mut ObjectMetadata| m.lock.retention_until_ms = 1_700_000_000_000), + ), + ( + "a restore", + Box::new(|m: &mut ObjectMetadata| m.lock.restore_until_ms = 1_700_000_000_000), + ), + ] { + let mut held_metadata = plain_metadata.clone(); + mutate(&mut held_metadata); + assert_ne!( + RecordValue::of_object(&plain_metadata), + RecordValue::of_object(&held_metadata), + "{what} must change the record's digest, or the metadata service cannot see it" + ); + assert_ne!( + ObjectProjection::from_metadata(&held_metadata).to_text(), + plain.to_text(), + "{what} must change the rendered text, which is what the digest is taken of" + ); + } - // NOT VACUOUS: an object that DOES carry one allocates, so the zero above is a - // statement about this object and not about the accessor always returning zero. + // AND IT REACHES THE STORED RECORD, not merely the renderer: the write path has to + // re-derive the digest when a hold is placed. store .put_object("bucket", "held", b"x".to_vec(), "text/plain") .unwrap(); + let before = store + .metadata_record(&meta_object_key(&ObjectId::new("bucket", "held"))) + .unwrap() + .value; store.set_object_legal_hold("bucket", "held", true).unwrap(); - let held = ObjectProjection::from_metadata(&store.head_object("bucket", "held").unwrap()); - assert!( - held.holds_heap_bytes() > 0, - "an object under a legal hold keeps the three behind a pointer" - ); - assert!(held.legal_hold()); + let after = store + .metadata_record(&meta_object_key(&ObjectId::new("bucket", "held"))) + .unwrap() + .value; assert_ne!( - plain, held, - "and the two are different values, which the derived equality must still see" + before, after, + "placing a legal hold must move the stored record's value, or the two services \ + disagree about an object under a hold" + ); + let held_metadata = store.head_object("bucket", "held").unwrap(); + assert!(held_metadata.lock.legal_hold, "the hold should be on the object"); + assert_eq!( + after, + RecordValue::of_object(&held_metadata), + "and the stored value must be the digest of the object as it now stands" ); } @@ -45595,7 +45669,7 @@ fn a_storage_class_is_one_byte_and_its_wire_is_still_a_name() { assert_eq!( std::mem::size_of::(), 64, - "the same in the projection -- the storage class took 88 to 72 -- and the three holds behind a pointer have since taken 72 to 64" + "the same in the projection -- the storage class took 88 to 72 -- and the three holds behind a pointer have since taken 72 to 64" ); let mut store = MatrixObjectStore::new(StoreOptions {