diff --git a/README.md b/README.md index d7e625a..aac53fe 100644 --- a/README.md +++ b/README.md @@ -238,18 +238,18 @@ At three replicas with the metadata counted, measured at 26-character keys: |---:|---:|---:|---:| | 64 KiB | 1,000 | **3.01x** | 3.00x | | 4 KiB | 4,000 | **3.08x** | 3.00x | -| 1 KiB | 8,000 | **3.33x** | 3.02x | +| 1 KiB | 8,000 | **3.32x** | 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 **298 bytes an object plus 164,240 fixed** whatever the object size. +is **290 bytes an object plus 164,232 fixed** whatever the object size. Those bytes are not all in one place. **186 of them are a storage node's own** -- the -object's entry in its map, and its key in the payload index -- and **112 are the metadata +object's entry in its map, and its key in the payload index -- and **104 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 186 bytes -an object rather than 298. +an object rather than 290. Neither map keys its entries by a copy of what the entry already holds. `objects` is ordered by the identity inside each `ObjectMetadata` and `meta.records` by the key inside each record, so @@ -282,9 +282,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 -**1.98x striped against 3.47x replicated**, both read out of `footprint_audit()` on a +**1.97x striped against 3.46x 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 298 bytes an object either +metadata line above, which striping does not change: the same 290 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 @@ -363,8 +363,8 @@ Writing 500 objects of 1 KiB and then deleting every one leaves the store | | bytes | |---|---:| -| holding 500 objects | 1,775,633 | -| after deleting all 500 | 2,244,646 | +| holding 500 objects | 1,771,625 | +| after deleting all 500 | 2,240,638 | 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 2d56feb..48d4b5d 100644 --- a/docs/footprint.md +++ b/docs/footprint.md @@ -22,7 +22,7 @@ Measured at three replicas, 26-character keys, with the metadata counted: |---:|---:|---:|---:| | 64 KiB | 1,000 | **3.01x** | 3.00x | | 4 KiB | 4,000 | **3.08x** | 3.00x | -| 1 KiB | 8,000 | **3.33x** | 3.02x | +| 1 KiB | 8,000 | **3.32x** | 3.02x | Reproduce any row with the measurement harness: @@ -67,7 +67,7 @@ are bounded rather than per object: | objects | 100 | 1,000 | 4,000 | 8,000 | |---:|---:|---:|---:|---:| -| metadata per object | 506 B | 462 B | 339 B | **318 B** | +| metadata per object | 498 B | 454 B | 331 B | **310 B** | 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 @@ -75,7 +75,7 @@ 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 = 298 bytes an object + 164,240 bytes fixed (remainder: zero) +metadata = 290 bytes an object + 164,232 bytes fixed (remainder: zero) ``` reproducing every total above exactly, at every count where the feeds have @@ -103,13 +103,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 298 is two items and a rounding error: +That 290 is two items and a rounding error: | | per object | share | |---|---:|---:| -| `objects` — the structured metadata | 178 B | 59.7% | -| `meta.records` — the metadata digest record | 112 B | 37.6% | -| payload index keys | 8 B | 2.7% | +| `objects` — the structured metadata | 178 B | 61.4% | +| `meta.records` — the metadata digest record | 104 B | 35.9% | +| payload index keys | 8 B | 2.8% | 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 @@ -164,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 298 bytes are, and what each remaining part would cost to remove +### What the 290 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: @@ -175,11 +175,11 @@ objects.mapkey_inline 0 the collection is ordered by the identity the objects.mapkey_heap 42 the key's characters: ONE allocation, shared with the value 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 56 MetadataRecord, and no map key beside it +meta.records.struct_inline 48 MetadataRecord: the deleted flag rides the version meta.records.mapkey_capacity 56 the record key's characters: ONE allocation, ONE holder 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 298 +METADATA LINE TOTAL 290 ``` `the_published_metadata_decomposition_sums_to_the_line` measures each one and fails if this @@ -209,7 +209,7 @@ characters, sixteen bytes once per allocation. #### The reductions that are left, and what each one costs -Each is priced against the 298. Two rows have come **off** this list, and both came off the +Each is priced against the 290. 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 @@ -219,7 +219,7 @@ decision and one representation. | | saves | what it costs | |---|---:|---| -| The metadata records are a second store of the same objects | **112** | 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. | +| The metadata records are a second store of the same objects | **104** | 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 | **32**, or **16** without unsafe code | `SharedText` is `Arc`, a fat pointer, so every holder spends two words to name one allocation. Six holders an object carry it -- two keying `objects`, three inside `ObjectMetadata` and one in a metadata record -- so one word each is **48 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 32**. The shape that needs no unsafe code is `Arc>`, which puts the length in a second allocation instead and nets **16**. 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 16 bytes an object -- 4.8% 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.** diff --git a/src/internal/codec/metadata_object_proofs/shard_integrity_routes/checkpoint_integrity.rs b/src/internal/codec/metadata_object_proofs/shard_integrity_routes/checkpoint_integrity.rs index a9c8eff..14620cd 100644 --- a/src/internal/codec/metadata_object_proofs/shard_integrity_routes/checkpoint_integrity.rs +++ b/src/internal/codec/metadata_object_proofs/shard_integrity_routes/checkpoint_integrity.rs @@ -135,8 +135,8 @@ pub(crate) fn metadata_integrity_plan_checksum(plan: &MetadataIntegrityPlan) -> append_u64(&mut material, plan.compactable_tombstones.len() as u64); for record in &plan.compactable_tombstones { append_string(&mut material, &record.key); - append_u64(&mut material, record.version); - append_u64(&mut material, record.deleted as u64); + append_u64(&mut material, record.version()); + append_u64(&mut material, record.deleted() as u64); } (etag(&material), material.len() as u64) } diff --git a/src/internal/codec/metadata_object_proofs/transaction_proofs.rs b/src/internal/codec/metadata_object_proofs/transaction_proofs.rs index 9592144..36940f7 100644 --- a/src/internal/codec/metadata_object_proofs/transaction_proofs.rs +++ b/src/internal/codec/metadata_object_proofs/transaction_proofs.rs @@ -17,9 +17,9 @@ pub(crate) fn append_metadata_record(out: &mut Vec, record: &MetadataRecord) // 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.version()); append_u64(out, record.update_time_ms); - append_u64(out, record.deleted as u64); + append_u64(out, record.deleted() as u64); } pub(crate) fn append_metadata_transaction(out: &mut Vec, transaction: &MetadataTransaction) { 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 556732f..9c70bb4 100644 --- a/src/internal/codec/snapshot_journal_codec/snapshot_read_metadata.rs +++ b/src/internal/codec/snapshot_journal_codec/snapshot_read_metadata.rs @@ -7,13 +7,17 @@ pub(crate) fn read_metadata_record( 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: 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()?, - }) + // THE ORDER IS THE FORMAT. These three read from the cursor, and a call evaluates its + // arguments left to right exactly as a struct literal evaluated its fields -- so this + // reads the same bytes in the same order. Reordering them would decode a different + // record and still compile. + Ok(MetadataRecord::new( + key.into(), + RecordValue::from_kind_tag(value_kind, &value_text)?, + cursor.read_u64()?, + cursor.read_u64()?, + cursor.read_bool()?, + )) } pub(crate) fn read_metadata_lease( diff --git a/src/protocol/metadata/leases_maintenance/lease_records.rs b/src/protocol/metadata/leases_maintenance/lease_records.rs index 00d4470..6afc4ac 100644 --- a/src/protocol/metadata/leases_maintenance/lease_records.rs +++ b/src/protocol/metadata/leases_maintenance/lease_records.rs @@ -8,9 +8,76 @@ pub struct MetadataRecord { /// A projection of the object at this key, or the text any other record /// carries. Sixteen bytes inline either way -- see `RecordValue`. pub value: RecordValue, - pub version: u64, + /// The version, with the deleted flag in its top bit. + /// + /// # WHY THESE SHARE A WORD + /// + /// The version is a per-key mutation counter and `deleted` is one bit, but alignment gave + /// the bool a whole eight-byte word after two `u64`s -- 8 bytes an object for one bit. + /// The top bit of a counter that cannot reach 2^63 is free space. + /// + /// The WIRE IS UNCHANGED: `append_metadata_record` and the snapshot encoder both write + /// the version and the flag as separate `u64`s, and they still do, through the accessors. + /// A representation change. + /// + /// Read them with [`MetadataRecord::version`] and [`MetadataRecord::deleted`]. + version_and_deleted: u64, pub update_time_ms: u64, - pub deleted: bool, +} + +impl MetadataRecord { + /// The flag's bit. The version is masked off it on the way in and out, so a version that + /// somehow reached this bound would be capped rather than flipping the flag -- which is + /// the failure a shared word has to rule out, not merely make unlikely. + const DELETED_BIT: u64 = 1 << 63; + /// The largest version the shared word can hold. Reaching it would take more writes to + /// one key than a store can perform in any span of time worth modelling, which is why + /// the top bit is free space rather than a range being borrowed. + pub const MAX_VERSION: u64 = Self::DELETED_BIT - 1; + + pub fn new( + key: SharedText, + value: RecordValue, + version: u64, + update_time_ms: u64, + deleted: bool, + ) -> Self { + let mut record = Self { + key, + value, + version_and_deleted: 0, + update_time_ms, + }; + record.set_version(version); + record.set_deleted(deleted); + record + } + + pub fn version(&self) -> u64 { + self.version_and_deleted & Self::MAX_VERSION + } + + pub fn deleted(&self) -> bool { + self.version_and_deleted & Self::DELETED_BIT != 0 + } + + pub fn set_version(&mut self, version: u64) { + debug_assert!( + version <= Self::MAX_VERSION, + "a version past {} cannot share a word with the deleted flag", + Self::MAX_VERSION + ); + self.version_and_deleted = + (self.version_and_deleted & Self::DELETED_BIT) | version.min(Self::MAX_VERSION); + } + + pub fn set_deleted(&mut self, deleted: bool) { + if deleted { + self.version_and_deleted |= Self::DELETED_BIT; + } else { + self.version_and_deleted &= Self::MAX_VERSION; + } + } } /// A metadata record ordered and identified BY ITS OWN KEY, so the collection holding it @@ -102,12 +169,12 @@ impl MetadataRecord { let mut fields = BTreeMap::new(); fields.insert(format!("{field_prefix}key"), self.key.to_string()); fields.insert(format!("{field_prefix}value"), self.value.as_text().into_owned()); - fields.insert(format!("{field_prefix}version"), self.version.to_string().into()); + fields.insert(format!("{field_prefix}version"), self.version().to_string().into()); fields.insert( format!("{field_prefix}update_time_ms"), self.update_time_ms.to_string().into(), ); - fields.insert(format!("{field_prefix}deleted"), self.deleted.to_string().into()); + fields.insert(format!("{field_prefix}deleted"), self.deleted().to_string().into()); fields } } diff --git a/src/protocol/metadata/snapshot_decode.inc.rs b/src/protocol/metadata/snapshot_decode.inc.rs index ab54818..e99fd72 100644 --- a/src/protocol/metadata/snapshot_decode.inc.rs +++ b/src/protocol/metadata/snapshot_decode.inc.rs @@ -112,13 +112,15 @@ impl MetaSnapshot { 0 }; let value_text = cursor.read_string()?; - let record = MetadataRecord { - key: key.clone(), - value: RecordValue::from_kind_tag(value_kind, &value_text)?, - version: cursor.read_u64()?, - update_time_ms: cursor.read_u64()?, - deleted: cursor.read_bool()?, - }; + // THE ORDER IS THE FORMAT -- see `read_metadata_record`. These three read from + // the cursor and must stay in this order. + let record = MetadataRecord::new( + key.clone(), + RecordValue::from_kind_tag(value_kind, &value_text)?, + cursor.read_u64()?, + cursor.read_u64()?, + cursor.read_bool()?, + ); records.insert(key, record); } let mut leases = BTreeMap::new(); diff --git a/src/protocol/metadata/snapshot_encode.inc.rs b/src/protocol/metadata/snapshot_encode.inc.rs index acfa481..af085be 100644 --- a/src/protocol/metadata/snapshot_encode.inc.rs +++ b/src/protocol/metadata/snapshot_encode.inc.rs @@ -36,9 +36,9 @@ impl MetaSnapshot { // 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.version()); append_u64(&mut out, record.update_time_ms); - append_u64(&mut out, record.deleted as u64); + append_u64(&mut out, record.deleted() as u64); } append_u64(&mut out, self.leases.len() as u64); for (namespace, lease) in &self.leases { diff --git a/src/protocol/metadata/snapshot_install_helpers.inc.rs b/src/protocol/metadata/snapshot_install_helpers.inc.rs index 8f7e526..88c5e6f 100644 --- a/src/protocol/metadata/snapshot_install_helpers.inc.rs +++ b/src/protocol/metadata/snapshot_install_helpers.inc.rs @@ -89,7 +89,7 @@ pub(crate) fn metadata_snapshot_install_report( let deleted_record_count = snapshot .records .values() - .filter(|record| record.deleted) + .filter(|record| record.deleted()) .count() as u64; let last_applied_index = snapshot.next_commit_index.saturating_sub(1); MetadataSnapshotInstallReport { diff --git a/src/protocol/metadata/snapshot_validation/checksum.rs b/src/protocol/metadata/snapshot_validation/checksum.rs index 1a25f27..717566c 100644 --- a/src/protocol/metadata/snapshot_validation/checksum.rs +++ b/src/protocol/metadata/snapshot_validation/checksum.rs @@ -27,9 +27,9 @@ impl MetaSnapshot { for (key, record) in &self.records { append_string(&mut material, key); append_string(&mut material, &record.value.as_text()); - append_u64(&mut material, record.version); + append_u64(&mut material, record.version()); append_u64(&mut material, record.update_time_ms); - append_u64(&mut material, record.deleted as u64); + append_u64(&mut material, record.deleted() as u64); } for (namespace, lease) in &self.leases { append_string(&mut material, namespace); diff --git a/src/protocol/metadata/snapshot_validation/validation.rs b/src/protocol/metadata/snapshot_validation/validation.rs index ebd714f..fde6482 100644 --- a/src/protocol/metadata/snapshot_validation/validation.rs +++ b/src/protocol/metadata/snapshot_validation/validation.rs @@ -40,7 +40,7 @@ impl MetaSnapshot { } for (key, record) in &self.records { validate_meta_key(key)?; - if record.key != *key || record.version == 0 { + if record.key != *key || record.version() == 0 { return Err(ObjectError::Corruption( "metadata snapshot record is invalid".to_string(), )); @@ -184,7 +184,7 @@ impl MetaSnapshot { } for record in &receipt.records { validate_meta_key(&record.key)?; - if record.version == 0 { + if record.version() == 0 { return Err(ObjectError::Corruption( "metadata snapshot batch receipt record is invalid".to_string(), )); @@ -247,7 +247,7 @@ impl MetaSnapshot { } for record in &receipt.records { validate_meta_key(&record.key)?; - if record.version == 0 { + if record.version() == 0 { return Err(ObjectError::Corruption( "metadata snapshot transaction receipt record is invalid".to_string(), )); diff --git a/src/services/meta/maintenance_persistence/mutation_preflight.rs b/src/services/meta/maintenance_persistence/mutation_preflight.rs index 44ea6d5..0b97eb3 100644 --- a/src/services/meta/maintenance_persistence/mutation_preflight.rs +++ b/src/services/meta/maintenance_persistence/mutation_preflight.rs @@ -117,7 +117,7 @@ impl MatrixMetaService { self.check_lease_fence(&mutation.key, lease_owner, lease_epoch, now_ms)?; let base_version = self .record(mutation.key.as_str()) - .map_or(0, |record| record.version); + .map_or(0, |record| record.version()); read_versions .entry(mutation.key.to_string()) .or_insert(base_version); @@ -231,7 +231,7 @@ impl MatrixMetaService { self.check_lease_fence(&mutation.key, lease_owner, lease_epoch, now_ms)?; let base_version = self .record(mutation.key.as_str()) - .map_or(0, |record| record.version); + .map_or(0, |record| record.version()); read_versions .entry(mutation.key.to_string()) .or_insert(base_version); @@ -265,7 +265,7 @@ impl MatrixMetaService { self.check_shard_fence(key)?; let current_version = self .record(key.as_str()) - .map_or(0, |record| record.version); + .map_or(0, |record| record.version()); if current_version != *expected_version { return Err(ObjectError::PreconditionFailed( "metadata transaction read set changed".to_string(), @@ -341,7 +341,7 @@ impl MatrixMetaService { ) -> Result { let current_version = self .record(mutation.key.as_str()) - .map_or(0, |record| record.version); + .map_or(0, |record| record.version()); if current_version != mutation.expected_version { return Err(ObjectError::PreconditionFailed( "metadata expected version mismatch".to_string(), @@ -350,23 +350,23 @@ impl MatrixMetaService { // One allocation for the three holders: the map files the record under // it, the record states it again, and the change log names it. let key = self.filed_record_key(mutation.key); - let record = MetadataRecord { - key: key.clone(), + let record = MetadataRecord::new( + key.clone(), // THE CARRY, and the reason the whole conversion was blocked here: a projection // reaches the record only if the mutation could hold one. - value: mutation.record_value, - version: current_version.saturating_add(1), - update_time_ms: now_ms, - deleted: mutation.delete, - }; + mutation.record_value, + current_version.saturating_add(1), + now_ms, + mutation.delete, + ); self.next_commit_index = self.next_commit_index.saturating_add(1); self.file_record(key.clone(), record.clone()); self.append_metadata_change(MetadataChange { sequence: self.next_change_sequence, commit_index: self.next_commit_index.saturating_sub(1), key, - version: record.version, - deleted: record.deleted, + version: record.version(), + deleted: record.deleted(), update_time_ms: now_ms, }); self.next_change_sequence = self.next_change_sequence.saturating_add(1); diff --git a/src/services/meta/maintenance_persistence/prefix_checkpoint/prefix_plan.rs b/src/services/meta/maintenance_persistence/prefix_checkpoint/prefix_plan.rs index c4dba62..e9dcc0c 100644 --- a/src/services/meta/maintenance_persistence/prefix_checkpoint/prefix_plan.rs +++ b/src/services/meta/maintenance_persistence/prefix_checkpoint/prefix_plan.rs @@ -19,7 +19,7 @@ impl MatrixMetaService { self.check_lease_fence(prefix, lease_owner, lease_epoch, now_ms)?; let keys: Vec = self .records_iter() - .filter(|(key, record)| key.starts_with(prefix) && !record.deleted) + .filter(|(key, record)| key.starts_with(prefix) && !record.deleted()) .map(|(key, _)| key.to_string()) .take(max_records) .collect(); @@ -33,7 +33,7 @@ impl MatrixMetaService { record_value: RecordValue::empty(), expected_version: self .record(key.as_str()) - .map_or(0, |record| record.version), + .map_or(0, |record| record.version()), delete: true, }) .collect(); @@ -66,7 +66,7 @@ impl MatrixMetaService { self.check_lease_fence(prefix, lease_owner, lease_epoch, now_ms)?; let keys: Vec = self .records_iter() - .filter(|(key, record)| key.starts_with(prefix) && !record.deleted) + .filter(|(key, record)| key.starts_with(prefix) && !record.deleted()) .map(|(key, _)| key.to_string()) .take(max_records) .collect(); @@ -77,7 +77,7 @@ impl MatrixMetaService { record_value: RecordValue::empty(), expected_version: self .record(key.as_str()) - .map_or(0, |record| record.version), + .map_or(0, |record| record.version()), delete: true, }) .collect(); @@ -102,12 +102,14 @@ impl MatrixMetaService { let prepared_commit_index = expected_previous_commit_index.saturating_add(1); let images: Vec = mutations .iter() - .map(|mutation| MetadataRecord { - key: mutation.key.clone(), - value: String::new().into(), - version: mutation.expected_version.saturating_add(1), - update_time_ms: now_ms, - deleted: true, + .map(|mutation| { + MetadataRecord::new( + mutation.key.clone(), + String::new().into(), + mutation.expected_version.saturating_add(1), + now_ms, + true, + ) }) .collect(); let (metadata_control_proof_checksum, metadata_control_proof_bytes) = @@ -168,7 +170,7 @@ impl MatrixMetaService { let mut mutations = Vec::new(); for (key, record) in self .records_iter() - .filter(|(key, record)| key.starts_with(prefix) && !record.deleted) + .filter(|(key, record)| key.starts_with(prefix) && !record.deleted()) { if mutations.len() >= max_records { break; @@ -191,7 +193,7 @@ impl MatrixMetaService { mutations.push(MetadataMutation { key: key.clone(), record_value: RecordValue::empty(), - expected_version: record.version, + expected_version: record.version(), delete: true, }); } @@ -216,12 +218,14 @@ impl MatrixMetaService { let prepared_commit_index = expected_previous_commit_index.saturating_add(1); let images: Vec = mutations .iter() - .map(|mutation| MetadataRecord { - key: mutation.key.clone(), - value: String::new().into(), - version: mutation.expected_version.saturating_add(1), - update_time_ms: now_ms, - deleted: true, + .map(|mutation| { + MetadataRecord::new( + mutation.key.clone(), + String::new().into(), + mutation.expected_version.saturating_add(1), + now_ms, + true, + ) }) .collect(); let (metadata_control_proof_checksum, metadata_control_proof_bytes) = diff --git a/src/services/meta/maintenance_persistence/snapshot_lifecycle/export_fingerprint.rs b/src/services/meta/maintenance_persistence/snapshot_lifecycle/export_fingerprint.rs index 4afaa25..de6bf92 100644 --- a/src/services/meta/maintenance_persistence/snapshot_lifecycle/export_fingerprint.rs +++ b/src/services/meta/maintenance_persistence/snapshot_lifecycle/export_fingerprint.rs @@ -88,9 +88,9 @@ impl MatrixMetaService { for (key, record) in self.records_iter() { append_string(&mut material, key); append_string(&mut material, &etag(record.value.as_text().as_bytes())); - append_u64(&mut material, record.version); + append_u64(&mut material, record.version()); append_u64(&mut material, record.update_time_ms); - append_u64(&mut material, record.deleted as u64); + append_u64(&mut material, record.deleted() as u64); } append_u64(&mut material, self.leases.len() as u64); diff --git a/src/services/meta/maintenance_persistence/snapshot_tombstone_retention/tombstone_collection.rs b/src/services/meta/maintenance_persistence/snapshot_tombstone_retention/tombstone_collection.rs index 83624b7..eebde7f 100644 --- a/src/services/meta/maintenance_persistence/snapshot_tombstone_retention/tombstone_collection.rs +++ b/src/services/meta/maintenance_persistence/snapshot_tombstone_retention/tombstone_collection.rs @@ -55,7 +55,7 @@ impl MatrixMetaService { report.next_key_marker = last_selected_key; break; } - if !record.deleted { + if !record.deleted() { report.skipped_records = report.skipped_records.saturating_add(1); continue; } @@ -92,7 +92,7 @@ impl MatrixMetaService { } self.changes.iter().any(|change| { change.key == record.key - && change.version == record.version + && change.version == record.version() && change.deleted && change.sequence <= safe_sequence }) diff --git a/src/services/meta/records_leases_shards/construction_receipts/handoff_apply.rs b/src/services/meta/records_leases_shards/construction_receipts/handoff_apply.rs index 3e445c2..b8e9e00 100644 --- a/src/services/meta/records_leases_shards/construction_receipts/handoff_apply.rs +++ b/src/services/meta/records_leases_shards/construction_receipts/handoff_apply.rs @@ -104,23 +104,23 @@ impl MatrixMetaService { "metadata prepared shard handoff contains duplicate record key".to_string(), )); } - if record.version == 0 || record.update_time_ms == 0 { + if record.version() == 0 || record.update_time_ms == 0 { return Err(ObjectError::InvalidArgument( "metadata prepared shard handoff record image is incomplete".to_string(), )); } - if record.deleted && !record.value.is_empty() { + if record.deleted() && !record.value.is_empty() { return Err(ObjectError::InvalidArgument( "metadata prepared shard handoff deleted record carries value".to_string(), )); } if let Some(existing) = self.record(record.key.as_str()) { - if existing.version > record.version { + if existing.version() > record.version() { return Err(ObjectError::PreconditionFailed( "metadata prepared shard handoff would overwrite newer record".to_string(), )); } - if existing.version == record.version && existing != record { + if existing.version() == record.version() && existing != record { return Err(ObjectError::PreconditionFailed( "metadata prepared shard handoff record version image mismatch".to_string(), )); @@ -143,8 +143,8 @@ impl MatrixMetaService { sequence: self.next_change_sequence, commit_index: prepared.prepared_commit_index, key: record.key.clone(), - version: record.version, - deleted: record.deleted, + version: record.version(), + deleted: record.deleted(), update_time_ms: record.update_time_ms, }); self.next_change_sequence = self.next_change_sequence.saturating_add(1); diff --git a/src/services/meta/records_leases_shards/construction_receipts/handoff_prepare.rs b/src/services/meta/records_leases_shards/construction_receipts/handoff_prepare.rs index f82c649..8711c8d 100644 --- a/src/services/meta/records_leases_shards/construction_receipts/handoff_prepare.rs +++ b/src/services/meta/records_leases_shards/construction_receipts/handoff_prepare.rs @@ -65,7 +65,7 @@ impl MatrixMetaService { continue; } scanned_records = scanned_records.saturating_add(1); - if record.deleted && !include_deleted { + if record.deleted() && !include_deleted { continue; } if records.len() >= limit { diff --git a/src/services/meta/records_leases_shards/readiness.inc.rs b/src/services/meta/records_leases_shards/readiness.inc.rs index 80c3bfe..9d6b486 100644 --- a/src/services/meta/records_leases_shards/readiness.inc.rs +++ b/src/services/meta/records_leases_shards/readiness.inc.rs @@ -161,7 +161,7 @@ impl MatrixMetaService { metadata_records: self.records.len() as u64, deleted_metadata_records: self .records_values() - .filter(|record| record.deleted) + .filter(|record| record.deleted()) .count() as u64, retained_change_records: self.changes.len() as u64, first_retained_change_sequence, diff --git a/src/services/meta/retention_transactions_routes/shard_routes.inc.rs b/src/services/meta/retention_transactions_routes/shard_routes.inc.rs index 2fa7b9a..b80d246 100644 --- a/src/services/meta/retention_transactions_routes/shard_routes.inc.rs +++ b/src/services/meta/retention_transactions_routes/shard_routes.inc.rs @@ -80,7 +80,7 @@ impl MatrixMetaService { } let payload_bytes = metadata_record_payload_bytes(record); stats.total_payload_bytes = stats.total_payload_bytes.saturating_add(payload_bytes); - if record.deleted { + if record.deleted() { stats.deleted_records = stats.deleted_records.saturating_add(1); stats.deleted_value_bytes = stats .deleted_value_bytes diff --git a/src/services/meta/sessions_changefeed_snapshot/record_batches/apply_batch.rs b/src/services/meta/sessions_changefeed_snapshot/record_batches/apply_batch.rs index 156defb..a72df7f 100644 --- a/src/services/meta/sessions_changefeed_snapshot/record_batches/apply_batch.rs +++ b/src/services/meta/sessions_changefeed_snapshot/record_batches/apply_batch.rs @@ -87,10 +87,10 @@ impl MatrixMetaService { } let current_version = self .record(mutation.key.as_str()) - .map_or(0, |record| record.version); + .map_or(0, |record| record.version()); if image.key != mutation.key - || image.deleted != mutation.delete - || image.version != current_version.saturating_add(1) + || image.deleted() != mutation.delete + || image.version() != current_version.saturating_add(1) || (mutation.delete && !image.value.is_empty()) || (!mutation.delete && image.value != mutation.record_value) || image.update_time_ms != prepared.now_ms @@ -106,8 +106,8 @@ impl MatrixMetaService { sequence: self.next_change_sequence, commit_index: prepared.prepared_commit_index, key: image.key.clone(), - version: image.version, - deleted: image.deleted, + version: image.version(), + deleted: image.deleted(), update_time_ms: image.update_time_ms, }); self.next_change_sequence = self.next_change_sequence.saturating_add(1); diff --git a/src/services/meta/sessions_changefeed_snapshot/record_batches/prepare_batch.rs b/src/services/meta/sessions_changefeed_snapshot/record_batches/prepare_batch.rs index ee9cdaf..553e210 100644 --- a/src/services/meta/sessions_changefeed_snapshot/record_batches/prepare_batch.rs +++ b/src/services/meta/sessions_changefeed_snapshot/record_batches/prepare_batch.rs @@ -67,23 +67,23 @@ impl MatrixMetaService { for mutation in &mutations { let base_version = self .record(mutation.key.as_str()) - .map_or(0, |record| record.version); + .map_or(0, |record| record.version()); let current_version = projected_versions .get(&mutation.key) .copied() .unwrap_or(base_version); projected_versions.insert(mutation.key.clone(), current_version.saturating_add(1)); - images.push(MetadataRecord { - key: mutation.key.clone(), - value: if mutation.delete { + images.push(MetadataRecord::new( + mutation.key.clone(), + if mutation.delete { String::new().into() } else { mutation.record_value.clone() }, - version: current_version.saturating_add(1), - update_time_ms: now_ms, - deleted: mutation.delete, - }); + current_version.saturating_add(1), + now_ms, + mutation.delete, + )); } let (metadata_control_proof_checksum, metadata_control_proof_bytes) = self.metadata_control_proof_digest_at(now_ms); diff --git a/src/services/meta/sessions_changefeed_snapshot/record_batches/records_read_set.rs b/src/services/meta/sessions_changefeed_snapshot/record_batches/records_read_set.rs index b3f623a..a296a13 100644 --- a/src/services/meta/sessions_changefeed_snapshot/record_batches/records_read_set.rs +++ b/src/services/meta/sessions_changefeed_snapshot/record_batches/records_read_set.rs @@ -48,7 +48,7 @@ impl MatrixMetaService { pub fn get_record(&self, key: &str) -> Result { self.record(key) - .filter(|record| !record.deleted) + .filter(|record| !record.deleted()) .cloned() .ok_or_else(|| ObjectError::NotFound(format!("metadata record {}", key))) } @@ -81,7 +81,7 @@ impl MatrixMetaService { if after_key.map_or(false, |marker| key.as_str() <= marker) { continue; } - if record.deleted { + if record.deleted() { continue; } page.scanned_records = page.scanned_records.saturating_add(1); @@ -131,14 +131,14 @@ impl MatrixMetaService { self.check_shard_fence(&condition.key)?; report.checked_records = report.checked_records.saturating_add(1); let current = self.record(condition.key.as_str()); - let current_version = current.map_or(0, |record| record.version); + let current_version = current.map_or(0, |record| record.version()); if current_version != condition.expected_version { return Err(ObjectError::PreconditionFailed( "metadata read set version changed".to_string(), )); } match current { - Some(record) if record.deleted => { + Some(record) if record.deleted() => { report.deleted_records = report.deleted_records.saturating_add(1); if condition.require_present { return Err(ObjectError::PreconditionFailed( diff --git a/src/services/meta/sessions_changefeed_snapshot/transaction_raft/apply.rs b/src/services/meta/sessions_changefeed_snapshot/transaction_raft/apply.rs index 68bf420..99b2b08 100644 --- a/src/services/meta/sessions_changefeed_snapshot/transaction_raft/apply.rs +++ b/src/services/meta/sessions_changefeed_snapshot/transaction_raft/apply.rs @@ -138,8 +138,8 @@ impl MatrixMetaService { for record in &prepared.receipt.records { let current_version = self .record(record.key.as_str()) - .map_or(0, |existing| existing.version); - if record.version != current_version.saturating_add(1) + .map_or(0, |existing| existing.version()); + if record.version() != current_version.saturating_add(1) || record.update_time_ms != prepared.receipt.update_time_ms { return Err(ObjectError::Corruption( @@ -153,8 +153,8 @@ impl MatrixMetaService { sequence: self.next_change_sequence, commit_index: prepared.prepared_commit_index, key: record.key.clone(), - version: record.version, - deleted: record.deleted, + version: record.version(), + deleted: record.deleted(), update_time_ms: record.update_time_ms, }); self.next_change_sequence = self.next_change_sequence.saturating_add(1); diff --git a/src/store/core/construction_control/bucket_create.rs b/src/store/core/construction_control/bucket_create.rs index 3f01493..4c46d9b 100644 --- a/src/store/core/construction_control/bucket_create.rs +++ b/src/store/core/construction_control/bucket_create.rs @@ -67,7 +67,7 @@ impl MatrixObjectStore { let expected_version = self .meta_service .record(key.as_str()) - .map_or(0, |record| record.version); + .map_or(0, |record| record.version()); self.meta_service.commit_record( &key, "created".to_string(), diff --git a/src/store/core/construction_control/bucket_delete.rs b/src/store/core/construction_control/bucket_delete.rs index 1f2d4b3..e21de02 100644 --- a/src/store/core/construction_control/bucket_delete.rs +++ b/src/store/core/construction_control/bucket_delete.rs @@ -88,7 +88,7 @@ impl MatrixObjectStore { let expected_version = self .meta_service .record(key.as_str()) - .map_or(0, |record| record.version); + .map_or(0, |record| record.version()); match self .meta_service .delete_record(&key, expected_version, None, None, update_time_ms) diff --git a/src/store/core/raft_apply_persistence/delete_multipart_projection/projection_changes.rs b/src/store/core/raft_apply_persistence/delete_multipart_projection/projection_changes.rs index a02eeef..294a060 100644 --- a/src/store/core/raft_apply_persistence/delete_multipart_projection/projection_changes.rs +++ b/src/store/core/raft_apply_persistence/delete_multipart_projection/projection_changes.rs @@ -13,7 +13,7 @@ impl MatrixObjectStore { let expected_version = self .meta_service .record(key.as_str()) - .map_or(0, |record| record.version); + .map_or(0, |record| record.version()); self.meta_service.commit_record( &key, value, diff --git a/src/store/core/readiness_catalog/metadata_maintenance.rs b/src/store/core/readiness_catalog/metadata_maintenance.rs index 83d7487..e0a8bfe 100644 --- a/src/store/core/readiness_catalog/metadata_maintenance.rs +++ b/src/store/core/readiness_catalog/metadata_maintenance.rs @@ -159,12 +159,12 @@ impl MatrixObjectStore { let live_record_count = snapshot .records .values() - .filter(|record| !record.deleted) + .filter(|record| !record.deleted()) .count() as u64; let deleted_record_count = snapshot .records .values() - .filter(|record| record.deleted) + .filter(|record| record.deleted()) .count() as u64; let tombstone_report = self.plan_metadata_tombstone_compaction(tombstone_prefix, None, sample_limit, 0)?; @@ -172,7 +172,7 @@ impl MatrixObjectStore { let compactable_tombstones = snapshot .records .values() - .filter(|record| record.deleted && record.key.starts_with(tombstone_prefix)) + .filter(|record| record.deleted() && record.key.starts_with(tombstone_prefix)) .take(sample_limit) .cloned() .collect::>(); diff --git a/src/store/core/readiness_catalog/metadata_records_transactions_snapshot/batch_preparation_and_apply.rs b/src/store/core/readiness_catalog/metadata_records_transactions_snapshot/batch_preparation_and_apply.rs index 300c75c..682109d 100644 --- a/src/store/core/readiness_catalog/metadata_records_transactions_snapshot/batch_preparation_and_apply.rs +++ b/src/store/core/readiness_catalog/metadata_records_transactions_snapshot/batch_preparation_and_apply.rs @@ -145,10 +145,10 @@ impl MatrixObjectStore { if batch.images.len() != 2 || batch.images[0].key != current_key || batch.images[0].value != current_value.into() - || batch.images[0].deleted + || batch.images[0].deleted() || batch.images[1].key != object_record_key || batch.images[1].value.as_text() != object_mutation_value.as_str() - || batch.images[1].deleted != delete_object + || batch.images[1].deleted() != delete_object { return Err(ObjectError::Corruption( "prepared object metadata images do not match request".to_string(), diff --git a/src/store/core/storage_service/listing_metadata_audit.inc.rs b/src/store/core/storage_service/listing_metadata_audit.inc.rs index be48528..ca5e9ce 100644 --- a/src/store/core/storage_service/listing_metadata_audit.inc.rs +++ b/src/store/core/storage_service/listing_metadata_audit.inc.rs @@ -132,7 +132,7 @@ impl MatrixObjectStore { audited_ids.insert(object_id.clone()); let metadata = self.object(&object_id).cloned(); let mut issues = Vec::new(); - if projection.deleted { + if projection.deleted() { audit.deleted_projection_count = audit.deleted_projection_count.saturating_add(1); issues.push("deleted_projection"); } else if projection.value.is_empty() { @@ -142,7 +142,7 @@ impl MatrixObjectStore { match metadata.as_ref() { Some(current) if !current.deleted => { let expected = metadata_projection_value(current); - if !projection.deleted && projection.value != expected.into() { + if !projection.deleted() && projection.value != expected.into() { audit.stale_projection_count = audit.stale_projection_count.saturating_add(1); issues.push("projection_payload_mismatch"); @@ -189,7 +189,7 @@ impl MatrixObjectStore { .meta_service .record(projection_key.as_str()) .cloned(); - if projection.as_ref().map_or(true, |record| record.deleted) { + if projection.as_ref().map_or(true, |record| record.deleted()) { audit.missing_projection_count = audit.missing_projection_count.saturating_add(1); emit_issue( &mut audit, diff --git a/src/tests.rs b/src/tests.rs index 96f0c4c..7b43c81 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1166,7 +1166,7 @@ fn persistent_journal_replays_metadata_transactions() { assert_eq!(expired.update_time_ms, 20); let record = snapshot.records.get("object/txn/commit").unwrap(); assert_eq!(record.value, "committed".into()); - assert_eq!(record.version, 1); + assert_eq!(record.version(), 1); assert!(!snapshot.records.contains_key("object/txn/abort")); assert!(!snapshot.records.contains_key("object/txn/expire")); let _ = std::fs::remove_dir_all(root); @@ -13621,7 +13621,7 @@ fn raft_backed_store_replays_metaserver_shards_and_leases() { follower .metadata_record(&meta_object_key(&ObjectId::new("bucket", "object"))) .unwrap() - .version, + .version(), 1 ); let snapshot = follower.export_snapshot().unwrap(); @@ -14795,7 +14795,7 @@ fn raft_backed_store_replays_metaserver_read_set_checks() { let mut follower = MatrixObjectStore::new(test_options()).unwrap(); replicated.replay_into(&mut follower).unwrap(); - assert_eq!(follower.metadata_record(&meta_key).unwrap().version, 2); + assert_eq!(follower.metadata_record(&meta_key).unwrap().version(), 2); assert_eq!(follower.last_applied_index(), 3); } @@ -14955,7 +14955,7 @@ fn raft_backed_store_replays_metaserver_qos_config() { replicated.replay_into(&mut follower).unwrap(); assert_eq!(follower.metadata_qos_config(), config); assert_eq!(follower.metadata_qos_state().write_ops_in_window, 1); - assert_eq!(follower.metadata_record("object/qos/a").unwrap().version, 1); + assert_eq!(follower.metadata_record("object/qos/a").unwrap().version(), 1); let snapshot = replicated.store().export_snapshot().unwrap(); let mut restored = MatrixObjectStore::new(test_options()).unwrap(); @@ -15112,7 +15112,7 @@ fn raft_backed_store_replays_owner_fenced_metaserver_writes() { let mut follower = MatrixObjectStore::new(test_options()).unwrap(); replicated.replay_into(&mut follower).unwrap(); let record = follower.metadata_record("object/ownerfence/item").unwrap(); - assert_eq!(record.version, 1); + assert_eq!(record.version(), 1); assert_eq!(record.value, "etag=owner-fenced length=1".into()); assert_eq!(follower.last_applied_index(), 3); } @@ -15720,7 +15720,7 @@ fn raft_backed_store_replays_idempotent_generic_metaserver_batches() { follower .metadata_record("object/raft-meta-generic/a") .unwrap() - .version, + .version(), 1 ); assert_eq!(follower.last_applied_index(), 2); @@ -15733,7 +15733,7 @@ fn raft_backed_store_replays_idempotent_generic_metaserver_batches() { .unwrap(); assert!(restored_retry.deduped); assert_eq!(records.len(), 2); - assert_eq!(records[0].version, 1); + assert_eq!(records[0].version(), 1); } #[test] @@ -15759,7 +15759,7 @@ fn raft_backed_store_applies_prepared_metaserver_batches() { assert_eq!(prepared.expected_previous_commit_index, 0); assert_eq!(prepared.prepared_commit_index, 1); assert_eq!(prepared.images.len(), 1); - assert_eq!(prepared.images[0].version, 1); + assert_eq!(prepared.images[0].version(), 1); assert_eq!(prepared.images[0].value, "etag=prepared length=1".into()); assert!(!prepared.request_checksum.is_empty()); assert!(!prepared.metadata_control_proof_checksum.is_empty()); @@ -15816,7 +15816,7 @@ fn raft_backed_store_applies_prepared_metaserver_batches() { replicated.replay_into(&mut follower).unwrap(); assert_eq!(follower.last_applied_index(), 1); let record = follower.metadata_record("object/prepared-meta/a").unwrap(); - assert_eq!(record.version, 1); + assert_eq!(record.version(), 1); assert_eq!(record.value, "etag=prepared length=1".into()); assert_eq!( follower @@ -16303,7 +16303,7 @@ fn raft_backed_store_applies_prepared_metaserver_transactions() { follower .metadata_record("object/prepared-txn/a") .unwrap() - .version, + .version(), 1 ); assert_eq!( @@ -17641,7 +17641,7 @@ fn raft_backed_store_replays_idempotent_owner_fenced_metaserver_writes() { let mut follower = MatrixObjectStore::new(test_options()).unwrap(); replicated.replay_into(&mut follower).unwrap(); let record = follower.metadata_record("object/owner-idem/item").unwrap(); - assert_eq!(record.version, 1); + assert_eq!(record.version(), 1); assert_eq!(record.value, "etag=owner-idem length=1".into()); let snapshot = follower.export_snapshot().unwrap(); assert_eq!(snapshot.meta_snapshot.batch_request_receipts.len(), 1); @@ -17659,7 +17659,7 @@ fn raft_backed_store_replays_idempotent_owner_fenced_metaserver_writes() { ) .unwrap(); assert!(restored_retry.deduped); - assert_eq!(records[0].version, 1); + assert_eq!(records[0].version(), 1); } #[test] @@ -30608,7 +30608,7 @@ fn metaserver_shard_fence_and_snapshot_round_trip() { restored .metadata_record(&meta_object_key(&metadata.object_id)) .unwrap() - .version, + .version(), 1 ); assert_eq!( @@ -32363,7 +32363,7 @@ fn metaserver_owner_fenced_writes_require_live_shard_owner() { 20, ) .unwrap(); - assert_eq!(records[0].version, 1); + assert_eq!(records[0].version(), 1); assert!(matches!( store.commit_owner_fenced_metadata_batch( vec![MetadataMutation { @@ -32457,7 +32457,7 @@ fn metaserver_dedupes_idempotent_metadata_batches_across_snapshots() { .unwrap(); assert!(!first.deduped); assert_eq!(records.len(), 2); - assert_eq!(records[0].version, 1); + assert_eq!(records[0].version(), 1); let (retry_records, retry) = store .commit_metadata_batch_idempotent(mutations.clone(), None, None, 20, "meta-batch-write-a") .unwrap(); @@ -32510,7 +32510,7 @@ fn metaserver_dedupes_idempotent_metadata_batches_across_snapshots() { ) .unwrap(); assert!(!owner_first.deduped); - assert_eq!(owner_records[0].version, 1); + assert_eq!(owner_records[0].version(), 1); let (_, owner_retry) = store .commit_owner_fenced_metadata_batch_idempotent( owner_mutations.clone(), @@ -32681,7 +32681,7 @@ fn metaserver_batches_transactions_and_changefeed_are_snapshot_safe() { .commit_metadata_batch(batch, None, None, now) .expect("batch commit"); assert_eq!(records.len(), 2); - assert_eq!(records[0].version, 1); + assert_eq!(records[0].version(), 1); let rejected = store.commit_metadata_batch( vec![ @@ -32725,7 +32725,7 @@ fn metaserver_batches_transactions_and_changefeed_are_snapshot_safe() { let committed = store .commit_metadata_transaction("txn-1", "writer-a", now.saturating_add(3)) .unwrap(); - assert_eq!(committed[0].version, 2); + assert_eq!(committed[0].version(), 2); let conflict_txn = store .begin_metadata_transaction( @@ -33740,7 +33740,7 @@ fn metaserver_prefix_delete_and_change_checkpoints_are_durable() { .delete_metadata_by_prefix("object/gc/", 1, None, None, now.saturating_add(2)) .unwrap(); assert_eq!(first_page.len(), 1); - assert!(first_page[0].deleted); + assert!(first_page[0].deleted()); assert_eq!( store.metadata_record("object/keep/c").unwrap().value, "live-c".into() @@ -35608,7 +35608,7 @@ fn filing_a_record_over_an_existing_key_replaces_it() { "a second write at one key must not add a second record" ); assert_ne!( - before.version, after.version, + before.version(), after.version(), "the filed record must be the NEW one -- `insert` on a set keeps the old element and \ reports success, so a write would silently not take effect" ); @@ -35786,13 +35786,13 @@ fn two_records_spelling_the_same_thing_in_different_shapes_hash_differently() { 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, - }, + &MetadataRecord::new( + "object/bucket/object".into(), + value.clone(), + 7, + 11, + false, + ), ); out }; @@ -37117,170 +37117,327 @@ fn measure_the_metadata_per_object_at_the_published_counts() { whose own guard checks it against this tree" ); } + #[test] #[ignore = "a measurement, not an assertion: cargo test -- --ignored --nocapture"] -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: +fn measure_the_route_to_under_a_hundred_bytes_a_storage_node() { + // The metadata line splits into a storage node's own bytes and the metadata service's. The + // service's share is a name, two stamps and a digest, because the OBJECT is the authority + // on what the characters say and the service only has to prove it agrees. // - // 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 + // A storage node has no such move available. It IS the authority: it cannot hold a digest + // of its own metadata and ask someone else for the rest. So this prices what is left, + // field by field, and names what the floor is made of. + // + // 42 objects.mapkey_heap -- the key's characters, one allocation plus its header + // 136 objects.metadata_inline -- ObjectMetadata + // 0 objects.extents_bytes -- the one extent is inline above + // 8 payloads.key_bytes // --- - // 128 + // 186 after the collection stopped keying by a copy of the identity // - // 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. + // Every candidate is a mirror sized by the compiler, and every control is asserted equal + // to the real type FIRST. use std::mem::size_of; - assert_eq!(size_of::(), 16, "a shared handle should be a fat pointer"); - assert_eq!( - 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::(), 16); + assert_eq!(size_of::(), 32, "two shared handles name an object"); assert_eq!( - size_of::(), - 56, - "the record struct should be 56: two handles, two stamps and a bool in its own word" + size_of::(), + 136, + "the struct this prices should be the 136 the document publishes" ); #[allow(dead_code)] - struct RecordControl { + struct IdControl { + bucket: SharedText, key: SharedText, - value: RecordValue, + } + #[allow(dead_code)] + struct MetadataControl { + object_id: IdControl, version: u64, + length: u64, + etag_digest_bits: u64, + has_etag: bool, + content_type: SharedText, + user_metadata: ObjectAttributes, + storage_class: StorageClass, + tags: ObjectAttributes, + lock: ObjectLock, update_time_ms: u64, + extents: ObjectExtents, deleted: bool, } assert_eq!( - size_of::(), - size_of::(), - "the record control has drifted, so nothing below means anything" + size_of::(), + size_of::(), + "the id mirror has drifted, so nothing below means anything" + ); + assert_eq!( + size_of::(), + size_of::(), + "the metadata mirror has drifted, so nothing below means anything" ); - // (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. + // WHAT IS ALREADY DONE, asserted rather than re-proposed. Two candidates I priced before + // reading the struct were wrong in opposite directions, which is why they are here as + // assertions instead of as mirrors: + // + // - an extent's offset and length are ALREADY 32-bit, bounded by `chunk_size`; + // - the etag is ALREADY split into a `u64` and a flag, because `Option` has no + // niche and would COST eight bytes, and because a digest of zero is a real value + // rather than an absent one, so `NonZeroU64` would be wrong as well as larger. + assert_eq!( + size_of::(), + 16, + "an extent is an identity and two 32-bit numbers; if this is 24 the narrowing was \ + undone and is a candidate again" + ); #[allow(dead_code)] - struct FoldedFlag { - key: SharedText, - value: RecordValue, - version_and_deleted: u64, + struct EtagAsOption { + object_id: IdControl, + version: u64, + length: u64, + etag: Option, + content_type: SharedText, + user_metadata: ObjectAttributes, + storage_class: StorageClass, + tags: ObjectAttributes, + lock: ObjectLock, update_time_ms: u64, + extents: ObjectExtents, + deleted: bool, } + assert!( + size_of::() > size_of::(), + "an `Option` etag should be BIGGER than the split pair, which is why the pair \ + exists: {} against {}", + size_of::(), + size_of::() + ); - // (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. + // (a) THE THREE RARE SIDE TABLES BEHIND ONE POINTER. `user_metadata`, `tags` and `lock` + // are each a pointer that is null for most objects. An object with none of the three pays + // twenty-four bytes to say so; behind one pointer it pays eight. #[allow(dead_code)] - enum TextOrDigest { - Text(Box), - Digest(u64), + struct RareSides { + user_metadata: ObjectAttributes, + tags: ObjectAttributes, + lock: ObjectLock, + } + #[allow(dead_code)] + struct ExtrasFolded { + object_id: IdControl, + version: u64, + length: u64, + etag_digest_bits: u64, + has_etag: bool, + content_type: SharedText, + storage_class: StorageClass, + extras: Option>, + update_time_ms: u64, + extents: ObjectExtents, + deleted: bool, } - // (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. + // (b) AND THE CONTENT TYPE AS AN INDEX, priced so the blocker is a number rather than a + // memory. The shared table is bounded by a COUNT, and an object past the bound owns its + // own string -- which a `u16` cannot represent, and an enum over the two cases is sixteen + // bytes again. Measured, then declined. #[allow(dead_code)] - struct TaggedValue { - tag_or_pointer: u64, - length_or_digest: u64, + struct ContentTypeIndexed { + object_id: IdControl, + version: u64, + length: u64, + etag_digest_bits: u64, + has_etag: bool, + content_type: u16, + storage_class: StorageClass, + extras: Option>, + update_time_ms: u64, + extents: ObjectExtents, + deleted: bool, } - // (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. + const KEY_CHARS: usize = 26; + const SHARED_HEADER: usize = 16; + const PAYLOAD_INDEX_KEY: usize = 8; + let name = KEY_CHARS + SHARED_HEADER; + + println!( + "PAYLOAD control metadata={} id={} extent={} extents={} rare_sides={}", + size_of::(), + size_of::(), + size_of::(), + size_of::(), + size_of::(), + ); + + let now = name + size_of::() + PAYLOAD_INDEX_KEY; + println!("PAYLOAD rung now={now}"); + let folded = name + size_of::() + PAYLOAD_INDEX_KEY; + println!( + "PAYLOAD rung the_three_rare_sides_behind_one_pointer={folded} saves={}", + now as i64 - folded as i64 + ); + let indexed = name + size_of::() + PAYLOAD_INDEX_KEY; + println!( + "PAYLOAD rung and_the_content_type_as_an_index={indexed} saves={} \ + (DECLINED: the table is bounded by a count)", + folded as i64 - indexed as i64 + ); + + // AND THE FLOOR, named rather than implied. What is left cannot be a digest, cannot be + // interned and cannot be dropped: + // + // - the object's NAME, as characters, because the key order IS the listing contract and + // an integer id would need a second name-ordered index that costs more than it saves; + // - a handle to those characters, which cannot be owned rather than shared: the bounded + // change feed names them too, and `an_object_key_costs_two_bytes_a_character_however_ + // many_places_name_it` measures copies as the SLOPE against key length, so an owned + // copy costs one byte a character for every holder; + // - where the bytes are, and what they are. + println!( + "PAYLOAD floor name_chars={KEY_CHARS} handle={} two_stamps_and_a_length={} \ + etag={} where_the_bytes_are={} payload_index_key={PAYLOAD_INDEX_KEY}", + 2 * size_of::(), + 3 * size_of::(), + size_of::(), + size_of::(), + ); + println!( + "PAYLOAD UNDER A HUNDRED NEEDS metadata_inline<={}, and the smallest COMPLETE struct \ + measured above is {} -- so it is not reachable by field width. It needs a storage \ + node that does not hold complete metadata resident, which is a paged index rather \ + than a packed struct.", + 100usize.saturating_sub(name + PAYLOAD_INDEX_KEY), + size_of::(), + ); +} +#[test] +#[ignore = "a measurement, not an assertion: cargo test -- --ignored --nocapture"] +fn measure_the_route_to_eighty_bytes_a_metadata_record() { + // The metadata service holds 104 bytes an object: + // + // 48 the record: a 16-byte key handle, a 16-byte value, and two stamps in one word + // each -- the deleted flag now rides the version's top bit + // 56 the key's characters: 40 of them behind a 16-byte allocation header + // --- + // 104 + // + // Under a hundred is 5 bytes away, so this prices every candidate by construction rather + // than by argument. Mirrors sized by the compiler; controls asserted equal to the real + // types FIRST, because arithmetic has lost to the compiler four times in this file. + use std::mem::size_of; + + assert_eq!(size_of::(), 16, "a shared handle should be a fat pointer"); + assert_eq!(size_of::(), 16, "a boxed string or a digest, in a niche"); + assert_eq!( + size_of::(), + 48, + "the record should be 48: a key handle, a value, and two stamps sharing one word with \ + the deleted flag" + ); + #[allow(dead_code)] - struct KeylessRecord { + struct RecordControl { + key: SharedText, value: RecordValue, version_and_deleted: u64, update_time_ms: u64, } + assert_eq!( + size_of::(), + size_of::(), + "the record control has drifted, so nothing below means anything" + ); + + const KEY_CHARS: usize = 40; + const SHARED_HEADER: usize = 16; + const KIND_PREFIX: usize = 7; // "object/" - // (e) ALL OF IT: no key, the flag folded, the value a tagged pair. + // (a) AN EIGHT-BYTE VALUE SLOT. A record at an object key needs 8 bytes of digest; one at + // a bucket, lifecycle or multipart key needs a pointer to its text, because that text is + // the only copy there is. The question is whether SAFE Rust can hold either in eight + // bytes, and the answer is what decides whether the collection has to be split by kind. #[allow(dead_code)] - struct Everything { - value: TaggedValue, - version_and_deleted: u64, - update_time_ms: u64, + enum ThinText { + // A thin pointer: the length lives in the inner box, so this variant is 8 bytes. + Text(Box>), + Digest(u64), } + #[allow(dead_code)] + enum ThinTextNonZero { + Text(Box>), + // Both variants now have a niche at zero, which is the same niche -- so this cannot + // be smaller than the one above however it looks. + Digest(std::num::NonZeroU64), + } + #[allow(dead_code)] + struct TaggedWord(u64); // what it WOULD cost, with `unsafe` to read a pointer back out - 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::(), + "EIGHTY value thin_enum={} thin_enum_nonzero={} tagged_word={} today={}", + size_of::(), + size_of::(), + 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; + #[allow(dead_code)] + struct ThinValueRecord { + key: SharedText, + value: TaggedWord, + version_and_deleted: u64, + update_time_ms: u64, + } + // (b) THE KIND OUT OF THE STORED KEY. Every object record's key begins `object/`, which is + // seven characters of kind tag stored once per record. A collection that knows the kind + // would not store it -- but the full spelling is what the snapshot writes and what every + // proof hashes, so the encoder would have to rebuild it, which is an allocation on the + // proof path for seven bytes of residency. + let record = size_of::(); + let chars = KEY_CHARS + SHARED_HEADER; + let now = record + chars; + assert_eq!(now, 104, "the rung we are standing on should be the published 104, not {now}"); 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. + + let thin_value = size_of::() + chars; println!( - "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::(), + "EIGHTY rung an_eight_byte_value_slot={thin_value} saves={} \ + (needs `unsafe`, or the collection split by kind)", + now as i64 - thin_value as i64 ); - - // 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; + let no_prefix = record + (KEY_CHARS - KIND_PREFIX) + SHARED_HEADER; 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), + "EIGHTY rung and_the_kind_out_of_the_stored_key={no_prefix} saves={}", + now as i64 - no_prefix as i64 + ); + let both = size_of::() + (KEY_CHARS - KIND_PREFIX) + SHARED_HEADER; + println!("EIGHTY rung both={both} saves={}", now as i64 - both as i64); + + // (c) THE ALLOCATION HEADER IS REFUSED, and by a guard rather than by an opinion. Owning + // the key's characters would drop 16 bytes an object, and + // `an_object_key_costs_two_bytes_a_character_however_many_places_name_it` measures + // resident copies as the SLOPE of metadata against key length. The bounded change log + // names the same characters, so an owned copy makes that slope 3 instead of 2 -- a cost + // of one byte per character per holder, which for a 1,024-byte key is a megabyte. + println!( + "EIGHTY the allocation header is {SHARED_HEADER} bytes and is NOT a candidate: the \ + change log shares these characters, so owning them costs one byte a character per \ + retained change -- measured and refused once already" + ); + println!( + "EIGHTY UNDER A HUNDRED NEEDS {} off {now}, and the two candidates above are worth \ + {} and {}", + now - 99, + now as i64 - thin_value as i64, + now as i64 - no_prefix as i64, ); } + #[test] #[ignore = "a measurement, not an assertion: cargo test -- --ignored --nocapture"] fn measure_the_route_to_under_a_hundred_bytes_an_object() { @@ -37588,17 +37745,19 @@ fn measure_what_a_one_word_text_handle_would_save() { struct RecordControl { key: SharedText, value: RecordValue, - version: u64, + // ONE WORD FOR BOTH. The deleted flag rides the version's top bit; spelling them as + // separate fields here measured 56 where the record is 48, and the control assertion + // below would have caught it -- except that this measurement is `#[ignore]`d and so + // is run by nothing. + version_and_deleted: u64, update_time_ms: u64, - deleted: bool, } #[allow(dead_code)] struct RecordThin { key: OneWord, value: RecordValue, - version: u64, + version_and_deleted: u64, update_time_ms: u64, - deleted: bool, } let size = std::mem::size_of::();