Skip to main content

ozzy_replication/flow/
probe.rs

1//! Small status retries only. No timer here schedules payloads or supplies votes.
2
3use std::time::Duration;
4
5use ozzy_proto::RequestId;
6
7use super::prefix_valid;
8use crate::{Digest, OpNumber, Prefix, Scope};
9
10/// One immutable status request, including the tail needed to detect a lost last batch.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct Probe {
13    /// Exact normal scope; this request never changes a view or authorizes recovery.
14    pub scope: Scope,
15    /// Unique exchange identity, frozen across retries and retired after completion.
16    pub request_id: RequestId,
17    /// Primary's outstanding tail at request creation, not a commit assertion.
18    pub tail: Prefix,
19    /// Highest locally available operation, including unsent publications.
20    /// Availability hint only, never a repair or commit boundary.
21    pub available: OpNumber,
22}
23
24/// Independent status-query pacing, not election or durable-progress timing.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct ProbeTiming {
27    /// Delay before the first retry and between unchanged completed exchanges.
28    pub initial: Duration,
29    /// Maximum retry delay after repeated unanswered status requests.
30    pub maximum: Duration,
31}
32
33/// Rejected probe transition. Errors preserve the pending request and deadlines.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
35pub enum ProbeError {
36    /// Zero identity, malformed scope/tail, or invalid initial/maximum delay.
37    #[error("invalid replica probe parameters")]
38    Invalid,
39    /// Retire old correlation explicitly before polling another view/group.
40    #[error("replica probe scope changed without invalidation")]
41    Scope,
42    /// Adapter-supplied monotonic time moved backward.
43    #[error("replica probe clock regressed")]
44    Clock,
45    /// A retry/completion deadline cannot be represented; no wraparound is allowed.
46    #[error("replica probe deadline exhausted")]
47    TimeExhausted,
48    /// The local request-identity sequence cannot advance; never reuse retired IDs.
49    #[error("replica probe request identities exhausted")]
50    IdsExhausted,
51}
52
53/// Allocation-free, one-outstanding-request scheduler for one authenticated peer.
54///
55/// Poll only when the adapter needs receipt status. Opening is immediate;
56/// unanswered small requests back off to an explicit ceiling. Repeated calls at
57/// one instant emit at most one pending request. New work after a completed
58/// exchange opens immediately unless paced; unchanged status retains its pacing delay.
59/// A fresh valid response resets pacing.
60/// No observation changes durable ACKs, quorum state, or commit-progress deadlines.
61#[derive(Debug)]
62pub struct ProbeScheduler {
63    timing: ProbeTiming,
64    next_id: u128,
65    now: Duration,
66    due: Duration,
67    delay: Duration,
68    pending: Option<Probe>,
69    advertised: OpNumber,
70    pace_new_work: bool,
71}
72
73impl ProbeScheduler {
74    /// Start a peer-local request sequence and clock without allocating or reading time.
75    ///
76    /// Supply a fresh process/session-specific starting ID, never a reused constant
77    /// across actor lifetimes. Keep this scheduler across view/receive-epoch changes;
78    /// `invalidate` retires correlation without resetting the identity sequence.
79    pub fn new(first: RequestId, timing: ProbeTiming, now: Duration) -> Result<Self, ProbeError> {
80        let next_id = u128::from_be_bytes(*first.as_bytes());
81        if next_id == 0 || timing.initial.is_zero() || timing.maximum < timing.initial {
82            return Err(ProbeError::Invalid);
83        }
84        now.checked_add(timing.maximum)
85            .ok_or(ProbeError::TimeExhausted)?;
86        Ok(Self {
87            timing,
88            next_id,
89            now,
90            due: now,
91            delay: timing.initial,
92            pending: None,
93            advertised: OpNumber(0),
94            pace_new_work: false,
95        })
96    }
97
98    /// When an unchanged exchange is next sent or retried. New work after a
99    /// completed exchange opens one sooner unless paced.
100    pub const fn due(&self) -> Duration {
101        self.due
102    }
103
104    /// Hold new work to the pacing delay too, for a receiver that reports
105    /// receipt unasked. A probe then only has to find loss.
106    pub fn pace_new_work(&mut self) {
107        self.pace_new_work = true;
108    }
109
110    /// Currently outstanding frozen request, for exact response/history-work matching.
111    pub const fn pending(&self) -> Option<Probe> {
112        self.pending
113    }
114
115    /// Emit a new small probe when due, or retry the exact pending request.
116    ///
117    /// Changed `tail` or `available` does not mutate a live exchange. After
118    /// completion new availability bypasses the idle delay unless paced, and
119    /// the next request captures both.
120    /// A returned request remains owned
121    /// here even if transport admission fails; retry keeps its request identity.
122    pub fn poll(
123        &mut self,
124        scope: Scope,
125        tail: Prefix,
126        available: OpNumber,
127        now: Duration,
128    ) -> Result<Option<Probe>, ProbeError> {
129        self.check_time(now)?;
130        if scope.group_id.as_bytes() == &[0; 16]
131            || scope.configuration_digest == Digest::ZERO
132            || !prefix_valid(tail)
133            || tail.op.0 == u64::MAX
134            || available < tail.op
135            || available.0 == u64::MAX
136        {
137            return Err(ProbeError::Invalid);
138        }
139        if self.pending.is_some_and(|request| request.scope != scope) {
140            return Err(ProbeError::Scope);
141        }
142        let new_work = !self.pace_new_work && self.pending.is_none() && available > self.advertised;
143        if now < self.due && !new_work {
144            self.now = now;
145            return Ok(None);
146        }
147        let (request, next_id) = if let Some(request) = self.pending {
148            (request, self.next_id)
149        } else {
150            let next_id = self
151                .next_id
152                .checked_add(1)
153                .ok_or(ProbeError::IdsExhausted)?;
154            (
155                Probe {
156                    scope,
157                    tail,
158                    available,
159
160                    request_id: RequestId::from_bytes(self.next_id.to_be_bytes()),
161                },
162                next_id,
163            )
164        };
165        let due = now
166            .checked_add(self.delay)
167            .ok_or(ProbeError::TimeExhausted)?;
168        self.now = now;
169        self.due = due;
170        self.delay = self.delay.saturating_mul(2).min(self.timing.maximum);
171        self.next_id = next_id;
172        self.pending = Some(request);
173        self.advertised = request.available;
174        Ok(Some(request))
175    }
176
177    /// Retire a validated response only if its scope and ID name the live request.
178    ///
179    /// Call after authenticated decoding and required report/history checks, not
180    /// on arbitrary network activity. Stale replies do not extend retry deadlines.
181    /// This method does not decide whether a newly reported receive epoch is valid.
182    pub fn complete(
183        &mut self,
184        scope: Scope,
185        id: RequestId,
186        now: Duration,
187    ) -> Result<bool, ProbeError> {
188        let Some(due) = self.completion_deadline(scope, id, now)? else {
189            self.now = now;
190            return Ok(false);
191        };
192        self.now = now;
193        self.due = due;
194        self.delay = self.timing.initial;
195        self.pending = None;
196        Ok(true)
197    }
198
199    pub(super) fn completion_deadline(
200        &self,
201        scope: Scope,
202        id: RequestId,
203        now: Duration,
204    ) -> Result<Option<Duration>, ProbeError> {
205        self.check_time(now)?;
206        if self
207            .pending
208            .is_none_or(|request| request.scope != scope || request.request_id != id)
209        {
210            return Ok(None);
211        }
212        now.checked_add(self.timing.initial)
213            .map(Some)
214            .ok_or(ProbeError::TimeExhausted)
215    }
216
217    /// Retire correlation after role/session replacement, keeping identities monotonic.
218    /// The next poll can open immediately; old responses cannot complete that request.
219    pub fn invalidate(&mut self, now: Duration) -> Result<(), ProbeError> {
220        self.check_time(now)?;
221        self.now = now;
222        self.due = now;
223        self.delay = self.timing.initial;
224        self.pending = None;
225        Ok(())
226    }
227
228    /// Retry the current immutable exchange sooner after a new receiver hint.
229    /// Preserve its identity, frozen tail, and any correlated history lookup.
230    pub(super) fn expedite(&mut self, now: Duration) -> Result<(), ProbeError> {
231        self.check_time(now)?;
232        self.now = now;
233        self.due = self.due.min(now);
234        self.delay = self.timing.initial;
235        Ok(())
236    }
237
238    fn check_time(&self, now: Duration) -> Result<(), ProbeError> {
239        if now < self.now {
240            Err(ProbeError::Clock)
241        } else {
242            Ok(())
243        }
244    }
245}