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