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