Skip to main content

hatchery_core/
buffer.rs

1//! Bounded in-memory replay buffer for the C2 restreamer (A2).
2//!
3//! Events are numbered with a monotonic `seq` per stream key (node + channel).
4//! Core ACKs the last processed seq; on reconnect it RESUMEs after N.
5//! Overflow applies backpressure to the node; gaps older than the buffer are
6//! recovered by C2 from node journals (never by core).
7
8use std::collections::{BTreeMap, VecDeque};
9use std::time::{Duration, Instant};
10
11/// Identity of one sequenced stream inside C2.
12#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct StreamKey {
14    pub node_id: String,
15    pub channel_id: u32,
16}
17
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct BufferedEvent {
20    pub seq: u64,
21    pub node_id: String,
22    pub channel_id: u32,
23    /// Opaque payload (inner ciphertext). C2 does not inspect it.
24    pub payload: Vec<u8>,
25    pub enqueued_at: Instant,
26}
27
28#[derive(Clone, Debug)]
29pub struct BufferLimits {
30    /// Max events retained across all streams.
31    pub max_events: usize,
32    /// Max total payload bytes retained.
33    pub max_bytes: usize,
34    /// Max age of an unacked event.
35    pub max_age: Duration,
36}
37
38impl Default for BufferLimits {
39    fn default() -> Self {
40        Self {
41            max_events: 4096,
42            max_bytes: 16 * 1024 * 1024,
43            max_age: Duration::from_secs(300),
44        }
45    }
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum EnqueueOutcome {
50    /// Event stored; `window_credit` remaining for the stream (0 = backpressure).
51    Accepted { window_credit: u32 },
52    /// Buffer full or age-capped; node must wait / retry from its journal.
53    Backpressure,
54}
55
56/// Per-stream sequencing + global bounded retention.
57pub struct ReplayBuffer {
58    limits: BufferLimits,
59    /// Next seq to assign per stream.
60    next_seq: BTreeMap<StreamKey, u64>,
61    /// Last ACK from core per stream (events with seq <= ack are releasable).
62    last_ack: BTreeMap<StreamKey, u64>,
63    /// Events awaiting ACK, oldest first.
64    events: VecDeque<BufferedEvent>,
65    total_bytes: usize,
66    /// Per-stream credit for WINDOW backpressure (starts at default_credit).
67    credits: BTreeMap<StreamKey, u32>,
68    default_credit: u32,
69}
70
71impl ReplayBuffer {
72    pub fn new(limits: BufferLimits) -> Self {
73        Self {
74            limits,
75            next_seq: BTreeMap::new(),
76            last_ack: BTreeMap::new(),
77            events: VecDeque::new(),
78            total_bytes: 0,
79            credits: BTreeMap::new(),
80            default_credit: 64,
81        }
82    }
83
84    pub fn with_default_credit(mut self, credit: u32) -> Self {
85        self.default_credit = credit.max(1);
86        self
87    }
88
89    pub fn len(&self) -> usize {
90        self.events.len()
91    }
92
93    pub fn is_empty(&self) -> bool {
94        self.events.is_empty()
95    }
96
97    pub fn total_bytes(&self) -> usize {
98        self.total_bytes
99    }
100
101    pub fn credit(&self, key: &StreamKey) -> u32 {
102        self.credits.get(key).copied().unwrap_or(self.default_credit)
103    }
104
105    /// A fresh OPEN / Noise handshake must not be refused because an earlier
106    /// burst spent the stream's credit. Restore default credit and drop the
107    /// oldest buffered events on this stream until a small frame fits.
108    pub fn ensure_handshake_credit(&mut self, node_id: &str, channel_id: u32) {
109        let key = StreamKey {
110            node_id: node_id.to_owned(),
111            channel_id,
112        };
113        self.credits.insert(key.clone(), self.default_credit);
114        // Free capacity for ~2 KiB handshake if the global buffer is full.
115        const NEED: usize = 2048;
116        while self.events.len() >= self.limits.max_events
117            || self.total_bytes + NEED > self.limits.max_bytes
118        {
119            let Some(idx) = self
120                .events
121                .iter()
122                .position(|ev| ev.node_id == key.node_id && ev.channel_id == key.channel_id)
123            else {
124                break;
125            };
126            if let Some(ev) = self.events.remove(idx) {
127                self.total_bytes = self.total_bytes.saturating_sub(ev.payload.len());
128            } else {
129                break;
130            }
131        }
132    }
133
134    /// Reset credit when the node (re)OPENs a channel.
135    pub fn reset_channel_credit(&mut self, node_id: &str, channel_id: u32) {
136        let key = StreamKey {
137            node_id: node_id.to_owned(),
138            channel_id,
139        };
140        self.credits.insert(key, self.default_credit);
141    }
142
143    /// Expire by age, then refuse if still over limits.
144    fn trim_expired(&mut self, now: Instant) {
145        while let Some(front) = self.events.front() {
146            if now.duration_since(front.enqueued_at) <= self.limits.max_age {
147                break;
148            }
149            if let Some(ev) = self.events.pop_front() {
150                self.total_bytes = self.total_bytes.saturating_sub(ev.payload.len());
151            }
152        }
153    }
154
155    pub fn enqueue(
156        &mut self,
157        node_id: impl Into<String>,
158        channel_id: u32,
159        payload: Vec<u8>,
160        now: Instant,
161    ) -> EnqueueOutcome {
162        self.trim_expired(now);
163        let node_id = node_id.into();
164        let key = StreamKey {
165            node_id: node_id.clone(),
166            channel_id,
167        };
168        let credit = self.credit(&key);
169        if credit == 0 {
170            return EnqueueOutcome::Backpressure;
171        }
172        let would_events = self.events.len() + 1;
173        let would_bytes = self.total_bytes + payload.len();
174        if would_events > self.limits.max_events || would_bytes > self.limits.max_bytes {
175            // Zero credit on this stream so the node sees WINDOW=0.
176            self.credits.insert(key, 0);
177            return EnqueueOutcome::Backpressure;
178        }
179        let seq = {
180            let entry = self.next_seq.entry(key.clone()).or_insert(1);
181            let seq = *entry;
182            *entry = seq + 1;
183            seq
184        };
185        self.total_bytes += payload.len();
186        self.events.push_back(BufferedEvent {
187            seq,
188            node_id,
189            channel_id,
190            payload,
191            enqueued_at: now,
192        });
193        let new_credit = credit - 1;
194        self.credits.insert(key, new_credit);
195        EnqueueOutcome::Accepted {
196            window_credit: new_credit,
197        }
198    }
199
200    /// Core ACK: drop events with seq <= ack for that stream; restore credit.
201    pub fn ack(&mut self, node_id: &str, channel_id: u32, seq: u64) {
202        let key = StreamKey {
203            node_id: node_id.to_owned(),
204            channel_id,
205        };
206        let prev = self.last_ack.get(&key).copied().unwrap_or(0);
207        if seq <= prev {
208            return;
209        }
210        self.last_ack.insert(key.clone(), seq);
211        let mut released = 0u32;
212        let mut kept = VecDeque::new();
213        while let Some(ev) = self.events.pop_front() {
214            let same = ev.node_id == key.node_id && ev.channel_id == key.channel_id;
215            if same && ev.seq <= seq {
216                self.total_bytes = self.total_bytes.saturating_sub(ev.payload.len());
217                released = released.saturating_add(1);
218            } else {
219                kept.push_back(ev);
220            }
221        }
222        self.events = kept;
223        let credit = self.credit(&key).saturating_add(released).min(self.default_credit);
224        self.credits.insert(key, credit);
225    }
226
227    /// Global ACK used when core drains a single combined stream and acks by global order.
228    /// Here we treat `seq` as the highest seq observed in the drain for a given stream key
229    /// carried in the envelope; callers should use [`ack`].
230    pub fn events_after(&self, node_id: &str, channel_id: u32, after: u64) -> Vec<BufferedEvent> {
231        self.events
232            .iter()
233            .filter(|ev| {
234                ev.node_id == node_id && ev.channel_id == channel_id && ev.seq > after
235            })
236            .cloned()
237            .collect()
238    }
239
240    /// Lowest seq still retained for the stream, if any.
241    pub fn oldest_seq(&self, node_id: &str, channel_id: u32) -> Option<u64> {
242        self.events
243            .iter()
244            .filter(|ev| ev.node_id == node_id && ev.channel_id == channel_id)
245            .map(|ev| ev.seq)
246            .min()
247    }
248
249    /// Highest seq assigned for the stream (whether or not still buffered).
250    pub fn next_seq(&self, node_id: &str, channel_id: u32) -> u64 {
251        let key = StreamKey {
252            node_id: node_id.to_owned(),
253            channel_id,
254        };
255        self.next_seq.get(&key).copied().unwrap_or(1)
256    }
257
258    /// True when RESUME(after=N) asks for a seq that has already left the buffer.
259    pub fn gap_before_buffer(&self, node_id: &str, channel_id: u32, after: u64) -> bool {
260        match self.oldest_seq(node_id, channel_id) {
261            None => {
262                // Nothing buffered: gap if core expects something already produced.
263                let next = self.next_seq(node_id, channel_id);
264                after + 1 < next
265            }
266            Some(oldest) => after + 1 < oldest,
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    fn limits_tiny() -> BufferLimits {
276        BufferLimits {
277            max_events: 3,
278            max_bytes: 1024,
279            max_age: Duration::from_secs(60),
280        }
281    }
282
283    #[test]
284    fn assigns_monotonic_seq_and_acks() {
285        let mut buf = ReplayBuffer::new(limits_tiny()).with_default_credit(8);
286        let now = Instant::now();
287        assert!(matches!(
288            buf.enqueue("n1", 1, b"a".to_vec(), now),
289            EnqueueOutcome::Accepted { window_credit: 7 }
290        ));
291        assert!(matches!(
292            buf.enqueue("n1", 1, b"b".to_vec(), now),
293            EnqueueOutcome::Accepted { .. }
294        ));
295        let after0 = buf.events_after("n1", 1, 0);
296        assert_eq!(after0.len(), 2);
297        assert_eq!(after0[0].seq, 1);
298        assert_eq!(after0[1].seq, 2);
299        buf.ack("n1", 1, 1);
300        let after1 = buf.events_after("n1", 1, 0);
301        assert_eq!(after1.len(), 1);
302        assert_eq!(after1[0].seq, 2);
303    }
304
305    #[test]
306    fn overflow_signals_backpressure() {
307        let mut buf = ReplayBuffer::new(limits_tiny()).with_default_credit(8);
308        let now = Instant::now();
309        for i in 0..3 {
310            assert!(
311                matches!(
312                    buf.enqueue("n1", 1, vec![i], now),
313                    EnqueueOutcome::Accepted { .. }
314                ),
315                "event {i}"
316            );
317        }
318        assert_eq!(
319            buf.enqueue("n1", 1, vec![9], now),
320            EnqueueOutcome::Backpressure
321        );
322        assert_eq!(buf.credit(&StreamKey { node_id: "n1".into(), channel_id: 1 }), 0);
323    }
324
325    #[test]
326    fn resume_gap_detected_when_acked_out() {
327        let mut buf = ReplayBuffer::new(BufferLimits {
328            max_events: 10,
329            max_bytes: 4096,
330            max_age: Duration::from_secs(60),
331        })
332        .with_default_credit(8);
333        let now = Instant::now();
334        for _ in 0..5 {
335            buf.enqueue("n1", 1, b"x".to_vec(), now);
336        }
337        buf.ack("n1", 1, 3);
338        // oldest retained is 4; RESUME(after=1) needs seq 2 which is gone.
339        assert!(buf.gap_before_buffer("n1", 1, 1));
340        assert!(!buf.gap_before_buffer("n1", 1, 3));
341        let replay = buf.events_after("n1", 1, 3);
342        assert_eq!(replay.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![4, 5]);
343    }
344
345    #[test]
346    fn age_expiry_drops_old_events() {
347        let mut buf = ReplayBuffer::new(BufferLimits {
348            max_events: 10,
349            max_bytes: 4096,
350            max_age: Duration::from_millis(5),
351        });
352        let past = Instant::now() - Duration::from_millis(50);
353        buf.enqueue("n1", 1, b"old".to_vec(), past);
354        assert_eq!(buf.len(), 1);
355        // enqueue triggers trim
356        let outcome = buf.enqueue("n1", 1, b"new".to_vec(), Instant::now());
357        assert!(matches!(outcome, EnqueueOutcome::Accepted { .. }));
358        assert_eq!(buf.len(), 1);
359        assert_eq!(buf.events_after("n1", 1, 0)[0].payload, b"new");
360    }
361
362    #[test]
363    fn reset_channel_credit_restores_after_exhaustion() {
364        let mut buf = ReplayBuffer::new(BufferLimits {
365            max_events: 64,
366            max_bytes: 64 * 1024,
367            max_age: Duration::from_secs(60),
368        })
369        .with_default_credit(2);
370        let now = Instant::now();
371        assert!(matches!(buf.enqueue("n1", 7, b"a".to_vec(), now), EnqueueOutcome::Accepted { .. }));
372        assert!(matches!(buf.enqueue("n1", 7, b"b".to_vec(), now), EnqueueOutcome::Accepted { .. }));
373        assert!(matches!(buf.enqueue("n1", 7, b"c".to_vec(), now), EnqueueOutcome::Backpressure));
374        assert_eq!(buf.credit(&StreamKey { node_id: "n1".into(), channel_id: 7 }), 0);
375        // Re-OPEN must restore credit; otherwise the node waits forever for WINDOW > 0.
376        buf.reset_channel_credit("n1", 7);
377        assert_eq!(buf.credit(&StreamKey { node_id: "n1".into(), channel_id: 7 }), 2);
378        assert!(matches!(buf.enqueue("n1", 7, b"d".to_vec(), now), EnqueueOutcome::Accepted { .. }));
379    }
380
381    /// Fifty independent channel credits: each OPEN-reset must isolate its own
382    /// stream so parallel PTY/mail channels never share a spent credit bucket.
383    #[test]
384    fn reset_channel_credit_is_per_channel_under_parallel_opens() {
385        let mut buf = ReplayBuffer::new(BufferLimits {
386            max_events: 256,
387            max_bytes: 256 * 1024,
388            max_age: Duration::from_secs(60),
389        })
390        .with_default_credit(4);
391        let now = Instant::now();
392        for ch in 1u32..=50 {
393            for _ in 0..4 {
394                assert!(matches!(
395                    buf.enqueue("box", ch, b"x".to_vec(), now),
396                    EnqueueOutcome::Accepted { .. }
397                ));
398            }
399            assert_eq!(buf.credit(&StreamKey { node_id: "box".into(), channel_id: ch }), 0);
400        }
401        for ch in 1u32..=50 {
402            buf.reset_channel_credit("box", ch);
403            assert_eq!(buf.credit(&StreamKey { node_id: "box".into(), channel_id: ch }), 4);
404        }
405        // One channel's reset must not revive another's spent credit without its own reset.
406        for _ in 0..4 {
407            let _ = buf.enqueue("box", 1, b"y".to_vec(), now);
408        }
409        assert_eq!(buf.credit(&StreamKey { node_id: "box".into(), channel_id: 1 }), 0);
410        assert_eq!(buf.credit(&StreamKey { node_id: "box".into(), channel_id: 2 }), 4);
411    }
412}