Represent every committed sequence in index coverage
A delta layer's `through_shard_sequence` states that every frame from its shard through that sequence is accounted for in the index. Skipping the layer when the delta was empty left a group that introduced no objects advancing the committed sequence without advancing coverage, 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. An empty layer is the smallest honest way to record that the sequence happened and carried nothing, so `with_subtree` now installs one whenever the publication advances the shard's committed sequence. A maintenance publication appends no frame and still owes nothing. `coverable_through` had the same gap from the other side: it stopped at the first layer whose entries did not resolve as a segment or an active tail, which excluded adopted projection artifacts. Those are the most durable entries in the index -- not in the journal at all, and carried forward by every successor generation -- and were read as the least. The empty layer counts toward layer fan-out and may seal into a zero-entry run. That is the bounded-run model working as designed and is the failure-safe direction: a stamped empty layer costs a run slot, an unstamped one costs the ability to checkpoint at all. Asserted with a plain object-less transaction rather than through the adopting path that found it, so the regression names what actually broke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGdH5V43XWnj1PdHqiPktQ
This commit is contained in:
parent
e0c599c432
commit
9bc298868a
|
|
@ -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(_)
|
||||
))
|
||||
)
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue