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}