diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24ca141..8746f44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -498,3 +498,12 @@ jobs: | tee /tmp/guard.log 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 + run: | + set -o pipefail + cargo test --locked --lib \ + tests::a_projection_holds_nothing_for_an_object_under_no_hold -- --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; } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc2ae36..9f7ac9c 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** | 46 individual tests, each run by name with `--exact` | +| **format and rollback guards** | 47 individual tests, each run by name with `--exact` | -**CI does not run the suite.** It compiles everything and runs those 46 guards. A green +**CI does not run the suite.** It compiles everything and runs those 47 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 c80b881..b0d4f79 100644 --- a/README.md +++ b/README.md @@ -237,19 +237,19 @@ 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.12x** | 3.00x | +| 4 KiB | 4,000 | **3.11x** | 3.00x | | 1 KiB | 8,000 | **3.44x** | 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 **418 bytes an object plus 164,256 fixed** whatever the object size. +is **410 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 **200 are the metadata +object's entry in its map, and its key in the payload index -- and **192 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 418. +an object rather than 410. `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 +269,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.10x striped against 3.58x replicated**, both read out of `footprint_audit()` on a +**2.09x striped against 3.58x 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 418 bytes an object either +metadata line above, which striping does not change: the same 410 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 +346,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.5% larger** than when it held them: +**25.6% larger** than when it held them: | | bytes | |---|---:| -| holding 500 objects | 1,835,649 | -| after deleting all 500 | 2,304,662 | +| holding 500 objects | 1,831,649 | +| after deleting all 500 | 2,300,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 f03cf16..6cde5c5 100644 --- a/docs/footprint.md +++ b/docs/footprint.md @@ -21,7 +21,7 @@ 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.12x** | 3.00x | +| 4 KiB | 4,000 | **3.11x** | 3.00x | | 1 KiB | 8,000 | **3.44x** | 3.02x | Reproduce any row with the measurement harness: @@ -67,14 +67,14 @@ are bounded rather than per object: | objects | 100 | 1,000 | 4,000 | 8,000 | |---:|---:|---:|---:|---:| -| metadata per object | 626 B | 582 B | 459 B | **438 B** | +| metadata per object | 618 B | 574 B | 451 B | **430 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: ``` -metadata = 418 bytes an object + 164,256 bytes fixed (remainder: zero) +metadata = 410 bytes an object + 164,256 bytes fixed (remainder: zero) ``` reproducing every total above exactly, at every count where the feeds have @@ -102,13 +102,13 @@ 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 418 is two items and a rounding error: +That 410 is two items and a rounding error: | | per object | share | |---|---:|---:| -| `objects` — the structured metadata | 210 B | 50.2% | -| `meta.records` — the metadata projection record | 200 B | 47.8% | -| payload index keys | 8 B | 1.9% | +| `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 @@ -157,7 +157,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 418 bytes are, and what each remaining part would cost to remove +### What the 410 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 +170,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 72 the projection the record holds +meta.records.value_capacity 64 the projection, its three holds behind a pointer payloads.key_bytes 8 the payload index's key -METADATA LINE TOTAL 418 +METADATA LINE TOTAL 410 ``` `the_published_metadata_decomposition_sums_to_the_line` measures each one and fails if this @@ -192,7 +192,7 @@ characters, sixteen bytes once per allocation. #### The reductions that are left, and what each one costs -Each is priced against the 418. Two rows have come **off** this list, and both came off the +Each is priced against the 410. 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 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 9f30a42..283f8e5 100644 --- a/src/internal/codec/control_storage_proofs/root_usage_projection.rs +++ b/src/internal/codec/control_storage_proofs/root_usage_projection.rs @@ -194,12 +194,24 @@ pub struct ObjectProjection { pub content_type: SharedText, pub metadata_count: u32, pub storage_class: StorageClass, - pub restore_until_ms: u64, pub deleted: bool, pub extent_count: u32, pub tag_count: u32, - pub legal_hold: bool, + /// A restore deadline, a retention deadline and a legal hold -- behind one pointer, + /// because most objects are under none of the three and paid sixteen bytes to say so. + /// + /// `None` and an all-zero box would spell the SAME TEXT, and a derived `PartialEq` + /// would call them different values. So `from_metadata` never builds the second, and + /// `a_projection_with_no_holds_is_the_same_value_as_one_with_empty_holds` asserts it. + holds: Option>, +} + +/// The three deadlines and the hold, for an object that has any of them. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProjectedHolds { + pub restore_until_ms: u64, pub retention_until_ms: u64, + pub legal_hold: bool, } impl ObjectProjection { @@ -212,12 +224,47 @@ impl ObjectProjection { content_type: metadata.content_type.clone(), metadata_count: metadata.user_metadata.len() as u32, storage_class: metadata.storage_class.clone(), - restore_until_ms: metadata.lock.restore_until_ms, deleted: metadata.deleted, extent_count: metadata.extents.len() as u32, tag_count: metadata.tags.len() as u32, - legal_hold: metadata.lock.legal_hold, - retention_until_ms: metadata.lock.retention_until_ms, + // ONLY WHEN THERE IS SOMETHING TO HOLD. An all-zero box spells the same text + // as no box, and a derived `PartialEq` would call the two different values. + holds: (metadata.lock.restore_until_ms != 0 + || metadata.lock.retention_until_ms != 0 + || metadata.lock.legal_hold) + .then(|| { + Box::new(ProjectedHolds { + restore_until_ms: metadata.lock.restore_until_ms, + retention_until_ms: metadata.lock.retention_until_ms, + legal_hold: metadata.lock.legal_hold, + }) + }), + } + } + + /// When a restore expires, or zero for an object under none. + pub fn restore_until_ms(&self) -> u64 { + self.holds.as_deref().map_or(0, |holds| holds.restore_until_ms) + } + + /// When a retention expires, or zero for an object under none. + pub fn retention_until_ms(&self) -> u64 { + self.holds + .as_deref() + .map_or(0, |holds| holds.retention_until_ms) + } + + /// Whether a legal hold is in force. + pub fn legal_hold(&self) -> bool { + 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, } } @@ -238,12 +285,12 @@ impl ObjectProjection { self.content_type, self.metadata_count, self.storage_class, - self.restore_until_ms, + self.restore_until_ms(), self.deleted, self.extent_count, self.tag_count, - self.legal_hold, - self.retention_until_ms + self.legal_hold(), + self.retention_until_ms() ) } } @@ -336,7 +383,13 @@ impl RecordValue { pub fn heap_bytes(&self) -> u64 { match self { RecordValue::Text(text) => text.len() as u64, - RecordValue::Projection(_) => std::mem::size_of::() 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()) + } } } diff --git a/src/tests.rs b/src/tests.rs index d904bc5..d7fbdbc 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -36875,8 +36875,9 @@ fn measure_the_route_to_under_a_hundred_bytes_a_metadata_record() { ); assert_eq!( size_of::(), - 72, - "and the boxed projection should be the 72 published as value_capacity" + 64, + "and the boxed projection should be the 64 published as value_capacity -- the three + holds moved behind a pointer" ); #[allow(dead_code)] @@ -36888,12 +36889,10 @@ fn measure_the_route_to_under_a_hundred_bytes_a_metadata_record() { content_type: SharedText, metadata_count: u32, storage_class: StorageClass, - restore_until_ms: u64, deleted: bool, extent_count: u32, tag_count: u32, - legal_hold: bool, - retention_until_ms: u64, + holds: Option>, } let control = size_of::(); assert_eq!( @@ -36913,12 +36912,10 @@ fn measure_the_route_to_under_a_hundred_bytes_a_metadata_record() { content_type: u16, metadata_count: u32, storage_class: StorageClass, - restore_until_ms: u64, + holds: Option>, deleted: bool, extent_count: u32, tag_count: u32, - legal_hold: bool, - retention_until_ms: u64, } // (b) THE THREE COUNTS ARE NOT 32-BIT QUANTITIES. A user-metadata entry count, a tag @@ -36932,12 +36929,10 @@ fn measure_the_route_to_under_a_hundred_bytes_a_metadata_record() { content_type: SharedText, metadata_count: u16, storage_class: StorageClass, - restore_until_ms: u64, + holds: Option>, deleted: bool, extent_count: u16, tag_count: u16, - legal_hold: bool, - retention_until_ms: u64, } // (c) THE TWO RETENTION STAMPS AND THE HOLD ARE RARE. An object under neither a @@ -36945,12 +36940,6 @@ fn measure_the_route_to_under_a_hundred_bytes_a_metadata_record() { // 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. #[allow(dead_code)] - struct Retention { - restore_until_ms: u64, - retention_until_ms: u64, - legal_hold: bool, - } - #[allow(dead_code)] struct BoxedRetention { version: u64, length: u64, @@ -36962,7 +36951,7 @@ fn measure_the_route_to_under_a_hundred_bytes_a_metadata_record() { deleted: bool, extent_count: u32, tag_count: u32, - retention: Option>, + retention: Option>, } // (d) ALL THREE. @@ -36978,7 +36967,7 @@ fn measure_the_route_to_under_a_hundred_bytes_a_metadata_record() { deleted: bool, extent_count: u16, tag_count: u16, - retention: Option>, + retention: Option>, } // (e) THE CLEAN SUBSET: (b) and (c), without the content type. (a) needs the shared @@ -36998,7 +36987,7 @@ fn measure_the_route_to_under_a_hundred_bytes_a_metadata_record() { deleted: bool, extent_count: u16, tag_count: u16, - retention: Option>, + retention: Option>, } let rows: [(&str, usize); 5] = [ ("a. the content type as an index (BLOCKED: the table is bounded by a count)", @@ -37018,9 +37007,12 @@ fn measure_the_route_to_under_a_hundred_bytes_a_metadata_record() { // 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, 200, "the metaserver share should be the 200 published, and it is {now}"); + 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). @@ -37359,12 +37351,10 @@ fn measure_what_a_one_word_text_handle_would_save() { content_type: SharedText, metadata_count: u32, storage_class: StorageClass, - restore_until_ms: u64, + holds: Option>, deleted: bool, extent_count: u32, tag_count: u32, - legal_hold: bool, - retention_until_ms: u64, } #[allow(dead_code)] struct ProjectionThin { @@ -37375,12 +37365,10 @@ fn measure_what_a_one_word_text_handle_would_save() { content_type: OneWord, metadata_count: u32, storage_class: StorageClass, - restore_until_ms: u64, + holds: Option>, deleted: bool, extent_count: u32, tag_count: u32, - legal_hold: bool, - retention_until_ms: u64, } let size = std::mem::size_of::(); @@ -37544,6 +37532,58 @@ fn measure_whether_a_projection_can_be_parsed_back() { ); } +#[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. + // + // So the constructor must never build an all-zero box, and this asserts that rather + // than trusting the `.then(..)` to keep saying so. + 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" + ); + assert_eq!(plain.restore_until_ms(), 0); + assert_eq!(plain.retention_until_ms(), 0); + assert!(!plain.legal_hold()); + + // 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")); + + // 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. + store + .put_object("bucket", "held", b"x".to_vec(), "text/plain") + .unwrap(); + 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()); + assert_ne!( + plain, held, + "and the two are different values, which the derived equality must still see" + ); +} + #[test] fn the_packed_projection_spells_the_same_text() { // The projection's FORMAT is hashed into three proofs, so a packed form is @@ -45550,10 +45590,12 @@ fn a_storage_class_is_one_byte_and_its_wire_is_still_a_name() { "the byte lands in padding the struct already had -- the storage class took 144 to \ 128 -- and one extent held inline has since taken 128 to 136" ); + // The same absolute-size tripwire as above, and it has now moved twice for reasons + // unrelated to storage classes. The claim stands; the figure names its causes. assert_eq!( std::mem::size_of::(), - 72, - "the same in the projection: 88 became 72" + 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 {