Skip to main content

ozzy_replication/
installation.rs

1//! Selected-view publication, activation, and late-view fencing.
2
3mod stage;
4
5use ozzy_proto::NodeId;
6
7use crate::{
8    Configuration, JournalGeneration, LogSource, NormalReplica, Prefix, PreparedOperation,
9    PromiseTicket, ReplicationError, Scope, ViewChange, ViewChangeError,
10};
11
12/// Immutable `START_VIEW` sent only after the primary durably installs this log.
13/// Sender identity comes from the authenticated configured primary.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct StartView {
16    /// Installed group/configuration/view.
17    pub scope: Scope,
18    /// Primary's frozen installed writer incarnation for transfer/retry binding.
19    pub generation: JournalGeneration,
20    /// Entire selected accepted prefix, including uncertain old-view operations.
21    pub accepted: Prefix,
22    /// Primary's known commit floor at installation time.
23    pub committed: Prefix,
24}
25
26/// A `START_VIEW` with the authenticated configured voter that sent it.
27/// Backup installation still checks that this voter is the view's primary.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct ReceivedStart {
30    /// Authenticated sender of `start`.
31    pub from: NodeId,
32    /// Installed descriptor as received.
33    pub start: StartView,
34}
35
36/// Exact selected-log publication action. Not evidence of successful I/O.
37///
38/// Persist the selected log with `promised_view = last_normal_view = scope.view`,
39/// preserving `protected_committed` and installing `committed`. Only then report
40/// completion, with canonical application state rebuilt through `committed`.
41/// Local history through `retained` lies in the selected lineage and is kept;
42/// only what follows is staged. A streamed backup stage proves that lineage.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct InstallTicket {
45    scope: Scope,
46    previous_generation: JournalGeneration,
47    generation: JournalGeneration,
48    source: LogSource,
49    protected_committed: Prefix,
50    retained: Prefix,
51    committed: Prefix,
52}
53
54impl InstallTicket {
55    /// Exact group/configuration and installed view.
56    pub const fn scope(self) -> Scope {
57        self.scope
58    }
59    /// Replaced writer incarnation. Old callbacks lose authority on installation.
60    pub const fn previous_generation(self) -> JournalGeneration {
61        self.previous_generation
62    }
63    /// Fresh local writer incarnation for the selected generation.
64    pub const fn generation(self) -> JournalGeneration {
65        self.generation
66    }
67    /// Frozen source from which canonical installation bytes are obtained.
68    pub const fn source(self) -> LogSource {
69        self.source
70    }
71    /// Highest old local commit; replacement must never overwrite it.
72    pub const fn protected_committed(self) -> Prefix {
73        self.protected_committed
74    }
75    /// Local prefix in the selected lineage, from `protected_committed` through
76    /// `committed`. Staging starts after it. Election verifies it, except a
77    /// streamed backup's, whose stage chains from it to the exact tail.
78    pub const fn retained(self) -> Prefix {
79        self.retained
80    }
81    /// Complete selected accepted prefix to install durably.
82    pub const fn accepted(self) -> Prefix {
83        self.source.accepted
84    }
85    /// Effective local committed prefix, never lower than prior local knowledge.
86    pub const fn committed(self) -> Prefix {
87        self.committed
88    }
89}
90
91/// Exactly one owned protocol role after installation.
92#[derive(Debug)]
93#[expect(
94    clippy::large_enum_variant,
95    reason = "rare ownership transition moves preallocated state without boxing"
96)]
97pub enum InstallOutcome {
98    /// Installed view is still current. Primary admission additionally waits for
99    /// a new-view backup ACK and ordered application through the selected tail.
100    Normal(NormalReplica),
101    /// A newer view arrived during I/O. No intermediate normal authority escapes.
102    ViewChanging(ViewChange),
103}
104
105/// Rejected admission together with the original fenced role.
106///
107/// Recoverable errors leave it available for retransmission/history fetching.
108/// Protected-history conflicts return the same role faulted for recovery.
109#[derive(Debug)]
110pub struct RejectedInstallation {
111    view_change: ViewChange,
112    error: ViewChangeError,
113}
114
115impl RejectedInstallation {
116    /// Why installation could not be admitted.
117    pub const fn error(&self) -> ViewChangeError {
118        self.error
119    }
120    /// Recover protocol ownership; rejection never resurrects normal voting.
121    pub fn into_view_change(self) -> ViewChange {
122        self.view_change
123    }
124}
125
126impl std::fmt::Display for RejectedInstallation {
127    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        std::fmt::Display::fmt(&self.error, formatter)
129    }
130}
131
132impl std::error::Error for RejectedInstallation {
133    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
134        Some(&self.error)
135    }
136}
137
138/// In-flight atomic installation. Neither old nor new normal voting is exposed.
139#[derive(Debug)]
140pub struct InstallingView {
141    ticket: InstallTicket,
142    previous_promise: PromiseTicket,
143    state: InstallState,
144    configuration: Configuration,
145    requested_view: u64,
146    validated_through: Prefix,
147}
148
149/// Ownership of the staged role while publication is outstanding.
150#[derive(Debug)]
151#[expect(
152    clippy::large_enum_variant,
153    reason = "the staged role moves without boxing"
154)]
155enum InstallState {
156    Pending(NormalReplica),
157    /// Ambiguous publication failure; only reopen and recovery remain.
158    Failed,
159    /// Completion transferred the role to its next owner.
160    Completed,
161}
162
163impl InstallState {
164    fn pending(&self) -> Result<&NormalReplica, ViewChangeError> {
165        match self {
166            Self::Pending(normal) => Ok(normal),
167            Self::Failed => Err(ViewChangeError::Faulted),
168            Self::Completed => Err(ViewChangeError::InstallationCompleted),
169        }
170    }
171
172    /// Transfer the staged role out exactly once.
173    fn take(&mut self) -> Result<NormalReplica, ViewChangeError> {
174        match std::mem::replace(self, Self::Completed) {
175            Self::Pending(normal) => Ok(normal),
176            Self::Failed => {
177                *self = Self::Failed;
178                Err(ViewChangeError::Faulted)
179            }
180            Self::Completed => Err(ViewChangeError::InstallationCompleted),
181        }
182    }
183}
184
185impl InstallingView {
186    pub(crate) fn requested_scope(&self) -> Scope {
187        Scope {
188            view: self.requested_view,
189            ..self.ticket.scope()
190        }
191    }
192
193    /// Submit this exact action to a dedicated storage worker.
194    pub const fn ticket(&self) -> InstallTicket {
195        self.ticket
196    }
197
198    /// Validate the next bounded chunk of a streamed selected suffix.
199    ///
200    /// Operations start strictly after the effective committed floor and arrive
201    /// in canonical order. Each call has the normal pipeline's count/byte limits;
202    /// no descriptors or payloads are retained. The caller independently validates
203    /// bodies/application transitions and stages the same bytes on the disk worker.
204    /// Rejected chunks leave this cursor unchanged. This is not an I/O completion.
205    pub fn validate_suffix(
206        &mut self,
207        operations: &[PreparedOperation],
208    ) -> Result<(), ViewChangeError> {
209        let (through, _) = self.state.pending()?.validate_install_chunk(
210            &self.ticket,
211            self.validated_through,
212            operations,
213        )?;
214        self.validated_through = through;
215        Ok(())
216    }
217
218    /// Fence the installing view when a configured peer advertises a later one.
219    /// Retains only the highest view; no unbounded message backlog is needed.
220    pub fn observe_view(&mut self, from: NodeId, scope: Scope) -> Result<(), ViewChangeError> {
221        self.require_pending()?;
222        self.configuration.voter_index(from)?;
223        if (Scope {
224            view: scope.view,
225            ..self.ticket.scope
226        }) != scope
227        {
228            return Err(ReplicationError::ScopeMismatch.into());
229        }
230        self.requested_view = self.requested_view.max(scope.view);
231        Ok(())
232    }
233
234    /// Local progress timeout can request a later view while disk work settles.
235    pub fn request_view(&mut self, view: u64) -> Result<(), ViewChangeError> {
236        self.require_pending()?;
237        if view <= self.requested_view {
238            return Err(ViewChangeError::ViewNotHigher);
239        }
240        self.requested_view = view;
241        Ok(())
242    }
243
244    /// Complete actual durable publication and canonical application recovery.
245    ///
246    /// `applied` asserts that deterministic committed state, identities, and
247    /// results have been rebuilt through this exact prefix. Wrong/stale callbacks
248    /// leave ownership in this object. Successful completion transfers it once.
249    pub fn complete(
250        &mut self,
251        ticket: InstallTicket,
252        applied: Prefix,
253    ) -> Result<InstallOutcome, ViewChangeError> {
254        self.require_ticket(&ticket)?;
255        if self.validated_through != ticket.accepted() {
256            return Err(ViewChangeError::HistoryMissing);
257        }
258        if applied != ticket.committed {
259            return Err(ViewChangeError::ApplicationPending);
260        }
261        let mut normal = self.state.take()?;
262        normal.activate_installed();
263        if self.requested_view > ticket.scope.view {
264            Ok(InstallOutcome::ViewChanging(
265                normal.into_view_change(self.requested_view)?,
266            ))
267        } else {
268            Ok(InstallOutcome::Normal(normal))
269        }
270    }
271
272    /// Complete a successful worker abort before publication was submitted.
273    ///
274    /// The adapter must first drain all admitted staging I/O and confirm the old
275    /// journal remains selected. An ambiguous write/publication error cannot use
276    /// this path. A later view is mandatory: never retry a different selection in
277    /// the abandoned view. Only old durable history is restored, always fenced.
278    pub fn complete_abandon(
279        &mut self,
280        ticket: InstallTicket,
281    ) -> Result<ViewChange, ViewChangeError> {
282        self.require_ticket(&ticket)?;
283        if self.requested_view <= ticket.scope.view {
284            return Err(ViewChangeError::ViewNotHigher);
285        }
286        ViewChange::after_abandon(
287            self.state.take()?,
288            self.previous_promise,
289            self.requested_view,
290        )
291    }
292
293    /// Ambiguous publication failure requires reopen/recovery, never old-role reuse.
294    pub fn fail(&mut self, ticket: InstallTicket) -> Result<(), ViewChangeError> {
295        self.require_ticket(&ticket)?;
296        self.state = InstallState::Failed;
297        Ok(())
298    }
299
300    pub(crate) fn require_pending(&self) -> Result<(), ViewChangeError> {
301        self.state.pending().map(|_| ())
302    }
303
304    fn require_ticket(&self, ticket: &InstallTicket) -> Result<(), ViewChangeError> {
305        self.require_pending()?;
306        if *ticket != self.ticket {
307            return Err(ViewChangeError::StaleInstallation);
308        }
309        Ok(())
310    }
311}