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