Compare commits
14 Commits
main
...
b1-snapsho
| Author | SHA1 | Date |
|---|---|---|
|
|
4f799e32a4 | |
|
|
b5db29354e | |
|
|
b10d5eee88 | |
|
|
660b4c74d0 | |
|
|
ee886f6138 | |
|
|
df2e22a3b0 | |
|
|
d884db3f3e | |
|
|
9b2117a406 | |
|
|
fb71bba615 | |
|
|
9bc298868a | |
|
|
e0c599c432 | |
|
|
d36f6a86f7 | |
|
|
93692e2a88 | |
|
|
bac31513e3 |
|
|
@ -560,9 +560,10 @@
|
|||
"index_run_ceiling": {
|
||||
"enum": [
|
||||
"store_default",
|
||||
"raised_above_store_default",
|
||||
"raised_because_index_sealing_unimplemented"
|
||||
],
|
||||
"description": "Whether StoreOptions::max_index_runs was left at the store default (64) or raised so the run could reach its measured seconds despite unimplemented index-delta sealing. Cross-checked against resources.configured_ceilings.max_index_runs by the two \"index run ceiling\" rules in allOf: \"store_default\" bounds the recorded value at 64 and the raised value floors it at 65, so a bundle that declares one and records the other is invalid. The recorded value must be the value the run configured, never a constant restated here."
|
||||
"description": "Whether StoreOptions::max_index_runs was left at the store default (64) or raised above it. Cross-checked against resources.configured_ceilings.max_index_runs by the two \"index run ceiling\" rules in allOf: \"store_default\" bounds the recorded value at 64 and either raised value floors it at 65, so a bundle that declares one and records the other is invalid. The recorded value must be the value the run configured, never a constant restated here. DEPRECATED: \"raised_because_index_sealing_unimplemented\" is accepted only so archived v1 bundles stay valid; index-delta sealing is implemented and the emitter no longer produces it. It states a cause the emitter never verified — the declaration is derived by comparing the configured ceiling with the store default, so it named a reason for a fact it only observed. Emit \"raised_above_store_default\", which is that fact and nothing more. Contract review 2026-08-09-B."
|
||||
},
|
||||
"receipt_reconciliation": {
|
||||
"enum": [
|
||||
|
|
@ -782,7 +783,7 @@
|
|||
"max_index_runs": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "The StoreOptions::max_index_runs the run actually configured, read back from the options the store opened with — never the store default restated here, and never omitted. Bounded against run_conditions.index_run_ceiling by the two \"index run ceiling\" rules in allOf, so a defaulted or contradicted value is invalid rather than merely unverified. It is a ceiling this workload genuinely reaches: index-delta sealing being unimplemented makes it the limit submit refuses at."
|
||||
"description": "The StoreOptions::max_index_runs the run actually configured, read back from the options the store opened with — never the store default restated here, and never omitted. Bounded against run_conditions.index_run_ceiling by the two \"index run ceiling\" rules in allOf, so a defaulted or contradicted value is invalid rather than merely unverified. It is a ceiling this workload genuinely reaches: the run seals against it, and its fan-out trigger is what imposes the steady state a P2 number has to be measured in."
|
||||
}
|
||||
},
|
||||
"additionalProperties": {
|
||||
|
|
@ -1574,7 +1575,10 @@
|
|||
"run_conditions": {
|
||||
"properties": {
|
||||
"index_run_ceiling": {
|
||||
"const": "raised_because_index_sealing_unimplemented"
|
||||
"enum": [
|
||||
"raised_above_store_default",
|
||||
"raised_because_index_sealing_unimplemented"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
|
|
|||
|
|
@ -944,6 +944,11 @@ fn submit_path_bundle() -> serde_json::Value {
|
|||
"mutation_path": "store_engine_submit",
|
||||
"checkpointing": "unimplemented",
|
||||
"index_maintenance": "deltas_retained_in_memory",
|
||||
// Deliberately the deprecated spelling: this fixture is the standing
|
||||
// proof that an archived v1 bundle still validates. The emitter
|
||||
// writes `raised_above_store_default` now, and
|
||||
// `both_raised_spellings_are_accepted_and_bound_the_same_way`
|
||||
// covers the pair. Contract review 2026-08-09-B.
|
||||
"index_run_ceiling": "raised_because_index_sealing_unimplemented",
|
||||
"receipt_reconciliation": "acceptance_of_any_committed_status",
|
||||
"objects_new_source": "summed_from_receipts",
|
||||
|
|
@ -1513,3 +1518,43 @@ fn the_instance_gates_are_not_loosened_by_the_storage_path_split() {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Both spellings of a raised ceiling are accepted, and both are bound the same
|
||||
/// way.
|
||||
///
|
||||
/// The deprecated one is kept only so an archived v1 bundle stays valid --
|
||||
/// `schema_version` is `const: 1`, so there is no later version to move it to,
|
||||
/// and this repository has never held a bundle to check against. What must not
|
||||
/// happen is the deprecation becoming a second, weaker rule: a bundle that
|
||||
/// declares the old string still has to record a ceiling above the default, or
|
||||
/// "deprecated" would have quietly turned into "unchecked".
|
||||
///
|
||||
/// The new spelling states only what the emitter observes. It compares the
|
||||
/// configured ceiling with the store default and cannot know why they differ,
|
||||
/// so it may not name a cause -- which is exactly what the old string did, for
|
||||
/// a cause that is no longer true. Contract review 2026-08-09-B.
|
||||
#[test]
|
||||
fn both_raised_spellings_are_accepted_and_bound_the_same_way() {
|
||||
for spelling in [
|
||||
"raised_above_store_default",
|
||||
"raised_because_index_sealing_unimplemented",
|
||||
] {
|
||||
let mut bundle = submit_path_bundle();
|
||||
bundle["run_conditions"]["index_run_ceiling"] = serde_json::json!(spelling);
|
||||
assert_valid(
|
||||
&bundle,
|
||||
&format!("{spelling} must be an accepted declaration of a raised ceiling"),
|
||||
);
|
||||
|
||||
// The floor still applies. Recording the store default under either
|
||||
// spelling is a bundle whose declaration and measurement disagree.
|
||||
bundle["resources"]["configured_ceilings"]["max_index_runs"] = serde_json::json!(64);
|
||||
assert_invalid(
|
||||
&bundle,
|
||||
&format!(
|
||||
"{spelling} recording the store default must be refused; a deprecated \
|
||||
spelling is still cross-checked"
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -933,11 +933,19 @@ pub enum CheckpointProbe {
|
|||
/// `StoreEngine::checkpoint` refused `NotImplemented`, so no checkpoint was
|
||||
/// taken and none could have been.
|
||||
RefusedNotImplemented,
|
||||
/// `StoreEngine::checkpoint` returned a lease. The measured window still
|
||||
/// contains no checkpoint — the harness takes none inside it — so the
|
||||
/// truthful value is `enabled_not_reached`, and reaching one is the
|
||||
/// emitter's next change rather than a relabelling of this one.
|
||||
/// `StoreEngine::checkpoint` returned a lease, but no checkpoint was taken
|
||||
/// inside the measured window. The value is honest and cannot pass: a run
|
||||
/// whose lookup fan-out grows for its whole duration and which never
|
||||
/// checkpoints is not measuring the steady state a P2 number is supposed to
|
||||
/// characterize.
|
||||
EnabledNotReached,
|
||||
/// At least one checkpoint completed **inside the measured interval**.
|
||||
///
|
||||
/// Counted from checkpoints the run actually took, never from the ability
|
||||
/// to take one -- that is what `EnabledNotReached` already records, and the
|
||||
/// distinction between "the store can checkpoint" and "this measurement
|
||||
/// contains one" is the whole reason scope §7 requires this field.
|
||||
Exercised,
|
||||
}
|
||||
|
||||
/// Whether the index reached a steady state, from what is on the device.
|
||||
|
|
@ -1176,7 +1184,14 @@ impl RunConditions {
|
|||
if observations.configured_max_index_runs == observations.default_max_index_runs {
|
||||
"store_default"
|
||||
} else if observations.configured_max_index_runs > observations.default_max_index_runs {
|
||||
"raised_because_index_sealing_unimplemented"
|
||||
// The fact, and only the fact. This branch is reached by
|
||||
// comparing two numbers and knows nothing about *why* the
|
||||
// ceiling was raised, so it may not name a reason.
|
||||
// `raised_because_index_sealing_unimplemented` did, and the
|
||||
// reason it named is no longer true; the schema keeps accepting
|
||||
// it for archived bundles and this emitter never writes it
|
||||
// again. Contract review 2026-08-09-B.
|
||||
"raised_above_store_default"
|
||||
} else {
|
||||
return Err(format!(
|
||||
"refusing to emit a bundle: the run configured max_index_runs = {} below \
|
||||
|
|
@ -1228,6 +1243,7 @@ impl RunConditions {
|
|||
checkpointing: match observations.checkpoint {
|
||||
CheckpointProbe::RefusedNotImplemented => "unimplemented",
|
||||
CheckpointProbe::EnabledNotReached => "enabled_not_reached",
|
||||
CheckpointProbe::Exercised => "exercised",
|
||||
},
|
||||
index_maintenance,
|
||||
index_run_ceiling,
|
||||
|
|
@ -2455,7 +2471,9 @@ pub fn assemble_bundle(
|
|||
// The `u32` from the `StoreOptions` the store was opened with,
|
||||
// carried through the run rather than `ENGINE_MAX_INDEX_RUNS`
|
||||
// written out a second time. It is the ceiling this workload
|
||||
// genuinely reaches: `submit` refuses `NotImplemented` at it.
|
||||
// genuinely reaches -- the fan-out trigger fires on it and the
|
||||
// run seals against it, which is the steady state a P2 number
|
||||
// has to be measured in.
|
||||
(
|
||||
"max_index_runs".into(),
|
||||
jint(i128::from(inputs.observations.configured_max_index_runs)),
|
||||
|
|
@ -3056,20 +3074,30 @@ fn skeleton_ack_record(sequence: u64) -> AckRecord {
|
|||
//
|
||||
// # What this run can and cannot claim, stated before the code
|
||||
//
|
||||
// Two of B1's unimplemented deliverables bound it, and both are a bound on the
|
||||
// *bundle*, not merely on this file:
|
||||
// One condition still bounds it, and it is a bound on the *bundle*, not merely
|
||||
// on this file:
|
||||
//
|
||||
// * `submit` refuses `NotImplemented` after `max_index_runs` group
|
||||
// publications, because sealing the in-memory index delta into an
|
||||
// `IndexRun` is unimplemented. The ceiling is raised here so the run can
|
||||
// reach its measured seconds at all, which means the run holds every delta
|
||||
// layer it ever published in memory and its lookup fan-out grows for the
|
||||
// whole run. A P2 measurement is of a steady state; this is not one, and
|
||||
// the bundle says so through `outcome` and its verdicts.
|
||||
// * `StoreEngine::checkpoint` is unimplemented, so no checkpoint is taken.
|
||||
// Section 7 requires that a P2 run not have been achieved with
|
||||
// checkpointing disabled. This one was. That alone makes the
|
||||
// `storage_primitive` gate unearnable today, whatever the rate says.
|
||||
// * `StoreEngine::checkpoint` exists but this run never accumulates enough
|
||||
// work to trip it, so the bundle declares `enabled_not_reached`. Section 7
|
||||
// requires that a P2 run not have been achieved with checkpointing
|
||||
// disabled, and `exercised` is the value it wants. Until a run reaches
|
||||
// that, the `storage_primitive` gate stays unearnable whatever the rate
|
||||
// says.
|
||||
//
|
||||
// One bound that stood here is gone, and the ceiling below is the evidence.
|
||||
// `submit` used to refuse `NotImplemented` after `max_index_runs` group
|
||||
// publications because sealing the in-memory delta into an `IndexRun` was
|
||||
// unimplemented, so this file raised the ceiling to a million to reach its
|
||||
// measured seconds at all — which meant the run held every delta layer it ever
|
||||
// published and its lookup fan-out grew for the whole run. Sealing landed, so
|
||||
// the raise outlived its reason and was dropped: the store opens at its own
|
||||
// default and the bundle declares `index_run_ceiling: store_default`.
|
||||
//
|
||||
// Dropping it is what makes `index_maintenance: runs_sealed` mean what it
|
||||
// reads. At a ceiling of a million the fan-out trigger never fired and every
|
||||
// seal in a measured run came from entry pressure; at the default the run seals
|
||||
// on fan-out as well, which is the steady state the ceiling exists to impose
|
||||
// and the one a P2 number has to be of.
|
||||
//
|
||||
// A third bound stood here until B1 landed startup state 1: `StoreEngine::open`
|
||||
// refused to build an absent root, so this run seeded one with
|
||||
|
|
@ -3087,8 +3115,18 @@ fn skeleton_ack_record(sequence: u64) -> AckRecord {
|
|||
// `transaction_status` on a reopened engine rather than comparing sequence
|
||||
// sets.
|
||||
|
||||
/// Raised because index-delta sealing is unimplemented; see the note above.
|
||||
const ENGINE_MAX_INDEX_RUNS: u32 = 1_000_000;
|
||||
/// A ceiling above the store default, used **only** by the derivation's unit
|
||||
/// tests so the `raised_*` branch stays covered after the measured run stopped
|
||||
/// producing it.
|
||||
///
|
||||
/// No measured run configures this. It is not the value the old
|
||||
/// `ENGINE_MAX_INDEX_RUNS` had a right to: that one was applied to the store,
|
||||
/// and this one exists so a test can hand `RunConditions::derive` a raised
|
||||
/// observation and check it says so. Deleting it would leave the branch
|
||||
/// reachable only from a configuration nothing here produces, which is how a
|
||||
/// derivation quietly stops being a derivation.
|
||||
#[cfg(test)]
|
||||
const RAISED_MAX_INDEX_RUNS_FOR_DERIVATION_TESTS: u32 = 1_000_000;
|
||||
|
||||
/// One commit's objects, matching the frozen workload: one 1 KiB blob, one
|
||||
/// tree, one commit.
|
||||
|
|
@ -3289,6 +3327,17 @@ struct EngineRun {
|
|||
/// Group publications, counted as fences: `journal::append_group_and_fence`
|
||||
/// performs exactly one per group and A1's acceptance pins that.
|
||||
groups: u64,
|
||||
/// Checkpoints that completed inside the measured interval. Zero is a
|
||||
/// truthful answer for a run too short to reach the interval, and it is
|
||||
/// what keeps `checkpointing` at `enabled_not_reached` rather than
|
||||
/// promoting a run that never checkpointed.
|
||||
checkpoints_taken: u64,
|
||||
/// Commits in each whole one-minute window of the measured interval.
|
||||
///
|
||||
/// Whole windows only: a trailing partial minute is not a one-minute window
|
||||
/// and including it would report a rate over less than a minute as though
|
||||
/// it were one, which §3's rule is specifically about.
|
||||
window_commits: Vec<u64>,
|
||||
signing_micros_p50: f64,
|
||||
/// Signing samples taken inside the measured interval, on the same basis.
|
||||
signings: u64,
|
||||
|
|
@ -3315,6 +3364,7 @@ fn run_engine(
|
|||
seconds: u64,
|
||||
shard_count: u16,
|
||||
submitters_per_shard: usize,
|
||||
checkpoint_interval: Duration,
|
||||
) -> Result<EngineRun, String> {
|
||||
use levcs_store::segment::RootLayout;
|
||||
use levcs_store::transaction::StagedObject;
|
||||
|
|
@ -3358,7 +3408,7 @@ fn run_engine(
|
|||
options.max_group_bytes = 8 * 1024 * 1024;
|
||||
options.max_group_idle = Duration::from_millis(1);
|
||||
options.journal_preallocate_bytes = 64 * 1024 * 1024;
|
||||
options.max_index_runs = ENGINE_MAX_INDEX_RUNS;
|
||||
// Deliberately left at the store default. See the note above.
|
||||
options.signer = Some(signer.clone());
|
||||
options
|
||||
};
|
||||
|
|
@ -3488,7 +3538,64 @@ fn run_engine(
|
|||
let started = Instant::now();
|
||||
let deadline = started + Duration::from_secs(seconds.max(1));
|
||||
|
||||
// Checkpoints taken inside the measured interval, by a thread that lives
|
||||
// exactly as long as the submitters do.
|
||||
//
|
||||
// Inside the window on purpose. A checkpoint taken before or after it would
|
||||
// leave the measured seconds describing a store that never compacted its
|
||||
// index -- fan-out growing for the whole run -- which is the condition
|
||||
// scope §7's stop clause exists to exclude. Its cost is therefore *in* the
|
||||
// reported rate, which is the honest place for it: a P2 number that
|
||||
// excluded checkpoint cost would not describe a steady state either.
|
||||
// Commits per one-minute window, indexed by whole minutes since the window
|
||||
// opened. Counted here rather than reconstructed from per-transaction
|
||||
// timestamps: the §3 rule needs a rate per minute, and a counter per minute
|
||||
// is bounded by the run's length while a timestamp per commit is bounded by
|
||||
// its throughput -- 22 million of them at the P2 target.
|
||||
let window_commits: Mutex<Vec<u64>> = Mutex::new(Vec::new());
|
||||
let checkpoints_taken = AtomicU64::new(0);
|
||||
let checkpoint_failure: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
std::thread::scope(|scope| {
|
||||
{
|
||||
let engine = &engine;
|
||||
let stop = &stop;
|
||||
let checkpoints_taken = &checkpoints_taken;
|
||||
let checkpoint_failure = &checkpoint_failure;
|
||||
scope.spawn(move || {
|
||||
// Polled rather than slept in one block, so the thread leaves
|
||||
// promptly when the window closes instead of holding the scope
|
||||
// open for a whole interval past it.
|
||||
let mut next = Instant::now() + checkpoint_interval;
|
||||
while !stop.load(Ordering::Relaxed) && Instant::now() < deadline {
|
||||
if Instant::now() < next {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
continue;
|
||||
}
|
||||
match engine.checkpoint() {
|
||||
Ok(_) => {
|
||||
checkpoints_taken.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
// Recorded and fatal to the run rather than retried. A
|
||||
// checkpoint that fails mid-measurement leaves the
|
||||
// store in a state the bundle would have to describe,
|
||||
// and continuing would report a rate for a run whose
|
||||
// index maintenance stopped working partway.
|
||||
Err(error) => {
|
||||
let mut slot =
|
||||
checkpoint_failure.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if slot.is_none() {
|
||||
*slot = Some(format!("{error:?}"));
|
||||
}
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
next = Instant::now() + checkpoint_interval;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for shard in 0..shard_count {
|
||||
for submitter in 0..submitters_per_shard {
|
||||
let namespace = namespaces[shard as usize];
|
||||
|
|
@ -3501,6 +3608,7 @@ fn run_engine(
|
|||
let unaccounted = &unaccounted;
|
||||
let results = &results;
|
||||
let latencies = &latencies;
|
||||
let window_commits = &window_commits;
|
||||
let objects_new = &objects_new;
|
||||
let raw_bytes = &raw_bytes;
|
||||
scope.spawn(move || {
|
||||
|
|
@ -3662,6 +3770,19 @@ fn run_engine(
|
|||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.push(micros);
|
||||
{
|
||||
// The window this commit landed in, from the
|
||||
// same clock the deadline uses. A commit is
|
||||
// counted when it is acknowledged, which is
|
||||
// what the rate is a rate of.
|
||||
let minute = started.elapsed().as_secs() / 60;
|
||||
let mut windows =
|
||||
window_commits.lock().unwrap_or_else(|p| p.into_inner());
|
||||
if windows.len() <= minute as usize {
|
||||
windows.resize(minute as usize + 1, 0);
|
||||
}
|
||||
windows[minute as usize] += 1;
|
||||
}
|
||||
results.lock().unwrap_or_else(|p| p.into_inner()).push((
|
||||
namespace,
|
||||
operation,
|
||||
|
|
@ -3763,6 +3884,26 @@ fn run_engine(
|
|||
signing.sort_unstable();
|
||||
let signing_p50 = percentile(&signing, 0.50) as f64;
|
||||
|
||||
// One last checkpoint, **after** the measured window has closed.
|
||||
//
|
||||
// A steady-state run is publishing deltas continuously and sealing them
|
||||
// periodically, so at the instant the window closes there is essentially
|
||||
// always a partial backlog -- it is an artifact of where the clock stopped,
|
||||
// not a statement about whether maintenance kept pace. `index_maintenance`
|
||||
// requires a drained backlog to declare `runs_sealed`, and that check is
|
||||
// worth keeping strict: the alternative is asserting a steady state while
|
||||
// carrying a backlog the bundle has no field to report.
|
||||
//
|
||||
// Draining here reconciles the two without weakening either. It is outside
|
||||
// the measured interval, so it changes no reported rate; the checkpoints
|
||||
// that establish maintenance kept pace are the periodic ones *inside* it,
|
||||
// counted separately and reported as `checkpointing`.
|
||||
if checkpoints_taken.load(Ordering::Relaxed) > 0 {
|
||||
engine
|
||||
.checkpoint()
|
||||
.map_err(|error| format!("the closing checkpoint failed: {error:?}"))?;
|
||||
}
|
||||
|
||||
drop(ack);
|
||||
drop(engine);
|
||||
|
||||
|
|
@ -3818,7 +3959,15 @@ fn run_engine(
|
|||
// What the store answers about checkpoints, asked of the store rather than
|
||||
// asserted about it. Asked after the reconciliation so a checkpoint the day
|
||||
// this stops refusing cannot move what the reconciliation read.
|
||||
let checkpoint = probe_checkpointing(&reopened)?;
|
||||
// `Exercised` comes from the run, never from the probe: the probe can only
|
||||
// establish that a checkpoint is *possible*, and the field is about whether
|
||||
// this measurement contains one. A run that took none falls through to the
|
||||
// probe and reports `enabled_not_reached`, which cannot pass.
|
||||
let checkpoint = if checkpoints_taken.load(Ordering::Relaxed) > 0 {
|
||||
CheckpointProbe::Exercised
|
||||
} else {
|
||||
probe_checkpointing(&reopened)?
|
||||
};
|
||||
// Read from the live engine, after the checkpoint probe, so it describes
|
||||
// the state the bundle is about — and before the handle is dropped, because
|
||||
// the reading only exists while the root is open.
|
||||
|
|
@ -3854,7 +4003,29 @@ fn run_engine(
|
|||
// as index runs rather than counted as directory entries.
|
||||
let index_scan = scan_index_runs(root, shard_count);
|
||||
|
||||
// A checkpoint that failed mid-window invalidates the measurement rather
|
||||
// than reducing it: the reported rate would be for a run whose index
|
||||
// maintenance stopped partway, which is not a steady state either.
|
||||
if let Some(error) = checkpoint_failure.into_inner().unwrap_or(None) {
|
||||
return Err(format!(
|
||||
"a checkpoint failed inside the measured interval: {error}. The run is \
|
||||
discarded rather than reported, because its rate would describe a store \
|
||||
that stopped maintaining its index partway through."
|
||||
));
|
||||
}
|
||||
|
||||
// Whole windows only. The run stops at its deadline, so the last bucket is
|
||||
// almost always a fraction of a minute; keeping it would divide a partial
|
||||
// minute's commits by sixty seconds and report a rate no window achieved.
|
||||
let mut window_commits = window_commits
|
||||
.into_inner()
|
||||
.unwrap_or_else(|p| p.into_inner());
|
||||
let whole_windows = (elapsed.as_secs() / 60) as usize;
|
||||
window_commits.truncate(whole_windows);
|
||||
|
||||
Ok(EngineRun {
|
||||
window_commits,
|
||||
checkpoints_taken: checkpoints_taken.load(Ordering::Relaxed),
|
||||
facts: RunFacts {
|
||||
initialization,
|
||||
mutation: MutationPath::StoreEngineSubmit,
|
||||
|
|
@ -3962,6 +4133,14 @@ fn check_global_uniqueness(records: &[AckRecord]) -> Result<UniquenessCheck, Str
|
|||
struct MeasuredRun {
|
||||
latencies_micros: Vec<u64>,
|
||||
groups: u64,
|
||||
/// Checkpoints completed inside the measured interval. The journal seam has
|
||||
/// no engine and therefore no checkpoint, so it is always zero there --
|
||||
/// which is why the drive path declares `unimplemented` rather than this.
|
||||
checkpoints_taken: u64,
|
||||
/// Commits per whole one-minute window, empty for a run shorter than a
|
||||
/// minute. Empty is what makes the §3 rule unevaluable rather than
|
||||
/// vacuously satisfied.
|
||||
window_commits: Vec<u64>,
|
||||
transactions: u64,
|
||||
/// Counted from the objects the store actually staged on the submit path,
|
||||
/// and derived as `transactions * 3` on the drive path, where there are no
|
||||
|
|
@ -3999,6 +4178,9 @@ struct MeasuredRun {
|
|||
impl From<SkeletonRun> for MeasuredRun {
|
||||
fn from(run: SkeletonRun) -> Self {
|
||||
Self {
|
||||
// The journal seam has no engine to checkpoint.
|
||||
checkpoints_taken: 0,
|
||||
window_commits: Vec::new(),
|
||||
groups: run.groups,
|
||||
transactions: run.transactions,
|
||||
objects_new: run.transactions * OBJECTS_PER_COMMIT,
|
||||
|
|
@ -4032,6 +4214,8 @@ impl From<SkeletonRun> for MeasuredRun {
|
|||
impl From<EngineRun> for MeasuredRun {
|
||||
fn from(run: EngineRun) -> Self {
|
||||
Self {
|
||||
checkpoints_taken: run.checkpoints_taken,
|
||||
window_commits: run.window_commits.clone(),
|
||||
groups: run.groups,
|
||||
transactions: run.transactions,
|
||||
objects_new: run.objects_new,
|
||||
|
|
@ -4309,24 +4493,27 @@ fn dispatch(subcommand: &str, flags: &Flags) -> Result<ExitCode, String> {
|
|||
}
|
||||
}
|
||||
} else if subcommand == "emit-skeleton" {
|
||||
emit_skeleton(&repo_root, &workload, &profile, flags)
|
||||
emit_bundle(&repo_root, &workload, &profile, flags, true)
|
||||
} else if subcommand == "run" {
|
||||
Err(
|
||||
"the P2 run goes through StoreEngine::submit, which is B1 NamespaceTxn \
|
||||
(scope 6-B1). Wave A can produce a P1-micro number and a skeleton \
|
||||
bundle; it cannot produce a P2 result."
|
||||
.to_string(),
|
||||
)
|
||||
emit_bundle(&repo_root, &workload, &profile, flags, false)
|
||||
} else {
|
||||
Err(format!("unknown subcommand {subcommand:?}\n\n{USAGE}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_skeleton(
|
||||
/// Emit one bundle, either as a skeleton or as a measured P2 run.
|
||||
///
|
||||
/// One function for both because the two must not drift: a skeleton whose
|
||||
/// measurement block is assembled by different code from the run's would let a
|
||||
/// field be checked in rehearsal and unchecked in the campaign. What differs is
|
||||
/// stated in `skeleton` and nowhere else -- the window arithmetic below, and
|
||||
/// the `skeleton` flag the outcome rule reads.
|
||||
fn emit_bundle(
|
||||
repo_root: &Path,
|
||||
workload: &FrozenWorkload,
|
||||
profile: &FrozenProfile,
|
||||
flags: &Flags,
|
||||
skeleton: bool,
|
||||
) -> Result<ExitCode, String> {
|
||||
if flags.get("allow-unsigned").is_none() {
|
||||
return Err(
|
||||
|
|
@ -4341,8 +4528,24 @@ fn emit_skeleton(
|
|||
|
||||
let root = PathBuf::from(flags.required("root")?);
|
||||
let out = PathBuf::from(flags.required("out")?);
|
||||
let seconds = flags.number::<u64>("seconds", 2)?;
|
||||
// A skeleton is a rehearsal and defaults to seconds; a measured run defaults
|
||||
// to the frozen `p2_p3_measured_seconds`, so the campaign's length comes
|
||||
// from the workload rather than from whatever the caller typed.
|
||||
let seconds = flags.number::<u64>(
|
||||
"seconds",
|
||||
if skeleton {
|
||||
2
|
||||
} else {
|
||||
workload.measured_seconds
|
||||
},
|
||||
)?;
|
||||
let group_len = flags.number::<usize>("group-len", 16)?;
|
||||
// How often the measured window takes a checkpoint. The default is short
|
||||
// enough that even a brief run reaches one, because a run that silently
|
||||
// never checkpointed is exactly the state scope §7 forbids a P2 number from
|
||||
// having been obtained in.
|
||||
let checkpoint_interval =
|
||||
Duration::from_millis(flags.number::<u64>("checkpoint-interval-millis", 1_000)?);
|
||||
|
||||
// Precheck 1 runs against the skeleton's own budget, not P2's: the point
|
||||
// is to exercise the code path and the arithmetic, and demanding 280 GB
|
||||
|
|
@ -4373,7 +4576,14 @@ fn emit_skeleton(
|
|||
let shards = flags.number::<u16>("shards", 4)?;
|
||||
let submitters = flags.number::<usize>("submitters-per-shard", group_len.max(1))?;
|
||||
run_engine(
|
||||
&root, &ack_path, ack_fault, group_len, seconds, shards, submitters,
|
||||
&root,
|
||||
&ack_path,
|
||||
ack_fault,
|
||||
group_len,
|
||||
seconds,
|
||||
shards,
|
||||
submitters,
|
||||
checkpoint_interval,
|
||||
)?
|
||||
.into()
|
||||
} else if path == "drive" {
|
||||
|
|
@ -4437,6 +4647,52 @@ fn emit_skeleton(
|
|||
0.0
|
||||
};
|
||||
|
||||
// The §3 window rule, computed from whole one-minute windows the run
|
||||
// actually recorded.
|
||||
//
|
||||
// A skeleton has none -- it is shorter than a minute by design -- so it
|
||||
// reports the single whole-run rate and a trivially-satisfied percentage,
|
||||
// and its `skeleton` flag is what stops the outcome rule reading that as a
|
||||
// pass. A measured run with no whole window is refused outright rather than
|
||||
// falling back to the same synthesis: a P2 bundle whose window rule was
|
||||
// evaluated over a synthesized window would state the rule as met without
|
||||
// having tested it.
|
||||
let target_rate = flags.number::<u64>("target-rate", 75_000)? as f64;
|
||||
let (window_rates, windows_meeting_target) = if run.window_commits.is_empty() {
|
||||
if !skeleton {
|
||||
return Err(format!(
|
||||
"refusing to emit a measured bundle: the run recorded no whole one-minute \
|
||||
window (it ran for {seconds}s). Section 3's rule is about one-minute \
|
||||
windows, and a run too short to contain one cannot have met it. Pass \
|
||||
--seconds 60 or more, or emit-skeleton if a rehearsal was intended."
|
||||
));
|
||||
}
|
||||
(vec![rate], 100.0)
|
||||
} else {
|
||||
let rates: Vec<f64> = run
|
||||
.window_commits
|
||||
.iter()
|
||||
.map(|commits| *commits as f64 / 60.0)
|
||||
.collect();
|
||||
// Both halves of the rule. The percentage is what the schema records;
|
||||
// the floor is checked here because a single window below 90% of target
|
||||
// fails §3 outright however good the percentage is, and nothing
|
||||
// downstream would notice it.
|
||||
let meeting = rates.iter().filter(|r| **r >= target_rate).count();
|
||||
let percent = (meeting as f64 / rates.len() as f64) * 100.0;
|
||||
let floor = target_rate * 0.90;
|
||||
let percent = if rates.iter().any(|r| *r < floor) {
|
||||
// Reported as a failure of the rule rather than as a separate
|
||||
// field, because the schema has one number for it and a bundle that
|
||||
// passed the percentage while dipping below the floor must not read
|
||||
// as having met the rule.
|
||||
0.0
|
||||
} else {
|
||||
percent
|
||||
};
|
||||
(rates, percent)
|
||||
};
|
||||
|
||||
let mut histogram_input = String::new();
|
||||
for value in &sorted {
|
||||
let _ = write!(histogram_input, "{value},");
|
||||
|
|
@ -4479,14 +4735,8 @@ fn emit_skeleton(
|
|||
latency_p99: percentile(&sorted, 0.99),
|
||||
latency_max: sorted.last().copied().unwrap_or(0),
|
||||
histogram_digest: digest_hex(histogram_input.as_bytes()),
|
||||
// A run shorter than a minute has no one-minute windows. What is
|
||||
// reported is the single whole-run rate, and the percentage is
|
||||
// therefore trivially 100 — which is why the bundle's `outcome` is
|
||||
// `preliminary` and its own gate verdict `not-applicable`. A P2 run
|
||||
// computes real windows; nothing here should be read as having met the
|
||||
// section 3 window rule.
|
||||
one_minute_windows: vec![rate],
|
||||
windows_meeting_target_percent: 100.0,
|
||||
one_minute_windows: window_rates.clone(),
|
||||
windows_meeting_target_percent: windows_meeting_target,
|
||||
ack_journal_digest: run.ack_journal_digest.clone(),
|
||||
acknowledged_loss: run.acknowledged_loss,
|
||||
torn_transactions: run.torn_transactions,
|
||||
|
|
@ -5127,9 +5377,14 @@ sys.exit(1 if errors else 0)
|
|||
assert_eq!(submit.initialization_path, "store_engine_open");
|
||||
assert_eq!(submit.mutation_path, "store_engine_submit");
|
||||
assert_eq!(
|
||||
submit.index_run_ceiling, "raised_because_index_sealing_unimplemented",
|
||||
"the submit run raises max_index_runs above the store default, and the \
|
||||
declaration must follow from that comparison rather than from the path"
|
||||
submit.index_run_ceiling, "raised_above_store_default",
|
||||
"this fixture configures max_index_runs above the store default, and the \
|
||||
declaration must follow from that comparison rather than from the path. \
|
||||
The measured submit run no longer raises it -- see \
|
||||
`a_run_at_the_store_default_declares_store_default` -- so this is the \
|
||||
only remaining cover for the raised branch. It must be the reason-free \
|
||||
value: this branch compares two numbers and cannot know why they differ \
|
||||
(contract review 2026-08-09-B)"
|
||||
);
|
||||
assert_eq!(submit.index_maintenance, "deltas_retained_in_memory");
|
||||
assert_eq!(
|
||||
|
|
@ -5181,6 +5436,31 @@ sys.exit(1 if errors else 0)
|
|||
);
|
||||
}
|
||||
|
||||
/// The measured submit run stopped raising `max_index_runs` once index
|
||||
/// sealing landed, so the value it now declares is `store_default`. This
|
||||
/// asserts the derivation follows the observation to that value rather
|
||||
/// than to the path it came from.
|
||||
///
|
||||
/// It is the other half of the pair: the fixture in
|
||||
/// `the_ten_run_conditions_are_derived_from_what_the_run_observed` keeps
|
||||
/// the raised branch covered, and this covers the branch a real run now
|
||||
/// takes. Neither is redundant, because the derivation is a comparison and
|
||||
/// a comparison needs both sides exercised.
|
||||
#[test]
|
||||
fn a_run_at_the_store_default_declares_store_default() {
|
||||
let mut at_default = submit_observations();
|
||||
at_default.configured_max_index_runs = at_default.default_max_index_runs;
|
||||
|
||||
let conditions = RunConditions::derive(&at_default, true, "diagnostic")
|
||||
.expect("a store-default ceiling declares");
|
||||
|
||||
assert_eq!(
|
||||
conditions.index_run_ceiling, "store_default",
|
||||
"a run that configured exactly the store default must declare it, \
|
||||
whatever path it ran"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_observation_with_no_named_value_is_refused_rather_than_guessed() {
|
||||
// Charter item 6 at the emitter: the schema's enumerations are closed
|
||||
|
|
@ -5372,8 +5652,19 @@ sys.exit(1 if errors else 0)
|
|||
let directory = tempfile::tempdir().expect("tempdir");
|
||||
let root = directory.path().join("root");
|
||||
let ack = directory.path().join("ack-journal");
|
||||
let run = run_engine(&root, &ack, AckJournalFault::None, 4, 1, 2, 1)
|
||||
.expect("an engine-driven run");
|
||||
// A one-second run with a half-second interval, so the measured window
|
||||
// contains a checkpoint rather than depending on the default cadence.
|
||||
let run = run_engine(
|
||||
&root,
|
||||
&ack,
|
||||
AckJournalFault::None,
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
Duration::from_millis(500),
|
||||
)
|
||||
.expect("an engine-driven run");
|
||||
|
||||
// The exclusion is not vacuous: creating the repositories really did
|
||||
// fence and really did sign, so there is something to exclude. Without
|
||||
|
|
@ -5454,6 +5745,7 @@ sys.exit(1 if errors else 0)
|
|||
1,
|
||||
2,
|
||||
0,
|
||||
Duration::from_secs(3_600),
|
||||
)
|
||||
.err()
|
||||
.expect("a run with no submitter measures nothing");
|
||||
|
|
@ -5468,6 +5760,7 @@ sys.exit(1 if errors else 0)
|
|||
1,
|
||||
0,
|
||||
1,
|
||||
Duration::from_secs(3_600),
|
||||
)
|
||||
.err()
|
||||
.expect("no shard is no repository and no transaction");
|
||||
|
|
@ -6013,7 +6306,7 @@ sys.exit(1 if errors else 0)
|
|||
refuses(
|
||||
&submit,
|
||||
submit.replace(
|
||||
"\"index_run_ceiling\": \"raised_because_index_sealing_unimplemented\"",
|
||||
"\"index_run_ceiling\": \"raised_above_store_default\"",
|
||||
"\"index_run_ceiling\": \"store_default\"",
|
||||
),
|
||||
"declaring the store default while recording a raised ceiling must be \
|
||||
|
|
@ -6024,7 +6317,7 @@ sys.exit(1 if errors else 0)
|
|||
&bundle,
|
||||
bundle.replace(
|
||||
"\"index_run_ceiling\": \"store_default\"",
|
||||
"\"index_run_ceiling\": \"raised_because_index_sealing_unimplemented\"",
|
||||
"\"index_run_ceiling\": \"raised_above_store_default\"",
|
||||
),
|
||||
"declaring a raise while recording the default must be rejected in the \
|
||||
other direction",
|
||||
|
|
@ -6235,7 +6528,7 @@ sys.exit(1 if errors else 0)
|
|||
groups: 1,
|
||||
unsealed_delta_backlog: None,
|
||||
},
|
||||
configured_max_index_runs: ENGINE_MAX_INDEX_RUNS,
|
||||
configured_max_index_runs: RAISED_MAX_INDEX_RUNS_FOR_DERIVATION_TESTS,
|
||||
default_max_index_runs: default_max_index_runs(),
|
||||
receipts: ReceiptComparison::AnyCommittedStatusAccepted,
|
||||
objects_new_counted: true,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1126,6 +1126,36 @@ impl IndexRun {
|
|||
/// at all — but a section covering the range does not mean an entry in it
|
||||
/// does, and answering `true` on the range alone would turn recoverable
|
||||
/// roots into outages for a generation no entry mentions.
|
||||
/// Every entry this run holds, in stored order.
|
||||
///
|
||||
/// Granted to B1 by contract review 2026-08-09-C, for index-run compaction.
|
||||
/// Merging sealed runs is the only way the open-run count stays under
|
||||
/// `max_open_index_runs` across a measured campaign -- nothing else in the
|
||||
/// store ever removes a run -- and a merge cannot be written against an API
|
||||
/// that answers only point lookups.
|
||||
///
|
||||
/// Read-only and additive: no byte of the format moves, and the order is
|
||||
/// the one already on the device (sections ascending by namespace, entries
|
||||
/// ascending within a section), so a caller that re-encodes what it reads
|
||||
/// produces the same layout it consumed.
|
||||
///
|
||||
/// Deliberately not `Iterator`-returning-`Result`: every value here is
|
||||
/// decoded from bytes this run already validated at `open`, so there is no
|
||||
/// per-entry failure to report. A run that could not be trusted entry by
|
||||
/// entry is one that should not have opened.
|
||||
pub fn entries(&self) -> impl Iterator<Item = (IndexKey, IndexLocation)> + '_ {
|
||||
(0..self.section_count).flat_map(move |i| {
|
||||
let section = self.section(i);
|
||||
(0..section.entry_count).map(move |j| {
|
||||
let at = section.first_entry + j;
|
||||
(
|
||||
IndexKey::new(section.namespace, self.entry_object(at)),
|
||||
self.entry_location(at, §ion),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn references_segment_generation(&self, generation: u64) -> bool {
|
||||
for i in 0..self.section_count {
|
||||
let section = self.section(i);
|
||||
|
|
@ -1322,6 +1352,60 @@ impl ObjectIndex {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Everything `entries` yields is what `get` answers for the same key, and
|
||||
/// it yields every entry the run counts.
|
||||
///
|
||||
/// Asserted against the run's own point lookup rather than against the
|
||||
/// delta it was built from: the delta is what the encoder was given, and
|
||||
/// the question is whether the decoder reads back what was written. A merge
|
||||
/// built on an iterator that disagreed with `get` would produce a run whose
|
||||
/// entries moved.
|
||||
#[test]
|
||||
fn entries_yields_every_entry_and_agrees_with_get() {
|
||||
let mut delta = IndexDelta::new(1_000, 1 << 20);
|
||||
let mut expected = Vec::new();
|
||||
// Several namespaces, so the walk crosses section boundaries, and
|
||||
// several generations within each, so the packed deltas vary.
|
||||
for ns in 0..4u8 {
|
||||
for object in 0..7u8 {
|
||||
let key = IndexKey::new(NamespaceId([ns; 32]), ObjectId([ns * 16 + object; 32]));
|
||||
let location = IndexLocation {
|
||||
segment_generation: 40 + u64::from(object),
|
||||
frame_offset: 512 * u64::from(object),
|
||||
frame_len: 128 + u32::from(object),
|
||||
object_type: 1,
|
||||
shard_sequence: 900 + u64::from(object),
|
||||
};
|
||||
delta.insert(key, location).expect("insert");
|
||||
expected.push((key, location));
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = IndexRunBuilder::new(ROOT, 9, 9)
|
||||
.build(&delta)
|
||||
.expect("build");
|
||||
let run = IndexRun::from_bytes(RunBytes::Owned(bytes), &ROOT).expect("open");
|
||||
|
||||
let seen: Vec<_> = run.entries().collect();
|
||||
assert_eq!(
|
||||
seen.len() as u64,
|
||||
run.entry_count(),
|
||||
"the iterator must yield exactly the entries the run counts"
|
||||
);
|
||||
for (key, location) in &seen {
|
||||
assert_eq!(
|
||||
run.get(key),
|
||||
Some(*location),
|
||||
"entries and get disagree for {key:?}"
|
||||
);
|
||||
}
|
||||
|
||||
expected.sort_by_key(|(key, _)| *key);
|
||||
let mut sorted = seen.clone();
|
||||
sorted.sort_by_key(|(key, _)| *key);
|
||||
assert_eq!(sorted, expected, "every inserted entry must be read back");
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
const ROOT: [u8; 16] = [7u8; 16];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -2320,6 +2320,17 @@ fn recover_shard_under_lock(
|
|||
// The frame's own sequence is the adoption's identity, and the only
|
||||
// authority that creates one: staging has no durable position for a pin
|
||||
// that transferred across a process boundary.
|
||||
// The same materialization the live adopter performs, replayed. A
|
||||
// process that stopped between the fence and materialization left some
|
||||
// prefix of this frame's runs on disk, and possibly none; recomputing
|
||||
// them from the same committed inputs either finds identical bytes or
|
||||
// writes what is missing. That is what makes that window recoverable
|
||||
// rather than a state recovery can only report.
|
||||
resolver.materialize_committed_index_runs(
|
||||
frame.facts.namespace,
|
||||
descriptor,
|
||||
frame.facts.shard_sequence,
|
||||
)?;
|
||||
let artifacts = resolver.resolve_committed(
|
||||
frame.facts.namespace,
|
||||
descriptor,
|
||||
|
|
@ -3204,6 +3215,18 @@ mod production_session_tests {
|
|||
Ok(Arc::from([self.descriptor.session_id, self.absent_session]))
|
||||
}
|
||||
|
||||
/// The double's runs are whatever its fixture already holds, so
|
||||
/// materialization has nothing to create. Recording that it *ran* is
|
||||
/// what the callers under test care about; producing bytes is not.
|
||||
fn materialize_committed_index_runs(
|
||||
&self,
|
||||
_namespace: NamespaceId,
|
||||
_descriptor: &StagedProjectionInstallV1,
|
||||
_adoption_shard_sequence: u64,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_committed(
|
||||
&self,
|
||||
namespace: NamespaceId,
|
||||
|
|
|
|||
|
|
@ -383,6 +383,19 @@ impl RetainedGeneration {
|
|||
/// shard and upper sequence so installing a sealed run can discard exactly
|
||||
/// the layers that run covers instead of allowing lookup fan-out to grow once
|
||||
/// per committed group forever.
|
||||
///
|
||||
/// # A layer is coverage, not content
|
||||
///
|
||||
/// `through_shard_sequence` says "every frame from this shard through here is
|
||||
/// accounted for in the index". It is not a claim that this layer holds an
|
||||
/// entry for any of them, and a layer with an empty delta is a well-formed
|
||||
/// statement rather than a degenerate one: a group whose transactions
|
||||
/// introduced no objects — an adopting transaction, whose objects live in
|
||||
/// staging's artifacts, or a ref-only transaction — has genuinely nothing to
|
||||
/// index and still has a sequence that must be represented. Checkpointing
|
||||
/// reads these stamps to decide whether pruning would strand objects, so a
|
||||
/// committed sequence that no layer reaches reads as a gap in the index rather
|
||||
/// than as an absence of work.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct IndexDeltaLayer {
|
||||
pub shard_index: u16,
|
||||
|
|
@ -483,7 +496,29 @@ impl LayeredObjectIndex {
|
|||
self.lookup(key).location
|
||||
}
|
||||
|
||||
fn with_subtree(&self, subtree: &ShardSubtree) -> Self {
|
||||
/// Compose one publication onto this index.
|
||||
///
|
||||
/// `advances_committed_sequence` is what decides whether a layer with no
|
||||
/// entries is still installed. A layer records **publication coverage**,
|
||||
/// not the presence of entries: it is the statement "every frame from this
|
||||
/// shard through `through_shard_sequence` is accounted for in the index",
|
||||
/// and a group that introduced no objects makes that statement truthfully
|
||||
/// with nothing in it.
|
||||
///
|
||||
/// Skipping empty deltas — the earlier behaviour — left the committed
|
||||
/// sequence advancing while coverage did not, so the next checkpoint
|
||||
/// refused: it read the highest layer stamp as the reach of the index and
|
||||
/// concluded that pruning would drop objects that never existed. Any
|
||||
/// transaction introducing no objects reached it, and an adopting
|
||||
/// transaction reaches it as the ordinary case, because its frame carries a
|
||||
/// descriptor rather than object bytes and its membership may resolve
|
||||
/// entirely into sealed runs.
|
||||
///
|
||||
/// The empty layer counts toward layer fan-out and may later seal into a
|
||||
/// zero-entry run. That is the bounded-run model working as designed, and
|
||||
/// it is the failure-safe direction: a stamped empty layer costs a run
|
||||
/// slot, while an unstamped one costs the ability to checkpoint at all.
|
||||
fn with_subtree(&self, subtree: &ShardSubtree, advances_committed_sequence: bool) -> Self {
|
||||
let mut next = self.clone();
|
||||
if let Some(sealed_through) = subtree.sealed_through_shard_sequence {
|
||||
next.delta_layers_newest_first.retain(|layer| {
|
||||
|
|
@ -494,7 +529,7 @@ impl LayeredObjectIndex {
|
|||
let delta_is_sealed = subtree
|
||||
.sealed_through_shard_sequence
|
||||
.is_some_and(|sealed_through| sealed_through >= subtree.shard_committed_sequence);
|
||||
if !delta_is_sealed && !subtree.index_delta.is_empty() {
|
||||
if !delta_is_sealed && (!subtree.index_delta.is_empty() || advances_committed_sequence) {
|
||||
next.delta_layers_newest_first
|
||||
.push_front(IndexDeltaLayer::new(
|
||||
subtree.shard_index,
|
||||
|
|
@ -732,9 +767,19 @@ impl CommittedRoot {
|
|||
retained_generations.insert(*id, Arc::clone(generation));
|
||||
}
|
||||
|
||||
// Whether this publication moves the shard's committed frame sequence
|
||||
// forward, which is what obliges it to record coverage. A maintenance
|
||||
// publication appends no frame and republishes the sequence it found,
|
||||
// so it owes nothing.
|
||||
let advances_committed_sequence = self
|
||||
.shard_committed_sequence(subtree.shard_index)
|
||||
.is_none_or(|published| subtree.shard_committed_sequence > published);
|
||||
|
||||
Self {
|
||||
repositories,
|
||||
index: self.index.with_subtree(subtree),
|
||||
index: self
|
||||
.index
|
||||
.with_subtree(subtree, advances_committed_sequence),
|
||||
terminal_statuses,
|
||||
shard_committed_sequences,
|
||||
retained_generations,
|
||||
|
|
|
|||
|
|
@ -7,14 +7,71 @@
|
|||
//! 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, RetainedObjectSource};
|
||||
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,
|
||||
/// The shard this namespace routes to, which is the space its logical
|
||||
/// generation numbers are unique within. Carried so
|
||||
/// [`RepoSnapshot::object_source`] can resolve a location without the
|
||||
/// caller re-deriving the routing.
|
||||
shard_index: u16,
|
||||
/// 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 +85,565 @@ 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.
|
||||
///
|
||||
/// `shard_index` is supplied rather than derived because deriving it needs
|
||||
/// the shard count, which lives in [`crate::StoreOptions`] and not in the
|
||||
/// root. It is well defined for a snapshot: a namespace routes to exactly
|
||||
/// one shard, so every frame and every index entry it owns lives in that
|
||||
/// shard's generations.
|
||||
pub(crate) fn capture(
|
||||
root: Arc<CommittedRoot>,
|
||||
namespace: NamespaceId,
|
||||
shard_index: u16,
|
||||
) -> Result<Self, StoreError> {
|
||||
let Some(state) = root.repo(&namespace) else {
|
||||
return Err(StoreError::NoSuchRepository { namespace });
|
||||
};
|
||||
let state = Arc::clone(state);
|
||||
Ok(Self {
|
||||
root,
|
||||
namespace,
|
||||
shard_index,
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Resolve a location's decoder and live pin.
|
||||
///
|
||||
/// [`Self::locate`] answers *which generation*, which is not enough to read
|
||||
/// anything: a logical generation is a segment, an active journal tail, or
|
||||
/// an adopted projection artifact, and the three are different files in
|
||||
/// different formats. Before adoption existed every location a reader could
|
||||
/// hold was a frame in this shard's journal, so the distinction was
|
||||
/// invisible and the generation number alone was serviceable. An adopted
|
||||
/// object is indexed at its artifact's generation and its bytes are not in
|
||||
/// the frame that installed it, so answering "where" without answering
|
||||
/// "what kind" is now an answer a reader cannot act on.
|
||||
///
|
||||
/// `Ok(None)` means no retained generation in this shard claims that
|
||||
/// number. For a location this snapshot just produced that is corruption in
|
||||
/// waiting, not an ordinary absence — but it is reported the same way the
|
||||
/// root reports it, because the root is what decides retention and this is
|
||||
/// a view onto the root, not a second opinion about it.
|
||||
pub fn object_source(
|
||||
&self,
|
||||
location: &ObjectLocation,
|
||||
) -> Result<Option<RetainedObjectSource<'_>>, StoreError> {
|
||||
self.root
|
||||
.object_source(self.shard_index, location.segment_generation)
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
};
|
||||
|
||||
/// The shard every root below is built in. Nothing here asserts about
|
||||
/// generation resolution, so the value only has to be the one constant
|
||||
/// these hand-built roots agree on.
|
||||
const SHARD: u16 = 0;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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, SHARD).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, SHARD).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, SHARD).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, SHARD)
|
||||
.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, SHARD).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, SHARD)
|
||||
.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, SHARD)
|
||||
.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, SHARD));
|
||||
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, SHARD).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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -19,11 +19,14 @@
|
|||
//! a builder supplied.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use levcs_core::{ObjectId, ObjectType};
|
||||
use levcs_protocol::v2::{RefTarget, TransactionEvidenceV1, TypedRefCas};
|
||||
use levcs_protocol::v2::{
|
||||
RefTarget, StagedProjectionInstallV1, TransactionEvidenceV1, TypedRefCas,
|
||||
};
|
||||
|
||||
use crate::staging::{ProjectionAdoptionOutcome, StagedProjectionAdoption};
|
||||
use crate::staging::{ProjectionAdoption, ProjectionAdoptionOutcome, StagedProjectionAdoption};
|
||||
use crate::types::{NamespaceId, OperationId, PrivilegedConstruction, StoreError};
|
||||
|
||||
/// The immutable output of the instance pipeline's stages 4-8 (plan §7).
|
||||
|
|
@ -49,6 +52,166 @@ pub struct ValidatedTransaction {
|
|||
pub(crate) expected_authority: Option<ObjectId>,
|
||||
pub(crate) new_authority: Option<ObjectId>,
|
||||
pub(crate) evidence: TransactionEvidenceV1,
|
||||
/// The canonical descriptor of an adopted projection, kept here rather than
|
||||
/// beside the pin because the frame is rebuilt from this value on **every**
|
||||
/// sequencing attempt. A pre-mark deadline recheck that drops a group member
|
||||
/// re-sequences, re-chains, and re-signs everything after it, and a
|
||||
/// descriptor that had left with the pin would leave the retry with no
|
||||
/// payload to encode.
|
||||
pub(crate) projection: Option<StagedProjectionInstallV1>,
|
||||
/// The live pin, and the one field of this struct that is not reproducible.
|
||||
///
|
||||
/// `Option` because the writer **takes** it, exactly once, before wrapping
|
||||
/// this transaction in the `Arc` that survives re-sequencing — an `Arc` has
|
||||
/// no move-out, so a pin left in here would be unreachable at the moment it
|
||||
/// has to be settled. From that point the pin lives in an `AdoptionSlot`
|
||||
/// beside the `Arc`, and `projection` above is what the retries read.
|
||||
pub(crate) adoption_pin: Option<AdoptionSlot>,
|
||||
}
|
||||
|
||||
impl ValidatedTransaction {
|
||||
/// Take the slot out on its way into the writer.
|
||||
///
|
||||
/// Called once, before `Arc::new`. Calling it twice yields `None` rather
|
||||
/// than a second capability: the pin is linear and there is only ever one.
|
||||
pub(crate) fn take_adoption(&mut self) -> Option<AdoptionSlot> {
|
||||
self.adoption_pin.take()
|
||||
}
|
||||
|
||||
/// Read by the writer when it encodes the frame payload. See the note on
|
||||
/// [`AppendPhase`] for why it is unreached today.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn projection(&self) -> Option<&StagedProjectionInstallV1> {
|
||||
self.projection.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// How far this transaction's frame has gone toward the device.
|
||||
///
|
||||
/// Only two values, because only two answers matter to a pin that has to be
|
||||
/// settled without knowing why: either nothing of this frame reached the
|
||||
/// journal, or something may have.
|
||||
// `Appended` is constructed only by the writer's append path, and the parts of
|
||||
// `AdoptionSlot` below that the writer alone calls are likewise unreached until
|
||||
// `StoreEngine::submit` stops refusing an adoption. Both are exercised by this
|
||||
// module's tests; the allow covers the non-test build only.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum AppendPhase {
|
||||
/// No byte of this transaction's frame has been written. An adoption
|
||||
/// abandoned here is definitively not installed.
|
||||
BeforeAppend,
|
||||
/// A write has been attempted. **Not** "a fence succeeded" — the
|
||||
/// transition is at the first possible frame write, so torn bytes and whole
|
||||
/// unfenced frames are both on this side of it. Recovery, which can read
|
||||
/// the device, is the only thing that can say which happened.
|
||||
Appended,
|
||||
}
|
||||
|
||||
/// The one-shot owner of an admitted adoption pin.
|
||||
///
|
||||
/// # Why this exists rather than a field on the transaction
|
||||
///
|
||||
/// `Prepared` retains an `Arc<ValidatedTransaction>` so a group can be
|
||||
/// re-sequenced, re-chained, and re-signed when the pre-mark deadline recheck
|
||||
/// drops a member. An `Arc` has no move-out, so a linear `ProjectionAdoption`
|
||||
/// stored inside one could never be taken out to be settled. The pin therefore
|
||||
/// travels *beside* the `Arc`, in this slot, and the descriptor the retries
|
||||
/// need stays in the transaction where it can be read any number of times.
|
||||
///
|
||||
/// # Why the phase, rather than settling at each site
|
||||
///
|
||||
/// Every route out of a submit has to settle the pin, and the dangerous ones
|
||||
/// are the routes nobody enumerated: a full or disconnected submission queue,
|
||||
/// a pre-append error, a panic inside the writer, a deadline recheck that
|
||||
/// reforms the group, an unwind. Naming them one at a time means the next
|
||||
/// route added is a leak. Instead the slot settles itself on drop and reads the
|
||||
/// outcome off the phase, so a route that was never considered is still
|
||||
/// correct — and correct in the safe direction, because the phase advances
|
||||
/// before the write rather than after the fence.
|
||||
pub(crate) struct AdoptionSlot {
|
||||
/// `None` only after an explicit [`Self::finish`]. A slot that still holds
|
||||
/// its pin at drop time settles it from the phase.
|
||||
handle: Option<ProjectionAdoption>,
|
||||
phase: AppendPhase,
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // See the note on `AppendPhase`.
|
||||
impl AdoptionSlot {
|
||||
pub(crate) fn new(handle: ProjectionAdoption) -> Self {
|
||||
Self {
|
||||
handle: Some(handle),
|
||||
phase: AppendPhase::BeforeAppend,
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance to [`AppendPhase::Appended`], at the first possible frame write.
|
||||
///
|
||||
/// Deliberately idempotent and one-way: a group that re-forms and appends
|
||||
/// again must not walk the phase back to `BeforeAppend`, because the
|
||||
/// earlier attempt's bytes may already be on the device.
|
||||
pub(crate) fn entered_append(&mut self) {
|
||||
self.phase = AppendPhase::Appended;
|
||||
}
|
||||
|
||||
pub(crate) fn phase(&self) -> AppendPhase {
|
||||
self.phase
|
||||
}
|
||||
|
||||
/// Read the pin without consuming it, for the pre-append revalidation.
|
||||
///
|
||||
/// `None` cannot happen before [`Self::finish`] and is not an error worth a
|
||||
/// second failure mode: a caller that gets it has already settled the pin
|
||||
/// and has nothing left to validate.
|
||||
pub(crate) fn resolution(
|
||||
&self,
|
||||
) -> Option<Result<Arc<crate::staging::ProjectionAdoptionResolution>, StoreError>> {
|
||||
self.handle.as_ref().map(|handle| handle.resolution())
|
||||
}
|
||||
|
||||
/// Settle with an explicit outcome, and hand back staging's answer.
|
||||
///
|
||||
/// The only path that reports a settlement failure. [`Drop`] cannot, which
|
||||
/// is why the writer calls this at the two points where it knows the
|
||||
/// outcome — the publication that adopted, and the pre-append refusal that
|
||||
/// did not.
|
||||
pub(crate) fn finish(mut self, outcome: ProjectionAdoptionOutcome) -> Result<(), StoreError> {
|
||||
match self.handle.take() {
|
||||
Some(handle) => handle.finish(outcome),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AdoptionSlot {
|
||||
fn drop(&mut self) {
|
||||
let Some(handle) = self.handle.take() else {
|
||||
return;
|
||||
};
|
||||
let outcome = match self.phase {
|
||||
AppendPhase::BeforeAppend => ProjectionAdoptionOutcome::DefinitivePreAppendFailure,
|
||||
AppendPhase::Appended => ProjectionAdoptionOutcome::TransferredToRecovery,
|
||||
};
|
||||
// Swallowed because a `Drop` has nowhere to put it, and accounted for
|
||||
// regardless: a `finish` that fails leaves `ProjectionAdoption` unfinished,
|
||||
// so its own `Drop` records the pin as dropped without an outcome. The
|
||||
// failure is visible in staging's counters rather than lost — it is only
|
||||
// this frame's error text that cannot be carried out of here.
|
||||
let _ = handle.finish(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliberately no `Debug` derive on the slot: `#[derive]` on a struct holding
|
||||
/// a capability invites printing it, and what a pin identifies is a session
|
||||
/// whose id belongs in staging's diagnostics rather than in a transaction dump.
|
||||
impl std::fmt::Debug for AdoptionSlot {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AdoptionSlot")
|
||||
.field("settled", &self.handle.is_none())
|
||||
.field("phase", &self.phase)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliberately does not print object or evidence bytes. A transaction's
|
||||
|
|
@ -105,7 +268,10 @@ pub struct ValidatedTransactionBuilder {
|
|||
#[allow(clippy::type_complexity)]
|
||||
authority: Option<(Option<ObjectId>, Option<ObjectId>)>,
|
||||
evidence: Option<TransactionEvidenceV1>,
|
||||
adoption: Option<StagedProjectionAdoption>,
|
||||
/// The descriptor and its pin, the pin already inside the slot that owns
|
||||
/// it. Held as a pair because `build` needs the descriptor and the slot to
|
||||
/// travel to different places, and neither may arrive without the other.
|
||||
adoption: Option<(StagedProjectionInstallV1, AdoptionSlot)>,
|
||||
}
|
||||
|
||||
fn incomplete(field: &str) -> StoreError {
|
||||
|
|
@ -157,49 +323,53 @@ impl ValidatedTransactionBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
/// Adopt one sealed staged projection. The opaque handle is the adoption
|
||||
/// pin and is consumed with the canonical wire descriptor so neither half
|
||||
/// can be forgotten independently.
|
||||
/// Adopt one sealed staged projection.
|
||||
///
|
||||
/// Takes the whole [`StagedProjectionAdoption`] rather than a descriptor
|
||||
/// and a pin separately. The frozen signature took two arguments so that
|
||||
/// neither half could be *forgotten* independently, which this keeps — and
|
||||
/// it additionally makes them impossible to *mismatch*, which two
|
||||
/// arguments could not. See the type's own note. Contract review
|
||||
/// 2026-08-09-A.
|
||||
pub fn adopt_projection(
|
||||
mut self,
|
||||
descriptor: levcs_protocol::v2::StagedProjectionInstallV1,
|
||||
handle: crate::staging::ProjectionAdoption,
|
||||
adoption: StagedProjectionAdoption,
|
||||
) -> Result<Self, StoreError> {
|
||||
if let Some(previous) = self.adoption.take() {
|
||||
let StagedProjectionAdoption { descriptor, handle } = adoption;
|
||||
// Wrapped before anything below can fail. From here the pin is owned by
|
||||
// a slot, so a builder abandoned mid-chain — `adopt_projection` called
|
||||
// and `build` never reached — releases it with a pre-append outcome
|
||||
// instead of dropping a live capability.
|
||||
let slot = AdoptionSlot::new(handle);
|
||||
if let Some((_, previous)) = self.adoption.take() {
|
||||
// Both pins were admitted, so both receive a terminal outcome
|
||||
// even though the builder rejects the duplicate. Returning early
|
||||
// after finishing only one would turn the other drop into the
|
||||
// lifecycle bug this handle exists to expose.
|
||||
let previous_result = previous
|
||||
.handle
|
||||
.finish(ProjectionAdoptionOutcome::DefinitivePreAppendFailure);
|
||||
// lifecycle bug this handle exists to expose. Settled explicitly
|
||||
// rather than by drop because only this path can report a
|
||||
// settlement that itself failed.
|
||||
let previous_result =
|
||||
previous.finish(ProjectionAdoptionOutcome::DefinitivePreAppendFailure);
|
||||
let submitted_result =
|
||||
handle.finish(ProjectionAdoptionOutcome::DefinitivePreAppendFailure);
|
||||
slot.finish(ProjectionAdoptionOutcome::DefinitivePreAppendFailure);
|
||||
previous_result?;
|
||||
submitted_result?;
|
||||
return Err(StoreError::Conflict(
|
||||
"a transaction may adopt exactly one staged projection".into(),
|
||||
));
|
||||
}
|
||||
self.adoption = Some(StagedProjectionAdoption { descriptor, handle });
|
||||
self.adoption = Some((descriptor, slot));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn build(mut self) -> Result<ValidatedTransaction, StoreError> {
|
||||
if let Some(adoption) = self.adoption.take() {
|
||||
// Adoption is the B1/B3 seam of scope 6.2 item 9 and is not part
|
||||
// of this slice. The pin is released with a definitive pre-append
|
||||
// outcome before the refusal, because a dropped pin with no
|
||||
// outcome is the leak §6.5 declares a bug — the refusal must not
|
||||
// create one.
|
||||
adoption
|
||||
.handle
|
||||
.finish(ProjectionAdoptionOutcome::DefinitivePreAppendFailure)?;
|
||||
return Err(StoreError::NotImplemented(
|
||||
"ValidatedTransactionBuilder::adopt_projection — B1 NamespaceTxn, \
|
||||
scope 6-B1 deliverable 3 and scope 6.2 item 9",
|
||||
));
|
||||
}
|
||||
// Held across every fallible step below rather than unwrapped at the
|
||||
// end. `AdoptionSlot` settles itself when dropped, so each `?` in this
|
||||
// function releases the pin with a definitive pre-append outcome
|
||||
// without naming it — and a refusal that forgot to would create exactly
|
||||
// the unsettled-drop §6.5 declares a bug. The slot reaches the returned
|
||||
// transaction only on the success path.
|
||||
let adoption = self.adoption.take();
|
||||
|
||||
let namespace = self.namespace.ok_or_else(|| incomplete("namespace"))?;
|
||||
let (operation_id, operation_digest, retry_until_micros) =
|
||||
|
|
@ -210,6 +380,23 @@ impl ValidatedTransactionBuilder {
|
|||
let mut objects = self.objects.ok_or_else(|| incomplete("objects"))?;
|
||||
let refs = self.refs.ok_or_else(|| incomplete("refs"))?;
|
||||
|
||||
// A frame carries inline objects or an install descriptor and never
|
||||
// both: `FrameObjectsV1` is an enum, so there is no encoding for the
|
||||
// pair. This is a refusal rather than a silent choice because both
|
||||
// silent choices are wrong — dropping the inline objects loses bytes
|
||||
// the caller asked to commit, and dropping the projection commits a
|
||||
// frame that references artifacts nothing installs.
|
||||
//
|
||||
// An adopting transaction is therefore built with an *empty* object
|
||||
// list, not with the list omitted. The projection is the payload.
|
||||
if adoption.is_some() && !objects.is_empty() {
|
||||
return Err(StoreError::Conflict(format!(
|
||||
"a transaction adopting a staged projection may not also introduce {} inline \
|
||||
object(s); a frame carries one payload or the other",
|
||||
objects.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Canonical frame order, established once. A duplicate `ObjectId` is
|
||||
// refused rather than deduplicated: two records for one object make
|
||||
// `objects_new` and the index disagree about what the transaction
|
||||
|
|
@ -248,6 +435,11 @@ impl ValidatedTransactionBuilder {
|
|||
}
|
||||
}
|
||||
|
||||
let (projection, adoption_pin) = match adoption {
|
||||
Some((descriptor, slot)) => (Some(descriptor), Some(slot)),
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
Ok(ValidatedTransaction {
|
||||
namespace,
|
||||
operation_id,
|
||||
|
|
@ -259,6 +451,8 @@ impl ValidatedTransactionBuilder {
|
|||
expected_authority,
|
||||
new_authority,
|
||||
evidence,
|
||||
projection,
|
||||
adoption_pin,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -309,6 +503,19 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The pairing a caller outside this crate cannot assemble by hand, which
|
||||
/// is the point of the type. These tests are inside the crate and can, so
|
||||
/// they build it through one helper rather than at each call site — a
|
||||
/// second construction site is a second chance to pair a descriptor with a
|
||||
/// pin from somewhere else, which is exactly what production no longer
|
||||
/// permits.
|
||||
fn adoption(session_id: [u8; 16], lifecycle: Arc<Lifecycle>) -> StagedProjectionAdoption {
|
||||
StagedProjectionAdoption {
|
||||
descriptor: descriptor(session_id),
|
||||
handle: ProjectionAdoption::new(lifecycle),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn administrative_evidence() -> TransactionEvidenceV1 {
|
||||
TransactionEvidenceV1::AdministrativeV1 {
|
||||
actor: [9; 32],
|
||||
|
|
@ -455,17 +662,46 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn a_projection_adoption_is_refused_and_releases_its_pin() {
|
||||
fn an_adoption_reaches_the_built_transaction_still_unsettled() {
|
||||
let lifecycle = Arc::new(Lifecycle::default());
|
||||
let transaction = complete()
|
||||
.adopt_projection(adoption([3; 16], lifecycle.clone()))
|
||||
.expect("one adoption is admitted")
|
||||
.build()
|
||||
.expect("an adopting transaction builds");
|
||||
|
||||
assert_eq!(
|
||||
transaction.projection().map(|install| install.session_id),
|
||||
Some([3; 16]),
|
||||
"the descriptor the frame is encoded from must survive `build`"
|
||||
);
|
||||
// Unsettled on purpose: `build` is not a decision about whether the
|
||||
// projection installs. Recording an outcome here would either claim an
|
||||
// append that has not happened or release artifacts a submit is about
|
||||
// to reference.
|
||||
assert_eq!(&*lifecycle.outcomes.lock().unwrap(), &[]);
|
||||
assert_eq!(*lifecycle.dropped.lock().unwrap(), 0);
|
||||
}
|
||||
|
||||
/// The enum has no encoding for both payloads, so the builder refuses
|
||||
/// rather than choosing which one to lose.
|
||||
#[test]
|
||||
fn an_adoption_alongside_inline_objects_is_refused_and_releases_its_pin() {
|
||||
let lifecycle = Arc::new(Lifecycle::default());
|
||||
let result = complete()
|
||||
.adopt_projection(
|
||||
descriptor([3; 16]),
|
||||
ProjectionAdoption::new(lifecycle.clone()),
|
||||
)
|
||||
.objects(vec![StagedObject {
|
||||
id: ObjectId([9; 32]),
|
||||
object_type: ObjectType::Blob,
|
||||
raw: vec![9; 8],
|
||||
}])
|
||||
.adopt_projection(adoption([3; 16], lifecycle.clone()))
|
||||
.expect("one adoption is admitted")
|
||||
.build();
|
||||
|
||||
assert!(matches!(result, Err(StoreError::NotImplemented(_))));
|
||||
let Err(StoreError::Conflict(message)) = result else {
|
||||
panic!("expected a conflict, got {result:?}");
|
||||
};
|
||||
assert!(message.contains("one payload or the other"), "{message}");
|
||||
assert_eq!(
|
||||
&*lifecycle.outcomes.lock().unwrap(),
|
||||
&[ProjectionAdoptionOutcome::DefinitivePreAppendFailure]
|
||||
|
|
@ -473,16 +709,107 @@ mod tests {
|
|||
assert_eq!(*lifecycle.dropped.lock().unwrap(), 0);
|
||||
}
|
||||
|
||||
/// The two abandonment routes that no call site names.
|
||||
///
|
||||
/// Neither is reachable by an enumerated release: one drops a builder that
|
||||
/// was never finished, the other drops a transaction that was never
|
||||
/// submitted. Both are ordinary things for a caller to do, and both would
|
||||
/// leak a live pin if settlement lived at the call sites rather than in the
|
||||
/// slot's `Drop`.
|
||||
#[test]
|
||||
fn an_abandoned_builder_or_transaction_releases_its_pin() {
|
||||
for abandon_before_build in [true, false] {
|
||||
let lifecycle = Arc::new(Lifecycle::default());
|
||||
let builder = complete()
|
||||
.adopt_projection(adoption([4; 16], lifecycle.clone()))
|
||||
.expect("one adoption is admitted");
|
||||
|
||||
if abandon_before_build {
|
||||
drop(builder);
|
||||
} else {
|
||||
drop(builder.build().expect("an adopting transaction builds"));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
&*lifecycle.outcomes.lock().unwrap(),
|
||||
&[ProjectionAdoptionOutcome::DefinitivePreAppendFailure],
|
||||
"abandon_before_build = {abandon_before_build}"
|
||||
);
|
||||
assert_eq!(
|
||||
*lifecycle.dropped.lock().unwrap(),
|
||||
0,
|
||||
"abandon_before_build = {abandon_before_build}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The phase, and only the phase, decides an unattended settlement.
|
||||
///
|
||||
/// Asserted directly on the slot because the writer's routes into
|
||||
/// `Appended` are failures and panics, and a test that could only reach
|
||||
/// this rule through one of them would be asserting about that failure
|
||||
/// rather than about the rule.
|
||||
#[test]
|
||||
fn a_dropped_slot_settles_from_its_append_phase() {
|
||||
let cases = [
|
||||
(
|
||||
false,
|
||||
ProjectionAdoptionOutcome::DefinitivePreAppendFailure,
|
||||
"no byte was written",
|
||||
),
|
||||
(
|
||||
true,
|
||||
ProjectionAdoptionOutcome::TransferredToRecovery,
|
||||
"a write was attempted",
|
||||
),
|
||||
];
|
||||
for (entered_append, expected, why) in cases {
|
||||
let lifecycle = Arc::new(Lifecycle::default());
|
||||
let mut slot = AdoptionSlot::new(ProjectionAdoption::new(lifecycle.clone()));
|
||||
assert_eq!(slot.phase(), AppendPhase::BeforeAppend);
|
||||
if entered_append {
|
||||
slot.entered_append();
|
||||
// One-way: a group that re-forms must not walk the phase back.
|
||||
slot.entered_append();
|
||||
assert_eq!(slot.phase(), AppendPhase::Appended);
|
||||
}
|
||||
drop(slot);
|
||||
|
||||
assert_eq!(&*lifecycle.outcomes.lock().unwrap(), &[expected], "{why}");
|
||||
assert_eq!(*lifecycle.dropped.lock().unwrap(), 0, "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An explicit settlement wins over the phase, and happens once.
|
||||
#[test]
|
||||
fn an_explicitly_finished_slot_does_not_settle_again_on_drop() {
|
||||
let lifecycle = Arc::new(Lifecycle::default());
|
||||
let mut slot = AdoptionSlot::new(ProjectionAdoption::new(lifecycle.clone()));
|
||||
slot.entered_append();
|
||||
slot.finish(ProjectionAdoptionOutcome::Adopted {
|
||||
committed_shard_sequence: 7,
|
||||
})
|
||||
.expect("staging accepts the outcome");
|
||||
|
||||
assert_eq!(
|
||||
&*lifecycle.outcomes.lock().unwrap(),
|
||||
&[ProjectionAdoptionOutcome::Adopted {
|
||||
committed_shard_sequence: 7
|
||||
}],
|
||||
"the drop that follows `finish` must not add a second outcome"
|
||||
);
|
||||
assert_eq!(*lifecycle.dropped.lock().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_projection_adoption_releases_both_pins() {
|
||||
let first = Arc::new(Lifecycle::default());
|
||||
let second = Arc::new(Lifecycle::default());
|
||||
let builder = ValidatedTransaction::builder(PrivilegedConstruction::internal())
|
||||
.adopt_projection(descriptor([1; 16]), ProjectionAdoption::new(first.clone()))
|
||||
.adopt_projection(adoption([1; 16], first.clone()))
|
||||
.expect("first adoption");
|
||||
|
||||
let result =
|
||||
builder.adopt_projection(descriptor([2; 16]), ProjectionAdoption::new(second.clone()));
|
||||
let result = builder.adopt_projection(adoption([2; 16], second.clone()));
|
||||
assert!(matches!(result, Err(StoreError::Conflict(_))));
|
||||
for lifecycle in [first, second] {
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ use levcs_store::StoreEngine;
|
|||
use support::group_model::{
|
||||
canonical_group_expectation, group_failpoint_expectation, oracle_recovery_outcome,
|
||||
outcome_admits, physical_state_class, verify_adopted_prefix, victim_placement,
|
||||
AdoptedPrefixExpectation, PhysicalStateClass, PrefixViolation, VictimPlacement,
|
||||
AdoptedPrefixExpectation, AdoptionOutcomeExpectation, PayloadKind, PhysicalStateClass,
|
||||
PrefixViolation, VictimPlacement,
|
||||
};
|
||||
use support::harness;
|
||||
|
||||
|
|
@ -122,6 +123,102 @@ fn the_two_independent_derivations_agree_for_every_row() {
|
|||
}
|
||||
}
|
||||
|
||||
/// The adoption expectation gets the same two-derivation treatment as the
|
||||
/// recovery outcome: the fixture states it, the physical state class derives
|
||||
/// it, and they must agree.
|
||||
///
|
||||
/// This runs on every Wave B row now, before any row lists
|
||||
/// `staged_projection`, and that is the point. Turning the kind on becomes a
|
||||
/// matter of listing it, not of also getting the expectation right in the same
|
||||
/// commit — and if the two ever disagree, the failure names the row rather
|
||||
/// than surfacing as a mis-driven adoption much later.
|
||||
#[test]
|
||||
fn the_adoption_expectation_agrees_with_the_physical_state_class() {
|
||||
let fixture = harness::load_fixture();
|
||||
let mut checked = 0;
|
||||
for row in &fixture.rows {
|
||||
let Some(plan) = &row.submit else { continue };
|
||||
let resolved = harness::resolve(row);
|
||||
|
||||
let stated =
|
||||
AdoptionOutcomeExpectation::from_name(&plan.adoption_outcome).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"row {}: submit.adoption_outcome {:?} names no AdoptionOutcomeExpectation",
|
||||
row.failpoint, plan.adoption_outcome
|
||||
)
|
||||
});
|
||||
let derived = resolved.class.adoption_outcome();
|
||||
|
||||
assert_eq!(
|
||||
stated,
|
||||
derived,
|
||||
"row {}: the fixture states the adoption pin ends as {}, but the \
|
||||
physical state class {} derives {}. The class is the authority on \
|
||||
whether a frame binding the artifacts exists, and a pin settled \
|
||||
against the wrong answer either strands a session or licenses \
|
||||
staging to reclaim content a committed root points into.",
|
||||
row.failpoint,
|
||||
stated.name(),
|
||||
resolved.class.name(),
|
||||
derived.name()
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
checked,
|
||||
fixture.rows.iter().filter(|r| r.submit.is_some()).count(),
|
||||
"every submit row must carry an adoption expectation"
|
||||
);
|
||||
assert!(checked > 0, "the fixture carries no submit rows to check");
|
||||
}
|
||||
|
||||
/// Every payload kind a row lists must name a real [`PayloadKind`], and the
|
||||
/// inline kind must be present on every submit row.
|
||||
///
|
||||
/// Inline is required rather than merely allowed because it is the payload the
|
||||
/// eight locations were characterized with. A row that dropped it while adding
|
||||
/// `staged_projection` would move coverage sideways and read as having added
|
||||
/// some.
|
||||
#[test]
|
||||
fn every_submit_row_lists_known_payload_kinds_including_inline() {
|
||||
let fixture = harness::load_fixture();
|
||||
for row in &fixture.rows {
|
||||
let Some(plan) = &row.submit else { continue };
|
||||
|
||||
let kinds: Vec<PayloadKind> = plan
|
||||
.payload_kinds
|
||||
.iter()
|
||||
.map(|name| {
|
||||
PayloadKind::from_name(name).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"row {}: submit.payload_kinds names {name:?}, which is no PayloadKind",
|
||||
row.failpoint
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
kinds.contains(&PayloadKind::Inline),
|
||||
"row {}: every submit row must keep driving the inline payload the \
|
||||
location was characterized with",
|
||||
row.failpoint
|
||||
);
|
||||
|
||||
let mut seen = kinds.clone();
|
||||
seen.sort_by_key(|kind| kind.name());
|
||||
seen.dedup();
|
||||
assert_eq!(
|
||||
seen.len(),
|
||||
kinds.len(),
|
||||
"row {}: submit.payload_kinds repeats a kind, which would drive the \
|
||||
same case twice and report it as two",
|
||||
row.failpoint
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_class_table_and_the_fixture_agree_on_every_row() {
|
||||
let fixture = harness::load_fixture();
|
||||
|
|
@ -1453,11 +1550,37 @@ fn wave_b_rows_drive_through_submit_to_their_full_failpoint_expectation() {
|
|||
continue;
|
||||
}
|
||||
|
||||
let expected_adoption = AdoptionOutcomeExpectation::from_name(&plan.adoption_outcome)
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"row {}: submit.adoption_outcome {:?} names no AdoptionOutcomeExpectation",
|
||||
row.failpoint, plan.adoption_outcome
|
||||
)
|
||||
});
|
||||
|
||||
for action in &plan.actions {
|
||||
let action = engine_matrix::action_from_name(action)
|
||||
.unwrap_or_else(|| panic!("row {}: unknown action {action:?}", row.failpoint));
|
||||
let observation = engine_matrix::drive_submit_row(&serial, resolved.point, action);
|
||||
assert_full_expectation(resolved.point, resolved.class, &observation);
|
||||
// The payload is a matrix axis, so every row runs once per kind it
|
||||
// declares. The staged-projection pass is what makes the adoption
|
||||
// outcome an observation rather than a fixture field: without it the
|
||||
// column would agree with the class table and describe nothing that
|
||||
// ran.
|
||||
for kind in &plan.payload_kinds {
|
||||
let kind = PayloadKind::from_name(kind).unwrap_or_else(|| {
|
||||
panic!("row {}: unknown payload kind {kind:?}", row.failpoint)
|
||||
});
|
||||
let observation = engine_matrix::drive_submit_row(
|
||||
&serial,
|
||||
resolved.point,
|
||||
action,
|
||||
kind.carries_adoption(),
|
||||
);
|
||||
assert_full_expectation(resolved.point, resolved.class, &observation);
|
||||
if kind.carries_adoption() {
|
||||
assert_adoption_outcome(&observation, expected_adoption);
|
||||
}
|
||||
}
|
||||
}
|
||||
driven.push(row.failpoint.clone());
|
||||
}
|
||||
|
|
@ -1473,6 +1596,44 @@ fn wave_b_rows_drive_through_submit_to_their_full_failpoint_expectation() {
|
|||
);
|
||||
}
|
||||
|
||||
/// The pin's terminal outcome, observed rather than derived.
|
||||
///
|
||||
/// The expectation comes from the fixture, which `the_adoption_expectation_
|
||||
/// agrees_with_the_physical_state_class` independently checks against the class
|
||||
/// table. This compares that expectation with what staging actually counted, so
|
||||
/// the two derivations the charter requires stay two: a rule about physical
|
||||
/// state, and a number a run produced.
|
||||
///
|
||||
/// Every driven row must settle exactly one pin. Settling none is the leak §6.5
|
||||
/// declares a bug, and settling two means one adoption reached two terminal
|
||||
/// states — both are failures here rather than shrugs.
|
||||
fn assert_adoption_outcome(
|
||||
observation: &engine_matrix::RowObservation,
|
||||
expected: AdoptionOutcomeExpectation,
|
||||
) {
|
||||
let counters = observation
|
||||
.adoption
|
||||
.expect("a staged-projection row records staging's pin counters");
|
||||
let observed = counters
|
||||
.sole_outcome()
|
||||
.unwrap_or_else(|why| panic!("row {}: {why}", observation.row));
|
||||
let observed = observed.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"row {}: the adoption pin was never settled; a pin dropped without an outcome is \
|
||||
the lifecycle bug the handle exists to expose ({counters:?})",
|
||||
observation.row
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
observed,
|
||||
expected.name(),
|
||||
"row {}: the adoption pin settled {observed}, but this row's physical state class \
|
||||
requires {} ({counters:?})",
|
||||
observation.row,
|
||||
expected.name()
|
||||
);
|
||||
}
|
||||
|
||||
/// The row B1 disclosed was never armed.
|
||||
///
|
||||
/// `DuringRootCasRetry` sits inside `publish_subtree`'s retry loop, past a
|
||||
|
|
|
|||
|
|
@ -50,7 +50,12 @@
|
|||
"actions": [
|
||||
"fail"
|
||||
],
|
||||
"requires_root_cas_contention": false
|
||||
"requires_root_cas_contention": false,
|
||||
"payload_kinds": [
|
||||
"inline",
|
||||
"staged_projection"
|
||||
],
|
||||
"adoption_outcome": "DefinitivePreAppendFailure"
|
||||
},
|
||||
"rationale": "Marking Resolving writes no journal byte, so the physical state is identical to BeforeAppend. The row is distinguished only by immediate_status and shard_poisoned, which is why it needed an engine: the status root is the only place the difference exists."
|
||||
},
|
||||
|
|
@ -89,7 +94,12 @@
|
|||
"actions": [
|
||||
"fail"
|
||||
],
|
||||
"requires_root_cas_contention": false
|
||||
"requires_root_cas_contention": false,
|
||||
"payload_kinds": [
|
||||
"inline",
|
||||
"staged_projection"
|
||||
],
|
||||
"adoption_outcome": "DefinitivePreAppendFailure"
|
||||
},
|
||||
"rationale": "Fires at scope 6.3 step 2, before the group is marked Resolving and before any byte is written. Contract review 2026-07-26-A gave it the BeforeAppend shape: a routine SignerError::Unavailable is an availability event, and poisoning the shard for it would trade a real availability property for a safety property that was never at risk."
|
||||
},
|
||||
|
|
@ -153,7 +163,12 @@
|
|||
"fail",
|
||||
"panic"
|
||||
],
|
||||
"requires_root_cas_contention": false
|
||||
"requires_root_cas_contention": false,
|
||||
"payload_kinds": [
|
||||
"inline",
|
||||
"staged_projection"
|
||||
],
|
||||
"adoption_outcome": "TransferredToRecovery"
|
||||
},
|
||||
"rationale": "The fence returned before the subtree build begins, so the frame is durable and the transaction is committed on the device while the publication that would make it visible never happens. Recovery replays the fenced frame and publishes the receipt."
|
||||
},
|
||||
|
|
@ -168,7 +183,12 @@
|
|||
"actions": [
|
||||
"fail"
|
||||
],
|
||||
"requires_root_cas_contention": false
|
||||
"requires_root_cas_contention": false,
|
||||
"payload_kinds": [
|
||||
"inline",
|
||||
"staged_projection"
|
||||
],
|
||||
"adoption_outcome": "TransferredToRecovery"
|
||||
},
|
||||
"rationale": "Same physical state as DuringCommittedRootBuild and reached one step later: the group is fenced, the subtree is built, and the allocation that would carry it into the root fails. Poisoning, because it is inside steps 4-8."
|
||||
},
|
||||
|
|
@ -184,7 +204,12 @@
|
|||
"fail",
|
||||
"panic"
|
||||
],
|
||||
"requires_root_cas_contention": false
|
||||
"requires_root_cas_contention": false,
|
||||
"payload_kinds": [
|
||||
"inline",
|
||||
"staged_projection"
|
||||
],
|
||||
"adoption_outcome": "TransferredToRecovery"
|
||||
},
|
||||
"rationale": "The last instant at which a fenced group is still unpublished. Everything before the compare-and-swap has succeeded, so the transaction is durable; the shard is poisoned because a group that fenced and did not publish leaves the status root claiming Resolving for a committed transaction."
|
||||
},
|
||||
|
|
@ -200,7 +225,12 @@
|
|||
"fail",
|
||||
"panic"
|
||||
],
|
||||
"requires_root_cas_contention": true
|
||||
"requires_root_cas_contention": true,
|
||||
"payload_kinds": [
|
||||
"inline",
|
||||
"staged_projection"
|
||||
],
|
||||
"adoption_outcome": "TransferredToRecovery"
|
||||
},
|
||||
"rationale": "Reached only after a lost compare-and-swap: another shard published between this shard's load of the committed root and its swap. The re-merge is against a newer root and the subtree is unchanged, so the outcome is identical to BeforeRootCas - which is the point, because a retry that resolved differently from a first attempt would make publication order observable."
|
||||
},
|
||||
|
|
@ -239,7 +269,12 @@
|
|||
"actions": [
|
||||
"fail"
|
||||
],
|
||||
"requires_root_cas_contention": false
|
||||
"requires_root_cas_contention": false,
|
||||
"payload_kinds": [
|
||||
"inline",
|
||||
"staged_projection"
|
||||
],
|
||||
"adoption_outcome": "Adopted"
|
||||
},
|
||||
"rationale": "Post-publication. The fence succeeded and the root published, so the transaction is committed; a waiter that never wakes is a hung request, not an absent transaction, and the receipt stays queryable through transaction_status."
|
||||
},
|
||||
|
|
@ -254,7 +289,12 @@
|
|||
"actions": [
|
||||
"fail"
|
||||
],
|
||||
"requires_root_cas_contention": false
|
||||
"requires_root_cas_contention": false,
|
||||
"payload_kinds": [
|
||||
"inline",
|
||||
"staged_projection"
|
||||
],
|
||||
"adoption_outcome": "Adopted"
|
||||
},
|
||||
"rationale": "Post-publication and pre-response: the receipt is already durable and idempotently retrievable by status or retry, and no reappend is required."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,331 @@
|
|||
//! Scope 6-B1 deliverable 7 — resubmitting an operation that already committed.
|
||||
//!
|
||||
//! A retry is the normal case, not an error. A client that submitted, lost the
|
||||
//! connection, and submitted again must be told what happened the first time
|
||||
//! rather than either appending a second frame or being refused. The operation
|
||||
//! ID is the identity that makes that answerable, and the stable digest is what
|
||||
//! makes the answer safe: the same ID over the same digest is the same request,
|
||||
//! and the same ID over a *different* digest is two different requests claiming
|
||||
//! one identity, which is the case that must be refused.
|
||||
//!
|
||||
//! Asserted against `oracle::coalescing_decision`, which is the frozen
|
||||
//! statement of that rule, rather than against a second copy of it written
|
||||
//! here. The oracle is fed the same three inputs the store has -- the durable
|
||||
//! status, the in-flight digest, and the incoming digest -- and the store's
|
||||
//! answer must be the one it names.
|
||||
|
||||
#![cfg(all(
|
||||
feature = "store-privileged",
|
||||
feature = "store-internals",
|
||||
feature = "failpoints"
|
||||
))]
|
||||
|
||||
use levcs_core::{ObjectId, ObjectType};
|
||||
use levcs_protocol::oracle::{coalescing_decision, CoalescingDecision};
|
||||
use levcs_protocol::v2::TransactionStatusV1;
|
||||
use levcs_store::transaction::StagedObject;
|
||||
use levcs_store::types::{NamespaceId, OperationId, PrivilegedConstruction, StoreError};
|
||||
use levcs_store::ValidatedTransaction;
|
||||
|
||||
#[path = "support/engine_matrix.rs"]
|
||||
mod engine_matrix;
|
||||
|
||||
use engine_matrix::{
|
||||
create_transaction, deadline, evidence, genesis_id, namespace_on_shard, open_absent_root,
|
||||
reopen_after_close, submit, DEFAULT_MAX_INDEX_RUNS,
|
||||
};
|
||||
|
||||
const SHARD_COUNT: u16 = 1;
|
||||
const OPERATION: u8 = 0x40;
|
||||
|
||||
/// One transaction, parameterized by the payload that determines its stable
|
||||
/// digest, so the same operation ID can be resubmitted with the same digest or
|
||||
/// a different one.
|
||||
fn transaction(namespace: NamespaceId, digest_seed: u8, blob: u8) -> ValidatedTransaction {
|
||||
let authority = genesis_id(&namespace);
|
||||
ValidatedTransaction::builder(PrivilegedConstruction::assert_validated())
|
||||
.namespace(namespace)
|
||||
.operation(
|
||||
OperationId([OPERATION; 16]),
|
||||
ObjectId([digest_seed; 32]),
|
||||
deadline(),
|
||||
)
|
||||
.objects(vec![StagedObject {
|
||||
id: ObjectId([blob; 32]),
|
||||
object_type: ObjectType::Blob,
|
||||
raw: vec![blob; 64],
|
||||
}])
|
||||
.refs(Vec::new())
|
||||
.authority(Some(authority), Some(authority))
|
||||
.evidence(evidence())
|
||||
.build()
|
||||
.expect("a complete transaction")
|
||||
}
|
||||
|
||||
/// The durable status the oracle is asked about, built from the receipt the
|
||||
/// store actually produced. Only `operation_digest` participates in the
|
||||
/// decision; the rest is carried so the value is a real status rather than a
|
||||
/// shape that happens to satisfy one accessor.
|
||||
fn durable_status(
|
||||
receipt: &levcs_store::types::CommitReceipt,
|
||||
digest: ObjectId,
|
||||
) -> TransactionStatusV1 {
|
||||
TransactionStatusV1::Committed(levcs_protocol::v2::CommitReceiptV1 {
|
||||
operation_id: *receipt.operation_id.as_bytes(),
|
||||
operation_digest: digest,
|
||||
repo_sequence: receipt.repo_sequence,
|
||||
current_authority: receipt.current_authority,
|
||||
refs: Vec::new(),
|
||||
objects_new: receipt.objects_new,
|
||||
retry_until_micros: 0,
|
||||
first_visible_at_micros: 0,
|
||||
receipt_visible_until_micros: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resubmitted_operation_returns_its_durable_receipt() {
|
||||
let directory = tempfile::TempDir::new().expect("a temporary root");
|
||||
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
|
||||
let engine = open_absent_root(directory.path(), SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
|
||||
submit(&engine, create_transaction(namespace, 1))
|
||||
.receipt()
|
||||
.expect("the repository is created");
|
||||
|
||||
let first = submit(&engine, transaction(namespace, 0xd1, 0xb1));
|
||||
let first = first
|
||||
.receipt()
|
||||
.unwrap_or_else(|| panic!("the first submit must commit: {:?}", first.error()))
|
||||
.clone();
|
||||
|
||||
// The oracle's answer for these inputs, stated before the store is asked.
|
||||
assert!(
|
||||
matches!(
|
||||
coalescing_decision(
|
||||
durable_status(&first, ObjectId([0xd1; 32])),
|
||||
None,
|
||||
ObjectId([0xd1; 32])
|
||||
),
|
||||
CoalescingDecision::ReturnDurable(_)
|
||||
),
|
||||
"the oracle must name this a durable return, or this test is asserting the wrong rule"
|
||||
);
|
||||
|
||||
let again = submit(&engine, transaction(namespace, 0xd1, 0xb1));
|
||||
let again = again.receipt().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"a same-digest resubmit must be answered: {:?}",
|
||||
again.error()
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
again.repo_sequence, first.repo_sequence,
|
||||
"a resubmit must return the receipt the first submit produced, not a new one; a second \
|
||||
repo_sequence means a second frame was appended for one operation"
|
||||
);
|
||||
assert_eq!(again.objects_new, first.objects_new);
|
||||
|
||||
// And after a reopen, where the answer can only come from the committed
|
||||
// root recovery rebuilt.
|
||||
drop(engine);
|
||||
let reopened = reopen_after_close(directory.path(), SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
|
||||
let recovered = submit(&reopened, transaction(namespace, 0xd1, 0xb1));
|
||||
let recovered = recovered.receipt().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"a resubmit after reopen must still be answered: {:?}",
|
||||
recovered.error()
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
recovered.repo_sequence, first.repo_sequence,
|
||||
"the durable answer must survive recovery"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_operation_id_with_a_second_digest_is_refused() {
|
||||
let directory = tempfile::TempDir::new().expect("a temporary root");
|
||||
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
|
||||
let engine = open_absent_root(directory.path(), SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
|
||||
submit(&engine, create_transaction(namespace, 1))
|
||||
.receipt()
|
||||
.expect("the repository is created");
|
||||
|
||||
let first = submit(&engine, transaction(namespace, 0xd1, 0xb1));
|
||||
let first = first
|
||||
.receipt()
|
||||
.unwrap_or_else(|| panic!("the first submit must commit: {:?}", first.error()))
|
||||
.clone();
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
coalescing_decision(
|
||||
durable_status(&first, ObjectId([0xd1; 32])),
|
||||
None,
|
||||
ObjectId([0xd2; 32])
|
||||
),
|
||||
CoalescingDecision::OperationIdMismatch
|
||||
),
|
||||
"the oracle must name this a mismatch, or this test is asserting the wrong rule"
|
||||
);
|
||||
|
||||
// Same operation ID, different stable digest: two different requests
|
||||
// claiming one identity. Returning the first receipt would tell the caller
|
||||
// its transaction committed when a different one did.
|
||||
let conflicting = submit(&engine, transaction(namespace, 0xd2, 0xb2));
|
||||
match conflicting.error() {
|
||||
Some(StoreError::Conflict(message)) => assert!(
|
||||
message.contains("digest"),
|
||||
"the refusal must say what disagreed: {message}"
|
||||
),
|
||||
other => panic!("a same-ID/different-digest resubmit must be refused, got {other:?}"),
|
||||
}
|
||||
|
||||
// The refusal is about identity, not about the shard: an unrelated
|
||||
// operation still commits.
|
||||
let unrelated = submit(&engine, {
|
||||
let authority = genesis_id(&namespace);
|
||||
ValidatedTransaction::builder(PrivilegedConstruction::assert_validated())
|
||||
.namespace(namespace)
|
||||
.operation(OperationId([0x77; 16]), ObjectId([0x77; 32]), deadline())
|
||||
.objects(vec![StagedObject {
|
||||
id: ObjectId([0x77; 32]),
|
||||
object_type: ObjectType::Blob,
|
||||
raw: vec![0x77; 64],
|
||||
}])
|
||||
.refs(Vec::new())
|
||||
.authority(Some(authority), Some(authority))
|
||||
.evidence(evidence())
|
||||
.build()
|
||||
.expect("a complete transaction")
|
||||
});
|
||||
unrelated.receipt().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"an unrelated operation must commit: {:?}",
|
||||
unrelated.error()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// A same-digest resubmit that arrives while the first is still in flight.
|
||||
///
|
||||
/// The durable path above cannot answer this one: nothing is committed yet, so
|
||||
/// there is no receipt to return and no terminal entry to read. The second
|
||||
/// submit has to attach to the first and receive whatever the first receives —
|
||||
/// one frame, one sequence, two callers answered.
|
||||
///
|
||||
/// The leader is held in flight by a group that will not close on its own: the
|
||||
/// transaction ceiling is high and the idle delay long, so the writer accepts
|
||||
/// the leader and waits. The follower is submitted from this thread while the
|
||||
/// leader's own submit is still blocked, which is the only arrangement where
|
||||
/// `StatusReservation::Attached` is reachable at all.
|
||||
#[test]
|
||||
fn a_same_digest_resubmit_attaches_to_an_in_flight_leader() {
|
||||
let directory = tempfile::TempDir::new().expect("a temporary root");
|
||||
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
|
||||
|
||||
let mut options = engine_matrix::options_with_index_runs(
|
||||
directory.path(),
|
||||
SHARD_COUNT,
|
||||
DEFAULT_MAX_INDEX_RUNS,
|
||||
);
|
||||
// Wide enough that the leader's group stays open until the follower has
|
||||
// been accepted, and short enough that the test does not depend on the
|
||||
// wall clock for correctness -- only for how long it waits.
|
||||
options.max_group_transactions = 8;
|
||||
options.max_group_idle = std::time::Duration::from_millis(250);
|
||||
|
||||
// Held for the whole test though nothing is armed here. The failpoint
|
||||
// registry is a one-shot global, so a sibling test that arms one would
|
||||
// otherwise fire it inside this engine and take the leader down -- which is
|
||||
// a different test's property and this test's spurious failure.
|
||||
let _serial = engine_matrix::serial();
|
||||
|
||||
let engine = levcs_store::StoreEngine::open(options).expect("the root initializes");
|
||||
submit(&engine, create_transaction(namespace, 1))
|
||||
.receipt()
|
||||
.expect("the repository is created");
|
||||
|
||||
let (leader, follower) = std::thread::scope(|scope| {
|
||||
let leading = scope.spawn(|| submit(&engine, transaction(namespace, 0xd1, 0xb1)));
|
||||
// The leader has to be accepted before the follower is submitted, or
|
||||
// the follower becomes the leader and the test asserts nothing.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
let follower = submit(&engine, transaction(namespace, 0xd1, 0xb1));
|
||||
(leading.join().expect("the leader's thread"), follower)
|
||||
});
|
||||
|
||||
let leader = leader
|
||||
.receipt()
|
||||
.unwrap_or_else(|| panic!("the leader must commit: {:?}", leader.error()))
|
||||
.clone();
|
||||
let follower = follower
|
||||
.receipt()
|
||||
.unwrap_or_else(|| panic!("the follower must be answered: {:?}", follower.error()));
|
||||
|
||||
assert_eq!(
|
||||
follower.repo_sequence, leader.repo_sequence,
|
||||
"a follower must receive the leader's outcome; a second repo_sequence means the store \
|
||||
appended a second frame for one operation"
|
||||
);
|
||||
assert_eq!(follower.objects_new, leader.objects_new);
|
||||
}
|
||||
|
||||
/// A follower is answered when its leader fails, not only when it commits.
|
||||
///
|
||||
/// This is the half of coalescing that is easy to get wrong and impossible to
|
||||
/// notice: a follower whose completion is never resolved does not fail, it
|
||||
/// *hangs*, with no receipt and no error. There are eight places a waiter is
|
||||
/// resolved and only one of them is the happy path, so the property worth
|
||||
/// asserting is that a leader taken down inside the poison window takes its
|
||||
/// followers' answers with it.
|
||||
///
|
||||
/// `AfterMarkedResolving` is armed to fail, which poisons the shard during
|
||||
/// publication -- after the follower has attached and before any receipt
|
||||
/// exists.
|
||||
#[test]
|
||||
fn a_follower_is_answered_when_its_leader_is_poisoned() {
|
||||
let directory = tempfile::TempDir::new().expect("a temporary root");
|
||||
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
|
||||
|
||||
let mut options = engine_matrix::options_with_index_runs(
|
||||
directory.path(),
|
||||
SHARD_COUNT,
|
||||
DEFAULT_MAX_INDEX_RUNS,
|
||||
);
|
||||
options.max_group_transactions = 8;
|
||||
options.max_group_idle = std::time::Duration::from_millis(250);
|
||||
|
||||
let engine = levcs_store::StoreEngine::open(options).expect("the root initializes");
|
||||
submit(&engine, create_transaction(namespace, 1))
|
||||
.receipt()
|
||||
.expect("the repository is created");
|
||||
|
||||
let serial = engine_matrix::serial();
|
||||
engine_matrix::arm(
|
||||
&serial,
|
||||
levcs_store::failpoints::Failpoint::AfterMarkedResolving,
|
||||
engine_matrix::action_from_name("fail").expect("the fail action"),
|
||||
);
|
||||
|
||||
let (leader, follower) = std::thread::scope(|scope| {
|
||||
let leading = scope.spawn(|| submit(&engine, transaction(namespace, 0xd1, 0xb1)));
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
let follower = submit(&engine, transaction(namespace, 0xd1, 0xb1));
|
||||
(leading.join().expect("the leader's thread"), follower)
|
||||
});
|
||||
engine_matrix::disarm(&serial);
|
||||
|
||||
assert!(
|
||||
leader.receipt().is_none(),
|
||||
"the armed failpoint must take the leader down, or this asserts nothing"
|
||||
);
|
||||
// The specific error matters less than its existence: what must not happen
|
||||
// is the follower waiting forever on a leader that is never going to
|
||||
// resolve it.
|
||||
assert!(
|
||||
follower.error().is_some(),
|
||||
"the follower must be told its leader failed; a follower with neither receipt nor \
|
||||
error is a hung request, which is the outcome this coalescing exists not to create"
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
//! Index coverage is a property of *publications*, not of index entries.
|
||||
//!
|
||||
//! A delta layer's `through_shard_sequence` states that every frame from its
|
||||
//! shard through that sequence is accounted for in the index. Checkpointing
|
||||
//! reads those stamps to decide whether pruning the journal would strand
|
||||
//! objects, so a committed sequence that no layer reaches is indistinguishable
|
||||
//! from a gap — the index simply does not reach that far.
|
||||
//!
|
||||
//! A group that introduces no objects has nothing to put in a layer and still
|
||||
//! advances the committed sequence. Skipping its layer, on the reasonable-looking
|
||||
//! grounds that an empty delta is not worth installing, left exactly that gap:
|
||||
//! the shard could never checkpoint again, and the error said objects would be
|
||||
//! dropped when there were none. An empty layer is the smallest honest way to
|
||||
//! record that the sequence happened and carried nothing.
|
||||
//!
|
||||
//! This is asserted with a plain object-less transaction rather than through the
|
||||
//! path that found it. An adopting transaction reaches the same state — its
|
||||
//! frame carries a descriptor rather than object bytes — but it reaches it for a
|
||||
//! reason specific to projections, and a regression that only fails when staging
|
||||
//! is involved would not name what actually broke.
|
||||
|
||||
#![cfg(all(
|
||||
feature = "store-privileged",
|
||||
feature = "store-internals",
|
||||
feature = "failpoints"
|
||||
))]
|
||||
|
||||
use levcs_core::ObjectId;
|
||||
use levcs_store::types::{NamespaceId, OperationId, PrivilegedConstruction};
|
||||
use levcs_store::ValidatedTransaction;
|
||||
|
||||
#[path = "support/engine_matrix.rs"]
|
||||
mod engine_matrix;
|
||||
|
||||
use engine_matrix::{
|
||||
create_transaction, deadline, evidence, genesis_id, namespace_on_shard, open_absent_root,
|
||||
reopen_after_close, submit, DEFAULT_MAX_INDEX_RUNS,
|
||||
};
|
||||
|
||||
const SHARD_COUNT: u16 = 2;
|
||||
|
||||
/// Authority-only: it moves the repository's state forward and introduces no
|
||||
/// object, so its frame contributes nothing to the index.
|
||||
fn objectless_transaction(namespace: NamespaceId, operation: u8) -> ValidatedTransaction {
|
||||
let authority = genesis_id(&namespace);
|
||||
ValidatedTransaction::builder(PrivilegedConstruction::assert_validated())
|
||||
.namespace(namespace)
|
||||
.operation(
|
||||
OperationId([operation; 16]),
|
||||
ObjectId([operation; 32]),
|
||||
deadline(),
|
||||
)
|
||||
.objects(Vec::new())
|
||||
.refs(Vec::new())
|
||||
.authority(Some(authority), Some(authority))
|
||||
.evidence(evidence())
|
||||
.build()
|
||||
.expect("an object-less transaction is complete")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transaction_introducing_no_objects_still_lets_the_shard_checkpoint_and_reopen() {
|
||||
let directory = tempfile::TempDir::new().expect("a temporary root");
|
||||
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
|
||||
|
||||
{
|
||||
let engine = open_absent_root(directory.path(), SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
|
||||
|
||||
// Sequence 0 carries the genesis authority object, so it stamps a layer
|
||||
// with an entry. Without it the shard would have no coverage at all and
|
||||
// the check under test would be skipped rather than exercised.
|
||||
let created = submit(&engine, create_transaction(namespace, 1));
|
||||
created
|
||||
.receipt()
|
||||
.unwrap_or_else(|| panic!("the repository must be created: {:?}", created.error()));
|
||||
|
||||
// Sequence 1 carries nothing. This is the frame whose coverage had no
|
||||
// representation.
|
||||
let empty = submit(&engine, objectless_transaction(namespace, 2));
|
||||
empty.receipt().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"an object-less transaction must commit: {:?}",
|
||||
empty.error()
|
||||
)
|
||||
});
|
||||
|
||||
engine.checkpoint().unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"the shard must checkpoint through a sequence that introduced no objects, but: \
|
||||
{error:?}"
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Through production recovery, because the checkpoint above is only correct
|
||||
// if what it wrote can be opened. A run sealed from the empty layer holds
|
||||
// fewer entries than the sequences it covers, and a reopen is what proves
|
||||
// that is a shape this store reads back rather than one it only writes.
|
||||
let reopened = reopen_after_close(directory.path(), SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
|
||||
let snapshot = reopened
|
||||
.snapshot(namespace)
|
||||
.expect("the repository survives");
|
||||
assert!(
|
||||
snapshot
|
||||
.locate(genesis_id(&namespace))
|
||||
.expect("locate")
|
||||
.is_some(),
|
||||
"the genesis authority object must still be findable after checkpoint and reopen; if the \
|
||||
empty layer sealed away the entries beside it, this is where that shows"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.current_authority(),
|
||||
genesis_id(&namespace),
|
||||
"the object-less transaction's own effect must survive the round trip too — it is the \
|
||||
frame whose coverage was missing, so a checkpoint that lost it would look like success"
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
//! Scope 3.4 — sealing the active journal and continuing in a fresh one.
|
||||
//!
|
||||
//! A journal is preallocated. When a forming group no longer fits it, the shard
|
||||
//! has to seal what it has into `segments/`, install the manifest generation
|
||||
//! that names it, and keep going in a new file. Until that exists the shard
|
||||
//! simply stops accepting work, which is the one failure mode a store cannot
|
||||
//! have: nothing about the transactions being refused is wrong, and no amount
|
||||
//! of retrying makes room.
|
||||
//!
|
||||
//! # What this asserts beyond "the submit succeeded"
|
||||
//!
|
||||
//! Rotation is easy to do in a way that passes a liveness test and loses data.
|
||||
//! The frames sealed into the segment are still the only copy of everything
|
||||
//! committed before the rotation, and every `IndexLocation` naming them was
|
||||
//! written against the *tail's* logical generation — so a rotation that
|
||||
//! installs the segment under a different generation, or that fails to retain
|
||||
//! it, leaves those objects indexed at a generation nothing resolves. The
|
||||
//! assertions here therefore span the rotation in both directions: objects
|
||||
//! written before it must still locate after it, and the whole thing must
|
||||
//! survive a reopen through production recovery.
|
||||
//!
|
||||
//! §3.4's link-then-unlink ordering exists for the crash window in the middle
|
||||
//! of that sequence. This file does not crash the process — that is the crash
|
||||
//! matrix's job — but it does prove the non-crash path leaves a root recovery
|
||||
//! reopens without replaying the sealed prefix twice.
|
||||
|
||||
#![cfg(all(
|
||||
feature = "store-privileged",
|
||||
feature = "store-internals",
|
||||
feature = "failpoints"
|
||||
))]
|
||||
|
||||
use levcs_core::{ObjectId, ObjectType};
|
||||
use levcs_store::transaction::StagedObject;
|
||||
use levcs_store::types::{NamespaceId, OperationId, PrivilegedConstruction};
|
||||
use levcs_store::{StoreEngine, StoreOptions, ValidatedTransaction};
|
||||
|
||||
#[path = "support/engine_matrix.rs"]
|
||||
mod engine_matrix;
|
||||
|
||||
use engine_matrix::{
|
||||
create_transaction, deadline, evidence, genesis_id, namespace_on_shard, submit,
|
||||
DEFAULT_MAX_INDEX_RUNS,
|
||||
};
|
||||
|
||||
const SHARD_COUNT: u16 = 1;
|
||||
/// Small enough that a handful of transactions fills it, and still at least
|
||||
/// `max_group_bytes`, which `StoreOptions::validate` requires.
|
||||
const JOURNAL_BYTES: u64 = 64 * 1024;
|
||||
const GROUP_BYTES: u64 = 8 * 1024;
|
||||
/// Four kilobytes per transaction, so the journal fills in about a dozen
|
||||
/// commits rather than a thousand.
|
||||
const OBJECT_BYTES: usize = 4 * 1024;
|
||||
|
||||
fn options(root: &std::path::Path) -> StoreOptions {
|
||||
let mut options =
|
||||
engine_matrix::options_with_index_runs(root, SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
|
||||
options.journal_preallocate_bytes = JOURNAL_BYTES;
|
||||
options.segment_max_bytes = JOURNAL_BYTES;
|
||||
options.max_group_bytes = GROUP_BYTES;
|
||||
options
|
||||
}
|
||||
|
||||
fn blob_id(seed: u16) -> ObjectId {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes[..2].copy_from_slice(&seed.to_le_bytes());
|
||||
ObjectId(bytes)
|
||||
}
|
||||
|
||||
/// A transaction carrying one sizeable object, so the journal fills quickly.
|
||||
fn filling_transaction(namespace: NamespaceId, seed: u16) -> ValidatedTransaction {
|
||||
let authority = genesis_id(&namespace);
|
||||
let mut operation = [0u8; 16];
|
||||
operation[..2].copy_from_slice(&seed.to_le_bytes());
|
||||
ValidatedTransaction::builder(PrivilegedConstruction::assert_validated())
|
||||
.namespace(namespace)
|
||||
.operation(
|
||||
OperationId(operation),
|
||||
ObjectId([seed as u8; 32]),
|
||||
deadline(),
|
||||
)
|
||||
.objects(vec![StagedObject {
|
||||
id: blob_id(seed),
|
||||
object_type: ObjectType::Blob,
|
||||
raw: vec![seed as u8; OBJECT_BYTES],
|
||||
}])
|
||||
.refs(Vec::new())
|
||||
.authority(Some(authority), Some(authority))
|
||||
.evidence(evidence())
|
||||
.build()
|
||||
.expect("a complete filling transaction")
|
||||
}
|
||||
|
||||
fn segment_count(root: &std::path::Path) -> usize {
|
||||
let segments = root.join("shards").join("00").join("segments");
|
||||
std::fs::read_dir(&segments)
|
||||
.map(|entries| entries.filter_map(Result::ok).count())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `segment_max_bytes` is a rotation trigger in its own right.
|
||||
///
|
||||
/// `Journal::should_rotate` names two boundaries — the preallocated length and
|
||||
/// the configured segment size, whichever comes first — and only the first one
|
||||
/// is a question about whether the next group *fits*. A shard that rotates
|
||||
/// solely on fit runs to the end of its preallocation however small the segment
|
||||
/// ceiling is, and produces segments many times the configured maximum.
|
||||
///
|
||||
/// The two limits are deliberately far apart here. The main test above sets
|
||||
/// them equal, which is the realistic default and also the one arrangement
|
||||
/// where this defect is invisible.
|
||||
#[test]
|
||||
fn a_segment_ceiling_below_the_preallocation_rotates_first() {
|
||||
let directory = tempfile::TempDir::new().expect("a temporary root");
|
||||
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
|
||||
|
||||
let mut options = options(directory.path());
|
||||
// Room for many groups in the file, but a segment ceiling reached after a
|
||||
// handful of them.
|
||||
options.journal_preallocate_bytes = 1024 * 1024;
|
||||
options.segment_max_bytes = 24 * 1024;
|
||||
|
||||
let engine = StoreEngine::open(options).expect("the root initializes");
|
||||
let created = submit(&engine, create_transaction(namespace, 1));
|
||||
created
|
||||
.receipt()
|
||||
.unwrap_or_else(|| panic!("the repository must be created: {:?}", created.error()));
|
||||
|
||||
const WRITES: u16 = 24;
|
||||
for seed in 1..=WRITES {
|
||||
let outcome = submit(&engine, filling_transaction(namespace, seed));
|
||||
outcome
|
||||
.receipt()
|
||||
.unwrap_or_else(|| panic!("write {seed} must commit: {:?}", outcome.error()));
|
||||
}
|
||||
|
||||
// Roughly 96 KiB written against a 24 KiB ceiling, so several segments.
|
||||
// Asserted as "more than one" rather than an exact count: a segment may
|
||||
// overshoot the ceiling by up to one group, because the boundary is checked
|
||||
// before a group is added and not in the middle of one.
|
||||
let sealed = segment_count(directory.path());
|
||||
assert!(
|
||||
sealed > 1,
|
||||
"with segment_max_bytes far below journal_preallocate_bytes the shard must seal on \
|
||||
the segment ceiling, but only {sealed} segment(s) exist after {WRITES} writes"
|
||||
);
|
||||
|
||||
for seed in 1..=WRITES {
|
||||
assert!(
|
||||
engine
|
||||
.snapshot(namespace)
|
||||
.expect("snapshot")
|
||||
.locate(blob_id(seed))
|
||||
.expect("locate")
|
||||
.is_some(),
|
||||
"object {seed} must survive a segment-ceiling rotation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame that no rotation can ever seat is a ceiling, not a rotation.
|
||||
///
|
||||
/// The distinction is off by exactly the journal header. A fresh journal's
|
||||
/// cursor starts past its own header, so the space a rotation actually offers
|
||||
/// is the preallocation *less* that header. A frame in between — small enough
|
||||
/// for the preallocation, too large once the header is there — was classified
|
||||
/// as rotatable, and the retry against the fresh journal failed the same test
|
||||
/// again. The caller was promised `LimitExceeded` and got a second rotation
|
||||
/// instead.
|
||||
#[test]
|
||||
fn a_frame_larger_than_a_fresh_journal_is_refused_rather_than_rotated() {
|
||||
let directory = tempfile::TempDir::new().expect("a temporary root");
|
||||
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
|
||||
|
||||
// The object is sized to land in the gap: its frame fits
|
||||
// `journal_preallocate_bytes` outright but not once the header is
|
||||
// accounted for. `max_group_bytes` is raised past it so the group ceiling
|
||||
// is not what refuses first -- this must be the journal's answer.
|
||||
const PREALLOCATED: u64 = 32 * 1024;
|
||||
let mut options = options(directory.path());
|
||||
options.journal_preallocate_bytes = PREALLOCATED;
|
||||
options.segment_max_bytes = PREALLOCATED;
|
||||
options.max_group_bytes = PREALLOCATED;
|
||||
|
||||
let engine = StoreEngine::open(options).expect("the root initializes");
|
||||
let created = submit(&engine, create_transaction(namespace, 1));
|
||||
created
|
||||
.receipt()
|
||||
.unwrap_or_else(|| panic!("the repository must be created: {:?}", created.error()));
|
||||
|
||||
// Just under the preallocation, so the frame around it is over the usable
|
||||
// space by roughly the header.
|
||||
let oversized = ValidatedTransaction::builder(PrivilegedConstruction::assert_validated())
|
||||
.namespace(namespace)
|
||||
.operation(OperationId([0xee; 16]), ObjectId([0xee; 32]), deadline())
|
||||
.objects(vec![StagedObject {
|
||||
id: blob_id(0xeee),
|
||||
object_type: ObjectType::Blob,
|
||||
raw: vec![0xee; PREALLOCATED as usize - 768],
|
||||
}])
|
||||
.refs(Vec::new())
|
||||
.authority(Some(genesis_id(&namespace)), Some(genesis_id(&namespace)))
|
||||
.evidence(evidence())
|
||||
.build()
|
||||
.expect("the transaction itself is well formed");
|
||||
|
||||
let outcome = submit(&engine, oversized);
|
||||
match outcome.error() {
|
||||
Some(levcs_store::types::StoreError::LimitExceeded {
|
||||
limit,
|
||||
observed,
|
||||
allowed,
|
||||
}) => {
|
||||
assert_eq!(
|
||||
*limit, "journal_preallocate_bytes",
|
||||
"the refusal must name the ceiling that stopped it"
|
||||
);
|
||||
// The reported ceiling is what makes this test see the off-by-one
|
||||
// rather than merely see a refusal. A frame can be far enough over
|
||||
// to be refused either way; what distinguishes the two is *which*
|
||||
// ceiling the store believes it has. A fresh journal's cursor
|
||||
// starts past its header, so the space a rotation can offer is the
|
||||
// preallocation less that header, and reporting the whole
|
||||
// preallocation means frames in the gap were called rotatable.
|
||||
assert_eq!(
|
||||
*allowed,
|
||||
PREALLOCATED - levcs_store::format::JOURNAL_HEADER_LEN as u64,
|
||||
"the ceiling must be the space a fresh journal actually offers, not the raw \
|
||||
preallocation"
|
||||
);
|
||||
assert!(
|
||||
*observed > *allowed,
|
||||
"the refusal must report a frame that genuinely exceeds the ceiling"
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"a frame no fresh journal can hold must be refused as a ceiling, not retried as a \
|
||||
rotation, but submit answered {other:?}"
|
||||
),
|
||||
}
|
||||
|
||||
// And the shard is still usable: a ceiling is a refusal of one transaction,
|
||||
// not a wedged writer.
|
||||
let after = submit(&engine, filling_transaction(namespace, 1));
|
||||
after
|
||||
.receipt()
|
||||
.unwrap_or_else(|| panic!("an ordinary write must still commit: {:?}", after.error()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_active_journal_seals_and_the_shard_keeps_committing() {
|
||||
let directory = tempfile::TempDir::new().expect("a temporary root");
|
||||
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
|
||||
|
||||
// Enough transactions to overrun the preallocated journal several times
|
||||
// over, so the test exercises repeated rotation rather than one boundary.
|
||||
const WRITES: u16 = 48;
|
||||
|
||||
{
|
||||
let engine = StoreEngine::open(options(directory.path())).expect("the root initializes");
|
||||
let created = submit(&engine, create_transaction(namespace, 1));
|
||||
created
|
||||
.receipt()
|
||||
.unwrap_or_else(|| panic!("the repository must be created: {:?}", created.error()));
|
||||
|
||||
for seed in 1..=WRITES {
|
||||
let outcome = submit(&engine, filling_transaction(namespace, seed));
|
||||
outcome.receipt().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"write {seed} of {WRITES} must commit; the journal filling is a rotation, \
|
||||
not a ceiling: {:?}",
|
||||
outcome.error()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Before the reopen, because a rotation that lost the segment would
|
||||
// still answer correctly from a root that has not been rebuilt yet.
|
||||
let snapshot = engine.snapshot(namespace).expect("snapshot");
|
||||
for seed in [1u16, WRITES / 2, WRITES] {
|
||||
assert!(
|
||||
snapshot.locate(blob_id(seed)).expect("locate").is_some(),
|
||||
"object {seed} was committed before a rotation and must still be located \
|
||||
after it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Through production recovery: the sealed segments are now the only copy of
|
||||
// everything but the last journal's frames, and a reopen is what proves the
|
||||
// manifest names them.
|
||||
let reopened = StoreEngine::open(options(directory.path())).expect("the root reopens");
|
||||
let snapshot = reopened.snapshot(namespace).expect("snapshot");
|
||||
for seed in 1..=WRITES {
|
||||
assert!(
|
||||
snapshot.locate(blob_id(seed)).expect("locate").is_some(),
|
||||
"object {seed} did not survive the reopen; a sealed prefix the manifest does not \
|
||||
name is a fenced, acknowledged transaction that recovery cannot find"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
//! Scope 6-B1 deliverable 3 and scope 6.2 item 9 — `adopt_projection` through
|
||||
//! `StoreEngine::submit`, asserted through the public surface.
|
||||
//!
|
||||
//! The property under test is **ownership**, not visibility. An adopted
|
||||
//! projection's objects are not in the frame that installed them: the frame
|
||||
//! carries a descriptor, and the bytes stay in the artifacts staging sealed. So
|
||||
//! two things have to be true at once, and each has a distinct way of being
|
||||
//! wrong:
|
||||
//!
|
||||
//! 1. Each object is indexed at its **artifact's** logical generation, not at
|
||||
//! the frame's. Indexing at the frame location would still make `locate`
|
||||
//! return `Some`, and a reader following it would land at an offset inside a
|
||||
//! journal frame that contains a descriptor rather than the object — a
|
||||
//! plausible-looking answer that decodes into nothing.
|
||||
//! 2. Those artifact generations survive the next retained generation. A
|
||||
//! checkpoint publishes a successor and releases the predecessor's pins; an
|
||||
//! adoption whose artifacts are not carried forward reads correctly exactly
|
||||
//! once and then resolves to nothing, with no error at the moment the
|
||||
//! mistake is made.
|
||||
//!
|
||||
//! One test catches both, which is why it is the first one written: locate and
|
||||
//! resolve every object, checkpoint into a successor generation, then locate
|
||||
//! and resolve all of them again. Failure 1 shows up in the first resolve,
|
||||
//! failure 2 only in the second.
|
||||
//!
|
||||
//! **At least two chunks, always.** A single-chunk projection has exactly one
|
||||
//! artifact, so "the index resolved every object to the right artifact" and
|
||||
//! "the index resolved every object to the only artifact there is" are the same
|
||||
//! observation, and B3 defines and resolves one `IndexLocation` per chunk. Two
|
||||
//! chunks is the smallest arrangement where per-chunk ownership is load-bearing.
|
||||
|
||||
// The same three-feature gate as `namespace_snapshot.rs`, for the same reason:
|
||||
// `engine_matrix` arms failpoints and reaches `drive.rs`, so it compiles only
|
||||
// under the full set, and the Phase 1 gate runs exactly this combination.
|
||||
#![cfg(all(
|
||||
feature = "store-privileged",
|
||||
feature = "store-internals",
|
||||
feature = "failpoints"
|
||||
))]
|
||||
|
||||
use levcs_core::{blake3_hash, ObjectHeader, ObjectId, ObjectType, FORMAT_VERSION};
|
||||
use levcs_protocol::v2::{
|
||||
ProjectionMode, ProjectionStageChunkV1, ProjectionStageManifestV1, ProjectionStageSessionV1,
|
||||
StageSourceKindV1, StagedChunkObjectV1, StagedObjectV1,
|
||||
};
|
||||
use levcs_store::staging::{ProjectionStageBinding, StagedProjectionAdoption};
|
||||
use levcs_store::types::NamespaceId;
|
||||
use levcs_store::{RetainedObjectSource, StoreEngine, ValidatedTransaction};
|
||||
|
||||
#[path = "support/engine_matrix.rs"]
|
||||
mod engine_matrix;
|
||||
|
||||
use engine_matrix::{
|
||||
create_transaction, deadline, evidence, genesis_id, namespace_on_shard, now_micros,
|
||||
open_absent_root, submit, DEFAULT_MAX_INDEX_RUNS,
|
||||
};
|
||||
|
||||
const SHARD_COUNT: u16 = 2;
|
||||
const CHUNKS: u32 = 2;
|
||||
const PER_CHUNK: usize = 2;
|
||||
const HOUR_MICROS: i64 = 3_600_000_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A projection bound to a repository the engine actually created
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Staged {
|
||||
binding: ProjectionStageBinding,
|
||||
chunks: Vec<ProjectionStageChunkV1>,
|
||||
}
|
||||
|
||||
impl Staged {
|
||||
/// Every object across every chunk, in no particular order — the test
|
||||
/// asserts about all of them and never about their arrangement.
|
||||
fn object_ids(&self) -> Vec<ObjectId> {
|
||||
self.chunks
|
||||
.iter()
|
||||
.flat_map(|chunk| chunk.objects.iter())
|
||||
.map(|object| object.descriptor.object_id)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn blob(body: &[u8]) -> StagedChunkObjectV1 {
|
||||
let mut raw = ObjectHeader {
|
||||
object_type: ObjectType::Blob,
|
||||
format_version: FORMAT_VERSION,
|
||||
body_len: body.len() as u64,
|
||||
}
|
||||
.encode()
|
||||
.to_vec();
|
||||
raw.extend_from_slice(body);
|
||||
let id = blake3_hash(&raw);
|
||||
StagedChunkObjectV1 {
|
||||
descriptor: StagedObjectV1 {
|
||||
object_id: id,
|
||||
object_type: ObjectType::Blob as u8,
|
||||
raw_len: raw.len() as u64,
|
||||
raw_digest: id,
|
||||
},
|
||||
raw_bytes: raw,
|
||||
}
|
||||
}
|
||||
|
||||
/// A self-consistent projection bound to `namespace`.
|
||||
///
|
||||
/// The binding's `destination_repo`, `destination_genesis`, and
|
||||
/// `expected_authority` are taken from the repository the engine created rather
|
||||
/// than from constants. A projection bound to a repository that does not exist
|
||||
/// is a different refusal entirely, and one bound to the wrong authority is
|
||||
/// another; neither is what this file is about.
|
||||
fn staged_projection(namespace: NamespaceId, session_id: [u8; 16], actor: [u8; 32]) -> Staged {
|
||||
let total = CHUNKS as usize * PER_CHUNK;
|
||||
let mut objects: Vec<StagedChunkObjectV1> = (0..total)
|
||||
.map(|index| blob(format!("adopted-{}-{index}", hex::encode(session_id)).as_bytes()))
|
||||
.collect();
|
||||
// The manifest is the ordered concatenation of the chunks and must be
|
||||
// strictly sorted, so the sort happens before the split, not inside each
|
||||
// chunk.
|
||||
objects.sort_by(|left, right| left.descriptor.cmp(&right.descriptor));
|
||||
|
||||
let total_object_bytes = objects
|
||||
.iter()
|
||||
.map(|object| object.descriptor.raw_len)
|
||||
.sum::<u64>();
|
||||
let descriptors: Vec<StagedObjectV1> = objects
|
||||
.iter()
|
||||
.map(|object| object.descriptor.clone())
|
||||
.collect();
|
||||
|
||||
let chunks: Vec<ProjectionStageChunkV1> = (0..CHUNKS)
|
||||
.map(|ordinal| {
|
||||
let start = ordinal as usize * PER_CHUNK;
|
||||
ProjectionStageChunkV1 {
|
||||
session_id,
|
||||
ordinal,
|
||||
chunk_count: CHUNKS,
|
||||
objects: objects[start..start + PER_CHUNK].to_vec(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let chunk_digests: Vec<ObjectId> = chunks
|
||||
.iter()
|
||||
.map(|chunk| chunk.chunk_digest().expect("chunk digest"))
|
||||
.collect();
|
||||
|
||||
// The instance layer's commitment. B3 stores it and proves the manifest
|
||||
// digest binds it; nothing in the store evaluates it.
|
||||
let membership_root = blake3_hash(&session_id[..]);
|
||||
let manifest = ProjectionStageManifestV1 {
|
||||
session_id,
|
||||
chunk_digests,
|
||||
objects: descriptors,
|
||||
membership_root,
|
||||
};
|
||||
let manifest_digest = manifest.manifest_digest().expect("manifest digest");
|
||||
let authority = genesis_id(&namespace);
|
||||
|
||||
Staged {
|
||||
binding: ProjectionStageBinding {
|
||||
session: ProjectionStageSessionV1 {
|
||||
session_id,
|
||||
destination_repo: ObjectId(*namespace.as_bytes()),
|
||||
destination_genesis: authority,
|
||||
expected_authority: authority,
|
||||
projection: ProjectionMode::Full,
|
||||
source_kind: StageSourceKindV1::Mirror,
|
||||
actor,
|
||||
actor_key_epoch: 3,
|
||||
source_generation_digest: ObjectId([12; 32]),
|
||||
fork_proof: None,
|
||||
final_operation_id: [13; 16],
|
||||
final_operation_digest: ObjectId([14; 32]),
|
||||
final_evidence_digest: ObjectId([15; 32]),
|
||||
total_object_count: total as u64,
|
||||
total_object_bytes,
|
||||
chunk_count: CHUNKS,
|
||||
manifest_digest,
|
||||
expires_at_micros: now_micros() + HOUR_MICROS,
|
||||
},
|
||||
membership_root,
|
||||
},
|
||||
chunks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage the projection through the engine's own staging and take the pin.
|
||||
///
|
||||
/// Through `engine.staging()` and not a second `ProjectionStaging`: staging's
|
||||
/// ceilings are root-global and its constructor demands the root lock, so a
|
||||
/// test that opened its own would be a second accountant for one root and would
|
||||
/// prove the property against a staging the store does not use.
|
||||
fn stage_and_finalize(engine: &StoreEngine, staged: &Staged) -> StagedProjectionAdoption {
|
||||
let session = engine
|
||||
.staging()
|
||||
.begin(staged.binding.clone(), now_micros())
|
||||
.expect("staging admits the session");
|
||||
for chunk in &staged.chunks {
|
||||
session.put_chunk(chunk, now_micros()).expect("chunk lands");
|
||||
}
|
||||
session.seal(now_micros()).expect("the session seals");
|
||||
session
|
||||
.finalize(now_micros())
|
||||
.expect("the sealed session yields an adoption pin")
|
||||
}
|
||||
|
||||
fn adopting_transaction(
|
||||
namespace: NamespaceId,
|
||||
operation: u8,
|
||||
adoption: StagedProjectionAdoption,
|
||||
) -> ValidatedTransaction {
|
||||
let authority = genesis_id(&namespace);
|
||||
ValidatedTransaction::builder(levcs_store::types::PrivilegedConstruction::assert_validated())
|
||||
.namespace(namespace)
|
||||
.operation(
|
||||
levcs_store::types::OperationId([operation; 16]),
|
||||
ObjectId([operation; 32]),
|
||||
deadline(),
|
||||
)
|
||||
// Empty, and not a convenience: `FrameObjectsV1` is an enum, so a frame
|
||||
// carries inline objects or an install descriptor and never both. The
|
||||
// projection *is* this transaction's payload.
|
||||
.objects(Vec::new())
|
||||
.refs(Vec::new())
|
||||
.authority(Some(authority), Some(authority))
|
||||
.evidence(evidence())
|
||||
.adopt_projection(adoption)
|
||||
.expect("the builder admits one adoption")
|
||||
.build()
|
||||
.expect("a complete adopting transaction")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn an_adopted_projection_is_owned_by_its_artifacts_and_survives_a_checkpoint() {
|
||||
let directory = tempfile::TempDir::new().expect("a temporary root");
|
||||
let engine = open_absent_root(directory.path(), SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
|
||||
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
|
||||
|
||||
submit(&engine, create_transaction(namespace, 1))
|
||||
.receipt()
|
||||
.expect("the repository is created");
|
||||
|
||||
let staged = staged_projection(namespace, [25; 16], [21; 32]);
|
||||
let ids = staged.object_ids();
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
CHUNKS as usize * PER_CHUNK,
|
||||
"the fixture must produce one object per chunk slot"
|
||||
);
|
||||
|
||||
// Nothing staged is visible before a submit adopts it. Asserted before the
|
||||
// adoption so a later `Some` is evidence the adoption produced it, rather
|
||||
// than something that was always there.
|
||||
let before = engine.snapshot(namespace).expect("snapshot");
|
||||
for id in &ids {
|
||||
assert_eq!(
|
||||
before.locate(*id).expect("locate"),
|
||||
None,
|
||||
"a sealed but unadopted object must not be visible"
|
||||
);
|
||||
}
|
||||
|
||||
let adoption = stage_and_finalize(&engine, &staged);
|
||||
let committed = submit(&engine, adopting_transaction(namespace, 2, adoption));
|
||||
committed
|
||||
.receipt()
|
||||
.unwrap_or_else(|| panic!("the adoption must commit: {:?}", committed.error()));
|
||||
|
||||
let adopted = engine.snapshot(namespace).expect("snapshot");
|
||||
let generations = resolve_all(&adopted, &ids, "immediately after adoption");
|
||||
|
||||
// A checkpoint publishes a successor retained generation and releases the
|
||||
// predecessor's pins. This is the moment an adoption that was never carried
|
||||
// forward stops resolving.
|
||||
engine.checkpoint().expect("the store checkpoints");
|
||||
|
||||
let after = engine.snapshot(namespace).expect("snapshot");
|
||||
let survived = resolve_all(&after, &ids, "after a checkpoint");
|
||||
|
||||
assert_eq!(
|
||||
generations, survived,
|
||||
"an adopted object's generation must not move when a successor generation is published; \
|
||||
the artifacts are the same files either side of the checkpoint"
|
||||
);
|
||||
}
|
||||
|
||||
/// Staging outlives the engine, so the root lock has to outlive it too.
|
||||
///
|
||||
/// `StoreEngine::staging` hands out a reference, but a reference is not a
|
||||
/// lifetime bound on what a caller may keep: `Arc::clone` escapes it, and so do
|
||||
/// a `ProjectionStageSession` and an adoption pin, each of which owns a clone.
|
||||
/// If `LOCK` were released when the engine dropped, any of those could still
|
||||
/// write into a root another process had since opened.
|
||||
///
|
||||
/// Asserted against `RecoverySession::open` rather than against a second
|
||||
/// `StoreEngine::open`, deliberately. Staging keeps an in-process registry of
|
||||
/// open roots, so an engine open would be refused by *that* whether or not the
|
||||
/// lock were held — a passing test proving nothing about the lock. Taking the
|
||||
/// lock directly is the only form of this assertion that fails when the lease
|
||||
/// is not retained.
|
||||
#[test]
|
||||
fn a_retained_staging_holds_the_root_lock_past_the_engine() {
|
||||
let directory = tempfile::TempDir::new().expect("a temporary root");
|
||||
let engine = open_absent_root(directory.path(), SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
|
||||
let staging = std::sync::Arc::clone(engine.staging());
|
||||
drop(engine);
|
||||
|
||||
let contested = levcs_store::recovery::RecoverySession::open(directory.path());
|
||||
assert!(
|
||||
contested.is_err(),
|
||||
"the root lock must still be held while staging is retained, but a second session took \
|
||||
it: {contested:?}"
|
||||
);
|
||||
|
||||
drop(staging);
|
||||
levcs_store::recovery::RecoverySession::open(directory.path())
|
||||
.expect("the lock is released once the last staging reference is gone");
|
||||
}
|
||||
|
||||
/// Locate every id, require an artifact source for each, and hand back the
|
||||
/// generations so the caller can compare them across a checkpoint.
|
||||
///
|
||||
/// Requiring `ProjectionArtifact` and not merely `Some` is the whole point:
|
||||
/// an object indexed at the installing frame's location resolves to a tail or a
|
||||
/// segment and would satisfy a `Some` assertion perfectly.
|
||||
fn resolve_all(
|
||||
snapshot: &levcs_store::RepoSnapshot,
|
||||
ids: &[ObjectId],
|
||||
when: &str,
|
||||
) -> Vec<(ObjectId, u64)> {
|
||||
let mut generations = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
let location = snapshot
|
||||
.locate(*id)
|
||||
.expect("locate")
|
||||
.unwrap_or_else(|| panic!("object {} is not visible {when}", hex::encode(id.0)));
|
||||
let source = snapshot
|
||||
.object_source(&location)
|
||||
.expect("resolving a location this snapshot produced")
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"object {} resolves to no retained source {when}: generation {} is not \
|
||||
retained, so its artifact was not carried forward",
|
||||
hex::encode(id.0),
|
||||
location.segment_generation
|
||||
)
|
||||
});
|
||||
match source {
|
||||
RetainedObjectSource::ProjectionArtifact(_) => {}
|
||||
other => panic!(
|
||||
"object {} resolves to {:?} {when}, not a projection artifact: it was indexed at \
|
||||
the installing frame's location rather than at its chunk's",
|
||||
hex::encode(id.0),
|
||||
std::mem::discriminant(&other)
|
||||
),
|
||||
}
|
||||
generations.push((*id, location.segment_generation));
|
||||
}
|
||||
generations
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ const HOUR_MICROS: i64 = 3_600_000_000;
|
|||
/// a constructor production cannot reach.
|
||||
struct Root {
|
||||
directory: TempDir,
|
||||
lock: RecoverySession,
|
||||
lock: Arc<RecoverySession>,
|
||||
options: StoreOptions,
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ impl Root {
|
|||
&DurabilityCounters::default(),
|
||||
)
|
||||
.expect("v2 root layout");
|
||||
let lock = RecoverySession::open(directory.path()).expect("root lock");
|
||||
let lock = Arc::new(RecoverySession::open(directory.path()).expect("root lock"));
|
||||
Self {
|
||||
directory,
|
||||
lock,
|
||||
|
|
@ -83,7 +83,7 @@ impl Root {
|
|||
}
|
||||
|
||||
fn open_with(&self, durability: Arc<DurabilityCounters>) -> Arc<ProjectionStaging> {
|
||||
ProjectionStaging::open(&self.lock, self.options.clone(), durability)
|
||||
ProjectionStaging::open(Arc::clone(&self.lock), self.options.clone(), durability)
|
||||
.expect("staging root opens")
|
||||
}
|
||||
|
||||
|
|
@ -1091,7 +1091,7 @@ fn one_root_admits_exactly_one_staging_accountant() {
|
|||
let (_first, _durability) = root.open();
|
||||
|
||||
let result = ProjectionStaging::open(
|
||||
&root.lock,
|
||||
Arc::clone(&root.lock),
|
||||
root.options.clone(),
|
||||
Arc::new(DurabilityCounters::default()),
|
||||
);
|
||||
|
|
@ -1108,7 +1108,7 @@ fn a_root_lock_held_on_another_root_is_not_proof() {
|
|||
let other = Root::new();
|
||||
|
||||
let result = ProjectionStaging::open(
|
||||
&other.lock,
|
||||
Arc::clone(&other.lock),
|
||||
owner.options.clone(),
|
||||
Arc::new(DurabilityCounters::default()),
|
||||
);
|
||||
|
|
@ -1189,20 +1189,21 @@ fn session_of(
|
|||
/// that the staged objects are invisible, and that they become visible only
|
||||
/// after a `submit` adopts the descriptor.
|
||||
///
|
||||
/// Still blocked on B1 NamespaceTxn, and blocked in one more place than before.
|
||||
/// `RepoSnapshot::locate`, `StoreEngine::snapshot`, and the submit path are all
|
||||
/// `NotImplemented`/`unimplemented!` today. On top of that, the P1-4 fix moved
|
||||
/// staging inside the locked engine lifetime: this test can no longer open its
|
||||
/// own `ProjectionStaging` beside a `StoreEngine`, because holding two root
|
||||
/// locks is exactly what was made unrepresentable. It needs an accessor on the
|
||||
/// engine — recorded as an interface request to B1 — to reach the staging the
|
||||
/// engine owns.
|
||||
/// Still blocked on B1 NamespaceTxn, but in one place now rather than three.
|
||||
/// `RepoSnapshot::locate` and `StoreEngine::snapshot` are implemented (scope
|
||||
/// 6-B1 deliverable 8), and the interface request this comment used to record —
|
||||
/// an engine accessor for the staging the engine owns, needed because the P1-4
|
||||
/// fix made holding two root locks unrepresentable — was granted as
|
||||
/// `StoreEngine::staging()` under contract review 2026-08-09-A. What remains is
|
||||
/// the submit path: it refuses an adopting transaction, settling the pin before
|
||||
/// anything is queued, until deliverable 3 wires the descriptor payload, the
|
||||
/// pre-append revalidation, and the post-fence artifact merge.
|
||||
///
|
||||
/// Asserting the property against staging's own state instead would be charter
|
||||
/// item 8 exactly — a property proved against the helper rather than the path
|
||||
/// that runs — so it is marked blocked rather than satisfied the wrong way.
|
||||
#[test]
|
||||
#[ignore = "blocked on B1 NamespaceTxn: needs RepoSnapshot::locate, StoreEngine::snapshot/submit (scope 6.4 deliverables 1, 3-8), and a StoreEngine accessor for the engine-owned ProjectionStaging"]
|
||||
#[ignore = "blocked on B1 NamespaceTxn: StoreEngine::submit refuses an adopting transaction until scope 6-B1 deliverable 3 wires it (the descriptor payload, the pre-append revalidation, and the post-fence artifact merge)"]
|
||||
fn sealed_objects_stay_invisible_until_a_submit_adopts_them() {
|
||||
let root = Root::new();
|
||||
let fixture = projection([25; 16], [21; 32], 1, 2, HOUR_MICROS);
|
||||
|
|
@ -1228,8 +1229,8 @@ fn sealed_objects_stay_invisible_until_a_submit_adopts_them() {
|
|||
}
|
||||
|
||||
unimplemented!(
|
||||
"B1: expose the engine-owned ProjectionStaging, begin/put/seal a session through it, \
|
||||
re-assert locate() is None for every staged object, then build a ValidatedTransaction \
|
||||
with adopt_projection(install, handle), submit it, and assert locate() returns Some"
|
||||
"B1: begin/put/seal a session through engine.staging(), re-assert locate() is None for \
|
||||
every staged object, then finalize() it and build a ValidatedTransaction with \
|
||||
adopt_projection(adoption), submit it, and assert locate() returns Some"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,29 +133,22 @@ impl CommitEvidenceSigner for CountingSigner {
|
|||
}
|
||||
}
|
||||
|
||||
/// `StoreOptions::max_index_runs`, the ceiling this slice reaches soonest.
|
||||
/// `StoreOptions::max_index_runs`, at the value a deployment runs.
|
||||
///
|
||||
/// Publishing a group adds one in-memory index delta layer, and B1's slice
|
||||
/// seals none of them into an `IndexRun` — deliverable 1 refuses by name. So
|
||||
/// `submit` refuses `NotImplemented` after exactly `max_index_runs` group
|
||||
/// publications for the life of an engine, root-wide, whatever the workload.
|
||||
/// At the shipping default of 64 that is 64 commits.
|
||||
/// **The weakening this note used to disclose is gone.** It said that B1's
|
||||
/// slice sealed no delta layer into an `IndexRun`, so `submit` refused
|
||||
/// `NotImplemented` after exactly `max_index_runs` group publications and a
|
||||
/// driver needing more had to raise the ceiling to run at all. Sealing is
|
||||
/// implemented: the ceiling is drained by sealing rather than reached by
|
||||
/// refusing, no `NotImplemented` refusal remains in the engine's production
|
||||
/// paths, and a 45-second soak at this value published 5,192 groups — 81× the
|
||||
/// ceiling — at a flat rate.
|
||||
///
|
||||
/// The single-row driver leaves the default alone: three submits per row is
|
||||
/// nowhere near it, and a row driven under a non-default ceiling would be a row
|
||||
/// driven against a store nobody ships. The contention driver raises it,
|
||||
/// because it must publish groups until two of them collide and the collision
|
||||
/// is not on a schedule. **That is a disclosed weakening**: the raised value is
|
||||
/// a real configuration, not a bypass, but it configures around a missing
|
||||
/// deliverable rather than around a tuning choice, and it is the same
|
||||
/// unimplemented seal that caps the benchmark.
|
||||
/// So the default is what every driver here uses, which is the point: a row
|
||||
/// driven under a non-default ceiling is a row driven against a store nobody
|
||||
/// ships.
|
||||
pub const DEFAULT_MAX_INDEX_RUNS: u32 = 64;
|
||||
|
||||
/// Enough that the collision search is never the thing that ends the run.
|
||||
/// Measured: the failpoint fires within 6 to 64 group publications, and the
|
||||
/// eight repository creations consume eight of them.
|
||||
pub const CONTENTION_MAX_INDEX_RUNS: u32 = 4096;
|
||||
|
||||
pub fn options(root: &Path, shard_count: u16) -> StoreOptions {
|
||||
options_with_index_runs(root, shard_count, DEFAULT_MAX_INDEX_RUNS)
|
||||
}
|
||||
|
|
@ -183,7 +176,13 @@ pub fn options_with_index_runs(root: &Path, shard_count: u16, max_index_runs: u3
|
|||
/// The client principal, deliberately not the signer's public key.
|
||||
const EVIDENCE_ACTOR: [u8; 32] = [0x7e; 32];
|
||||
|
||||
fn evidence() -> TransactionEvidenceV1 {
|
||||
/// Public because an adopting transaction cannot be assembled from
|
||||
/// [`create_transaction`] or [`push_transaction`]: it carries no inline
|
||||
/// objects, so B1's projection-adoption tests build their own and need the same
|
||||
/// evidence, deadline, and genesis authority these two produce. Two independent
|
||||
/// notions of "the authority for this namespace" in one test binary would make
|
||||
/// a mismatched-authority refusal look like a harness bug.
|
||||
pub fn evidence() -> TransactionEvidenceV1 {
|
||||
TransactionEvidenceV1::AdministrativeV1 {
|
||||
actor: EVIDENCE_ACTOR,
|
||||
actor_key_epoch: 11,
|
||||
|
|
@ -192,18 +191,20 @@ fn evidence() -> TransactionEvidenceV1 {
|
|||
}
|
||||
}
|
||||
|
||||
fn now_micros() -> i64 {
|
||||
/// Public for the same reason as [`evidence`]: staging's `begin`, `put_chunk`,
|
||||
/// `seal`, and `finalize` all take a caller-supplied clock reading.
|
||||
pub fn now_micros() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_micros() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn deadline() -> i64 {
|
||||
pub fn deadline() -> i64 {
|
||||
now_micros() + 600_000_000
|
||||
}
|
||||
|
||||
fn genesis_id(namespace: &NamespaceId) -> ObjectId {
|
||||
pub fn genesis_id(namespace: &NamespaceId) -> ObjectId {
|
||||
let mut bytes = [0u8; 32];
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(b"levcs-store/b4/genesis-authority/v1\0");
|
||||
|
|
@ -273,6 +274,163 @@ pub fn push_transaction(namespace: NamespaceId, operation: u8, blob: u8) -> Vali
|
|||
.expect("a complete push transaction")
|
||||
}
|
||||
|
||||
/// Stage and finalize a two-chunk projection through the engine's own staging.
|
||||
///
|
||||
/// Two chunks rather than one for the same reason the B1 acceptance test uses
|
||||
/// two: it is the smallest projection where per-chunk ownership is load-bearing,
|
||||
/// and a crash row that only ever adopted one chunk would not exercise the
|
||||
/// partitioned run set at all.
|
||||
pub fn stage_projection_for(
|
||||
engine: &StoreEngine,
|
||||
namespace: NamespaceId,
|
||||
seed: u8,
|
||||
) -> levcs_store::staging::StagedProjectionAdoption {
|
||||
let (binding, chunks) = projection_binding(namespace, seed);
|
||||
let session = engine
|
||||
.staging()
|
||||
.begin(binding, now_micros())
|
||||
.expect("staging admits the row's session");
|
||||
for chunk in &chunks {
|
||||
session
|
||||
.put_chunk(chunk, now_micros())
|
||||
.expect("the row's chunk lands");
|
||||
}
|
||||
session.seal(now_micros()).expect("the row's session seals");
|
||||
session
|
||||
.finalize(now_micros())
|
||||
.expect("the row's sealed session yields an adoption pin")
|
||||
}
|
||||
|
||||
/// The victim transaction when the row is driving the staged-projection axis.
|
||||
pub fn adopting_transaction(
|
||||
namespace: NamespaceId,
|
||||
operation: u8,
|
||||
adoption: levcs_store::staging::StagedProjectionAdoption,
|
||||
) -> ValidatedTransaction {
|
||||
let authority = genesis_id(&namespace);
|
||||
ValidatedTransaction::builder(privileged())
|
||||
.namespace(namespace)
|
||||
.operation(
|
||||
OperationId([operation; 16]),
|
||||
ObjectId([operation; 32]),
|
||||
deadline(),
|
||||
)
|
||||
// Empty: a frame carries inline objects or an install descriptor, never
|
||||
// both, so the projection is this transaction's whole payload.
|
||||
.objects(Vec::new())
|
||||
.refs(Vec::new())
|
||||
.authority(Some(authority), Some(authority))
|
||||
.evidence(evidence())
|
||||
.adopt_projection(adoption)
|
||||
.expect("one adoption is admitted")
|
||||
.build()
|
||||
.expect("a complete adopting transaction")
|
||||
}
|
||||
|
||||
fn projection_binding(
|
||||
namespace: NamespaceId,
|
||||
seed: u8,
|
||||
) -> (
|
||||
levcs_store::staging::ProjectionStageBinding,
|
||||
Vec<levcs_protocol::v2::ProjectionStageChunkV1>,
|
||||
) {
|
||||
use levcs_core::{blake3_hash, ObjectHeader, FORMAT_VERSION};
|
||||
use levcs_protocol::v2::{
|
||||
ProjectionMode, ProjectionStageChunkV1, ProjectionStageManifestV1,
|
||||
ProjectionStageSessionV1, StageSourceKindV1, StagedChunkObjectV1, StagedObjectV1,
|
||||
};
|
||||
|
||||
const CHUNKS: u32 = 2;
|
||||
const PER_CHUNK: usize = 1;
|
||||
let session_id = [seed; 16];
|
||||
|
||||
let staged = |index: usize| -> StagedChunkObjectV1 {
|
||||
let body = format!("row-staged-{seed:02x}-{index}");
|
||||
let mut raw = ObjectHeader {
|
||||
object_type: ObjectType::Blob,
|
||||
format_version: FORMAT_VERSION,
|
||||
body_len: body.len() as u64,
|
||||
}
|
||||
.encode()
|
||||
.to_vec();
|
||||
raw.extend_from_slice(body.as_bytes());
|
||||
let id = blake3_hash(&raw);
|
||||
StagedChunkObjectV1 {
|
||||
descriptor: StagedObjectV1 {
|
||||
object_id: id,
|
||||
object_type: ObjectType::Blob as u8,
|
||||
raw_len: raw.len() as u64,
|
||||
raw_digest: id,
|
||||
},
|
||||
raw_bytes: raw,
|
||||
}
|
||||
};
|
||||
|
||||
let mut objects: Vec<StagedChunkObjectV1> =
|
||||
(0..CHUNKS as usize * PER_CHUNK).map(staged).collect();
|
||||
// The manifest is the ordered concatenation of the chunks and must be
|
||||
// strictly sorted, so the sort happens before the split.
|
||||
objects.sort_by(|left, right| left.descriptor.cmp(&right.descriptor));
|
||||
|
||||
let total_object_bytes = objects
|
||||
.iter()
|
||||
.map(|object| object.descriptor.raw_len)
|
||||
.sum::<u64>();
|
||||
let descriptors: Vec<StagedObjectV1> = objects
|
||||
.iter()
|
||||
.map(|object| object.descriptor.clone())
|
||||
.collect();
|
||||
let chunks: Vec<ProjectionStageChunkV1> = (0..CHUNKS)
|
||||
.map(|ordinal| ProjectionStageChunkV1 {
|
||||
session_id,
|
||||
ordinal,
|
||||
chunk_count: CHUNKS,
|
||||
objects: objects[ordinal as usize * PER_CHUNK..][..PER_CHUNK].to_vec(),
|
||||
})
|
||||
.collect();
|
||||
let chunk_digests: Vec<ObjectId> = chunks
|
||||
.iter()
|
||||
.map(|chunk| chunk.chunk_digest().expect("chunk digest"))
|
||||
.collect();
|
||||
|
||||
let membership_root = blake3_hash(&session_id[..]);
|
||||
let manifest = ProjectionStageManifestV1 {
|
||||
session_id,
|
||||
chunk_digests,
|
||||
objects: descriptors,
|
||||
membership_root,
|
||||
};
|
||||
let manifest_digest = manifest.manifest_digest().expect("manifest digest");
|
||||
let authority = genesis_id(&namespace);
|
||||
|
||||
(
|
||||
levcs_store::staging::ProjectionStageBinding {
|
||||
session: ProjectionStageSessionV1 {
|
||||
session_id,
|
||||
destination_repo: ObjectId(*namespace.as_bytes()),
|
||||
destination_genesis: authority,
|
||||
expected_authority: authority,
|
||||
projection: ProjectionMode::Full,
|
||||
source_kind: StageSourceKindV1::Mirror,
|
||||
actor: [0x21; 32],
|
||||
actor_key_epoch: 3,
|
||||
source_generation_digest: ObjectId([12; 32]),
|
||||
fork_proof: None,
|
||||
final_operation_id: [13; 16],
|
||||
final_operation_digest: ObjectId([14; 32]),
|
||||
final_evidence_digest: ObjectId([15; 32]),
|
||||
total_object_count: objects.len() as u64,
|
||||
total_object_bytes,
|
||||
chunk_count: CHUNKS,
|
||||
manifest_digest,
|
||||
expires_at_micros: now_micros() + 3_600_000_000,
|
||||
},
|
||||
membership_root,
|
||||
},
|
||||
chunks,
|
||||
)
|
||||
}
|
||||
|
||||
fn privileged() -> levcs_store::types::PrivilegedConstruction {
|
||||
levcs_store::types::PrivilegedConstruction::assert_validated()
|
||||
}
|
||||
|
|
@ -580,6 +738,56 @@ pub struct RowObservation {
|
|||
/// against a comment about where the fence sits.
|
||||
pub fences_before: u64,
|
||||
pub fences_after: u64,
|
||||
/// Staging's four adoption-pin terminal counters, read on the live engine
|
||||
/// **before** it is dropped. A pin settled by an unwind or by a slot's
|
||||
/// `Drop` is recorded here and nowhere else: the counters live in the
|
||||
/// engine-owned staging and go with it, and reopening builds fresh ones.
|
||||
///
|
||||
/// `None` for an inline row, which takes no pin. That is a different fact
|
||||
/// from "took a pin and settled it zero times", and collapsing the two
|
||||
/// would let a row that silently stopped adopting keep passing.
|
||||
pub adoption: Option<AdoptionCounters>,
|
||||
}
|
||||
|
||||
/// The four ways an admitted pin can end, as staging counts them.
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AdoptionCounters {
|
||||
pub adopted: u64,
|
||||
pub released: u64,
|
||||
pub transferred: u64,
|
||||
pub dropped: u64,
|
||||
}
|
||||
|
||||
impl AdoptionCounters {
|
||||
/// Which terminal outcome this row's pin actually reached.
|
||||
///
|
||||
/// `None` when no pin settled at all, and an error when more than one did:
|
||||
/// a single adoption has exactly one outcome, and two counters moving means
|
||||
/// the pin was settled twice or two pins were admitted where one was
|
||||
/// expected.
|
||||
pub fn sole_outcome(&self) -> Result<Option<&'static str>, String> {
|
||||
let mut seen: Vec<&'static str> = Vec::new();
|
||||
for (count, name) in [
|
||||
(self.adopted, "Adopted"),
|
||||
(self.released, "DefinitivePreAppendFailure"),
|
||||
(self.transferred, "TransferredToRecovery"),
|
||||
(self.dropped, "DroppedWithoutOutcome"),
|
||||
] {
|
||||
if count > 1 {
|
||||
return Err(format!(
|
||||
"{name} was recorded {count} times for one adoption"
|
||||
));
|
||||
}
|
||||
if count == 1 {
|
||||
seen.push(name);
|
||||
}
|
||||
}
|
||||
match seen.as_slice() {
|
||||
[] => Ok(None),
|
||||
[one] => Ok(Some(one)),
|
||||
many => Err(format!("one adoption reached {many:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RowObservation {
|
||||
|
|
@ -617,8 +825,18 @@ pub fn drive_submit_row(
|
|||
serial: &Serial,
|
||||
point: Failpoint,
|
||||
action: FailpointAction,
|
||||
adopts: bool,
|
||||
) -> RowObservation {
|
||||
let row = format!("{} [{}]", point.name(), action_name(action));
|
||||
let row = format!(
|
||||
"{} [{}] [{}]",
|
||||
point.name(),
|
||||
action_name(action),
|
||||
if adopts {
|
||||
"staged_projection"
|
||||
} else {
|
||||
"inline"
|
||||
}
|
||||
);
|
||||
let directory = tempfile::tempdir().expect("tempdir");
|
||||
let root = directory.path().join("root");
|
||||
|
||||
|
|
@ -641,8 +859,16 @@ pub fn drive_submit_row(
|
|||
.expect("shard 0 has a writer")
|
||||
.fdatasync;
|
||||
|
||||
// Staged *before* the failpoint is armed. Staging writes chunks through
|
||||
// maintenance workers, and arming first would let the row's fault fire on
|
||||
// staging's own I/O rather than on the submit under test.
|
||||
let staged = adopts.then(|| stage_projection_for(&engine, namespace, 0x22));
|
||||
|
||||
arm(serial, point, action);
|
||||
let victim = submit(&engine, push_transaction(namespace, 0x22, 0xb2));
|
||||
let victim = match staged {
|
||||
Some(adoption) => submit(&engine, adopting_transaction(namespace, 0x22, adoption)),
|
||||
None => submit(&engine, push_transaction(namespace, 0x22, 0xb2)),
|
||||
};
|
||||
let fences_after = engine
|
||||
.durability_counters(0)
|
||||
.expect("shard 0 has a writer")
|
||||
|
|
@ -651,6 +877,19 @@ pub fn drive_submit_row(
|
|||
.transaction_status(namespace, victim_operation)
|
||||
.expect("transaction_status is a read and never fails on an open engine");
|
||||
|
||||
// Read while the engine is alive: staging's counters are engine-owned and a
|
||||
// reopen builds fresh ones, so a pin settled by an unwind is only visible
|
||||
// here.
|
||||
let adoption = adopts.then(|| {
|
||||
let counters = engine.staging().counters().snapshot();
|
||||
AdoptionCounters {
|
||||
adopted: counters.sessions_adopted,
|
||||
released: counters.adoption_pins_released,
|
||||
transferred: counters.adoption_pins_transferred,
|
||||
dropped: counters.adoption_pins_dropped,
|
||||
}
|
||||
});
|
||||
|
||||
let probe = submit(&engine, push_transaction(namespace, 0x33, 0xb3));
|
||||
// Whatever happened, nothing may stay armed for the next row: the registry
|
||||
// is a one-shot global and a row that did not fire would otherwise hand its
|
||||
|
|
@ -677,6 +916,7 @@ pub fn drive_submit_row(
|
|||
prior_recovered,
|
||||
fences_before,
|
||||
fences_after,
|
||||
adoption,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -798,7 +1038,7 @@ pub fn drive_root_cas_retry_row(
|
|||
.map(|shard| namespace_on_shard(shard, CONTENTION_SHARDS, 0x0CA5_0000 + shard as u64))
|
||||
.collect();
|
||||
|
||||
let engine = open_absent_root(&root, CONTENTION_SHARDS, CONTENTION_MAX_INDEX_RUNS);
|
||||
let engine = open_absent_root(&root, CONTENTION_SHARDS, DEFAULT_MAX_INDEX_RUNS);
|
||||
for (index, namespace) in namespaces.iter().enumerate() {
|
||||
let created = submit_plain(&engine, create_transaction(*namespace, 0x40 + index as u8));
|
||||
assert!(
|
||||
|
|
@ -913,7 +1153,7 @@ pub fn drive_root_cas_retry_row(
|
|||
disarm(serial);
|
||||
drop(engine);
|
||||
|
||||
let reopened = reopen_after_close(&root, CONTENTION_SHARDS, CONTENTION_MAX_INDEX_RUNS);
|
||||
let reopened = reopen_after_close(&root, CONTENTION_SHARDS, DEFAULT_MAX_INDEX_RUNS);
|
||||
let recovered = reopened
|
||||
.transaction_status(namespace, victim_operation)
|
||||
.expect("status read");
|
||||
|
|
@ -938,6 +1178,10 @@ pub fn drive_root_cas_retry_row(
|
|||
prior_recovered,
|
||||
fences_before,
|
||||
fences_after,
|
||||
// The contention driver races shards against each other for the
|
||||
// root CAS and never adopts; the payload axis is driven by
|
||||
// `drive_submit_row`.
|
||||
adoption: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,6 +143,128 @@ impl PhysicalStateClass {
|
|||
PhysicalStateClass::WholeFrameFencedAndPublished => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which terminal outcome a `StagedProjection` payload's adoption pin must
|
||||
/// carry when this class is produced (scope 6.2 item 9).
|
||||
///
|
||||
/// This is the class-derived half of the adoption expectation, and the
|
||||
/// fixture's stated `adoption_outcome` is the other. The matrix requires
|
||||
/// them to agree, exactly as it already does for `required_outcome`.
|
||||
///
|
||||
/// The rule is the physical one and nothing else: **whether a frame
|
||||
/// binding these artifacts exists on the device.**
|
||||
///
|
||||
/// * No bytes reached the file, so no frame names the artifacts. They are
|
||||
/// unreferenced and reclaiming them is correct, which is what
|
||||
/// `DefinitivePreAppendFailure` licenses.
|
||||
/// * A torn or unfenced frame is `AbsentRetriable` for the transaction,
|
||||
/// but it is **not** `DefinitivePreAppendFailure` for the pin. Bytes are
|
||||
/// on the device and recovery decides what they mean; telling staging to
|
||||
/// reclaim now would race that decision. Only recovery can resolve it.
|
||||
/// * A fenced frame is durable and binds the manifest, so the pin outlives
|
||||
/// the process and recovery notifies staging of the resolution.
|
||||
/// * Fenced *and* published means the committed root already references
|
||||
/// the artifacts and the shard sequence is known, so the writer settled
|
||||
/// `Adopted` before this failpoint could fire. A failure after that
|
||||
/// point cannot un-adopt what a published root points into.
|
||||
///
|
||||
/// Note the asymmetry against `required_outcome`: `PartialFrame` and
|
||||
/// `WholeFrameUnfenced` are `AbsentRetriable` there and
|
||||
/// `TransferredToRecovery` here. A transaction that will not commit and a
|
||||
/// pin whose artifacts may be referenced are different questions, and
|
||||
/// collapsing them is how a retriable refusal would come to delete
|
||||
/// content.
|
||||
pub const fn adoption_outcome(self) -> AdoptionOutcomeExpectation {
|
||||
match self {
|
||||
PhysicalStateClass::NoBytes => AdoptionOutcomeExpectation::DefinitivePreAppendFailure,
|
||||
PhysicalStateClass::PartialFrame => AdoptionOutcomeExpectation::TransferredToRecovery,
|
||||
PhysicalStateClass::WholeFrameUnfenced => {
|
||||
AdoptionOutcomeExpectation::TransferredToRecovery
|
||||
}
|
||||
PhysicalStateClass::WholeFrameFenced => {
|
||||
AdoptionOutcomeExpectation::TransferredToRecovery
|
||||
}
|
||||
PhysicalStateClass::WholeFrameFencedAndPublished => AdoptionOutcomeExpectation::Adopted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The terminal outcome expected of an adoption pin, as the matrix names it.
|
||||
///
|
||||
/// A test-side mirror of `ProjectionAdoptionOutcome` without the payload:
|
||||
/// `Adopted` carries a committed shard sequence the matrix cannot predict, and
|
||||
/// an expectation that had to guess it would assert less, not more.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AdoptionOutcomeExpectation {
|
||||
Adopted,
|
||||
DefinitivePreAppendFailure,
|
||||
TransferredToRecovery,
|
||||
}
|
||||
|
||||
impl AdoptionOutcomeExpectation {
|
||||
pub const ALL: &'static [AdoptionOutcomeExpectation] = &[
|
||||
AdoptionOutcomeExpectation::Adopted,
|
||||
AdoptionOutcomeExpectation::DefinitivePreAppendFailure,
|
||||
AdoptionOutcomeExpectation::TransferredToRecovery,
|
||||
];
|
||||
|
||||
/// Name as it appears in `tests/fixtures/phase1-failpoints.json`.
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
AdoptionOutcomeExpectation::Adopted => "Adopted",
|
||||
AdoptionOutcomeExpectation::DefinitivePreAppendFailure => "DefinitivePreAppendFailure",
|
||||
AdoptionOutcomeExpectation::TransferredToRecovery => "TransferredToRecovery",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed by linear search over `ALL`, for the reason
|
||||
/// `PhysicalStateClass::from_name` is: adding a variant must not silently
|
||||
/// acquire a default.
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.iter().copied().find(|o| o.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which payload a Wave B row drives the failpoint's location with.
|
||||
///
|
||||
/// The third independent axis of a submit row, alongside the location the
|
||||
/// failpoint names and the action the driver chooses. It is an axis rather
|
||||
/// than a second list of failpoints because the locations do not change: a
|
||||
/// staged-projection adoption reaches every one of the eight Wave B locations,
|
||||
/// and giving it its own rows would produce a parallel list that drifts from
|
||||
/// this one the first time a location is added to either.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PayloadKind {
|
||||
/// Objects carried in the frame, which is every Wave B row today.
|
||||
Inline,
|
||||
/// A `StagedProjectionInstallV1` descriptor adopting sealed artifacts,
|
||||
/// with an adoption pin whose terminal outcome is asserted.
|
||||
StagedProjection,
|
||||
}
|
||||
|
||||
impl PayloadKind {
|
||||
pub const ALL: &'static [PayloadKind] = &[PayloadKind::Inline, PayloadKind::StagedProjection];
|
||||
|
||||
/// Name as it appears in `tests/fixtures/phase1-failpoints.json`.
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
PayloadKind::Inline => "inline",
|
||||
PayloadKind::StagedProjection => "staged_projection",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.iter().copied().find(|k| k.name() == name)
|
||||
}
|
||||
|
||||
/// Whether driving this kind requires an adoption pin whose outcome the
|
||||
/// row must assert.
|
||||
pub const fn carries_adoption(self) -> bool {
|
||||
match self {
|
||||
PayloadKind::Inline => false,
|
||||
PayloadKind::StagedProjection => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The physical state class each failpoint produces.
|
||||
|
|
|
|||
|
|
@ -54,6 +54,20 @@ pub struct SubmitPlan {
|
|||
/// True for exactly one row, and the matrix asserts that rather than
|
||||
/// letting a second row quietly acquire it.
|
||||
pub requires_root_cas_contention: bool,
|
||||
/// Which payloads this location is driven with — the third independent
|
||||
/// axis, for the reason given on [`PayloadKind`]. Required: a row that
|
||||
/// omitted it would acquire a default, and a default here is a silent
|
||||
/// claim about coverage.
|
||||
pub payload_kinds: Vec<String>,
|
||||
/// The terminal outcome an adoption pin must carry at this location, as
|
||||
/// the fixture states it.
|
||||
///
|
||||
/// Stated even on rows that do not yet list `staged_projection`, and
|
||||
/// checked against `PhysicalStateClass::adoption_outcome` on every row.
|
||||
/// That agreement check is what makes turning the kind on a matter of
|
||||
/// listing it rather than of also getting the expectation right at the
|
||||
/// same moment.
|
||||
pub adoption_outcome: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -196,6 +210,10 @@ pub fn load_fixture() -> Fixture {
|
|||
.get("requires_root_cas_contention")
|
||||
.and_then(|v| v.as_bool())
|
||||
.expect("submit.requires_root_cas_contention"),
|
||||
payload_kinds: string_list(
|
||||
plan.get("payload_kinds").expect("submit.payload_kinds"),
|
||||
),
|
||||
adoption_outcome: string_field(plan, "adoption_outcome"),
|
||||
})
|
||||
}),
|
||||
rationale: string_field(row, "rationale"),
|
||||
|
|
|
|||
|
|
@ -1839,6 +1839,194 @@ 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-09-C
|
||||
|
||||
`crates/levcs-store/src/index.rs` — `IndexRun::entries`. A frozen Wave A interface, so
|
||||
requested rather than emitted. Additive and read-only; no byte of the run format moves.
|
||||
|
||||
**Nothing in the store has ever removed an index run.** `seal_index` pushes one per seal and
|
||||
the adoption path pushes the runs staging materialized; the successor generation carries the
|
||||
predecessor's forward. That was invisible while nothing checkpointed on a cadence. Wiring the
|
||||
P2 harness to take checkpoints inside its measured window made it immediate: a 125-second run
|
||||
died with `max_open_index_runs (observed: 33, allowed: 32)`.
|
||||
|
||||
**The frozen workload is arithmetically unreachable without compaction.** 300 s warmup plus
|
||||
900 s measured against a hard ceiling of 32 open runs allows at most one checkpoint per ~37 s,
|
||||
and ends at the ceiling with no headroom for a fan-out-triggered seal. The alternatives were
|
||||
raising the ceiling — the disclosed-weakening pattern retired the same day by 2026-08-09-B, and
|
||||
it would make `index_maintenance = runs_sealed` describe a run whose fan-out grows unbounded —
|
||||
or shortening the measured window, which is a Phase 0 contract. Merging runs is the only option
|
||||
that does not trade what the number means for the ability to produce one.
|
||||
|
||||
**A merge cannot be written against the existing API**, which answers point lookups only:
|
||||
`get`, `may_contain`, `entry_count`, `references_segment_generation`,
|
||||
`references_only_segment_generation`. `entries` yields `(IndexKey, IndexLocation)` in the order
|
||||
already on the device — sections ascending by namespace, entries ascending within a section — so
|
||||
a caller that re-encodes what it reads produces the layout it consumed. It returns no `Result`
|
||||
per entry: every value is decoded from bytes the run validated at `open`, and a run that could
|
||||
not be trusted entry by entry should not have opened.
|
||||
`entries_yields_every_entry_and_agrees_with_get` asserts the walk against the run's own `get`
|
||||
rather than against the delta it was built from, because the question is whether the decoder
|
||||
reads back what the encoder wrote.
|
||||
|
||||
**Compaction partitions by generation domain, and that is a rule rather than a preference.** A
|
||||
run section is per-namespace with a 16-bit generation span, and adopted-projection generations
|
||||
sit in the band at `1 << 63`. Journal-backed runs may merge with each other and staging-owned
|
||||
artifact runs with each other; a merge across the two is unencodable. Today's `seal_index` is
|
||||
safe only because adopted entries never enter a shard delta, so compaction is the first
|
||||
operation that could mix them.
|
||||
|
||||
Scope is deliberately narrower than Phase 4's `CompactionBackup`: sealed index runs only, no
|
||||
segment compaction, no event-floor retention, no backup interaction.
|
||||
|
||||
##### Contract review 2026-08-09-B
|
||||
|
||||
`bench/result-schema.json` — `run_conditions.index_run_ceiling`. Requested after index-delta
|
||||
sealing landed, because the enum's only raised value names a cause that is no longer true and
|
||||
that nothing ever verified. One value is added, none is removed.
|
||||
|
||||
**The declaration was never a claim about *why*.** `store-bench.rs` derives it by comparing the
|
||||
configured ceiling with the store default; that comparison cannot know what motivated a raise.
|
||||
It nevertheless emitted `raised_because_index_sealing_unimplemented`, so the bundle asserted a
|
||||
cause on the strength of a subtraction. That was accurate while the only reason to raise the
|
||||
ceiling was the unimplemented seal. It is not accurate now, and a future run raising the ceiling
|
||||
for any other reason — deliberately measuring fan-out, say — would have emitted a false
|
||||
explanation that the schema called valid.
|
||||
|
||||
**`raised_above_store_default` is added and is what the emitter now writes.** It states the fact
|
||||
the comparison establishes and stops there.
|
||||
|
||||
**The old value is retained and deprecated, not removed.** `schema_version` is `const: 1`, so
|
||||
there is no later version to move archived bundles to, and this repository has never held a
|
||||
bundle to check against — the reference machine may hold ones that declare it. Removing it would
|
||||
invalidate evidence already produced, which is a worse outcome than carrying a spelling nothing
|
||||
emits. It is retained **solely so archived v1 bundles validate, and is not a current causal
|
||||
claim**; the enum's description says so.
|
||||
|
||||
**Deprecated does not mean unchecked.** The `allOf` rule that floors a declared raise at 65
|
||||
accepts either spelling, so a bundle using the old string is still cross-checked against
|
||||
`resources.configured_ceilings.max_index_runs`.
|
||||
`both_raised_spellings_are_accepted_and_bound_the_same_way` asserts both halves — that each
|
||||
validates, and that each is refused when it records the store default. Verified by mutation:
|
||||
narrowing the rule to the new spelling alone makes the deprecated one silently unchecked and
|
||||
fails that test.
|
||||
|
||||
The `configured_ceilings.max_index_runs` description carried the same causal claim and now
|
||||
records what is true — the run seals against that ceiling, and its fan-out trigger is what
|
||||
imposes the steady state a P2 number must be measured in.
|
||||
|
||||
##### Contract review 2026-08-09-A
|
||||
|
||||
B1 deliverable 3 — `adopt_projection` through submit. Requested by B1 on starting the wiring,
|
||||
because the public entry point the deliverable is named after could not be called. Three
|
||||
visibility changes, two accessors, one lifetime correction, and one signature amendment —
|
||||
`adopt_projection` takes the adoption as a single value, for the reason recorded below. No wire
|
||||
format moves.
|
||||
|
||||
**`ValidatedTransactionBuilder::adopt_projection` was a public function no external caller
|
||||
could invoke.** It takes a `ProjectionAdoption`, whose only issuer is
|
||||
`ProjectionStageSession::finalize`, which was `pub(crate)` and returned the `pub(crate)`
|
||||
`StagedProjectionAdoption`. The pin has no other constructor by design — that unforgeability is
|
||||
the point — so the two together made the signature readable and uncallable. **`finalize` and
|
||||
`StagedProjectionAdoption` become `pub`.** `StagedProjectionAdoption` sits in D0-B's frozen
|
||||
adoption seam; this changes who may name it and nothing about what it is.
|
||||
|
||||
**`adopt_projection` now takes the whole `StagedProjectionAdoption`, and its fields stay
|
||||
private.** Review found that publishing the two fields and keeping the two-argument signature
|
||||
admits a pairing that is *wrong* rather than merely incomplete: finalize session A, take the
|
||||
descriptor from session B, submit `(descriptor_B, handle_A)`. Every field of `descriptor_B` is
|
||||
internally valid, so nothing looks damaged — the frame would simply name B's artifacts while
|
||||
A's are the ones held against deletion. The frozen two-argument form existed so neither half
|
||||
could be *forgotten* independently; taking one value keeps that and additionally makes the
|
||||
mismatch unrepresentable. A pre-append revalidation against the pin's canonical resolution
|
||||
still runs, because staging's state can change between `finalize` and submit — but it is now a
|
||||
recheck of a pairing the type guarantees rather than the only thing standing between a caller
|
||||
and a mispaired frame. `descriptor()` is added for inspection; it borrows, because taking the
|
||||
descriptor out is what the type exists to prevent.
|
||||
|
||||
**`StoreEngine::staging()` is added**, returning `&Arc<ProjectionStaging>`. Since the P1-4 fix
|
||||
moved staging inside the locked engine lifetime, no caller can construct one beside a
|
||||
`StoreEngine` — holding two root locks is what that fix made unrepresentable — so without an
|
||||
accessor there is no reachable way to stage the projection an adoption adopts. This closes the
|
||||
interface request recorded in the ignored test at `tests/staging_sessions.rs`.
|
||||
|
||||
**`ProjectionStaging` now retains the `RecoverySession` lease, and the first version of this
|
||||
accessor was unsound without it.** Its justification claimed that returning a reference kept
|
||||
staging from outliving the lock. It does not: `Arc::clone` escapes the borrow, and so do a
|
||||
`ProjectionStageSession` and an adoption pin, each of which owns an `Arc<ProjectionStaging>`.
|
||||
With `LOCK` released when the engine dropped, any of those could still write into a root
|
||||
another process had since opened — a second accountant for one root, which is the exact
|
||||
condition staging's construction rules exist to prevent. **`ProjectionStaging::open` therefore
|
||||
takes `Arc<RecoverySession>` and keeps it**, so the lock is released when the last holder is
|
||||
dropped rather than when the engine is. `EngineShared` shares the same lease.
|
||||
`a_retained_staging_holds_the_root_lock_past_the_engine` asserts it by taking the lock
|
||||
directly, not by attempting a second engine open — staging's in-process root registry would
|
||||
refuse that whether or not the lock were held, so only the direct form fails when the lease is
|
||||
not retained. Verified by mutation: replacing the retained `Arc` with a `Weak` makes the test
|
||||
fail with a second session holding the root.
|
||||
|
||||
**`RepoSnapshot` gains `object_source()` and carries its shard index.** `locate` answers *which
|
||||
generation*, which stopped being sufficient the moment adoption existed: a logical generation
|
||||
is a segment, an active tail, or a projection artifact, and an adopted object's bytes are not in
|
||||
the frame that installed it. Before adoption every location a reader could hold was a frame in
|
||||
this shard's journal, so the distinction was invisible; now a reader that cannot ask "what
|
||||
kind" cannot act on the answer at all. The shard index is passed to `capture` rather than
|
||||
derived because deriving it needs the shard count, which lives in `StoreOptions` and not in the
|
||||
root. `RetainedObjectSource` is already re-exported, so `lib.rs` does not move.
|
||||
|
||||
**One finding recorded and not acted on.** `checkpoint.rs` has no notion of projection
|
||||
artifacts: a retained generation's `projection_artifacts` are in-memory pins, and their
|
||||
durability comes from the journal frame plus staging's adoption marker, with recovery
|
||||
rebuilding them by replaying the frame through `resolve_committed`. The successor-generation
|
||||
paths clone them forward correctly within a process. Whether a manifest that outlives the
|
||||
frames it was derived from needs to record them is a B3/checkpoint question, not a B1 one, and
|
||||
is raised here rather than answered.
|
||||
|
||||
##### 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
|
||||
|
|
@ -2282,7 +2470,7 @@ there.
|
|||
|
||||
**`max_index_runs` is now a named required member of `resources.configured_ceilings`.** It was
|
||||
reachable only through that block's free-form `additionalProperties`, so an emitter could omit
|
||||
the one ceiling this workload actually reaches — `submit` refuses `NotImplemented` at it — and
|
||||
the one ceiling this workload actually reaches — the run seals against it — and
|
||||
the bundle stayed valid. It must be the value the run configured, read back from the options the
|
||||
store opened with. The schema cannot see the process's options, so the bite is a cross-check:
|
||||
`index_run_ceiling: "store_default"` bounds the recorded value at 64 and
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1982,22 +2003,23 @@ Owns the crash driver, the benchmark, the matrix, and the recovery script.
|
|||
|
||||
`bench/result-schema.json` now requires a `run_conditions` block and a named
|
||||
`resources.configured_ceilings.max_index_runs`, and requires two verification claims on the
|
||||
submit path. The emitter does not produce any of them, so `store-bench emit-skeleton` currently
|
||||
writes a bundle that fails validation on exactly two fields:
|
||||
submit path.
|
||||
|
||||
```
|
||||
[]: 'run_conditions' is a required property
|
||||
['resources', 'configured_ceilings']: 'max_index_runs' is a required property
|
||||
```
|
||||
**Closed by `062797d`, "Condition benchmark claims on the run that produced them".** The
|
||||
state this section recorded — the emitter producing none of them, so `store-bench
|
||||
emit-skeleton` wrote a bundle failing validation on `run_conditions` and
|
||||
`resources.configured_ceilings.max_index_runs`, with `bundle=schema-invalid`, `VERIFY_EXIT=1`
|
||||
and `check-phase1.sh` red alongside — is history, and is kept here because the *mechanism* is
|
||||
the point: the gate was red for a schema the store satisfied, because it runs `store-bench`'s
|
||||
unit tests and three of them validate the emitted bundle against the schema rather than
|
||||
against substrings. Schema conformance is inside the gate. That is the property review
|
||||
2026-07-24-B was after, working, and it is the reason landing a contract first is allowed to
|
||||
turn the gate red.
|
||||
|
||||
`scripts/verify-store-recovery.sh --cycles 2` reports `bundle=schema-invalid`, `VERIFY_EXIT=1`
|
||||
with everything else green (`matrix=pass`, `acknowledged_loss=0`, `torn_transactions=0`).
|
||||
**`scripts/check-phase1.sh` is red as well**, which is not what landing a schema alone would
|
||||
normally do: the gate runs `store-bench`'s unit tests, and three of them validate the emitted
|
||||
bundle against the schema rather than against substrings, so schema conformance is inside the
|
||||
gate. That is the property review 2026-07-24-B was after, working. All three failures are in
|
||||
`store-bench.rs` and none is a defect in the store; they are expected collateral of landing the
|
||||
contract first and they close with item 5.
|
||||
Verified green on 2026-08-07 at `b4e4c7e`: `scripts/check-phase1.sh` reports `GATE_EXIT=0`,
|
||||
and `scripts/verify-store-recovery.sh --cycles 2` reports `bundle=schema-valid` on both
|
||||
`--path submit` and `--path drive`, `matrix=pass`, `acknowledged_loss=0`,
|
||||
`torn_transactions=0`, `VERIFY_EXIT=0`.
|
||||
|
||||
5. **Emit the run conditions, the index-run ceiling, and the two earned claims.** All of it in
|
||||
`store-bench.rs`; no other file is involved. Nothing here may be a constant this file
|
||||
|
|
@ -2013,9 +2035,9 @@ contract first and they close with item 5.
|
|||
|---|---|---|
|
||||
| `initialization_path` | `store_engine_open` (see **f**) | `shard_drive_create` |
|
||||
| `mutation_path` | `store_engine_submit` | `journal_drive` |
|
||||
| `checkpointing` | `unimplemented` | `unimplemented` |
|
||||
| `checkpointing` | `enabled_not_reached` (see §7) | `unimplemented` |
|
||||
| `index_maintenance` | `deltas_retained_in_memory` | `no_index_in_path` |
|
||||
| `index_run_ceiling` | `raised_because_index_sealing_unimplemented` | `store_default` |
|
||||
| `index_run_ceiling` | `store_default` (see the carry-forward below) | `store_default` |
|
||||
| `receipt_reconciliation` | `acceptance_of_any_committed_status` | `no_receipts_in_path` |
|
||||
| `objects_new_source` | `summed_from_receipts` | `derived_from_transaction_count` |
|
||||
| `commit_id_uniqueness` | `checked_globally_across_ack_records` (after **d**) | `not_checked` |
|
||||
|
|
@ -2068,6 +2090,71 @@ contract first and they close with item 5.
|
|||
required field the negative control never removes is a field the suite cannot notice the loss
|
||||
of.
|
||||
|
||||
*Met* at `062797d`, verified 2026-08-07 at `b4e4c7e`.
|
||||
|
||||
#### Carry-forward: `ENGINE_MAX_INDEX_RUNS` outlived its reason
|
||||
|
||||
Recorded 2026-08-07. `store-bench.rs` opens its measured store with
|
||||
`ENGINE_MAX_INDEX_RUNS = 1_000_000` against a store default of 64, carrying the comment
|
||||
"Raised because index-delta sealing is unimplemented". Sealing landed in `fef8520` and was
|
||||
bounded by `e03ca2b`, so that reason has lapsed, and two things follow that did not follow
|
||||
before:
|
||||
|
||||
1. **The emitted `run_conditions.index_run_ceiling` names a reason that is no longer true.**
|
||||
The derivation is correct — it compares the configured ceiling to the store default and
|
||||
reports a raise, so it cannot drift from what the run configured — but the only enum value
|
||||
the schema offers for a raise is `raised_because_index_sealing_unimplemented`. (Amended
|
||||
2026-08-09-B: the schema now also offers `raised_above_store_default`, which is what a
|
||||
comparison can honestly report.)
|
||||
2. **The raise now suppresses a behaviour the store has.** At a ceiling of a million, the
|
||||
fan-out trigger never fires, so every seal in a measured run is triggered by entry pressure.
|
||||
`index_maintenance = runs_sealed` is therefore true and narrower than it reads, and a P2 run
|
||||
at this ceiling is not measuring the fan-out steady state the ceiling exists to impose.
|
||||
|
||||
**This was a decision, not an edit, and it is not B4's to make alone.** Two ways could close it
|
||||
and they were not equivalent:
|
||||
|
||||
- **Drop the raise** — set the bench to the store default, and `index_run_ceiling` emits
|
||||
`store_default` with no schema change at all. The outcome to prefer *if the measured run
|
||||
survives the real ceiling*, because it is the configuration the exit criterion is about. A
|
||||
change to what the benchmark measures, to be demonstrated rather than assumed.
|
||||
- **Amend the enum** — if some raise were still genuinely required, the value would need a name
|
||||
stating the surviving reason. `bench/result-schema.json` is lead-owned; per item 4 above,
|
||||
**request it, do not emit it**, which would make it a contract review rather than a commit.
|
||||
|
||||
**Closed 2026-08-07 by dropping the raise, ruled by the lead and demonstrated rather than
|
||||
assumed.** The store is opened at its own default and `ENGINE_MAX_INDEX_RUNS` is retired; the
|
||||
schema enum is untouched, so no contract review was needed.
|
||||
|
||||
**Amended 2026-08-09 by contract review 2026-08-09-B, for the other reason.** Finding 1 above
|
||||
was closed by no longer *emitting* the raised value, which left the value itself still naming a
|
||||
lapsed cause — harmless while nothing raised the ceiling, and a false explanation the moment
|
||||
anything did, since the emitter derives the declaration from a comparison and cannot know why
|
||||
two numbers differ. `raised_above_store_default` is added and is what the emitter writes;
|
||||
`raised_because_index_sealing_unimplemented` is retained, deprecated, and still cross-checked,
|
||||
solely so archived v1 bundles validate. That is an enum amendment, so it *was* a contract
|
||||
review — requested rather than emitted, per item 4.
|
||||
|
||||
The demonstration is the part that mattered, because the whole question was whether a run
|
||||
survives the ceiling it had been raised to escape. A 45-second submit-path run at
|
||||
`max_index_runs = 64` published **5,192 groups** — 81× the ceiling — with no `NotImplemented`
|
||||
refusal, and its rate was flat against a 5-second run at the same ceiling (113.2/s vs 117.7/s,
|
||||
debug build, diagnostic hardware; the figures are for survival, not for throughput). The
|
||||
emitted bundle declares `index_run_ceiling: store_default` against
|
||||
`resources.configured_ceilings.max_index_runs = 64`, with `index_maintenance: runs_sealed`
|
||||
alongside. `check-phase1.sh` reports `GATE_EXIT=0` and `verify-store-recovery.sh --cycles 2`
|
||||
reports `matrix=pass`, `bundle=schema-valid`, `VERIFY_EXIT=0`.
|
||||
|
||||
`runs_sealed` now means what it reads: seals come from fan-out pressure as well as entry
|
||||
pressure, which is the steady state the ceiling exists to impose. §7's count is unchanged at
|
||||
three of four — this was never one of the four — but the third of them is now earned under the
|
||||
ceiling a deployment would actually run.
|
||||
|
||||
The raised branch of the derivation has not become dead: `bench`'s
|
||||
`a_run_at_the_store_default_declares_store_default` covers the branch a real run now takes, and
|
||||
the existing fixture keeps the raised branch covered, because a comparison needs both sides
|
||||
exercised.
|
||||
|
||||
#### Carry-forward: the SIGKILL cycles still drive the journal seam
|
||||
|
||||
Recorded here because until now it existed only as a comment in the harness, and **a harness
|
||||
|
|
@ -2228,15 +2315,35 @@ achieved with checkpointing disabled.
|
|||
the conditions the number was obtained under travel inside the bundle as values, and a bundle
|
||||
that met none of them cannot encode a pass.
|
||||
|
||||
The harness as it stands satisfies **two of the four**: since B1 landed startup state 1, the
|
||||
submit path both creates its root through `StoreEngine::open` and mutates it through
|
||||
`StoreEngine::submit`. It satisfies neither `checkpointing` nor `index_maintenance`, and those
|
||||
are the two that decide whether a P2 figure describes a steady state or a burst — a run holding
|
||||
every index delta in memory, with a lookup fan-out that grows for its whole duration and no
|
||||
checkpoint ever taken, is measuring a system that has not yet reached the condition the number
|
||||
is supposed to characterize. Two of four is the accurate reading of how much of the P2 exit
|
||||
criterion is currently earned, and the remaining two are the expensive ones — the same
|
||||
disclosure the SIGKILL carry-forward above makes about the crash-recovery row.
|
||||
The harness as it stands satisfies **three of the four**, measured on 2026-08-07 at `b4e4c7e`.
|
||||
Since B1 landed startup state 1, the submit path both creates its root through
|
||||
`StoreEngine::open` and mutates it through `StoreEngine::submit`. Index sealing (`fef8520`,
|
||||
bounded by `e03ca2b`) earned the third: the submit path now declares
|
||||
`index_maintenance = runs_sealed`, derived from the `IndexRun` files read back off the device
|
||||
rather than from a label.
|
||||
|
||||
`checkpointing` is the one outstanding, and it has moved from `unimplemented` to
|
||||
**`enabled_not_reached`** — `StoreEngine::checkpoint` exists and answers (`5462952`, corrected
|
||||
by `bff8e8a`), and the short verification runs simply never accumulate enough work to trip it.
|
||||
That distinction matters for planning: what remains is a *run long enough to take a
|
||||
checkpoint*, not a mechanism to build. It is the last of the four, and it is the one that
|
||||
decides whether a P2 figure describes a steady state or a burst — a run with a lookup fan-out
|
||||
that grows for its whole duration and no checkpoint ever taken is measuring a system that has
|
||||
not yet reached the condition the number is supposed to characterize.
|
||||
|
||||
**A fourth condition is not on the exit list and bore on the same question, and it is now
|
||||
closed.** The submit path used to declare `index_run_ceiling =
|
||||
raised_because_index_sealing_unimplemented`, because `store-bench.rs` opened its store with
|
||||
`ENGINE_MAX_INDEX_RUNS = 1_000_000` against a store default of 64 — so no seal in a measured
|
||||
run was ever triggered by fan-out pressure and `index_maintenance = runs_sealed` was earned by
|
||||
entry pressure alone. The raise was dropped on 2026-08-07 after a run demonstrated survival at
|
||||
the real ceiling; the path now declares `store_default`, and `runs_sealed` above is earned
|
||||
under the ceiling a deployment would run. The carry-forward under §6.6 item 5 records the
|
||||
evidence.
|
||||
|
||||
Three of four is the accurate reading of how much of the P2 exit criterion is currently earned,
|
||||
and the remainder is the expensive one — the same disclosure the SIGKILL carry-forward above
|
||||
makes about the crash-recovery row.
|
||||
|
||||
## 8. Capacity analysis for P2 on the frozen reference hardware
|
||||
|
||||
|
|
|
|||
|
|
@ -75,12 +75,15 @@ else
|
|||
fi
|
||||
|
||||
echo "== crash matrix has no pending rows at Phase 1 exit =="
|
||||
# During Waves A and B the fixture legitimately carries pending-wave-b rows.
|
||||
# This check only fires once B1 has landed engine.rs, at which point a pending
|
||||
# row means a forgotten failpoint rather than a sequenced one.
|
||||
# During Waves A and B the fixture legitimately carried pending-wave-b rows, so
|
||||
# this check was gated on B1 having landed engine.rs -- proxied by engine.rs no
|
||||
# longer mentioning `NotImplemented`. That proxy stopped holding: B1 landed, and
|
||||
# the only mentions left are a module doc and two tests asserting an error is
|
||||
# *not* one, so the grep matched and the check silently stopped running. B1's
|
||||
# deliverables are complete, so the transitional guard is gone and a pending row
|
||||
# is now always a forgotten failpoint.
|
||||
matrix=crates/levcs-store/tests/fixtures/phase1-failpoints.json
|
||||
if [ -f "$matrix" ] && grep -q 'fn submit' crates/levcs-store/src/engine.rs \
|
||||
&& ! grep -q 'NotImplemented' crates/levcs-store/src/engine.rs; then
|
||||
if [ -f "$matrix" ]; then
|
||||
if grep -q 'pending-wave-b' "$matrix"; then
|
||||
echo "engine.rs is implemented but the crash matrix still has pending rows:" >&2
|
||||
grep -n 'pending-wave-b' "$matrix" >&2
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
#!/usr/bin/env bash
|
||||
# Run every piece of Phase 1 exit evidence and report it as a checklist.
|
||||
#
|
||||
# Scope §7 lists seven exit criteria and two stop-condition clauses. Most are
|
||||
# already asserted by tests that run in the ordinary gate; what this script adds
|
||||
# is the part that cannot run anywhere except on reference hardware -- the P2
|
||||
# campaign -- and a single place that says, in one screen, which criteria are
|
||||
# met and which are not.
|
||||
#
|
||||
# It is deliberately a *reporter*, not a promoter. It exits non-zero unless
|
||||
# every criterion passes, and it never edits a bundle to make one pass. A run on
|
||||
# a machine that does not match a frozen hardware profile will correctly report
|
||||
# the P2 rows as failed, which is what makes it safe to run here as a rehearsal.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/close-phase1.sh [--work DIR] [--seconds N] [--reps N] [--cycles N]
|
||||
# [--skip-recovery] [--skip-gate]
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
work="$repo_root/target/phase1-closure"
|
||||
seconds=120
|
||||
reps=3
|
||||
cycles=100
|
||||
run_recovery=1
|
||||
run_gate=1
|
||||
|
||||
usage() {
|
||||
sed -n '2,17p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--work) work="$2"; shift 2 ;;
|
||||
--seconds) seconds="$2"; shift 2 ;;
|
||||
--reps) reps="$2"; shift 2 ;;
|
||||
--cycles) cycles="$2"; shift 2 ;;
|
||||
--skip-recovery) run_recovery=0; shift ;;
|
||||
--skip-gate) run_gate=0; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
evidence="$repo_root/evidence/phase1-closure-$stamp"
|
||||
|
||||
# Every row this script can report on. `record` appends one; nothing else
|
||||
# writes to the checklist, so a criterion with no row is a criterion nobody
|
||||
# measured rather than one that silently passed.
|
||||
checklist=()
|
||||
record() { checklist+=("$1|$2|$3"); }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preflight
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "== preflight ==" >&2
|
||||
|
||||
target_dir="$(cargo metadata --no-deps --format-version 1 \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')"
|
||||
if [ -z "$target_dir" ]; then
|
||||
echo "could not resolve cargo's target directory from cargo metadata" >&2
|
||||
exit 70
|
||||
fi
|
||||
|
||||
if ! python3 -c 'import jsonschema' 2>/dev/null; then
|
||||
echo "jsonschema is not installed; bundles could not be validated" >&2
|
||||
exit 70
|
||||
fi
|
||||
|
||||
mkdir -p "$work" "$evidence"
|
||||
work="$(cd -- "$work" && pwd)"
|
||||
|
||||
# The frozen profile requires nodatacow on the journal and segment directories,
|
||||
# and store-bench refuses on mismatch rather than recording it. Setting the
|
||||
# attribute on an empty parent and creating each store root inside it is the
|
||||
# only way an unprivileged run can satisfy that.
|
||||
bench_parent="$work/bench"
|
||||
rm -rf "$bench_parent"
|
||||
mkdir -p "$bench_parent"
|
||||
chattr +C "$bench_parent" 2>/dev/null || true
|
||||
|
||||
fs_type="$(stat -f -c %T "$work" 2>/dev/null || echo unknown)"
|
||||
if [ "$fs_type" = "tmpfs" ]; then
|
||||
echo "the work directory is on tmpfs; no durability claim survives that." >&2
|
||||
echo "Pass --work with a persistent filesystem." >&2
|
||||
exit 70
|
||||
fi
|
||||
echo "work=$work fs=$fs_type evidence=$evidence" >&2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The ordinary gate
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$run_gate" -eq 1 ]; then
|
||||
echo "== phase 1 gate ==" >&2
|
||||
if bash "$repo_root/scripts/check-phase1.sh" >"$evidence/check-phase1.log" 2>&1; then
|
||||
record "Gate (fmt, workspace tests, crash matrix, whitespace)" PASS "check-phase1.log"
|
||||
else
|
||||
record "Gate (fmt, workspace tests, crash matrix, whitespace)" FAIL "check-phase1.log"
|
||||
fi
|
||||
else
|
||||
record "Gate (fmt, workspace tests, crash matrix, whitespace)" SKIP "--skip-gate"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Acknowledged crash recovery
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$run_recovery" -eq 1 ]; then
|
||||
echo "== crash recovery campaign ($cycles cycles) ==" >&2
|
||||
if bash "$repo_root/scripts/verify-store-recovery.sh" \
|
||||
--cycles "$cycles" --work "$work/recovery" \
|
||||
>"$evidence/verify-store-recovery.log" 2>&1; then
|
||||
record "Acknowledged crash recovery ($cycles SIGKILL cycles)" PASS "verify-store-recovery.log"
|
||||
else
|
||||
record "Acknowledged crash recovery ($cycles SIGKILL cycles)" FAIL "verify-store-recovery.log"
|
||||
fi
|
||||
else
|
||||
record "Acknowledged crash recovery" SKIP "--skip-recovery"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. The P2 campaign
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Release build, because `environment_fidelity` refuses `reference_profile`
|
||||
# without one -- a debug bundle is a diagnostic bundle whatever the hardware.
|
||||
echo "== building store-bench (release) ==" >&2
|
||||
cargo build -q --release -p levcs-store \
|
||||
--features bench-harness,store-internals,store-privileged --bin store-bench >&2
|
||||
|
||||
p2_pass=1
|
||||
for rep in $(seq 1 "$reps"); do
|
||||
echo "== P2 repetition $rep of $reps (${seconds}s) ==" >&2
|
||||
root="$bench_parent/p2-$rep"
|
||||
out="$evidence/storage-primitive-$rep.json"
|
||||
rm -rf "$root"
|
||||
if ! "$target_dir/release/store-bench" run \
|
||||
--root "$root" --out "$out" --path submit \
|
||||
--seconds "$seconds" >"$evidence/p2-$rep.log" 2>&1; then
|
||||
record "P2 repetition $rep" FAIL "p2-$rep.log"
|
||||
p2_pass=0
|
||||
continue
|
||||
fi
|
||||
|
||||
verdict="$(python3 - "$repo_root/bench/result-schema.json" "$out" <<'PY'
|
||||
import json, sys
|
||||
import jsonschema
|
||||
|
||||
schema = json.load(open(sys.argv[1]))
|
||||
bundle = json.load(open(sys.argv[2]))
|
||||
|
||||
errors = sorted(
|
||||
jsonschema.Draft202012Validator(schema).iter_errors(bundle),
|
||||
key=lambda e: list(e.path),
|
||||
)
|
||||
if errors:
|
||||
print("INVALID " + "; ".join(f"{list(e.path)}: {e.message}" for e in errors[:3]))
|
||||
raise SystemExit(0)
|
||||
|
||||
rc = bundle.get("run_conditions", {})
|
||||
# The four conditions a passing storage_primitive bundle must declare, checked
|
||||
# by name so the reason a repetition did not close is the field rather than a
|
||||
# schema error twenty levels down.
|
||||
missing = [
|
||||
f"{k}={rc.get(k)!r}"
|
||||
for k, want in (
|
||||
("initialization_path", "store_engine_open"),
|
||||
("mutation_path", "store_engine_submit"),
|
||||
("checkpointing", "exercised"),
|
||||
("index_maintenance", "runs_sealed"),
|
||||
)
|
||||
if rc.get(k) != want
|
||||
]
|
||||
fidelity = rc.get("environment_fidelity")
|
||||
if fidelity != "reference_profile":
|
||||
missing.append(f"environment_fidelity={fidelity!r}")
|
||||
outcome = bundle.get("outcome")
|
||||
if outcome != "pass":
|
||||
missing.append(f"outcome={outcome!r}")
|
||||
|
||||
print("PASS" if not missing else "SHORT " + ", ".join(missing))
|
||||
PY
|
||||
)"
|
||||
case "$verdict" in
|
||||
PASS) record "P2 repetition $rep (schema-valid, conditions met)" PASS "$(basename "$out")" ;;
|
||||
*) record "P2 repetition $rep" FAIL "$(basename "$out"): $verdict"; p2_pass=0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$p2_pass" -eq 1 ]; then
|
||||
record "P2 >=75k commits/s, p99 <=50ms, $reps repetitions" PASS "storage-primitive-*.json"
|
||||
else
|
||||
record "P2 >=75k commits/s, p99 <=50ms, $reps repetitions" FAIL "see repetitions above"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checklist
|
||||
# ---------------------------------------------------------------------------
|
||||
echo >&2
|
||||
echo "===========================================================" >&2
|
||||
echo " Phase 1 exit checklist ($stamp)" >&2
|
||||
echo "===========================================================" >&2
|
||||
failed=0
|
||||
for row in "${checklist[@]}"; do
|
||||
IFS='|' read -r what verdict artifact <<<"$row"
|
||||
printf ' %-6s %-56s %s\n' "$verdict" "$what" "$artifact" >&2
|
||||
[ "$verdict" = "FAIL" ] && failed=1
|
||||
done
|
||||
echo "-----------------------------------------------------------" >&2
|
||||
echo " evidence archived in: $evidence" >&2
|
||||
|
||||
{
|
||||
echo "# Phase 1 closure run $stamp"
|
||||
echo
|
||||
printf '| verdict | criterion | artifact |\n|---|---|---|\n'
|
||||
for row in "${checklist[@]}"; do
|
||||
IFS='|' read -r what verdict artifact <<<"$row"
|
||||
printf '| %s | %s | `%s` |\n' "$verdict" "$what" "$artifact"
|
||||
done
|
||||
} >"$evidence/CHECKLIST.md"
|
||||
|
||||
if [ "$failed" -eq 1 ]; then
|
||||
echo "CLOSURE_EXIT=1 (at least one criterion did not pass)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "CLOSURE_EXIT=0" >&2
|
||||
|
|
@ -108,9 +108,26 @@ trap cleanup EXIT
|
|||
|
||||
features="failpoints,store-internals,store-privileged"
|
||||
|
||||
# Where cargo puts binaries is not necessarily `$repo_root/target`:
|
||||
# `CARGO_TARGET_DIR`, a `build.target-dir` in a config.toml, and a shared
|
||||
# workspace target directory all move it. Assuming the default turns a host
|
||||
# that sets one into a build that succeeds followed by a "was not built"
|
||||
# refusal, which reads as a compilation failure and is not one. Ask cargo.
|
||||
#
|
||||
# This resolves the *build output* directory only. The working directory above
|
||||
# deliberately stays under `$repo_root/target`: it holds crash roots whose
|
||||
# filesystem is part of what the campaign measures, and a shared target
|
||||
# directory may be on a different one.
|
||||
target_dir="$(cargo metadata --no-deps --format-version 1 \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')"
|
||||
if [ -z "$target_dir" ]; then
|
||||
echo "could not resolve cargo's target directory from cargo metadata" >&2
|
||||
exit 70
|
||||
fi
|
||||
|
||||
echo "== building the crash driver ==" >&2
|
||||
cargo build -q -p levcs-store --features "$features" --bin store-crash-driver
|
||||
driver="$repo_root/target/debug/store-crash-driver"
|
||||
driver="$target_dir/debug/store-crash-driver"
|
||||
if [ ! -x "$driver" ]; then
|
||||
echo "store-crash-driver was not built at $driver" >&2
|
||||
exit 70
|
||||
|
|
@ -270,7 +287,7 @@ if [ "$run_bundle" = "1" ]; then
|
|||
bundle_out="$work/storage-primitive-skeleton-$bundle_variant.json"
|
||||
rm -rf "$bundle_root" "$bundle_out"
|
||||
|
||||
if "$repo_root/target/debug/store-bench" emit-skeleton \
|
||||
if "$target_dir/debug/store-bench" emit-skeleton \
|
||||
--root "$bundle_root" --out "$bundle_out" --path "$bundle_variant" \
|
||||
--allow-unsigned --seconds 2 --group-len 16 >&2; then
|
||||
if python3 - "$repo_root/bench/result-schema.json" "$bundle_out" >&2 <<'PY'
|
||||
|
|
@ -319,7 +336,7 @@ PY
|
|||
zero_work_root="$bundle_parent/zero-work-root"
|
||||
zero_work_out="$work/storage-primitive-zero-work.json"
|
||||
rm -rf "$zero_work_root" "$zero_work_out"
|
||||
if "$repo_root/target/debug/store-bench" emit-skeleton \
|
||||
if "$target_dir/debug/store-bench" emit-skeleton \
|
||||
--root "$zero_work_root" --out "$zero_work_out" --path submit \
|
||||
--allow-unsigned --seconds 2 --group-len 16 \
|
||||
--submitters-per-shard 0 >&2; then
|
||||
|
|
@ -348,7 +365,7 @@ PY
|
|||
unaccounted_root="$bundle_parent/unaccounted-root"
|
||||
unaccounted_out="$work/storage-primitive-unaccounted.json"
|
||||
rm -rf "$unaccounted_root" "$unaccounted_out"
|
||||
if "$repo_root/target/debug/store-bench" emit-skeleton \
|
||||
if "$target_dir/debug/store-bench" emit-skeleton \
|
||||
--root "$unaccounted_root" --out "$unaccounted_out" --path submit \
|
||||
--allow-unsigned --seconds 2 --group-len 4 \
|
||||
--shards 1 --submitters-per-shard 1 \
|
||||
|
|
|
|||
Loading…
Reference in New Issue