Skip to main content

ozzy_replication/
lib.rs

1//! Deterministic replication with explicit network and storage evidence.
2//!
3//! This core implements normal operation, durable view-change initiation, and
4//! log selection and installation for three fixed durable voters. Intact-disk
5//! restart leaves the last installed normal view or resumes an unfinished promise,
6//! never old primary authority. Lost-state recovery has a bounded fresh-quorum
7//! admission, streamed validation, and wire codecs. The runtime journal worker
8//! owns immutable donor pins and the segment store has a nonvoting publication
9//! gate. Runtime receiving workers and OMQ recovery actors validate transferred
10//! history before handing off to fenced restart, never directly to voting.
11//! Payload validation, persistence, transport, and deterministic application are
12//! separate adapters. Normal metadata storage is preallocated and byte-bounded.
13#![forbid(unsafe_code)]
14#![warn(missing_docs)]
15#![warn(clippy::indexing_slicing)]
16#![cfg_attr(
17    test,
18    allow(
19        clippy::indexing_slicing,
20        clippy::unwrap_used,
21        clippy::expect_used,
22        clippy::panic,
23        clippy::unreachable,
24        clippy::too_many_lines,
25        clippy::too_many_arguments,
26        reason = "product code is linted without cfg(test)"
27    )
28)]
29
30pub mod clock;
31mod configuration_record;
32pub mod driver;
33pub mod flow;
34mod installation;
35pub mod local;
36mod normal;
37pub mod recovery;
38mod view_change;
39mod voters;
40pub mod wire;
41
42pub use configuration_record::{
43    CONFIGURATION_RECORD_BYTES, ConfigurationRecord, ConfigurationRecordError, ConfiguredVoter,
44};
45pub use installation::{
46    InstallOutcome, InstallTicket, InstallingView, ReceivedStart, RejectedInstallation, StartView,
47};
48pub use normal::{Admission, NormalReplica, ReplicaSnapshot, Status};
49pub use ozzy_journal::operation::Digest;
50pub use ozzy_journal::progress::{JournalGeneration, OpNumber, SyncTicket, WriteTicket};
51pub use view_change::{
52    DoViewChange, FrozenLog, LogSource, PromiseTicket, RecoveredState, SelectedView,
53    StartViewChange, ViewChange, ViewChangeError,
54};
55
56use std::collections::VecDeque;
57
58use ozzy_journal::operation::{CanonicalOperation, logical_operation_digest_with_body_digest};
59use ozzy_journal::progress::ProgressError;
60use ozzy_proto::{GroupId, NodeId};
61use voters::{VoterIndex, VoterSet, Voters};
62
63/// Exact canonical group prefix. Zero with the zero digest is genesis.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct Prefix {
66    /// Last operation in the contiguous prefix.
67    pub op: OpNumber,
68    /// Canonical chain digest at that operation.
69    pub digest: Digest,
70}
71
72impl Prefix {
73    /// Empty group history.
74    pub const GENESIS: Self = Self {
75        op: OpNumber(0),
76        digest: Digest::ZERO,
77    };
78}
79
80/// Immutable identity and consensus generation carried by normal messages.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct Scope {
83    /// Persistent group identity.
84    pub group_id: GroupId,
85    /// Fixed membership/configuration epoch.
86    pub configuration_epoch: u64,
87    /// Digest of the complete configured membership, principals, and policy.
88    pub configuration_digest: Digest,
89    /// Active primary view, not an operation's original view.
90    pub view: u64,
91}
92
93/// Three ordered, distinct voters. Confirmation needs synchronized history on
94/// the leader and one backup.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub struct Configuration {
97    scope: Scope,
98    voters: Voters<NodeId>,
99}
100
101impl Configuration {
102    /// Validate an explicitly bootstrapped fixed configuration at view zero.
103    ///
104    /// `digest` must identify the full durable configuration, including its
105    /// authenticated voter mapping and [`ozzy_journal::integrity::PROFILE`].
106    /// Prefer [`ConfigurationRecord`] for that canonical binding. The digest is
107    /// not computed from socket identities.
108    pub fn new(
109        group_id: GroupId,
110        configuration_epoch: u64,
111        digest: Digest,
112        voters: [NodeId; 3],
113    ) -> Result<Self, ReplicationError> {
114        if group_id.as_bytes() == &[0; 16]
115            || voters.iter().any(|voter| voter.as_bytes() == &[0; 16])
116            || digest == Digest::ZERO
117            || voters[0] == voters[1]
118            || voters[0] == voters[2]
119            || voters[1] == voters[2]
120        {
121            return Err(ReplicationError::InvalidConfiguration);
122        }
123        Ok(Self {
124            scope: Scope {
125                group_id,
126                configuration_epoch,
127                configuration_digest: digest,
128                view: 0,
129            },
130            voters: Voters(voters),
131        })
132    }
133
134    /// Initial configuration scope. Later views require the view-change protocol.
135    pub const fn scope(self) -> Scope {
136        self.scope
137    }
138
139    /// Ordered persistent voter identities.
140    pub const fn voters(&self) -> &[NodeId; 3] {
141        &self.voters.0
142    }
143
144    /// Deterministic primary selection. Does not grant leadership authority.
145    pub fn primary(&self, view: u64) -> NodeId {
146        self.voters[VoterIndex::primary(view)]
147    }
148
149    fn voter(&self, index: VoterIndex) -> NodeId {
150        self.voters[index]
151    }
152
153    fn voter_index(&self, id: NodeId) -> Result<VoterIndex, ReplicationError> {
154        self.voters
155            .iter()
156            .find_map(|(index, &voter)| (voter == id).then_some(index))
157            .ok_or(ReplicationError::UnknownVoter)
158    }
159}
160
161/// Bounds on live prepare metadata/payloads and each streamed validation chunk.
162/// Frozen recovered/installed history can exceed these bounds and stays on disk.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub struct PipelineLimits {
165    /// Maximum retained live descriptors, preallocated at bootstrap or restart.
166    pub max_operations: usize,
167    /// Maximum total canonical body bytes retained by the payload adapter.
168    pub max_body_bytes: usize,
169}
170
171/// Live windows a backup retains beyond its released prefix: the primary's
172/// window beyond the commit it announced, plus the window that release can
173/// trail that commit by while the other backup confirms the primary's writes.
174const BACKUP_WINDOWS: usize = 2;
175
176impl PipelineLimits {
177    /// Capacity a backup retains beyond its released prefix for a primary
178    /// with this live window. Everything a backup holds accepted but not yet
179    /// applied, in any owner, must fit this bound.
180    #[must_use]
181    pub const fn backup(self) -> Self {
182        Self {
183            max_operations: self.max_operations.saturating_mul(BACKUP_WINDOWS),
184            max_body_bytes: self.max_body_bytes.saturating_mul(BACKUP_WINDOWS),
185        }
186    }
187}
188
189/// Metadata for one already validated canonical operation.
190///
191/// This value is not payload storage or proof of validation. The caller must
192/// verify the body codec and deterministic application transition before using
193/// it, and retain the exact body until application/replication releases it.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub struct PreparedOperation {
196    group_id: GroupId,
197    configuration_epoch: u64,
198    original_view: u64,
199    prefix: Prefix,
200    previous_digest: Digest,
201    body_bytes: usize,
202}
203
204impl PreparedOperation {
205    /// Reuse a verified body digest; hash only the small canonical envelope.
206    ///
207    /// The caller asserts `body_digest` was computed over `operation.body` and
208    /// that both body and state transition passed their respective validators.
209    pub fn from_verified(operation: &CanonicalOperation<'_>, body_digest: Digest) -> Self {
210        Self {
211            group_id: operation.group_id,
212            configuration_epoch: operation.configuration_epoch,
213            original_view: operation.original_view,
214            prefix: Prefix {
215                op: OpNumber(operation.op_number),
216                digest: logical_operation_digest_with_body_digest(operation, body_digest),
217            },
218            previous_digest: operation.previous_digest,
219            body_bytes: operation.body.len(),
220        }
221    }
222
223    /// Canonical position and digest after this operation.
224    pub const fn prefix(self) -> Prefix {
225        self.prefix
226    }
227
228    /// Canonical body bytes charged against pipeline capacity.
229    pub const fn body_bytes(self) -> usize {
230        self.body_bytes
231    }
232}
233
234/// Drop the leading operations through `through` from a contiguous window.
235/// Returns the canonical body bytes they retained.
236fn release_through(operations: &mut VecDeque<PreparedOperation>, through: OpNumber) -> usize {
237    let count = operations.partition_point(|operation| operation.prefix.op <= through);
238    operations
239        .drain(..count)
240        .map(|operation| operation.body_bytes)
241        .sum()
242}
243
244/// Cumulative durable `PREPARE_OK`. Sender identity comes from authentication.
245///
246/// There is deliberately no written/received evidence variant in this initial
247/// disk-quorum core. A transport adapter must reject weaker wire evidence.
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub struct PrepareOk {
250    /// Group/configuration/current-view fence.
251    pub scope: Scope,
252    /// Highest matching contiguous prefix covered by a completed disk barrier.
253    pub durable: Prefix,
254}
255
256/// Primary's cumulative commit announcement, also usable as an idle heartbeat.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub struct Commit {
259    /// Group/configuration/current-view fence.
260    pub scope: Scope,
261    /// Highest quorum-committed canonical operation.
262    pub committed: Prefix,
263}
264
265/// Rejected replication event. Capacity and missing history are recoverable;
266/// conflicting current lineage faults the normal core.
267#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
268pub enum ReplicationError {
269    /// Configuration needs nonzero group/voter IDs, distinct voters, and a digest.
270    #[error("invalid fixed replication configuration")]
271    InvalidConfiguration,
272    /// Both pipeline bounds must be nonzero and operation count representable.
273    #[error("invalid replication pipeline limits")]
274    InvalidLimits,
275    /// Message sender is not an authenticated configured voter.
276    #[error("unknown replica voter")]
277    UnknownVoter,
278    /// This role cannot perform the requested action.
279    #[error("action requires the current primary or a backup")]
280    WrongRole,
281    /// Group/configuration/view does not match this normal generation.
282    #[error("replication message scope mismatch")]
283    ScopeMismatch,
284    /// Normal operation was fenced or faulted and must not resume in place.
285    #[error("replica is not in normal operation")]
286    NotNormal,
287    /// Caller must backpressure rather than overrun payload/metadata capacity.
288    #[error("replication pipeline capacity exhausted")]
289    Capacity,
290    /// One physical admission must include at least one operation.
291    #[error("empty prepare group")]
292    EmptyPrepare,
293    /// Receiver needs an earlier prefix or an appropriately bounded retry.
294    #[error("prepare or evidence leaves a local history gap")]
295    HistoryGap,
296    /// Metadata was released or is in a disk-backed installed tail; consult the log.
297    #[error("operation metadata is outside the retained pipeline")]
298    HistoryUnavailable,
299    /// Same active lineage names incompatible canonical bytes.
300    #[error("conflicting canonical replication lineage")]
301    ConflictingHistory,
302    /// Application completion may not outrun quorum/local-durability evidence.
303    #[error("application completion exceeds committed prefix")]
304    ApplyBeyondCommit,
305    /// Installed primary must reconfirm and apply the selected tail first.
306    #[error("selected view tail has not reached quorum and application")]
307    ActivationPending,
308    /// Existing journal ticket/generation contract rejected the completion.
309    #[error(transparent)]
310    Journal(#[from] ProgressError),
311}