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/pull
5//! source — object-storage backfills (`spate-s3`) today, HTTP chunked bodies,
6//! WebSocket message streams, or file tails tomorrow — must turn a byte stream
7//! into records before a [`Deserializer`](crate::deser::Deserializer) decodes
8//! each one. That "one payload → one record" split is what lets a single
9//! decoder serve every source; the framer decides what one payload *is*.
10//!
11//! `spate-core` owns only 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, not the transport:
15//! `spate-json` provides newline-delimited framing for NDJSON, a future
16//! `spate-avro` an object-container block framer, and so on. A source stays
17//! format-agnostic and receives its framer at pipeline-assembly time (e.g.
18//! `S3Source::with_framer`), so the same framer serves every streaming source
19//! 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 (this is load-bearing for
25//! at-least-once resume; see the `spate-s3` offset model). Compression is *not*
26//! part 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 — it ran a [`RecordFramer`] over its byte stream (e.g.
39///   an `spate-s3` backfill framing each object with the format's framer). The
40///   deserializer must decode a *single* unit; a deserializer configured to
41///   *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 **must
59/// be a pure function of the byte stream** — the record sequence must not
60/// 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).
63pub trait RecordFramer: Send {
64    /// Feed the next run of decoded bytes.
65    fn push(&mut self, bytes: &[u8]) -> io::Result<()>;
66
67    /// End of the stream/object: complete an unterminated final record if the
68    /// format allows one, and validate any end-of-stream structure.
69    fn finish(&mut self) -> io::Result<()>;
70
71    /// Pop the next completed record, in stream order.
72    fn pop(&mut self) -> Option<Vec<u8>>;
73
74    /// Total decoded bytes fed so far (for metrics).
75    fn decoded_bytes(&self) -> u64;
76}
77
78/// A [`Write`] adapter over a [`RecordFramer`], so a source whose decompressor
79/// (or any other `Write`-sink codec) emits decoded bytes through `Write` can
80/// feed a framer without the framer implementing `Write` itself. Every
81/// `write` forwards the buffer to [`RecordFramer::push`].
82pub struct FramerWriter {
83    framer: Box<dyn RecordFramer>,
84}
85
86impl FramerWriter {
87    /// Wrap `framer` as a `Write` target.
88    #[must_use]
89    pub fn new(framer: Box<dyn RecordFramer>) -> FramerWriter {
90        FramerWriter { framer }
91    }
92
93    /// The wrapped framer (shared) — read `decoded_bytes`.
94    #[must_use]
95    pub fn framer(&self) -> &dyn RecordFramer {
96        &*self.framer
97    }
98
99    /// The wrapped framer — pop records, read `decoded_bytes`, `finish`.
100    pub fn framer_mut(&mut self) -> &mut dyn RecordFramer {
101        &mut *self.framer
102    }
103
104    /// Reclaim the wrapped framer (e.g. after a decompressor's end-of-stream
105    /// validation hands the writer back).
106    #[must_use]
107    pub fn into_inner(self) -> Box<dyn RecordFramer> {
108        self.framer
109    }
110}
111
112impl std::fmt::Debug for FramerWriter {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.debug_struct("FramerWriter").finish_non_exhaustive()
115    }
116}
117
118impl Write for FramerWriter {
119    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
120        self.framer.push(buf)?;
121        Ok(buf.len())
122    }
123
124    fn flush(&mut self) -> io::Result<()> {
125        Ok(())
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::collections::VecDeque;
133
134    /// A minimal in-crate [`RecordFramer`] for exercising the seam itself
135    /// without a concrete framer — those live in the format crates. It
136    /// concatenates everything pushed and emits it as one record at `finish`.
137    #[derive(Default)]
138    struct WholeFramer {
139        buf: Vec<u8>,
140        ready: VecDeque<Vec<u8>>,
141        decoded: u64,
142    }
143
144    impl RecordFramer for WholeFramer {
145        fn push(&mut self, bytes: &[u8]) -> io::Result<()> {
146            self.decoded += bytes.len() as u64;
147            self.buf.extend_from_slice(bytes);
148            Ok(())
149        }
150
151        fn finish(&mut self) -> io::Result<()> {
152            if !self.buf.is_empty() {
153                self.ready.push_back(std::mem::take(&mut self.buf));
154            }
155            Ok(())
156        }
157
158        fn pop(&mut self) -> Option<Vec<u8>> {
159            self.ready.pop_front()
160        }
161
162        fn decoded_bytes(&self) -> u64 {
163            self.decoded
164        }
165    }
166
167    #[test]
168    fn record_framer_is_object_safe() {
169        // Compiles only if the trait is dyn-compatible (the seam's contract).
170        let mut framer: Box<dyn RecordFramer> = Box::new(WholeFramer::default());
171        framer.push(b"x").unwrap();
172        framer.finish().unwrap();
173        assert_eq!(framer.pop(), Some(b"x".to_vec()));
174    }
175
176    #[test]
177    fn framer_writer_forwards_writes_to_push_and_counts_bytes() {
178        let mut w = FramerWriter::new(Box::new(WholeFramer::default()));
179        w.write_all(b"a\nb\n").unwrap();
180        assert_eq!(
181            w.framer().decoded_bytes(),
182            4,
183            "every written byte reaches push"
184        );
185        w.framer_mut().finish().unwrap();
186        let mut out = Vec::new();
187        while let Some(r) = w.framer_mut().pop() {
188            out.push(r);
189        }
190        assert_eq!(out, vec![b"a\nb\n".to_vec()]);
191    }
192
193    #[test]
194    fn framer_writer_into_inner_reclaims_the_framer() {
195        let w = FramerWriter::new(Box::new(WholeFramer::default()));
196        let framer = w.into_inner();
197        assert_eq!(framer.decoded_bytes(), 0);
198    }
199}