Skip to main content

ozzy_replication/
installation.rs

1//! Selected-view publication, activation, and late-view fencing.
2
3use ozzy_proto::NodeId;
4
5use crate::{
6    Configuration, Digest, JournalGeneration, LogSource, NormalReplica, OpNumber, Prefix,
7    PreparedOperation, PromiseTicket, ReplicationError, Scope, ViewChange, ViewChangeError,
8    VoterIndex,
9};
10
11/// Immutable `START_VIEW` sent only after the primary durably installs this log.
12/// Sender identity comes from the authenticated configured primary.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct StartView {
15    /// Installed group/configuration/view.
16    pub scope: Scope,
17    /// Primary's frozen installed writer incarnation for transfer/retry binding.
18    pub generation: JournalGeneration,
19    /// Entire selected accepted prefix, including uncertain old-view operations.
20    pub accepted: Prefix,
21    /// Primary's known commit floor at installation time.
22    pub committed: Prefix,
23}
24
25/// A `START_VIEW` with the authenticated configured voter that sent it.
26/// Backup installation still checks that this voter is the view's primary.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct ReceivedStart {
29    /// Authenticated sender of `start`.
30    pub from: NodeId,
31    /// Installed descriptor as received.
32    pub start: StartView,
33}
34
35/// Exact selected-log publication action. Not evidence of successful I/O.
36///
37/// Persist the selected log with `promised_view = last_normal_view = scope.view`,
38/// preserving `protected_committed` and installing `committed`. Only then report
39/// completion, with canonical application state rebuilt through `committed`.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub struct InstallTicket {
42    scope: Scope,
43    previous_generation: JournalGeneration,
44    generation: JournalGeneration,
45    source: LogSource,
46    protected_committed: Prefix,
47    committed: Prefix,
48}
49
50impl InstallTicket {
51    /// Exact group/configuration and installed view.
52    pub const fn scope(self) -> Scope {
53        self.scope
54    }
55    /// Replaced writer incarnation. Old callbacks lose authority on installation.
56    pub const fn previous_generation(self) -> JournalGeneration {
57        self.previous_generation
58    }
59    /// Fresh local writer incarnation for the selected generation.
60    pub const fn generation(self) -> JournalGeneration {
61        self.generation
62    }
63    /// Frozen source from which canonical installation bytes are obtained.
64    pub const fn source(self) -> LogSource {
65        self.source
66    }
67    /// Highest old local commit; replacement must never overwrite it.
68    pub const fn protected_committed(self) -> Prefix {
69        self.protected_committed
70    }
71    /// Complete selected accepted prefix to install durably.
72    pub const fn accepted(self) -> Prefix {
73        self.source.accepted
74    }
75    /// Effective local committed prefix, never lower than prior local knowledge.
76    pub const fn committed(self) -> Prefix {
77        self.committed
78    }
79}
80
81/// Exactly one owned protocol role after installation.
82#[derive(Debug)]
83#[expect(
84    clippy::large_enum_variant,
85    reason = "rare ownership transition moves preallocated state without boxing"
86)]
87pub enum InstallOutcome {
88    /// Installed view is still current. Primary admission additionally waits for
89    /// a new-view backup ACK and ordered application through the selected tail.
90    Normal(NormalReplica),
91    /// A newer view arrived during I/O. No intermediate normal authority escapes.
92    ViewChanging(ViewChange),
93}
94
95/// Rejected admission together with the original fenced role.
96///
97/// Recoverable errors leave it available for retransmission/history fetching.
98/// Protected-history conflicts return the same role faulted for recovery.
99#[derive(Debug)]
100pub struct RejectedInstallation {
101    view_change: ViewChange,
102    error: ViewChangeError,
103}
104
105impl RejectedInstallation {
106    /// Why installation could not be admitted.
107    pub const fn error(&self) -> ViewChangeError {
108        self.error
109    }
110    /// Recover protocol ownership; rejection never resurrects normal voting.
111    pub fn into_view_change(self) -> ViewChange {
112        self.view_change
113    }
114}
115
116impl std::fmt::Display for RejectedInstallation {
117    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        std::fmt::Display::fmt(&self.error, formatter)
119    }
120}
121
122impl std::error::Error for RejectedInstallation {
123    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
124        Some(&self.error)
125    }
126}
127
128/// In-flight atomic installation. Neither old nor new normal voting is exposed.
129#[derive(Debug)]
130pub struct InstallingView {
131    ticket: InstallTicket,
132    previous_promise: PromiseTicket,
133    state: InstallState,
134    configuration: Configuration,
135    requested_view: u64,
136    validated_through: Prefix,
137}
138
139/// Ownership of the staged role while publication is outstanding.
140#[derive(Debug)]
141#[expect(
142    clippy::large_enum_variant,
143    reason = "the staged role moves without boxing"
144)]
145enum InstallState {
146    Pending(NormalReplica),
147    /// Ambiguous publication failure; only reopen and recovery remain.
148    Failed,
149    /// Completion transferred the role to its next owner.
150    Completed,
151}
152
153impl InstallState {
154    fn pending(&self) -> Result<&NormalReplica, ViewChangeError> {
155        match self {
156            Self::Pending(normal) => Ok(normal),
157            Self::Failed => Err(ViewChangeError::Faulted),
158            Self::Completed => Err(ViewChangeError::InstallationCompleted),
159        }
160    }
161
162    /// Transfer the staged role out exactly once.
163    fn take(&mut self) -> Result<NormalReplica, ViewChangeError> {
164        match std::mem::replace(self, Self::Completed) {
165            Self::Pending(normal) => Ok(normal),
166            Self::Failed => {
167                *self = Self::Failed;
168                Err(ViewChangeError::Faulted)
169            }
170            Self::Completed => Err(ViewChangeError::InstallationCompleted),
171        }
172    }
173}
174
175impl InstallingView {
176    pub(crate) fn requested_scope(&self) -> Scope {
177        Scope {
178            view: self.requested_view,
179            ..self.ticket.scope()
180        }
181    }
182
183    /// Submit this exact action to a dedicated storage worker.
184    pub const fn ticket(&self) -> InstallTicket {
185        self.ticket
186    }
187
188    /// Validate the next bounded chunk of a streamed selected suffix.
189    ///
190    /// Operations start strictly after the effective committed floor and arrive
191    /// in canonical order. Each call has the normal pipeline's count/byte limits;
192    /// no descriptors or payloads are retained. The caller independently validates
193    /// bodies/application transitions and stages the same bytes on the disk worker.
194    /// Rejected chunks leave this cursor unchanged. This is not an I/O completion.
195    pub fn validate_suffix(
196        &mut self,
197        operations: &[PreparedOperation],
198    ) -> Result<(), ViewChangeError> {
199        let (through, _) = self.state.pending()?.validate_install_chunk(
200            self.ticket,
201            self.validated_through,
202            operations,
203        )?;
204        self.validated_through = through;
205        Ok(())
206    }
207
208    /// Fence the installing view when a configured peer advertises a later one.
209    /// Retains only the highest view; no unbounded message backlog is needed.
210    pub fn observe_view(&mut self, from: NodeId, scope: Scope) -> Result<(), ViewChangeError> {
211        self.require_pending()?;
212        self.configuration.voter_index(from)?;
213        if (Scope {
214            view: scope.view,
215            ..self.ticket.scope
216        }) != scope
217        {
218            return Err(ReplicationError::ScopeMismatch.into());
219        }
220        self.requested_view = self.requested_view.max(scope.view);
221        Ok(())
222    }
223
224    /// Local progress timeout can request a later view while disk work settles.
225    pub fn request_view(&mut self, view: u64) -> Result<(), ViewChangeError> {
226        self.require_pending()?;
227        if view <= self.requested_view {
228            return Err(ViewChangeError::ViewNotHigher);
229        }
230        self.requested_view = view;
231        Ok(())
232    }
233
234    /// Complete actual durable publication and canonical application recovery.
235    ///
236    /// `applied` asserts that deterministic committed state, identities, and
237    /// results have been rebuilt through this exact prefix. Wrong/stale callbacks
238    /// leave ownership in this object. Successful completion transfers it once.
239    pub fn complete(
240        &mut self,
241        ticket: InstallTicket,
242        applied: Prefix,
243    ) -> Result<InstallOutcome, ViewChangeError> {
244        self.require_ticket(ticket)?;
245        if self.validated_through != ticket.accepted() {
246            return Err(ViewChangeError::HistoryMissing);
247        }
248        if applied != ticket.committed {
249            return Err(ViewChangeError::ApplicationPending);
250        }
251        let mut normal = self.state.take()?;
252        normal.activate_installed();
253        if self.requested_view > ticket.scope.view {
254            Ok(InstallOutcome::ViewChanging(
255                normal.into_view_change(self.requested_view)?,
256            ))
257        } else {
258            Ok(InstallOutcome::Normal(normal))
259        }
260    }
261
262    /// Complete a successful worker abort before publication was submitted.
263    ///
264    /// The adapter must first drain all admitted staging I/O and confirm the old
265    /// journal remains selected. An ambiguous write/publication error cannot use
266    /// this path. A later view is mandatory: never retry a different selection in
267    /// the abandoned view. Only old durable history is restored, always fenced.
268    pub fn complete_abandon(
269        &mut self,
270        ticket: InstallTicket,
271    ) -> Result<ViewChange, ViewChangeError> {
272        self.require_ticket(ticket)?;
273        if self.requested_view <= ticket.scope.view {
274            return Err(ViewChangeError::ViewNotHigher);
275        }
276        ViewChange::after_abandon(
277            self.state.take()?,
278            self.previous_promise,
279            self.requested_view,
280        )
281    }
282
283    /// Ambiguous publication failure requires reopen/recovery, never old-role reuse.
284    pub fn fail(&mut self, ticket: InstallTicket) -> Result<(), ViewChangeError> {
285        self.require_ticket(ticket)?;
286        self.state = InstallState::Failed;
287        Ok(())
288    }
289
290    pub(crate) fn require_pending(&self) -> Result<(), ViewChangeError> {
291        self.state.pending().map(|_| ())
292    }
293
294    fn require_ticket(&self, ticket: InstallTicket) -> Result<(), ViewChangeError> {
295        self.require_pending()?;
296        if ticket != self.ticket {
297            return Err(ViewChangeError::StaleInstallation);
298        }
299        Ok(())
300    }
301}
302
303/// Installation inputs checked before the fenced role moves.
304#[derive(Debug, Clone, Copy)]
305struct Admitted {
306    ticket: InstallTicket,
307    start: StartView,
308    promise: PromiseTicket,
309    body_bytes: usize,
310}
311
312/// Where the selected tail after the commit floor comes from.
313#[derive(Debug, Clone, Copy)]
314enum Suffix<'a> {
315    /// Supplied whole with this call and already validated.
316    Supplied(&'a [PreparedOperation]),
317    /// Stays in the journal and is validated later in bounded chunks.
318    Streamed,
319}
320
321impl ViewChange {
322    /// Stage primary installation whose selected tail remains in the journal.
323    ///
324    /// Feed `InstallingView::validate_suffix` bounded chunks before completion.
325    /// Durable publication still installs the complete selected log atomically.
326    /// After activation, a whole-tail new-view ACK and ordered application are
327    /// required before fresh appends; old operations consume no live ring slots.
328    #[expect(
329        clippy::result_large_err,
330        reason = "rejection returns owned fenced role"
331    )]
332    pub fn begin_primary_install(
333        self,
334        generation: JournalGeneration,
335    ) -> Result<InstallingView, RejectedInstallation> {
336        let admitted = self.admit_primary(generation);
337        self.stage(&admitted, Suffix::Streamed)
338    }
339
340    /// Stage bounded-stream backup installation after authenticated `START_VIEW`.
341    ///
342    /// Same promise, primary, and protected-prefix checks as `install_backup`.
343    /// Every selected byte must be validated and durably published before the
344    /// backup can ACK. Fresh prepares also wait for whole-tail commit/application.
345    #[expect(
346        clippy::result_large_err,
347        reason = "rejection returns owned fenced role"
348    )]
349    pub fn begin_backup_install(
350        mut self,
351        received: ReceivedStart,
352        generation: JournalGeneration,
353        mut lookup: impl FnMut(LogSource, OpNumber) -> Option<Digest>,
354    ) -> Result<InstallingView, RejectedInstallation> {
355        let admitted = self.admit_backup(received, generation, &mut lookup);
356        self.stage(&admitted, Suffix::Streamed)
357    }
358
359    /// Consume the selected primary's old role and stage atomic installation.
360    ///
361    /// `suffix` is the verified selected tail strictly after `ticket.committed`,
362    /// not the full retained WAL. Transfer/application/storage independently
363    /// validate earlier history and preserve the protected old committed prefix.
364    /// Staging checks operation/byte bounds and reuses the old descriptor arena.
365    /// Rejection returns the fenced role so missing data can be retried.
366    #[expect(
367        clippy::result_large_err,
368        reason = "rejection returns protocol ownership without allocation"
369    )]
370    pub fn install_primary(
371        self,
372        generation: JournalGeneration,
373        suffix: &[PreparedOperation],
374    ) -> Result<InstallingView, RejectedInstallation> {
375        let admitted = self
376            .admit_primary(generation)
377            .and_then(|admitted| self.validate_suffix(&admitted, suffix));
378        self.stage(&admitted, Suffix::Supplied(suffix))
379    }
380
381    /// Consume a backup's fenced role after an authenticated `START_VIEW`.
382    ///
383    /// Unlike primary selection, this needs no local first/second-phase quorum:
384    /// the current primary's installed descriptor supplies protocol authority.
385    /// It still requires this backup's completed view promise and verified
386    /// compatibility with its own possibly higher committed prefix.
387    /// `lookup` has the same pinned, nonblocking lineage contract as `select`.
388    /// `suffix` starts after the greater of local and announced commit knowledge.
389    #[expect(
390        clippy::result_large_err,
391        reason = "rejection returns protocol ownership without allocation"
392    )]
393    pub fn install_backup(
394        mut self,
395        received: ReceivedStart,
396        generation: JournalGeneration,
397        suffix: &[PreparedOperation],
398        mut lookup: impl FnMut(LogSource, OpNumber) -> Option<Digest>,
399    ) -> Result<InstallingView, RejectedInstallation> {
400        let admitted = self
401            .admit_backup(received, generation, &mut lookup)
402            .and_then(|admitted| self.validate_suffix(&admitted, suffix));
403        self.stage(&admitted, Suffix::Supplied(suffix))
404    }
405
406    fn admit_primary(&self, generation: JournalGeneration) -> Result<Admitted, ViewChangeError> {
407        let promise = self.require_promised()?;
408        self.require_primary()?;
409        let selected = self.selected.ok_or(ViewChangeError::SelectionRequired)?;
410        Ok(Admitted {
411            ticket: self.install_ticket(generation, selected.source(), selected.committed())?,
412            start: StartView {
413                scope: selected.scope(),
414                generation,
415                accepted: selected.source().accepted,
416                committed: selected.committed(),
417            },
418            promise,
419            body_bytes: 0,
420        })
421    }
422
423    fn admit_backup(
424        &mut self,
425        ReceivedStart { from, start }: ReceivedStart,
426        generation: JournalGeneration,
427        lookup: &mut impl FnMut(LogSource, OpNumber) -> Option<Digest>,
428    ) -> Result<Admitted, ViewChangeError> {
429        self.require_scope(start.scope)?;
430        let promise = self.require_promised()?;
431        let voter = self.configuration.voter_index(from)?;
432        if voter != VoterIndex::primary(start.scope.view) || voter == self.local {
433            return Err(ReplicationError::WrongRole.into());
434        }
435        if !crate::view_change::valid_prefix(start.accepted)
436            || !crate::view_change::valid_prefix(start.committed)
437            || start.committed.op > start.accepted.op
438        {
439            return Err(ViewChangeError::InvalidReport);
440        }
441        let source = LogSource {
442            voter: from,
443            generation: start.generation,
444            accepted: start.accepted,
445        };
446        let local_commit = self.normal.snapshot().committed;
447        self.verify_prefix(source, local_commit, lookup)?;
448        self.verify_prefix(source, start.committed, lookup)?;
449        let committed = if local_commit.op > start.committed.op {
450            local_commit
451        } else {
452            start.committed
453        };
454        Ok(Admitted {
455            ticket: self.install_ticket(generation, source, committed)?,
456            start,
457            promise,
458            body_bytes: 0,
459        })
460    }
461
462    fn validate_suffix(
463        &self,
464        admitted: &Admitted,
465        suffix: &[PreparedOperation],
466    ) -> Result<Admitted, ViewChangeError> {
467        let body_bytes = self.normal.validate_install(admitted.ticket, suffix)?;
468        Ok(Admitted {
469            body_bytes,
470            ..*admitted
471        })
472    }
473
474    fn install_ticket(
475        &self,
476        generation: JournalGeneration,
477        source: LogSource,
478        committed: Prefix,
479    ) -> Result<InstallTicket, ViewChangeError> {
480        let previous = self.normal.snapshot();
481        if generation == previous.journal.generation {
482            return Err(ViewChangeError::ReusedGeneration);
483        }
484        Ok(InstallTicket {
485            scope: self.scope(),
486            previous_generation: previous.journal.generation,
487            generation,
488            source,
489            protected_committed: previous.committed,
490            committed,
491        })
492    }
493
494    #[expect(
495        clippy::result_large_err,
496        reason = "rejection returns protocol ownership without allocation"
497    )]
498    fn stage(
499        self,
500        admitted: &Result<Admitted, ViewChangeError>,
501        suffix: Suffix<'_>,
502    ) -> Result<InstallingView, RejectedInstallation> {
503        let admitted = match *admitted {
504            Ok(admitted) => admitted,
505            Err(error) => {
506                return Err(RejectedInstallation {
507                    view_change: self,
508                    error,
509                });
510            }
511        };
512        let ticket = admitted.ticket;
513        let (operations, validated_through) = match suffix {
514            Suffix::Supplied(operations) => (operations, ticket.accepted()),
515            Suffix::Streamed => (&[][..], ticket.committed()),
516        };
517        let mut normal =
518            self.normal
519                .stage_install(ticket, operations, admitted.start, admitted.body_bytes);
520        if let Suffix::Streamed = suffix {
521            normal.retain_installed_tail_on_disk();
522        }
523        Ok(InstallingView {
524            ticket,
525            previous_promise: admitted.promise,
526            state: InstallState::Pending(normal),
527            configuration: self.configuration,
528            requested_view: ticket.scope.view,
529            validated_through,
530        })
531    }
532}