diff --git a/crates/levcs-store/src/engine.rs b/crates/levcs-store/src/engine.rs index 2147210..af2aea5 100644 --- a/crates/levcs-store/src/engine.rs +++ b/crates/levcs-store/src/engine.rs @@ -2863,12 +2863,25 @@ impl ShardWriter { if layer.shard_index != self.shard_index { continue; } + // What "stable" asks is whether these entries still resolve after + // the frames behind them are pruned. A sealed segment and an active + // tail do; so does an adopted projection artifact, which is not in + // the journal at all and which every successor generation carries + // forward by construction. Leaving `ProjectionArtifact` out of this + // list stopped coverage at the first adopting group and made the + // shard permanently uncheckpointable — the entries were the most + // durable kind in the index, and were read as the least. + // + // Enumerated and not a catch-all: a source kind added later must + // fail this compile and be decided, because the wrong answer here + // prunes frames whose objects nothing else can find. let stable = layer.delta.iter().all(|(_, location)| { matches!( root.object_source(self.shard_index, location.segment_generation), Ok(Some( crate::roots::RetainedObjectSource::Segment(_) | crate::roots::RetainedObjectSource::ActiveTail(_) + | crate::roots::RetainedObjectSource::ProjectionArtifact(_) )) ) }); diff --git a/crates/levcs-store/src/roots.rs b/crates/levcs-store/src/roots.rs index 1aef665..53a5a68 100644 --- a/crates/levcs-store/src/roots.rs +++ b/crates/levcs-store/src/roots.rs @@ -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, diff --git a/crates/levcs-store/tests/index_coverage.rs b/crates/levcs-store/tests/index_coverage.rs new file mode 100644 index 0000000..64a2bc4 --- /dev/null +++ b/crates/levcs-store/tests/index_coverage.rs @@ -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" + ); +} diff --git a/crates/levcs-store/tests/support/engine_matrix.rs b/crates/levcs-store/tests/support/engine_matrix.rs index 01d36b5..459e59d 100644 --- a/crates/levcs-store/tests/support/engine_matrix.rs +++ b/crates/levcs-store/tests/support/engine_matrix.rs @@ -183,7 +183,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 +198,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");