Skip to main content

ozzy_replication/flow/
transmit.rs

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