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