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}