Skip to main content

dora_node_api/node/
arrow_utils.rs

1//! Utility functions for converting Arrow arrays to/from raw data.
2//!
3pub mod ipc_encode;
4
5use aligned_vec::{AVec, ConstAlign};
6use arrow::array::ArrayData;
7use dora_arrow_convert::{
8    DoraArray,
9    internal::{array_ref, from_array_data},
10};
11use eyre::Context;
12
13/// A byte buffer holding an Arrow IPC stream, ready to decode.
14///
15/// A dora-owned wrapper: the receive path needs to hand the decoder a buffer
16/// whose backing allocation it does not copy, but the Arrow buffer type that
17/// makes that possible must not appear in dora's frozen public API (it would
18/// pin 1.x to one Arrow major — see
19/// `docs/plan-arrow-version-decoupling.md`). Construct one from the payload
20/// you have; decoding is zero-copy when the payload is 64-byte aligned, which
21/// dora's own 128-byte-aligned and page-aligned shared-memory payloads always
22/// are.
23#[derive(Debug, Clone)]
24pub struct IpcPayload(arrow::buffer::Buffer);
25
26impl IpcPayload {
27    /// Wrap a 128-byte-aligned payload buffer without copying it.
28    pub fn from_aligned_vec(data: AVec<u8, ConstAlign<128>>) -> Self {
29        let ptr = std::ptr::NonNull::new(data.as_ptr() as *mut u8)
30            .expect("AVec allocation pointer is never null");
31        let len = data.len();
32        // SAFETY: `ptr`/`len` describe `data`'s allocation, and `data` itself is
33        // moved into the `Arc` that owns the buffer, so the allocation outlives
34        // every reference the `Buffer` hands out.
35        Self(unsafe {
36            arrow::buffer::Buffer::from_custom_allocation(ptr, len, std::sync::Arc::new(data))
37        })
38    }
39
40    /// Take ownership of a `Vec` payload without copying it.
41    ///
42    /// A plain `Vec` carries no alignment guarantee, so the decoder may have to
43    /// realign individual buffers; use [`from_aligned_vec`](Self::from_aligned_vec)
44    /// on the hot path.
45    pub fn from_vec(data: Vec<u8>) -> Self {
46        Self(arrow::buffer::Buffer::from_vec(data))
47    }
48
49    /// Copy a payload out of a slice.
50    pub fn from_slice(data: &[u8]) -> Self {
51        Self(arrow::buffer::Buffer::from_slice_ref(data))
52    }
53
54    /// The payload length in bytes.
55    pub fn len(&self) -> usize {
56        self.0.len()
57    }
58
59    /// Whether the payload is empty.
60    pub fn is_empty(&self) -> bool {
61        self.0.is_empty()
62    }
63
64    /// The payload bytes.
65    pub fn as_slice(&self) -> &[u8] {
66        self.0.as_slice()
67    }
68
69    pub(crate) fn into_arrow(self) -> arrow::buffer::Buffer {
70        self.0
71    }
72}
73
74/// Maximum Arrow IPC payload size (256 MB).
75const MAX_IPC_BYTES: usize = 256 * 1024 * 1024;
76
77/// Alignment guaranteed for every raw Arrow buffer inside Dora payloads.
78///
79/// Arrow kernels can issue SIMD loads from buffer bases. Some ARM platforms
80/// fault on under-aligned SIMD loads, so every body buffer of an Arrow IPC
81/// stream is placed at a 64-byte boundary relative to the payload base.
82pub(crate) const ARROW_BUFFER_ALIGNMENT: usize = 64;
83pub(crate) const ARROW_BUFFER_ALIGNMENT_EXPONENT: u8 =
84    ARROW_BUFFER_ALIGNMENT.trailing_zeros() as u8;
85const _: () = assert!(ARROW_BUFFER_ALIGNMENT.is_power_of_two());
86
87/// Encode an Arrow [`ArrayData`] into an Arrow IPC stream byte buffer.
88///
89/// The resulting buffer contains a full IPC stream: schema message, one record
90/// batch, and an end-of-stream marker. This is self-describing and can be
91/// decoded without external type information.
92///
93/// # Example
94///
95/// ```
96/// # fn main() -> eyre::Result<()> {
97/// use dora_node_api::IntoArrow;
98/// use dora_node_api::arrow_utils::{decode_arrow_ipc, encode_arrow_ipc};
99///
100/// let ipc = encode_arrow_ipc(&vec![1u64, 2, 3].into_arrow())?;
101///
102/// // The stream is self-describing: decoding recovers the original data
103/// // without any external type information.
104/// let decoded = decode_arrow_ipc(&ipc)?;
105/// let values: Vec<u64> = (&decoded).try_into()?;
106/// assert_eq!(values, vec![1, 2, 3]);
107/// # Ok(())
108/// # }
109/// ```
110pub fn encode_arrow_ipc(array: &DoraArray) -> eyre::Result<Vec<u8>> {
111    encode_arrow_ipc_data(&array_ref(array).to_data())
112}
113
114/// Same, for dora-internal callers that already hold an [`ArrayData`].
115pub(crate) fn encode_arrow_ipc_data(arrow_array: &ArrayData) -> eyre::Result<Vec<u8>> {
116    use arrow::ipc::writer::StreamWriter;
117    use arrow::record_batch::RecordBatch;
118    use arrow_schema::{Field, Schema};
119    use std::sync::Arc;
120
121    let schema = Schema::new(vec![Field::new(
122        "data",
123        arrow_array.data_type().clone(),
124        true,
125    )]);
126    let schema_ref = Arc::new(schema);
127
128    let array_ref = arrow::array::make_array(arrow_array.clone());
129    let batch = RecordBatch::try_new(schema_ref.clone(), vec![array_ref])
130        .context("failed to create RecordBatch for IPC encoding")?;
131
132    let mut buf = Vec::new();
133    {
134        let mut writer = StreamWriter::try_new(&mut buf, &schema_ref)
135            .context("failed to create Arrow IPC StreamWriter")?;
136        writer
137            .write(&batch)
138            .context("failed to write RecordBatch to IPC stream")?;
139        writer
140            .finish()
141            .context("failed to finish Arrow IPC stream")?;
142    }
143
144    // Fail loudly at the producer instead of emitting a stream that every
145    // receive path will unconditionally reject. `decode_arrow_ipc`,
146    // `decode_arrow_ipc_zero_copy`, and the streaming `InputDecoder` all bail
147    // on payloads over `MAX_IPC_BYTES`, and the fast-path encoder refuses
148    // oversized arrays too (routing them here). Without this check an
149    // oversized array would encode successfully, get sent, and then be
150    // silently dropped as undecodable on the consumer with no error on the
151    // sending side — see the matching guard in `uint8_layout`.
152    if buf.len() > MAX_IPC_BYTES {
153        eyre::bail!(
154            "Arrow IPC payload too large: {} bytes (max {MAX_IPC_BYTES}); \
155             split the output into smaller batches",
156            buf.len()
157        );
158    }
159    Ok(buf)
160}
161
162/// Decode an Arrow IPC stream byte buffer back into [`ArrayData`].
163///
164/// Expects the buffer to contain exactly one record batch with a single
165/// column named `"data"`, as produced by [`encode_arrow_ipc`]. Returns an
166/// error for an empty, truncated, or otherwise malformed stream, and for any
167/// payload larger than 256 MB.
168///
169/// # Example
170///
171/// ```
172/// # fn main() -> eyre::Result<()> {
173/// use dora_node_api::IntoArrow;
174/// use dora_node_api::arrow_utils::{decode_arrow_ipc, encode_arrow_ipc};
175///
176/// let ipc = encode_arrow_ipc(&"hello".to_string().into_arrow())?;
177/// let decoded = decode_arrow_ipc(&ipc)?;
178/// let text: String = (&decoded).try_into()?;
179/// assert_eq!(text, "hello");
180/// # Ok(())
181/// # }
182/// ```
183pub fn decode_arrow_ipc(ipc_buf: &[u8]) -> eyre::Result<DoraArray> {
184    decode_arrow_ipc_data(ipc_buf).map(from_array_data)
185}
186
187/// Same, for dora-internal callers that want the raw [`ArrayData`].
188pub(crate) fn decode_arrow_ipc_data(ipc_buf: &[u8]) -> eyre::Result<ArrayData> {
189    use arrow::ipc::reader::StreamReader;
190    use std::io::Cursor;
191
192    if ipc_buf.len() > MAX_IPC_BYTES {
193        eyre::bail!(
194            "Arrow IPC payload too large: {} bytes (max {MAX_IPC_BYTES})",
195            ipc_buf.len()
196        );
197    }
198
199    let cursor = Cursor::new(ipc_buf);
200    let mut reader =
201        StreamReader::try_new(cursor, None).context("failed to open Arrow IPC stream")?;
202
203    let batch = reader
204        .next()
205        .ok_or_else(|| eyre::eyre!("Arrow IPC stream contained no record batches"))?
206        .context("failed to read RecordBatch from IPC stream")?;
207
208    if batch.num_columns() != 1 {
209        eyre::bail!(
210            "expected 1 column in IPC record batch, got {}",
211            batch.num_columns()
212        );
213    }
214
215    Ok(batch.column(0).to_data())
216}
217
218/// Decode an Arrow IPC stream from an Arrow [`Buffer`] **without copying** the
219/// payload buffers when they are properly aligned.
220///
221/// Unlike [`decode_arrow_ipc`], which reads from a byte slice through
222/// `StreamReader` (and therefore allocates a fresh buffer and copies every
223/// array buffer out of the stream), this uses
224/// [`arrow::ipc::reader::StreamDecoder`], which slices the array buffers
225/// directly out of the provided [`Buffer`]. When the input buffer is suitably
226/// aligned — as Dora's shared-memory payloads always are (128-byte `AVec` /
227/// page-aligned Zenoh SHM) — the decoded array aliases the input and no payload
228/// copy happens.
229///
230/// The decoder runs with the default `require_alignment = false`, so an
231/// under-aligned input (e.g. an arbitrary heap `Vec`) is handled gracefully by
232/// copying just the misaligned buffers rather than erroring. This keeps the
233/// receive path robust while preserving zero-copy for the common SHM case.
234///
235/// # Example
236///
237/// ```
238/// # fn main() -> eyre::Result<()> {
239/// use dora_node_api::IntoArrow;
240/// use dora_node_api::arrow_utils::{IpcPayload, decode_arrow_ipc_zero_copy, encode_arrow_ipc};
241///
242/// let ipc = encode_arrow_ipc(&vec![1u64, 2, 3].into_arrow())?;
243/// let decoded = decode_arrow_ipc_zero_copy(IpcPayload::from_vec(ipc))?;
244/// let values: Vec<u64> = (&decoded).try_into()?;
245/// assert_eq!(values, vec![1, 2, 3]);
246/// # Ok(())
247/// # }
248/// ```
249pub fn decode_arrow_ipc_zero_copy(payload: IpcPayload) -> eyre::Result<DoraArray> {
250    decode_arrow_ipc_zero_copy_raw(payload.into_arrow()).map(from_array_data)
251}
252
253/// Same, for dora-internal callers that already hold an Arrow buffer.
254pub(crate) fn decode_arrow_ipc_zero_copy_raw(
255    mut buffer: arrow::buffer::Buffer,
256) -> eyre::Result<arrow::array::ArrayData> {
257    use arrow::ipc::reader::StreamDecoder;
258
259    if buffer.len() > MAX_IPC_BYTES {
260        eyre::bail!(
261            "Arrow IPC payload too large: {} bytes (max {MAX_IPC_BYTES})",
262            buffer.len()
263        );
264    }
265
266    let mut decoder = StreamDecoder::new();
267    let mut batch = None;
268    // `decode` is push-based: it may consume the schema message and return
269    // `None` before yielding the record batch, so loop until we get a batch or
270    // exhaust the input.
271    while !buffer.is_empty() {
272        let before = buffer.len();
273        if let Some(b) = decoder
274            .decode(&mut buffer)
275            .context("failed to decode Arrow IPC stream")?
276        {
277            batch = Some(b);
278            break;
279        }
280        // `decode` must consume bytes when it yields no batch; a crafted or
281        // truncated payload that leaves the buffer unchanged would otherwise
282        // spin this loop forever on the zenoh IO worker. Bail instead.
283        if buffer.len() == before {
284            eyre::bail!("Arrow IPC decoder made no progress on a partial/corrupt stream");
285        }
286    }
287
288    let batch = batch.ok_or_else(|| eyre::eyre!("Arrow IPC stream contained no record batches"))?;
289
290    if batch.num_columns() != 1 {
291        eyre::bail!(
292            "expected 1 column in IPC record batch, got {}",
293            batch.num_columns()
294        );
295    }
296
297    Ok(batch.column(0).to_data())
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use arrow::array::{Array, StringArray, UInt64Array};
304
305    #[test]
306    fn ipc_roundtrip_primitive() {
307        let array = UInt64Array::from(vec![1, 2, 3, 4, 5]);
308        let data = array.into_data();
309        let encoded = encode_arrow_ipc_data(&data).unwrap();
310        let decoded = decode_arrow_ipc_data(&encoded).unwrap();
311        assert_eq!(data, decoded);
312    }
313
314    /// An array whose IPC stream exceeds `MAX_IPC_BYTES` must fail at encode
315    /// time. Otherwise the sender would emit a stream that every receive path
316    /// rejects, silently dropping the message with no producer-side error.
317    #[test]
318    fn ipc_encode_rejects_oversized_payload() {
319        use arrow::array::UInt8Array;
320
321        // Body just over the 256 MB cap; the framing pushes the stream over too.
322        let array = UInt8Array::from(vec![0u8; MAX_IPC_BYTES + 1]);
323        let data = array.into_data();
324        let err = encode_arrow_ipc_data(&data)
325            .expect_err("oversized payload must be rejected by the encoder");
326        assert!(
327            err.to_string().contains("too large"),
328            "unexpected error: {err}"
329        );
330    }
331
332    /// Copy `bytes` into a 128-byte-aligned buffer, mirroring how Dora's
333    /// receive path backs IPC payloads (an `AVec<u8, ConstAlign<128>>` for the
334    /// daemon path, page-aligned Zenoh SHM for the zero-copy path). This is the
335    /// precondition under which `decode_arrow_ipc_zero_copy` aliases the input.
336    fn aligned_buffer_from(bytes: &[u8]) -> (arrow::buffer::Buffer, usize, usize) {
337        use aligned_vec::{AVec, ConstAlign};
338        use std::ptr::NonNull;
339
340        let mut aligned: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, bytes.len());
341        aligned.copy_from_slice(bytes);
342        let base = aligned.as_ptr() as usize;
343        let len = aligned.len();
344        let ptr = NonNull::new(aligned.as_ptr() as *mut u8).unwrap();
345        // SAFETY: `ptr`/`len` describe `aligned`'s allocation, which the Arc
346        // keeps alive for the Buffer's lifetime.
347        let buffer = unsafe {
348            arrow::buffer::Buffer::from_custom_allocation(ptr, len, std::sync::Arc::new(aligned))
349        };
350        (buffer, base, len)
351    }
352
353    #[test]
354    fn ipc_zero_copy_roundtrip_primitive() {
355        let array = UInt64Array::from((0..1000u64).collect::<Vec<_>>());
356        let data = array.into_data();
357        let encoded = encode_arrow_ipc_data(&data).unwrap();
358        let (buffer, _, _) = aligned_buffer_from(&encoded);
359        let decoded = decode_arrow_ipc_zero_copy_raw(buffer).unwrap();
360        assert_eq!(data, decoded);
361    }
362
363    /// The headline claim: for an aligned input buffer the decoded array's data
364    /// buffer points *into* the input allocation (no payload copy), and the
365    /// strict `require_alignment(true)` decoder accepts it without falling back
366    /// to a realigning copy.
367    #[test]
368    fn ipc_decode_is_zero_copy_for_aligned_buffer() {
369        use arrow::ipc::reader::StreamDecoder;
370
371        // A large primitive array so the data buffer dominates and any copy
372        // would be unmistakable.
373        let array = UInt64Array::from((0..100_000u64).collect::<Vec<_>>());
374        let data = array.into_data();
375        let encoded = encode_arrow_ipc_data(&data).unwrap();
376
377        // 1) Proof via the strict decoder: require_alignment(true) errors if any
378        //    buffer would need realigning. A clean decode proves the body
379        //    buffers are used in place.
380        {
381            let (mut buffer, _, _) = aligned_buffer_from(&encoded);
382            let mut decoder = StreamDecoder::new().with_require_alignment(true);
383            let mut got = None;
384            while !buffer.is_empty() {
385                if let Some(b) = decoder
386                    .decode(&mut buffer)
387                    .expect("aligned IPC buffer must decode without realignment")
388                {
389                    got = Some(b);
390                    break;
391                }
392            }
393            assert_eq!(got.unwrap().column(0).to_data(), data);
394        }
395
396        // 2) Proof via pointer aliasing: the decoded data buffer lies within the
397        //    input allocation's address range.
398        {
399            let (buffer, base, len) = aligned_buffer_from(&encoded);
400            let decoded = decode_arrow_ipc_zero_copy_raw(buffer).unwrap();
401            let data_ptr = decoded.buffers()[0].as_ptr() as usize;
402            assert!(
403                data_ptr >= base && data_ptr < base + len,
404                "decoded data buffer at {data_ptr:#x} is outside input \
405                 [{base:#x}, {:#x}) — a copy happened (not zero-copy)",
406                base + len
407            );
408        }
409    }
410
411    /// Production safety: an *under-aligned* input must still decode correctly.
412    /// The default decoder (`require_alignment = false`) falls back to copying
413    /// only the misaligned buffers rather than erroring.
414    #[test]
415    fn ipc_zero_copy_decoder_handles_misaligned_input() {
416        let array = UInt64Array::from(vec![1, 2, 3, 4, 5, 6, 7, 8]);
417        let data = array.into_data();
418        let encoded = encode_arrow_ipc_data(&data).unwrap();
419
420        // Force a 1-byte-offset (deliberately misaligned) backing buffer.
421        let mut shifted = Vec::with_capacity(encoded.len() + 1);
422        shifted.push(0u8);
423        shifted.extend_from_slice(&encoded);
424        let buffer = arrow::buffer::Buffer::from_vec(shifted).slice(1);
425
426        let decoded = decode_arrow_ipc_zero_copy_raw(buffer).unwrap();
427        assert_eq!(data, decoded);
428    }
429
430    #[test]
431    fn ipc_roundtrip_string() {
432        let array = StringArray::from(vec!["hello", "world"]);
433        let data = array.into_data();
434        let encoded = encode_arrow_ipc_data(&data).unwrap();
435        let decoded = decode_arrow_ipc_data(&encoded).unwrap();
436        assert_eq!(data, decoded);
437    }
438
439    #[test]
440    fn ipc_roundtrip_empty_array() {
441        let array = UInt64Array::from(Vec::<u64>::new());
442        let data = array.into_data();
443        let encoded = encode_arrow_ipc_data(&data).unwrap();
444        let decoded = decode_arrow_ipc_data(&encoded).unwrap();
445        assert_eq!(data.len(), decoded.len());
446    }
447
448    /// A zero-length *typed* array must encode to a self-describing stream that
449    /// decodes back to the SAME type, not `Null`. record/replay relies on this:
450    /// `record-node` IPC-encodes empty typed arrays (rather than dropping them
451    /// to an absent payload) so replay preserves the type instead of collapsing
452    /// to `NullArray::new(0)` (#2027/#2083).
453    #[test]
454    fn ipc_roundtrip_empty_typed_array_preserves_type() {
455        use arrow::array::Float32Array;
456        let data = Float32Array::from(Vec::<f32>::new()).into_data();
457        let encoded = encode_arrow_ipc_data(&data).unwrap();
458        let decoded = decode_arrow_ipc_data(&encoded).unwrap();
459        assert_eq!(decoded.data_type(), &arrow_schema::DataType::Float32);
460        assert_eq!(decoded.len(), 0);
461    }
462
463    #[test]
464    fn ipc_roundtrip_with_nulls() {
465        let array = UInt64Array::from(vec![Some(1), None, Some(3)]);
466        let data = array.into_data();
467        let encoded = encode_arrow_ipc_data(&data).unwrap();
468        let decoded = decode_arrow_ipc_data(&encoded).unwrap();
469        assert_eq!(data, decoded);
470    }
471}