Skip to main content

dora_node_api/node/arrow_utils/
ipc_encode.rs

1//! Hand-rolled, 1-copy Arrow IPC stream encoder.
2//!
3//! Arrow's official [`StreamWriter`](arrow::ipc::writer::StreamWriter) always
4//! stages the record-batch body in an internal `Vec` before writing it out, so
5//! encoding a message through it copies the payload at least twice. This module
6//! provides a *fast path* that writes the IPC flatbuffer headers and copies each
7//! array buffer **directly into a caller-provided `&mut [u8]`** — exactly one
8//! copy of the payload, straight into the (shared-memory) sample. For types the
9//! fast path does not handle it falls back to the official writer.
10//!
11//! The fast-path output is a normal Arrow IPC stream and decodes through the
12//! official [`StreamDecoder`](arrow::ipc::reader::StreamDecoder) — including the
13//! zero-copy [`decode_arrow_ipc_zero_copy`](super::decode_arrow_ipc_zero_copy)
14//! receive path — because every body buffer is placed at a 64-byte-aligned
15//! offset, matching what the official writer produces with its default
16//! `alignment = 64`.
17//!
18//! ## How the fast path stays correct without slice-truncation logic
19//!
20//! Arrow's writer contains a lot of per-type code to *truncate* buffers for
21//! sliced arrays. We sidestep all of it with two rules:
22//!  * **Require `offset() == 0` on every node** — so logical element `i` lives at
23//!    physical position `i`. Any array (or child) with a non-zero offset routes
24//!    to the fallback.
25//!  * **Copy each data buffer in full.** Arrow tolerates buffers that are larger
26//!    than strictly required for `len` elements, so copying the whole buffer (a
27//!    freshly built array's buffers are exactly sized anyway) always decodes to
28//!    a logically-equal array. The only generated buffer is the all-ones
29//!    validity bitmap for a node with no nulls, exactly as arrow emits.
30//!
31//! Two types need their children sliced before recursion because the child's
32//! IPC length is the parent's rather than the child's own: `Struct` (each field
33//! to the struct's `len`) and `FixedSizeList` (its child to `len * value_size`).
34//! `List`/`LargeList` children are bounded by an offsets buffer and recursed at
35//! full length.
36
37use arrow::array::ArrayData;
38use arrow::buffer::Buffer as ArrowBuffer;
39use arrow::ipc::writer::{DictionaryTracker, IpcDataGenerator, IpcWriteOptions};
40use arrow::ipc::{Buffer as IpcBuffer, FieldNode, MessageHeader, MetadataVersion};
41use arrow_schema::{DataType, Field, Schema};
42use eyre::{Context, bail, eyre};
43
44use super::ARROW_BUFFER_ALIGNMENT as ALIGN;
45
46/// IPC stream continuation marker (precedes every message length prefix).
47const CONTINUATION_MARKER: [u8; 4] = [0xff, 0xff, 0xff, 0xff];
48/// Bytes of the message length/continuation prefix (continuation + i32 length).
49const PREFIX_LEN: usize = 8;
50
51#[inline]
52fn round_up(n: usize, align: usize) -> usize {
53    debug_assert!(align.is_power_of_two());
54    (n + align - 1) & !(align - 1)
55}
56
57/// Whether the fast path handles this `DataType` directly (per node — children
58/// are validated by recursion). Everything else uses the official-writer
59/// fallback: dictionary, any `*View`, `Union`, `Map`, run-end-encoded, etc.
60fn is_fast_path_type(data_type: &DataType) -> bool {
61    use DataType::*;
62    matches!(
63        data_type,
64        Null | Boolean
65            | Int8
66            | Int16
67            | Int32
68            | Int64
69            | UInt8
70            | UInt16
71            | UInt32
72            | UInt64
73            | Float16
74            | Float32
75            | Float64
76            | Timestamp(_, _)
77            | Date32
78            | Date64
79            | Time32(_)
80            | Time64(_)
81            | Duration(_)
82            | Interval(_)
83            | Decimal128(_, _)
84            | Decimal256(_, _)
85            | FixedSizeBinary(_)
86            | Binary
87            | LargeBinary
88            | Utf8
89            | LargeUtf8
90            | List(_)
91            | LargeList(_)
92            | FixedSizeList(_, _)
93            | Struct(_)
94    )
95}
96
97/// One body buffer to emit: a 64-aligned body offset, its byte length, and where
98/// the bytes come from.
99struct Desc {
100    offset: usize,
101    len: usize,
102    src: BufferSrc,
103}
104
105enum BufferSrc {
106    /// Copy `len` bytes from this buffer starting at the given byte offset.
107    Bytes(ArrowBuffer, usize),
108    /// Fill `len` bytes with `0xff` (an all-valid validity bitmap).
109    AllOnes,
110}
111
112#[derive(Default)]
113struct Layout {
114    nodes: Vec<FieldNode>,
115    ipc_buffers: Vec<IpcBuffer>,
116    descs: Vec<Desc>,
117    body_len: usize,
118}
119
120impl Layout {
121    /// Append a buffer descriptor at the current (64-aligned) offset and advance
122    /// past it (padded to 64). Returns `None` if the running body size would
123    /// overflow `usize` or exceed [`super::MAX_IPC_BYTES`] — such arrays route to
124    /// the fallback rather than wrapping to a too-small offset.
125    fn push_buffer(&mut self, off: &mut usize, len: usize, src: BufferSrc) -> Option<()> {
126        // `*off` is always 64-aligned here (the invariant `push_buffer` keeps).
127        let padded = len.checked_add(ALIGN - 1).map(|n| n & !(ALIGN - 1))?;
128        let next = off.checked_add(padded)?;
129        if next > super::MAX_IPC_BYTES {
130            return None;
131        }
132        self.ipc_buffers
133            .push(IpcBuffer::new(*off as i64, len as i64));
134        self.descs.push(Desc {
135            offset: *off,
136            len,
137            src,
138        });
139        *off = next;
140        Some(())
141    }
142}
143
144/// Walk `array` building the IPC field-node list, buffer descriptors, and body
145/// length. Returns `None` if any node is not fast-path eligible (unsupported
146/// type or non-zero offset), so the caller falls back to the official writer.
147fn build_layout(array: &ArrayData) -> Option<Layout> {
148    let mut layout = Layout::default();
149    let mut off = 0usize;
150    build_layout_rec(array, &mut layout, &mut off)?;
151    layout.body_len = off;
152    Some(layout)
153}
154
155fn build_layout_rec(array: &ArrayData, layout: &mut Layout, off: &mut usize) -> Option<()> {
156    let data_type = array.data_type();
157    if !is_fast_path_type(data_type) || array.offset() != 0 {
158        return None;
159    }
160
161    let len = array.len();
162    // NullArray reports `null_count == 0` on its `ArrayData`, but the IPC field
163    // node records every element as null (matching arrow's writer).
164    let null_count = if matches!(data_type, DataType::Null) {
165        len
166    } else {
167        array.null_count()
168    };
169    layout
170        .nodes
171        .push(FieldNode::new(len as i64, null_count as i64));
172
173    // Validity bitmap (every type except `Null` carries one in IPC V5; the
174    // fast-path set excludes the other no-validity types — Union/RunEndEncoded).
175    if !matches!(data_type, DataType::Null) {
176        match array.nulls() {
177            Some(nulls) => {
178                if nulls.inner().offset() != 0 {
179                    return None;
180                }
181                let sliced = nulls.inner().sliced();
182                let bytes = sliced.len();
183                layout.push_buffer(off, bytes, BufferSrc::Bytes(sliced, 0))?;
184            }
185            None => {
186                let bytes = len.div_ceil(8);
187                layout.push_buffer(off, bytes, BufferSrc::AllOnes)?;
188            }
189        }
190    }
191
192    // Data buffers, copied in full (empty for Struct/FixedSizeList).
193    for buffer in array.buffers() {
194        layout.push_buffer(off, buffer.len(), BufferSrc::Bytes(buffer.clone(), 0))?;
195    }
196
197    // Children.
198    match data_type {
199        DataType::FixedSizeList(_, value_size) => {
200            // The child length is implied (`len * value_size`), not carried by
201            // an offsets buffer, so slice it to exactly that before recursing.
202            let n = len.checked_mul(*value_size as usize)?;
203            let child = array.child_data().first()?;
204            if child.len() < n {
205                return None;
206            }
207            build_layout_rec(&child.slice(0, n), layout, off)?;
208        }
209        DataType::Struct(_) => {
210            // Every struct field's IPC field node must report the struct's row
211            // count. A child `ArrayData` may legally be *longer* than the struct
212            // (e.g. built via the low-level builder), so slice each child to the
213            // struct's `len` before recursing — otherwise the child node would
214            // declare a different length and the stream would be un-decodable.
215            for child in array.child_data() {
216                if child.len() < len {
217                    return None;
218                }
219                build_layout_rec(&child.slice(0, len), layout, off)?;
220            }
221        }
222        _ => {
223            // List/LargeList: the single child is the values array, bounded by
224            // the offsets buffer (its length is independent of the parent's), so
225            // it is recursed at full length.
226            for child in array.child_data() {
227                build_layout_rec(child, layout, off)?;
228            }
229        }
230    }
231
232    Some(())
233}
234
235/// Everything needed to write the stream, plus the exact total length.
236struct Prepared {
237    layout: Layout,
238    schema_message: Vec<u8>,
239    record_batch_message: Vec<u8>,
240    schema_block: usize,
241    record_batch_block: usize,
242    total: usize,
243}
244
245fn ipc_write_options() -> eyre::Result<IpcWriteOptions> {
246    IpcWriteOptions::try_new(ALIGN, false, MetadataVersion::V5)
247        .map_err(|e| eyre!("failed to build Arrow IPC write options: {e}"))
248}
249
250/// Build the schema IPC message flatbuffer (one nullable field named `data`),
251/// matching what `encode_arrow_ipc` / the official writer emit.
252fn build_schema_message(data_type: &DataType) -> eyre::Result<Vec<u8>> {
253    let schema = Schema::new(vec![Field::new("data", data_type.clone(), true)]);
254    let options = ipc_write_options()?;
255    let mut tracker = DictionaryTracker::new(false);
256    let encoded = IpcDataGenerator {}.schema_to_bytes_with_dictionary_tracker(
257        &schema,
258        &mut tracker,
259        &options,
260    );
261    Ok(encoded.ipc_message)
262}
263
264/// Hand-build the RecordBatch IPC message flatbuffer (header only — no body),
265/// mirroring arrow's `record_batch_to_bytes`.
266fn build_record_batch_message(
267    num_rows: usize,
268    nodes: &[FieldNode],
269    buffers: &[IpcBuffer],
270    body_len: usize,
271) -> Vec<u8> {
272    use flatbuffers::FlatBufferBuilder;
273
274    let mut fbb = FlatBufferBuilder::new();
275    let buffers_fb = fbb.create_vector(buffers);
276    let nodes_fb = fbb.create_vector(nodes);
277
278    let record_batch = {
279        let mut builder = arrow::ipc::RecordBatchBuilder::new(&mut fbb);
280        builder.add_length(num_rows as i64);
281        builder.add_nodes(nodes_fb);
282        builder.add_buffers(buffers_fb);
283        builder.finish()
284    };
285
286    let message = {
287        let mut builder = arrow::ipc::MessageBuilder::new(&mut fbb);
288        builder.add_version(MetadataVersion::V5);
289        builder.add_header_type(MessageHeader::RecordBatch);
290        builder.add_bodyLength(body_len as i64);
291        builder.add_header(record_batch.as_union_value());
292        builder.finish()
293    };
294
295    fbb.finish(message, None);
296    fbb.finished_data().to_vec()
297}
298
299fn prepare(array: &ArrayData) -> Option<Prepared> {
300    let layout = build_layout(array)?;
301    let schema_message = build_schema_message(array.data_type()).ok()?;
302    let record_batch_message = build_record_batch_message(
303        array.len(),
304        &layout.nodes,
305        &layout.ipc_buffers,
306        layout.body_len,
307    );
308    let schema_block = round_up(PREFIX_LEN + schema_message.len(), ALIGN);
309    let record_batch_block = round_up(PREFIX_LEN + record_batch_message.len(), ALIGN);
310    // schema block + record-batch header block + body + end-of-stream (8 bytes).
311    let total = schema_block + record_batch_block + layout.body_len + PREFIX_LEN;
312    // `push_buffer` only bounds the running body length; the schema and
313    // record-batch header blocks push `total` slightly higher, so a body just
314    // under MAX_IPC_BYTES can still yield a stream over it. Bound the whole
315    // stream here so the fast path declines (routing to the fallback encoder,
316    // which bails loudly) rather than emitting a stream every receiver rejects
317    // — matching the whole-stream limit the decoders enforce (#2586).
318    if total > super::MAX_IPC_BYTES {
319        return None;
320    }
321    Some(Prepared {
322        layout,
323        schema_message,
324        record_batch_message,
325        schema_block,
326        record_batch_block,
327        total,
328    })
329}
330
331/// Exact byte length of the IPC stream [`encode_ipc_into`] would write, or
332/// `None` if `array` is not fast-path eligible (use [`encode_ipc_to_vec`]).
333///
334/// Lets a caller size the (shared-memory) sample before encoding into it.
335pub fn ipc_fast_path_len(array: &ArrayData) -> Option<usize> {
336    prepare(array).map(|p| p.total)
337}
338
339/// Frame one IPC message into `dst[at..]`: continuation marker, i32-LE metadata
340/// length, the flatbuffer, then zero padding so the block is 64-aligned (which
341/// makes the following body — or next message — start 64-aligned). Returns the
342/// block size written.
343fn write_framed_message(dst: &mut [u8], at: usize, flatbuffer: &[u8]) -> usize {
344    let block = round_up(PREFIX_LEN + flatbuffer.len(), ALIGN);
345    let metadata_len = (block - PREFIX_LEN) as i32;
346    dst[at..at + 4].copy_from_slice(&CONTINUATION_MARKER);
347    dst[at + 4..at + 8].copy_from_slice(&metadata_len.to_le_bytes());
348    dst[at + 8..at + 8 + flatbuffer.len()].copy_from_slice(flatbuffer);
349    // Zero the trailing padding (the sample buffer may be uninitialized SHM).
350    dst[at + 8 + flatbuffer.len()..at + block].fill(0);
351    block
352}
353
354/// Encode `array` as a complete Arrow IPC stream directly into `dst`, copying
355/// each array buffer exactly once.
356///
357/// `dst.len()` must equal [`ipc_fast_path_len(array)`](ipc_fast_path_len);
358/// `array` must be fast-path eligible (it is when `ipc_fast_path_len` returned
359/// `Some`).
360pub fn encode_ipc_into(array: &ArrayData, dst: &mut [u8]) -> eyre::Result<()> {
361    let prepared =
362        prepare(array).ok_or_else(|| eyre!("array is not Arrow IPC fast-path eligible"))?;
363    if dst.len() != prepared.total {
364        bail!(
365            "destination size {} does not match required IPC length {}",
366            dst.len(),
367            prepared.total
368        );
369    }
370
371    let mut at = 0;
372    at += write_framed_message(dst, at, &prepared.schema_message);
373    debug_assert_eq!(at, prepared.schema_block);
374    at += write_framed_message(dst, at, &prepared.record_batch_message);
375    debug_assert_eq!(at, prepared.schema_block + prepared.record_batch_block);
376
377    let body_start = at;
378    write_body(dst, body_start, &prepared.layout);
379    at = body_start + prepared.layout.body_len;
380
381    // End-of-stream: continuation marker + zero length.
382    dst[at..at + 4].copy_from_slice(&CONTINUATION_MARKER);
383    dst[at + 4..at + 8].copy_from_slice(&0i32.to_le_bytes());
384    debug_assert_eq!(at + PREFIX_LEN, prepared.total);
385
386    Ok(())
387}
388
389/// Copy each body buffer to its 64-aligned offset within `dst[body_start..]`,
390/// zeroing per-buffer alignment padding (the destination may be uninitialized
391/// SHM). Shared by the full-stream and schema-less-batch encoders.
392fn write_body(dst: &mut [u8], body_start: usize, layout: &Layout) {
393    for desc in &layout.descs {
394        let start = body_start + desc.offset;
395        let end = start + desc.len;
396        match &desc.src {
397            BufferSrc::Bytes(buffer, src_off) => {
398                dst[start..end].copy_from_slice(&buffer.as_slice()[*src_off..*src_off + desc.len]);
399            }
400            BufferSrc::AllOnes => dst[start..end].fill(0xff),
401        }
402        let padded_end = body_start + desc.offset + round_up(desc.len, ALIGN);
403        dst[end..padded_end].fill(0);
404    }
405}
406
407/// Encode the IPC **schema message** for `data_type` as a framed,
408/// 64-byte-aligned block (one nullable field named `data`).
409///
410/// This is the schema prefix of a stream, sent once. A receiver feeds it to a
411/// persistent [`StreamDecoder`](arrow::ipc::reader::StreamDecoder) to prime it,
412/// then decodes a sequence of schema-less batch messages from
413/// [`encode_batch_into`] against the same decoder (the W3 schema-once path).
414pub fn encode_schema_message(data_type: &DataType) -> eyre::Result<Vec<u8>> {
415    let schema_message = build_schema_message(data_type)?;
416    let block = round_up(PREFIX_LEN + schema_message.len(), ALIGN);
417    let mut dst = vec![0u8; block];
418    write_framed_message(&mut dst, 0, &schema_message);
419    Ok(dst)
420}
421
422/// Exact byte length of the schema-less batch message [`encode_batch_into`]
423/// would write (record-batch header block + body + 8-byte end-of-stream marker,
424/// **no** schema prefix), or `None` if `array` is not fast-path eligible.
425pub fn batch_fast_path_len(array: &ArrayData) -> Option<usize> {
426    let prepared = prepare(array)?;
427    Some(prepared.record_batch_block + prepared.layout.body_len + PREFIX_LEN)
428}
429
430/// Encode `array` as a schema-less Arrow IPC **batch message** into `dst`:
431/// the record-batch header block, the body, and a trailing 8-byte end-of-stream
432/// marker (no schema prefix). Decoded by a [`StreamDecoder`] already primed with
433/// the matching [`encode_schema_message`]. The trailing marker lets the decoder
434/// flush a 0-row batch (empty body); a non-empty batch never reads it. `dst.len()`
435/// must equal [`batch_fast_path_len(array)`](batch_fast_path_len).
436pub fn encode_batch_into(array: &ArrayData, dst: &mut [u8]) -> eyre::Result<()> {
437    let prepared =
438        prepare(array).ok_or_else(|| eyre!("array is not Arrow IPC fast-path eligible"))?;
439    let expected = prepared.record_batch_block + prepared.layout.body_len + PREFIX_LEN;
440    if dst.len() != expected {
441        bail!(
442            "destination size {} does not match required batch length {expected}",
443            dst.len(),
444        );
445    }
446    let at = write_framed_message(dst, 0, &prepared.record_batch_message);
447    debug_assert_eq!(at, prepared.record_batch_block);
448    write_body(dst, at, &prepared.layout);
449    // End-of-stream marker (continuation + zero length), matching the tail of a
450    // full stream so an empty batch flushes through the persistent decoder.
451    let body_end = at + prepared.layout.body_len;
452    dst[body_end..body_end + 4].copy_from_slice(&CONTINUATION_MARKER);
453    dst[body_end + 4..body_end + PREFIX_LEN].copy_from_slice(&0i32.to_le_bytes());
454    Ok(())
455}
456
457/// Fallback encoder for any array (including non-fast-path types): produce a
458/// full Arrow IPC stream `Vec` via the official writer. The caller copies this
459/// into the sample, so this path costs two payload copies.
460pub fn encode_ipc_to_vec(array: &ArrayData) -> eyre::Result<Vec<u8>> {
461    super::encode_arrow_ipc(array).context("Arrow IPC fallback encode")
462}
463
464/// IPC layout of a `UInt8` array of `data_len` elements (no nulls): the byte
465/// offsets and message blocks, computed directly without materializing the
466/// data buffer (so it is cheap for a large image/tensor).
467struct Uint8Layout {
468    schema_message: Vec<u8>,
469    record_batch_message: Vec<u8>,
470    validity_len: usize,
471    validity_padded: usize,
472    body_len: usize,
473    total: usize,
474    data_offset: usize,
475}
476
477fn uint8_layout(data_len: usize) -> eyre::Result<Uint8Layout> {
478    // Cheap pre-filter: the emitted stream is always >= `data_len`, so anything
479    // over the limit is rejected below regardless, and bounding `data_len` here
480    // keeps the round_up/add arithmetic that computes `total` well within
481    // `usize` range.
482    if data_len > super::MAX_IPC_BYTES {
483        bail!(
484            "UInt8 payload too large: {data_len} bytes (max {})",
485            super::MAX_IPC_BYTES
486        );
487    }
488    let validity_len = data_len.div_ceil(8);
489    let validity_padded = round_up(validity_len, ALIGN);
490    let body_len = validity_padded + round_up(data_len, ALIGN);
491    // Matches what `encode_ipc_into` emits for a no-null UInt8Array: one field
492    // node, then [validity all-ones | data].
493    let nodes = [FieldNode::new(data_len as i64, 0)];
494    let buffers = [
495        IpcBuffer::new(0, validity_len as i64),
496        IpcBuffer::new(validity_padded as i64, data_len as i64),
497    ];
498    let record_batch_message = build_record_batch_message(data_len, &nodes, &buffers, body_len);
499    let schema_message = build_schema_message(&DataType::UInt8)?;
500    let schema_block = round_up(PREFIX_LEN + schema_message.len(), ALIGN);
501    let record_batch_block = round_up(PREFIX_LEN + record_batch_message.len(), ALIGN);
502    let total = schema_block + record_batch_block + body_len + PREFIX_LEN;
503    // Bound the *resulting stream* rather than just `data_len`: the emitted IPC
504    // stream is ~1.125x larger (validity bitmap, 64-byte alignment padding,
505    // schema + record-batch message blocks). Every receiver guards the whole
506    // stream against MAX_IPC_BYTES, so a band of `data_len` just under the limit
507    // would otherwise encode and send here yet be rejected — silently dropped on
508    // the zenoh path — by every receiver. Reject at the producer instead (#2586).
509    if total > super::MAX_IPC_BYTES {
510        bail!(
511            "UInt8 payload too large: {data_len} bytes encodes to a {total}-byte \
512             Arrow IPC stream (max {})",
513            super::MAX_IPC_BYTES
514        );
515    }
516    let data_offset = schema_block + record_batch_block + validity_padded;
517    Ok(Uint8Layout {
518        schema_message,
519        record_batch_message,
520        validity_len,
521        validity_padded,
522        body_len,
523        total,
524        data_offset,
525    })
526}
527
528/// Total IPC stream length for a no-null `UInt8` array of `data_len` elements.
529/// Lets a caller size the sample before constructing the message in place via
530/// [`encode_uint8_ipc_header`].
531pub fn uint8_ipc_len(data_len: usize) -> eyre::Result<usize> {
532    Ok(uint8_layout(data_len)?.total)
533}
534
535/// Write a complete no-null `UInt8` IPC stream into `dst` **except the data
536/// region**, and return the byte offset at which the caller must write the
537/// `data_len` data bytes (the buffer-protocol "construct in place" path).
538///
539/// `dst.len()` must equal [`uint8_ipc_len(data_len)`](uint8_ipc_len). After the
540/// caller fills `dst[offset..offset + data_len]`, `dst` is a valid IPC stream
541/// that decodes to the user's bytes as a `UInt8Array` — with zero payload
542/// copies.
543pub fn encode_uint8_ipc_header(dst: &mut [u8], data_len: usize) -> eyre::Result<usize> {
544    let layout = uint8_layout(data_len)?;
545    if dst.len() != layout.total {
546        bail!(
547            "destination size {} does not match required UInt8 IPC length {}",
548            dst.len(),
549            layout.total
550        );
551    }
552    let mut at = 0;
553    at += write_framed_message(dst, at, &layout.schema_message);
554    at += write_framed_message(dst, at, &layout.record_batch_message);
555    let body_start = at;
556
557    // Validity: all-ones bitmap, then padding to the data buffer.
558    dst[body_start..body_start + layout.validity_len].fill(0xff);
559    dst[body_start + layout.validity_len..body_start + layout.validity_padded].fill(0);
560
561    // The data region [data_offset .. data_offset + data_len] is left for the
562    // caller. Zero only its trailing alignment padding.
563    let data_end = layout.data_offset + data_len;
564    let body_end = body_start + layout.body_len;
565    dst[data_end..body_end].fill(0);
566
567    // End-of-stream marker.
568    dst[body_end..body_end + 4].copy_from_slice(&CONTINUATION_MARKER);
569    dst[body_end + 4..body_end + 8].copy_from_slice(&0i32.to_le_bytes());
570    debug_assert_eq!(body_end + PREFIX_LEN, layout.total);
571
572    Ok(layout.data_offset)
573}
574
575/// Length of the leading schema-message block of a full IPC stream produced by
576/// [`encode_ipc_into`] (so a receiver can split it into the schema prefix and
577/// the record-batch+body), or `None` if `stream` is not a framed IPC message.
578pub fn schema_block_len(stream: &[u8]) -> Option<usize> {
579    if stream.len() < PREFIX_LEN || stream[0..4] != CONTINUATION_MARKER {
580        return None;
581    }
582    let metadata_len = i32::from_le_bytes(stream[4..8].try_into().ok()?);
583    let block = PREFIX_LEN.checked_add(usize::try_from(metadata_len).ok()?)?;
584    (block <= stream.len()).then_some(block)
585}
586
587/// Schema identity of a full IPC stream: the FNV-1a hash of its leading schema
588/// block, plus the block itself. This pairing IS the schema-once wire contract
589/// — the producer (`publish_schema_once`), the node receive path (in-band
590/// priming), and the daemon's `dora topic` rebuild all derive the hash from
591/// exactly these bytes via this function; independent re-derivations could
592/// drift and silently break batch↔schema matching.
593pub fn schema_block_and_hash(stream: &[u8]) -> Option<(u64, &[u8])> {
594    let block = schema_block_len(stream)?;
595    let schema = stream.get(..block)?;
596    Some((dora_message::metadata::fnv1a(schema), schema))
597}
598
599/// Given a full IPC stream, return the schema-less record-batch slice —
600/// everything after the schema block, **including** the trailing 8-byte
601/// end-of-stream marker. The marker is what lets the receiver's persistent
602/// decoder flush a 0-row batch (whose body is empty); a non-empty batch never
603/// reads it. `None` if `stream` is malformed. See [`decode_one_batch`].
604pub fn batch_slice(stream: &[u8]) -> Option<&[u8]> {
605    let block = schema_block_len(stream)?;
606    // The stream is [schema block][record-batch block][body][EOS(8)]; keep
607    // everything from the record-batch block onward.
608    (stream.len() >= block + PREFIX_LEN).then(|| &stream[block..])
609}
610
611/// Maximum number of distinct schemas an [`InputDecoder`] retains for local
612/// re-priming. Bounds the memory a misbehaving producer (rotating through many
613/// schemas on one output) can pin in a receiver; beyond it the oldest schema is
614/// evicted and its batches drop until it is re-installed.
615const MAX_RETAINED_SCHEMAS: usize = 8;
616
617/// Per-input receive state for the schema-once zenoh path: one persistent
618/// [`StreamDecoder`](arrow::ipc::reader::StreamDecoder) primed from the schema
619/// published on the output's `@schema` subtopic (or in-band, from the schema
620/// block of a full self-describing stream on the data topic), then reused to
621/// decode the schema-less batch messages that flow on the data topic.
622pub struct InputDecoder {
623    /// Live decoder, primed with the schema for [`schema_hash`](Self::schema_hash).
624    decoder: arrow::ipc::reader::StreamDecoder,
625    /// Hash of the schema the live `decoder` is currently primed with.
626    schema_hash: Option<u64>,
627    /// The framed schema messages installed via [`set_schema`](Self::set_schema),
628    /// keyed by hash (most-recent last, bounded by [`MAX_RETAINED_SCHEMAS`]).
629    /// Retained so the decoder can be re-primed locally (no network round-trip)
630    /// after a failed batch decode soft-resets the live decoder, or when batches
631    /// reference a schema seen earlier (e.g. after in-band priming from a
632    /// different schema's full stream re-primed the live decoder in between).
633    schemas: Vec<(u64, ArrowBuffer)>,
634}
635
636impl Default for InputDecoder {
637    fn default() -> Self {
638        Self::new()
639    }
640}
641
642impl InputDecoder {
643    /// Create an unprimed decoder. It decodes nothing until a schema is
644    /// installed via [`set_schema`](Self::set_schema) (delivered from the output's
645    /// `@schema` subtopic).
646    pub fn new() -> Self {
647        Self {
648            decoder: arrow::ipc::reader::StreamDecoder::new(),
649            schema_hash: None,
650            schemas: Vec::new(),
651        }
652    }
653
654    /// Forget all state — the primed decoder AND the retained schemas. The next
655    /// schema message must re-establish priming. Used on producer restart and
656    /// for lock-poison recovery.
657    pub fn reset(&mut self) {
658        self.decoder = arrow::ipc::reader::StreamDecoder::new();
659        self.schema_hash = None;
660        self.schemas.clear();
661    }
662
663    /// Whether a schema with this hash is already available — live or retained.
664    /// The in-band priming path skips the schema-block copy and the eager
665    /// re-prime for known schemas; [`decode_batch`](Self::decode_batch)
666    /// re-primes lazily from the retained set when a batch actually needs one,
667    /// so eagerly re-priming a retained schema would only churn the live
668    /// decoder (e.g. a large full-stream message of schema B clobbering the
669    /// live prime of schema A between A's schema-less batches).
670    pub fn knows_schema(&self, hash: u64) -> bool {
671        self.schema_hash == Some(hash) || self.schemas.iter().any(|(h, _)| *h == hash)
672    }
673
674    /// Install the schema for `hash` from a framed IPC **schema message** (the
675    /// payload published on the `@schema` subtopic, or the schema block of a
676    /// full stream received in-band): prime a fresh decoder with it and retain
677    /// the bytes for later local re-priming. A no-op when the live decoder is
678    /// already primed with `hash`.
679    pub fn set_schema(&mut self, hash: u64, schema: ArrowBuffer) -> eyre::Result<()> {
680        check_ipc_size(schema.len())?;
681        if self.schema_hash == Some(hash) {
682            return Ok(());
683        }
684        self.prime(hash, schema)
685    }
686
687    /// Prime a fresh `StreamDecoder` with `schema` and record it as the current
688    /// and retained schema for `hash`.
689    fn prime(&mut self, hash: u64, schema: ArrowBuffer) -> eyre::Result<()> {
690        // Create a fresh decoder before priming. In Arrow 59+, even consuming a
691        // schema message can leave the decoder close to terminal state when the
692        // EOS marker is encountered. A fresh decoder ensures we start clean.
693        let mut decoder = arrow::ipc::reader::StreamDecoder::new();
694        prime_with_schema(&mut decoder, schema.clone())?;
695        self.decoder = decoder;
696        self.schema_hash = Some(hash);
697        self.schemas.retain(|(h, _)| *h != hash);
698        self.schemas.push((hash, schema));
699        if self.schemas.len() > MAX_RETAINED_SCHEMAS {
700            self.schemas.remove(0);
701        }
702        Ok(())
703    }
704
705    /// Decode a schema-less batch message against the decoder primed for `hash`.
706    ///
707    /// If the live decoder is not primed for `hash` but a matching schema was
708    /// previously installed (e.g. after a soft reset), it re-primes from the
709    /// retained bytes first. Returns `Ok(None)` when no schema for `hash` is
710    /// known yet — the caller drops the message, which is fine on the lossy
711    /// `CongestionControl::Drop` data plane (the `@schema` history query, the
712    /// next schema publish, or the producer's periodic full-stream refresh —
713    /// which primes in-band — will prime it).
714    pub fn decode_batch(
715        &mut self,
716        buffer: ArrowBuffer,
717        hash: u64,
718    ) -> eyre::Result<Option<arrow::array::ArrayData>> {
719        check_ipc_size(buffer.len())?;
720        if self.schema_hash != Some(hash) {
721            match self.schemas.iter().find(|(h, _)| *h == hash) {
722                Some((_, schema)) => {
723                    let schema = schema.clone();
724                    self.prime(hash, schema)?;
725                }
726                // No schema for this hash known yet — drop (lossy plane).
727                None => return Ok(None),
728            }
729        }
730        match decode_one_batch(&mut self.decoder, buffer) {
731            Ok(array) => {
732                // Arrow 59+ reaches a terminal state after decoding each message's
733                // EOS marker, rejecting further input. Reset the decoder so the next
734                // batch (another independent IPC stream) can be decoded against the
735                // same schema. Clearing schema_hash forces the next batch to re-prime
736                // from the retained schemas (instant lookup, no network round-trip).
737                self.decoder = arrow::ipc::reader::StreamDecoder::new();
738                self.schema_hash = None;
739                Ok(Some(array))
740            }
741            Err(e) => {
742                // A failed batch decode (truncated/corrupt payload — zenoh
743                // tail-loss or a malicious peer) leaves the persistent decoder
744                // mid-message. Soft-reset the LIVE decoder so the next batch is
745                // not fed into that poisoned state (which would misinterpret it
746                // against this message's stale buffers and deliver corrupt data).
747                // The retained schema is kept, so the next batch re-primes
748                // locally — instant recovery, with no wait for a schema refresh.
749                self.decoder = arrow::ipc::reader::StreamDecoder::new();
750                self.schema_hash = None;
751                Err(e)
752            }
753        }
754    }
755}
756
757/// Reject an IPC payload larger than [`super::MAX_IPC_BYTES`] before decoding.
758/// Defense-in-depth against an oversized peer-controlled zenoh payload, mirroring
759/// the guard in [`decode_arrow_ipc_zero_copy`](super::decode_arrow_ipc_zero_copy)
760/// (the persistent-decoder paths receive the same untrusted bytes).
761fn check_ipc_size(len: usize) -> eyre::Result<()> {
762    if len > super::MAX_IPC_BYTES {
763        bail!(
764            "Arrow IPC payload too large: {len} bytes (max {})",
765            super::MAX_IPC_BYTES
766        );
767    }
768    Ok(())
769}
770
771/// Feed a schema message to `decoder`; it must yield no batch.
772fn prime_with_schema(
773    decoder: &mut arrow::ipc::reader::StreamDecoder,
774    mut buffer: ArrowBuffer,
775) -> eyre::Result<()> {
776    while !buffer.is_empty() {
777        let before = buffer.len();
778        if decoder
779            .decode(&mut buffer)
780            .map_err(|e| eyre!("failed to decode IPC schema message: {e}"))?
781            .is_some()
782        {
783            bail!("expected a schema message but got a record batch");
784        }
785        // Guard against a crafted/truncated payload that decodes to no batch
786        // without consuming bytes — otherwise this loop spins forever.
787        if buffer.len() == before {
788            bail!("IPC schema decoder made no progress on a partial/corrupt message");
789        }
790    }
791    Ok(())
792}
793
794/// Feed a record-batch message to `decoder` and return the single decoded array.
795///
796/// The schema-less batch is terminated by an 8-byte end-of-stream marker (see
797/// [`encode_batch_into`]/[`batch_slice`]). For a non-empty batch the body bytes
798/// flush it on their own; the marker matters for a **0-row** batch, whose
799/// zero-length body arrow's `StreamDecoder` only emits when polled with more
800/// input — without the trailing marker an empty array is silently dropped
801/// (PR #2366). The decoder yields the pending batch *before* consuming the
802/// marker's zero length, so it never reaches its terminal state and stays
803/// usable for the next batch.
804fn decode_one_batch(
805    decoder: &mut arrow::ipc::reader::StreamDecoder,
806    mut buffer: ArrowBuffer,
807) -> eyre::Result<arrow::array::ArrayData> {
808    while !buffer.is_empty() {
809        let before = buffer.len();
810        if let Some(batch) = decoder
811            .decode(&mut buffer)
812            .map_err(|e| eyre!("failed to decode IPC record batch: {e}"))?
813        {
814            if batch.num_columns() != 1 {
815                bail!(
816                    "expected 1 column in IPC record batch, got {}",
817                    batch.num_columns()
818                );
819            }
820            return Ok(batch.column(0).to_data());
821        }
822        // Guard against a crafted/truncated payload that decodes to no batch
823        // without consuming bytes — otherwise this loop spins forever.
824        if buffer.len() == before {
825            bail!("IPC batch decoder made no progress on a partial/corrupt message");
826        }
827    }
828    bail!("IPC batch message yielded no record batch")
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834    use crate::arrow_utils::decode_arrow_ipc_zero_copy;
835    use arrow::array::{
836        Array, ArrayRef, BooleanArray, FixedSizeBinaryArray, Float32Array, Int32Array,
837        LargeStringArray, ListArray, NullArray, StringArray, StructArray, UInt8Array, UInt64Array,
838    };
839    use arrow::buffer::Buffer;
840    use arrow::ipc::reader::{StreamDecoder, StreamReader};
841    use arrow_schema::{DataType, Field};
842    use std::io::Cursor;
843    use std::sync::Arc;
844
845    /// Encode via the fast path into a fresh `Vec` sized by `ipc_fast_path_len`.
846    fn fast_encode(array: &ArrayData) -> Vec<u8> {
847        let len = ipc_fast_path_len(array).expect("array should be fast-path eligible");
848        let mut buf = vec![0u8; len];
849        encode_ipc_into(array, &mut buf).expect("fast-path encode");
850        buf
851    }
852
853    /// Decode a complete IPC stream with the OFFICIAL arrow `StreamReader` (the
854    /// oracle for wire correctness — independent of our decoder).
855    fn read_official(bytes: &[u8]) -> ArrayData {
856        let mut reader = StreamReader::try_new(Cursor::new(bytes), None).expect("open IPC stream");
857        let batch = reader
858            .next()
859            .expect("one batch")
860            .expect("batch decodes via official reader");
861        assert_eq!(batch.num_columns(), 1);
862        batch.column(0).to_data()
863    }
864
865    /// Copy `bytes` into a 128-byte-aligned Arrow `Buffer`, mirroring how the
866    /// receive path backs SHM payloads — the precondition for zero-copy decode.
867    fn aligned_buffer(bytes: &[u8]) -> (Buffer, usize, usize) {
868        use aligned_vec::{AVec, ConstAlign};
869        use std::ptr::NonNull;
870        let mut aligned: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, bytes.len());
871        aligned.copy_from_slice(bytes);
872        let base = aligned.as_ptr() as usize;
873        let len = aligned.len();
874        let ptr = NonNull::new(aligned.as_ptr() as *mut u8).unwrap();
875        // SAFETY: ptr/len describe `aligned`; the Arc keeps it alive.
876        let buffer =
877            unsafe { Buffer::from_custom_allocation(ptr, len, std::sync::Arc::new(aligned)) };
878        (buffer, base, len)
879    }
880
881    /// The core assertion: the fast-path stream (1) decodes via the official
882    /// reader to an equal array, and (2) `ipc_fast_path_len` matches the bytes
883    /// written and the stream the reader fully consumes.
884    fn assert_fast_roundtrip(array: &ArrayData) {
885        let encoded = fast_encode(array);
886        let decoded = read_official(&encoded);
887        assert_eq!(array, &decoded, "fast-path stream must decode to the input");
888        // Our own zero-copy decoder must agree too.
889        let (buffer, _, _) = aligned_buffer(&encoded);
890        let zc = decode_arrow_ipc_zero_copy(buffer).expect("zero-copy decode");
891        assert_eq!(array, &zc, "zero-copy decode must equal the input");
892    }
893
894    /// Encode `array` as a schema-less batch message (the fast path's
895    /// `batch_slice` equivalent), as shipped on the schema-once data plane.
896    fn batch_bytes(array: &ArrayData) -> Vec<u8> {
897        let len = batch_fast_path_len(array).unwrap();
898        let mut buf = vec![0u8; len];
899        encode_batch_into(array, &mut buf).unwrap();
900        buf
901    }
902
903    fn batch_buf(array: &ArrayData) -> Buffer {
904        Buffer::from_vec(batch_bytes(array))
905    }
906
907    /// The schema-once receive contract: an `InputDecoder` is primed by a schema
908    /// message (as delivered from the `@schema` subtopic), then decodes the
909    /// schema-less batches that follow, and drops a batch whose hash it isn't
910    /// primed for.
911    #[test]
912    fn input_decoder_schema_then_batches() {
913        let f32_schema = || Buffer::from_vec(encode_schema_message(&DataType::Float32).unwrap());
914
915        let mut dec = InputDecoder::new();
916
917        // A batch arriving before any schema is dropped (not primed).
918        let early = Float32Array::from(vec![9.0]).into_data();
919        assert!(dec.decode_batch(batch_buf(&early), 7).unwrap().is_none());
920
921        // Installing the schema primes the decoder; following batches decode.
922        dec.set_schema(7, f32_schema()).unwrap();
923        for vals in [vec![1.0f32, 2.0, 3.0], vec![4.0], vec![5.0, 6.0, 7.0]] {
924            let array = Float32Array::from(vals).into_data();
925            assert_eq!(
926                dec.decode_batch(batch_buf(&array), 7).unwrap().unwrap(),
927                array
928            );
929        }
930
931        // A batch tagged with a hash the decoder isn't primed for is dropped.
932        let other = Float32Array::from(vec![8.0]).into_data();
933        assert!(dec.decode_batch(batch_buf(&other), 99).unwrap().is_none());
934
935        // Installing a schema under the new hash primes it; its batches decode.
936        dec.set_schema(99, f32_schema()).unwrap();
937        let after = Float32Array::from(vec![10.0, 11.0]).into_data();
938        assert_eq!(
939            dec.decode_batch(batch_buf(&after), 99).unwrap().unwrap(),
940            after
941        );
942    }
943
944    /// Priming a new schema must not forget previously seen ones: batches for a
945    /// schema installed earlier still decode after the live decoder was re-primed
946    /// with a different schema in between (re-primed locally from the retained
947    /// set). Without this, in-band priming from a full stream (e.g. a large
948    /// message or a schema-change message on the same output) would clobber the
949    /// schema that later schema-less batches reference, silently dropping them.
950    #[test]
951    fn input_decoder_retains_multiple_schemas() {
952        let schema_msg = |dt: &DataType| Buffer::from_vec(encode_schema_message(dt).unwrap());
953
954        let mut dec = InputDecoder::new();
955        dec.set_schema(1, schema_msg(&DataType::Float32)).unwrap();
956        dec.set_schema(2, schema_msg(&DataType::Int32)).unwrap();
957
958        // Live decoder is primed for hash 2 …
959        let ints = Int32Array::from(vec![1, 2, 3]).into_data();
960        assert_eq!(
961            dec.decode_batch(batch_buf(&ints), 2).unwrap().unwrap(),
962            ints
963        );
964
965        // … but a batch for hash 1 must still decode (retained schema).
966        let floats = Float32Array::from(vec![4.0, 5.0]).into_data();
967        assert_eq!(
968            dec.decode_batch(batch_buf(&floats), 1).unwrap().unwrap(),
969            floats,
970            "a schema installed earlier must be retained across later primes"
971        );
972
973        // And switching back again also works.
974        let more_ints = Int32Array::from(vec![6]).into_data();
975        assert_eq!(
976            dec.decode_batch(batch_buf(&more_ints), 2).unwrap().unwrap(),
977            more_ints
978        );
979    }
980
981    /// The retained-schema set is bounded: schemas beyond the cap evict the
982    /// oldest, whose batches then drop until it is re-installed.
983    /// Test that schema-less batches can be decoded repeatedly after the
984    /// decoder is soft-reset between batches (Arrow 59+ terminal state fix).
985    /// This is the regression test for PR #2445/#2366 interaction with Arrow 59.
986    #[test]
987    fn input_decoder_handles_sequential_batches_arrow_59_terminal_state() {
988        let f32_schema = || Buffer::from_vec(encode_schema_message(&DataType::Float32).unwrap());
989
990        let mut dec = InputDecoder::new();
991        // Prime with a schema.
992        dec.set_schema(7, f32_schema()).unwrap();
993
994        // Decode 5 schema-less batches sequentially. Each one should decode
995        // correctly despite the decoder being reset between batches (soft-reset
996        // for Arrow 59 terminal state handling).
997        let batches = [
998            Float32Array::from(vec![1.0, 2.0]).into_data(),
999            Float32Array::from(vec![3.0]).into_data(),
1000            Float32Array::from(vec![4.0, 5.0, 6.0]).into_data(),
1001            Float32Array::from(vec![7.0, 8.0]).into_data(),
1002            Float32Array::from(vec![9.0, 10.0, 11.0, 12.0]).into_data(),
1003        ];
1004
1005        for (i, batch) in batches.iter().enumerate() {
1006            let result = dec.decode_batch(batch_buf(batch), 7);
1007            assert!(
1008                result.is_ok(),
1009                "batch {} decode failed: {:?}",
1010                i,
1011                result.err()
1012            );
1013            let decoded = result.unwrap().unwrap();
1014            assert_eq!(
1015                &decoded, batch,
1016                "batch {} mismatch: expected {:?}, got {:?}",
1017                i, batch, decoded
1018            );
1019        }
1020    }
1021
1022    #[test]
1023    fn input_decoder_evicts_oldest_schema_beyond_cap() {
1024        let f32_schema = || Buffer::from_vec(encode_schema_message(&DataType::Float32).unwrap());
1025
1026        let mut dec = InputDecoder::new();
1027        // Install cap + 1 distinct hashes (same schema bytes — only the hash
1028        // keys retention); hash 0 must be evicted, the rest retained.
1029        for hash in 0..=(MAX_RETAINED_SCHEMAS as u64) {
1030            dec.set_schema(hash, f32_schema()).unwrap();
1031        }
1032        let array = Float32Array::from(vec![1.0]).into_data();
1033        assert!(
1034            dec.decode_batch(batch_buf(&array), 0).unwrap().is_none(),
1035            "the oldest schema must be evicted beyond the cap"
1036        );
1037        assert_eq!(
1038            dec.decode_batch(batch_buf(&array), 1).unwrap().unwrap(),
1039            array,
1040            "schemas within the cap must be retained"
1041        );
1042    }
1043
1044    /// A failed (truncated) batch decode must soft-reset the persistent decoder
1045    /// so the next valid batch is not misinterpreted against the truncated message's
1046    /// stale state — and, because the schema is retained, that next batch
1047    /// re-primes locally and decodes (instant recovery, not drop-until-refresh).
1048    #[test]
1049    fn decode_batch_resets_on_error_then_reprimes() {
1050        let mut dec = InputDecoder::new();
1051        dec.set_schema(
1052            7,
1053            Buffer::from_vec(encode_schema_message(&DataType::Float32).unwrap()),
1054        )
1055        .unwrap();
1056
1057        // A valid batch decodes.
1058        let good = Float32Array::from(vec![4.0, 5.0]).into_data();
1059        assert_eq!(
1060            dec.decode_batch(batch_buf(&good), 7).unwrap().unwrap(),
1061            good
1062        );
1063
1064        // A truncated batch errors and soft-resets the live decoder. Cut the
1065        // buffer in half so the record-batch header survives but its declared
1066        // body is incomplete (a tail loss) — dropping only the trailing 8-byte
1067        // EOS marker would leave a still-valid batch.
1068        let dropped = Float32Array::from(vec![6.0, 7.0, 8.0, 9.0]).into_data();
1069        let mut truncated = batch_bytes(&dropped);
1070        truncated.truncate(truncated.len() / 2);
1071        assert!(dec.decode_batch(Buffer::from_vec(truncated), 7).is_err());
1072
1073        // The next valid batch (same hash) re-primes from the retained schema and
1074        // decodes correctly — not dropped, not corrupted.
1075        let after = Float32Array::from(vec![10.0, 11.0]).into_data();
1076        assert_eq!(
1077            dec.decode_batch(batch_buf(&after), 7).unwrap().unwrap(),
1078            after,
1079            "after a failed batch the decoder must re-prime from the retained schema"
1080        );
1081
1082        // `reset()` forgets the schema too; batches then drop until re-installed.
1083        dec.reset();
1084        let final_batch = Float32Array::from(vec![12.0]).into_data();
1085        assert!(
1086            dec.decode_batch(batch_buf(&final_batch), 7)
1087                .unwrap()
1088                .is_none(),
1089            "after a full reset the decoder must drop until a schema is re-installed"
1090        );
1091    }
1092
1093    /// Schema-once batch path for a *fallback* (dictionary) type. Dictionary
1094    /// arrays are not fast-path eligible, so the full stream comes from the
1095    /// official writer as `[schema][dictionary batch][record batch][EOS]`. The
1096    /// schema-once optimization still applies to small messages (node/mod.rs),
1097    /// shipping `batch_slice` = `[dictionary batch][record batch]` against a
1098    /// decoder primed from the schema subtopic — i.e. a *replacement* dictionary
1099    /// on every message. The `InputDecoder` tests otherwise use only `Float32`; a
1100    /// failure here is silent intermittent input loss, so it needs an explicit
1101    /// oracle.
1102    #[test]
1103    fn input_decoder_dictionary_fallback_batch_sequence() {
1104        use arrow::array::DictionaryArray;
1105        use arrow::datatypes::Int32Type;
1106
1107        // Build a Dictionary<Int32, Utf8> from words, distinct values in
1108        // first-seen order (deterministic, no iterator-trait ambiguity).
1109        fn dict(words: &[&str]) -> ArrayData {
1110            let mut values: Vec<&str> = Vec::new();
1111            let mut keys: Vec<i32> = Vec::new();
1112            for w in words {
1113                let idx = values.iter().position(|v| v == w).unwrap_or_else(|| {
1114                    values.push(*w);
1115                    values.len() - 1
1116                });
1117                keys.push(idx as i32);
1118            }
1119            DictionaryArray::<Int32Type>::try_new(
1120                Int32Array::from(keys),
1121                Arc::new(StringArray::from(values)),
1122            )
1123            .unwrap()
1124            .into_data()
1125        }
1126
1127        let first = dict(&["a", "b", "a", "c", "b"]);
1128        // Confirm this really exercises the fallback (not the fast path).
1129        assert!(
1130            ipc_fast_path_len(&first).is_none(),
1131            "dictionary must route to the official-writer fallback"
1132        );
1133
1134        // Prime from the schema block of the dictionary stream — exactly the
1135        // bytes the producer publishes on the `@schema` subtopic.
1136        let mut dec = InputDecoder::new();
1137        let full0 = encode_ipc_to_vec(&first).unwrap();
1138        let block = schema_block_len(&full0).unwrap();
1139        dec.set_schema(1, Buffer::from(&full0[..block])).unwrap();
1140
1141        // Every message (including the first) ships only the schema-less batch
1142        // slice (which for a dictionary type carries a replacement dictionary
1143        // batch + record batch) against the primed decoder. Each must decode to
1144        // its own input.
1145        let slice0 = batch_slice(&full0).expect("fallback stream is a valid IPC stream");
1146        assert_eq!(
1147            dec.decode_batch(Buffer::from(slice0), 1)
1148                .unwrap()
1149                .expect("first batch decodes against the primed decoder"),
1150            first
1151        );
1152        for words in [
1153            ["x", "y", "x", "z"].as_slice(),
1154            ["b", "b"].as_slice(),
1155            ["new", "values", "entirely"].as_slice(),
1156        ] {
1157            let arr = dict(words);
1158            let full = encode_ipc_to_vec(&arr).unwrap();
1159            let slice = batch_slice(&full).expect("fallback stream is a valid IPC stream");
1160            let got = dec
1161                .decode_batch(Buffer::from(slice), 1)
1162                .unwrap()
1163                .expect("batch must decode against the primed decoder");
1164            assert_eq!(
1165                got, arr,
1166                "replacement-dictionary batch must decode correctly"
1167            );
1168        }
1169    }
1170
1171    /// The W3 schema-once contract: prime one persistent `StreamDecoder` with a
1172    /// single schema message, then decode a *sequence* of schema-less batch
1173    /// messages against it. (A fresh full stream per message can't do this — its
1174    /// EOS terminates the decoder.)
1175    #[test]
1176    fn schema_primed_decoder_decodes_batch_sequence() {
1177        let schema = encode_schema_message(&DataType::Float32).unwrap();
1178        let mut decoder = StreamDecoder::new();
1179
1180        // Prime: feeding the schema message yields no batch.
1181        let mut sbuf = Buffer::from_vec(schema);
1182        while !sbuf.is_empty() {
1183            assert!(
1184                decoder.decode(&mut sbuf).unwrap().is_none(),
1185                "schema message must not yield a batch"
1186            );
1187        }
1188
1189        for vals in [vec![1.0f32, 2.0, 3.0], vec![4.0, 5.0], vec![6.0]] {
1190            let array = Float32Array::from(vals).into_data();
1191            let len = batch_fast_path_len(&array).unwrap();
1192            let mut buf = vec![0u8; len];
1193            encode_batch_into(&array, &mut buf).unwrap();
1194
1195            let mut bbuf = Buffer::from_vec(buf);
1196            let mut got = None;
1197            while !bbuf.is_empty() {
1198                if let Some(b) = decoder.decode(&mut bbuf).unwrap() {
1199                    got = Some(b);
1200                    break;
1201                }
1202            }
1203            assert_eq!(
1204                got.expect("batch message must decode against the primed decoder")
1205                    .column(0)
1206                    .to_data(),
1207                array
1208            );
1209        }
1210    }
1211
1212    /// `encode_uint8_ipc_header` + caller-filled data region produces a valid
1213    /// IPC stream identical to `encode_ipc_into` of the equivalent UInt8Array —
1214    /// the zero-copy "construct in place" path for `send_output_raw`/Python.
1215    #[test]
1216    fn uint8_ipc_header_constructs_in_place() {
1217        for data_len in [0usize, 1, 7, 8, 9, 1000] {
1218            let bytes: Vec<u8> = (0..data_len).map(|i| (i % 251) as u8).collect();
1219            let total = uint8_ipc_len(data_len).unwrap();
1220            let mut dst = vec![0u8; total];
1221            let offset = encode_uint8_ipc_header(&mut dst, data_len).unwrap();
1222            // The caller writes the data in place (no copy in real use).
1223            dst[offset..offset + data_len].copy_from_slice(&bytes);
1224
1225            // Decodes to the original bytes as a no-null UInt8Array.
1226            let arr = arrow::array::make_array(read_official(&dst));
1227            let u8 = arr.as_any().downcast_ref::<UInt8Array>().unwrap();
1228            assert_eq!(u8.values(), bytes.as_slice(), "len {data_len}");
1229            assert_eq!(u8.null_count(), 0);
1230
1231            // Byte-identical to encoding the equivalent array directly.
1232            let array = UInt8Array::from(bytes).into_data();
1233            assert_eq!(dst, fast_encode(&array), "len {data_len}");
1234        }
1235    }
1236
1237    /// The UInt8 encode guard must bound the *resulting stream*, not the input
1238    /// `data_len`: since receivers reject any stream over `MAX_IPC_BYTES`,
1239    /// anything the producer accepts here must decode there. Regression for
1240    /// #2586 (a band of `data_len` just under the limit used to encode/send yet
1241    /// be rejected — silently dropped on zenoh — by every receiver).
1242    #[test]
1243    fn uint8_ipc_len_bounds_resulting_stream_not_input() {
1244        let max = super::super::MAX_IPC_BYTES;
1245
1246        // Exactly at the input limit: the stream is ~1.125x larger, so it must
1247        // be rejected rather than produce an undecodable stream.
1248        let err = uint8_ipc_len(max).unwrap_err().to_string();
1249        assert!(
1250            err.contains("too large"),
1251            "data_len == MAX_IPC_BYTES should be rejected, got: {err}"
1252        );
1253
1254        // The largest accepted payload must encode to a stream within the limit.
1255        // Binary-search the boundary so the test tracks the exact layout.
1256        let mut lo = 0usize;
1257        let mut hi = max;
1258        while lo < hi {
1259            let mid = lo + (hi - lo).div_ceil(2);
1260            match uint8_ipc_len(mid) {
1261                Ok(_) => lo = mid,
1262                Err(_) => hi = mid - 1,
1263            }
1264        }
1265        let largest_ok = lo;
1266        let total = uint8_ipc_len(largest_ok).unwrap();
1267        assert!(
1268            total <= max,
1269            "accepted payload of {largest_ok} bytes encodes to {total} > {max}"
1270        );
1271        // One byte more must be rejected, and its (hypothetical) stream exceeds
1272        // the limit — i.e. the boundary is set by the stream size, not data_len.
1273        assert!(uint8_ipc_len(largest_ok + 1).is_err());
1274        assert!(
1275            largest_ok < max,
1276            "boundary should sit below the input limit"
1277        );
1278    }
1279
1280    #[test]
1281    fn roundtrip_primitive_no_nulls() {
1282        let array = Float32Array::from((0..1000).map(|i| i as f32).collect::<Vec<_>>()).into_data();
1283        assert_fast_roundtrip(&array);
1284    }
1285
1286    #[test]
1287    fn roundtrip_primitive_with_nulls() {
1288        let array = UInt64Array::from(vec![Some(1), None, Some(3), None, Some(5)]).into_data();
1289        assert_fast_roundtrip(&array);
1290    }
1291
1292    #[test]
1293    fn roundtrip_empty_primitive() {
1294        let array = Int32Array::from(Vec::<i32>::new()).into_data();
1295        assert_fast_roundtrip(&array);
1296    }
1297
1298    /// A 0-row array sent through the `send_output_raw` UInt8-header path decodes
1299    /// back to a 0-row `UInt8` array via the full-stream zero-copy decoder.
1300    /// (The full stream keeps its end-of-stream marker, so this path was never
1301    /// broken — this pins it as a baseline alongside the schema-once regression
1302    /// below.)
1303    #[test]
1304    fn uint8_header_zero_len_full_stream_roundtrip() {
1305        let len = uint8_ipc_len(0).unwrap();
1306        let mut buf = vec![0u8; len];
1307        let off = encode_uint8_ipc_header(&mut buf, 0).unwrap();
1308        // Empty data region: it sits immediately before the 8-byte EOS marker.
1309        assert_eq!(off, len - PREFIX_LEN);
1310        assert_eq!(read_official(&buf).len(), 0);
1311        let (buffer, _, _) = aligned_buffer(&buf);
1312        let decoded = decode_arrow_ipc_zero_copy(buffer).unwrap();
1313        assert_eq!(decoded.data_type(), &DataType::UInt8);
1314        assert_eq!(decoded.len(), 0);
1315    }
1316
1317    /// The schema-once receive path must decode a 0-row (empty) batch, not drop
1318    /// it. A schema-less batch carries no trailing end-of-stream marker, and
1319    /// arrow's `StreamDecoder` only emits a zero-length body on the poll *after*
1320    /// the header — so `decode_one_batch` must poll once more once its input
1321    /// drains. Regression guard for the empty-array drop reported on PR #2366.
1322    #[test]
1323    fn schema_once_zero_len_batch_roundtrip() {
1324        let schema = || Buffer::from_vec(encode_schema_message(&DataType::UInt8).unwrap());
1325        let batch = |vals: &[u8]| {
1326            let a = UInt8Array::from(vals.to_vec()).into_data();
1327            let len = batch_fast_path_len(&a).unwrap();
1328            let mut b = vec![0u8; len];
1329            encode_batch_into(&a, &mut b).unwrap();
1330            Buffer::from_vec(b)
1331        };
1332
1333        let mut dec = InputDecoder::new();
1334        dec.set_schema(1, schema()).unwrap();
1335
1336        // The empty batch decodes to a 0-row UInt8 array, not `Ok(None)`/error.
1337        let decoded = dec
1338            .decode_batch(batch(&[]), 1)
1339            .unwrap()
1340            .expect("0-row batch must decode, not drop");
1341        assert_eq!(decoded.data_type(), &DataType::UInt8);
1342        assert_eq!(decoded.len(), 0);
1343
1344        // The persistent decoder stays usable across mixed empty/non-empty
1345        // batches (flushing the empty body must not wedge or terminate it).
1346        for vals in [vec![1u8, 2, 3], vec![], vec![9u8], vec![]] {
1347            let d = dec.decode_batch(batch(&vals), 1).unwrap().unwrap();
1348            assert_eq!(d.len(), vals.len());
1349            assert_eq!(d.data_type(), &DataType::UInt8);
1350        }
1351    }
1352
1353    /// Same regression, but exercising the exact production producer path: the
1354    /// full stream is built by the UInt8 header encoder, the schema is extracted
1355    /// via [`schema_block_len`] and the schema-less batch via [`batch_slice`]
1356    /// (as `zenoh_publish` does), then decoded by the per-input [`InputDecoder`].
1357    #[test]
1358    fn schema_once_zero_len_via_batch_slice_roundtrip() {
1359        let total = uint8_ipc_len(0).unwrap();
1360        let mut full = vec![0u8; total];
1361        encode_uint8_ipc_header(&mut full, 0).unwrap();
1362
1363        let sblock = schema_block_len(&full).unwrap();
1364        let schema = Buffer::from(&full[..sblock]);
1365        let batch = batch_slice(&full).expect("batch slice of a valid stream");
1366
1367        let mut dec = InputDecoder::new();
1368        dec.set_schema(7, schema).unwrap();
1369        let decoded = dec
1370            .decode_batch(Buffer::from(batch), 7)
1371            .unwrap()
1372            .expect("0-row batch via batch_slice must decode, not drop");
1373        assert_eq!(decoded.data_type(), &DataType::UInt8);
1374        assert_eq!(decoded.len(), 0);
1375    }
1376
1377    /// The daemon's `dora topic` debug path rebuilds a schema-once batch into a
1378    /// full self-describing stream by concatenating the cached `@schema` block
1379    /// with the schema-less batch. That only works because
1380    /// `schema_block ++ batch_slice` is byte-identical to the original stream —
1381    /// lock that invariant (and that the result still decodes) here.
1382    #[test]
1383    fn schema_block_plus_batch_slice_reconstructs_full_stream() {
1384        let array = Int32Array::from(vec![1, 2, 3]).into_data();
1385        let full = fast_encode(&array);
1386        let sblock = schema_block_len(&full).unwrap();
1387        let schema = &full[..sblock];
1388        let batch = batch_slice(&full).expect("batch slice of a valid stream");
1389        let rebuilt = [schema, batch].concat();
1390        assert_eq!(
1391            rebuilt, full,
1392            "schema_block ++ batch_slice must equal the original stream"
1393        );
1394        assert_eq!(read_official(&rebuilt), array);
1395    }
1396
1397    #[test]
1398    fn roundtrip_boolean() {
1399        let array =
1400            BooleanArray::from(vec![true, false, true, true, false, false, true]).into_data();
1401        assert_fast_roundtrip(&array);
1402    }
1403
1404    #[test]
1405    fn roundtrip_utf8() {
1406        let array =
1407            StringArray::from(vec![Some("hello"), None, Some(""), Some("world!")]).into_data();
1408        assert_fast_roundtrip(&array);
1409    }
1410
1411    #[test]
1412    fn roundtrip_large_utf8_64bit_offsets() {
1413        let array = LargeStringArray::from(vec!["a", "bb", "ccc"]).into_data();
1414        assert_fast_roundtrip(&array);
1415    }
1416
1417    #[test]
1418    fn roundtrip_fixed_size_binary() {
1419        let values = vec![vec![1u8, 2, 3], vec![4, 5, 6], vec![7, 8, 9]];
1420        let array = FixedSizeBinaryArray::try_from_iter(values.into_iter())
1421            .unwrap()
1422            .into_data();
1423        assert_fast_roundtrip(&array);
1424    }
1425
1426    /// `FixedSizeList` exercises the unique fast-path child-slicing branch
1427    /// (`n = len * value_size`, then `child.slice(0, n)`). No other test covers
1428    /// it — `roundtrip_fixed_size_binary` is `FixedSizeBinary`, a leaf type.
1429    #[test]
1430    fn roundtrip_fixed_size_list() {
1431        use arrow::array::FixedSizeListArray;
1432        let values = Int32Array::from((0..12).collect::<Vec<_>>());
1433        let field = Arc::new(Field::new("item", DataType::Int32, true));
1434        let array = FixedSizeListArray::try_new(field, 3, Arc::new(values), None)
1435            .unwrap()
1436            .into_data();
1437        assert_fast_roundtrip(&array);
1438    }
1439
1440    /// `FixedSizeList` with a list-level validity bitmap (null lists), so the
1441    /// child-slicing branch runs alongside the parent's null buffer.
1442    #[test]
1443    fn roundtrip_fixed_size_list_with_nulls() {
1444        use arrow::array::FixedSizeListArray;
1445        use arrow::buffer::NullBuffer;
1446        let values = Int32Array::from((0..12).collect::<Vec<_>>());
1447        let field = Arc::new(Field::new("item", DataType::Int32, true));
1448        let nulls = NullBuffer::from(vec![true, false, true, true]);
1449        let array = FixedSizeListArray::try_new(field, 3, Arc::new(values), Some(nulls))
1450            .unwrap()
1451            .into_data();
1452        assert_fast_roundtrip(&array);
1453    }
1454
1455    /// `Decimal128` is fast-path (buffer copied verbatim) but otherwise untested.
1456    #[test]
1457    fn roundtrip_decimal128() {
1458        use arrow::array::Decimal128Array;
1459        let array = Decimal128Array::from(vec![Some(12_345i128), None, Some(-9_876), Some(0)])
1460            .with_precision_and_scale(20, 4)
1461            .unwrap()
1462            .into_data();
1463        assert_fast_roundtrip(&array);
1464    }
1465
1466    /// A temporal type (`Timestamp`) — also fast-path-verbatim, also untested.
1467    #[test]
1468    fn roundtrip_timestamp_temporal() {
1469        use arrow::array::TimestampMicrosecondArray;
1470        let array =
1471            TimestampMicrosecondArray::from(vec![Some(1_000_000i64), None, Some(2_500_000)])
1472                .into_data();
1473        assert_fast_roundtrip(&array);
1474    }
1475
1476    #[test]
1477    fn roundtrip_struct_with_multilevel_nulls() {
1478        let array = StructArray::from(vec![
1479            (
1480                Arc::new(Field::new("a", DataType::UInt64, true)),
1481                Arc::new(UInt64Array::from(vec![Some(1), None, Some(3)])) as ArrayRef,
1482            ),
1483            (
1484                Arc::new(Field::new("b", DataType::Utf8, true)),
1485                Arc::new(StringArray::from(vec![Some("x"), Some("yy"), None])) as ArrayRef,
1486            ),
1487        ])
1488        .into_data();
1489        assert_fast_roundtrip(&array);
1490    }
1491
1492    /// Regression: a Struct whose child `ArrayData` is *longer* than the struct
1493    /// (constructible via the low-level builder) must be sliced to the struct's
1494    /// len, otherwise the child field node declares the wrong row count and the
1495    /// stream is un-decodable.
1496    #[test]
1497    fn roundtrip_struct_with_oversized_child() {
1498        use arrow_schema::Fields;
1499        let child = Int32Array::from(vec![10, 20, 30]).into_data(); // len 3
1500        let fields: Fields = vec![Field::new("v", DataType::Int32, false)].into();
1501        let struct_data = ArrayData::builder(DataType::Struct(fields))
1502            .len(2) // shorter than the child
1503            .add_child_data(child)
1504            .build()
1505            .unwrap();
1506
1507        assert!(
1508            ipc_fast_path_len(&struct_data).is_some(),
1509            "struct with an oversized child should stay on the fast path"
1510        );
1511        let decoded = read_official(&fast_encode(&struct_data));
1512        assert_eq!(decoded.len(), 2);
1513        let arr = arrow::array::make_array(decoded);
1514        let sa = arr.as_any().downcast_ref::<StructArray>().unwrap();
1515        let col = sa.column(0).as_any().downcast_ref::<Int32Array>().unwrap();
1516        assert_eq!(
1517            col.values(),
1518            &[10, 20],
1519            "child must be truncated to the struct's len"
1520        );
1521    }
1522
1523    #[test]
1524    fn roundtrip_list_of_primitive() {
1525        let data = vec![
1526            Some(vec![Some(0), Some(1), Some(2)]),
1527            None,
1528            Some(vec![Some(3), None, Some(5)]),
1529            Some(vec![]),
1530        ];
1531        let array =
1532            ListArray::from_iter_primitive::<arrow::datatypes::Int32Type, _, _>(data).into_data();
1533        assert_fast_roundtrip(&array);
1534    }
1535
1536    #[test]
1537    fn roundtrip_nullarray_zero_and_n() {
1538        assert_fast_roundtrip(&NullArray::new(0).into_data());
1539        assert_fast_roundtrip(&NullArray::new(7).into_data());
1540    }
1541
1542    /// Headline zero-copy proof: the fast-path body is 64-aligned, so the strict
1543    /// decoder accepts it without a realigning copy AND the decoded data buffer
1544    /// aliases the input allocation.
1545    #[test]
1546    fn fast_path_decodes_zero_copy() {
1547        let array = UInt64Array::from((0..50_000u64).collect::<Vec<_>>()).into_data();
1548        let encoded = fast_encode(&array);
1549
1550        // (1) strict decoder: errors if any buffer needs realigning.
1551        {
1552            let (mut buffer, _, _) = aligned_buffer(&encoded);
1553            let mut decoder = StreamDecoder::new().with_require_alignment(true);
1554            let mut got = None;
1555            while !buffer.is_empty() {
1556                if let Some(b) = decoder
1557                    .decode(&mut buffer)
1558                    .expect("aligned fast-path stream must decode without realignment")
1559                {
1560                    got = Some(b);
1561                    break;
1562                }
1563            }
1564            assert_eq!(got.unwrap().column(0).to_data(), array);
1565        }
1566
1567        // (2) pointer aliasing: the decoded data buffer lies inside the input.
1568        {
1569            let (buffer, base, len) = aligned_buffer(&encoded);
1570            let decoded = decode_arrow_ipc_zero_copy(buffer).unwrap();
1571            let ptr = decoded.buffers()[0].as_ptr() as usize;
1572            assert!(
1573                ptr >= base && ptr < base + len,
1574                "decoded data buffer at {ptr:#x} is outside input [{base:#x}, {:#x}) — a copy happened",
1575                base + len
1576            );
1577        }
1578    }
1579
1580    #[test]
1581    fn fast_path_len_matches_official_decode() {
1582        // The whole stream is consumed by the official reader (no trailing junk,
1583        // no truncation): `ipc_fast_path_len` is exact.
1584        let array = Float32Array::from(vec![1.0, 2.0, 3.0, 4.0]).into_data();
1585        let encoded = fast_encode(&array);
1586        let mut reader = StreamReader::try_new(Cursor::new(&encoded[..]), None).unwrap();
1587        let _ = reader.next().unwrap().unwrap();
1588        assert!(reader.next().is_none(), "exactly one batch, fully consumed");
1589    }
1590
1591    /// Sliced (non-zero offset) arrays are routed to the fallback, which must
1592    /// still round-trip the *logical* slice (not the parent).
1593    #[test]
1594    fn sliced_array_routes_to_fallback_and_roundtrips() {
1595        let array = UInt64Array::from(vec![10, 20, 30, 40, 50])
1596            .into_data()
1597            .slice(2, 2); // offset 2 -> not fast-path
1598        assert_eq!(array.offset(), 2);
1599        assert!(ipc_fast_path_len(&array).is_none());
1600
1601        let encoded = encode_ipc_to_vec(&array).unwrap();
1602        let decoded = read_official(&encoded);
1603        assert_eq!(array.len(), decoded.len());
1604        let dec = arrow::array::make_array(decoded);
1605        let dec = dec.as_any().downcast_ref::<UInt64Array>().unwrap();
1606        assert_eq!(dec.values(), &[30, 40]);
1607    }
1608
1609    /// A `*View` type must route to the fallback (validate the classifier).
1610    #[test]
1611    fn view_type_routes_to_fallback() {
1612        use arrow::array::StringViewArray;
1613        let array = StringViewArray::from(vec!["a", "bb", "ccc"]).into_data();
1614        assert!(
1615            ipc_fast_path_len(&array).is_none(),
1616            "Utf8View is not fast-path eligible"
1617        );
1618        let encoded = encode_ipc_to_vec(&array).unwrap();
1619        let decoded = read_official(&encoded);
1620        assert_eq!(array, decoded);
1621    }
1622
1623    /// Deterministic fuzz: many shapes through the bidirectional assertion.
1624    #[test]
1625    fn fuzz_roundtrip_many_shapes() {
1626        // Simple LCG so the matrix is varied but reproducible (no rng/time).
1627        let mut state: u64 = 0x1234_5678_9abc_def0;
1628        let mut next = || {
1629            state = state
1630                .wrapping_mul(6364136223846793005)
1631                .wrapping_add(1442695040888963407);
1632            state
1633        };
1634
1635        for _ in 0..200 {
1636            let len = (next() % 64) as usize;
1637            let kind = next() % 6;
1638            let array: ArrayData = match kind {
1639                0 => UInt8Array::from(
1640                    (0..len)
1641                        .map(|i| {
1642                            if next().is_multiple_of(4) {
1643                                None
1644                            } else {
1645                                Some((i as u8).wrapping_add(1))
1646                            }
1647                        })
1648                        .collect::<Vec<_>>(),
1649                )
1650                .into_data(),
1651                1 => Float32Array::from((0..len).map(|i| i as f32 * 0.5).collect::<Vec<_>>())
1652                    .into_data(),
1653                2 => BooleanArray::from(
1654                    (0..len)
1655                        .map(|i| (i + next() as usize).is_multiple_of(2))
1656                        .collect::<Vec<_>>(),
1657                )
1658                .into_data(),
1659                3 => StringArray::from(
1660                    (0..len)
1661                        .map(|i| {
1662                            if next().is_multiple_of(5) {
1663                                None
1664                            } else {
1665                                Some("x".repeat(i % 7))
1666                            }
1667                        })
1668                        .collect::<Vec<_>>(),
1669                )
1670                .into_data(),
1671                4 => Int32Array::from(
1672                    (0..len)
1673                        .map(|i| {
1674                            if next().is_multiple_of(3) {
1675                                None
1676                            } else {
1677                                Some(i as i32 - 10)
1678                            }
1679                        })
1680                        .collect::<Vec<_>>(),
1681                )
1682                .into_data(),
1683                _ => StructArray::from(vec![(
1684                    Arc::new(Field::new("v", DataType::Int32, true)),
1685                    Arc::new(Int32Array::from(
1686                        (0..len).map(|i| Some(i as i32)).collect::<Vec<_>>(),
1687                    )) as ArrayRef,
1688                )])
1689                .into_data(),
1690            };
1691            assert_fast_roundtrip(&array);
1692        }
1693    }
1694}