Skip to main content

vortex_layout/layouts/dict/
writer.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::pin::Pin;
5use std::sync::Arc;
6use std::task::Context;
7use std::task::Poll;
8
9use async_stream::stream;
10use async_stream::try_stream;
11use async_trait::async_trait;
12use futures::FutureExt;
13use futures::Stream;
14use futures::StreamExt;
15use futures::TryStreamExt;
16use futures::future::BoxFuture;
17use futures::pin_mut;
18use futures::stream::BoxStream;
19use futures::stream::once;
20use futures::try_join;
21use vortex_array::ArrayContext;
22use vortex_array::ArrayRef;
23use vortex_array::ExecutionCtx;
24use vortex_array::IntoArray;
25use vortex_array::VortexSessionExecute;
26use vortex_array::arrays::Dict;
27use vortex_array::builders::dict::DictConstraints;
28use vortex_array::builders::dict::DictEncoder;
29use vortex_array::builders::dict::dict_encoder;
30use vortex_array::dtype::DType;
31use vortex_array::dtype::Nullability;
32use vortex_array::dtype::PType;
33use vortex_error::VortexError;
34use vortex_error::VortexExpect;
35use vortex_error::VortexResult;
36use vortex_error::vortex_err;
37use vortex_io::kanal_ext::KanalExt;
38use vortex_io::session::RuntimeSessionExt;
39use vortex_session::VortexSession;
40
41use crate::LayoutRef;
42use crate::LayoutStrategy;
43use crate::OwnedLayoutChildren;
44use crate::layouts::chunked::ChunkedLayout;
45use crate::layouts::compressed::CompressorPlugin;
46use crate::layouts::dict::DictLayout;
47use crate::segments::SegmentSinkRef;
48use crate::sequence::SendableSequentialStream;
49use crate::sequence::SequenceId;
50use crate::sequence::SequencePointer;
51use crate::sequence::SequentialStream;
52use crate::sequence::SequentialStreamAdapter;
53use crate::sequence::SequentialStreamExt;
54
55/// Constraints for dictionary layout encoding.
56///
57/// Note that [`max_len`](Self::max_len) is limited to `u16` (65,535 entries) by design. Since
58/// layout chunks are typically ~8k elements, having more than 64k unique values in a dictionary
59/// means dictionary encoding provides little compression benefit. If a column has very high
60/// cardinality, the fallback encoding strategy should be used instead.
61#[derive(Clone)]
62pub struct DictLayoutConstraints {
63    /// Maximum size of the dictionary in bytes.
64    pub max_bytes: usize,
65    /// Maximum dictionary length. Limited to `u16` because dictionaries with more than 64k unique
66    /// values provide diminishing compression returns given typical chunk sizes (~8k elements).
67    ///
68    /// The codes dtype is determined upfront from this constraint:
69    /// - [`PType::U8`] when max_len <= 255
70    /// - [`PType::U16`] when max_len > 255
71    ///
72    /// Vortex encoders must always produce unsigned integer codes; signed codes are only accepted for external compatibility.
73    pub max_len: u16,
74}
75
76impl From<DictLayoutConstraints> for DictConstraints {
77    fn from(value: DictLayoutConstraints) -> Self {
78        DictConstraints {
79            max_bytes: value.max_bytes,
80            max_len: value.max_len as usize,
81        }
82    }
83}
84
85impl Default for DictLayoutConstraints {
86    fn default() -> Self {
87        Self {
88            max_bytes: 1024 * 1024,
89            max_len: u16::MAX,
90        }
91    }
92}
93
94#[derive(Clone, Default)]
95pub struct DictLayoutOptions {
96    pub constraints: DictLayoutConstraints,
97}
98
99/// A layout strategy that encodes chunk into values and codes, if found
100/// appropriate by the btrblocks compressor. Current implementation only
101/// checks the first chunk to decide whether to apply dict layout and
102/// encodes chunks into dictionaries. When the dict constraints are hit, a
103/// new dictionary is created.
104#[derive(Clone)]
105pub struct DictStrategy {
106    codes: Arc<dyn LayoutStrategy>,
107    values: Arc<dyn LayoutStrategy>,
108    fallback: Arc<dyn LayoutStrategy>,
109    options: DictLayoutOptions,
110    probe_compressor: Arc<dyn CompressorPlugin>,
111}
112
113impl DictStrategy {
114    pub fn new<Codes: LayoutStrategy, Values: LayoutStrategy, Fallback: LayoutStrategy>(
115        codes: Codes,
116        values: Values,
117        fallback: Fallback,
118        options: DictLayoutOptions,
119        probe_compressor: Arc<dyn CompressorPlugin>,
120    ) -> Self {
121        Self {
122            codes: Arc::new(codes),
123            values: Arc::new(values),
124            fallback: Arc::new(fallback),
125            options,
126            probe_compressor,
127        }
128    }
129}
130
131#[async_trait]
132impl LayoutStrategy for DictStrategy {
133    async fn write_stream(
134        &self,
135        ctx: ArrayContext,
136        segment_sink: SegmentSinkRef,
137        stream: SendableSequentialStream,
138        mut eof: SequencePointer,
139        session: &VortexSession,
140    ) -> VortexResult<LayoutRef> {
141        // Fallback if dtype is not supported
142        if !dict_layout_supported(stream.dtype()) {
143            return self
144                .fallback
145                .write_stream(ctx, segment_sink, stream, eof, session)
146                .await;
147        }
148
149        let options = self.options.clone();
150        let dtype = stream.dtype().clone();
151
152        // 0. decide if chunks are eligible for dict encoding
153        let (stream, first_chunk) = peek_first_chunk(stream).await?;
154        let stream = SequentialStreamAdapter::new(dtype.clone(), stream).sendable();
155
156        let should_fallback = match first_chunk {
157            None => true, // empty stream
158            Some(chunk) => {
159                let mut exec_ctx = session.create_execution_ctx();
160                let compressed = self
161                    .probe_compressor
162                    .compress_chunk(&chunk, &mut exec_ctx)?;
163                !compressed.is::<Dict>()
164            }
165        };
166        if should_fallback {
167            // first chunk did not compress to dict, or did not exist. Skip dict layout
168            return self
169                .fallback
170                .write_stream(ctx, segment_sink, stream, eof, session)
171                .await;
172        }
173
174        // 1. from a chunk stream, create a stream that yields codes
175        // followed by a single value chunk when dict constraints are hit.
176        // (a1, a2) -> (code(c1), code(c2), values(v1), code(c3), ...)
177        let dict_stream = dict_encode_stream(
178            stream,
179            options.constraints.into(),
180            session.create_execution_ctx(),
181        );
182
183        // Wrap up the dict stream to yield pairs of (codes_stream, values_future).
184        // Each of these pairs becomes a child dict layout.
185        let runs = DictionaryTransformer::new(dict_stream);
186
187        let handle = session.handle();
188        let dtype2 = dtype.clone();
189        let child_layouts = stream! {
190            pin_mut!(runs);
191
192            while let Some((codes_stream, values_fut)) = runs.next().await {
193                let codes = Arc::clone(&self.codes);
194                let codes_eof = eof.split_off();
195                let ctx2 = ctx.clone();
196                let segment_sink2 = Arc::clone(&segment_sink);
197                let session2 = session.clone();
198                let codes_fut = handle.spawn_nested(move |h| async move {
199                    let session2 = session2.with_handle(h);
200                    codes.write_stream(
201                        ctx2,
202                        segment_sink2,
203                        codes_stream.sendable(),
204                        codes_eof,
205                        &session2,
206                    ).await
207                });
208
209                let values = Arc::clone(&self.values);
210                let values_eof = eof.split_off();
211                let ctx2 = ctx.clone();
212                let segment_sink2 = Arc::clone(&segment_sink);
213                let dtype2 = dtype2.clone();
214                let session2 = session.clone();
215                let values_layout = handle.spawn_nested(move |h| async move {
216                    let session2 = session2.with_handle(h);
217                    values.write_stream(
218                        ctx2,
219                        segment_sink2,
220                        SequentialStreamAdapter::new(dtype2, once(values_fut)).sendable(),
221                        values_eof,
222                        &session2,
223                    ).await
224                });
225
226                yield async move {
227                    try_join!(codes_fut, values_layout)
228                }.boxed();
229            }
230        };
231
232        let mut child_layouts = child_layouts
233            .buffered(usize::MAX)
234            .map(|result| {
235                let (codes_layout, values_layout) = result?;
236                // All values are referenced when created via dictionary encoding
237                Ok::<_, VortexError>(DictLayout::new(values_layout, codes_layout).into_layout())
238            })
239            .try_collect::<Vec<_>>()
240            .await?;
241
242        if child_layouts.len() == 1 {
243            return Ok(child_layouts.remove(0));
244        }
245
246        let row_count = child_layouts.iter().map(|child| child.row_count()).sum();
247        Ok(ChunkedLayout::new(
248            row_count,
249            dtype,
250            OwnedLayoutChildren::layout_children(child_layouts),
251        )
252        .into_layout())
253    }
254
255    fn buffered_bytes(&self) -> u64 {
256        self.codes.buffered_bytes() + self.values.buffered_bytes() + self.fallback.buffered_bytes()
257    }
258}
259
260enum DictionaryChunk {
261    Codes {
262        seq_id: SequenceId,
263        codes: ArrayRef,
264        codes_ptype: PType,
265    },
266    Values((SequenceId, ArrayRef)),
267}
268
269type DictionaryStream = BoxStream<'static, VortexResult<DictionaryChunk>>;
270
271fn dict_encode_stream(
272    input: SendableSequentialStream,
273    constraints: DictConstraints,
274    mut exec_ctx: ExecutionCtx,
275) -> DictionaryStream {
276    Box::pin(try_stream! {
277        let mut state = DictStreamState {
278            encoder: None,
279            constraints,
280        };
281
282        let input = input.peekable();
283        pin_mut!(input);
284
285        while let Some(item) = input.next().await {
286            let (sequence_id, chunk) = item?;
287
288            // labeler potentially creates sub sequences, we must
289            // create it on both arms to avoid having a SequencePointer
290            // between await points
291            match input.as_mut().peek().await {
292                Some(_) => {
293                    let mut labeler = DictChunkLabeler::new(sequence_id);
294                    let chunks = state.encode(&mut labeler, chunk, &mut exec_ctx)?;
295                    drop(labeler);
296                    for dict_chunk in chunks {
297                        yield dict_chunk;
298                    }
299                }
300                None => {
301                    // this is the last element, encode and drain chunks
302                    let mut labeler = DictChunkLabeler::new(sequence_id);
303                    let encoded = state.encode(&mut labeler, chunk, &mut exec_ctx)?;
304                    let drained = state.drain_values(&mut labeler);
305                    drop(labeler);
306                    for dict_chunk in encoded.into_iter().chain(drained.into_iter()) {
307                        yield dict_chunk;
308                    }
309                }
310            }
311        }
312    })
313}
314
315struct DictStreamState {
316    encoder: Option<Box<dyn DictEncoder>>,
317    constraints: DictConstraints,
318}
319
320impl DictStreamState {
321    fn encode(
322        &mut self,
323        labeler: &mut DictChunkLabeler,
324        chunk: ArrayRef,
325        exec_ctx: &mut ExecutionCtx,
326    ) -> VortexResult<Vec<DictionaryChunk>> {
327        let mut res = Vec::new();
328        let mut to_be_encoded = Some(chunk);
329        while let Some(remaining) = to_be_encoded.take() {
330            match self.encoder.take() {
331                None => match start_encoding(&self.constraints, &remaining, exec_ctx)? {
332                    EncodingState::Continue((encoder, encoded)) => {
333                        let ptype = encoder.codes_ptype();
334                        res.push(labeler.codes(encoded, ptype));
335                        self.encoder = Some(encoder);
336                    }
337                    EncodingState::Done((values, encoded, unencoded)) => {
338                        // Encoder was created and consumed within start_encoding
339                        let ptype = PType::try_from(encoded.dtype())
340                            .vortex_expect("codes should be primitive");
341                        res.push(labeler.codes(encoded, ptype));
342                        res.push(labeler.values(values));
343                        to_be_encoded = Some(unencoded);
344                    }
345                },
346                Some(encoder) => {
347                    let ptype = encoder.codes_ptype();
348                    match encode_chunk(encoder, &remaining, exec_ctx)? {
349                        EncodingState::Continue((encoder, encoded)) => {
350                            res.push(labeler.codes(encoded, ptype));
351                            self.encoder = Some(encoder);
352                        }
353                        EncodingState::Done((values, encoded, unencoded)) => {
354                            res.push(labeler.codes(encoded, ptype));
355                            res.push(labeler.values(values));
356                            to_be_encoded = Some(unencoded);
357                        }
358                    }
359                }
360            }
361        }
362        Ok(res)
363    }
364
365    fn drain_values(&mut self, labeler: &mut DictChunkLabeler) -> Vec<DictionaryChunk> {
366        match self.encoder.as_mut() {
367            None => Vec::new(),
368            Some(encoder) => vec![labeler.values(encoder.reset())],
369        }
370    }
371}
372
373struct DictChunkLabeler {
374    sequence_pointer: SequencePointer,
375}
376
377impl DictChunkLabeler {
378    fn new(starting_id: SequenceId) -> Self {
379        let sequence_pointer = starting_id.descend();
380        Self { sequence_pointer }
381    }
382
383    fn codes(&mut self, chunk: ArrayRef, ptype: PType) -> DictionaryChunk {
384        DictionaryChunk::Codes {
385            seq_id: self.sequence_pointer.advance(),
386            codes: chunk,
387            codes_ptype: ptype,
388        }
389    }
390
391    fn values(&mut self, chunk: ArrayRef) -> DictionaryChunk {
392        DictionaryChunk::Values((self.sequence_pointer.advance(), chunk))
393    }
394}
395
396type SequencedChunk = VortexResult<(SequenceId, ArrayRef)>;
397
398struct DictionaryTransformer {
399    input: DictionaryStream,
400    active_codes_tx: Option<kanal::AsyncSender<SequencedChunk>>,
401    active_values_tx: Option<oneshot::Sender<SequencedChunk>>,
402    pending_send: Option<BoxFuture<'static, Result<(), kanal::SendError>>>,
403}
404
405impl DictionaryTransformer {
406    fn new(input: DictionaryStream) -> Self {
407        Self {
408            input,
409            active_codes_tx: None,
410            active_values_tx: None,
411            pending_send: None,
412        }
413    }
414}
415
416impl Stream for DictionaryTransformer {
417    type Item = (SendableSequentialStream, BoxFuture<'static, SequencedChunk>);
418
419    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
420        loop {
421            // First, try to complete any pending send
422            if let Some(mut send_fut) = self.pending_send.take() {
423                match send_fut.poll_unpin(cx) {
424                    Poll::Ready(Ok(())) => {
425                        // Send completed, continue processing
426                    }
427                    Poll::Ready(Err(_)) => {
428                        // Receiver dropped, close this group
429                        self.active_codes_tx = None;
430                        if let Some(values_tx) = self.active_values_tx.take() {
431                            drop(values_tx.send(Err(vortex_err!("values receiver dropped"))));
432                        }
433                    }
434                    Poll::Pending => {
435                        // Still pending, save it and return
436                        self.pending_send = Some(send_fut);
437                        return Poll::Pending;
438                    }
439                }
440            }
441
442            match self.input.poll_next_unpin(cx) {
443                Poll::Ready(Some(Ok(DictionaryChunk::Codes {
444                    seq_id,
445                    codes,
446                    codes_ptype,
447                }))) => {
448                    if self.active_codes_tx.is_none() {
449                        // Start a new group
450                        let (codes_tx, codes_rx) = kanal::bounded_async::<SequencedChunk>(1);
451                        let (values_tx, values_rx) = oneshot::channel();
452
453                        self.active_codes_tx = Some(codes_tx.clone());
454                        self.active_values_tx = Some(values_tx);
455
456                        // Use passed codes_ptype instead of getting from array
457                        let codes_dtype = DType::Primitive(codes_ptype, Nullability::NonNullable);
458
459                        // Send first codes.
460                        self.pending_send =
461                            Some(Box::pin(
462                                async move { codes_tx.send(Ok((seq_id, codes))).await },
463                            ));
464
465                        // Create output streams.
466                        let codes_stream = SequentialStreamAdapter::new(
467                            codes_dtype,
468                            codes_rx.into_stream().boxed(),
469                        )
470                        .sendable();
471
472                        let values_future = async move {
473                            values_rx
474                                .await
475                                .map_err(|e| vortex_err!("values sender dropped: {}", e))
476                                .flatten()
477                        }
478                        .boxed();
479
480                        return Poll::Ready(Some((codes_stream, values_future)));
481                    }
482
483                    // Continue streaming codes to existing group
484                    if let Some(tx) = &self.active_codes_tx {
485                        let tx = tx.clone();
486                        self.pending_send =
487                            Some(Box::pin(async move { tx.send(Ok((seq_id, codes))).await }));
488                    }
489                }
490                Poll::Ready(Some(Ok(DictionaryChunk::Values(values)))) => {
491                    // Complete the current group
492                    if let Some(values_tx) = self.active_values_tx.take() {
493                        drop(values_tx.send(Ok(values)));
494                    }
495                    self.active_codes_tx = None; // Close codes stream
496                }
497                Poll::Ready(Some(Err(e))) => {
498                    // Send error to active channels if any
499                    if let Some(values_tx) = self.active_values_tx.take() {
500                        drop(values_tx.send(Err(e)));
501                    }
502                    self.active_codes_tx = None;
503                    // And terminate the stream
504                    return Poll::Ready(None);
505                }
506                Poll::Ready(None) => {
507                    // Handle any incomplete group
508                    if let Some(values_tx) = self.active_values_tx.take() {
509                        drop(values_tx.send(Err(vortex_err!("Incomplete dictionary group"))));
510                    }
511                    self.active_codes_tx = None;
512                    return Poll::Ready(None);
513                }
514                Poll::Pending => return Poll::Pending,
515            }
516        }
517    }
518}
519
520async fn peek_first_chunk(
521    mut stream: BoxStream<'static, SequencedChunk>,
522) -> VortexResult<(BoxStream<'static, SequencedChunk>, Option<ArrayRef>)> {
523    match stream.next().await {
524        None => Ok((stream.boxed(), None)),
525        Some(Err(e)) => Err(e),
526        Some(Ok((sequence_id, chunk))) => {
527            let chunk_clone = chunk.clone();
528            let reconstructed_stream =
529                once(async move { Ok((sequence_id, chunk_clone)) }).chain(stream);
530            Ok((reconstructed_stream.boxed(), Some(chunk)))
531        }
532    }
533}
534
535pub fn dict_layout_supported(dtype: &DType) -> bool {
536    matches!(
537        dtype,
538        DType::Primitive(..) | DType::Utf8(_) | DType::Binary(_)
539    )
540}
541
542#[derive(prost::Message)]
543pub struct DictLayoutMetadata {
544    #[prost(enumeration = "PType", tag = "1")]
545    // i32 is required for proto, use the generated getter to read this field.
546    codes_ptype: i32,
547}
548
549impl DictLayoutMetadata {
550    pub fn new(codes_ptype: PType) -> Self {
551        let mut metadata = Self::default();
552        metadata.set_codes_ptype(codes_ptype);
553        metadata
554    }
555}
556
557enum EncodingState {
558    Continue((Box<dyn DictEncoder>, ArrayRef)),
559    // (values, encoded, unencoded)
560    Done((ArrayRef, ArrayRef, ArrayRef)),
561}
562
563fn start_encoding(
564    constraints: &DictConstraints,
565    chunk: &ArrayRef,
566    ctx: &mut ExecutionCtx,
567) -> VortexResult<EncodingState> {
568    let encoder = dict_encoder(chunk, constraints);
569    encode_chunk(encoder, chunk, ctx)
570}
571
572fn encode_chunk(
573    mut encoder: Box<dyn DictEncoder>,
574    chunk: &ArrayRef,
575    ctx: &mut ExecutionCtx,
576) -> VortexResult<EncodingState> {
577    let encoded = encoder.encode(chunk, ctx)?.into_array();
578    match remainder(chunk, encoded.len())? {
579        None => Ok(EncodingState::Continue((encoder, encoded))),
580        Some(unencoded) => Ok(EncodingState::Done((encoder.reset(), encoded, unencoded))),
581    }
582}
583
584fn remainder(array: &ArrayRef, encoded_len: usize) -> VortexResult<Option<ArrayRef>> {
585    if encoded_len < array.len() {
586        Ok(Some(array.slice(encoded_len..array.len())?))
587    } else {
588        Ok(None)
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use std::sync::LazyLock;
595
596    use futures::StreamExt;
597    use vortex_array::IntoArray;
598    use vortex_array::VortexSessionExecute;
599    use vortex_array::arrays::VarBinArray;
600    use vortex_array::builders::dict::DictConstraints;
601    use vortex_array::dtype::DType;
602    use vortex_array::dtype::Nullability::NonNullable;
603    use vortex_array::dtype::PType;
604    use vortex_array::session::ArraySession;
605    use vortex_session::VortexSession;
606
607    use super::DictionaryTransformer;
608    use super::dict_encode_stream;
609    use crate::sequence::SequenceId;
610    use crate::sequence::SequentialStream;
611    use crate::sequence::SequentialStreamAdapter;
612    use crate::sequence::SequentialStreamExt;
613
614    static SESSION: LazyLock<VortexSession> =
615        LazyLock::new(|| VortexSession::empty().with::<ArraySession>());
616
617    /// Regression test for a bug where the codes stream dtype was hardcoded to U16 instead of
618    /// using the actual codes dtype from the array. When `max_len <= 255`, the dict encoder
619    /// produces U8 codes, but the stream was incorrectly typed as U16, causing a dtype mismatch
620    /// assertion failure in [`SequentialStreamAdapter`].
621    #[tokio::test]
622    async fn test_dict_transformer_uses_u8_for_small_dictionaries() {
623        // Use max_len = 100 to force U8 codes (since 100 <= 255).
624        let constraints = DictConstraints {
625            max_bytes: 1024 * 1024,
626            max_len: 100,
627        };
628
629        // Create a simple string array with a few unique values.
630        let arr = VarBinArray::from(vec!["hello", "world", "hello", "world"]).into_array();
631
632        // Wrap into a sequential stream.
633        let mut pointer = SequenceId::root();
634        let input_stream = SequentialStreamAdapter::new(
635            arr.dtype().clone(),
636            futures::stream::once(async move { Ok((pointer.advance(), arr)) }),
637        )
638        .sendable();
639
640        // Encode into dict chunks.
641        let dict_stream =
642            dict_encode_stream(input_stream, constraints, SESSION.create_execution_ctx());
643
644        // Transform into codes/values streams.
645        let mut transformer = DictionaryTransformer::new(dict_stream);
646
647        // Get the first (and only) run.
648        let (codes_stream, _values_fut) = transformer
649            .next()
650            .await
651            .expect("expected at least one dictionary run");
652
653        // The key assertion: codes stream dtype should be U8, not U16.
654        assert_eq!(
655            codes_stream.dtype(),
656            &DType::Primitive(PType::U8, NonNullable),
657            "codes stream should use U8 dtype for small dictionaries, not U16"
658        );
659    }
660
661    /// Test that the codes stream uses U16 dtype when the dictionary has more than 255 entries.
662    #[tokio::test]
663    async fn test_dict_transformer_uses_u16_for_large_dictionaries() {
664        // Use max_len = 1000 to allow U16 codes (since 1000 > 255).
665        let constraints = DictConstraints {
666            max_bytes: 1024 * 1024,
667            max_len: 1000,
668        };
669
670        // Create an array with more than 255 distinct values to force U16 codes.
671        let values: Vec<String> = (0..300).map(|i| format!("value_{i}")).collect();
672        let arr =
673            VarBinArray::from(values.iter().map(|s| s.as_str()).collect::<Vec<_>>()).into_array();
674
675        // Wrap into a sequential stream.
676        let mut pointer = SequenceId::root();
677        let input_stream = SequentialStreamAdapter::new(
678            arr.dtype().clone(),
679            futures::stream::once(async move { Ok((pointer.advance(), arr)) }),
680        )
681        .sendable();
682
683        // Encode into dict chunks.
684        let dict_stream =
685            dict_encode_stream(input_stream, constraints, SESSION.create_execution_ctx());
686
687        // Transform into codes/values streams.
688        let mut transformer = DictionaryTransformer::new(dict_stream);
689
690        // Get the first (and only) run.
691        let (codes_stream, _values_fut) = transformer
692            .next()
693            .await
694            .expect("expected at least one dictionary run");
695
696        // Codes stream dtype should be U16 since we have more than 255 distinct values.
697        assert_eq!(
698            codes_stream.dtype(),
699            &DType::Primitive(PType::U16, NonNullable),
700            "codes stream should use U16 dtype for dictionaries with >255 entries"
701        );
702    }
703}