217 lines
8.5 KiB
Rust
217 lines
8.5 KiB
Rust
//! 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());
|
|
}
|