Skip to main content

spate_core/framing/
mod.rs

1//! Streaming record framing: the seam that cuts a decoded byte stream into
2//! records.
3//!
4//! Framing is a **transport- and format-agnostic seam**. Any streaming or pull
5//! source (object-storage backfills in `spate-s3`, HTTP chunked bodies,
6//! WebSocket message streams, file tails) must turn a byte stream into records
7//! before a [`Deserializer`](crate::deser::Deserializer) decodes each one.
8//! That "one payload → one record" split lets a single decoder serve every
9//! source; the framer decides what one payload *is*.
10//!
11//! `spate-core` owns the seam here (the [`RecordFramer`] trait, the
12//! [`FramerWriter`] decompressor shim, and the [`FramingContract`] handshake).
13//! The concrete framers are owned by the **format** crates, because how a byte
14//! stream splits into records is a property of the format rather than the
15//! transport. `spate-json` provides newline-delimited framing for NDJSON, a
16//! future `spate-avro` an object-container block framer, and so on. A source
17//! stays format-agnostic and receives its framer at pipeline-assembly time
18//! (e.g. `S3Source::with_framer`), so the same framer serves every streaming
19//! source and no source hard-codes a format.
20//!
21//! A [`RecordFramer`] is fed decoded bytes in arbitrary chunks and yields
22//! completed record byte-slices. It **must be a pure function of the byte
23//! stream**, independent of how the bytes are chunked, so a source that
24//! resumes by record index replays deterministically; at-least-once resume
25//! depends on it (see the `spate-s3` offset model). Compression is *not* part
26//! of framing. A source that decompresses wraps its decompressor around a
27//! [`FramerWriter`], so the framer only ever sees already-decoded bytes.
28//!
29//! Dispatch through `Box<dyn RecordFramer>` happens once per fed chunk, never
30//! per record, so each impl's per-record scan stays monomorphized.
31
32use std::io::{self, Write};
33
34/// What one payload emitted by a source represents, so the framework can pair
35/// a source with a deserializer without the two being coordinated by hand.
36///
37/// - [`PerRecord`](FramingContract::PerRecord): the source already framed one
38///   record per payload, having run a [`RecordFramer`] over its byte stream
39///   (e.g. an `spate-s3` backfill framing each object with the format's
40///   framer). The deserializer must decode a *single* unit; a deserializer
41///   configured to *also* frame the payload is a double-framing error.
42/// - [`WholePayload`](FramingContract::WholePayload): the source emits whole
43///   payloads and the deserializer owns framing (e.g. a Kafka message, which
44///   may carry one record or many). This is the default for sources that do
45///   not frame.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum FramingContract {
49    /// One record per payload; the deserializer decodes a single unit.
50    PerRecord,
51    /// Whole payloads; the deserializer frames them.
52    WholePayload,
53}
54
55/// A streaming record framer: fed decoded bytes, yields record byte-payloads.
56///
57/// Concrete implementations live in the format crates (`spate-json`'s NDJSON
58/// framer, a future `spate-avro` container framer, ...). Implementations
59/// **must be a pure function of the byte stream**. The record sequence must
60/// not depend on how bytes are split across [`push`](Self::push) calls, so
61/// resume-by-record-index stays deterministic. Enforce any record-size bound
62/// inside `push` (return an error rather than buffer unboundedly), and check
63/// it against the record accumulated so far rather than against the bytes of
64/// the current `push`, so the framer fails at the same position in the stream
65/// however the bytes were split. A record completed before that position is
66/// queued for [`pop`](Self::pop) before the error returns, so a caller that
67/// drains after a failed [`push`](Self::push) or [`finish`](Self::finish)
68/// sees the same records as one that fed the stream whole.
69pub trait RecordFramer: Send {
70    /// Feed the next run of decoded bytes.
71    fn push(&mut self, bytes: &[u8]) -> io::Result<()>;
72
73    /// End of the stream/object: complete an unterminated final record if the
74    /// format allows one, and validate any end-of-stream structure.
75    fn finish(&mut self) -> io::Result<()>;
76
77    /// Pop the next completed record, in stream order.
78    fn pop(&mut self) -> Option<Vec<u8>>;
79
80    /// Total decoded bytes fed so far: the sum of the lengths passed to
81    /// [`push`](Self::push).
82    ///
83    /// A source reports it as a metric and may also bound how much it decodes
84    /// per step by it, so a framer that under-counts lets that step run
85    /// longer.
86    fn decoded_bytes(&self) -> u64;
87
88    /// The byte after which a reader can start framing mid-stream, or `None`
89    /// when the format has no such byte.
90    ///
91    /// Returning `Some(delim)` promises that for any stream `S` and any index
92    /// `d` with `S[d] == delim`, framing `S` yields the records of `S[..=d]`
93    /// followed by the records of `S[d + 1..]`, each framed by a fresh framer
94    /// through [`finish`](Self::finish). A source may then split one stream
95    /// into byte ranges and frame each on its own. A format with a header, a
96    /// BOM, magic bytes, or a delimiter that can appear quoted inside a record
97    /// (CSV) returns `None`.
98    fn resync_delimiter(&self) -> Option<u8> {
99        None
100    }
101}
102
103/// A [`Write`] adapter over a [`RecordFramer`], so a source whose decompressor
104/// (or any other `Write`-sink codec) emits decoded bytes through `Write` can
105/// feed a framer without the framer implementing `Write` itself. Every
106/// `write` forwards the buffer to [`RecordFramer::push`].
107pub struct FramerWriter {
108    framer: Box<dyn RecordFramer>,
109}
110
111impl FramerWriter {
112    /// Wrap `framer` as a `Write` target.
113    #[must_use]
114    pub fn new(framer: Box<dyn RecordFramer>) -> FramerWriter {
115        FramerWriter { framer }
116    }
117
118    /// The wrapped framer (shared), for reading `decoded_bytes`.
119    #[must_use]
120    pub fn framer(&self) -> &dyn RecordFramer {
121        &*self.framer
122    }
123
124    /// The wrapped framer, for popping records, reading `decoded_bytes`, and
125    /// `finish`.
126    pub fn framer_mut(&mut self) -> &mut dyn RecordFramer {
127        &mut *self.framer
128    }
129
130    /// Reclaim the wrapped framer (e.g. after a decompressor's end-of-stream
131    /// validation hands the writer back).
132    #[must_use]
133    pub fn into_inner(self) -> Box<dyn RecordFramer> {
134        self.framer
135    }
136}
137
138impl std::fmt::Debug for FramerWriter {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.debug_struct("FramerWriter").finish_non_exhaustive()
141    }
142}
143
144impl Write for FramerWriter {
145    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
146        self.framer.push(buf)?;
147        Ok(buf.len())
148    }
149
150    fn flush(&mut self) -> io::Result<()> {
151        Ok(())
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use std::collections::VecDeque;
159
160    /// A minimal in-crate [`RecordFramer`] for exercising the seam without a
161    /// concrete framer; those live in the format crates. It concatenates
162    /// everything pushed and emits it as one record at `finish`.
163    #[derive(Default)]
164    struct WholeFramer {
165        buf: Vec<u8>,
166        ready: VecDeque<Vec<u8>>,
167        decoded: u64,
168    }
169
170    impl RecordFramer for WholeFramer {
171        fn push(&mut self, bytes: &[u8]) -> io::Result<()> {
172            self.decoded += bytes.len() as u64;
173            self.buf.extend_from_slice(bytes);
174            Ok(())
175        }
176
177        fn finish(&mut self) -> io::Result<()> {
178            if !self.buf.is_empty() {
179                self.ready.push_back(std::mem::take(&mut self.buf));
180            }
181            Ok(())
182        }
183
184        fn pop(&mut self) -> Option<Vec<u8>> {
185            self.ready.pop_front()
186        }
187
188        fn decoded_bytes(&self) -> u64 {
189            self.decoded
190        }
191    }
192
193    #[test]
194    fn record_framer_is_object_safe() {
195        // Compiles only if the trait is dyn-compatible (the seam's contract).
196        let mut framer: Box<dyn RecordFramer> = Box::new(WholeFramer::default());
197        framer.push(b"x").unwrap();
198        framer.finish().unwrap();
199        assert_eq!(framer.pop(), Some(b"x".to_vec()));
200    }
201
202    /// A framer that does not override `resync_delimiter` declares no resync
203    /// point.
204    #[test]
205    fn a_framer_declares_no_resync_delimiter_by_default() {
206        assert_eq!(WholeFramer::default().resync_delimiter(), None);
207    }
208
209    #[test]
210    fn framer_writer_forwards_writes_to_push_and_counts_bytes() {
211        let mut w = FramerWriter::new(Box::new(WholeFramer::default()));
212        w.write_all(b"a\nb\n").unwrap();
213        assert_eq!(
214            w.framer().decoded_bytes(),
215            4,
216            "every written byte reaches push"
217        );
218        w.framer_mut().finish().unwrap();
219        let mut out = Vec::new();
220        while let Some(r) = w.framer_mut().pop() {
221            out.push(r);
222        }
223        assert_eq!(out, vec![b"a\nb\n".to_vec()]);
224    }
225
226    #[test]
227    fn framer_writer_into_inner_reclaims_the_framer() {
228        let w = FramerWriter::new(Box::new(WholeFramer::default()));
229        let framer = w.into_inner();
230        assert_eq!(framer.decoded_bytes(), 0);
231    }
232}