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 arrow::array::ArrayData;
6use eyre::Context;
7
8/// Maximum Arrow IPC payload size (256 MB).
9const MAX_IPC_BYTES: usize = 256 * 1024 * 1024;
10
11/// Alignment guaranteed for every raw Arrow buffer inside Dora payloads.
12///
13/// Arrow kernels can issue SIMD loads from buffer bases. Some ARM platforms
14/// fault on under-aligned SIMD loads, so every body buffer of an Arrow IPC
15/// stream is placed at a 64-byte boundary relative to the payload base.
16pub(crate) const ARROW_BUFFER_ALIGNMENT: usize = 64;
17pub(crate) const ARROW_BUFFER_ALIGNMENT_EXPONENT: u8 =
18    ARROW_BUFFER_ALIGNMENT.trailing_zeros() as u8;
19const _: () = assert!(ARROW_BUFFER_ALIGNMENT.is_power_of_two());
20
21/// Encode an Arrow [`ArrayData`] into an Arrow IPC stream byte buffer.
22///
23/// The resulting buffer contains a full IPC stream: schema message, one record
24/// batch, and an end-of-stream marker. This is self-describing and can be
25/// decoded without external type information.
26pub fn encode_arrow_ipc(arrow_array: &ArrayData) -> eyre::Result<Vec<u8>> {
27    use arrow::ipc::writer::StreamWriter;
28    use arrow::record_batch::RecordBatch;
29    use arrow_schema::{Field, Schema};
30    use std::sync::Arc;
31
32    let schema = Schema::new(vec![Field::new(
33        "data",
34        arrow_array.data_type().clone(),
35        true,
36    )]);
37    let schema_ref = Arc::new(schema);
38
39    let array_ref = arrow::array::make_array(arrow_array.clone());
40    let batch = RecordBatch::try_new(schema_ref.clone(), vec![array_ref])
41        .context("failed to create RecordBatch for IPC encoding")?;
42
43    let mut buf = Vec::new();
44    {
45        let mut writer = StreamWriter::try_new(&mut buf, &schema_ref)
46            .context("failed to create Arrow IPC StreamWriter")?;
47        writer
48            .write(&batch)
49            .context("failed to write RecordBatch to IPC stream")?;
50        writer
51            .finish()
52            .context("failed to finish Arrow IPC stream")?;
53    }
54
55    // Fail loudly at the producer instead of emitting a stream that every
56    // receive path will unconditionally reject. `decode_arrow_ipc`,
57    // `decode_arrow_ipc_zero_copy`, and the streaming `InputDecoder` all bail
58    // on payloads over `MAX_IPC_BYTES`, and the fast-path encoder refuses
59    // oversized arrays too (routing them here). Without this check an
60    // oversized array would encode successfully, get sent, and then be
61    // silently dropped as undecodable on the consumer with no error on the
62    // sending side — see the matching guard in `uint8_layout`.
63    if buf.len() > MAX_IPC_BYTES {
64        eyre::bail!(
65            "Arrow IPC payload too large: {} bytes (max {MAX_IPC_BYTES}); \
66             split the output into smaller batches",
67            buf.len()
68        );
69    }
70    Ok(buf)
71}
72
73/// Decode an Arrow IPC stream byte buffer back into [`ArrayData`].
74///
75/// Expects the buffer to contain exactly one record batch with a single
76/// column named `"data"`, as produced by [`encode_arrow_ipc`].
77pub fn decode_arrow_ipc(ipc_buf: &[u8]) -> eyre::Result<ArrayData> {
78    use arrow::ipc::reader::StreamReader;
79    use std::io::Cursor;
80
81    if ipc_buf.len() > MAX_IPC_BYTES {
82        eyre::bail!(
83            "Arrow IPC payload too large: {} bytes (max {MAX_IPC_BYTES})",
84            ipc_buf.len()
85        );
86    }
87
88    let cursor = Cursor::new(ipc_buf);
89    let mut reader =
90        StreamReader::try_new(cursor, None).context("failed to open Arrow IPC stream")?;
91
92    let batch = reader
93        .next()
94        .ok_or_else(|| eyre::eyre!("Arrow IPC stream contained no record batches"))?
95        .context("failed to read RecordBatch from IPC stream")?;
96
97    if batch.num_columns() != 1 {
98        eyre::bail!(
99            "expected 1 column in IPC record batch, got {}",
100            batch.num_columns()
101        );
102    }
103
104    Ok(batch.column(0).to_data())
105}
106
107/// Decode an Arrow IPC stream from an Arrow [`Buffer`] **without copying** the
108/// payload buffers when they are properly aligned.
109///
110/// Unlike [`decode_arrow_ipc`], which reads from a byte slice through
111/// `StreamReader` (and therefore allocates a fresh buffer and copies every
112/// array buffer out of the stream), this uses
113/// [`arrow::ipc::reader::StreamDecoder`], which slices the array buffers
114/// directly out of the provided [`Buffer`]. When the input buffer is suitably
115/// aligned — as Dora's shared-memory payloads always are (128-byte `AVec` /
116/// page-aligned Zenoh SHM) — the decoded array aliases the input and no payload
117/// copy happens.
118///
119/// The decoder runs with the default `require_alignment = false`, so an
120/// under-aligned input (e.g. an arbitrary heap `Vec`) is handled gracefully by
121/// copying just the misaligned buffers rather than erroring. This keeps the
122/// receive path robust while preserving zero-copy for the common SHM case.
123pub fn decode_arrow_ipc_zero_copy(
124    mut buffer: arrow::buffer::Buffer,
125) -> eyre::Result<arrow::array::ArrayData> {
126    use arrow::ipc::reader::StreamDecoder;
127
128    if buffer.len() > MAX_IPC_BYTES {
129        eyre::bail!(
130            "Arrow IPC payload too large: {} bytes (max {MAX_IPC_BYTES})",
131            buffer.len()
132        );
133    }
134
135    let mut decoder = StreamDecoder::new();
136    let mut batch = None;
137    // `decode` is push-based: it may consume the schema message and return
138    // `None` before yielding the record batch, so loop until we get a batch or
139    // exhaust the input.
140    while !buffer.is_empty() {
141        let before = buffer.len();
142        if let Some(b) = decoder
143            .decode(&mut buffer)
144            .context("failed to decode Arrow IPC stream")?
145        {
146            batch = Some(b);
147            break;
148        }
149        // `decode` must consume bytes when it yields no batch; a crafted or
150        // truncated payload that leaves the buffer unchanged would otherwise
151        // spin this loop forever on the zenoh IO worker. Bail instead.
152        if buffer.len() == before {
153            eyre::bail!("Arrow IPC decoder made no progress on a partial/corrupt stream");
154        }
155    }
156
157    let batch = batch.ok_or_else(|| eyre::eyre!("Arrow IPC stream contained no record batches"))?;
158
159    if batch.num_columns() != 1 {
160        eyre::bail!(
161            "expected 1 column in IPC record batch, got {}",
162            batch.num_columns()
163        );
164    }
165
166    Ok(batch.column(0).to_data())
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use arrow::array::{Array, StringArray, UInt64Array};
173
174    #[test]
175    fn ipc_roundtrip_primitive() {
176        let array = UInt64Array::from(vec![1, 2, 3, 4, 5]);
177        let data = array.into_data();
178        let encoded = encode_arrow_ipc(&data).unwrap();
179        let decoded = decode_arrow_ipc(&encoded).unwrap();
180        assert_eq!(data, decoded);
181    }
182
183    /// An array whose IPC stream exceeds `MAX_IPC_BYTES` must fail at encode
184    /// time. Otherwise the sender would emit a stream that every receive path
185    /// rejects, silently dropping the message with no producer-side error.
186    #[test]
187    fn ipc_encode_rejects_oversized_payload() {
188        use arrow::array::UInt8Array;
189
190        // Body just over the 256 MB cap; the framing pushes the stream over too.
191        let array = UInt8Array::from(vec![0u8; MAX_IPC_BYTES + 1]);
192        let data = array.into_data();
193        let err =
194            encode_arrow_ipc(&data).expect_err("oversized payload must be rejected by the encoder");
195        assert!(
196            err.to_string().contains("too large"),
197            "unexpected error: {err}"
198        );
199    }
200
201    /// Copy `bytes` into a 128-byte-aligned buffer, mirroring how Dora's
202    /// receive path backs IPC payloads (an `AVec<u8, ConstAlign<128>>` for the
203    /// daemon path, page-aligned Zenoh SHM for the zero-copy path). This is the
204    /// precondition under which `decode_arrow_ipc_zero_copy` aliases the input.
205    fn aligned_buffer_from(bytes: &[u8]) -> (arrow::buffer::Buffer, usize, usize) {
206        use aligned_vec::{AVec, ConstAlign};
207        use std::ptr::NonNull;
208
209        let mut aligned: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, bytes.len());
210        aligned.copy_from_slice(bytes);
211        let base = aligned.as_ptr() as usize;
212        let len = aligned.len();
213        let ptr = NonNull::new(aligned.as_ptr() as *mut u8).unwrap();
214        // SAFETY: `ptr`/`len` describe `aligned`'s allocation, which the Arc
215        // keeps alive for the Buffer's lifetime.
216        let buffer = unsafe {
217            arrow::buffer::Buffer::from_custom_allocation(ptr, len, std::sync::Arc::new(aligned))
218        };
219        (buffer, base, len)
220    }
221
222    #[test]
223    fn ipc_zero_copy_roundtrip_primitive() {
224        let array = UInt64Array::from((0..1000u64).collect::<Vec<_>>());
225        let data = array.into_data();
226        let encoded = encode_arrow_ipc(&data).unwrap();
227        let (buffer, _, _) = aligned_buffer_from(&encoded);
228        let decoded = decode_arrow_ipc_zero_copy(buffer).unwrap();
229        assert_eq!(data, decoded);
230    }
231
232    /// The headline claim: for an aligned input buffer the decoded array's data
233    /// buffer points *into* the input allocation (no payload copy), and the
234    /// strict `require_alignment(true)` decoder accepts it without falling back
235    /// to a realigning copy.
236    #[test]
237    fn ipc_decode_is_zero_copy_for_aligned_buffer() {
238        use arrow::ipc::reader::StreamDecoder;
239
240        // A large primitive array so the data buffer dominates and any copy
241        // would be unmistakable.
242        let array = UInt64Array::from((0..100_000u64).collect::<Vec<_>>());
243        let data = array.into_data();
244        let encoded = encode_arrow_ipc(&data).unwrap();
245
246        // 1) Proof via the strict decoder: require_alignment(true) errors if any
247        //    buffer would need realigning. A clean decode proves the body
248        //    buffers are used in place.
249        {
250            let (mut buffer, _, _) = aligned_buffer_from(&encoded);
251            let mut decoder = StreamDecoder::new().with_require_alignment(true);
252            let mut got = None;
253            while !buffer.is_empty() {
254                if let Some(b) = decoder
255                    .decode(&mut buffer)
256                    .expect("aligned IPC buffer must decode without realignment")
257                {
258                    got = Some(b);
259                    break;
260                }
261            }
262            assert_eq!(got.unwrap().column(0).to_data(), data);
263        }
264
265        // 2) Proof via pointer aliasing: the decoded data buffer lies within the
266        //    input allocation's address range.
267        {
268            let (buffer, base, len) = aligned_buffer_from(&encoded);
269            let decoded = decode_arrow_ipc_zero_copy(buffer).unwrap();
270            let data_ptr = decoded.buffers()[0].as_ptr() as usize;
271            assert!(
272                data_ptr >= base && data_ptr < base + len,
273                "decoded data buffer at {data_ptr:#x} is outside input \
274                 [{base:#x}, {:#x}) — a copy happened (not zero-copy)",
275                base + len
276            );
277        }
278    }
279
280    /// Production safety: an *under-aligned* input must still decode correctly.
281    /// The default decoder (`require_alignment = false`) falls back to copying
282    /// only the misaligned buffers rather than erroring.
283    #[test]
284    fn ipc_zero_copy_decoder_handles_misaligned_input() {
285        let array = UInt64Array::from(vec![1, 2, 3, 4, 5, 6, 7, 8]);
286        let data = array.into_data();
287        let encoded = encode_arrow_ipc(&data).unwrap();
288
289        // Force a 1-byte-offset (deliberately misaligned) backing buffer.
290        let mut shifted = Vec::with_capacity(encoded.len() + 1);
291        shifted.push(0u8);
292        shifted.extend_from_slice(&encoded);
293        let buffer = arrow::buffer::Buffer::from_vec(shifted).slice(1);
294
295        let decoded = decode_arrow_ipc_zero_copy(buffer).unwrap();
296        assert_eq!(data, decoded);
297    }
298
299    #[test]
300    fn ipc_roundtrip_string() {
301        let array = StringArray::from(vec!["hello", "world"]);
302        let data = array.into_data();
303        let encoded = encode_arrow_ipc(&data).unwrap();
304        let decoded = decode_arrow_ipc(&encoded).unwrap();
305        assert_eq!(data, decoded);
306    }
307
308    #[test]
309    fn ipc_roundtrip_empty_array() {
310        let array = UInt64Array::from(Vec::<u64>::new());
311        let data = array.into_data();
312        let encoded = encode_arrow_ipc(&data).unwrap();
313        let decoded = decode_arrow_ipc(&encoded).unwrap();
314        assert_eq!(data.len(), decoded.len());
315    }
316
317    /// A zero-length *typed* array must encode to a self-describing stream that
318    /// decodes back to the SAME type, not `Null`. record/replay relies on this:
319    /// `record-node` IPC-encodes empty typed arrays (rather than dropping them
320    /// to an absent payload) so replay preserves the type instead of collapsing
321    /// to `NullArray::new(0)` (#2027/#2083).
322    #[test]
323    fn ipc_roundtrip_empty_typed_array_preserves_type() {
324        use arrow::array::Float32Array;
325        let data = Float32Array::from(Vec::<f32>::new()).into_data();
326        let encoded = encode_arrow_ipc(&data).unwrap();
327        let decoded = decode_arrow_ipc(&encoded).unwrap();
328        assert_eq!(decoded.data_type(), &arrow_schema::DataType::Float32);
329        assert_eq!(decoded.len(), 0);
330    }
331
332    #[test]
333    fn ipc_roundtrip_with_nulls() {
334        let array = UInt64Array::from(vec![Some(1), None, Some(3)]);
335        let data = array.into_data();
336        let encoded = encode_arrow_ipc(&data).unwrap();
337        let decoded = decode_arrow_ipc(&encoded).unwrap();
338        assert_eq!(data, decoded);
339    }
340}