Skip to main content

proxima_core/
batch.rs

1//! Batched terminal writes: accumulate many encoded items into one buffer, then
2//! flush the whole accumulation to a sink in a SINGLE write. The discipline that
3//! turns N per-item writes into one — the expensive terminal op (a syscall, a
4//! DMA submit) is paid once per batch instead of per item, so a producer is
5//! never stalled per-item on a slow terminal.
6//!
7//! [`BatchSink`] is the no_std-clean terminal abstraction: it does NOT name
8//! `std::io::Write` (which is std-only), so embedded / UART / DPDK / NVMe
9//! terminals can implement it. A `std::io::Write` adapter lives in the consumer
10//! crate (e.g. proxima-telemetry wraps a writer as a `BatchSink`) because the
11//! orphan rule forbids a blanket `impl BatchSink for W: io::Write` here.
12//!
13//! [`BatchBuffer`] accumulates encoded bytes — `push_str` for formatted text,
14//! `push_bytes` for binary, or `core::fmt::Write` for `write!` — and
15//! [`flush_to`](BatchBuffer::flush_to) hands the whole accumulation to a
16//! [`BatchSink`] in one call, then clears for reuse.
17//!
18//! This is the primitive proxima-telemetry's terminal sink composes: per record
19//! it appends a formatted line; per drain batch it flushes once.
20
21use alloc::vec::Vec;
22
23/// A terminal that accepts an entire batch of bytes in one operation.
24///
25/// no_std-clean by construction — no `std::io::Write`. The consumer adapts a
26/// concrete writer (a file, a socket, an egress ring) to this trait; the
27/// batching machinery above it (`BatchBuffer`) never names the terminal's kind.
28pub trait BatchSink {
29    /// Terminal-specific failure (an `io::Error`, a device error, ...).
30    type Error;
31
32    /// Write the whole batch in one terminal operation.
33    fn write_batch(&mut self, bytes: &[u8]) -> Result<(), Self::Error>;
34}
35
36/// Accumulates encoded bytes and flushes them to a [`BatchSink`] in one write.
37///
38/// Append with [`push_str`](Self::push_str) / [`push_bytes`](Self::push_bytes),
39/// or via `write!` (it implements [`core::fmt::Write`]); then
40/// [`flush_to`](Self::flush_to) writes the accumulation in a single
41/// [`BatchSink::write_batch`] and clears for reuse.
42#[derive(Debug, Default)]
43pub struct BatchBuffer {
44    bytes: Vec<u8>,
45}
46
47impl BatchBuffer {
48    #[must_use]
49    pub fn new() -> Self {
50        Self { bytes: Vec::new() }
51    }
52
53    /// Preallocate `capacity` bytes — size it to a batch's typical total so the
54    /// accumulation does not grow mid-batch.
55    #[must_use]
56    pub fn with_capacity(capacity: usize) -> Self {
57        Self {
58            bytes: Vec::with_capacity(capacity),
59        }
60    }
61
62    /// Append a UTF-8 string — the common case (a formatted log/metric line).
63    pub fn push_str(&mut self, text: &str) {
64        self.bytes.extend_from_slice(text.as_bytes());
65    }
66
67    /// Append raw bytes — for binary wire formats.
68    pub fn push_bytes(&mut self, bytes: &[u8]) {
69        self.bytes.extend_from_slice(bytes);
70    }
71
72    #[must_use]
73    pub fn is_empty(&self) -> bool {
74        self.bytes.is_empty()
75    }
76
77    #[must_use]
78    pub fn len(&self) -> usize {
79        self.bytes.len()
80    }
81
82    /// Bytes reserved in the backing allocation (>= the `with_capacity` request).
83    /// A batch that stays under this never reallocates mid-accumulation.
84    #[must_use]
85    pub fn capacity(&self) -> usize {
86        self.bytes.capacity()
87    }
88
89    #[must_use]
90    pub fn as_bytes(&self) -> &[u8] {
91        &self.bytes
92    }
93
94    /// Drop the accumulation without flushing (retains the backing allocation).
95    pub fn clear(&mut self) {
96        self.bytes.clear();
97    }
98
99    /// Flush the whole accumulation to `sink` in ONE [`BatchSink::write_batch`],
100    /// then clear for reuse. No-op (and no terminal write) when empty.
101    pub fn flush_to<S: BatchSink>(&mut self, sink: &mut S) -> Result<(), S::Error> {
102        if self.bytes.is_empty() {
103            return Ok(());
104        }
105        sink.write_batch(&self.bytes)?;
106        self.bytes.clear();
107        Ok(())
108    }
109}
110
111impl core::fmt::Write for BatchBuffer {
112    fn write_str(&mut self, text: &str) -> core::fmt::Result {
113        self.push_str(text);
114        Ok(())
115    }
116}
117
118/// Adapts any [`std::io::Write`] to a [`BatchSink`], mapping a whole batch to one
119/// `write_all`. Lives here (behind `std`) rather than in each consumer: `BatchSink`
120/// is defined in this crate, so implementing it for a wrapper is allowed — the
121/// orphan rule only blocks a *third* crate from writing this impl. Std consumers
122/// (a telemetry terminal sink, a socket sink, ...) wrap their writer in
123/// `WriteSink` instead of re-deriving the bridge.
124#[cfg(feature = "std")]
125pub struct WriteSink<W>(pub W);
126
127#[cfg(feature = "std")]
128impl<W: std::io::Write> BatchSink for WriteSink<W> {
129    type Error = std::io::Error;
130    fn write_batch(&mut self, bytes: &[u8]) -> Result<(), std::io::Error> {
131        self.0.write_all(bytes)
132    }
133}
134
135#[cfg(test)]
136#[allow(clippy::unwrap_used, clippy::expect_used)]
137mod tests {
138    use super::*;
139    use alloc::vec::Vec;
140    use core::convert::Infallible;
141    use core::fmt::Write as _;
142
143    // A sink that records every write_batch call so a test can prove the batch
144    // left the buffer in exactly one terminal write — the whole point.
145    #[derive(Default)]
146    struct RecordingSink {
147        writes: usize,
148        bytes: Vec<u8>,
149    }
150
151    impl BatchSink for RecordingSink {
152        type Error = Infallible;
153        fn write_batch(&mut self, bytes: &[u8]) -> Result<(), Infallible> {
154            self.writes += 1;
155            self.bytes.extend_from_slice(bytes);
156            Ok(())
157        }
158    }
159
160    // The batching contract: many appends, ONE write_batch carrying the
161    // concatenation, buffer cleared after.
162    #[test]
163    fn many_appends_flush_in_one_write() {
164        let mut buffer = BatchBuffer::new();
165        buffer.push_str("alpha\n");
166        buffer.push_str("beta\n");
167        buffer.push_str("gamma\n");
168
169        let mut sink = RecordingSink::default();
170        buffer.flush_to(&mut sink).unwrap();
171
172        assert_eq!(sink.writes, 1, "three appends flush in a single write");
173        assert_eq!(&sink.bytes, b"alpha\nbeta\ngamma\n");
174        assert!(buffer.is_empty(), "buffer cleared after flush");
175    }
176
177    // Empty flush is a no-op — no spurious zero-length terminal write.
178    #[test]
179    fn empty_flush_does_not_write() {
180        let mut buffer = BatchBuffer::new();
181        let mut sink = RecordingSink::default();
182        buffer.flush_to(&mut sink).unwrap();
183        assert_eq!(sink.writes, 0, "no write for an empty batch");
184    }
185
186    // Reuse: a second batch after a flush starts clean and flushes once more.
187    #[test]
188    fn buffer_reuses_after_flush() {
189        let mut buffer = BatchBuffer::with_capacity(64);
190        let mut sink = RecordingSink::default();
191
192        buffer.push_str("first\n");
193        buffer.flush_to(&mut sink).unwrap();
194        buffer.push_str("second\n");
195        buffer.flush_to(&mut sink).unwrap();
196
197        assert_eq!(sink.writes, 2, "one write per batch");
198        assert_eq!(&sink.bytes, b"first\nsecond\n");
199    }
200
201    // push_bytes carries arbitrary (non-UTF-8) bytes for binary terminals.
202    #[test]
203    fn push_bytes_carries_raw_bytes() {
204        let mut buffer = BatchBuffer::new();
205        buffer.push_bytes(&[0xDE, 0xAD, 0xBE, 0xEF]);
206        let mut sink = RecordingSink::default();
207        buffer.flush_to(&mut sink).unwrap();
208        assert_eq!(&sink.bytes, &[0xDE, 0xAD, 0xBE, 0xEF]);
209    }
210
211    // with_capacity reserves up front so a batch under that size never
212    // reallocates mid-accumulation (the sized-floor preallocation).
213    #[test]
214    fn with_capacity_preallocates() {
215        let buffer = BatchBuffer::with_capacity(8192);
216        assert!(buffer.capacity() >= 8192, "reserves the requested bytes");
217        assert!(buffer.is_empty(), "reserved but holds nothing yet");
218    }
219
220    // the std WriteSink adapter turns any io::Write into a BatchSink — many
221    // appends land in one write_all on the underlying writer.
222    #[cfg(feature = "std")]
223    #[test]
224    fn write_sink_adapts_io_write() {
225        let mut out: Vec<u8> = Vec::new();
226        let mut buffer = BatchBuffer::new();
227        buffer.push_str("alpha\n");
228        buffer.push_str("beta\n");
229        buffer.flush_to(&mut WriteSink(&mut out)).unwrap();
230        assert_eq!(&out, b"alpha\nbeta\n");
231    }
232
233    // core::fmt::Write lets a caller format straight into the batch.
234    #[test]
235    fn fmt_write_appends_formatted_output() {
236        let mut buffer = BatchBuffer::new();
237        let seq = 12_345;
238        let peer = "10.0.0.1:443";
239        writeln!(buffer, "seq={seq} peer={peer}").unwrap();
240        let mut sink = RecordingSink::default();
241        buffer.flush_to(&mut sink).unwrap();
242        assert_eq!(&sink.bytes, b"seq=12345 peer=10.0.0.1:443\n");
243    }
244}