Skip to main content

ozzy_replication/
driver.rs

1//! Deterministic normal-to-election scheduling with explicit durable completions.
2//!
3//! The adapter supplies monotonic process-relative time and authenticated,
4//! wire-validated controls. One poll emits at most one action; broadcast has
5//! exactly two destinations. Keep outbound work bounded and independently retry
6//! sends without blocking receive, timers, or disk completion. Poll again after
7//! each action/completion, subject to the adapter's count/byte scheduling budget.
8//! Selection and pinned history lookup remain explicit adapter work. The driver
9//! owns in-flight installation while the adapter stages and publishes on its disk
10//! worker. This driver does not itself enable networked process failover.
11
12mod deadline;
13mod exit_view;
14mod installation;
15mod poll;
16mod receive;
17mod storage;
18mod validation;
19
20pub use installation::ActivationTicket;
21pub use validation::ValidationTicket;
22
23use std::time::Duration;
24
25use ozzy_proto::NodeId;
26
27use crate::wire::Control;
28use crate::{
29    Configuration, Digest, InstallingView, LogSource, NormalReplica, OpNumber, Prefix,
30    PromiseTicket, ReplicaSnapshot, ReplicationError, Scope, SelectedView, Status, ViewChange,
31    ViewChangeError, VoterIndex, VoterSet, Voters,
32};
33
34/// Independent heartbeat, progress, and election retry intervals.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct Timing {
37    /// Period between primary idle commit announcements.
38    pub heartbeat: Duration,
39    /// Maximum primary silence, missing commit progress, or stalled local persistence.
40    pub primary_timeout: Duration,
41    /// Minimum interval between repeated first/second-phase election sends.
42    pub retransmit: Duration,
43    /// Initial deadline for election, selected-log publication, or tail activation.
44    pub election_timeout: Duration,
45    /// Ceiling when doubling the deadline after unsuccessful election timeouts.
46    pub max_election_timeout: Duration,
47}
48
49impl Timing {
50    fn validate(self, now: Duration) -> Result<(), DriverError> {
51        if self.heartbeat.is_zero()
52            || self.retransmit.is_zero()
53            || self.heartbeat >= self.primary_timeout
54            || self.retransmit >= self.election_timeout
55            || self.election_timeout > self.max_election_timeout
56        {
57            return Err(DriverError::InvalidTiming);
58        }
59        if now
60            .checked_add(self.max_election_timeout.max(self.primary_timeout))
61            .is_none()
62        {
63            return Err(DriverError::TimeExhausted);
64        }
65        Ok(())
66    }
67}
68
69/// One bounded network or storage request. Not evidence of its completion.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum Action {
72    /// Send to both other configured voters, never to the local voter.
73    Broadcast(Control),
74    /// Send to one configured voter.
75    Send {
76        /// Configured destination identity, not a socket routing identity.
77        to: NodeId,
78        /// Control message to encode in that peer's established session.
79        message: Control,
80    },
81    /// Persist the exact frozen history and view promise on the disk worker.
82    PersistPromise(PromiseTicket),
83}
84
85/// Owned protocol phase; extracting it does not establish installation authority.
86#[derive(Debug)]
87#[expect(
88    clippy::large_enum_variant,
89    reason = "move preallocated roles without boxing"
90)]
91pub enum Role {
92    /// Existing normal state, including any fault/fence status.
93    Normal(NormalReplica),
94    /// Fenced old state and bounded election evidence.
95    ViewChanging(ViewChange),
96    /// Owned selected-log publication; no normal authority until exact completion.
97    Installing(InstallingView),
98    /// A consuming transition failed and took the previous phase with it.
99    /// Every event fails closed; only restart recovery resumes this voter.
100    Fenced(Scope),
101}
102
103/// Timer and election-message driver for one fixed three-voter group.
104///
105/// No clocks, allocations, I/O, or automatic durability assertions on transitions.
106/// Old writes must settle before a promise; a pending promise survives later views.
107#[derive(Debug)]
108pub struct ReplicaDriver {
109    role: Role,
110    configuration: Configuration,
111    local: VoterIndex,
112    timing: Timing,
113    now: Duration,
114    contact_deadline: Duration,
115    progress_deadline: Duration,
116    persistence_deadline: Duration,
117    /// When busy local storage last completed work; `None` while idle.
118    storage_busy_since: Option<Duration>,
119    election_deadline: Duration,
120    election_delay: Duration,
121    retry_at: Duration,
122    start_pending: bool,
123    report_pending: bool,
124    commit_pending: bool,
125    // At most one selected-tail activation response per remote voter. A late
126    // installed voter cannot receive fresh payload until that tail is applied.
127    activation_pending: VoterSet,
128    /// Current-view timeout suspicions. Never durable or history-selection votes.
129    exit_votes: VoterSet,
130    /// When each peer's suspicion of the current view last arrived.
131    exit_heard_at: Voters<Duration>,
132    /// `None` means local suspicion is inactive or was withdrawn after progress.
133    exit_retry_at: Option<Duration>,
134    /// An older view peers still ask to leave, and which voters asked. This
135    /// voter already left it.
136    exit_echo: Option<(Scope, VoterSet)>,
137    /// Earliest next answer to each voter's request to leave an older view.
138    exit_echo_at: Voters<Duration>,
139    /// Voters still asking to leave a view older than this voter's durably
140    /// promised one. Each gets that view's `START_VIEW_CHANGE`.
141    fence_answers: VoterSet,
142    /// Voters asking to leave a view newer than this normal voter's. Each gets
143    /// a request to leave this view, which it answers with its fence.
144    stale_answers: VoterSet,
145}
146
147impl ReplicaDriver {
148    /// Attach scheduling to a bootstrapped or completely installed normal core.
149    /// Intact restart must instead enter the existing nonvoting recovery path.
150    pub fn from_normal(
151        normal: NormalReplica,
152        now: Duration,
153        timing: Timing,
154    ) -> Result<Self, DriverError> {
155        let (configuration, local) = normal.driver_identity();
156        Self::new(Role::Normal(normal), configuration, local, now, timing)
157    }
158
159    /// Resume scheduling after intact-disk recovery or a superseded installation.
160    /// The supplied core is already fenced and cannot restore old normal authority.
161    pub fn from_view_change(
162        changing: ViewChange,
163        now: Duration,
164        timing: Timing,
165    ) -> Result<Self, DriverError> {
166        let configuration = changing.configuration;
167        let local = changing.local;
168        Self::new(
169            Role::ViewChanging(changing),
170            configuration,
171            local,
172            now,
173            timing,
174        )
175    }
176
177    fn new(
178        role: Role,
179        configuration: Configuration,
180        local: VoterIndex,
181        now: Duration,
182        timing: Timing,
183    ) -> Result<Self, DriverError> {
184        timing.validate(now)?;
185        let changing = matches!(role, Role::ViewChanging(_));
186        let activating =
187            matches!(&role, Role::Normal(normal) if !normal.snapshot().ready_for_appends);
188        Ok(Self {
189            role,
190            configuration,
191            local,
192            timing,
193            now,
194            contact_deadline: now.saturating_add(timing.primary_timeout),
195            persistence_deadline: now.saturating_add(timing.primary_timeout),
196            storage_busy_since: None,
197            progress_deadline: now
198                + if activating {
199                    timing.election_timeout
200                } else {
201                    timing.primary_timeout
202                },
203            election_deadline: now.saturating_add(timing.election_timeout),
204            election_delay: timing.election_timeout,
205            retry_at: now
206                + if changing {
207                    timing.retransmit
208                } else {
209                    timing.heartbeat
210                },
211            start_pending: changing,
212            report_pending: changing,
213            commit_pending: false,
214            activation_pending: VoterSet::EMPTY,
215            exit_votes: VoterSet::EMPTY,
216            exit_heard_at: Voters::splat(Duration::ZERO),
217            exit_retry_at: None,
218            exit_echo: None,
219            exit_echo_at: Voters::splat(Duration::ZERO),
220            fence_answers: VoterSet::EMPTY,
221            stale_answers: VoterSet::EMPTY,
222        })
223    }
224
225    /// Inspect normal evidence. No mutable access can bypass scheduling/fencing.
226    pub fn normal(&self) -> Option<&NormalReplica> {
227        match &self.role {
228            Role::Normal(normal) => Some(normal),
229            Role::ViewChanging(_) | Role::Installing(_) | Role::Fenced(_) => None,
230        }
231    }
232
233    /// A fenced old view needs an actual barrier before publishing its promise.
234    pub fn election_needs_sync(&self) -> bool {
235        let Role::ViewChanging(changing) = &self.role else {
236            return false;
237        };
238        let journal = changing.normal_snapshot().journal;
239        journal.written == journal.accepted && journal.written > journal.durable
240    }
241
242    /// Current normal or proposed election scope, not necessarily durable.
243    pub fn scope(&self) -> Scope {
244        match &self.role {
245            Role::Normal(normal) => normal.scope(),
246            Role::ViewChanging(changing) => changing.scope(),
247            Role::Installing(installing) => installing.requested_scope(),
248            Role::Fenced(scope) => *scope,
249        }
250    }
251
252    /// Extract phase ownership. Pending I/O still must settle; a replacement driver
253    /// must preserve newer-view fencing rather than resume an old normal role.
254    pub fn into_role(self) -> Role {
255        self.role
256    }
257
258    /// Select after collecting reports, using only nonblocking pinned-history lookup.
259    /// Missing history/quorum is recoverable. Success is not installed authority.
260    pub fn select(
261        &mut self,
262        lookup: impl FnMut(LogSource, Prefix) -> Option<Digest>,
263    ) -> Result<SelectedView, DriverError> {
264        Ok(self.changing()?.select(lookup)?)
265    }
266
267    /// Frozen election sources; their bytes still require exact-tail verification.
268    pub fn history_sources(&self) -> [Option<LogSource>; 3] {
269        match &self.role {
270            Role::ViewChanging(changing) => changing.history_sources(),
271            Role::Normal(_) | Role::Installing(_) | Role::Fenced(_) => [None; 3],
272        }
273    }
274
275    /// Operations whose prefixes this election may look up in its chosen
276    /// source. One lookup scan can verify each one it passes.
277    pub fn lookup_ops(&self) -> [Option<OpNumber>; 8] {
278        match &self.role {
279            Role::ViewChanging(changing) => changing.lookup_ops(),
280            Role::Normal(_) | Role::Installing(_) | Role::Fenced(_) => [None; 8],
281        }
282    }
283
284    fn changing(&mut self) -> Result<&mut ViewChange, DriverError> {
285        match &mut self.role {
286            Role::ViewChanging(changing) => Ok(changing),
287            Role::Normal(_) | Role::Installing(_) => Err(ReplicationError::WrongRole.into()),
288            Role::Fenced(_) => Err(ViewChangeError::Faulted.into()),
289        }
290    }
291
292    fn require_normal(&self) -> Result<&NormalReplica, DriverError> {
293        Ok(self.normal().ok_or(ReplicationError::NotNormal)?)
294    }
295
296    /// Reject events once storage, lineage, or a failed transition faulted this voter.
297    fn require_healthy(&self) -> Result<(), DriverError> {
298        match &self.role {
299            Role::Normal(normal) if normal.snapshot().status == Status::Faulted => {
300                Err(ViewChangeError::Faulted.into())
301            }
302            Role::ViewChanging(changing)
303                if changing.normal_snapshot().status == Status::Faulted =>
304            {
305                Err(ViewChangeError::Faulted.into())
306            }
307            Role::Installing(installing) => Ok(installing.require_pending()?),
308            Role::Fenced(_) => Err(ViewChangeError::Faulted.into()),
309            Role::Normal(_) | Role::ViewChanging(_) => Ok(()),
310        }
311    }
312
313    /// Take the role for a consuming transition. The driver stays fenced until
314    /// the caller installs the next role; a failed transition leaves it fenced.
315    fn take_role(&mut self) -> Role {
316        let fenced = Role::Fenced(self.scope());
317        std::mem::replace(&mut self.role, fenced)
318    }
319
320    fn observe_time(&mut self, now: Duration) -> Result<(), DriverError> {
321        if now < self.now {
322            return Err(DriverError::ClockRegressed);
323        }
324        self.timing.validate(now)?;
325        self.now = now;
326        Ok(())
327    }
328
329    fn progress_timeout(&self) -> Duration {
330        if self
331            .normal()
332            .is_some_and(|normal| normal.snapshot().ready_for_appends)
333        {
334            self.timing.primary_timeout
335        } else {
336            self.election_delay
337        }
338    }
339
340    fn observe_normal_progress(
341        &mut self,
342        previous: &ReplicaSnapshot,
343        current: &ReplicaSnapshot,
344        now: Duration,
345    ) {
346        if current.committed != previous.committed {
347            self.progress_deadline = now.saturating_add(self.progress_timeout());
348        }
349        if current.journal.durable > previous.journal.durable {
350            self.persistence_deadline = now.saturating_add(self.timing.primary_timeout);
351        }
352    }
353
354    fn normal_timed_out(&self, snapshot: &ReplicaSnapshot) -> bool {
355        let primary = VoterIndex::primary(snapshot.scope.view) == self.local;
356        let outstanding = snapshot.accepted != snapshot.committed || !snapshot.ready_for_appends;
357        (!primary && self.now >= self.contact_deadline)
358            || (outstanding && self.now >= self.progress_deadline)
359            || (snapshot.accepted.op > snapshot.journal.durable
360                && self.now >= self.persistence_deadline)
361            || self
362                .storage_busy_since
363                .is_some_and(|since| self.now >= since.saturating_add(self.timing.primary_timeout))
364    }
365
366    fn next_view(&mut self) -> Result<(), DriverError> {
367        let view = self
368            .scope()
369            .view
370            .checked_add(1)
371            .ok_or(ViewChangeError::ViewExhausted)?;
372        self.change_view(view)
373    }
374
375    fn change_view(&mut self, view: u64) -> Result<(), DriverError> {
376        match &mut self.role {
377            Role::ViewChanging(changing) => changing.advance_view(view)?,
378            Role::Installing(installing) => installing.request_view(view)?,
379            Role::Normal(normal) if normal.snapshot().status == Status::Faulted => {
380                return Err(ViewChangeError::Faulted.into());
381            }
382            Role::Normal(_) => {
383                let Role::Normal(normal) = self.take_role() else {
384                    return Err(ViewChangeError::Faulted.into());
385                };
386                self.role = Role::ViewChanging(normal.into_view_change(view)?);
387            }
388            Role::Fenced(_) => return Err(ViewChangeError::Faulted.into()),
389        }
390        self.exit_votes = VoterSet::EMPTY;
391        self.exit_retry_at = None;
392        // A new view is not durably promised yet, and this voter left the
393        // view it would ask others to leave.
394        self.fence_answers = VoterSet::EMPTY;
395        self.stale_answers = VoterSet::EMPTY;
396        self.election_deadline = self.now.saturating_add(self.election_delay);
397        self.retry_at = self.now.saturating_add(self.timing.retransmit);
398        self.start_pending = true;
399        self.report_pending = true;
400        self.commit_pending = false;
401        self.activation_pending = VoterSet::EMPTY;
402        Ok(())
403    }
404}
405
406/// Rejected scheduler event. No clock or packet can bypass core authority checks.
407#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
408pub enum DriverError {
409    /// Intervals must be positive and retries shorter than their failure deadlines.
410    #[error("invalid replica timing intervals")]
411    InvalidTiming,
412    /// The adapter's process-relative monotonic clock moved backward.
413    #[error("replica clock regressed")]
414    ClockRegressed,
415    /// A deadline cannot be represented without wrapping.
416    #[error("replica clock exhausted")]
417    TimeExhausted,
418    /// Worker-side validation used a different voter, writer, role, or application prefix.
419    #[error("stale replica application validation")]
420    StaleValidation,
421    /// Normal membership, lineage, status, or journal rejection.
422    #[error(transparent)]
423    Replication(#[from] ReplicationError),
424    /// View-change promise, selection, or storage rejection.
425    #[error(transparent)]
426    ViewChange(#[from] ViewChangeError),
427}