Skip to main content

vortex_file/
writer.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::io;
5use std::io::Write;
6use std::sync::Arc;
7use std::sync::atomic::AtomicU64;
8use std::sync::atomic::Ordering;
9
10use futures::FutureExt;
11use futures::StreamExt;
12use futures::TryStreamExt;
13use futures::future::Fuse;
14use futures::future::LocalBoxFuture;
15use futures::future::ready;
16use futures::pin_mut;
17use futures::select;
18use itertools::Itertools;
19use vortex_array::ArrayContext;
20use vortex_array::ArrayId;
21use vortex_array::ArrayRef;
22use vortex_array::dtype::DType;
23use vortex_array::dtype::FieldPath;
24use vortex_array::expr::stats::Stat;
25use vortex_array::iter::ArrayIterator;
26use vortex_array::iter::ArrayIteratorExt;
27use vortex_array::session::ArraySessionExt;
28use vortex_array::stats::PRUNING_STATS;
29use vortex_array::stream::ArrayStream;
30use vortex_array::stream::ArrayStreamAdapter;
31use vortex_array::stream::ArrayStreamExt;
32use vortex_array::stream::SendableArrayStream;
33use vortex_btrblocks::BtrBlocksCompressorBuilder;
34use vortex_buffer::ByteBuffer;
35use vortex_edition::ComponentKind;
36use vortex_edition::EditionSessionExt;
37use vortex_error::VortexError;
38use vortex_error::VortexExpect;
39use vortex_error::VortexResult;
40use vortex_error::vortex_bail;
41use vortex_error::vortex_err;
42use vortex_io::IoBuf;
43use vortex_io::VortexWrite;
44use vortex_io::kanal_ext::KanalExt;
45use vortex_io::runtime::BlockingRuntime;
46use vortex_io::session::RuntimeSessionExt;
47use vortex_layout::BufferedBytesTracker;
48use vortex_layout::LayoutContext;
49use vortex_layout::LayoutStrategy;
50use vortex_layout::LayoutWriterContext;
51use vortex_layout::layouts::file_stats::accumulate_stats;
52use vortex_layout::sequence::SequenceId;
53use vortex_layout::sequence::SequentialStreamAdapter;
54use vortex_layout::sequence::SequentialStreamExt;
55use vortex_session::SessionExt;
56use vortex_session::VortexSession;
57use vortex_session::registry::Id;
58use vortex_session::registry::ReadContext;
59use vortex_utils::aliases::hash_map::HashMap;
60use vortex_utils::aliases::hash_set::HashSet;
61
62use crate::Footer;
63use crate::MAGIC_BYTES;
64use crate::WriteStrategyBuilder;
65use crate::counting::CountingVortexWrite;
66use crate::footer::FileStatistics;
67use crate::footer::MAX_METADATA_KEY_BYTES;
68use crate::footer::MAX_METADATA_SEGMENTS;
69use crate::segments::writer::BufferedSegmentSink;
70
71/// Configure a new writer, which can eventually be used to write an [`ArrayStream`] into a sink
72/// that implements [`VortexWrite`].
73///
74/// Serialized arrays, layouts, extension dtypes, and zone-map aggregates are restricted to the
75/// component IDs in the session's enabled editions unless edition enforcement is explicitly
76/// disabled. An empty component set therefore forbids writing any component of that kind.
77///
78/// Construct with [`WriteOptionsSessionExt::write_options`] for normal use so the writer inherits
79/// the session's runtime, array registry, and memory configuration.
80pub struct VortexWriteOptions {
81    session: VortexSession,
82    strategy: Option<Arc<dyn LayoutStrategy>>,
83    disable_editions: bool,
84    buffered_bytes: BufferedBytesTracker,
85    exclude_dtype: bool,
86    max_variable_length_statistics_size: usize,
87    file_statistics: Vec<Stat>,
88    metadata: HashMap<String, ByteBuffer>,
89}
90
91/// Extension trait for constructing [`VortexWriteOptions`] from a session.
92pub trait WriteOptionsSessionExt: SessionExt {
93    /// Create [`VortexWriteOptions`] for writing to a Vortex file.
94    fn write_options(&self) -> VortexWriteOptions {
95        VortexWriteOptions::new(self.session())
96    }
97}
98impl<S: SessionExt> WriteOptionsSessionExt for S {}
99
100impl VortexWriteOptions {
101    /// Create a new [`VortexWriteOptions`] with the given session.
102    pub fn new(session: VortexSession) -> Self {
103        VortexWriteOptions {
104            strategy: None,
105            disable_editions: false,
106            buffered_bytes: BufferedBytesTracker::new(),
107            session,
108            exclude_dtype: false,
109            file_statistics: PRUNING_STATS.to_vec(),
110            max_variable_length_statistics_size: 64,
111            metadata: HashMap::default(),
112        }
113    }
114
115    /// Replace the default layout strategy with the provided one.
116    ///
117    /// The strategy controls repartitioning, statistics layout, compression, and leaf segment
118    /// emission, and is used without being reconfigured from the enabled editions. Use
119    /// [`WriteStrategyBuilder`] when only a small part of the default strategy needs customization.
120    /// Unless edition enforcement is explicitly disabled, the final serialization context still
121    /// rejects array IDs not permitted by the enabled editions, independently of which in-memory
122    /// encodings a compressor produces.
123    pub fn with_strategy(mut self, strategy: Arc<dyn LayoutStrategy>) -> Self {
124        self.strategy = Some(strategy);
125        self
126    }
127
128    /// Disable all edition enforcement for this write.
129    ///
130    /// The writer permits every serialized array ID registered in the session and does not
131    /// restrict layout IDs, extension dtype IDs, or aggregate function IDs. This does not register
132    /// missing implementations, and the resulting file may not be readable by other Vortex
133    /// versions or configurations.
134    pub fn disable_editions(mut self) -> Self {
135        self.disable_editions = true;
136        self
137    }
138
139    /// Returns the tracker accounting for bytes that layout strategies are holding but have not
140    /// yet emitted.
141    ///
142    /// The tracker is shared with the write these options start, so it can be captured before
143    /// calling [`Self::write`] and polled while the write runs. [`Writer::buffered_bytes`] exposes
144    /// the same counter for the push-based API.
145    pub fn buffered_bytes_tracker(&self) -> BufferedBytesTracker {
146        self.buffered_bytes.clone()
147    }
148
149    /// Exclude the DType from the Vortex file. You must provide the DType to the reader.
150    // TODO(ngates): Should we store some sort of DType checksum to make sure the one passed at
151    //  read-time is sane? I guess most layouts will have some reasonable validation.
152    pub fn exclude_dtype(mut self) -> Self {
153        self.exclude_dtype = true;
154        self
155    }
156
157    /// Configure which statistics to compute at the file level.
158    ///
159    /// Pass an empty vector to omit file-level statistics.
160    pub fn with_file_statistics(mut self, file_statistics: Vec<Stat>) -> Self {
161        self.file_statistics = file_statistics;
162        self
163    }
164
165    /// Add a user-defined metadata segment (keyed, opaque bytes); a repeated key replaces the
166    /// previous value. Keys and the segment count are validated against `MAX_METADATA_*`.
167    pub fn with_metadata_segment(
168        mut self,
169        key: impl Into<String>,
170        metadata: impl Into<ByteBuffer>,
171    ) -> Self {
172        let key = key.into();
173        let metadata = metadata.into();
174        self.metadata.insert(key, metadata);
175        self
176    }
177
178    /// Add user-defined metadata segments to the file.
179    ///
180    /// If a key already exists, the previous segment for that key is replaced.
181    pub fn with_metadata_segments<I, K, B>(mut self, metadata: I) -> Self
182    where
183        I: IntoIterator<Item = (K, B)>,
184        K: Into<String>,
185        B: Into<ByteBuffer>,
186    {
187        for (key, metadata) in metadata {
188            self = self.with_metadata_segment(key, metadata);
189        }
190        self
191    }
192
193    /// Check the configured metadata segments against the `MAX_METADATA_*` limits.
194    ///
195    /// [`Self::write`] performs the same check, but only once the sink is already being written
196    /// to. Callers that accept metadata from elsewhere (FFI bindings, for example) can use this to
197    /// reject an invalid set before any bytes are produced.
198    pub fn validate_metadata(&self) -> VortexResult<()> {
199        validate_metadata_segments(&self.metadata)
200    }
201}
202
203impl VortexWriteOptions {
204    /// Drop into the blocking writer API using the given runtime.
205    ///
206    /// The returned adapter drives async writer internals on `runtime` while accepting ordinary
207    /// [`std::io::Write`] sinks and [`ArrayIterator`] inputs.
208    pub fn blocking<B: BlockingRuntime>(self, runtime: &B) -> BlockingWrite<'_, B> {
209        BlockingWrite {
210            options: self,
211            runtime,
212        }
213    }
214
215    /// Write an [`ArrayStream`] as a Vortex file.
216    ///
217    /// Note that buffers are flushed as soon as they are available with no buffering, the caller
218    /// is responsible for deciding how to configure buffering on the underlying `Write` sink.
219    ///
220    /// When edition enforcement is enabled, the set of encodings permitted in the file is
221    /// snapshotted from the session's enabled editions here, so editions enabled after this call
222    /// do not affect the write.
223    pub async fn write<W: VortexWrite + Unpin, S: ArrayStream + Send + 'static>(
224        self,
225        write: W,
226        stream: S,
227    ) -> VortexResult<WriteSummary> {
228        self.write_internal(write, ArrayStreamExt::boxed(stream))
229            .await
230    }
231
232    async fn write_internal<W: VortexWrite + Unpin>(
233        self,
234        mut write: W,
235        stream: SendableArrayStream,
236    ) -> VortexResult<WriteSummary> {
237        validate_metadata_segments(&self.metadata)?;
238
239        let enforce_editions = !self.disable_editions;
240        // The array context is built here, rather than when the options were constructed, so that
241        // encodings registered on the session in between are still eligible for the file.
242        let (array_ctx, allowed_array_encodings) =
243            new_array_context(&self.session, enforce_editions);
244        let ctx = LayoutWriterContext::new(array_ctx)
245            .with_buffered_bytes_tracker(self.buffered_bytes.clone());
246        let ctx = if enforce_editions {
247            ctx.with_allowed_aggregates(edition_filter(&self.session, ComponentKind::Aggregate))
248        } else {
249            ctx
250        };
251        let strategy = match self.strategy {
252            Some(strategy) => strategy,
253            None => WriteStrategyBuilder::default()
254                .with_btrblocks_builder(
255                    BtrBlocksCompressorBuilder::default()
256                        .retain_allowed_encodings(&allowed_array_encodings),
257                )
258                .build(),
259        };
260        let dtype = stream.dtype().clone();
261        if enforce_editions {
262            validate_dtype_editions(&self.session, &dtype)?;
263        }
264
265        let (mut ptr, eof) = SequenceId::root().split();
266
267        let stream = SequentialStreamAdapter::new(
268            dtype.clone(),
269            stream
270                .try_filter(|chunk| ready(!chunk.is_empty()))
271                .map(move |result| result.map(|chunk| (ptr.advance(), chunk))),
272        )
273        .sendable();
274        let (file_stats, stream) = accumulate_stats(
275            stream,
276            self.file_statistics.clone().into(),
277            self.max_variable_length_statistics_size,
278            &self.session,
279        );
280
281        // First, write the magic bytes.
282        write.write_all(ByteBuffer::copy_from(MAGIC_BYTES)).await?;
283        let mut position = MAGIC_BYTES.len() as u64;
284
285        // Create a channel to send buffers from the segment sink to the output stream.
286        let (send, recv) = kanal::bounded_async(1);
287
288        let segments = Arc::new(BufferedSegmentSink::new(send, position));
289
290        // We spawn the layout future so it is driven in the background while we write the
291        // buffer stream, so we don't need to poll it until all buffers have been drained.
292        let ctx2 = ctx.clone();
293        let session = self.session.clone();
294        let layout_fut = self.session.handle().spawn_nested(move |_| async move {
295            let layout = strategy
296                .write_stream(
297                    ctx2,
298                    Arc::<BufferedSegmentSink>::clone(&segments),
299                    stream,
300                    eof,
301                    &session,
302                )
303                .await?;
304            Ok::<_, VortexError>((layout, segments.segment_specs()))
305        });
306
307        // Flush buffers as they arrive
308        let recv_stream = recv.into_stream();
309        pin_mut!(recv_stream);
310        while let Some(buffer) = recv_stream.next().await {
311            if buffer.is_empty() {
312                continue;
313            }
314            position += buffer.len() as u64;
315            write.write_all(buffer).await?;
316        }
317
318        let (layout, segment_specs) = layout_fut.await?;
319
320        // Assemble the Footer object now that we have all the segments.
321        let statistics = if self.file_statistics.is_empty() {
322            None
323        } else {
324            Some(FileStatistics::new_with_dtype(
325                file_stats.stats_sets().into(),
326                &dtype,
327            ))
328        };
329        let mut footer = Footer::new(
330            Arc::clone(&layout),
331            segment_specs,
332            statistics,
333            ReadContext::new(ctx.array_ctx().to_ids()),
334        );
335
336        // Emit the footer buffers and EOF.
337        let (footer_buffers, metadata, approx_byte_size) = footer
338            .clone()
339            .into_serializer()
340            .with_layout_context(new_layout_context(&self.session, enforce_editions))
341            .with_metadata_segments(self.metadata)
342            .with_offset(position)
343            .with_exclude_dtype(self.exclude_dtype)
344            .serialize_with_metadata()?;
345        footer = footer
346            .with_metadata_segments(metadata)
347            .with_approx_byte_size(approx_byte_size);
348
349        for buffer in footer_buffers {
350            position += buffer.len() as u64;
351            write.write_all(buffer).await?;
352        }
353
354        write.flush().await?;
355
356        Ok(WriteSummary {
357            footer,
358            size: position,
359        })
360    }
361
362    /// Create a push-based [`Writer`] that can be used to incrementally write arrays to the file.
363    ///
364    /// Each pushed chunk must have dtype `dtype`. Call [`Writer::finish`] to close the input stream,
365    /// flush remaining buffers, and receive the [`WriteSummary`].
366    pub fn writer<'w, W: VortexWrite + Unpin + 'w>(self, write: W, dtype: DType) -> Writer<'w> {
367        // Create a channel for sending arrays to the layout task.
368        let (arrays_send, arrays_recv) = kanal::bounded_async(1);
369
370        let arrays =
371            ArrayStreamExt::boxed(ArrayStreamAdapter::new(dtype, arrays_recv.into_stream()));
372
373        let write = CountingVortexWrite::new(write);
374        let bytes_written = write.counter();
375        let buffered_bytes = self.buffered_bytes.clone();
376        let future = self.write(write, arrays).boxed_local().fuse();
377
378        Writer {
379            arrays: Some(arrays_send),
380            future,
381            bytes_written,
382            buffered_bytes,
383        }
384    }
385}
386
387fn new_array_context(
388    session: &VortexSession,
389    enforce_editions: bool,
390) -> (ArrayContext, HashSet<ArrayId>) {
391    // NOTE(os): Set up an array context with all eligible serialized IDs pre-populated.
392    // This is preferred for now over having an empty context here, because only the
393    // serialised array order is deterministic. The serialisation of arrays are done
394    // parallel and with an empty context they can register their encodings to the context
395    // in different order, changing the written bytes from run to run.
396    let arrays = session.arrays();
397    let serialized_ids = if enforce_editions {
398        session.enabled_component_ids(ComponentKind::Array)
399    } else {
400        arrays
401            .registry()
402            .read(|registry| registry.keys().copied().collect())
403    };
404    let allowed_array_encodings = serialized_ids
405        .iter()
406        .filter_map(|serialized_id| arrays.registry().get(serialized_id))
407        .map(|plugin| plugin.id())
408        .collect();
409    let array_ctx = ArrayContext::new(serialized_ids.iter().copied().sorted().collect());
410    let array_ctx = if enforce_editions {
411        // Only permit serialized IDs in the enabled editions.
412        array_ctx.with_allowed_ids(serialized_ids.into_iter().collect())
413    } else {
414        array_ctx
415    };
416    (array_ctx, allowed_array_encodings)
417}
418
419/// The ids of `kind` the enabled editions permit.
420fn edition_filter(session: &VortexSession, kind: ComponentKind) -> HashSet<Id> {
421    session.enabled_component_ids(kind).into_iter().collect()
422}
423
424/// Validate every extension dtype nested in the file schema against the enabled editions.
425fn validate_dtype_editions(session: &VortexSession, dtype: &DType) -> VortexResult<()> {
426    let allowed = edition_filter(session, ComponentKind::DType);
427
428    fn validate(dtype: &DType, allowed: &HashSet<Id>) -> VortexResult<()> {
429        match dtype {
430            DType::List(element, _) | DType::FixedSizeList(element, ..) => {
431                validate(element, allowed)
432            }
433            DType::Map(map, _) => {
434                validate(&map.key_dtype(), allowed)?;
435                validate(&map.value_dtype(), allowed)
436            }
437            DType::Struct(fields, _) => {
438                for field in fields.fields() {
439                    validate(&field, allowed)?;
440                }
441                Ok(())
442            }
443            DType::Union(variants, _) => {
444                for variant in variants.variants() {
445                    validate(&variant, allowed)?;
446                }
447                Ok(())
448            }
449            DType::Extension(extension) => {
450                if !allowed.contains(&extension.id()) {
451                    vortex_bail!(
452                        "Extension DType {} not permitted by enabled editions",
453                        extension.id()
454                    );
455                }
456                validate(extension.storage_dtype(), allowed)
457            }
458            DType::Null
459            | DType::Bool(_)
460            | DType::Primitive(..)
461            | DType::Decimal(..)
462            | DType::Utf8(_)
463            | DType::Binary(_)
464            | DType::Variant(_) => Ok(()),
465        }
466    }
467
468    validate(dtype, &allowed)
469}
470
471/// The context every layout in the file is interned through.
472fn new_layout_context(session: &VortexSession, enforce_editions: bool) -> LayoutContext {
473    let context = LayoutContext::default();
474    if enforce_editions {
475        context.with_allowed_ids(edition_filter(session, ComponentKind::Layout))
476    } else {
477        context
478    }
479}
480
481fn validate_metadata_segments(metadata: &HashMap<String, ByteBuffer>) -> VortexResult<()> {
482    if metadata.len() > MAX_METADATA_SEGMENTS {
483        vortex_bail!(
484            "Vortex files may contain at most {} metadata segments; got {} metadata segments. Metadata keys must be non-empty and at most {} bytes",
485            MAX_METADATA_SEGMENTS,
486            metadata.len(),
487            MAX_METADATA_KEY_BYTES
488        );
489    }
490
491    for key in metadata.keys() {
492        if key.is_empty() {
493            vortex_bail!(
494                "Vortex metadata keys must be non-empty and at most {} bytes; files may contain at most {} metadata segments",
495                MAX_METADATA_KEY_BYTES,
496                MAX_METADATA_SEGMENTS
497            );
498        }
499
500        let key_bytes = key.len();
501        if key_bytes > MAX_METADATA_KEY_BYTES {
502            vortex_bail!(
503                "Vortex metadata key {key:?} is {key_bytes} bytes, but keys must be at most {} bytes; files may contain at most {} metadata segments",
504                MAX_METADATA_KEY_BYTES,
505                MAX_METADATA_SEGMENTS
506            );
507        }
508    }
509
510    Ok(())
511}
512
513/// An async API for writing Vortex files.
514pub struct Writer<'w> {
515    // The input channel for sending arrays to the writer.
516    arrays: Option<kanal::AsyncSender<VortexResult<ArrayRef>>>,
517    // The writer task that ultimately produces the footer.
518    future: Fuse<LocalBoxFuture<'w, VortexResult<WriteSummary>>>,
519    // The bytes written so far.
520    bytes_written: Arc<AtomicU64>,
521    // The buffered bytes accounting shared with the layout strategies for this write.
522    buffered_bytes: BufferedBytesTracker,
523}
524
525impl Writer<'_> {
526    /// Push a new chunk into the writer.
527    pub async fn push(&mut self, chunk: ArrayRef) -> VortexResult<()> {
528        let arrays = self.arrays.clone().vortex_expect("missing arrays sender");
529        let send_fut = async move { arrays.send(Ok(chunk)).await }.fuse();
530        pin_mut!(send_fut);
531
532        // We poll the writer future to continue writing bytes to the output, while waiting for
533        // enough room to push the next chunk into the channel.
534        select! {
535            result = send_fut => {
536                // If the send future failed, the writer has failed or panicked.
537                if result.is_err() {
538                    return Err(self.handle_failed_task().await);
539                }
540            },
541            result = &mut self.future => {
542                // Under normal operation, the writer future should never complete until
543                // finish() is called. Therefore, we can assume the writer has failed.
544                // The writer future has failed, we need to propagate the error.
545                match result {
546                    Ok(_) => vortex_bail!("Internal error: writer future completed early"),
547                    Err(e) => return Err(e),
548                }
549            }
550        }
551
552        Ok(())
553    }
554
555    /// Push an entire [`ArrayStream`] into the writer, consuming it.
556    ///
557    /// A task is spawned to consume the stream and push it into the writer, with the current
558    /// thread being used to write buffers to the output.
559    pub async fn push_stream(&mut self, mut stream: SendableArrayStream) -> VortexResult<()> {
560        let arrays = self.arrays.clone().vortex_expect("missing arrays sender");
561        let stream_fut = async move {
562            while let Some(chunk) = stream.next().await {
563                arrays.send(chunk).await?;
564            }
565            Ok::<_, kanal::SendError>(())
566        }
567        .fuse();
568        pin_mut!(stream_fut);
569
570        // We poll the writer future to continue writing bytes to the output, while waiting for
571        // enough room to push the stream into the channel.
572        select! {
573            result = stream_fut => {
574                if let Err(_send_err) = result {
575                    // If the send future failed, the writer has failed or panicked.
576                    return Err(self.handle_failed_task().await);
577                }
578            }
579
580            result = &mut self.future => {
581                // Under normal operation, the writer future should never complete until
582                // finish() is called. Therefore, we can assume the writer has failed.
583                // The writer future has failed, we need to propagate the error.
584                match result {
585                    Ok(_) => vortex_bail!("Internal error: writer future completed early"),
586                    Err(e) => return Err(e),
587                }
588            }
589        }
590
591        Ok(())
592    }
593
594    /// Returns the number of bytes written to the file so far.
595    pub fn bytes_written(&self) -> u64 {
596        self.bytes_written.load(Ordering::Relaxed)
597    }
598
599    /// Returns the number of bytes currently buffered by the layout writers.
600    pub fn buffered_bytes(&self) -> u64 {
601        self.buffered_bytes.buffered_bytes()
602    }
603
604    /// Finish writing the Vortex file, flushing any remaining buffers and returning the
605    /// new file's footer.
606    pub async fn finish(mut self) -> VortexResult<WriteSummary> {
607        // Drop the input channel to signal EOF.
608        drop(self.arrays.take());
609
610        // Await the future task.
611        self.future.await
612    }
613
614    /// Assuming the writer task has failed, await it to get the error.
615    async fn handle_failed_task(&mut self) -> VortexError {
616        match (&mut self.future).await {
617            Ok(_) => vortex_err!(
618                "Internal error: writer task completed successfully but write future finished early"
619            ),
620            Err(e) => e,
621        }
622    }
623}
624
625/// Blocking adapter for [`VortexWriteOptions`].
626pub struct BlockingWrite<'rt, B: BlockingRuntime> {
627    options: VortexWriteOptions,
628    runtime: &'rt B,
629}
630
631impl<'rt, B: BlockingRuntime> BlockingWrite<'rt, B> {
632    /// Write a Vortex file into the given `Write` sink.
633    ///
634    /// The iterator is converted to an [`ArrayStream`] and driven to completion on
635    /// the configured blocking runtime.
636    pub fn write<W: Write + Unpin + Send>(
637        self,
638        write: W,
639        iter: impl ArrayIterator + Send + 'static,
640    ) -> VortexResult<WriteSummary> {
641        self.runtime.block_on(async move {
642            self.options
643                .write(BlockingWriteAdapter(write), iter.into_array_stream())
644                .await
645        })
646    }
647
648    /// Create a blocking push-based writer for chunks with dtype `dtype`.
649    pub fn writer<'w, W: Write + Unpin + Send + 'w>(
650        self,
651        write: W,
652        dtype: DType,
653    ) -> BlockingWriter<'rt, 'w, B> {
654        BlockingWriter {
655            writer: self.options.writer(BlockingWriteAdapter(write), dtype),
656            runtime: self.runtime,
657        }
658    }
659}
660
661/// A blocking adapter around a [`Writer`], allowing incremental writing of arrays to a Vortex file.
662pub struct BlockingWriter<'rt, 'w, B: BlockingRuntime> {
663    runtime: &'rt B,
664    writer: Writer<'w>,
665}
666
667impl<B: BlockingRuntime> BlockingWriter<'_, '_, B> {
668    /// Push one array chunk into the file.
669    pub fn push(&mut self, chunk: ArrayRef) -> VortexResult<()> {
670        self.runtime.block_on(self.writer.push(chunk))
671    }
672
673    /// Returns the number of bytes written to the sink so far.
674    pub fn bytes_written(&self) -> u64 {
675        self.writer.bytes_written()
676    }
677
678    /// Returns the number of bytes currently buffered by layout strategies.
679    pub fn buffered_bytes(&self) -> u64 {
680        self.writer.buffered_bytes()
681    }
682
683    /// Finish writing and return the written file summary.
684    pub fn finish(self) -> VortexResult<WriteSummary> {
685        self.runtime.block_on(self.writer.finish())
686    }
687}
688
689// TODO(ngates): this blocking API may change, for now we just run blocking I/O inline.
690struct BlockingWriteAdapter<W>(W);
691
692impl<W: Write + Unpin + Send> VortexWrite for BlockingWriteAdapter<W> {
693    async fn write_all<B: IoBuf>(&mut self, buffer: B) -> io::Result<B> {
694        self.0.write_all(buffer.as_slice())?;
695        Ok(buffer)
696    }
697
698    fn flush(&mut self) -> impl Future<Output = io::Result<()>> + Send {
699        ready(self.0.flush())
700    }
701
702    fn shutdown(&mut self) -> impl Future<Output = io::Result<()>> + Send {
703        ready(Ok(()))
704    }
705}
706
707/// Summary returned after a Vortex file is written.
708pub struct WriteSummary {
709    footer: Footer,
710    size: u64,
711    // TODO(ngates): add a checksum
712}
713
714impl WriteSummary {
715    /// The footer of the written Vortex file.
716    pub fn footer(&self) -> &Footer {
717        &self.footer
718    }
719
720    /// The total size of the written Vortex file in bytes.
721    pub fn size(&self) -> u64 {
722        self.size
723    }
724
725    /// The total number of rows in the written Vortex file.
726    pub fn row_count(&self) -> u64 {
727        self.footer.row_count()
728    }
729
730    /// Returns the compressed size in bytes of each top-level column in schema order.
731    ///
732    /// A column's size includes every physical segment attributed to its layout subtree,
733    /// including auxiliary segments such as zone maps and dictionaries; see
734    /// [`Footer::compressed_field_sizes`] for the exact attribution semantics and for sizes of
735    /// nested fields. Bytes not attributable to a specific column (e.g. top-level struct
736    /// validity) are not included in any column's size.
737    ///
738    /// For a non-struct file, the returned vector contains a single entry for the root column.
739    pub fn compressed_column_sizes(&self) -> VortexResult<Vec<u64>> {
740        let sizes = self.footer.compressed_field_sizes()?;
741        let Some(fields) = self.footer.dtype().as_struct_fields_opt() else {
742            return Ok(vec![sizes.total()]);
743        };
744        Ok(fields
745            .names()
746            .iter()
747            .map(|name| {
748                sizes
749                    .get(&FieldPath::from_name(name.clone()))
750                    .unwrap_or_default()
751            })
752            .collect())
753    }
754}
755
756#[cfg(test)]
757mod tests {
758    use rstest::rstest;
759    use vortex_array::VTable;
760    use vortex_array::array_session;
761    use vortex_array::arrays::Bool;
762    use vortex_array::arrays::Primitive;
763    use vortex_buffer::ByteBuffer;
764    use vortex_edition::ComponentKind;
765    use vortex_edition::Edition;
766    use vortex_edition::EditionDeclaration;
767    use vortex_edition::EditionId;
768    use vortex_edition::EditionInclusion;
769    use vortex_edition::EditionMember;
770    use vortex_edition::EditionSession;
771    use vortex_edition::EditionSessionExt;
772
773    use super::*;
774
775    #[test]
776    fn array_context_only_permits_enabled_encodings() -> VortexResult<()> {
777        const EDITION: EditionId = EditionId::new("test", 2026, 7, 0);
778        static DECLARATION: EditionDeclaration = EditionDeclaration {
779            edition: Edition {
780                id: EDITION,
781                min_library_version: None,
782            },
783            added: &[EditionMember::array(&"vortex.primitive")],
784        };
785
786        let session = array_session().with::<EditionSession>();
787        session.register_edition(&DECLARATION)?;
788        session.enable_edition(EDITION)?;
789
790        let (ctx, allowed_array_encodings) = new_array_context(&session, true);
791        assert_eq!(ctx.to_ids(), [Primitive.id()]);
792        assert!(ctx.intern(&Bool.id()).is_none());
793        assert_eq!(allowed_array_encodings, HashSet::from([Primitive.id()]));
794        Ok(())
795    }
796
797    #[test]
798    fn disabling_editions_allows_all_registered_array_ids() {
799        let session = array_session();
800        let (registered_ids, registered_encodings) = session.arrays().registry().read(|registry| {
801            (
802                registry.keys().copied().sorted().collect::<Vec<_>>(),
803                registry
804                    .values()
805                    .map(|plugin| plugin.id())
806                    .collect::<HashSet<_>>(),
807            )
808        });
809
810        let (ctx, allowed_array_encodings) = new_array_context(&session, false);
811        assert_eq!(ctx.to_ids(), registered_ids);
812        assert_eq!(allowed_array_encodings, registered_encodings);
813        assert!(ctx.intern(&Bool.id()).is_some());
814    }
815
816    /// This test edition declares only arrays, so every other kind must forbid all components.
817    #[test]
818    fn kind_filters_are_active_when_empty() -> VortexResult<()> {
819        const EDITION: EditionId = EditionId::new("test", 2026, 8, 0);
820        static ARRAYS_ONLY: EditionDeclaration = EditionDeclaration {
821            edition: Edition {
822                id: EDITION,
823                min_library_version: None,
824            },
825            added: &[EditionMember::array(&"vortex.primitive")],
826        };
827
828        let session = array_session().with::<EditionSession>();
829        session.register_edition(&ARRAYS_ONLY)?;
830        session.enable_edition(EDITION)?;
831        assert!(edition_filter(&session, ComponentKind::Layout).is_empty());
832        assert!(edition_filter(&session, ComponentKind::DType).is_empty());
833        assert!(edition_filter(&session, ComponentKind::Aggregate).is_empty());
834        assert!(
835            new_layout_context(&session, true)
836                .intern(&"vortex.flat".into())
837                .is_none()
838        );
839        assert!(
840            new_layout_context(&session, false)
841                .intern(&"vortex.flat".into())
842                .is_some()
843        );
844
845        session.editions().declare_inclusion(EditionInclusion::new(
846            ComponentKind::Aggregate,
847            "vortex.min",
848            EDITION,
849        ))?;
850        let allowed = edition_filter(&session, ComponentKind::Aggregate);
851        assert_eq!(allowed.len(), 1);
852        assert!(allowed.contains(&Id::from("vortex.min")));
853        Ok(())
854    }
855
856    #[test]
857    fn dtype_filter_checks_nested_extension_dtypes() -> VortexResult<()> {
858        use vortex_array::dtype::Nullability;
859        use vortex_array::extension::datetime::Date;
860        use vortex_array::extension::datetime::Time;
861        use vortex_array::extension::datetime::TimeUnit;
862
863        const EDITION: EditionId = EditionId::new("test", 2026, 8, 0);
864        static DECLARATION: EditionDeclaration = EditionDeclaration {
865            edition: Edition {
866                id: EDITION,
867                min_library_version: None,
868            },
869            added: &[EditionMember::dtype(&"vortex.date")],
870        };
871
872        let session = array_session().with::<EditionSession>();
873        session.register_edition(&DECLARATION)?;
874        session.enable_edition(EDITION)?;
875
876        let date = DType::Extension(Date::new(TimeUnit::Days, Nullability::NonNullable).erased());
877        let nested = DType::struct_([("date", date)], Nullability::NonNullable);
878        validate_dtype_editions(&session, &nested)?;
879
880        let time =
881            DType::Extension(Time::new(TimeUnit::Seconds, Nullability::NonNullable).erased());
882        let error = validate_dtype_editions(&session, &time)
883            .expect_err("vortex.time is not in the enabled edition");
884        assert!(error.to_string().contains("vortex.time"));
885        Ok(())
886    }
887
888    fn write_options_with_keys(keys: &[String]) -> VortexWriteOptions {
889        array_session().write_options().with_metadata_segments(
890            keys.iter()
891                .map(|key| (key.clone(), ByteBuffer::copy_from(b"value"))),
892        )
893    }
894
895    #[rstest]
896    #[case::empty_key(vec![String::new()], "non-empty")]
897    #[case::oversized_key(vec!["k".repeat(MAX_METADATA_KEY_BYTES + 1)], "keys must be at most")]
898    // The cap is on bytes, not characters.
899    #[case::oversized_multibyte_key(
900        vec!["é".repeat(MAX_METADATA_KEY_BYTES / "é".len() + 1)],
901        "keys must be at most"
902        )]
903    #[case::too_many_segments(
904        (0..=MAX_METADATA_SEGMENTS).map(|idx| format!("key-{idx}")).collect(),
905        "at most 16 metadata segments"
906        )]
907    fn validate_metadata_rejects(#[case] keys: Vec<String>, #[case] expected: &str) {
908        let Err(error) = write_options_with_keys(&keys).validate_metadata() else {
909            panic!("metadata must be rejected for {keys:?}");
910        };
911        assert!(
912            error.to_string().contains(expected),
913            "error should mention {expected:?}, got: {error}"
914        );
915    }
916
917    #[test]
918    fn validate_metadata_accepts_the_limits() -> VortexResult<()> {
919        // Distinct keys, each exactly at the key-length cap.
920        let keys = (0..MAX_METADATA_SEGMENTS)
921            .map(|idx| format!("{idx:0>width$}", width = MAX_METADATA_KEY_BYTES))
922            .collect::<Vec<_>>();
923        write_options_with_keys(&keys).validate_metadata()
924    }
925
926    #[test]
927    fn validate_metadata_accepts_no_metadata() -> VortexResult<()> {
928        write_options_with_keys(&[]).validate_metadata()
929    }
930}