Skip to main content

ozzy_replication/recovery/
transfer.rs

1//! Full-WAL validation and exact publication evidence before fenced restart.
2
3use crate::{FrozenLog, Prefix, PreparedOperation, RecoveredState, ReplicationError};
4
5use super::{Recovery, RecoveryError, RecoveryTicket, Transfer};
6
7impl Recovery {
8    /// Observe privately decoded canonical state at the pinned original anchor.
9    /// The adapter must verify schema, bytes, digest, and revision before calling.
10    /// This grants no vote or publication authority.
11    pub fn complete_checkpoint(
12        &mut self,
13        ticket: RecoveryTicket,
14        anchor: super::CheckpointAnchor,
15        state_revision: u64,
16    ) -> Result<(), RecoveryError> {
17        self.require_ticket(&ticket)?;
18        if ticket.checkpoint() != Some(anchor) {
19            return Err(RecoveryError::StaleTransfer);
20        }
21        if state_revision != anchor.position.op.0 {
22            return Err(RecoveryError::ApplicationPending);
23        }
24        self.pending
25            .as_mut()
26            .expect("checked transfer")
27            .checkpoint_ready = true;
28        Ok(())
29    }
30
31    /// Validate one count/byte-bounded chunk starting at the current full-WAL cursor.
32    ///
33    /// No descriptors or payloads are retained. The adapter must independently
34    /// validate canonical bodies/application transitions, authenticate and bind
35    /// the source/nonce, and stage these same bytes. Rejection leaves the cursor
36    /// unchanged. Current-view prepares are included, not discarded as uncertain.
37    pub fn validate_chunk(
38        &mut self,
39        ticket: RecoveryTicket,
40        operations: &[PreparedOperation],
41    ) -> Result<(), RecoveryError> {
42        let pending = self.require_ticket(&ticket)?;
43        if !pending.checkpoint_ready {
44            return Err(RecoveryError::ApplicationPending);
45        }
46        if operations.is_empty() {
47            return Err(ReplicationError::HistoryGap.into());
48        }
49        if operations.len() > self.limits.max_operations {
50            return Err(ReplicationError::Capacity.into());
51        }
52        let mut through = pending.through;
53        let mut bytes = 0_usize;
54        for operation in operations {
55            bytes = bytes
56                .checked_add(operation.body_bytes)
57                .ok_or(ReplicationError::Capacity)?;
58            if bytes > self.limits.max_body_bytes {
59                return Err(ReplicationError::Capacity.into());
60            }
61            if operation.group_id != ticket.scope.group_id
62                || operation.configuration_epoch != ticket.scope.configuration_epoch
63                || operation.original_view > ticket.scope.view
64            {
65                return Err(ReplicationError::ScopeMismatch.into());
66            }
67            if through.op.0.checked_add(1) != Some(operation.prefix.op.0) {
68                return Err(ReplicationError::HistoryGap.into());
69            }
70            if operation.previous_digest != through.digest
71                || operation.prefix.op > ticket.source.accepted.op
72                || (operation.prefix.op == ticket.source.accepted.op
73                    && operation.prefix != ticket.source.accepted)
74                || (operation.prefix.op == ticket.committed.op
75                    && operation.prefix != ticket.committed)
76                || ticket.checkpoint.is_some_and(|anchor| {
77                    operation.prefix.op == anchor.position.op && operation.prefix != anchor.position
78                })
79            {
80                return Err(ReplicationError::ConflictingHistory.into());
81            }
82            through = operation.prefix;
83        }
84        self.pending
85            .as_mut()
86            .expect("checked pending transfer")
87            .through = through;
88        Ok(())
89    }
90
91    /// Consume actual durable full-store publication and private application validation.
92    ///
93    /// The worker must atomically publish exact configuration, selected history,
94    /// and `promised_view = last_normal_view = ticket.scope().view` before reporting
95    /// `durable`. `applied` asserts privately rebuilt canonical state, not consumer
96    /// processing. Submission, received bytes, and page-cache writes do not qualify.
97    ///
98    /// This returns only validated disk state for `ViewChange::recover_intact`,
99    /// never a same-view normal role. The existing fenced election/install gates
100    /// must still run. Missing-store formatting and runtime orchestration remain
101    /// separate adapters; an incomplete replacement must reopen nonvoting.
102    pub fn complete(
103        &mut self,
104        ticket: RecoveryTicket,
105        durable: Prefix,
106        applied: Prefix,
107    ) -> Result<RecoveredState, RecoveryError> {
108        let pending = self.require_ticket(&ticket)?;
109        if !pending.checkpoint_ready {
110            return Err(RecoveryError::ApplicationPending);
111        }
112        if pending.through != ticket.source.accepted {
113            return Err(RecoveryError::HistoryMissing);
114        }
115        if durable != ticket.source.accepted {
116            return Err(RecoveryError::StoragePending);
117        }
118        if applied != ticket.committed {
119            return Err(RecoveryError::ApplicationPending);
120        }
121        self.pending = None;
122        self.completed = true;
123        Ok(RecoveredState {
124            scope: ticket.scope,
125            log: FrozenLog {
126                last_normal_view: ticket.scope.view,
127                accepted: ticket.source.accepted,
128                committed: ticket.committed,
129            },
130        })
131    }
132
133    /// Fence after an actual uncertain storage error, including an older-view action.
134    /// Dropping a waiter is not storage failure. Stale callbacks cannot fault a
135    /// different recovery attempt; no faulted instance may return recovered state.
136    pub fn fail(&mut self, ticket: RecoveryTicket) -> Result<(), RecoveryError> {
137        self.pending_ticket(&ticket)?;
138        self.faulted = true;
139        Ok(())
140    }
141
142    /// Retire fresh recovery authority after physical repair and full private
143    /// replay of the original local history. Unlike full replacement, this must
144    /// preserve the original last-normal view. The caller supplies actual durable
145    /// publication evidence. Success permits only fenced intact restart.
146    pub fn complete_repair(
147        &mut self,
148        ticket: RecoveryTicket,
149        repaired: RecoveredState,
150    ) -> Result<RecoveredState, RecoveryError> {
151        self.require_ticket(&ticket)?;
152        if repaired.scope
153            != (crate::Scope {
154                view: repaired.scope.view,
155                ..ticket.scope
156            })
157            || repaired.scope.view < ticket.scope.view
158            || repaired.log.last_normal_view > repaired.scope.view
159            || [repaired.log.accepted, repaired.log.committed]
160                .iter()
161                .any(|prefix| (prefix.op.0 == 0) != (prefix.digest == crate::Digest::ZERO))
162            || repaired.log.committed.op > repaired.log.accepted.op
163            || (repaired.log.committed.op == repaired.log.accepted.op
164                && repaired.log.committed != repaired.log.accepted)
165        {
166            return Err(RecoveryError::StoragePending);
167        }
168        self.pending = None;
169        self.completed = true;
170        Ok(repaired)
171    }
172
173    fn pending_ticket(&self, ticket: &RecoveryTicket) -> Result<Transfer, RecoveryError> {
174        self.require_active()?;
175        self.pending
176            .filter(|pending| pending.ticket == *ticket)
177            .ok_or(RecoveryError::StaleTransfer)
178    }
179
180    fn require_ticket(&self, ticket: &RecoveryTicket) -> Result<Transfer, RecoveryError> {
181        let pending = self.pending_ticket(ticket)?;
182        if self.view > ticket.scope.view {
183            return Err(RecoveryError::StaleView);
184        }
185        Ok(pending)
186    }
187}