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 stream to contain exactly one record batch with a single
173/// column, as produced by [`encode_arrow_ipc`] (which writes a single batch
174/// whose one column is named `"data"`). The array is taken from that column by
175/// position; the column *name* is not inspected. Returns an error for an empty,
176/// truncated, multi-batch, or otherwise malformed stream, for a stream with
177/// trailing bytes after its end-of-stream marker, for a batch whose column
178/// count is not exactly one, and for any payload larger than 256 MB.
179///
180/// The trailing-byte rejection matches [`decode_arrow_ipc_zero_copy`], so both
181/// decoders accept and reject exactly the same streams.
182///
183/// # Example
184///
185/// ```
186/// # fn main() -> eyre::Result<()> {
187/// use dora_node_api::IntoArrow;
188/// use dora_node_api::arrow_utils::{decode_arrow_ipc, encode_arrow_ipc};
189///
190/// let ipc = encode_arrow_ipc(&"hello".to_string().into_arrow())?;
191/// let decoded = decode_arrow_ipc(&ipc)?;
192/// let text: String = (&decoded).try_into()?;
193/// assert_eq!(text, "hello");
194/// # Ok(())
195/// # }
196/// ```
197pub fn decode_arrow_ipc(ipc_buf: &[u8]) -> eyre::Result<DoraArray> {
198 decode_arrow_ipc_data(ipc_buf).map(from_array_data)
199}
200
201/// Error for a stream that carries more than one record batch. The encoder
202/// always writes exactly one, so extras are malformed (or crafted): both
203/// decoders reject them rather than silently returning only the first batch's
204/// data, which would drop payload with no error.
205const MULTI_BATCH_ERROR: &str =
206 "expected exactly one record batch in IPC stream, but found more than one";
207
208/// Error for a stream that carries extra bytes after the single record batch and
209/// its end-of-stream marker. The encoder sizes every stream exactly, so trailing
210/// bytes are malformed (or crafted). Both decoders reject them, so the daemon
211/// (`decode_arrow_ipc_data`) and inter-node (`decode_arrow_ipc_zero_copy_raw`)
212/// paths agree on the same input rather than one accepting what the other
213/// rejects.
214const TRAILING_BYTES_ERROR: &str = "unexpected trailing bytes after the record batch in IPC stream";
215
216/// Extract the single column's [`ArrayData`] from a decoded batch, rejecting a
217/// batch whose column count is not exactly one. Shared by both decoders so the
218/// column-count check and the extraction cannot drift apart.
219fn array_from_single_column_batch(
220 batch: arrow::record_batch::RecordBatch,
221) -> eyre::Result<ArrayData> {
222 if batch.num_columns() != 1 {
223 eyre::bail!(
224 "expected 1 column in IPC record batch, got {}",
225 batch.num_columns()
226 );
227 }
228 Ok(batch.column(0).to_data())
229}
230
231/// Same, for dora-internal callers that want the raw [`ArrayData`].
232pub(crate) fn decode_arrow_ipc_data(ipc_buf: &[u8]) -> eyre::Result<ArrayData> {
233 use arrow::ipc::reader::StreamReader;
234 use std::io::Cursor;
235
236 if ipc_buf.len() > MAX_IPC_BYTES {
237 eyre::bail!(
238 "Arrow IPC payload too large: {} bytes (max {MAX_IPC_BYTES})",
239 ipc_buf.len()
240 );
241 }
242
243 let cursor = Cursor::new(ipc_buf);
244 let mut reader =
245 StreamReader::try_new(cursor, None).context("failed to open Arrow IPC stream")?;
246
247 let batch = reader
248 .next()
249 .ok_or_else(|| eyre::eyre!("Arrow IPC stream contained no record batches"))?
250 .context("failed to read RecordBatch from IPC stream")?;
251
252 // Inspect what follows the first batch. A second decoded batch is a
253 // multi-batch stream; a decode *error* on the next message must be reported
254 // as such rather than mislabeled as "more than one batch"; `None` is the
255 // end-of-stream marker.
256 match reader.next() {
257 Some(Ok(_)) => eyre::bail!(MULTI_BATCH_ERROR),
258 Some(Err(e)) => {
259 return Err(e).context("failed to read RecordBatch from IPC stream");
260 }
261 None => {}
262 }
263
264 // Reject bytes after the end-of-stream marker so this path agrees with the
265 // zero-copy decoder (see `TRAILING_BYTES_ERROR`). `StreamReader` stops at the
266 // marker and would otherwise silently ignore anything after it; the inner
267 // `Cursor`'s position is how many bytes it consumed reaching that point.
268 let consumed = reader.get_ref().position() as usize;
269 if consumed != ipc_buf.len() {
270 eyre::bail!(TRAILING_BYTES_ERROR);
271 }
272
273 array_from_single_column_batch(batch)
274}
275
276/// Decode an Arrow IPC stream from an Arrow [`Buffer`] **without copying** the
277/// payload buffers when they are properly aligned.
278///
279/// Unlike [`decode_arrow_ipc`], which reads from a byte slice through
280/// `StreamReader` (and therefore allocates a fresh buffer and copies every
281/// array buffer out of the stream), this uses
282/// [`arrow::ipc::reader::StreamDecoder`], which slices the array buffers
283/// directly out of the provided [`Buffer`]. When the input buffer is suitably
284/// aligned — as Dora's shared-memory payloads always are (128-byte `AVec` /
285/// page-aligned Zenoh SHM) — the decoded array aliases the input and no payload
286/// copy happens.
287///
288/// The decoder runs with the default `require_alignment = false`, so an
289/// under-aligned input (e.g. an arbitrary heap `Vec`) is handled gracefully by
290/// copying just the misaligned buffers rather than erroring. This keeps the
291/// receive path robust while preserving zero-copy for the common SHM case.
292///
293/// # Errors
294///
295/// Like [`decode_arrow_ipc`], returns an error for an empty, truncated,
296/// multi-batch, or otherwise malformed stream, for a stream with trailing bytes
297/// after its end-of-stream marker, for a batch whose column count is not exactly
298/// one, and for any payload larger than 256 MB — it never panics on bad input.
299/// It accepts and rejects exactly the same streams as [`decode_arrow_ipc`].
300///
301/// # Example
302///
303/// ```
304/// # fn main() -> eyre::Result<()> {
305/// use dora_node_api::IntoArrow;
306/// use dora_node_api::arrow_utils::{IpcPayload, decode_arrow_ipc_zero_copy, encode_arrow_ipc};
307///
308/// let ipc = encode_arrow_ipc(&vec![1u64, 2, 3].into_arrow())?;
309/// let decoded = decode_arrow_ipc_zero_copy(IpcPayload::from_vec(ipc))?;
310/// let values: Vec<u64> = (&decoded).try_into()?;
311/// assert_eq!(values, vec![1, 2, 3]);
312///
313/// // A malformed stream is rejected with an error rather than panicking.
314/// assert!(decode_arrow_ipc_zero_copy(IpcPayload::from_vec(vec![0u8; 8])).is_err());
315/// # Ok(())
316/// # }
317/// ```
318pub fn decode_arrow_ipc_zero_copy(payload: IpcPayload) -> eyre::Result<DoraArray> {
319 decode_arrow_ipc_zero_copy_raw(payload.into_arrow()).map(from_array_data)
320}
321
322/// Same, for dora-internal callers that already hold an Arrow buffer.
323pub(crate) fn decode_arrow_ipc_zero_copy_raw(
324 mut buffer: arrow::buffer::Buffer,
325) -> eyre::Result<arrow::array::ArrayData> {
326 use arrow::ipc::reader::StreamDecoder;
327
328 if buffer.len() > MAX_IPC_BYTES {
329 eyre::bail!(
330 "Arrow IPC payload too large: {} bytes (max {MAX_IPC_BYTES})",
331 buffer.len()
332 );
333 }
334
335 let mut decoder = StreamDecoder::new();
336 let mut batch = None;
337 // `decode` is push-based: it may consume the schema message and return
338 // `None` before yielding the record batch, so loop until we get a batch or
339 // exhaust the input. Keep draining after the first batch so a second one is
340 // detected and rejected rather than silently dropped (the encoder always
341 // writes exactly one batch; a stream with more is malformed or crafted).
342 while !buffer.is_empty() {
343 let before = buffer.len();
344 let decoded = match decoder.decode(&mut buffer) {
345 Ok(decoded) => decoded,
346 Err(e) => {
347 // `decode` consumes the 8-byte end-of-stream marker and enters
348 // its `Finished` state, then errors on any byte after it. So
349 // once we already hold the batch, a decode error means the
350 // stream had extra bytes after a complete single-batch stream:
351 // report that as trailing garbage (matching the `StreamReader`
352 // path) rather than a generic decode failure. Before the batch,
353 // it is a genuinely malformed/truncated stream.
354 let context = if batch.is_some() {
355 TRAILING_BYTES_ERROR
356 } else {
357 "failed to decode Arrow IPC stream"
358 };
359 return Err::<arrow::array::ArrayData, _>(e).context(context);
360 }
361 };
362 match decoded {
363 Some(b) => {
364 // `replace` both stores the batch and tells us whether one was
365 // already present, so a second batch is detected and rejected
366 // rather than silently dropped.
367 if batch.replace(b).is_some() {
368 eyre::bail!(MULTI_BATCH_ERROR);
369 }
370 }
371 None if buffer.len() == before => {
372 // `decode` yielded no batch and consumed nothing. Before the
373 // first batch this is a crafted/truncated payload that would
374 // otherwise spin this loop forever on the zenoh IO worker
375 // (surfaced below as "no record batches"), so stop. `decode`
376 // otherwise only returns `None` once it has drained the buffer,
377 // so trailing bytes after the marker never reach here — they hit
378 // the `Err` arm above.
379 break;
380 }
381 // Progress without a batch: the schema message before the batch, or
382 // the end-of-stream marker after it. Keep going; the loop ends when
383 // the buffer is drained.
384 None => {}
385 }
386 }
387
388 let batch = batch.ok_or_else(|| eyre::eyre!("Arrow IPC stream contained no record batches"))?;
389
390 array_from_single_column_batch(batch)
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use arrow::array::{Array, StringArray, UInt64Array};
397
398 #[test]
399 fn ipc_roundtrip_primitive() {
400 let array = UInt64Array::from(vec![1, 2, 3, 4, 5]);
401 let data = array.into_data();
402 let encoded = encode_arrow_ipc_data(&data).unwrap();
403 let decoded = decode_arrow_ipc_data(&encoded).unwrap();
404 assert_eq!(data, decoded);
405 }
406
407 /// Build an IPC stream that (invalidly) carries the same single-column
408 /// batch twice, to exercise the multi-batch rejection on both decoders.
409 fn two_batch_ipc_stream() -> Vec<u8> {
410 use arrow::array::ArrayRef;
411 use arrow::ipc::writer::StreamWriter;
412 use arrow::record_batch::RecordBatch;
413 use arrow_schema::{Field, Schema};
414 use std::sync::Arc;
415
416 let array = UInt64Array::from(vec![1, 2, 3]);
417 let schema = Arc::new(Schema::new(vec![Field::new(
418 "data",
419 array.data_type().clone(),
420 true,
421 )]));
422 let batch =
423 RecordBatch::try_new(schema.clone(), vec![Arc::new(array) as ArrayRef]).unwrap();
424
425 let mut buf = Vec::new();
426 {
427 let mut writer = StreamWriter::try_new(&mut buf, &schema).unwrap();
428 writer.write(&batch).unwrap();
429 writer.write(&batch).unwrap();
430 writer.finish().unwrap();
431 }
432 buf
433 }
434
435 /// A stream carrying more than one record batch must be rejected rather
436 /// than silently decoded down to its first batch, which would drop the rest
437 /// of the payload with no error. `decode_arrow_ipc` documents "exactly one
438 /// record batch".
439 #[test]
440 fn ipc_decode_rejects_multiple_batches() {
441 let buf = two_batch_ipc_stream();
442 let err =
443 decode_arrow_ipc_data(&buf).expect_err("a multi-batch IPC stream must be rejected");
444 assert!(
445 err.to_string().contains("more than one"),
446 "unexpected error: {err}"
447 );
448 }
449
450 /// The zero-copy decoder backs the actual inter-node receive path, so it
451 /// must reject a multi-batch stream too rather than silently returning only
452 /// the first batch.
453 #[test]
454 fn ipc_zero_copy_decode_rejects_multiple_batches() {
455 let buf = two_batch_ipc_stream();
456 let err = decode_arrow_ipc_zero_copy_raw(arrow::buffer::Buffer::from_vec(buf))
457 .expect_err("a multi-batch IPC stream must be rejected");
458 assert!(
459 err.to_string().contains("more than one"),
460 "unexpected error: {err}"
461 );
462 }
463
464 /// A valid single-batch stream with extra bytes appended after its
465 /// end-of-stream marker must be rejected by *both* decoders. The arrow-ipc
466 /// `StreamDecoder` consumes the 8-byte end-of-stream marker and then errors
467 /// on any further byte, and `StreamReader` stops at the marker; without an
468 /// explicit check the two paths would disagree (one rejecting, one silently
469 /// ignoring the tail). No in-tree sender produces trailing bytes, so
470 /// rejecting them keeps the two decoders in lockstep on the same input.
471 #[test]
472 fn ipc_decode_rejects_trailing_bytes() {
473 let array = UInt64Array::from(vec![1, 2, 3]);
474 let mut encoded = encode_arrow_ipc_data(&array.into_data()).unwrap();
475 // Distinct from the `0xFF..` end-of-stream continuation marker, so these
476 // are unambiguously bytes *after* a complete stream.
477 encoded.extend_from_slice(&[0u8; 16]);
478
479 let err = decode_arrow_ipc_data(&encoded)
480 .expect_err("a stream with trailing bytes must be rejected (StreamReader)");
481 assert!(
482 err.to_string().contains("trailing bytes"),
483 "unexpected error: {err}"
484 );
485
486 let err = decode_arrow_ipc_zero_copy_raw(arrow::buffer::Buffer::from_vec(encoded))
487 .expect_err("a stream with trailing bytes must be rejected (zero-copy)");
488 assert!(
489 err.to_string().contains("trailing bytes"),
490 "unexpected error: {err}"
491 );
492 }
493
494 /// An array whose IPC stream exceeds `MAX_IPC_BYTES` must fail at encode
495 /// time. Otherwise the sender would emit a stream that every receive path
496 /// rejects, silently dropping the message with no producer-side error.
497 #[test]
498 fn ipc_encode_rejects_oversized_payload() {
499 use arrow::array::UInt8Array;
500
501 // Body just over the 256 MB cap; the framing pushes the stream over too.
502 let array = UInt8Array::from(vec![0u8; MAX_IPC_BYTES + 1]);
503 let data = array.into_data();
504 let err = encode_arrow_ipc_data(&data)
505 .expect_err("oversized payload must be rejected by the encoder");
506 assert!(
507 err.to_string().contains("too large"),
508 "unexpected error: {err}"
509 );
510 }
511
512 /// Copy `bytes` into a 128-byte-aligned buffer, mirroring how Dora's
513 /// receive path backs IPC payloads (an `AVec<u8, ConstAlign<128>>` for the
514 /// daemon path, page-aligned Zenoh SHM for the zero-copy path). This is the
515 /// precondition under which `decode_arrow_ipc_zero_copy` aliases the input.
516 fn aligned_buffer_from(bytes: &[u8]) -> (arrow::buffer::Buffer, usize, usize) {
517 use aligned_vec::{AVec, ConstAlign};
518 use std::ptr::NonNull;
519
520 let mut aligned: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, bytes.len());
521 aligned.copy_from_slice(bytes);
522 let base = aligned.as_ptr() as usize;
523 let len = aligned.len();
524 let ptr = NonNull::new(aligned.as_ptr() as *mut u8).unwrap();
525 // SAFETY: `ptr`/`len` describe `aligned`'s allocation, which the Arc
526 // keeps alive for the Buffer's lifetime.
527 let buffer = unsafe {
528 arrow::buffer::Buffer::from_custom_allocation(ptr, len, std::sync::Arc::new(aligned))
529 };
530 (buffer, base, len)
531 }
532
533 #[test]
534 fn ipc_zero_copy_roundtrip_primitive() {
535 let array = UInt64Array::from((0..1000u64).collect::<Vec<_>>());
536 let data = array.into_data();
537 let encoded = encode_arrow_ipc_data(&data).unwrap();
538 let (buffer, _, _) = aligned_buffer_from(&encoded);
539 let decoded = decode_arrow_ipc_zero_copy_raw(buffer).unwrap();
540 assert_eq!(data, decoded);
541 }
542
543 /// The headline claim: for an aligned input buffer the decoded array's data
544 /// buffer points *into* the input allocation (no payload copy), and the
545 /// strict `require_alignment(true)` decoder accepts it without falling back
546 /// to a realigning copy.
547 #[test]
548 fn ipc_decode_is_zero_copy_for_aligned_buffer() {
549 use arrow::ipc::reader::StreamDecoder;
550
551 // A large primitive array so the data buffer dominates and any copy
552 // would be unmistakable.
553 let array = UInt64Array::from((0..100_000u64).collect::<Vec<_>>());
554 let data = array.into_data();
555 let encoded = encode_arrow_ipc_data(&data).unwrap();
556
557 // 1) Proof via the strict decoder: require_alignment(true) errors if any
558 // buffer would need realigning. A clean decode proves the body
559 // buffers are used in place.
560 {
561 let (mut buffer, _, _) = aligned_buffer_from(&encoded);
562 let mut decoder = StreamDecoder::new().with_require_alignment(true);
563 let mut got = None;
564 while !buffer.is_empty() {
565 if let Some(b) = decoder
566 .decode(&mut buffer)
567 .expect("aligned IPC buffer must decode without realignment")
568 {
569 got = Some(b);
570 break;
571 }
572 }
573 assert_eq!(got.unwrap().column(0).to_data(), data);
574 }
575
576 // 2) Proof via pointer aliasing: the decoded data buffer lies within the
577 // input allocation's address range.
578 {
579 let (buffer, base, len) = aligned_buffer_from(&encoded);
580 let decoded = decode_arrow_ipc_zero_copy_raw(buffer).unwrap();
581 let data_ptr = decoded.buffers()[0].as_ptr() as usize;
582 assert!(
583 data_ptr >= base && data_ptr < base + len,
584 "decoded data buffer at {data_ptr:#x} is outside input \
585 [{base:#x}, {:#x}) — a copy happened (not zero-copy)",
586 base + len
587 );
588 }
589 }
590
591 /// Production safety: an *under-aligned* input must still decode correctly.
592 /// The default decoder (`require_alignment = false`) falls back to copying
593 /// only the misaligned buffers rather than erroring.
594 #[test]
595 fn ipc_zero_copy_decoder_handles_misaligned_input() {
596 let array = UInt64Array::from(vec![1, 2, 3, 4, 5, 6, 7, 8]);
597 let data = array.into_data();
598 let encoded = encode_arrow_ipc_data(&data).unwrap();
599
600 // Force a 1-byte-offset (deliberately misaligned) backing buffer.
601 let mut shifted = Vec::with_capacity(encoded.len() + 1);
602 shifted.push(0u8);
603 shifted.extend_from_slice(&encoded);
604 let buffer = arrow::buffer::Buffer::from_vec(shifted).slice(1);
605
606 let decoded = decode_arrow_ipc_zero_copy_raw(buffer).unwrap();
607 assert_eq!(data, decoded);
608 }
609
610 #[test]
611 fn ipc_roundtrip_string() {
612 let array = StringArray::from(vec!["hello", "world"]);
613 let data = array.into_data();
614 let encoded = encode_arrow_ipc_data(&data).unwrap();
615 let decoded = decode_arrow_ipc_data(&encoded).unwrap();
616 assert_eq!(data, decoded);
617 }
618
619 #[test]
620 fn ipc_roundtrip_empty_array() {
621 let array = UInt64Array::from(Vec::<u64>::new());
622 let data = array.into_data();
623 let encoded = encode_arrow_ipc_data(&data).unwrap();
624 let decoded = decode_arrow_ipc_data(&encoded).unwrap();
625 assert_eq!(data.len(), decoded.len());
626 }
627
628 /// A zero-length *typed* array must encode to a self-describing stream that
629 /// decodes back to the SAME type, not `Null`. record/replay relies on this:
630 /// `record-node` IPC-encodes empty typed arrays (rather than dropping them
631 /// to an absent payload) so replay preserves the type instead of collapsing
632 /// to `NullArray::new(0)` (#2027/#2083).
633 #[test]
634 fn ipc_roundtrip_empty_typed_array_preserves_type() {
635 use arrow::array::Float32Array;
636 let data = Float32Array::from(Vec::<f32>::new()).into_data();
637 let encoded = encode_arrow_ipc_data(&data).unwrap();
638 let decoded = decode_arrow_ipc_data(&encoded).unwrap();
639 assert_eq!(decoded.data_type(), &arrow_schema::DataType::Float32);
640 assert_eq!(decoded.len(), 0);
641 }
642
643 #[test]
644 fn ipc_roundtrip_with_nulls() {
645 let array = UInt64Array::from(vec![Some(1), None, Some(3)]);
646 let data = array.into_data();
647 let encoded = encode_arrow_ipc_data(&data).unwrap();
648 let decoded = decode_arrow_ipc_data(&encoded).unwrap();
649 assert_eq!(data, decoded);
650 }
651}