diff --git a/crates/levcs-store/src/engine.rs b/crates/levcs-store/src/engine.rs index 2c65a5f..2147210 100644 --- a/crates/levcs-store/src/engine.rs +++ b/crates/levcs-store/src/engine.rs @@ -352,10 +352,16 @@ impl StoreEngine { /// Acquire exactly one committed root and derive a repository view from /// it. Readers never observe an index entry newer than their captured /// root (plan §5.3). - pub fn snapshot(&self, _repo: NamespaceId) -> Result { - Err(StoreError::NotImplemented( - "StoreEngine::snapshot — B1 NamespaceTxn, scope 6-B1 deliverable 8", - )) + pub fn snapshot(&self, repo: NamespaceId) -> Result { + // `load_full` and not `load`: the snapshot outlives this call, so it + // must own the generation it read rather than borrow the slot. That + // ownership is the whole retention guarantee -- see the note on + // `RepoSnapshot`. + // + // One load, so the snapshot is a single point in the publication + // order. Resolving the repository from a second load could pair one + // generation's `RepoState` with another's index. + RepoSnapshot::capture(self.shared.committed.load_full(), repo) } /// Submit a validated transaction for sequencing, group append, fence, and diff --git a/crates/levcs-store/src/lib.rs b/crates/levcs-store/src/lib.rs index df53162..c6bec7f 100644 --- a/crates/levcs-store/src/lib.rs +++ b/crates/levcs-store/src/lib.rs @@ -69,7 +69,11 @@ pub use roots::{ RetainedObjectSource, RetainedProjectionArtifact, RetainedReceipt, RetainedSegment, RetainedTail, ShardSubtree, StatusEntry, StatusPhase, StatusReservation, TerminalStatusEntry, }; -pub use snapshot::RepoSnapshot; +// `ObjectLocation` names `RepoSnapshot::locate`'s success value, and the two +// namespace enums name the accessors contract review 2026-08-07-A added, so a +// caller cannot use the re-exported `RepoSnapshot` without them. +pub use index::{NamespaceLifecycle, NamespaceStorageMode}; +pub use snapshot::{ObjectLocation, RepoSnapshot}; pub use staging::{ ProjectionAdoption, ProjectionAdoptionOutcome, ProjectionAdoptionResolution, ProjectionArtifact, RecoveredProjectionOutcome, RecoveredProjectionResolution, diff --git a/crates/levcs-store/src/snapshot.rs b/crates/levcs-store/src/snapshot.rs index e254c6b..7ffd57f 100644 --- a/crates/levcs-store/src/snapshot.rs +++ b/crates/levcs-store/src/snapshot.rs @@ -7,14 +7,66 @@ //! A snapshot must not clone the index; plan §5.1 requires reads to retain the //! committed generation and share structure, so a snapshot is cheap enough to //! take per request. +//! +//! # What a capture costs, and why that is a correctness property +//! +//! Capturing is two `Arc` clones and one hash lookup. It is not "fast because +//! the maps are small" — it is O(1) in the number of objects, namespaces, and +//! generations the root holds, and `snapshot_capture_allocates_nothing` below +//! measures it as a byte figure rather than asserting it in a comment. §5.3 +//! makes this correctness and not performance: a per-request read that copied +//! the index would make the index's size a per-request cost, and the read path +//! would degrade as the store filled rather than at a bound anyone configured. +//! +//! # What holding one guarantees +//! +//! An `ObjectLocation` names a physical place — a generation, an offset, a +//! length. That is only answerable while the artifact carrying it is still on +//! disk, and compaction is entitled to remove artifacts no committed root +//! references. Retaining the whole `CommittedRoot` is what closes that: every +//! `RetainedGeneration` behind every location this snapshot can return is +//! pinned for as long as the snapshot lives, so a reader cannot be handed an +//! offset into a segment that is deleted before it reads. + +use std::sync::Arc; use levcs_core::{ObjectId, ObjectType}; +use crate::index::{IndexKey, NamespaceLifecycle, NamespaceStorageMode}; +use crate::roots::{CommittedRoot, RepoState}; use crate::types::{NamespaceId, StoreError}; /// One repository's committed logical state at one generation. pub struct RepoSnapshot { - _private: (), + /// The whole visibility boundary, retained — see the module note on what + /// holding it guarantees. Capturing it is one atomic increment and never a + /// traversal. + root: Arc, + namespace: NamespaceId, + /// Resolved once, at capture. `RepoMap` holds each `RepoState` behind its + /// own `Arc`, so this shares that allocation rather than copying the refs + /// map, and it takes the hash lookup off every accessor below. + /// + /// Resolving it eagerly is also what makes the accessors total: a + /// `RepoSnapshot` that exists at all has a `RepoState`, so `repo_sequence` + /// and the two authorities have something to return without an `Option` + /// the frozen signatures do not have. + state: Arc, +} + +/// Deliberately hand-written and deliberately small. A derived `Debug` would +/// reach through the retained `CommittedRoot` and render the entire index, so +/// one stray `{:?}` in a read path would serialize the store into a log line. +/// What identifies a snapshot is which repository it is of and which +/// generation it caught, and that is what this prints. +impl std::fmt::Debug for RepoSnapshot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RepoSnapshot") + .field("namespace", &self.namespace) + .field("repo_sequence", &self.state.repo_sequence) + .field("lifecycle", &self.state.lifecycle) + .finish_non_exhaustive() + } } /// Where an object lives, for a reader holding a snapshot. @@ -28,31 +80,525 @@ pub struct ObjectLocation { } impl RepoSnapshot { + /// Capture `namespace` against `root`. + /// + /// The absent-repository rule lives here rather than at the call site so + /// that every way of obtaining a snapshot obeys it, and so that the + /// invariant the accessors depend on — a live `RepoSnapshot` always has a + /// `RepoState` — is established by the only constructor. + /// + /// Contract review 2026-08-07-A: an unbound namespace is + /// `StoreError::NoSuchRepository`, an inability to answer. A namespace + /// that is bound and retired is a lifecycle, is reported through + /// [`RepoSnapshot::lifecycle`], and is captured normally — refusing it + /// here would make a deleted repository indistinguishable from one that + /// never existed, and the two have different answers to every question + /// below. + pub(crate) fn capture( + root: Arc, + namespace: NamespaceId, + ) -> Result { + let Some(state) = root.repo(&namespace) else { + return Err(StoreError::NoSuchRepository { namespace }); + }; + let state = Arc::clone(state); + Ok(Self { + root, + namespace, + state, + }) + } + pub fn namespace(&self) -> NamespaceId { - unimplemented!("B1 NamespaceTxn: scope 6-B1 deliverable 8") + self.namespace } /// The logical federation cursor value, never the physical /// `shard_sequence` (plan §4 transaction invariant 8). pub fn repo_sequence(&self) -> u64 { - unimplemented!("B1 NamespaceTxn: scope 6-B1 deliverable 8") + self.state.repo_sequence } pub fn current_authority(&self) -> ObjectId { - unimplemented!("B1 NamespaceTxn: scope 6-B1 deliverable 8") + self.state.current_authority } pub fn genesis_authority(&self) -> ObjectId { - unimplemented!("B1 NamespaceTxn: scope 6-B1 deliverable 8") + self.state.genesis_authority + } + + /// Whether this repository is `Active`, `ReadOnly`, or `Deleted` at the + /// captured generation. + /// + /// Added by contract review 2026-08-07-A. Without it a reader holding a + /// snapshot cannot tell a live repository from a retired one, and §4 makes + /// that distinction observable; `NoSuchRepository` deliberately does not + /// cover it, because a retired repository still has a state and still + /// answers every other question here. + pub fn lifecycle(&self) -> NamespaceLifecycle { + self.state.lifecycle + } + + /// Added by contract review 2026-08-07-A, for the same reason as + /// [`RepoSnapshot::lifecycle`]. + pub fn storage_mode(&self) -> NamespaceStorageMode { + self.state.storage_mode } /// Locate an object *within this namespace*. Namespace membership, not /// global object existence, controls reads: identical bytes in private /// repository A do not make an object readable through repository B /// (plan §4 resource invariants). - pub fn locate(&self, _id: ObjectId) -> Result, StoreError> { - Err(StoreError::NotImplemented( - "RepoSnapshot::locate — B1 NamespaceTxn, scope 6-B1 deliverable 8", - )) + /// + /// The isolation is structural rather than checked: [`IndexKey`] has no + /// constructor that omits a namespace, so the only key this can build is + /// one scoped to `self.namespace`. There is no branch here that a future + /// edit could invert. + /// + /// `Ok(None)` is "this namespace does not contain that object" and is not + /// a statement about whether the object exists anywhere in the store — + /// answering the second question is precisely what the invariant forbids. + pub fn locate(&self, id: ObjectId) -> Result, StoreError> { + let Some(location) = self.root.index().get(&IndexKey::new(self.namespace, id)) else { + return Ok(None); + }; + + // The index stores the type as a code because the packed run format is + // bytes; the frozen `ObjectLocation` hands back a typed value. An + // undefined code is not a missing object and must not be reported as + // one: the entry was written by this store, so a code no version of it + // ever assigned means the run or the delta behind it is damaged. + let object_type = ObjectType::from_u8(location.object_type).map_err(|_| { + StoreError::Corruption(format!( + "index entry for object {} in namespace {} carries object type code {}, \ + which names no defined object type; the index run or delta holding it \ + is damaged", + hex::encode(id.0), + self.namespace.to_hex(), + location.object_type, + )) + })?; + + Ok(Some(ObjectLocation { + segment_generation: location.segment_generation, + offset: location.frame_offset, + len: u64::from(location.frame_len), + object_type, + shard_sequence: location.shard_sequence, + })) + } +} + +#[cfg(test)] +mod tests { + use std::alloc::{GlobalAlloc, Layout, System}; + use std::cell::Cell; + use std::sync::Arc; + + use im::Vector; + + use super::*; + use crate::index::{IndexDelta, IndexLocation}; + use crate::roots::{ + GenerationMap, IndexDeltaLayer, LayeredObjectIndex, RepoMap, ShardSequenceMap, + TerminalStatusMap, TypedRefMap, + }; + + // ----------------------------------------------------------------------- + // A thread-local allocation meter + // ----------------------------------------------------------------------- + // + // Scope 6-B1 deliverable 8 asks for "a measured assertion that taking a + // snapshot allocates no index copy -- a count or a byte figure, not a + // comment", and for a test that fails if someone clones. Counting bytes + // through the global allocator is the only form of that which cannot be + // satisfied by a cheaper clone: `Arc::clone` allocates nothing, and every + // way of copying an index -- `Vector`, `HashMap`, `Vec` -- allocates + // something. + // + // The counter is thread-local rather than global because the test binary + // runs tests in parallel, and a global counter would measure whatever else + // happened to be allocating at the same moment. That would make this test + // flaky in the direction that matters least (spurious failure) and, worse, + // would tempt someone to widen the bound until it stopped failing. + + thread_local! { + static MEASURING: Cell = const { Cell::new(false) }; + static ALLOCATED_BYTES: Cell = const { Cell::new(0) }; + } + + struct MeteredAllocator; + + unsafe impl GlobalAlloc for MeteredAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + // `MEASURING` gates the accounting so that the thread-local access + // itself -- which may allocate on first touch -- cannot recurse + // into the counter it is trying to update. + if MEASURING.get() { + ALLOCATED_BYTES.set(ALLOCATED_BYTES.get() + layout.size()); + } + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + } + + #[global_allocator] + static ALLOCATOR: MeteredAllocator = MeteredAllocator; + + /// Bytes allocated on this thread while `body` ran. + fn allocated_bytes(body: impl FnOnce() -> T) -> (T, usize) { + // Touch both thread-locals before arming, so their own lazy + // initialization is never part of the measurement. + MEASURING.set(false); + ALLOCATED_BYTES.set(0); + MEASURING.set(true); + let value = body(); + MEASURING.set(false); + (value, ALLOCATED_BYTES.get()) + } + + // ----------------------------------------------------------------------- + // Fixtures + // ----------------------------------------------------------------------- + + fn namespace(seed: u8) -> NamespaceId { + NamespaceId::from_bytes([seed; 32]) + } + + fn object(seed: u8) -> ObjectId { + ObjectId([seed; 32]) + } + + fn repo_state(sequence: u64, lifecycle: NamespaceLifecycle) -> Arc { + Arc::new(RepoState { + repo_sequence: sequence, + current_authority: object(0xc0), + genesis_authority: object(0x6e), + refs: TypedRefMap::new(), + lifecycle, + storage_mode: NamespaceStorageMode::Full, + previous_event_digest: object(0xed), + }) + } + + fn location(object_type: u8) -> IndexLocation { + IndexLocation { + segment_generation: 3, + frame_offset: 4_096, + frame_len: 512, + object_type, + shard_sequence: 7, + } + } + + /// A root binding `repos`, with `entries` in a single delta layer. + fn root_with( + repos: &[(NamespaceId, Arc)], + entries: &[(NamespaceId, ObjectId, IndexLocation)], + ) -> Arc { + let mut repositories = RepoMap::new(); + for (ns, state) in repos { + repositories.insert(*ns, Arc::clone(state)); + } + + let mut delta = IndexDelta::new(4_096, 1 << 20); + for (ns, id, location) in entries { + delta + .insert(IndexKey::new(*ns, *id), *location) + .expect("delta accepts the fixture entries"); + } + let mut layers = Vector::new(); + layers.push_back(IndexDeltaLayer::new(0, 1, Arc::new(delta))); + + Arc::new(CommittedRoot::new( + repositories, + LayeredObjectIndex::new(layers, Vector::new()), + TerminalStatusMap::new(), + ShardSequenceMap::new(), + GenerationMap::new(), + )) + } + + // ----------------------------------------------------------------------- + // Capture + // ----------------------------------------------------------------------- + + #[test] + fn capture_reports_the_state_bound_at_the_captured_generation() { + let ns = namespace(1); + let root = root_with(&[(ns, repo_state(42, NamespaceLifecycle::Active))], &[]); + + let snapshot = RepoSnapshot::capture(root, ns).expect("the namespace is bound"); + + assert_eq!(snapshot.namespace(), ns); + assert_eq!(snapshot.repo_sequence(), 42); + assert_eq!(snapshot.current_authority(), object(0xc0)); + assert_eq!(snapshot.genesis_authority(), object(0x6e)); + assert_eq!(snapshot.lifecycle(), NamespaceLifecycle::Active); + assert_eq!(snapshot.storage_mode(), NamespaceStorageMode::Full); + } + + #[test] + fn capture_refuses_a_namespace_no_repository_is_bound_for() { + let bound = namespace(1); + let unbound = namespace(2); + let root = root_with(&[(bound, repo_state(1, NamespaceLifecycle::Active))], &[]); + + let error = RepoSnapshot::capture(root, unbound).expect_err("nothing is bound for it"); + + // Asserted by name and by payload: contract review 2026-08-07-A makes + // the identity part of the error, so a caller can recover what it + // asked for without parsing the message. + match error { + StoreError::NoSuchRepository { namespace } => assert_eq!(namespace, unbound), + other => panic!("expected NoSuchRepository, got {other:?}"), + } + } + + /// The distinction contract review 2026-08-07-A turns on. A retired + /// repository still has a state and still answers; only an unbound one is + /// an inability to answer. Refusing both would erase the difference. + #[test] + fn a_deleted_repository_captures_and_reports_its_lifecycle() { + let ns = namespace(1); + let root = root_with(&[(ns, repo_state(9, NamespaceLifecycle::Deleted))], &[]); + + let snapshot = RepoSnapshot::capture(root, ns).expect("a retired repository is bound"); + + assert_eq!(snapshot.lifecycle(), NamespaceLifecycle::Deleted); + assert_eq!(snapshot.repo_sequence(), 9); + } + + // ----------------------------------------------------------------------- + // Namespace isolation + // ----------------------------------------------------------------------- + + #[test] + fn locate_answers_for_its_own_namespace() { + let ns = namespace(1); + let id = object(0xa1); + let root = root_with( + &[(ns, repo_state(1, NamespaceLifecycle::Active))], + &[(ns, id, location(ObjectType::Commit as u8))], + ); + + let found = RepoSnapshot::capture(root, ns) + .expect("bound") + .locate(id) + .expect("a defined type code decodes") + .expect("the entry is in this namespace"); + + assert_eq!( + found, + ObjectLocation { + segment_generation: 3, + offset: 4_096, + len: 512, + object_type: ObjectType::Commit, + shard_sequence: 7, + } + ); + } + + /// §7's namespace-isolation exit criterion, at the index-key level: + /// *identical bytes* in A are not readable through B. The same `ObjectId` + /// is indexed under both namespaces at deliberately different locations, + /// so a leak would return B's row rather than nothing and the assertion + /// distinguishes the two failures. + #[test] + fn identical_bytes_in_another_namespace_are_not_readable_through_this_one() { + let a = namespace(1); + let b = namespace(2); + let shared = object(0xa1); + let only_in_b = object(0xb2); + + let root = root_with( + &[ + (a, repo_state(1, NamespaceLifecycle::Active)), + (b, repo_state(1, NamespaceLifecycle::Active)), + ], + &[ + (a, shared, location(ObjectType::Blob as u8)), + (b, shared, location(ObjectType::Tree as u8)), + (b, only_in_b, location(ObjectType::Blob as u8)), + ], + ); + + let through_a = RepoSnapshot::capture(Arc::clone(&root), a).expect("bound"); + + // The object present in both resolves to A's row, not B's. + let found = through_a + .locate(shared) + .expect("decodes") + .expect("present in A"); + assert_eq!( + found.object_type, + ObjectType::Blob, + "A's snapshot resolved the shared id through B's index row" + ); + + // The object present only in B is invisible through A. + assert_eq!( + through_a.locate(only_in_b).expect("decodes"), + None, + "an object stored only in B was readable through A" + ); + } + + #[test] + fn locate_reports_a_miss_rather_than_an_error() { + let ns = namespace(1); + let root = root_with(&[(ns, repo_state(1, NamespaceLifecycle::Active))], &[]); + + assert_eq!( + RepoSnapshot::capture(root, ns) + .expect("bound") + .locate(object(0xff)) + .expect("an absent object is not a failure"), + None + ); + } + + #[test] + fn an_undefined_object_type_code_is_corruption_and_not_a_miss() { + let ns = namespace(1); + let id = object(0xa1); + // 0 and 6 are outside `ObjectType`'s 1..=5; no version of this store + // ever wrote either, so an entry carrying one is damaged. + let root = root_with( + &[(ns, repo_state(1, NamespaceLifecycle::Active))], + &[(ns, id, location(6))], + ); + + let error = RepoSnapshot::capture(root, ns) + .expect("bound") + .locate(id) + .expect_err("an undefined type code must not be reported as absence"); + + match error { + StoreError::Corruption(message) => { + assert!(message.contains("object type code 6"), "{message}"); + } + other => panic!("expected Corruption, got {other:?}"), + } + } + + // ----------------------------------------------------------------------- + // Scope 6-B1 deliverable 8: the measured no-copy assertion + // ----------------------------------------------------------------------- + + /// *Accept:* "a measured assertion that taking a snapshot allocates no + /// index copy — a count or a byte figure, not a comment". + /// + /// The figure is bytes allocated on this thread across the capture. It is + /// asserted at exactly zero rather than at a threshold: `Arc::clone` is + /// two atomic increments and allocates nothing at all, so any nonzero + /// reading is a structure someone copied, and a threshold would be a + /// budget for copying rather than a prohibition on it. + /// + /// The index is large enough that a clone is unmissable — 4,000 entries + /// across two namespaces — so this fails loudly if `capture` ever stops + /// sharing. + #[test] + fn snapshot_capture_allocates_nothing() { + let ns = namespace(1); + let other = namespace(2); + let entries: Vec<_> = (0..2_000u32) + .flat_map(|i| { + let mut id = [0u8; 32]; + id[..4].copy_from_slice(&i.to_le_bytes()); + [ + (ns, ObjectId(id), location(ObjectType::Blob as u8)), + (other, ObjectId(id), location(ObjectType::Blob as u8)), + ] + }) + .collect(); + let root = root_with( + &[ + (ns, repo_state(1, NamespaceLifecycle::Active)), + (other, repo_state(1, NamespaceLifecycle::Active)), + ], + &entries, + ); + + // Prove the meter can see a copy at all before trusting it to report + // zero. A test whose instrument is never shown to move is a test that + // passes when the instrument is broken. + // + // The control materializes the same entry set into a `std` map, which + // is what copying an index actually costs. + // + // **`(*root).clone()` does not work as a control, and neither does + // cloning the index.** Two earlier versions of this test tried each and + // both read zero: `CommittedRoot` is built entirely from `im` + // persistent structures — `im::HashMap`, `im::Vector`, `im::OrdMap` — + // whose clones are O(1) and allocate nothing. That is the structure + // sharing §5.3 asks for, working. It also means **a byte figure alone + // cannot fail on a clone in this crate**, because here a clone *is* + // sharing; the `Arc::ptr_eq` assertion below is what covers that case, + // and the byte figure covers materialization. See the carry-forward in + // scope §6.4 deliverable 8. + let (copied, copy_bytes) = allocated_bytes(|| { + entries + .iter() + .map(|(ns, id, location)| (IndexKey::new(*ns, *id), *location)) + .collect::>() + }); + assert!( + copy_bytes > 0, + "the allocation meter read zero while materializing a copy of the \ + index, so it cannot be trusted to report zero below" + ); + drop(copied); + + let (snapshot, capture_bytes) = + allocated_bytes(|| RepoSnapshot::capture(Arc::clone(&root), ns)); + let snapshot = snapshot.expect("bound"); + + assert_eq!( + capture_bytes, 0, + "capturing a snapshot allocated {capture_bytes} bytes; it must share \ + the committed root's substructures, not copy them (plan §5.3). \ + Copying this root costs {copy_bytes} bytes." + ); + + // The byte figure says nothing was built. This says what was retained + // is the caller's own root and not an equal one: same allocation, so + // there is no traversal anywhere behind the capture, whatever the + // allocator happened to see. + assert!( + Arc::ptr_eq(&snapshot.root, &root), + "the snapshot retained a different CommittedRoot allocation than \ + the one it was given" + ); + + // ...and the shared index still answers, so "allocated nothing" is not + // "captured nothing". + let mut id = [0u8; 32]; + id[..4].copy_from_slice(&7u32.to_le_bytes()); + assert!(snapshot.locate(ObjectId(id)).expect("decodes").is_some()); + } + + /// Reads are answered from the generation the snapshot captured, so a + /// later root cannot change what an existing snapshot returns (plan §5.3: + /// "readers never observe an index entry newer than their captured root"). + #[test] + fn a_snapshot_is_unaffected_by_a_later_root() { + let ns = namespace(1); + let added_later = object(0xbb); + let before = root_with(&[(ns, repo_state(1, NamespaceLifecycle::Active))], &[]); + let snapshot = RepoSnapshot::capture(before, ns).expect("bound"); + + // A newer root binds the same namespace and indexes a new object. + let _after = root_with( + &[(ns, repo_state(2, NamespaceLifecycle::Active))], + &[(ns, added_later, location(ObjectType::Blob as u8))], + ); + + assert_eq!(snapshot.repo_sequence(), 1); + assert_eq!(snapshot.locate(added_later).expect("decodes"), None); } } diff --git a/crates/levcs-store/src/types.rs b/crates/levcs-store/src/types.rs index 3c4dc3f..c1396b4 100644 --- a/crates/levcs-store/src/types.rs +++ b/crates/levcs-store/src/types.rs @@ -196,6 +196,25 @@ pub enum StoreError { #[error("mutable-state conflict: {0}")] Conflict(String), + /// No repository is bound for this namespace in the captured committed + /// root. Contract review 2026-08-07-A, requested by B1 for scope 6-B1 + /// deliverable 8. + /// + /// This is an inability to answer and not a lifecycle state, which is the + /// distinction the taxonomy above turns on. A namespace that was never + /// bound has no `RepoState`, so there is no `genesis_authority` to report + /// and no sequence to report it at; `StoreEngine::snapshot` cannot + /// manufacture either without fabricating a trust root. A namespace that + /// *is* bound and has been retired is the opposite case — `Deleted` is a + /// lifecycle, it has a `RepoState`, and it is reported through + /// `RepoSnapshot::lifecycle` rather than through this error. + /// + /// It carries the typed `NamespaceId` rather than a rendered string so a + /// caller can match on the identity it asked for instead of parsing it + /// back out of a message. + #[error("no repository bound for namespace {}", namespace.to_hex())] + NoSuchRepository { namespace: NamespaceId }, + #[error("store is not ready")] NotReady, diff --git a/crates/levcs-store/tests/namespace_snapshot.rs b/crates/levcs-store/tests/namespace_snapshot.rs new file mode 100644 index 0000000..99bbf96 --- /dev/null +++ b/crates/levcs-store/tests/namespace_snapshot.rs @@ -0,0 +1,216 @@ +//! Scope 6-B1 deliverable 8 and §7's namespace-isolation exit criterion, +//! asserted through the public surface. +//! +//! `snapshot.rs`'s unit tests already assert isolation at the index-key level, +//! against a `CommittedRoot` the test built. This file asserts it against a +//! root **the store built**: every namespace here is created by +//! `StoreEngine::submit`, every object is staged through a real transaction, +//! and every read goes through `StoreEngine::snapshot` and +//! `RepoSnapshot::locate`. +//! +//! That distinction is scope 5 charter item 8 — "assert against the path that +//! runs, not the helper". A hand-built root proves the lookup rule; it cannot +//! prove that the writer files entries under the namespace it was given, which +//! is the half of the isolation property that lives in the write path. +//! +//! The exit criterion says *identical bytes*, so the shared object here is +//! genuinely identical in both repositories: `push_transaction` stages +//! `ObjectId([blob; 32])` with `raw: vec![blob; 64]`, so the same `blob` seed +//! in two namespaces produces the same id over the same bytes. Isolation that +//! held only because the two repositories held different objects would not be +//! isolation at all. + +// Gated on all three features because `engine_matrix` arms failpoints and +// reaches `drive.rs`, so it compiles only under the full set. This file needs +// neither, and the alternative was a second copy of the transaction builders +// and the shard-routing search inside a B1-owned test — duplicating a +// B4-owned harness to avoid a feature gate is the worse trade. The Phase 1 +// gate runs exactly this combination, so these tests run on every gate run. +#![cfg(all( + feature = "store-privileged", + feature = "store-internals", + feature = "failpoints" +))] + +use levcs_core::ObjectId; +use levcs_store::types::{NamespaceId, StoreError}; + +// Only the submit harness, by path: `support/mod.rs` also carries the crash +// driver's plumbing, and this file needs none of it. +#[path = "support/engine_matrix.rs"] +mod engine_matrix; + +use engine_matrix::{ + create_transaction, namespace_on_shard, open_absent_root, push_transaction, submit, + DEFAULT_MAX_INDEX_RUNS, +}; + +const SHARD_COUNT: u16 = 2; + +/// Present in both repositories, as the same id over the same bytes. +const SHARED_BLOB: u8 = 0x7a; +/// Present only in B. +const ONLY_IN_B_BLOB: u8 = 0x5b; + +fn blob_id(seed: u8) -> ObjectId { + ObjectId([seed; 32]) +} + +/// Two repositories **on the same shard**, each holding `SHARED_BLOB`; B also +/// holds `ONLY_IN_B_BLOB`. +/// +/// Same shard deliberately. Two repositories on different shards write to +/// different journals, seal into different segment-generation spaces, and land +/// in different index deltas, so isolation between them holds by construction +/// and a namespace-blind lookup would still pass. Co-locating them puts both +/// repositories' frames in one journal and both their entries in one index, +/// which is the only arrangement where the namespace component of the key is +/// load-bearing. +fn store_with_two_repositories( + root: &std::path::Path, +) -> (levcs_store::StoreEngine, NamespaceId, NamespaceId) { + let engine = open_absent_root(root, SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS); + let a = namespace_on_shard(0, SHARD_COUNT, 1); + let b = namespace_on_shard(0, SHARD_COUNT, 2); + assert_ne!(a, b, "the two repositories must be distinct"); + + for (operation, namespace) in [(1u8, a), (2, b)] { + submit(&engine, create_transaction(namespace, operation)) + .receipt() + .expect("the repository is created"); + } + for (operation, namespace, blob) in [ + (3u8, a, SHARED_BLOB), + (4, b, SHARED_BLOB), + (5, b, ONLY_IN_B_BLOB), + ] { + submit(&engine, push_transaction(namespace, operation, blob)) + .receipt() + .expect("the push commits"); + } + + (engine, a, b) +} + +/// §7: "identical bytes in A not readable through B". +#[test] +fn an_object_stored_only_in_one_repository_is_invisible_through_the_other() { + let dir = tempfile::tempdir().expect("tempdir"); + let (engine, a, b) = store_with_two_repositories(dir.path()); + + let through_a = engine.snapshot(a).expect("A is bound"); + let through_b = engine.snapshot(b).expect("B is bound"); + + // The control: B really does hold it, so the assertion below is about + // visibility and not about a push that silently failed. + assert!( + through_b + .locate(blob_id(ONLY_IN_B_BLOB)) + .expect("a committed object decodes") + .is_some(), + "B does not hold the object this test is about; the fixture is wrong, \ + not the store" + ); + + assert_eq!( + through_a + .locate(blob_id(ONLY_IN_B_BLOB)) + .expect("a miss is not a failure"), + None, + "an object committed only to B was readable through A's snapshot" + ); +} + +/// The same bytes committed to both repositories resolve through each, and +/// each answer names its own repository's write. +/// +/// This is the case a namespace-blind index would *pass* by accident, so the +/// assertion is on the physical record each answer names. Both repositories sit +/// on one shard, so their frames share a journal and a generation space and the +/// two offsets are directly comparable: A and B were separate transactions, so +/// a snapshot returning the other's row is detectable even though the object id +/// and the staged bytes are identical. +/// +/// `(segment_generation, offset)` is compared rather than `segment_generation` +/// alone because generations are **per shard**, not global — the same pair +/// occurs in every shard's journal. That is not ambiguity in `ObjectLocation`: +/// a location is only ever read through a snapshot, and the snapshot's +/// namespace determines the shard. It does mean a cross-shard comparison +/// asserts nothing, which is the other reason these two repositories are +/// co-located. +#[test] +fn identical_bytes_in_both_repositories_resolve_independently() { + let dir = tempfile::tempdir().expect("tempdir"); + let (engine, a, b) = store_with_two_repositories(dir.path()); + + let from_a = engine + .snapshot(a) + .expect("A is bound") + .locate(blob_id(SHARED_BLOB)) + .expect("decodes") + .expect("A holds it"); + let from_b = engine + .snapshot(b) + .expect("B is bound") + .locate(blob_id(SHARED_BLOB)) + .expect("decodes") + .expect("B holds it"); + + assert_eq!(from_a.object_type, levcs_core::ObjectType::Blob); + assert_eq!(from_b.object_type, levcs_core::ObjectType::Blob); + assert_ne!( + (from_a.segment_generation, from_a.offset), + (from_b.segment_generation, from_b.offset), + "both repositories resolved the shared id to the same physical record, \ + so one of them is reading the other's write" + ); +} + +/// Contract review 2026-08-07-A, through the entry point a consumer calls. +#[test] +fn snapshot_refuses_a_namespace_no_repository_is_bound_for() { + let dir = tempfile::tempdir().expect("tempdir"); + let (engine, _a, _b) = store_with_two_repositories(dir.path()); + let never_created = namespace_on_shard(0, SHARD_COUNT, 99); + + match engine.snapshot(never_created) { + Err(StoreError::NoSuchRepository { namespace }) => { + assert_eq!(namespace, never_created) + } + Err(other) => panic!("expected NoSuchRepository, got {other:?}"), + Ok(_) => panic!("snapshot returned a view of a repository that was never created"), + } +} + +/// A snapshot is taken against one generation and keeps answering from it, so +/// a later commit cannot change what an already-taken snapshot reports +/// (plan §5.3). +#[test] +fn a_snapshot_does_not_observe_a_commit_that_followed_it() { + let dir = tempfile::tempdir().expect("tempdir"); + let (engine, a, _b) = store_with_two_repositories(dir.path()); + + let before = engine.snapshot(a).expect("A is bound"); + let sequence_before = before.repo_sequence(); + + const LATER: u8 = 0x9c; + submit(&engine, push_transaction(a, 6, LATER)) + .receipt() + .expect("the later push commits"); + + assert_eq!( + before.locate(blob_id(LATER)).expect("decodes"), + None, + "a snapshot observed an object committed after it was taken" + ); + assert_eq!(before.repo_sequence(), sequence_before); + + // ...and a snapshot taken now does see it, so the assertion above is about + // the captured generation and not about a push that never landed. + assert!(engine + .snapshot(a) + .expect("A is bound") + .locate(blob_id(LATER)) + .expect("decodes") + .is_some()); +} diff --git a/doc/instance-throughput-rewrite-plan.md b/doc/instance-throughput-rewrite-plan.md index c8669ec..4a120ed 100644 --- a/doc/instance-throughput-rewrite-plan.md +++ b/doc/instance-throughput-rewrite-plan.md @@ -1839,6 +1839,51 @@ The integration suite's `cleanup_is_deferred_and_names_its_deliverable` is repla deleted — the distinction it stood for, that "nothing to do" and "cannot answer yet" are different results, is now asserted the other way round at the public surface. +##### Contract review 2026-08-07-A + +B1 deliverable 8 — `RepoSnapshot` and `StoreEngine::snapshot`. Requested by B1 before any body +was written, because the frozen D0 signatures could not express the answer to a question the +deliverable cannot avoid. Two lead-owned files move; both are amendments, neither is a +redefinition. + +**`StoreEngine::snapshot` had no way to say "that namespace is not bound".** The frozen +signature returns `Result`, and no `StoreError` variant covered an +absent repository. The alternatives were all worse than an amendment: `Conflict`, `NotReady` +and `UnrecognizedLayout` each name a different condition, so reusing one would make the error +a false statement about what happened, and a caller could not distinguish it from a genuine +instance of that condition. **`StoreError::NoSuchRepository { namespace }` is added to +`types.rs`.** It carries the typed `NamespaceId` rather than a rendered string so a caller +matches on the identity it asked for instead of parsing a message. + +**It is an inability to answer and not a lifecycle state**, which is the distinction the +taxonomy's own doc comment turns on. A namespace that was never bound has no `RepoState`, so +there is no `genesis_authority` to report and no sequence to report it at, and `snapshot` +cannot manufacture either without fabricating a trust root. A namespace that *is* bound and +retired is the opposite: `Deleted` is a lifecycle, it has a state, and it answers every other +question. Refusing both would erase a difference the store knows. + +**`RepoSnapshot` therefore gains `lifecycle()` and `storage_mode()`.** `RepoState` already +carries both; without accessors a reader holding a snapshot could not tell an `Active` +repository from a `ReadOnly` or `Deleted` one, and §4 makes that distinction observable. Both +read straight off the pinned state and allocate nothing. `lib.rs` re-exports `ObjectLocation` +and the two namespace enums, which name the return types of the frozen `locate` and of these +two accessors — a caller cannot use the re-exported `RepoSnapshot` without them. + +**Deliverable 8's acceptance is amended, and the reason is that the design already +succeeded.** "A test that fails if someone clones" assumes a clone is a copy; `CommittedRoot` +is built entirely from `im` persistent structures, so cloning it allocates zero bytes and a +byte figure cannot fail on a clone anywhere in this crate. The scope records the full finding. +The test carries a measured no-materialization figure *and* an `Arc::ptr_eq` structural proof, +with a live control, rather than the single measured assertion the wording asked for. + +**One observation, recorded and not acted on.** `ObjectLocation::segment_generation` is +per-shard, not global, so the same `(generation, offset)` pair occurs in every shard's journal. +This is not ambiguity — a location is only ever read through a snapshot, and the snapshot's +namespace determines the shard — but it means any test comparing locations across shards +asserts nothing. `tests/namespace_snapshot.rs` co-locates its two repositories for that reason +and for the stronger one: only a shared shard makes the namespace component of the index key +load-bearing. + ##### Contract review 2026-07-31-A B3 deliverable 6 — finalize and the adoption pin. Deliverables 7 and 8 are **not** in this diff --git a/doc/phase1-storage-spine-scope.md b/doc/phase1-storage-spine-scope.md index 490a116..e99ef4b 100644 --- a/doc/phase1-storage-spine-scope.md +++ b/doc/phase1-storage-spine-scope.md @@ -1653,6 +1653,27 @@ criteria. taking a snapshot allocates no index copy — a count or a byte figure, not a comment. §5.3 makes this a correctness property, so it needs a test that fails if someone clones. + **Amended 2026-08-07: the acceptance as written cannot be met, and the reason is that the + design already succeeded.** "A test that fails if someone clones" assumes a clone is a copy. + `CommittedRoot` is built entirely from `im` persistent structures — `im::HashMap` for the + repositories, terminal statuses and shard sequences, `im::Vector` for the index layers and + sealed runs, `im::OrdMap` for typed refs — and every one of them clones in O(1) without + allocating. `(*root).clone()` therefore allocates **zero bytes**, and so does cloning + `LayeredObjectIndex`. Both were tried as the negative control and both read zero. + + A byte figure alone consequently cannot fail on a clone anywhere in this crate. It is still + worth having and is still asserted at exactly zero, because it does catch the other + regression — *materializing* the index, collecting entries into a `Vec` or a `std` map, + which is what an index copy would actually cost and which the control now demonstrates + allocates. What the byte figure cannot catch, `Arc::ptr_eq` does: the test asserts the + snapshot retained **the caller's own root allocation**, so no traversal happened behind the + capture whatever the allocator saw. + + `snapshot_capture_allocates_nothing` therefore carries both assertions and a live control. + Read the acceptance as *"a measured no-materialization figure plus a structural + no-copy proof"*; the single measured assertion the wording asks for does not exist here, + and recording that is more useful than a test that passes for a reason it does not state. + **B1 must not** touch the crash harness (B4), staging (B3), or any frozen Wave A file. #### Carry-forward: index sealing does not yet bound what a reopen rebuilds