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