Skip to main content

laminar_core/serialization/
mod.rs

1//! Shared serialization helpers.
2//!
3//! - Arrow IPC: `RecordBatch` ↔ bytes conversion using the Arrow IPC stream format.
4//! - `jsonb_tags`: Canonical JSONB binary format type tag constants.
5
6/// Canonical JSONB binary format type tag constants.
7pub mod jsonb_tags;
8
9use arrow::buffer::Buffer;
10use arrow_array::RecordBatch;
11use arrow_ipc::reader::{StreamDecoder, StreamReader};
12use arrow_ipc::writer::StreamWriter;
13use arrow_schema::{ArrowError, Schema};
14
15/// A growable byte writer that rejects payloads and retained capacities above a fixed limit.
16/// This is shared by Arrow IPC and archive encoders at checkpoint/shuffle boundaries.
17pub struct BoundedBytesWriter {
18    bytes: Vec<u8>,
19    limit: usize,
20}
21
22impl BoundedBytesWriter {
23    /// Create an empty bounded writer.
24    #[must_use]
25    pub fn new(limit: usize) -> Self {
26        Self::with_capacity(limit, 0)
27    }
28
29    fn with_capacity(limit: usize, initial_capacity: usize) -> Self {
30        Self {
31            bytes: Vec::with_capacity(initial_capacity.min(limit)),
32            limit,
33        }
34    }
35
36    /// Consume the writer and return its retained bytes without copying.
37    #[must_use]
38    pub fn into_vec(self) -> Vec<u8> {
39        self.bytes
40    }
41}
42
43impl std::io::Write for BoundedBytesWriter {
44    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
45        let next_len = self
46            .bytes
47            .len()
48            .checked_add(buf.len())
49            .filter(|next_len| *next_len <= self.limit)
50            .ok_or_else(|| {
51                std::io::Error::new(
52                    std::io::ErrorKind::OutOfMemory,
53                    "serialized payload exceeds its configured bound",
54                )
55            })?;
56        if next_len > self.bytes.capacity() {
57            self.bytes
58                .try_reserve_exact(next_len - self.bytes.len())
59                .map_err(std::io::Error::other)?;
60            if self.bytes.capacity() < next_len || self.bytes.capacity() > self.limit {
61                return Err(std::io::Error::new(
62                    std::io::ErrorKind::OutOfMemory,
63                    "serialized allocation exceeds its configured bound",
64                ));
65            }
66        }
67        self.bytes.extend_from_slice(buf);
68        debug_assert!(self.bytes.capacity() <= self.limit);
69        Ok(buf.len())
70    }
71
72    fn flush(&mut self) -> std::io::Result<()> {
73        Ok(())
74    }
75}
76
77/// Serializes a single [`RecordBatch`] to Arrow IPC stream bytes.
78///
79/// # Errors
80///
81/// Returns [`arrow_schema::ArrowError`] if IPC encoding fails.
82pub fn serialize_batch_stream(batch: &RecordBatch) -> Result<Vec<u8>, arrow_schema::ArrowError> {
83    let mut buf = Vec::new();
84    {
85        let mut writer = StreamWriter::try_new(&mut buf, &batch.schema())?;
86        writer.write(batch)?;
87        writer.finish()?;
88    }
89    Ok(buf)
90}
91
92/// Deserializes a single [`RecordBatch`] from Arrow IPC stream bytes.
93///
94/// # Errors
95///
96/// Returns [`arrow_schema::ArrowError`] if the bytes are invalid or contain no batches.
97pub fn deserialize_batch_stream(bytes: &[u8]) -> Result<RecordBatch, arrow_schema::ArrowError> {
98    let cursor = std::io::Cursor::new(bytes);
99    let mut reader = StreamReader::try_new(cursor, None)?;
100    reader.next().ok_or_else(|| {
101        arrow_schema::ArrowError::IpcError("no record batch in IPC stream".to_string())
102    })?
103}
104
105/// Test encoder for exercising incrementally chunked Arrow IPC streams.
106#[cfg(test)]
107pub(crate) struct BatchStreamEncoder {
108    writer: StreamWriter<Vec<u8>>,
109}
110
111#[cfg(test)]
112impl BatchStreamEncoder {
113    /// Encoder for `schema`; the schema message flushes out with the first batch.
114    ///
115    /// # Errors
116    /// [`ArrowError`] if the schema header can't be IPC-encoded.
117    pub(crate) fn new(schema: &Schema) -> Result<Self, ArrowError> {
118        Ok(Self {
119            writer: StreamWriter::try_new(Vec::new(), schema)?,
120        })
121    }
122
123    /// Encode one batch, returning the bytes written since the last call (the
124    /// first call also carries the schema).
125    ///
126    /// # Errors
127    /// [`ArrowError`] if IPC encoding fails.
128    pub(crate) fn encode(&mut self, batch: &RecordBatch) -> Result<Vec<u8>, ArrowError> {
129        self.writer.write(batch)?;
130        Ok(std::mem::take(self.writer.get_mut()))
131    }
132
133    /// Finish the stream, returning the end-of-stream marker to append after the
134    /// last [`encode`](Self::encode). Also lets the decoder flush a trailing
135    /// zero-row batch; no batches may be encoded after this.
136    ///
137    /// # Errors
138    /// [`ArrowError`] if writing the marker fails.
139    pub(crate) fn finish(&mut self) -> Result<Vec<u8>, ArrowError> {
140        self.writer.finish()?;
141        Ok(std::mem::take(self.writer.get_mut()))
142    }
143}
144
145/// Decoder for an incrementally chunked Arrow IPC stream. The first chunk's
146/// schema decodes all later schema-less chunks.
147#[derive(Debug, Default)]
148pub struct BatchStreamDecoder {
149    decoder: StreamDecoder,
150}
151
152impl BatchStreamDecoder {
153    /// Creates an empty decoder that has not yet seen a schema.
154    #[must_use]
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    /// Decode every complete batch in one chunk; a batch straddling a chunk
160    /// boundary is buffered until the rest arrives, preserving order.
161    ///
162    /// # Errors
163    /// [`ArrowError`] if the bytes aren't a valid continuation (e.g. a batch
164    /// before any schema).
165    pub fn decode_chunk(&mut self, bytes: Vec<u8>) -> Result<Vec<RecordBatch>, ArrowError> {
166        let mut buffer = Buffer::from_vec(bytes);
167        let mut batches = Vec::new();
168        // Drain the chunk: `decode` yields a batch each time one completes.
169        while !buffer.is_empty() {
170            if let Some(batch) = self.decoder.decode(&mut buffer)? {
171                batches.push(batch);
172            }
173        }
174        Ok(batches)
175    }
176
177    /// Verify that the last chunk ended between IPC messages rather than in a
178    /// buffered header, metadata block, or body. This does not finish or reset
179    /// the decoder.
180    #[cfg(feature = "cluster")]
181    pub(crate) fn ensure_message_boundary(&mut self) -> Result<(), ArrowError> {
182        self.decoder.finish()
183    }
184}
185
186/// Serializes batches as one Arrow IPC stream without allowing the writer to allocate beyond
187/// `max_bytes`.
188///
189/// # Errors
190///
191/// Returns [`arrow_schema::ArrowError`] if encoding fails or crosses the byte bound.
192pub fn serialize_batches_stream_bounded<'a, I>(
193    schema: &Schema,
194    batches: I,
195    max_bytes: usize,
196) -> Result<Vec<u8>, arrow_schema::ArrowError>
197where
198    I: IntoIterator<Item = &'a RecordBatch>,
199{
200    serialize_batches_stream_bounded_with_capacity(schema, batches, max_bytes, 0)
201}
202
203fn serialize_batches_stream_bounded_with_capacity<'a, I>(
204    schema: &Schema,
205    batches: I,
206    max_bytes: usize,
207    initial_capacity: usize,
208) -> Result<Vec<u8>, arrow_schema::ArrowError>
209where
210    I: IntoIterator<Item = &'a RecordBatch>,
211{
212    let mut bounded = BoundedBytesWriter::with_capacity(max_bytes, initial_capacity);
213    {
214        let mut writer = StreamWriter::try_new(&mut bounded, schema)?;
215        for batch in batches {
216            writer.write(batch)?;
217        }
218        writer.finish()?;
219    }
220    Ok(bounded.bytes)
221}
222
223#[cfg(feature = "cluster")]
224/// Single-batch adapter that preserves the shuffle path's measured initial-capacity hint.
225pub(crate) fn serialize_batch_stream_bounded(
226    batch: &RecordBatch,
227    max_bytes: usize,
228    initial_capacity: usize,
229) -> Result<Vec<u8>, arrow_schema::ArrowError> {
230    serialize_batches_stream_bounded_with_capacity(
231        batch.schema().as_ref(),
232        std::iter::once(batch),
233        max_bytes,
234        initial_capacity,
235    )
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use arrow_array::Int32Array;
242    use arrow_schema::{DataType, Field};
243    use std::io::Write as _;
244    use std::sync::Arc;
245
246    fn batch(values: &[i32]) -> RecordBatch {
247        let schema = Arc::new(Schema::new(vec![Field::new("n", DataType::Int32, false)]));
248        RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(values.to_vec()))]).unwrap()
249    }
250
251    // The schema rides only in the first chunk: an equal-sized later batch encodes
252    // to fewer bytes than the first, and to fewer bytes than a standalone
253    // schema-carrying serialization of the same batch.
254    #[test]
255    fn stream_encoder_emits_schema_once() {
256        let schema = Arc::new(Schema::new(vec![Field::new("n", DataType::Int32, false)]));
257        let mut encoder = BatchStreamEncoder::new(&schema).unwrap();
258
259        let first = encoder.encode(&batch(&[1, 2, 3])).unwrap();
260        let second = encoder.encode(&batch(&[4, 5, 6])).unwrap();
261
262        // Same-width batches, yet the first is larger because it also carries the
263        // one-time schema message.
264        assert!(first.len() > second.len());
265
266        // A standalone (schema + batch) serialization of the equal-sized batch is
267        // larger than the schema-less chunk, proving the duplicate schema is gone.
268        let standalone = serialize_batch_stream(&batch(&[4, 5, 6])).unwrap();
269        assert!(second.len() < standalone.len());
270    }
271
272    // Encoding a batch sequence then feeding the chunks to a single decoder
273    // round-trips every batch, in order, including an empty (zero-row) batch.
274    #[test]
275    fn stream_encode_decode_roundtrip() {
276        let schema = Arc::new(Schema::new(vec![Field::new("n", DataType::Int32, false)]));
277        let mut encoder = BatchStreamEncoder::new(&schema).unwrap();
278        // The trailing batch is empty (zero rows): it round-trips only because the
279        // end-of-stream marker from `finish` lets the push decoder flush it.
280        let inputs = [batch(&[1, 2]), batch(&[3, 4, 5]), batch(&[])];
281        let mut chunks: Vec<Vec<u8>> = inputs.iter().map(|b| encoder.encode(b).unwrap()).collect();
282        chunks.push(encoder.finish().unwrap());
283
284        let mut decoder = BatchStreamDecoder::new();
285        let mut out = Vec::new();
286        for chunk in chunks {
287            out.extend(decoder.decode_chunk(chunk).unwrap());
288        }
289
290        assert_eq!(out, inputs);
291    }
292
293    #[test]
294    fn bounded_writer_never_grows_past_its_limit() {
295        let mut writer = BoundedBytesWriter::with_capacity(8, 4);
296        writer.write_all(&[1, 2, 3]).unwrap();
297        writer.write_all(&[4, 5, 6, 7, 8]).unwrap();
298        assert_eq!(writer.bytes, vec![1, 2, 3, 4, 5, 6, 7, 8]);
299        assert!(writer.bytes.capacity() <= 8);
300
301        let error = writer.write_all(&[9]).unwrap_err();
302        assert_eq!(error.kind(), std::io::ErrorKind::OutOfMemory);
303        assert_eq!(writer.bytes.len(), 8);
304        assert!(writer.bytes.capacity() <= 8);
305    }
306
307    #[test]
308    fn bounded_batch_stream_round_trips_multiple_batches_and_fails_at_the_bound() {
309        let inputs = [batch(&[1, 2]), batch(&[3, 4, 5])];
310        let schema = inputs[0].schema();
311        let encoded =
312            serialize_batches_stream_bounded(schema.as_ref(), inputs.iter(), usize::MAX).unwrap();
313        let decoded = StreamReader::try_new(std::io::Cursor::new(&encoded), None)
314            .unwrap()
315            .collect::<Result<Vec<_>, _>>()
316            .unwrap();
317        assert_eq!(decoded, inputs);
318
319        let error =
320            serialize_batches_stream_bounded(schema.as_ref(), inputs.iter(), encoded.len() - 1)
321                .unwrap_err();
322        assert!(error.to_string().contains("configured bound"));
323
324        let tiny_error =
325            serialize_batches_stream_bounded(schema.as_ref(), inputs.iter(), 1).unwrap_err();
326        assert!(tiny_error.to_string().contains("configured bound"));
327    }
328}