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/// # 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}