Skip to main content

ozzy_replication/flow/
transmit.rs

1//! Shared normal/catch-up send policy. Timers produce queries, never data copies.
2
3mod open;
4
5use std::time::Duration;
6
7use crate::OpNumber;
8use ozzy_proto::RequestId;
9
10use super::{
11    Channel, FlowError, Operation, PipelineLimits, Prefix, Probe, ProbeError, ProbeScheduler,
12    ProbeTiming, Report, Scope, Sender, VecDeque, ledger,
13};
14
15/// Exact report awaiting independently verified local history.
16/// New receiver epochs require correlation; known-epoch PUB progress does not.
17///
18/// A disk callback must additionally match the actor's current journal generation.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct OpenRequest {
21    request_id: Option<RequestId>,
22    report: Report,
23}
24
25impl OpenRequest {
26    /// Candidate base/receipt prefixes and receive incarnation, not trusted history.
27    pub const fn report(self) -> Report {
28        self.report
29    }
30}
31
32/// One bounded repair range using existing reservations. Never new send capacity.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct Repair {
35    /// Exact receiver incarnation to put on each packet.
36    pub channel: Channel,
37    /// Last known retained or already retried prefix; start strictly after it.
38    pub after: Prefix,
39    /// Frozen outstanding tail covered by the answered probe, inclusive.
40    pub through: Prefix,
41}
42
43/// Nonvoting result of an authenticated receipt/status observation.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum StatusOutcome {
46    /// Stale correlation, another scope/epoch notification, or unchanged status.
47    Ignored,
48    /// Same-channel accounting or the current probe exchange advanced.
49    Observed,
50    /// New channel or unreserved PUB progress needs a local-history check.
51    Verify,
52}
53
54/// Rejected send-policy transition. No error supplies durability or quorum evidence.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
56pub enum TransmitError {
57    /// Receipt or exact-history validation failed.
58    #[error(transparent)]
59    Flow(#[from] FlowError),
60    /// Monotonic time or probe identity cannot advance safely.
61    #[error(transparent)]
62    Probe(#[from] ProbeError),
63}
64
65/// One peer's preallocated local ledger, correlated probes, and bounded repair.
66///
67/// The actor and simulator share this policy. Neither timer expiry nor an
68/// unsolicited partial receipt schedules a retransmission. Only a valid response
69/// to a live probe can identify an outstanding range for repair. Payload ownership,
70/// authenticated binding, role checks, journal generations, and quorum stay outside.
71#[derive(Debug)]
72pub struct Transmitter {
73    scope: Scope,
74    limits: PipelineLimits,
75    probes: ProbeScheduler,
76    sender: Option<Sender>,
77    announced_channel: Option<Channel>,
78    // Reserved before the first open; moved into Sender without allocating.
79    spare: VecDeque<Operation>,
80    active: bool,
81    candidate: Option<OpenRequest>,
82    candidate_limit: Option<OpNumber>,
83    repair: Option<Repair>,
84    broadcast: bool,
85    catch_up: Option<OpNumber>,
86    receipt_progress: ozzy_core::live::LiveProgress,
87}
88
89impl Transmitter {
90    /// Reserve this sender's local metadata window and start fresh probe correlation.
91    /// Remote receipt reports cannot increase this allocation.
92    pub fn new(
93        scope: Scope,
94        limits: PipelineLimits,
95        first: RequestId,
96        timing: ProbeTiming,
97        now: Duration,
98    ) -> Result<Self, TransmitError> {
99        // The initial probe validates scope before any network or ledger action.
100        let probes = ProbeScheduler::new(first, timing, now)?;
101        Ok(Self {
102            scope,
103            limits,
104            probes,
105            sender: None,
106            announced_channel: None,
107            spare: ledger(limits)?,
108            active: false,
109            candidate: None,
110            candidate_limit: None,
111            repair: None,
112            broadcast: false,
113            catch_up: None,
114            receipt_progress: ozzy_core::live::LiveProgress::new(timing.maximum),
115        })
116    }
117
118    /// Enable loss-tolerant PUB delivery. PEER reservations cover repair only.
119    /// Receivers report PUB receipt unasked, so new work waits for the probe delay.
120    pub fn enable_broadcast(&mut self) {
121        self.broadcast = true;
122        self.probes.pace_new_work();
123    }
124
125    /// A correlated probe found missing publications beyond existing reservations.
126    /// Fresh PEER sends are allowed only while the frozen target remains unsent.
127    pub fn needs_catch_up(&self) -> bool {
128        self.catch_up.is_some_and(|target| {
129            self.sender()
130                .is_some_and(|sender| sender.sent().op < target)
131        })
132    }
133
134    /// Frozen upper bound for fresh PEER catch-up in broadcast mode.
135    pub const fn catch_up_through(&self) -> Option<OpNumber> {
136        self.catch_up
137    }
138
139    /// Active peer ledger, or none until exact history verifies a correlated open.
140    pub fn sender(&self) -> Option<&Sender> {
141        self.sender.as_ref().filter(|_| self.active)
142    }
143
144    /// Pending history check, for matching asynchronous callbacks before adoption.
145    pub const fn candidate(&self) -> Option<OpenRequest> {
146        self.candidate
147    }
148
149    /// Current immutable exchange, for adapter correlation and diagnostics.
150    pub const fn pending_probe(&self) -> Option<Probe> {
151        self.probes.pending()
152    }
153
154    /// Fence link-session correlation without granting fresh receive capacity.
155    /// Retain the channel ledger, outstanding sends, and any established repair.
156    /// A new probe verifies peer state; an old asynchronous history lookup cannot
157    /// install its retired candidate after the session changes.
158    pub fn replace_session(&mut self, now: Duration) -> Result<(), TransmitError> {
159        self.probes.invalidate(now)?;
160        self.announced_channel = None;
161        self.candidate = None;
162        self.candidate_limit = None;
163        Ok(())
164    }
165
166    /// Fence a changed normal scope while retaining all startup allocations and IDs.
167    /// Reconnection alone does not call this method or reset local accounting.
168    pub fn change_scope(&mut self, scope: Scope, now: Duration) -> Result<(), TransmitError> {
169        if scope == self.scope {
170            return Ok(());
171        }
172        self.probes.invalidate(now)?;
173        self.scope = scope;
174        self.active = false;
175        self.announced_channel = None;
176        self.candidate = None;
177        self.candidate_limit = None;
178        self.repair = None;
179        self.catch_up = None;
180        self.receipt_progress.reset();
181        Ok(())
182    }
183
184    /// When `poll_probe` next sends or retries on time alone. `None` while a
185    /// repair runs, which probes never overtake.
186    pub fn next_probe(&self) -> Option<Duration> {
187        self.repair.is_none().then(|| self.probes.due())
188    }
189
190    /// Poll small status traffic independently of durable ACK or election timers.
191    ///
192    /// `transport_pending` includes this peer's locally retained data transmissions.
193    /// Do not let a probe overtake that queue and mistake unsent bytes for a gap.
194    /// Poll while durability/receipt remains unresolved, including received
195    /// but not durably acknowledged bytes: a receiver can reset after volatile receipt.
196    pub fn poll_probe(
197        &mut self,
198        available: Prefix,
199        transport_pending: bool,
200        now: Duration,
201    ) -> Result<Option<Probe>, TransmitError> {
202        if transport_pending || self.repair.is_some() {
203            return Ok(None);
204        }
205        let tail = if self.broadcast {
206            available
207        } else {
208            self.sender().map_or(available, Sender::sent)
209        };
210        Ok(self
211            .probes
212            .poll(self.scope, tail, available.op.max(tail.op), now)?)
213    }
214
215    /// Reserve a fresh suffix once after ensuring the adapter can retain its packet.
216    /// Known repair takes precedence over sending more data beyond that gap.
217    pub fn record_send(&mut self, operations: &[Operation]) -> Result<(), FlowError> {
218        let Some(sender) = self.sender.as_mut().filter(|_| self.active) else {
219            return Err(FlowError::Channel);
220        };
221        if self.repair.is_some()
222            || self.broadcast
223                && self.catch_up.is_some_and(|through| {
224                    operations.last().is_some_and(|op| op.prefix.op > through)
225                })
226        {
227            return Err(FlowError::Capacity);
228        }
229        sender.record_send(sender.channel(), operations)
230    }
231
232    /// Observe authenticated status. Unknown epochs need correlation plus history.
233    /// Same-epoch unsolicited receipts may retire reservations but never request repair.
234    pub fn observe(
235        &mut self,
236        report: Report,
237        request_id: Option<RequestId>,
238        now: Duration,
239    ) -> Result<StatusOutcome, TransmitError> {
240        self.observe_with_repair_limit(report, request_id, None, now)
241    }
242
243    /// A correlated response may stop repair before data already held on SUB.
244    /// The bound grants no capacity or history authority and cannot exceed the probe.
245    pub fn observe_with_repair_limit(
246        &mut self,
247        report: Report,
248        request_id: Option<RequestId>,
249        repair_limit: Option<OpNumber>,
250        now: Duration,
251    ) -> Result<StatusOutcome, TransmitError> {
252        if report.channel.scope != self.scope {
253            return Ok(StatusOutcome::Ignored);
254        }
255        let correlated = if let Some(id) = request_id {
256            let Some(probe) = self.probes.pending().filter(|probe| probe.request_id == id) else {
257                return Ok(StatusOutcome::Ignored);
258            };
259            self.probes.completion_deadline(probe.scope, id, now)?;
260            Some(probe)
261        } else {
262            None
263        };
264        report.validate_shape()?;
265        let request = OpenRequest { request_id, report };
266        if self
267            .sender()
268            .is_none_or(|sender| sender.channel() != report.channel)
269        {
270            if correlated.is_none() {
271                // A changed receive epoch announces reset volatile history between
272                // periodic probes. Ask immediately, but keep any live correlation
273                // and history lookup. The hint itself installs no receive capacity.
274                if self.sender().is_some() && self.announced_channel != Some(report.channel) {
275                    self.probes.expedite(now)?;
276                    self.announced_channel = Some(report.channel);
277                }
278                return Ok(StatusOutcome::Ignored);
279            }
280            return Ok(self.hold(request, repair_limit));
281        }
282        if self.superseded(report) {
283            return Ok(StatusOutcome::Ignored);
284        }
285        if self.broadcast
286            && self
287                .sender()
288                .is_some_and(|sender| report.received.op > sender.sent().op)
289        {
290            return Ok(self.hold(request, repair_limit));
291        }
292        let changed = self.observe_receipt(report, now)?;
293        if let Some(probe) = correlated {
294            self.answer_probe(probe, repair_limit, now)?;
295        }
296        self.trim_repair();
297        Ok(if changed || correlated.is_some() {
298            StatusOutcome::Observed
299        } else {
300            StatusOutcome::Ignored
301        })
302    }
303
304    /// Hold a report for local-history verification, unless the pending
305    /// candidate already carries a newer revision of the same channel.
306    fn hold(&mut self, request: OpenRequest, repair_limit: Option<OpNumber>) -> StatusOutcome {
307        if self.superseded(request.report) {
308            return StatusOutcome::Ignored;
309        }
310        self.candidate = Some(request);
311        self.candidate_limit = repair_limit;
312        StatusOutcome::Verify
313    }
314
315    fn superseded(&self, report: Report) -> bool {
316        self.candidate.is_some_and(|old| {
317            old.report.channel == report.channel && old.report.revision > report.revision
318        })
319    }
320
321    fn clear_candidate(&mut self) {
322        self.candidate = None;
323        self.candidate_limit = None;
324    }
325
326    /// Drop `request` once its lookup is done and adopted nothing. Only a newer
327    /// report can change the answer, so the same lookup must not run again.
328    pub fn forget_candidate(&mut self, request: OpenRequest) {
329        if self.candidate == Some(request) {
330            self.clear_candidate();
331        }
332    }
333
334    /// Retire reservations the current channel reports received.
335    fn observe_receipt(&mut self, report: Report, now: Duration) -> Result<bool, FlowError> {
336        let Some(sender) = self.sender.as_mut() else {
337            return Ok(false);
338        };
339        let before = sender.received();
340        let changed = sender.observe(report)?;
341        if sender.received().op > before.op {
342            self.receipt_progress.advanced(sender.received().op.0, now);
343        }
344        Ok(changed)
345    }
346
347    /// Complete a live probe on the current channel and plan the repair of
348    /// what it shows outstanding through its frozen tail.
349    fn answer_probe(
350        &mut self,
351        probe: Probe,
352        repair_limit: Option<OpNumber>,
353        now: Duration,
354    ) -> Result<(), TransmitError> {
355        if let Some(sender) = self.sender.as_ref() {
356            let target = if self.broadcast {
357                repair_limit.unwrap_or(probe.tail.op).min(probe.tail.op)
358            } else {
359                probe.tail.op
360            };
361            if self.broadcast {
362                self.catch_up = (sender.received().op < target).then_some(target);
363            }
364            let through = sender
365                .outstanding()
366                .take_while(|op| op.prefix.op <= target)
367                .last()
368                .map_or(sender.received(), |op| op.prefix);
369            // A status reply can overtake the data connection. Retrying a
370            // moving window duplicates the stream and competes with repair.
371            let quiet = !self.broadcast
372                || self
373                    .receipt_progress
374                    .repair_limit(sender.received().op.0, None, now)
375                    .is_none();
376            self.repair = if quiet && sender.received().op < through.op {
377                Some(Repair {
378                    channel: sender.channel(),
379                    after: sender.received(),
380                    through,
381                })
382            } else {
383                None
384            };
385        }
386        self.probes.complete(probe.scope, probe.request_id, now)?;
387        self.clear_candidate();
388        Ok(())
389    }
390
391    /// Current bounded retry range, suppressed while this peer has queued data.
392    pub fn repair(&self, transport_pending: bool) -> Option<Repair> {
393        if transport_pending { None } else { self.repair }
394    }
395
396    /// Record one retained retry chunk without charging unique-send metadata again.
397    /// `through` must name an exact reserved operation inside the current repair.
398    pub fn record_repair(&mut self, repair: Repair, through: Prefix) -> Result<(), FlowError> {
399        if self.repair != Some(repair) {
400            return Err(FlowError::Channel);
401        }
402        if through.op <= repair.after.op
403            || through.op > repair.through.op
404            || self
405                .sender()
406                .is_none_or(|sender| !sender.outstanding().any(|op| op.prefix == through))
407        {
408            return Err(FlowError::History);
409        }
410        self.repair = if through == repair.through {
411            None
412        } else {
413            Some(Repair {
414                after: through,
415                ..repair
416            })
417        };
418        Ok(())
419    }
420
421    fn trim_repair(&mut self) {
422        if self.catch_up.is_some_and(|target| {
423            self.sender()
424                .is_some_and(|sender| sender.received().op >= target)
425        }) {
426            self.catch_up = None;
427        }
428        let (Some(repair), Some(received)) = (self.repair, self.sender().map(Sender::received))
429        else {
430            return;
431        };
432        self.repair = if received.op >= repair.through.op {
433            None
434        } else if received.op > repair.after.op {
435            Some(Repair {
436                after: received,
437                ..repair
438            })
439        } else {
440            Some(repair)
441        };
442    }
443}