Answer reads from a captured root, scoped to one namespace
B1 deliverable 8. `StoreEngine::snapshot` and every `RepoSnapshot` accessor were the frozen D0 signatures returning `NotImplemented`; they now capture one committed root and answer from it. The frozen signature could not express an absent repository, so contract review 2026-08-07-A adds `StoreError::NoSuchRepository`. Reusing `Conflict`, `NotReady` or `UnrecognizedLayout` would have made the error a false statement about what happened and left a caller unable to distinguish it from a genuine instance of that condition. It is an inability to answer and not a lifecycle state, which is the distinction the taxonomy turns on: a namespace never bound has no `RepoState`, so there is no genesis authority to report and none can be manufactured without fabricating a trust root. A namespace that is bound and retired is the opposite case, and it captures normally - refusing both would erase a difference the store knows. `RepoSnapshot` therefore gains `lifecycle` and `storage_mode`, without which a reader cannot tell an active repository from a deleted one. Isolation is structural rather than checked. `IndexKey` has no constructor that omits a namespace, so the only key `locate` can build is one scoped to its own, and there is no branch a later edit could invert. An undefined object type code is `Corruption` and not a miss: the entry was written by this store, so a code no version of it ever assigned means the run behind it is damaged, and reporting that as absence would hide it. Capture is two `Arc` clones and a hash lookup, and holding the root is what pins every generation behind the locations it can return - a reader cannot be handed an offset into a segment deleted before it reads. `Debug` is hand-written, since a derived one would render the whole index into any log line that formatted a snapshot. 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 entirely `im` persistent structures, so `(*root).clone()` allocates zero bytes and so does cloning the index. Both were tried as the negative control and both read zero. The test keeps a measured figure asserted at exactly zero, which catches materialization, and adds `Arc::ptr_eq`, which catches the copy the figure cannot. A live control proves the meter moves. Four integration tests assert the same property through `open`, `submit` and `snapshot` rather than against a hand-built root - charter item 8. Their two repositories are co-located on one shard deliberately: separate shards write to separate journals and separate index deltas, so isolation holds there by construction and a namespace-blind lookup would still pass. Verified by giving `locate` a namespace-blind fallback, which fails the isolation assertion at both levels. Recorded and not acted on: `segment_generation` is per-shard, so the same generation and offset pair occurs in every shard's journal. Not ambiguity - a location is only read through a snapshot, whose namespace determines the shard - but it means a cross-shard location comparison asserts nothing. Deliverables 1, 3 and 7 remain. The ignored staging test's blocker list is now stale in B3's file and is left for B3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b4e4c7ebc7
commit
bac31513e3
|
|
@ -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<RepoSnapshot, StoreError> {
|
||||
Err(StoreError::NotImplemented(
|
||||
"StoreEngine::snapshot — B1 NamespaceTxn, scope 6-B1 deliverable 8",
|
||||
))
|
||||
pub fn snapshot(&self, repo: NamespaceId) -> Result<RepoSnapshot, StoreError> {
|
||||
// `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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<CommittedRoot>,
|
||||
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<RepoState>,
|
||||
}
|
||||
|
||||
/// 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<CommittedRoot>,
|
||||
namespace: NamespaceId,
|
||||
) -> Result<Self, StoreError> {
|
||||
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<Option<ObjectLocation>, 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<Option<ObjectLocation>, 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<u8>` -- 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<bool> = const { Cell::new(false) };
|
||||
static ALLOCATED_BYTES: Cell<usize> = 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<T>(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<RepoState> {
|
||||
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<RepoState>)],
|
||||
entries: &[(NamespaceId, ObjectId, IndexLocation)],
|
||||
) -> Arc<CommittedRoot> {
|
||||
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::<std::collections::HashMap<_, _>>()
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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<RepoSnapshot, StoreError>`, 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue