Skip to main content

ozzy_replication/
view_change.rs

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