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    /// Retire reservations the current channel reports received.
327    fn observe_receipt(&mut self, report: Report, now: Duration) -> Result<bool, FlowError> {
328        let Some(sender) = self.sender.as_mut() else {
329            return Ok(false);
330        };
331        let before = sender.received();
332        let changed = sender.observe(report)?;
333        if sender.received().op > before.op {
334            self.receipt_progress.advanced(sender.received().op.0, now);
335        }
336        Ok(changed)
337    }
338
339    /// Complete a live probe on the current channel and plan the repair of
340    /// what it shows outstanding through its frozen tail.
341    fn answer_probe(
342        &mut self,
343        probe: Probe,
344        repair_limit: Option<OpNumber>,
345        now: Duration,
346    ) -> Result<(), TransmitError> {
347        if let Some(sender) = self.sender.as_ref() {
348            let target = if self.broadcast {
349                repair_limit.unwrap_or(probe.tail.op).min(probe.tail.op)
350            } else {
351                probe.tail.op
352            };
353            if self.broadcast {
354                self.catch_up = (sender.received().op < target).then_some(target);
355            }
356            let through = sender
357                .outstanding()
358                .take_while(|op| op.prefix.op <= target)
359                .last()
360                .map_or(sender.received(), |op| op.prefix);
361            // A status reply can overtake the data connection. Retrying a
362            // moving window duplicates the stream and competes with repair.
363            let quiet = !self.broadcast
364                || self
365                    .receipt_progress
366                    .repair_limit(sender.received().op.0, None, now)
367                    .is_none();
368            self.repair = if quiet && sender.received().op < through.op {
369                Some(Repair {
370                    channel: sender.channel(),
371                    after: sender.received(),
372                    through,
373                })
374            } else {
375                None
376            };
377        }
378        self.probes.complete(probe.scope, probe.request_id, now)?;
379        self.clear_candidate();
380        Ok(())
381    }
382
383    /// Current bounded retry range, suppressed while this peer has queued data.
384    pub fn repair(&self, transport_pending: bool) -> Option<Repair> {
385        if transport_pending { None } else { self.repair }
386    }
387
388    /// Record one retained retry chunk without charging unique-send metadata again.
389    /// `through` must name an exact reserved operation inside the current repair.
390    pub fn record_repair(&mut self, repair: Repair, through: Prefix) -> Result<(), FlowError> {
391        if self.repair != Some(repair) {
392            return Err(FlowError::Channel);
393        }
394        if through.op <= repair.after.op
395            || through.op > repair.through.op
396            || self
397                .sender()
398                .is_none_or(|sender| !sender.outstanding().any(|op| op.prefix == through))
399        {
400            return Err(FlowError::History);
401        }
402        self.repair = if through == repair.through {
403            None
404        } else {
405            Some(Repair {
406                after: through,
407                ..repair
408            })
409        };
410        Ok(())
411    }
412
413    fn trim_repair(&mut self) {
414        if self.catch_up.is_some_and(|target| {
415            self.sender()
416                .is_some_and(|sender| sender.received().op >= target)
417        }) {
418            self.catch_up = None;
419        }
420        let (Some(repair), Some(received)) = (self.repair, self.sender().map(Sender::received))
421        else {
422            return;
423        };
424        self.repair = if received.op >= repair.through.op {
425            None
426        } else if received.op > repair.after.op {
427            Some(Repair {
428                after: received,
429                ..repair
430            })
431        } else {
432            Some(repair)
433        };
434    }
435}