Skip to main content

ozzy_replication/
view_change.rs

1//! Durable view-change initiation and selection. Installation is a separate gate.
2
3mod recovery;
4mod selection;
5
6use ozzy_proto::NodeId;
7
8use crate::{
9    Configuration, Digest, JournalGeneration, NormalReplica, Prefix, ReplicaSnapshot,
10    ReplicationError, Scope, Status, SyncTicket, VoterIndex, VoterSet, Voters, WriteTicket,
11};
12
13/// One immutable, synchronized log description for a view-change report.
14///
15/// The adapter must pin the corresponding canonical history until the report is
16/// invalidated. Retired predecessors require independently verified overlapping
17/// lineage or checkpoint evidence from the adapter; the core performs no I/O.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct FrozenLog {
20    /// Last durably installed normal view, not the highest entry's original view.
21    pub last_normal_view: u64,
22    /// Complete synchronized accepted prefix, including uncertain operations.
23    pub accepted: Prefix,
24    /// Known committed prefix that any selected history must preserve.
25    pub committed: Prefix,
26}
27
28/// Intact durable state independently validated and synchronized on startup.
29///
30/// This descriptor is an adapter assertion, not proof of disk integrity. The
31/// caller must verify immutable voter/configuration identity, the complete
32/// selected canonical lineage and commit floor, and durable hard state. Empty,
33/// rolled-back, lost, or ambiguously corrupt stores must not use this path.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct RecoveredState {
36    /// Exact durable configuration and highest promised view, not last normal view.
37    pub scope: Scope,
38    /// Recovered accepted bytes and known commit floor. No ACK sets are restored.
39    pub log: FrozenLog,
40}
41
42/// Storage action that persists a view promise without installing a normal view.
43///
44/// Fields are private so completions can identify only actions this core issued.
45/// The adapter atomically persists this exact log and promised view, retaining
46/// the prior last-normal-view. Submission or an ordinary file flush is not a
47/// successful completion. Storage failure must call `ViewChange::fail_promise`.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct PromiseTicket {
50    scope: Scope,
51    generation: JournalGeneration,
52    log: FrozenLog,
53}
54
55impl PromiseTicket {
56    /// Exact group/configuration and new promised view to persist.
57    pub const fn scope(self) -> Scope {
58        self.scope
59    }
60    /// Unique writer incarnation whose frozen history the promise covers.
61    pub const fn generation(self) -> JournalGeneration {
62        self.generation
63    }
64    /// Log positions and prior normal view that must survive publication.
65    pub const fn log(self) -> FrozenLog {
66        self.log
67    }
68}
69
70/// First view-change phase. Sender identity comes from the authenticated peer.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct StartViewChange {
73    /// Exact proposed group/configuration/view.
74    pub scope: Scope,
75}
76
77/// Second view-change phase. A bounded descriptor, never an unbounded log frame.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct DoViewChange {
80    /// Exact proposed group/configuration/view.
81    pub scope: Scope,
82    /// Frozen source incarnation; transferred chunks must not mix generations.
83    pub generation: JournalGeneration,
84    /// Immutable synchronized history and protected commit floor.
85    pub log: FrozenLog,
86}
87
88/// Exact source against which transferred history must be verified.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct LogSource {
91    /// Configured voter supplying the selected bytes.
92    pub voter: NodeId,
93    /// Frozen source writer incarnation from its report.
94    pub generation: JournalGeneration,
95    /// Tail hash anchoring the complete selected canonical lineage.
96    pub accepted: Prefix,
97}
98
99/// Verified view-change selection, not installed leadership authority.
100///
101/// A future installation adapter must durably install these bytes and hard state
102/// before `START_VIEW` or normal voting. This value cannot construct a normal core.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct SelectedView {
105    scope: Scope,
106    source: LogSource,
107    committed: Prefix,
108    voters: VoterSet,
109}
110
111impl SelectedView {
112    /// New view whose quorum selected this lineage.
113    pub const fn scope(self) -> Scope {
114        self.scope
115    }
116    /// Frozen history to install. Uncertain accepted operations remain included.
117    pub const fn source(self) -> LogSource {
118        self.source
119    }
120    /// Largest compatible known commit floor in the participating reports.
121    pub const fn committed(self) -> Prefix {
122        self.committed
123    }
124    /// Bitset of participating voters in configured order, including the primary.
125    pub const fn voter_mask(self) -> u8 {
126        self.voters.mask()
127    }
128}
129
130/// Fixed-three-voter durable view-change initiation and log selection.
131///
132/// Owns the fenced former normal core. No normal-message entry point is exposed.
133/// Retains at most three reports and one pending metadata action. It has no I/O,
134/// timers, or allocations on live election transitions. Intact restart allocates
135/// only the bounded live pipeline and stays above its last installed normal view. Installation
136/// consumes this role and uses a separate completion/activation gate.
137#[derive(Debug)]
138pub struct ViewChange {
139    pub(crate) normal: NormalReplica,
140    pub(crate) configuration: Configuration,
141    pub(crate) local: VoterIndex,
142    view: u64,
143    promise: Promise,
144    pending: Option<PromiseTicket>,
145    starts: VoterSet,
146    reports: Voters<Option<DoViewChange>>,
147    pub(crate) selected: Option<SelectedView>,
148}
149
150/// Highest view whose durable promise this core has observed.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152enum Promise {
153    /// Promised before this core began: the installed view, or recovered
154    /// metadata without a promise for the requested view.
155    Prior(u64),
156    /// Completed here, carried across an abandoned installation, or restored
157    /// from durable restart evidence.
158    Completed(PromiseTicket),
159}
160
161impl Promise {
162    const fn view(self) -> u64 {
163        match self {
164            Self::Prior(view) => view,
165            Self::Completed(ticket) => ticket.scope.view,
166        }
167    }
168}
169
170impl ViewChange {
171    /// Frozen reported sources available for bounded, externally verified history repair.
172    /// A report alone supplies no ancestry or installation authority.
173    pub fn history_sources(&self) -> [Option<LogSource>; 3] {
174        VoterIndex::ALL.map(|voter| {
175            self.reports[voter].map(|report| LogSource {
176                voter: self.configuration.voter(voter),
177                generation: report.generation,
178                accepted: report.log.accepted,
179            })
180        })
181    }
182
183    pub(crate) fn begin(
184        mut normal: NormalReplica,
185        configuration: Configuration,
186        local: VoterIndex,
187        view: u64,
188    ) -> Result<Self, ViewChangeError> {
189        normal.fence();
190        if normal.snapshot().status == Status::Faulted {
191            return Err(ViewChangeError::Faulted);
192        }
193        if view <= configuration.scope().view {
194            return Err(ViewChangeError::ViewNotHigher);
195        }
196        Ok(Self {
197            normal,
198            configuration,
199            local,
200            view,
201            promise: Promise::Prior(configuration.scope().view),
202            pending: None,
203            starts: VoterSet::EMPTY,
204            reports: Voters::splat(None),
205            selected: None,
206        })
207    }
208
209    /// Read-only old-role journal/application evidence for storage scheduling.
210    pub fn normal_snapshot(&self) -> ReplicaSnapshot {
211        self.normal.snapshot()
212    }
213
214    /// Requested view; it may not yet have a completed durable promise.
215    pub fn scope(&self) -> Scope {
216        Scope {
217            view: self.view,
218            ..self.configuration.scope()
219        }
220    }
221
222    /// Highest view whose promise completion this core has observed.
223    pub const fn promised_view(&self) -> u64 {
224        self.promise.view()
225    }
226
227    /// Move toward a later view without discarding an outstanding disk action.
228    ///
229    /// Old reports/selection immediately lose authority. A pending earlier
230    /// promise must settle before the next can be issued. Its late completion
231    /// never authorizes messages for either the old or the unpersisted new view.
232    pub fn advance_view(&mut self, view: u64) -> Result<(), ViewChangeError> {
233        self.require_active()?;
234        if view <= self.view {
235            return Err(ViewChangeError::ViewNotHigher);
236        }
237        self.view = view;
238        self.starts = VoterSet::EMPTY;
239        self.reports = Voters::splat(None);
240        self.selected = None;
241        Ok(())
242    }
243
244    /// Settle a previously admitted complete write without reviving old votes.
245    pub fn complete_write(&mut self, ticket: WriteTicket) -> Result<(), ViewChangeError> {
246        Ok(self.normal.complete_write(ticket)?)
247    }
248
249    /// Capture the written prefix for a real barrier on the storage worker.
250    pub fn begin_sync(&self) -> Result<SyncTicket, ViewChangeError> {
251        Ok(self.normal.begin_sync()?)
252    }
253
254    /// Settle a real disk barrier. It cannot advance old-view commit.
255    pub fn complete_sync(&mut self, ticket: SyncTicket) -> Result<(), ViewChangeError> {
256        Ok(self.normal.complete_sync(ticket)?)
257    }
258
259    /// Report uncertain journal I/O and permanently fault this transition.
260    pub fn fail_io(&mut self, generation: JournalGeneration) -> Result<(), ViewChangeError> {
261        Ok(self.normal.fail_io(generation)?)
262    }
263
264    /// Issue one metadata promise after every accepted operation is synchronized.
265    ///
266    /// Finishing all old writes makes the report immutable, including operations
267    /// another replica may already have acknowledged before local I/O finished.
268    pub fn begin_promise(&mut self) -> Result<PromiseTicket, ViewChangeError> {
269        self.require_active()?;
270        if self.pending.is_some() {
271            return Err(ViewChangeError::PromisePending);
272        }
273        if self.promise.view() == self.view {
274            return Err(ViewChangeError::AlreadyPromised);
275        }
276        let snapshot = self.normal.snapshot();
277        if snapshot.journal.durable != snapshot.accepted.op {
278            return Err(ViewChangeError::StoragePending);
279        }
280        let ticket = PromiseTicket {
281            scope: self.scope(),
282            generation: snapshot.journal.generation,
283            log: FrozenLog {
284                last_normal_view: snapshot.scope.view,
285                accepted: snapshot.accepted,
286                committed: snapshot.committed,
287            },
288        };
289        self.pending = Some(ticket);
290        Ok(ticket)
291    }
292
293    /// Observe successful durable publication of the exact issued promise.
294    pub fn complete_promise(&mut self, ticket: PromiseTicket) -> Result<(), ViewChangeError> {
295        self.require_active()?;
296        if self.promise == Promise::Completed(ticket) {
297            return Ok(());
298        }
299        self.require_ticket(ticket)?;
300        self.pending = None;
301        self.promise = Promise::Completed(ticket);
302        if ticket.scope.view == self.view {
303            self.starts.insert(self.local);
304        }
305        Ok(())
306    }
307
308    /// An ambiguous promise failure fences all election messages and selection.
309    pub fn fail_promise(&mut self, ticket: PromiseTicket) -> Result<(), ViewChangeError> {
310        self.require_active()?;
311        self.require_ticket(ticket)?;
312        self.fail_io(ticket.generation)
313    }
314
315    /// Broadcast only after the requested view promise is durably complete.
316    pub fn start_message(&self) -> Result<StartViewChange, ViewChangeError> {
317        self.require_promised()?;
318        Ok(StartViewChange {
319            scope: self.scope(),
320        })
321    }
322
323    /// Collect an authenticated other voter's first-phase message.
324    /// Duplicate messages cannot create extra voters or replace local persistence.
325    pub fn receive_start(
326        &mut self,
327        from: NodeId,
328        message: StartViewChange,
329    ) -> Result<(), ViewChangeError> {
330        self.require_scope(message.scope)?;
331        let voter = self.configuration.voter_index(from)?;
332        if voter == self.local {
333            return Err(ReplicationError::WrongRole.into());
334        }
335        self.starts.insert(voter);
336        Ok(())
337    }
338
339    /// Freeze the local second-phase report after a first-phase majority.
340    /// Send it to the configured primary; the primary retains its own report too.
341    pub fn report(&mut self) -> Result<DoViewChange, ViewChangeError> {
342        let promise = self.require_promised()?;
343        if self.starts.len() < 2 {
344            return Err(ViewChangeError::StartQuorumMissing);
345        }
346        let report = DoViewChange {
347            scope: self.scope(),
348            generation: promise.generation,
349            log: promise.log,
350        };
351        self.reports[self.local] = Some(report);
352        Ok(report)
353    }
354
355    /// Primary collects immutable, authenticated second-phase reports.
356    ///
357    /// Later views are explicit `advance_view` events. A new report cannot alter
358    /// an already selected quorum; changed frozen logs from one voter are faults.
359    /// Intact restart may repeat the identical log with a fresh writer generation.
360    /// Keep the first source descriptor unchanged, including after selection:
361    /// this is one vote, not permission to mix or redirect history chunks.
362    pub fn receive_report(
363        &mut self,
364        from: NodeId,
365        report: DoViewChange,
366    ) -> Result<(), ViewChangeError> {
367        self.require_scope(report.scope)?;
368        self.require_primary()?;
369        let voter = self.configuration.voter_index(from)?;
370        if voter == self.local {
371            return Err(ReplicationError::WrongRole.into());
372        }
373        validate_report(report)?;
374        if let Some(previous) = self.reports[voter] {
375            if previous.log != report.log {
376                return Err(self.conflict());
377            }
378            // An unfinished durable promise survives restart, but its process's
379            // writer incarnation does not. Equivalent history is not a fault.
380            // A vanished first source may require another view; never replace a
381            // pinned or selected source merely because a different ID arrived.
382            return Ok(());
383        }
384        if self.selected.is_some() {
385            return Err(ViewChangeError::SelectionFrozen);
386        }
387        self.reports[voter] = Some(report);
388        Ok(())
389    }
390
391    fn require_active(&self) -> Result<(), ViewChangeError> {
392        if self.normal.snapshot().status == Status::Faulted {
393            return Err(ViewChangeError::Faulted);
394        }
395        Ok(())
396    }
397
398    pub(crate) fn require_scope(&self, scope: Scope) -> Result<(), ViewChangeError> {
399        self.require_active()?;
400        if scope != self.scope() {
401            return Err(ReplicationError::ScopeMismatch.into());
402        }
403        Ok(())
404    }
405
406    pub(crate) fn require_primary(&self) -> Result<(), ViewChangeError> {
407        if VoterIndex::primary(self.view) != self.local {
408            return Err(ReplicationError::WrongRole.into());
409        }
410        Ok(())
411    }
412
413    /// The completed promise for the requested view.
414    pub(crate) fn require_promised(&self) -> Result<PromiseTicket, ViewChangeError> {
415        self.require_active()?;
416        match self.promise {
417            Promise::Completed(ticket) if ticket.scope.view == self.view => Ok(ticket),
418            Promise::Prior(_) | Promise::Completed(_) => Err(ViewChangeError::PromiseRequired),
419        }
420    }
421
422    fn require_ticket(&self, ticket: PromiseTicket) -> Result<(), ViewChangeError> {
423        if self.pending != Some(ticket) {
424            return Err(ViewChangeError::StalePromise);
425        }
426        Ok(())
427    }
428
429    fn conflict(&mut self) -> ViewChangeError {
430        self.normal.fault();
431        ViewChangeError::ConflictingHistory
432    }
433}
434
435pub(crate) fn valid_prefix(prefix: Prefix) -> bool {
436    prefix.op.0 != u64::MAX && ((prefix.op.0 == 0) == (prefix.digest == Digest::ZERO))
437}
438
439fn validate_report(report: DoViewChange) -> Result<(), ViewChangeError> {
440    let log = report.log;
441    if log.last_normal_view >= report.scope.view
442        || !valid_prefix(log.accepted)
443        || !valid_prefix(log.committed)
444        || log.committed.op > log.accepted.op
445        || (log.committed.op == log.accepted.op && log.committed != log.accepted)
446    {
447        return Err(ViewChangeError::InvalidReport);
448    }
449    Ok(())
450}
451
452/// Rejected election event. Missing history/quorums wait; conflicts fault.
453#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
454pub enum ViewChangeError {
455    /// Existing journal/normal scope, role, or membership check failed.
456    #[error(transparent)]
457    Replication(#[from] ReplicationError),
458    /// Timers/messages cannot regress or reuse the target view.
459    #[error("view must increase")]
460    ViewNotHigher,
461    /// A restart cannot safely wrap its highest durable view back to zero.
462    #[error("durable view number exhausted")]
463    ViewExhausted,
464    /// All accepted old-view operations must finish synchronization first.
465    #[error("old-view journal work has not settled")]
466    StoragePending,
467    /// Only one metadata promise action can be in flight.
468    #[error("a view promise is already pending")]
469    PromisePending,
470    /// No new metadata write is necessary for this requested view.
471    #[error("requested view already promised")]
472    AlreadyPromised,
473    /// Completion is not the exact outstanding metadata action.
474    #[error("stale or foreign view-promise completion")]
475    StalePromise,
476    /// No election message before successful durable promise completion.
477    #[error("requested view promise has not completed")]
478    PromiseRequired,
479    /// Need own durable promise plus another distinct first-phase voter.
480    #[error("start-view-change quorum missing")]
481    StartQuorumMissing,
482    /// Selection needs the primary's own report and another distinct voter.
483    #[error("do-view-change quorum missing")]
484    ReportQuorumMissing,
485    /// Fetch and validate the frozen selected history before selecting it.
486    #[error("selected lineage evidence missing")]
487    HistoryMissing,
488    /// Malformed positions or last-normal-view cannot become evidence.
489    #[error("invalid frozen view-change report")]
490    InvalidReport,
491    /// A protected prefix or immutable report conflicts with this lineage.
492    #[error("conflicting view-change history")]
493    ConflictingHistory,
494    /// Do not change the selected quorum while installation may be in flight.
495    #[error("view selection already frozen")]
496    SelectionFrozen,
497    /// Primary cannot install without its completed quorum selection.
498    #[error("view has not been selected")]
499    SelectionRequired,
500    /// A replacement writer needs a fresh, globally unique incarnation.
501    #[error("installation reused the old writer generation")]
502    ReusedGeneration,
503    /// Completion does not identify this exact pending installation.
504    #[error("stale or foreign installation completion")]
505    StaleInstallation,
506    /// Successful completion has already transferred ownership to a new role.
507    #[error("installation already completed")]
508    InstallationCompleted,
509    /// Committed canonical state must be rebuilt before role activation.
510    #[error("installed application state is not at the required committed prefix")]
511    ApplicationPending,
512    /// Storage or lineage fault requires recovery, not another local election.
513    #[error("view change faulted")]
514    Faulted,
515}