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}