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    pub fn enable_broadcast(&mut self) {
118        self.broadcast = true;
119    }
120
121    /// A correlated probe found missing publications beyond existing reservations.
122    /// Fresh PEER sends are allowed only while the frozen target remains unsent.
123    pub fn needs_catch_up(&self) -> bool {
124        self.catch_up.is_some_and(|target| {
125            self.sender()
126                .is_some_and(|sender| sender.sent().op < target)
127        })
128    }
129
130    /// Frozen upper bound for fresh PEER catch-up in broadcast mode.
131    pub const fn catch_up_through(&self) -> Option<OpNumber> {
132        self.catch_up
133    }
134
135    /// Active peer ledger, or none until exact history verifies a correlated open.
136    pub fn sender(&self) -> Option<&Sender> {
137        self.sender.as_ref().filter(|_| self.active)
138    }
139
140    /// Pending history check, for matching asynchronous callbacks before adoption.
141    pub const fn candidate(&self) -> Option<OpenRequest> {
142        self.candidate
143    }
144
145    /// Current immutable exchange, for adapter correlation and diagnostics.
146    pub const fn pending_probe(&self) -> Option<Probe> {
147        self.probes.pending()
148    }
149
150    /// Fence link-session correlation without granting fresh receive capacity.
151    /// Retain the channel ledger, outstanding sends, and any established repair.
152    /// A new probe verifies peer state; an old asynchronous history lookup cannot
153    /// install its retired candidate after the session changes.
154    pub fn replace_session(&mut self, now: Duration) -> Result<(), TransmitError> {
155        self.probes.invalidate(now)?;
156        self.announced_channel = None;
157        self.candidate = None;
158        self.candidate_limit = None;
159        Ok(())
160    }
161
162    /// Fence a changed normal scope while retaining all startup allocations and IDs.
163    /// Reconnection alone does not call this method or reset local accounting.
164    pub fn change_scope(&mut self, scope: Scope, now: Duration) -> Result<(), TransmitError> {
165        if scope == self.scope {
166            return Ok(());
167        }
168        self.probes.invalidate(now)?;
169        self.scope = scope;
170        self.active = false;
171        self.announced_channel = None;
172        self.candidate = None;
173        self.candidate_limit = None;
174        self.repair = None;
175        self.catch_up = None;
176        self.receipt_progress.reset();
177        Ok(())
178    }
179
180    /// Poll small status traffic independently of durable ACK or election timers.
181    ///
182    /// `transport_pending` includes this peer's locally retained data transmissions.
183    /// Do not let a probe overtake that queue and mistake unsent bytes for a gap.
184    /// Poll while durability/receipt remains unresolved, including received
185    /// but not durably acknowledged bytes: a receiver can reset after volatile receipt.
186    pub fn poll_probe(
187        &mut self,
188        available: Prefix,
189        transport_pending: bool,
190        now: Duration,
191    ) -> Result<Option<Probe>, TransmitError> {
192        if transport_pending || self.repair.is_some() {
193            return Ok(None);
194        }
195        let tail = if self.broadcast {
196            available
197        } else {
198            self.sender().map_or(available, Sender::sent)
199        };
200        Ok(self
201            .probes
202            .poll(self.scope, tail, available.op.max(tail.op), now)?)
203    }
204
205    /// Reserve a fresh suffix once after ensuring the adapter can retain its packet.
206    /// Known repair takes precedence over sending more data beyond that gap.
207    pub fn record_send(&mut self, operations: &[Operation]) -> Result<(), FlowError> {
208        if !self.active {
209            return Err(FlowError::Channel);
210        }
211        if self.repair.is_some()
212            || self.broadcast
213                && self.catch_up.is_some_and(|through| {
214                    operations.last().is_some_and(|op| op.prefix.op > through)
215                })
216        {
217            return Err(FlowError::Capacity);
218        }
219        let sender = self.sender.as_mut().expect("active ledger");
220        sender.record_send(sender.channel(), operations)
221    }
222
223    /// Observe authenticated status. Unknown epochs need correlation plus history.
224    /// Same-epoch unsolicited receipts may retire reservations but never request repair.
225    pub fn observe(
226        &mut self,
227        report: Report,
228        request_id: Option<RequestId>,
229        now: Duration,
230    ) -> Result<StatusOutcome, TransmitError> {
231        self.observe_with_repair_limit(report, request_id, None, now)
232    }
233
234    /// A correlated response may stop repair before data already held on SUB.
235    /// The bound grants no capacity or history authority and cannot exceed the probe.
236    pub fn observe_with_repair_limit(
237        &mut self,
238        report: Report,
239        request_id: Option<RequestId>,
240        repair_limit: Option<OpNumber>,
241        now: Duration,
242    ) -> Result<StatusOutcome, TransmitError> {
243        if report.channel.scope != self.scope {
244            return Ok(StatusOutcome::Ignored);
245        }
246        let correlated = if let Some(id) = request_id {
247            let Some(probe) = self.probes.pending().filter(|probe| probe.request_id == id) else {
248                return Ok(StatusOutcome::Ignored);
249            };
250            self.probes.completion_deadline(probe.scope, id, now)?;
251            Some(probe)
252        } else {
253            None
254        };
255        report.validate_shape()?;
256        if self
257            .sender()
258            .is_some_and(|sender| sender.channel() == report.channel)
259        {
260            if self.candidate.is_some_and(|old| {
261                old.report.channel == report.channel && old.report.revision > report.revision
262            }) {
263                return Ok(StatusOutcome::Ignored);
264            }
265            if self.broadcast && report.received.op > self.sender().expect("active").sent().op {
266                self.candidate = Some(OpenRequest { request_id, report });
267                self.candidate_limit = repair_limit;
268                return Ok(StatusOutcome::Verify);
269            }
270            let sender = self.sender.as_mut().expect("active ledger");
271            let before = sender.received();
272            let changed = sender.observe(report)?;
273            if sender.received().op > before.op {
274                self.receipt_progress.advanced(sender.received().op.0, now);
275            }
276            if let Some(probe) = correlated {
277                let target = if self.broadcast {
278                    repair_limit.unwrap_or(probe.tail.op).min(probe.tail.op)
279                } else {
280                    probe.tail.op
281                };
282                if self.broadcast {
283                    self.catch_up = (sender.received().op < target).then_some(target);
284                }
285                let through = sender
286                    .outstanding()
287                    .take_while(|op| op.prefix.op <= target)
288                    .last()
289                    .map_or(sender.received(), |op| op.prefix);
290                // A status reply can overtake the data connection. Retrying a
291                // moving window duplicates the stream and competes with repair.
292                let quiet = !self.broadcast
293                    || self
294                        .receipt_progress
295                        .repair_limit(sender.received().op.0, None, now)
296                        .is_none();
297                self.repair = if quiet && sender.received().op < through.op {
298                    Some(Repair {
299                        channel: sender.channel(),
300                        after: sender.received(),
301                        through,
302                    })
303                } else {
304                    None
305                };
306                self.probes.complete(probe.scope, probe.request_id, now)?;
307                self.candidate = None;
308                self.candidate_limit = None;
309            }
310            self.trim_repair();
311            return Ok(if changed || correlated.is_some() {
312                StatusOutcome::Observed
313            } else {
314                StatusOutcome::Ignored
315            });
316        }
317        let Some(probe) = correlated else {
318            // A changed receive epoch announces reset volatile history between
319            // periodic probes. Ask immediately, but keep any live correlation
320            // and history lookup. The hint itself installs no receive capacity.
321            if self.sender().is_some() && self.announced_channel != Some(report.channel) {
322                self.probes.expedite(now)?;
323                self.announced_channel = Some(report.channel);
324            }
325            return Ok(StatusOutcome::Ignored);
326        };
327        if self.candidate.is_some_and(|old| {
328            old.report.channel == report.channel && old.report.revision > report.revision
329        }) {
330            return Ok(StatusOutcome::Ignored);
331        }
332        let candidate = OpenRequest {
333            request_id: Some(probe.request_id),
334            report,
335        };
336        self.candidate = Some(candidate);
337        self.candidate_limit = repair_limit;
338        Ok(StatusOutcome::Verify)
339    }
340
341    /// Adopt only the still-current candidate after exact local-history verification.
342    ///
343    /// Prefix arguments must come from current-generation local history, not copied
344    /// report fields. Stale callbacks return false. Old pending payloads must be
345    /// fenced by their epochs; they cannot become reservations in the new channel.
346    pub fn open_verified(
347        &mut self,
348        request: OpenRequest,
349        base: Prefix,
350        received: Prefix,
351        now: Duration,
352    ) -> Result<bool, TransmitError> {
353        if self.candidate != Some(request) {
354            return Ok(false);
355        }
356        let probe = if let Some(id) = request.request_id {
357            let Some(probe) = self.probes.pending().filter(|probe| {
358                probe.scope == request.report.channel.scope && probe.request_id == id
359            }) else {
360                return Ok(false);
361            };
362            self.probes.completion_deadline(probe.scope, id, now)?;
363            Some(probe)
364        } else {
365            if !self.broadcast
366                || self
367                    .sender()
368                    .is_none_or(|sender| sender.channel() != request.report.channel)
369            {
370                return Ok(false);
371            }
372            None
373        };
374        let report = request.report;
375        if report.base != base || report.received != received {
376            return Err(FlowError::History.into());
377        }
378        report.validate_shape()?;
379        let same_channel = self
380            .sender()
381            .is_some_and(|sender| sender.channel() == report.channel);
382        let previous = self.sender().map(|sender| sender.received().op);
383        if let Some(sender) = &mut self.sender {
384            if self.broadcast && same_channel {
385                if !sender.observe_publication(report, received)? {
386                    self.candidate = None;
387                    self.candidate_limit = None;
388                    return Ok(false);
389                }
390            } else {
391                sender.reopen(report, received)?;
392            }
393        } else {
394            self.sender = Some(Sender::from_checked_report(
395                report,
396                self.limits,
397                std::mem::take(&mut self.spare),
398            ));
399        }
400        if let Some(probe) = probe {
401            self.probes.complete(probe.scope, probe.request_id, now)?;
402            if self.broadcast {
403                let target = self
404                    .candidate_limit
405                    .unwrap_or(probe.tail.op)
406                    .min(probe.tail.op);
407                self.catch_up = (report.received.op < target).then_some(target);
408            }
409        }
410        self.active = true;
411        self.candidate = None;
412        self.candidate_limit = None;
413        if !same_channel {
414            self.repair = None;
415            self.receipt_progress.reset();
416        }
417        if !same_channel || previous.is_none_or(|op| received.op > op) {
418            self.receipt_progress.advanced(received.op.0, now);
419        }
420        self.trim_repair();
421        Ok(true)
422    }
423
424    /// Current bounded retry range, suppressed while this peer has queued data.
425    pub fn repair(&self, transport_pending: bool) -> Option<Repair> {
426        if transport_pending { None } else { self.repair }
427    }
428
429    /// Record one retained retry chunk without charging unique-send metadata again.
430    /// `through` must name an exact reserved operation inside the current repair.
431    pub fn record_repair(&mut self, repair: Repair, through: Prefix) -> Result<(), FlowError> {
432        if self.repair != Some(repair) {
433            return Err(FlowError::Channel);
434        }
435        if through.op <= repair.after.op
436            || through.op > repair.through.op
437            || self
438                .sender()
439                .is_none_or(|sender| !sender.outstanding().any(|op| op.prefix == through))
440        {
441            return Err(FlowError::History);
442        }
443        self.repair = if through == repair.through {
444            None
445        } else {
446            Some(Repair {
447                after: through,
448                ..repair
449            })
450        };
451        Ok(())
452    }
453
454    fn trim_repair(&mut self) {
455        if self.catch_up.is_some_and(|target| {
456            self.sender()
457                .is_some_and(|sender| sender.received().op >= target)
458        }) {
459            self.catch_up = None;
460        }
461        let Some(repair) = self.repair else {
462            return;
463        };
464        let received = self
465            .sender()
466            .expect("repair has an active ledger")
467            .received();
468        self.repair = if received.op >= repair.through.op {
469            None
470        } else if received.op > repair.after.op {
471            Some(Repair {
472                after: received,
473                ..repair
474            })
475        } else {
476            Some(repair)
477        };
478    }
479}