LeVCS/crates/levcs-store/src/roots.rs

1379 lines
45 KiB
Rust

//! Immutable publication roots for committed and in-flight store state.
//!
//! **Lead-owned (D0-B).** The types in this file are the boundary between the
//! shard writers and every reader. Writers build a complete [`ShardSubtree`],
//! merge it into the last root they loaded, and publish the resulting
//! [`CommittedRoot`] through `ArcSwap`. Nothing reachable from an already
//! published root is mutated.
//!
//! The persistent collections are intentional. A group touching a few
//! repositories, receipts, or refs must share the untouched majority rather
//! than clone it. Typed refs use [`im::OrdMap`] because their canonical order
//! feeds checkpoint/digest construction; hot lookup tables use
//! [`im::HashMap`].
use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use im::{HashMap, OrdMap, Vector};
use levcs_core::ObjectId;
use levcs_protocol::v2::RefTarget;
use crate::index::{
IndexDelta, IndexKey, IndexLocation, IndexRun, LookupResult, NamespaceLifecycle,
NamespaceStorageMode,
};
use crate::types::{
CommitReceipt, NamespaceId, OperationId, PendingPhase, StoreError, TransactionStatus,
};
/// Key shared by the committed terminal table and the transient status root.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct OperationKey {
pub namespace: NamespaceId,
pub operation_id: OperationId,
}
impl OperationKey {
pub const fn new(namespace: NamespaceId, operation_id: OperationId) -> Self {
Self {
namespace,
operation_id,
}
}
}
/// Canonically ordered typed refs.
///
/// Ref order is observable anywhere a state digest or checkpoint is encoded,
/// so this is the one hot publication map for which `OrdMap`, rather than the
/// faster unordered HAMT, is required.
pub type TypedRefMap = OrdMap<RefTarget, ObjectId>;
/// One repository's complete logical state at a committed publication.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RepoState {
pub repo_sequence: u64,
pub current_authority: ObjectId,
pub genesis_authority: ObjectId,
pub refs: TypedRefMap,
pub lifecycle: NamespaceLifecycle,
pub storage_mode: NamespaceStorageMode,
pub previous_event_digest: ObjectId,
}
/// A committed operation retained while its receipt remains visible.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RetainedReceipt {
pub operation_digest: ObjectId,
pub receipt: CommitReceipt,
pub shard_sequence: u64,
pub retry_until_micros: i64,
pub first_visible_at_micros: i64,
pub receipt_visible_until_micros: i64,
}
/// A committed operation whose receipt aged out but whose ID/digest tombstone
/// still prevents ambiguous reuse.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReceiptTombstone {
pub operation_digest: ObjectId,
pub retry_until_micros: i64,
pub tombstone_until_micros: i64,
}
/// Durable terminal state for one `(namespace, operation ID)`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TerminalStatusEntry {
Committed(RetainedReceipt),
Expired(ReceiptTombstone),
}
impl TerminalStatusEntry {
pub fn operation_digest(&self) -> ObjectId {
match self {
Self::Committed(receipt) => receipt.operation_digest,
Self::Expired(tombstone) => tombstone.operation_digest,
}
}
pub fn transaction_status(&self) -> TransactionStatus {
match self {
Self::Committed(receipt) => TransactionStatus::Committed(receipt.receipt.clone()),
Self::Expired(tombstone) => TransactionStatus::Expired {
operation_digest: tombstone.operation_digest,
retry_until_micros: tombstone.retry_until_micros,
tombstone_until_micros: tombstone.tombstone_until_micros,
},
}
}
}
/// A file pin retained by a committed generation.
///
/// The live `File`, not the path, is the ownership. On the supported Unix
/// storage targets it keeps the inode alive even after reclamation removes a
/// directory entry. The path remains for diagnostics and reference proofs.
#[derive(Clone, Debug)]
pub struct PinnedFile {
path: PathBuf,
file: Arc<File>,
}
impl PinnedFile {
pub fn new(path: PathBuf, file: File) -> Self {
Self {
path,
file: Arc::new(file),
}
}
pub fn from_shared(path: PathBuf, file: Arc<File>) -> Self {
Self { path, file }
}
pub fn open(path: impl Into<PathBuf>) -> Result<Self, StoreError> {
let path = path.into();
let file = File::open(&path)?;
Ok(Self::new(path, file))
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn file(&self) -> &Arc<File> {
&self.file
}
}
/// A validated immutable index run together with its installed name.
///
/// `IndexRun` owns its mapping; retaining the `Arc` therefore retains the
/// mapped inode. Keeping the path alongside it makes adoption cleanup's
/// committed-root reference proof possible without consulting mutable
/// staging bookkeeping.
#[derive(Clone, Debug)]
pub struct RetainedIndexRun {
path: PathBuf,
run: Arc<IndexRun>,
}
impl RetainedIndexRun {
pub fn new(path: PathBuf, run: Arc<IndexRun>) -> Self {
Self { path, run }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn run(&self) -> &Arc<IndexRun> {
&self.run
}
}
/// A validated sealed segment addressed by the generation stored in index
/// locations.
///
/// The pin keeps the inode alive; the logical generation is what lets a
/// reader resolve `IndexLocation::segment_generation` without parsing a file
/// name or consulting mutable manifest state.
#[derive(Clone, Debug)]
pub struct RetainedSegment {
pub logical_generation: u64,
pub journal_id: [u8; 16],
pub first_shard_sequence: u64,
pub last_shard_sequence: u64,
pub file: PinnedFile,
}
impl RetainedSegment {
pub fn new(
logical_generation: u64,
journal_id: [u8; 16],
first_shard_sequence: u64,
last_shard_sequence: u64,
file: PinnedFile,
) -> Self {
Self {
logical_generation,
journal_id,
first_shard_sequence,
last_shard_sequence,
file,
}
}
pub fn path(&self) -> &Path {
self.file.path()
}
}
/// A validated active journal prefix addressed as one logical segment.
///
/// Replayed index entries use `IndexLocation::segment_generation`, so the
/// generation assigned during recovery must travel with the file pin into
/// every committed root. A bare `PinnedFile` would keep the bytes alive but
/// leave readers unable to resolve those locations without reopening or
/// guessing a path.
#[derive(Clone, Debug)]
pub struct RetainedTail {
pub logical_generation: u64,
pub file: PinnedFile,
}
impl RetainedTail {
pub fn new(logical_generation: u64, file: PinnedFile) -> Self {
Self {
logical_generation,
file,
}
}
pub fn path(&self) -> &Path {
self.file.path()
}
}
/// Physical encoding used by an adopted projection artifact.
///
/// Staged chunks are independently canonical and checksummed; they are not
/// transaction frames. The source kind therefore travels with the generation
/// pin so a reader never applies the journal-frame decoder to chunk bytes.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ProjectionArtifactFormat {
CanonicalStageChunkV1,
}
/// One adopted projection artifact addressable by index locations.
#[derive(Clone, Debug)]
pub struct RetainedProjectionArtifact {
pub logical_generation: u64,
pub format: ProjectionArtifactFormat,
pub file: PinnedFile,
}
impl RetainedProjectionArtifact {
pub fn new(
logical_generation: u64,
format: ProjectionArtifactFormat,
file: PinnedFile,
) -> Self {
Self {
logical_generation,
format,
file,
}
}
pub fn path(&self) -> &Path {
self.file.path()
}
}
/// The physical record decoder selected by one index generation.
#[derive(Copy, Clone, Debug)]
pub enum RetainedObjectSource<'a> {
Segment(&'a RetainedSegment),
ActiveTail(&'a RetainedTail),
ProjectionArtifact(&'a RetainedProjectionArtifact),
}
impl RetainedObjectSource<'_> {
pub fn path(&self) -> &Path {
match self {
Self::Segment(source) => source.path(),
Self::ActiveTail(source) => source.path(),
Self::ProjectionArtifact(source) => source.path(),
}
}
}
/// Stable identity of one retained manifest generation.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct GenerationId {
pub shard_index: u16,
pub manifest_generation: u64,
}
impl GenerationId {
pub const fn new(shard_index: u16, manifest_generation: u64) -> Self {
Self {
shard_index,
manifest_generation,
}
}
}
/// Live ownership of every artifact retained by one manifest generation.
///
/// Recovery constructs this only after validating all referents. Moving it
/// into a root leaves no interval where the artifacts are merely named but
/// unowned and therefore reclaimable.
#[derive(Clone, Debug)]
pub struct RetainedGeneration {
pub id: GenerationId,
pub segments: Arc<[RetainedSegment]>,
pub index_runs: Arc<[RetainedIndexRun]>,
pub checkpoints: Arc<[PinnedFile]>,
pub active_tails: Arc<[RetainedTail]>,
pub projection_artifacts: Arc<[RetainedProjectionArtifact]>,
}
impl RetainedGeneration {
pub fn new(
id: GenerationId,
segments: Arc<[RetainedSegment]>,
index_runs: Arc<[RetainedIndexRun]>,
checkpoints: Arc<[PinnedFile]>,
active_tails: Arc<[RetainedTail]>,
projection_artifacts: Arc<[RetainedProjectionArtifact]>,
) -> Self {
Self {
id,
segments,
index_runs,
checkpoints,
active_tails,
projection_artifacts,
}
}
pub fn references_path(&self, path: &Path) -> bool {
self.segments.iter().any(|segment| segment.path() == path)
|| self.index_runs.iter().any(|run| run.path() == path)
|| self.checkpoints.iter().any(|file| file.path() == path)
|| self.active_tails.iter().any(|tail| tail.path() == path)
|| self
.projection_artifacts
.iter()
.any(|artifact| artifact.path() == path)
}
pub fn retained_tail(&self, logical_generation: u64) -> Option<&RetainedTail> {
self.active_tails
.iter()
.find(|tail| tail.logical_generation == logical_generation)
}
pub fn retained_segment(&self, logical_generation: u64) -> Option<&RetainedSegment> {
self.segments
.iter()
.find(|segment| segment.logical_generation == logical_generation)
}
pub fn retained_projection_artifact(
&self,
logical_generation: u64,
) -> Option<&RetainedProjectionArtifact> {
self.projection_artifacts
.iter()
.find(|artifact| artifact.logical_generation == logical_generation)
}
}
/// Immutable object-index layering captured by every reader.
///
/// Both vectors are newest-first. Group deltas sit above sealed runs, and the
/// first hit wins. Each layer/run is `Arc`-shared, so a merge adds only the
/// new group's layer and shares every older one. Delta layers carry their
/// 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.
#[derive(Clone, Debug)]
pub struct IndexDeltaLayer {
pub shard_index: u16,
pub through_shard_sequence: u64,
pub delta: Arc<IndexDelta>,
}
impl IndexDeltaLayer {
pub fn new(shard_index: u16, through_shard_sequence: u64, delta: Arc<IndexDelta>) -> Self {
Self {
shard_index,
through_shard_sequence,
delta,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct LayeredObjectIndex {
delta_layers_newest_first: Vector<IndexDeltaLayer>,
sealed_runs_newest_first: Vector<Arc<IndexRun>>,
}
impl LayeredObjectIndex {
pub fn new(
delta_layers_newest_first: Vector<IndexDeltaLayer>,
sealed_runs_newest_first: Vector<Arc<IndexRun>>,
) -> Self {
Self {
delta_layers_newest_first,
sealed_runs_newest_first,
}
}
/// Construct the complete recovery lookup stack: replayed tail above the
/// selected generation's already ordered sealed runs.
pub fn from_recovery(
shard_index: u16,
through_shard_sequence: u64,
replayed_delta: Arc<IndexDelta>,
sealed_runs_newest_first: Vector<Arc<IndexRun>>,
) -> Self {
let mut delta_layers = Vector::new();
if !replayed_delta.is_empty() {
delta_layers.push_back(IndexDeltaLayer::new(
shard_index,
through_shard_sequence,
replayed_delta,
));
}
Self::new(delta_layers, sealed_runs_newest_first)
}
pub fn delta_layers(&self) -> &Vector<IndexDeltaLayer> {
&self.delta_layers_newest_first
}
pub fn sealed_runs(&self) -> &Vector<Arc<IndexRun>> {
&self.sealed_runs_newest_first
}
pub fn delta_layer_count(&self) -> usize {
self.delta_layers_newest_first.len()
}
pub fn sealed_run_count(&self) -> usize {
self.sealed_runs_newest_first.len()
}
pub fn lookup(&self, key: &IndexKey) -> LookupResult {
for layer in &self.delta_layers_newest_first {
if let Some(location) = layer.delta.get(key) {
return LookupResult {
location: Some(location),
runs_searched: 0,
runs_filtered: 0,
answered_by_delta: true,
};
}
}
let mut result = LookupResult::default();
for run in &self.sealed_runs_newest_first {
if !run.may_contain(key) {
result.runs_filtered += 1;
continue;
}
result.runs_searched += 1;
if let Some(location) = run.get(key) {
result.location = Some(location);
return result;
}
}
result
}
pub fn get(&self, key: &IndexKey) -> Option<IndexLocation> {
self.lookup(key).location
}
fn with_subtree(&self, subtree: &ShardSubtree) -> Self {
let mut next = self.clone();
if let Some(sealed_through) = subtree.sealed_through_shard_sequence {
next.delta_layers_newest_first.retain(|layer| {
layer.shard_index != subtree.shard_index
|| layer.through_shard_sequence > sealed_through
});
}
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() {
next.delta_layers_newest_first
.push_front(IndexDeltaLayer::new(
subtree.shard_index,
subtree.shard_committed_sequence,
Arc::clone(&subtree.index_delta),
));
}
// `subtree` is already newest-first. Pushing from its back preserves
// that order at the front of the composed stack.
for run in subtree.sealed_runs_newest_first.iter().rev() {
next.sealed_runs_newest_first.push_front(Arc::clone(run));
}
next
}
}
pub type RepoMap = HashMap<NamespaceId, Arc<RepoState>>;
pub type TerminalStatusMap = HashMap<OperationKey, TerminalStatusEntry>;
pub type ShardSequenceMap = HashMap<u16, u64>;
pub type GenerationMap = HashMap<GenerationId, Arc<RetainedGeneration>>;
/// The immutable visibility boundary for all committed reads.
#[derive(Clone, Debug, Default)]
pub struct CommittedRoot {
repositories: RepoMap,
index: LayeredObjectIndex,
terminal_statuses: TerminalStatusMap,
shard_committed_sequences: ShardSequenceMap,
retained_generations: GenerationMap,
}
impl CommittedRoot {
pub fn new(
repositories: RepoMap,
index: LayeredObjectIndex,
terminal_statuses: TerminalStatusMap,
shard_committed_sequences: ShardSequenceMap,
retained_generations: GenerationMap,
) -> Self {
Self {
repositories,
index,
terminal_statuses,
shard_committed_sequences,
retained_generations,
}
}
pub fn repositories(&self) -> &RepoMap {
&self.repositories
}
pub fn repo(&self, namespace: &NamespaceId) -> Option<&Arc<RepoState>> {
self.repositories.get(namespace)
}
pub fn index(&self) -> &LayeredObjectIndex {
&self.index
}
pub fn terminal_statuses(&self) -> &TerminalStatusMap {
&self.terminal_statuses
}
pub fn terminal_status(&self, key: &OperationKey) -> Option<&TerminalStatusEntry> {
self.terminal_statuses.get(key)
}
pub fn shard_committed_sequences(&self) -> &ShardSequenceMap {
&self.shard_committed_sequences
}
pub fn shard_committed_sequence(&self, shard_index: u16) -> Option<u64> {
self.shard_committed_sequences.get(&shard_index).copied()
}
pub fn retained_generations(&self) -> &GenerationMap {
&self.retained_generations
}
/// Reference proof used by finalized-staging cleanup.
///
/// The answer comes from the captured committed root's live generation
/// ownership, never from staging's mutable bookkeeping.
pub fn references_path(&self, path: &Path) -> bool {
self.retained_generations
.values()
.any(|generation| generation.references_path(path))
}
/// Staging-facing spelling of [`Self::references_path`].
pub fn references_artifact(&self, path: &Path) -> bool {
self.references_path(path)
}
pub fn references_generation(&self, id: GenerationId) -> bool {
self.retained_generations.contains_key(&id)
}
/// Resolve a replayed active-tail index generation to its retained pin.
pub fn retained_tail(
&self,
shard_index: u16,
logical_generation: u64,
) -> Option<&RetainedTail> {
self.retained_generations
.values()
.filter(|generation| generation.id.shard_index == shard_index)
.find_map(|generation| generation.retained_tail(logical_generation))
}
/// Resolve a sealed-run index generation to its retained segment pin.
pub fn retained_segment(
&self,
shard_index: u16,
logical_generation: u64,
) -> Option<&RetainedSegment> {
self.retained_generations
.values()
.filter(|generation| generation.id.shard_index == shard_index)
.find_map(|generation| generation.retained_segment(logical_generation))
}
/// Resolve an adopted projection generation to its retained artifact.
pub fn retained_projection_artifact(
&self,
shard_index: u16,
logical_generation: u64,
) -> Option<&RetainedProjectionArtifact> {
self.retained_generations
.values()
.filter(|generation| generation.id.shard_index == shard_index)
.find_map(|generation| generation.retained_projection_artifact(logical_generation))
}
/// Resolve the decoder and live pin for an index location.
///
/// A logical generation may be retained by several manifest generations,
/// but every occurrence must name the same path and source kind. A
/// segment/chunk collision is corruption, never an arbitrary lookup
/// preference.
pub fn object_source(
&self,
shard_index: u16,
logical_generation: u64,
) -> Result<Option<RetainedObjectSource<'_>>, StoreError> {
let mut found: Option<RetainedObjectSource<'_>> = None;
for generation in self
.retained_generations
.values()
.filter(|generation| generation.id.shard_index == shard_index)
{
let candidates = [
generation
.retained_segment(logical_generation)
.map(RetainedObjectSource::Segment),
generation
.retained_tail(logical_generation)
.map(RetainedObjectSource::ActiveTail),
generation
.retained_projection_artifact(logical_generation)
.map(RetainedObjectSource::ProjectionArtifact),
];
for candidate in candidates.into_iter().flatten() {
if let Some(existing) = found {
if std::mem::discriminant(&existing) != std::mem::discriminant(&candidate)
|| existing.path() != candidate.path()
{
return Err(StoreError::Corruption(format!(
"shard {shard_index} generation {logical_generation} names \
multiple object sources"
)));
}
} else {
found = Some(candidate);
}
}
}
Ok(found)
}
/// Purely merge one fenced shard publication into this root.
///
/// Reapplying a subtree whose shard sequence is already present is a
/// no-op. This makes the CAS retry path idempotent in effect while still
/// allowing a subtree from another shard to merge against a newer root.
/// No object reachable through `self` is mutated.
///
/// # Maintenance publications
///
/// Contract review 2026-07-29-C, amendment 3. An index seal publishes a
/// subtree that appends **no frame**, so its `shard_committed_sequence` is
/// the one already published — and the guard above, which exists to make a
/// re-merged *group* idempotent, would discard it as a duplicate. Sealing
/// would then advance the manifest on disk and never reach the root, which is
/// exactly the ambiguity the seal path poisons to avoid; it would have been
/// silent instead.
///
/// So the guard asks what the subtree carries rather than only what sequence
/// it names. Re-merging a maintenance subtree stays idempotent for a
/// different reason: the layer-discard is a `retain` (already-dropped layers
/// stay dropped), and the CAS loop re-merges the same subtree against a fresh
/// root each attempt rather than accumulating onto its own output, so a run
/// is pushed once into whichever root wins.
pub fn merge(&self, subtree: &ShardSubtree) -> CommittedRoot {
if self
.shard_committed_sequence(subtree.shard_index)
.is_some_and(|published| published >= subtree.shard_committed_sequence)
&& !subtree.publishes_index_maintenance()
{
return self.clone();
}
let mut repositories = self.repositories.clone();
for (namespace, state) in &subtree.repositories {
repositories.insert(*namespace, Arc::clone(state));
}
let mut terminal_statuses = self.terminal_statuses.clone();
for key in &subtree.terminal_status_removals {
terminal_statuses.remove(key);
}
for (key, status) in &subtree.terminal_statuses {
terminal_statuses.insert(*key, status.clone());
}
let mut shard_committed_sequences = self.shard_committed_sequences.clone();
shard_committed_sequences.insert(subtree.shard_index, subtree.shard_committed_sequence);
let mut retained_generations = self.retained_generations.clone();
for id in &subtree.retained_generation_removals {
retained_generations.remove(id);
}
for (id, generation) in &subtree.retained_generations {
retained_generations.insert(*id, Arc::clone(generation));
}
Self {
repositories,
index: self.index.with_subtree(subtree),
terminal_statuses,
shard_committed_sequences,
retained_generations,
}
}
}
/// Complete immutable output of one fenced group, before root merge.
#[derive(Clone, Debug)]
pub struct ShardSubtree {
pub shard_index: u16,
pub shard_committed_sequence: u64,
pub index_delta: Arc<IndexDelta>,
/// When present, the newly installed sealed runs cover every delta layer
/// from this shard through the named sequence, including this subtree's
/// delta when the value reaches `shard_committed_sequence`.
pub sealed_through_shard_sequence: Option<u64>,
/// Any newly installed runs, newest-first. Most groups leave this empty.
pub sealed_runs_newest_first: Vector<Arc<IndexRun>>,
pub repositories: RepoMap,
/// Terminal rows whose tombstone horizon has ended. Removals are applied
/// before replacements, which keeps retrying the same subtree idempotent.
pub terminal_status_removals: Vector<OperationKey>,
pub terminal_statuses: TerminalStatusMap,
/// Generation pins released by the same publication after every newer
/// root/index reference has been installed.
pub retained_generation_removals: Vector<GenerationId>,
pub retained_generations: GenerationMap,
}
impl ShardSubtree {
#[allow(clippy::too_many_arguments)]
pub fn new(
shard_index: u16,
shard_committed_sequence: u64,
index_delta: Arc<IndexDelta>,
sealed_through_shard_sequence: Option<u64>,
sealed_runs_newest_first: Vector<Arc<IndexRun>>,
repositories: RepoMap,
terminal_statuses: TerminalStatusMap,
retained_generations: GenerationMap,
) -> Self {
Self {
shard_index,
shard_committed_sequence,
index_delta,
sealed_through_shard_sequence,
sealed_runs_newest_first,
repositories,
terminal_status_removals: Vector::new(),
terminal_statuses,
retained_generation_removals: Vector::new(),
retained_generations,
}
}
/// Whether this subtree publishes index maintenance rather than (or as well
/// as) a group.
///
/// Read by [`CommittedRoot::merge`] so a publication that appends no frame is
/// not mistaken for a re-merged duplicate. Deliberately a question about the
/// payload and not a flag the writer sets: a boolean could disagree with the
/// fields, and the disagreement that matters — a seal marked as a group —
/// would drop the seal.
pub fn publishes_index_maintenance(&self) -> bool {
self.sealed_through_shard_sequence.is_some()
|| !self.sealed_runs_newest_first.is_empty()
|| !self.retained_generations.is_empty()
|| !self.retained_generation_removals.is_empty()
}
}
/// Phase stored in the bounded transient status root.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum StatusPhase {
Pending(PendingPhase),
Resolving,
}
/// One transient operation reservation.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct StatusEntry {
pub operation_digest: ObjectId,
pub retry_until_micros: i64,
pub phase: StatusPhase,
pub shard_sequence: Option<u64>,
}
impl StatusEntry {
pub fn pending(
operation_digest: ObjectId,
retry_until_micros: i64,
phase: PendingPhase,
) -> Self {
Self {
operation_digest,
retry_until_micros,
phase: StatusPhase::Pending(phase),
shard_sequence: None,
}
}
pub fn resolving(
operation_digest: ObjectId,
retry_until_micros: i64,
shard_sequence: Option<u64>,
) -> Self {
Self {
operation_digest,
retry_until_micros,
phase: StatusPhase::Resolving,
shard_sequence,
}
}
pub fn transaction_status(self) -> TransactionStatus {
match self.phase {
StatusPhase::Pending(phase) => TransactionStatus::Pending {
operation_digest: self.operation_digest,
retry_until_micros: self.retry_until_micros,
phase,
},
StatusPhase::Resolving => TransactionStatus::Resolving {
operation_digest: self.operation_digest,
retry_until_micros: self.retry_until_micros,
shard_sequence: self.shard_sequence,
},
}
}
}
/// Result of atomically reconsidering a reservation against one status root.
///
/// The engine reruns this operation after an `ArcSwap` CAS failure. Existing
/// IDs are examined before the capacity bound, preserving attachment and
/// mismatch semantics under overload.
#[derive(Clone, Debug)]
pub enum StatusReservation {
Attached(StatusEntry),
OperationIdMismatch {
existing_digest: ObjectId,
submitted_digest: ObjectId,
},
AtCapacity {
limit: u64,
},
Inserted(OperationStatusRoot),
}
/// Observable capacity metrics for the status-root CAS owner.
///
/// These counters deliberately live beside, not inside, the immutable root.
/// The engine records occupancy only after a successful CAS and records a
/// rejection only after the retry loop reaches a final `AtCapacity` result;
/// speculative CAS attempts therefore cannot inflate either value.
#[derive(Debug, Default)]
pub struct OperationStatusMetrics {
occupancy: AtomicU64,
rejections: AtomicU64,
}
impl OperationStatusMetrics {
pub fn record_published(&self, root: &OperationStatusRoot) {
self.occupancy.store(root.len() as u64, Ordering::Relaxed);
}
pub fn record_rejection(&self) {
self.rejections.fetch_add(1, Ordering::Relaxed);
}
pub fn snapshot(&self) -> OperationStatusMetricSnapshot {
OperationStatusMetricSnapshot {
occupancy: self.occupancy.load(Ordering::Relaxed),
rejections: self.rejections.load(Ordering::Relaxed),
}
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct OperationStatusMetricSnapshot {
pub occupancy: u64,
pub rejections: u64,
}
/// Bounded immutable map of operations that have not reached terminal state.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OperationStatusRoot {
entries: HashMap<OperationKey, StatusEntry>,
max_entries: u64,
}
impl OperationStatusRoot {
pub fn new(max_entries: u64) -> Self {
assert!(
max_entries > 0,
"max_status_entries is validated as nonzero at startup"
);
Self {
entries: HashMap::new(),
max_entries,
}
}
pub fn entries(&self) -> &HashMap<OperationKey, StatusEntry> {
&self.entries
}
pub fn get(&self, key: &OperationKey) -> Option<&StatusEntry> {
self.entries.get(key)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn max_entries(&self) -> u64 {
self.max_entries
}
/// Reconsider and, when possible, immutably insert one reservation.
///
/// The caller must publish an `Inserted` root with CAS and retry this
/// method on contention. That retry is what makes the bound and insertion
/// atomic under concurrent reservations.
pub fn reserve(&self, key: OperationKey, entry: StatusEntry) -> StatusReservation {
if let Some(existing) = self.entries.get(&key).copied() {
if existing.operation_digest == entry.operation_digest {
return StatusReservation::Attached(existing);
}
return StatusReservation::OperationIdMismatch {
existing_digest: existing.operation_digest,
submitted_digest: entry.operation_digest,
};
}
if self.entries.len() as u64 >= self.max_entries {
return StatusReservation::AtCapacity {
limit: self.max_entries,
};
}
let mut entries = self.entries.clone();
entries.insert(key, entry);
StatusReservation::Inserted(Self {
entries,
max_entries: self.max_entries,
})
}
/// Replace an already reserved entry without applying the admission
/// bound. In particular, a transition to `Resolving` can never be evicted
/// or refused because unrelated entries filled the root afterward.
pub fn replace_existing(&self, key: OperationKey, entry: StatusEntry) -> Option<Self> {
let existing = self.entries.get(&key)?;
if existing.operation_digest != entry.operation_digest {
return None;
}
let mut entries = self.entries.clone();
entries.insert(key, entry);
Some(Self {
entries,
max_entries: self.max_entries,
})
}
pub fn without(&self, key: &OperationKey) -> Self {
let mut entries = self.entries.clone();
entries.remove(key);
Self {
entries,
max_entries: self.max_entries,
}
}
pub fn without_all<'a>(&self, keys: impl IntoIterator<Item = &'a OperationKey>) -> Self {
let mut entries = self.entries.clone();
for key in keys {
entries.remove(key);
}
Self {
entries,
max_entries: self.max_entries,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::{IndexKey, IndexLocation};
fn id(byte: u8) -> ObjectId {
ObjectId([byte; 32])
}
fn namespace(byte: u8) -> NamespaceId {
NamespaceId([byte; 32])
}
fn operation(byte: u8) -> OperationId {
OperationId([byte; 16])
}
fn repo(sequence: u64) -> Arc<RepoState> {
Arc::new(RepoState {
repo_sequence: sequence,
current_authority: id(1),
genesis_authority: id(2),
refs: TypedRefMap::new(),
lifecycle: NamespaceLifecycle::Active,
storage_mode: NamespaceStorageMode::Full,
previous_event_digest: id(3),
})
}
fn delta(namespace: NamespaceId, object: ObjectId, location: IndexLocation) -> Arc<IndexDelta> {
let mut delta = IndexDelta::new(100, 1 << 20);
delta
.insert(IndexKey::new(namespace, object), location)
.expect("insert");
Arc::new(delta)
}
fn subtree(
shard: u16,
shard_sequence: u64,
namespace: NamespaceId,
repo_sequence: u64,
index_delta: Arc<IndexDelta>,
) -> ShardSubtree {
let mut repositories = RepoMap::new();
repositories.insert(namespace, repo(repo_sequence));
ShardSubtree::new(
shard,
shard_sequence,
index_delta,
None,
Vector::new(),
repositories,
TerminalStatusMap::new(),
GenerationMap::new(),
)
}
#[test]
fn duplicate_subtree_is_idempotent_in_effect() {
let ns = namespace(1);
let object = id(7);
let location = IndexLocation {
segment_generation: 3,
frame_offset: 40,
frame_len: 50,
object_type: 1,
shard_sequence: 9,
};
let mut subtree = subtree(0, 9, ns, 1, delta(ns, object, location));
let key = OperationKey::new(ns, operation(4));
subtree.terminal_statuses.insert(
key,
TerminalStatusEntry::Expired(ReceiptTombstone {
operation_digest: id(8),
retry_until_micros: 100,
tombstone_until_micros: 200,
}),
);
let once = CommittedRoot::default().merge(&subtree);
let twice = once.merge(&subtree);
assert_eq!(twice.repositories().len(), 1);
assert_eq!(twice.terminal_statuses().len(), 1);
assert_eq!(twice.index().delta_layer_count(), 1);
assert_eq!(twice.shard_committed_sequence(0), Some(9));
assert_eq!(
twice.index().get(&IndexKey::new(ns, object)),
Some(location)
);
}
#[test]
fn subtree_can_prune_terminal_rows_without_rebuilding_the_table() {
let ns = namespace(1);
let removed = OperationKey::new(ns, operation(4));
let retained = OperationKey::new(ns, operation(5));
let mut statuses = TerminalStatusMap::new();
for key in [removed, retained] {
statuses.insert(
key,
TerminalStatusEntry::Expired(ReceiptTombstone {
operation_digest: id(key.operation_id.0[0]),
retry_until_micros: 100,
tombstone_until_micros: 200,
}),
);
}
let root = CommittedRoot::new(
RepoMap::new(),
LayeredObjectIndex::default(),
statuses,
ShardSequenceMap::new(),
GenerationMap::new(),
);
let mut prune = subtree(0, 1, ns, 1, Arc::new(IndexDelta::new(10, 1024)));
prune.terminal_status_removals.push_back(removed);
let merged = root.merge(&prune);
assert!(merged.terminal_status(&removed).is_none());
assert!(merged.terminal_status(&retained).is_some());
}
#[test]
fn concurrent_shards_merge_without_losing_the_other_subtree() {
let ns_a = namespace(1);
let ns_b = namespace(2);
let empty_a = Arc::new(IndexDelta::new(10, 1024));
let empty_b = Arc::new(IndexDelta::new(10, 1024));
let a = subtree(0, 4, ns_a, 4, empty_a);
let b = subtree(1, 8, ns_b, 7, empty_b);
let after_a = CommittedRoot::default().merge(&a);
let after_b_retry = after_a.merge(&b);
assert_eq!(after_b_retry.repo(&ns_a).unwrap().repo_sequence, 4);
assert_eq!(after_b_retry.repo(&ns_b).unwrap().repo_sequence, 7);
assert_eq!(after_b_retry.shard_committed_sequence(0), Some(4));
assert_eq!(after_b_retry.shard_committed_sequence(1), Some(8));
}
#[test]
fn sealed_publication_discards_only_covered_layers_from_its_shard() {
let ns_a = namespace(1);
let ns_b = namespace(2);
let a = subtree(
0,
1,
ns_a,
1,
delta(
ns_a,
id(10),
IndexLocation {
segment_generation: 1,
frame_offset: 1,
frame_len: 1,
object_type: 1,
shard_sequence: 1,
},
),
);
let b = subtree(
1,
1,
ns_b,
1,
delta(
ns_b,
id(11),
IndexLocation {
segment_generation: 1,
frame_offset: 2,
frame_len: 1,
object_type: 1,
shard_sequence: 1,
},
),
);
let root = CommittedRoot::default().merge(&a).merge(&b);
assert_eq!(root.index().delta_layer_count(), 2);
let mut seal = subtree(0, 2, ns_a, 2, Arc::new(IndexDelta::new(10, 1024)));
seal.sealed_through_shard_sequence = Some(2);
let sealed = root.merge(&seal);
assert_eq!(sealed.index().delta_layer_count(), 1);
let survivor = sealed.index().delta_layers().front().unwrap();
assert_eq!(survivor.shard_index, 1);
}
#[test]
fn untouched_repository_arc_is_shared_across_merge() {
let untouched_namespace = namespace(1);
let changed_namespace = namespace(2);
let untouched = repo(5);
let mut repositories = RepoMap::new();
repositories.insert(untouched_namespace, Arc::clone(&untouched));
let root = CommittedRoot::new(
repositories,
LayeredObjectIndex::default(),
TerminalStatusMap::new(),
ShardSequenceMap::new(),
GenerationMap::new(),
);
let update = subtree(
1,
1,
changed_namespace,
1,
Arc::new(IndexDelta::new(10, 1024)),
);
let merged = root.merge(&update);
assert!(Arc::ptr_eq(
merged.repo(&untouched_namespace).unwrap(),
&untouched
));
}
#[test]
fn status_reservation_checks_identity_before_capacity() {
let ns = namespace(1);
let key = OperationKey::new(ns, operation(1));
let entry = StatusEntry::pending(id(1), 100, PendingPhase::Queued);
let root = match OperationStatusRoot::new(1).reserve(key, entry) {
StatusReservation::Inserted(root) => root,
other => panic!("expected insertion, got {other:?}"),
};
assert!(matches!(
root.reserve(key, entry),
StatusReservation::Attached(found) if found == entry
));
assert!(matches!(
root.reserve(key, StatusEntry::pending(id(2), 100, PendingPhase::Queued)),
StatusReservation::OperationIdMismatch { .. }
));
assert!(matches!(
root.reserve(
OperationKey::new(ns, operation(2)),
StatusEntry::pending(id(3), 100, PendingPhase::Queued)
),
StatusReservation::AtCapacity { limit: 1 }
));
}
#[test]
fn resolving_transition_is_not_subject_to_the_capacity_bound() {
let ns = namespace(1);
let key = OperationKey::new(ns, operation(1));
let root = match OperationStatusRoot::new(1).reserve(
key,
StatusEntry::pending(id(1), 100, PendingPhase::Sequenced),
) {
StatusReservation::Inserted(root) => root,
other => panic!("expected insertion, got {other:?}"),
};
let resolving = StatusEntry::resolving(id(1), 100, Some(9));
let root = root
.replace_existing(key, resolving)
.expect("existing reservations transition despite full capacity");
assert_eq!(root.get(&key), Some(&resolving));
}
#[test]
fn status_metrics_observe_only_final_publications_and_rejections() {
let metrics = OperationStatusMetrics::default();
let root = OperationStatusRoot::new(1);
metrics.record_published(&root);
metrics.record_rejection();
metrics.record_rejection();
assert_eq!(
metrics.snapshot(),
OperationStatusMetricSnapshot {
occupancy: 0,
rejections: 2,
}
);
}
#[test]
fn retained_generation_reference_proof_uses_the_committed_root() {
let temp = tempfile::tempdir().expect("tempdir");
let segment_path = temp.path().join("1-1-1.seg");
let tail_path = temp.path().join("2.journal");
let projection_path = temp.path().join("3.stage-chunk");
std::fs::write(&segment_path, b"segment").expect("write");
std::fs::write(&tail_path, b"tail").expect("write");
std::fs::write(&projection_path, b"projection").expect("write");
let pinned = RetainedSegment::new(
1,
[7; 16],
1,
1,
PinnedFile::open(segment_path.clone()).expect("pin"),
);
let tail = RetainedTail::new(
2,
PinnedFile::open(tail_path.clone()).expect("pin active tail"),
);
let generation = Arc::new(RetainedGeneration::new(
GenerationId::new(0, 1),
Arc::from([pinned]),
Arc::from([]),
Arc::from([]),
Arc::from([tail]),
Arc::from([RetainedProjectionArtifact::new(
3,
ProjectionArtifactFormat::CanonicalStageChunkV1,
PinnedFile::open(projection_path.clone()).expect("pin projection artifact"),
)]),
));
let mut generations = GenerationMap::new();
generations.insert(generation.id, generation);
let root = CommittedRoot::new(
RepoMap::new(),
LayeredObjectIndex::default(),
TerminalStatusMap::new(),
ShardSequenceMap::new(),
generations,
);
assert!(root.references_path(&segment_path));
assert!(root.references_path(&tail_path));
assert!(root.references_path(&projection_path));
assert!(root.references_generation(GenerationId::new(0, 1)));
assert_eq!(
root.retained_tail(0, 2).map(RetainedTail::path),
Some(tail_path.as_path())
);
assert_eq!(
root.retained_segment(0, 1).map(RetainedSegment::path),
Some(segment_path.as_path())
);
assert_eq!(
root.retained_projection_artifact(0, 3)
.map(RetainedProjectionArtifact::path),
Some(projection_path.as_path())
);
assert!(matches!(
root.object_source(0, 3).expect("unambiguous source"),
Some(RetainedObjectSource::ProjectionArtifact(_))
));
assert!(root.retained_tail(1, 2).is_none());
assert!(root.retained_segment(1, 1).is_none());
assert!(!root.references_path(&temp.path().join("unreferenced.seg")));
}
}