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    /// Expire by age, then refuse if still over limits.
106    fn trim_expired(&mut self, now: Instant) {
107        while let Some(front) = self.events.front() {
108            if now.duration_since(front.enqueued_at) <= self.limits.max_age {
109                break;
110            }
111            if let Some(ev) = self.events.pop_front() {
112                self.total_bytes = self.total_bytes.saturating_sub(ev.payload.len());
113            }
114        }
115    }
116
117    pub fn enqueue(
118        &mut self,
119        node_id: impl Into<String>,
120        channel_id: u32,
121        payload: Vec<u8>,
122        now: Instant,
123    ) -> EnqueueOutcome {
124        self.trim_expired(now);
125        let node_id = node_id.into();
126        let key = StreamKey {
127            node_id: node_id.clone(),
128            channel_id,
129        };
130        let credit = self.credit(&key);
131        if credit == 0 {
132            return EnqueueOutcome::Backpressure;
133        }
134        let would_events = self.events.len() + 1;
135        let would_bytes = self.total_bytes + payload.len();
136        if would_events > self.limits.max_events || would_bytes > self.limits.max_bytes {
137            // Zero credit on this stream so the node sees WINDOW=0.
138            self.credits.insert(key, 0);
139            return EnqueueOutcome::Backpressure;
140        }
141        let seq = {
142            let entry = self.next_seq.entry(key.clone()).or_insert(1);
143            let seq = *entry;
144            *entry = seq + 1;
145            seq
146        };
147        self.total_bytes += payload.len();
148        self.events.push_back(BufferedEvent {
149            seq,
150            node_id,
151            channel_id,
152            payload,
153            enqueued_at: now,
154        });
155        let new_credit = credit - 1;
156        self.credits.insert(key, new_credit);
157        EnqueueOutcome::Accepted {
158            window_credit: new_credit,
159        }
160    }
161
162    /// Core ACK: drop events with seq <= ack for that stream; restore credit.
163    pub fn ack(&mut self, node_id: &str, channel_id: u32, seq: u64) {
164        let key = StreamKey {
165            node_id: node_id.to_owned(),
166            channel_id,
167        };
168        let prev = self.last_ack.get(&key).copied().unwrap_or(0);
169        if seq <= prev {
170            return;
171        }
172        self.last_ack.insert(key.clone(), seq);
173        let mut released = 0u32;
174        let mut kept = VecDeque::new();
175        while let Some(ev) = self.events.pop_front() {
176            let same = ev.node_id == key.node_id && ev.channel_id == key.channel_id;
177            if same && ev.seq <= seq {
178                self.total_bytes = self.total_bytes.saturating_sub(ev.payload.len());
179                released = released.saturating_add(1);
180            } else {
181                kept.push_back(ev);
182            }
183        }
184        self.events = kept;
185        let credit = self.credit(&key).saturating_add(released).min(self.default_credit);
186        self.credits.insert(key, credit);
187    }
188
189    /// Global ACK used when core drains a single combined stream and acks by global order.
190    /// Here we treat `seq` as the highest seq observed in the drain for a given stream key
191    /// carried in the envelope; callers should use [`ack`].
192    pub fn events_after(&self, node_id: &str, channel_id: u32, after: u64) -> Vec<BufferedEvent> {
193        self.events
194            .iter()
195            .filter(|ev| {
196                ev.node_id == node_id && ev.channel_id == channel_id && ev.seq > after
197            })
198            .cloned()
199            .collect()
200    }
201
202    /// Lowest seq still retained for the stream, if any.
203    pub fn oldest_seq(&self, node_id: &str, channel_id: u32) -> Option<u64> {
204        self.events
205            .iter()
206            .filter(|ev| ev.node_id == node_id && ev.channel_id == channel_id)
207            .map(|ev| ev.seq)
208            .min()
209    }
210
211    /// Highest seq assigned for the stream (whether or not still buffered).
212    pub fn next_seq(&self, node_id: &str, channel_id: u32) -> u64 {
213        let key = StreamKey {
214            node_id: node_id.to_owned(),
215            channel_id,
216        };
217        self.next_seq.get(&key).copied().unwrap_or(1)
218    }
219
220    /// True when RESUME(after=N) asks for a seq that has already left the buffer.
221    pub fn gap_before_buffer(&self, node_id: &str, channel_id: u32, after: u64) -> bool {
222        match self.oldest_seq(node_id, channel_id) {
223            None => {
224                // Nothing buffered: gap if core expects something already produced.
225                let next = self.next_seq(node_id, channel_id);
226                after + 1 < next
227            }
228            Some(oldest) => after + 1 < oldest,
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    fn limits_tiny() -> BufferLimits {
238        BufferLimits {
239            max_events: 3,
240            max_bytes: 1024,
241            max_age: Duration::from_secs(60),
242        }
243    }
244
245    #[test]
246    fn assigns_monotonic_seq_and_acks() {
247        let mut buf = ReplayBuffer::new(limits_tiny()).with_default_credit(8);
248        let now = Instant::now();
249        assert!(matches!(
250            buf.enqueue("n1", 1, b"a".to_vec(), now),
251            EnqueueOutcome::Accepted { window_credit: 7 }
252        ));
253        assert!(matches!(
254            buf.enqueue("n1", 1, b"b".to_vec(), now),
255            EnqueueOutcome::Accepted { .. }
256        ));
257        let after0 = buf.events_after("n1", 1, 0);
258        assert_eq!(after0.len(), 2);
259        assert_eq!(after0[0].seq, 1);
260        assert_eq!(after0[1].seq, 2);
261        buf.ack("n1", 1, 1);
262        let after1 = buf.events_after("n1", 1, 0);
263        assert_eq!(after1.len(), 1);
264        assert_eq!(after1[0].seq, 2);
265    }
266
267    #[test]
268    fn overflow_signals_backpressure() {
269        let mut buf = ReplayBuffer::new(limits_tiny()).with_default_credit(8);
270        let now = Instant::now();
271        for i in 0..3 {
272            assert!(
273                matches!(
274                    buf.enqueue("n1", 1, vec![i], now),
275                    EnqueueOutcome::Accepted { .. }
276                ),
277                "event {i}"
278            );
279        }
280        assert_eq!(
281            buf.enqueue("n1", 1, vec![9], now),
282            EnqueueOutcome::Backpressure
283        );
284        assert_eq!(buf.credit(&StreamKey { node_id: "n1".into(), channel_id: 1 }), 0);
285    }
286
287    #[test]
288    fn resume_gap_detected_when_acked_out() {
289        let mut buf = ReplayBuffer::new(BufferLimits {
290            max_events: 10,
291            max_bytes: 4096,
292            max_age: Duration::from_secs(60),
293        })
294        .with_default_credit(8);
295        let now = Instant::now();
296        for _ in 0..5 {
297            buf.enqueue("n1", 1, b"x".to_vec(), now);
298        }
299        buf.ack("n1", 1, 3);
300        // oldest retained is 4; RESUME(after=1) needs seq 2 which is gone.
301        assert!(buf.gap_before_buffer("n1", 1, 1));
302        assert!(!buf.gap_before_buffer("n1", 1, 3));
303        let replay = buf.events_after("n1", 1, 3);
304        assert_eq!(replay.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![4, 5]);
305    }
306
307    #[test]
308    fn age_expiry_drops_old_events() {
309        let mut buf = ReplayBuffer::new(BufferLimits {
310            max_events: 10,
311            max_bytes: 4096,
312            max_age: Duration::from_millis(5),
313        });
314        let past = Instant::now() - Duration::from_millis(50);
315        buf.enqueue("n1", 1, b"old".to_vec(), past);
316        assert_eq!(buf.len(), 1);
317        // enqueue triggers trim
318        let outcome = buf.enqueue("n1", 1, b"new".to_vec(), Instant::now());
319        assert!(matches!(outcome, EnqueueOutcome::Accepted { .. }));
320        assert_eq!(buf.len(), 1);
321        assert_eq!(buf.events_after("n1", 1, 0)[0].payload, b"new");
322    }
323}