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 until a small frame fits — prefer this stream,
108    /// then any stream (Handshake1 must not die behind unrelated backpressure).
109    pub fn ensure_handshake_credit(&mut self, node_id: &str, channel_id: u32) {
110        let key = StreamKey {
111            node_id: node_id.to_owned(),
112            channel_id,
113        };
114        self.credits.insert(key.clone(), self.default_credit);
115        // Free capacity for ~2 KiB handshake if the global buffer is full.
116        const NEED: usize = 2048;
117        while self.events.len() >= self.limits.max_events
118            || self.total_bytes + NEED > self.limits.max_bytes
119        {
120            let idx = self
121                .events
122                .iter()
123                .position(|ev| ev.node_id == key.node_id && ev.channel_id == key.channel_id)
124                .or_else(|| if self.events.is_empty() { None } else { Some(0) });
125            let Some(idx) = idx else {
126                break;
127            };
128            if let Some(ev) = self.events.remove(idx) {
129                self.total_bytes = self.total_bytes.saturating_sub(ev.payload.len());
130            } else {
131                break;
132            }
133        }
134    }
135
136    /// Reset credit when the node (re)OPENs a channel.
137    pub fn reset_channel_credit(&mut self, node_id: &str, channel_id: u32) {
138        let key = StreamKey {
139            node_id: node_id.to_owned(),
140            channel_id,
141        };
142        self.credits.insert(key, self.default_credit);
143    }
144
145    /// Expire by age, then refuse if still over limits.
146    fn trim_expired(&mut self, now: Instant) {
147        while let Some(front) = self.events.front() {
148            if now.duration_since(front.enqueued_at) <= self.limits.max_age {
149                break;
150            }
151            if let Some(ev) = self.events.pop_front() {
152                self.total_bytes = self.total_bytes.saturating_sub(ev.payload.len());
153            }
154        }
155    }
156
157    pub fn enqueue(
158        &mut self,
159        node_id: impl Into<String>,
160        channel_id: u32,
161        payload: Vec<u8>,
162        now: Instant,
163    ) -> EnqueueOutcome {
164        self.trim_expired(now);
165        let node_id = node_id.into();
166        let key = StreamKey {
167            node_id: node_id.clone(),
168            channel_id,
169        };
170        let credit = self.credit(&key);
171        if credit == 0 {
172            return EnqueueOutcome::Backpressure;
173        }
174        let would_events = self.events.len() + 1;
175        let would_bytes = self.total_bytes + payload.len();
176        if would_events > self.limits.max_events || would_bytes > self.limits.max_bytes {
177            // Zero credit on this stream so the node sees WINDOW=0.
178            self.credits.insert(key, 0);
179            return EnqueueOutcome::Backpressure;
180        }
181        let seq = {
182            let entry = self.next_seq.entry(key.clone()).or_insert(1);
183            let seq = *entry;
184            *entry = seq + 1;
185            seq
186        };
187        self.total_bytes += payload.len();
188        self.events.push_back(BufferedEvent {
189            seq,
190            node_id,
191            channel_id,
192            payload,
193            enqueued_at: now,
194        });
195        let new_credit = credit - 1;
196        self.credits.insert(key, new_credit);
197        EnqueueOutcome::Accepted {
198            window_credit: new_credit,
199        }
200    }
201
202    /// Core ACK: drop events with seq <= ack for that stream; restore credit.
203    pub fn ack(&mut self, node_id: &str, channel_id: u32, seq: u64) {
204        let key = StreamKey {
205            node_id: node_id.to_owned(),
206            channel_id,
207        };
208        let prev = self.last_ack.get(&key).copied().unwrap_or(0);
209        if seq <= prev {
210            return;
211        }
212        self.last_ack.insert(key.clone(), seq);
213        let mut released = 0u32;
214        let mut kept = VecDeque::new();
215        while let Some(ev) = self.events.pop_front() {
216            let same = ev.node_id == key.node_id && ev.channel_id == key.channel_id;
217            if same && ev.seq <= seq {
218                self.total_bytes = self.total_bytes.saturating_sub(ev.payload.len());
219                released = released.saturating_add(1);
220            } else {
221                kept.push_back(ev);
222            }
223        }
224        self.events = kept;
225        let credit = self.credit(&key).saturating_add(released).min(self.default_credit);
226        self.credits.insert(key, credit);
227    }
228
229    /// Global ACK used when core drains a single combined stream and acks by global order.
230    /// Here we treat `seq` as the highest seq observed in the drain for a given stream key
231    /// carried in the envelope; callers should use [`ack`].
232    pub fn events_after(&self, node_id: &str, channel_id: u32, after: u64) -> Vec<BufferedEvent> {
233        self.events
234            .iter()
235            .filter(|ev| {
236                ev.node_id == node_id && ev.channel_id == channel_id && ev.seq > after
237            })
238            .cloned()
239            .collect()
240    }
241
242    /// Lowest seq still retained for the stream, if any.
243    pub fn oldest_seq(&self, node_id: &str, channel_id: u32) -> Option<u64> {
244        self.events
245            .iter()
246            .filter(|ev| ev.node_id == node_id && ev.channel_id == channel_id)
247            .map(|ev| ev.seq)
248            .min()
249    }
250
251    /// Highest seq assigned for the stream (whether or not still buffered).
252    pub fn next_seq(&self, node_id: &str, channel_id: u32) -> u64 {
253        let key = StreamKey {
254            node_id: node_id.to_owned(),
255            channel_id,
256        };
257        self.next_seq.get(&key).copied().unwrap_or(1)
258    }
259
260    /// True when RESUME(after=N) asks for a seq that has already left the buffer.
261    pub fn gap_before_buffer(&self, node_id: &str, channel_id: u32, after: u64) -> bool {
262        match self.oldest_seq(node_id, channel_id) {
263            None => {
264                // Nothing buffered: gap if core expects something already produced.
265                let next = self.next_seq(node_id, channel_id);
266                after + 1 < next
267            }
268            Some(oldest) => after + 1 < oldest,
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    fn limits_tiny() -> BufferLimits {
278        BufferLimits {
279            max_events: 3,
280            max_bytes: 1024,
281            max_age: Duration::from_secs(60),
282        }
283    }
284
285    #[test]
286    fn assigns_monotonic_seq_and_acks() {
287        let mut buf = ReplayBuffer::new(limits_tiny()).with_default_credit(8);
288        let now = Instant::now();
289        assert!(matches!(
290            buf.enqueue("n1", 1, b"a".to_vec(), now),
291            EnqueueOutcome::Accepted { window_credit: 7 }
292        ));
293        assert!(matches!(
294            buf.enqueue("n1", 1, b"b".to_vec(), now),
295            EnqueueOutcome::Accepted { .. }
296        ));
297        let after0 = buf.events_after("n1", 1, 0);
298        assert_eq!(after0.len(), 2);
299        assert_eq!(after0[0].seq, 1);
300        assert_eq!(after0[1].seq, 2);
301        buf.ack("n1", 1, 1);
302        let after1 = buf.events_after("n1", 1, 0);
303        assert_eq!(after1.len(), 1);
304        assert_eq!(after1[0].seq, 2);
305    }
306
307    #[test]
308    fn overflow_signals_backpressure() {
309        let mut buf = ReplayBuffer::new(limits_tiny()).with_default_credit(8);
310        let now = Instant::now();
311        for i in 0..3 {
312            assert!(
313                matches!(
314                    buf.enqueue("n1", 1, vec![i], now),
315                    EnqueueOutcome::Accepted { .. }
316                ),
317                "event {i}"
318            );
319        }
320        assert_eq!(
321            buf.enqueue("n1", 1, vec![9], now),
322            EnqueueOutcome::Backpressure
323        );
324        assert_eq!(buf.credit(&StreamKey { node_id: "n1".into(), channel_id: 1 }), 0);
325    }
326
327    #[test]
328    fn resume_gap_detected_when_acked_out() {
329        let mut buf = ReplayBuffer::new(BufferLimits {
330            max_events: 10,
331            max_bytes: 4096,
332            max_age: Duration::from_secs(60),
333        })
334        .with_default_credit(8);
335        let now = Instant::now();
336        for _ in 0..5 {
337            buf.enqueue("n1", 1, b"x".to_vec(), now);
338        }
339        buf.ack("n1", 1, 3);
340        // oldest retained is 4; RESUME(after=1) needs seq 2 which is gone.
341        assert!(buf.gap_before_buffer("n1", 1, 1));
342        assert!(!buf.gap_before_buffer("n1", 1, 3));
343        let replay = buf.events_after("n1", 1, 3);
344        assert_eq!(replay.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![4, 5]);
345    }
346
347    #[test]
348    fn age_expiry_drops_old_events() {
349        let mut buf = ReplayBuffer::new(BufferLimits {
350            max_events: 10,
351            max_bytes: 4096,
352            max_age: Duration::from_millis(5),
353        });
354        let past = Instant::now() - Duration::from_millis(50);
355        buf.enqueue("n1", 1, b"old".to_vec(), past);
356        assert_eq!(buf.len(), 1);
357        // enqueue triggers trim
358        let outcome = buf.enqueue("n1", 1, b"new".to_vec(), Instant::now());
359        assert!(matches!(outcome, EnqueueOutcome::Accepted { .. }));
360        assert_eq!(buf.len(), 1);
361        assert_eq!(buf.events_after("n1", 1, 0)[0].payload, b"new");
362    }
363
364    #[test]
365    fn reset_channel_credit_restores_after_exhaustion() {
366        let mut buf = ReplayBuffer::new(BufferLimits {
367            max_events: 64,
368            max_bytes: 64 * 1024,
369            max_age: Duration::from_secs(60),
370        })
371        .with_default_credit(2);
372        let now = Instant::now();
373        assert!(matches!(buf.enqueue("n1", 7, b"a".to_vec(), now), EnqueueOutcome::Accepted { .. }));
374        assert!(matches!(buf.enqueue("n1", 7, b"b".to_vec(), now), EnqueueOutcome::Accepted { .. }));
375        assert!(matches!(buf.enqueue("n1", 7, b"c".to_vec(), now), EnqueueOutcome::Backpressure));
376        assert_eq!(buf.credit(&StreamKey { node_id: "n1".into(), channel_id: 7 }), 0);
377        // Re-OPEN must restore credit; otherwise the node waits forever for WINDOW > 0.
378        buf.reset_channel_credit("n1", 7);
379        assert_eq!(buf.credit(&StreamKey { node_id: "n1".into(), channel_id: 7 }), 2);
380        assert!(matches!(buf.enqueue("n1", 7, b"d".to_vec(), now), EnqueueOutcome::Accepted { .. }));
381    }
382
383    /// Fifty independent channel credits: each OPEN-reset must isolate its own
384    /// stream so parallel PTY/mail channels never share a spent credit bucket.
385    #[test]
386    fn reset_channel_credit_is_per_channel_under_parallel_opens() {
387        let mut buf = ReplayBuffer::new(BufferLimits {
388            max_events: 256,
389            max_bytes: 256 * 1024,
390            max_age: Duration::from_secs(60),
391        })
392        .with_default_credit(4);
393        let now = Instant::now();
394        for ch in 1u32..=50 {
395            for _ in 0..4 {
396                assert!(matches!(
397                    buf.enqueue("box", ch, b"x".to_vec(), now),
398                    EnqueueOutcome::Accepted { .. }
399                ));
400            }
401            assert_eq!(buf.credit(&StreamKey { node_id: "box".into(), channel_id: ch }), 0);
402        }
403        for ch in 1u32..=50 {
404            buf.reset_channel_credit("box", ch);
405            assert_eq!(buf.credit(&StreamKey { node_id: "box".into(), channel_id: ch }), 4);
406        }
407        // One channel's reset must not revive another's spent credit without its own reset.
408        for _ in 0..4 {
409            let _ = buf.enqueue("box", 1, b"y".to_vec(), now);
410        }
411        assert_eq!(buf.credit(&StreamKey { node_id: "box".into(), channel_id: 1 }), 0);
412        assert_eq!(buf.credit(&StreamKey { node_id: "box".into(), channel_id: 2 }), 4);
413    }
414}