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 = new_array_context(&self.session, enforce_editions);
243        let ctx = LayoutWriterContext::new(array_ctx)
244            .with_buffered_bytes_tracker(self.buffered_bytes.clone());
245        let ctx = if enforce_editions {
246            ctx.with_allowed_aggregates(edition_filter(&self.session, ComponentKind::Aggregate))
247        } else {
248            ctx
249        };
250        let strategy = match self.strategy {
251            Some(strategy) => strategy,
252            None => {
253                let mut compressor = BtrBlocksCompressorBuilder::from_session(&self.session);
254                if !enforce_editions {
255                    compressor = compressor.disable_editions();
256                }
257                WriteStrategyBuilder::from_session(&self.session)
258                    .with_btrblocks_builder(compressor)
259                    .build()
260            }
261        };
262        let dtype = stream.dtype().clone();
263        if enforce_editions {
264            validate_dtype_editions(&self.session, &dtype)?;
265        }
266
267        let (mut ptr, eof) = SequenceId::root().split();
268
269        let stream = SequentialStreamAdapter::new(
270            dtype.clone(),
271            stream
272                .try_filter(|chunk| ready(!chunk.is_empty()))
273                .map(move |result| result.map(|chunk| (ptr.advance(), chunk))),
274        )
275        .sendable();
276        let (file_stats, stream) = accumulate_stats(
277            stream,
278            self.file_statistics.clone().into(),
279            self.max_variable_length_statistics_size,
280            &self.session,
281        );
282
283        // First, write the magic bytes.
284        write.write_all(ByteBuffer::copy_from(MAGIC_BYTES)).await?;
285        let mut position = MAGIC_BYTES.len() as u64;
286
287        // Create a channel to send buffers from the segment sink to the output stream.
288        let (send, recv) = kanal::bounded_async(1);
289
290        let segments = Arc::new(BufferedSegmentSink::new(send, position));
291
292        // We spawn the layout future so it is driven in the background while we write the
293        // buffer stream, so we don't need to poll it until all buffers have been drained.
294        let ctx2 = ctx.clone();
295        let session = self.session.clone();
296        let layout_fut = self.session.handle().spawn_nested(move |_| async move {
297            let layout = strategy
298                .write_stream(
299                    ctx2,
300                    Arc::<BufferedSegmentSink>::clone(&segments),
301                    stream,
302                    eof,
303                    &session,
304                )
305                .await?;
306            Ok::<_, VortexError>((layout, segments.segment_specs()))
307        });
308
309        // Flush buffers as they arrive
310        let recv_stream = recv.into_stream();
311        pin_mut!(recv_stream);
312        while let Some(buffer) = recv_stream.next().await {
313            if buffer.is_empty() {
314                continue;
315            }
316            position += buffer.len() as u64;
317            write.write_all(buffer).await?;
318        }
319
320        let (layout, segment_specs) = layout_fut.await?;
321
322        // Assemble the Footer object now that we have all the segments.
323        let statistics = if self.file_statistics.is_empty() {
324            None
325        } else {
326            Some(FileStatistics::new_with_dtype(
327                file_stats.stats_sets().into(),
328                &dtype,
329            ))
330        };
331        let mut footer = Footer::new(
332            Arc::clone(&layout),
333            segment_specs,
334            statistics,
335            ReadContext::new(ctx.array_ctx().to_ids()),
336        );
337
338        // Emit the footer buffers and EOF.
339        let (footer_buffers, metadata, approx_byte_size) = footer
340            .clone()
341            .into_serializer()
342            .with_layout_context(new_layout_context(&self.session, enforce_editions))
343            .with_metadata_segments(self.metadata)
344            .with_offset(position)
345            .with_exclude_dtype(self.exclude_dtype)
346            .serialize_with_metadata()?;
347        footer = footer
348            .with_metadata_segments(metadata)
349            .with_approx_byte_size(approx_byte_size);
350
351        for buffer in footer_buffers {
352            position += buffer.len() as u64;
353            write.write_all(buffer).await?;
354        }
355
356        write.flush().await?;
357
358        Ok(WriteSummary {
359            footer,
360            size: position,
361        })
362    }
363
364    /// Create a push-based [`Writer`] that can be used to incrementally write arrays to the file.
365    ///
366    /// Each pushed chunk must have dtype `dtype`. Call [`Writer::finish`] to close the input stream,
367    /// flush remaining buffers, and receive the [`WriteSummary`].
368    pub fn writer<'w, W: VortexWrite + Unpin + 'w>(self, write: W, dtype: DType) -> Writer<'w> {
369        // Create a channel for sending arrays to the layout task.
370        let (arrays_send, arrays_recv) = kanal::bounded_async(1);
371
372        let arrays =
373            ArrayStreamExt::boxed(ArrayStreamAdapter::new(dtype, arrays_recv.into_stream()));
374
375        let write = CountingVortexWrite::new(write);
376        let bytes_written = write.counter();
377        let buffered_bytes = self.buffered_bytes.clone();
378        let future = self.write(write, arrays).boxed_local().fuse();
379
380        Writer {
381            arrays: Some(arrays_send),
382            future,
383            bytes_written,
384            buffered_bytes,
385        }
386    }
387}
388
389fn new_array_context(session: &VortexSession, enforce_editions: bool) -> ArrayContext {
390    // NOTE(os): Set up an array context with all eligible serialized IDs pre-populated.
391    // This is preferred for now over having an empty context here, because only the
392    // serialised array order is deterministic. The serialisation of arrays are done
393    // parallel and with an empty context they can register their encodings to the context
394    // in different order, changing the written bytes from run to run.
395
396    // The registry keys are exactly the serialized IDs that can be produced by the plugins
397    // registered in the session. Intersected with the serialized IDs the enabled editions permit,
398    // this is the set of IDs that can be written. Only seed the array context with these IDs, and
399    // only allow compressor schemes that produce arrays with serialized IDs within this set.
400    let registered: HashSet<ArrayId> = session
401        .arrays()
402        .registry()
403        .read(|registry| registry.keys().copied().collect());
404    let serialized_ids: Vec<ArrayId> = if enforce_editions {
405        // Must filter by the set of serialized IDs supported by registered plugins. Otherwise,
406        // we could enable an ID that will never be written.
407        session
408            .enabled_component_ids(ComponentKind::Array)
409            .into_iter()
410            .filter(|id| registered.contains(id))
411            .collect()
412    } else {
413        registered.into_iter().collect()
414    };
415    let array_ctx = ArrayContext::new(serialized_ids.iter().copied().sorted().collect());
416    if enforce_editions {
417        // Only permit serialized IDs in the enabled editions.
418        array_ctx.with_allowed_ids(serialized_ids.into_iter().collect())
419    } else {
420        array_ctx
421    }
422}
423
424/// The ids of `kind` the enabled editions permit.
425fn edition_filter(session: &VortexSession, kind: ComponentKind) -> HashSet<Id> {
426    session.enabled_component_ids(kind).into_iter().collect()
427}
428
429/// Validate every extension dtype nested in the file schema against the enabled editions.
430fn validate_dtype_editions(session: &VortexSession, dtype: &DType) -> VortexResult<()> {
431    let allowed = edition_filter(session, ComponentKind::DType);
432
433    fn validate(dtype: &DType, allowed: &HashSet<Id>) -> VortexResult<()> {
434        match dtype {
435            DType::List(element, _) | DType::FixedSizeList(element, ..) => {
436                validate(element, allowed)
437            }
438            DType::Map(map, _) => {
439                validate(&map.key_dtype(), allowed)?;
440                validate(&map.value_dtype(), allowed)
441            }
442            DType::Struct(fields, _) => {
443                for field in fields.fields() {
444                    validate(&field, allowed)?;
445                }
446                Ok(())
447            }
448            DType::Union(variants, _) => {
449                for variant in variants.variants() {
450                    validate(&variant, allowed)?;
451                }
452                Ok(())
453            }
454            DType::Extension(extension) => {
455                if !allowed.contains(&extension.id()) {
456                    vortex_bail!(
457                        "Extension DType {} not permitted by enabled editions",
458                        extension.id()
459                    );
460                }
461                validate(extension.storage_dtype(), allowed)
462            }
463            DType::Null
464            | DType::Bool(_)
465            | DType::Primitive(..)
466            | DType::Decimal(..)
467            | DType::Utf8(_)
468            | DType::Binary(_)
469            | DType::Variant(_) => Ok(()),
470        }
471    }
472
473    validate(dtype, &allowed)
474}
475
476/// The context every layout in the file is interned through.
477fn new_layout_context(session: &VortexSession, enforce_editions: bool) -> LayoutContext {
478    let context = LayoutContext::default();
479    if enforce_editions {
480        context.with_allowed_ids(edition_filter(session, ComponentKind::Layout))
481    } else {
482        context
483    }
484}
485
486fn validate_metadata_segments(metadata: &HashMap<String, ByteBuffer>) -> VortexResult<()> {
487    if metadata.len() > MAX_METADATA_SEGMENTS {
488        vortex_bail!(
489            "Vortex files may contain at most {} metadata segments; got {} metadata segments. Metadata keys must be non-empty and at most {} bytes",
490            MAX_METADATA_SEGMENTS,
491            metadata.len(),
492            MAX_METADATA_KEY_BYTES
493        );
494    }
495
496    for key in metadata.keys() {
497        if key.is_empty() {
498            vortex_bail!(
499                "Vortex metadata keys must be non-empty and at most {} bytes; files may contain at most {} metadata segments",
500                MAX_METADATA_KEY_BYTES,
501                MAX_METADATA_SEGMENTS
502            );
503        }
504
505        let key_bytes = key.len();
506        if key_bytes > MAX_METADATA_KEY_BYTES {
507            vortex_bail!(
508                "Vortex metadata key {key:?} is {key_bytes} bytes, but keys must be at most {} bytes; files may contain at most {} metadata segments",
509                MAX_METADATA_KEY_BYTES,
510                MAX_METADATA_SEGMENTS
511            );
512        }
513    }
514
515    Ok(())
516}
517
518/// An async API for writing Vortex files.
519pub struct Writer<'w> {
520    // The input channel for sending arrays to the writer.
521    arrays: Option<kanal::AsyncSender<VortexResult<ArrayRef>>>,
522    // The writer task that ultimately produces the footer.
523    future: Fuse<LocalBoxFuture<'w, VortexResult<WriteSummary>>>,
524    // The bytes written so far.
525    bytes_written: Arc<AtomicU64>,
526    // The buffered bytes accounting shared with the layout strategies for this write.
527    buffered_bytes: BufferedBytesTracker,
528}
529
530impl Writer<'_> {
531    /// Push a new chunk into the writer.
532    pub async fn push(&mut self, chunk: ArrayRef) -> VortexResult<()> {
533        let arrays = self.arrays.clone().vortex_expect("missing arrays sender");
534        let send_fut = async move { arrays.send(Ok(chunk)).await }.fuse();
535        pin_mut!(send_fut);
536
537        // We poll the writer future to continue writing bytes to the output, while waiting for
538        // enough room to push the next chunk into the channel.
539        select! {
540            result = send_fut => {
541                // If the send future failed, the writer has failed or panicked.
542                if result.is_err() {
543                    return Err(self.handle_failed_task().await);
544                }
545            },
546            result = &mut self.future => {
547                // Under normal operation, the writer future should never complete until
548                // finish() is called. Therefore, we can assume the writer has failed.
549                // The writer future has failed, we need to propagate the error.
550                match result {
551                    Ok(_) => vortex_bail!("Internal error: writer future completed early"),
552                    Err(e) => return Err(e),
553                }
554            }
555        }
556
557        Ok(())
558    }
559
560    /// Push an entire [`ArrayStream`] into the writer, consuming it.
561    ///
562    /// A task is spawned to consume the stream and push it into the writer, with the current
563    /// thread being used to write buffers to the output.
564    pub async fn push_stream(&mut self, mut stream: SendableArrayStream) -> VortexResult<()> {
565        let arrays = self.arrays.clone().vortex_expect("missing arrays sender");
566        let stream_fut = async move {
567            while let Some(chunk) = stream.next().await {
568                arrays.send(chunk).await?;
569            }
570            Ok::<_, kanal::SendError>(())
571        }
572        .fuse();
573        pin_mut!(stream_fut);
574
575        // We poll the writer future to continue writing bytes to the output, while waiting for
576        // enough room to push the stream into the channel.
577        select! {
578            result = stream_fut => {
579                if let Err(_send_err) = result {
580                    // If the send future failed, the writer has failed or panicked.
581                    return Err(self.handle_failed_task().await);
582                }
583            }
584
585            result = &mut self.future => {
586                // Under normal operation, the writer future should never complete until
587                // finish() is called. Therefore, we can assume the writer has failed.
588                // The writer future has failed, we need to propagate the error.
589                match result {
590                    Ok(_) => vortex_bail!("Internal error: writer future completed early"),
591                    Err(e) => return Err(e),
592                }
593            }
594        }
595
596        Ok(())
597    }
598
599    /// Returns the number of bytes written to the file so far.
600    pub fn bytes_written(&self) -> u64 {
601        self.bytes_written.load(Ordering::Relaxed)
602    }
603
604    /// Returns the number of bytes currently buffered by the layout writers.
605    pub fn buffered_bytes(&self) -> u64 {
606        self.buffered_bytes.buffered_bytes()
607    }
608
609    /// Finish writing the Vortex file, flushing any remaining buffers and returning the
610    /// new file's footer.
611    pub async fn finish(mut self) -> VortexResult<WriteSummary> {
612        // Drop the input channel to signal EOF.
613        drop(self.arrays.take());
614
615        // Await the future task.
616        self.future.await
617    }
618
619    /// Assuming the writer task has failed, await it to get the error.
620    async fn handle_failed_task(&mut self) -> VortexError {
621        match (&mut self.future).await {
622            Ok(_) => vortex_err!(
623                "Internal error: writer task completed successfully but write future finished early"
624            ),
625            Err(e) => e,
626        }
627    }
628}
629
630/// Blocking adapter for [`VortexWriteOptions`].
631pub struct BlockingWrite<'rt, B: BlockingRuntime> {
632    options: VortexWriteOptions,
633    runtime: &'rt B,
634}
635
636impl<'rt, B: BlockingRuntime> BlockingWrite<'rt, B> {
637    /// Write a Vortex file into the given `Write` sink.
638    ///
639    /// The iterator is converted to an [`ArrayStream`] and driven to completion on
640    /// the configured blocking runtime.
641    pub fn write<W: Write + Unpin + Send>(
642        self,
643        write: W,
644        iter: impl ArrayIterator + Send + 'static,
645    ) -> VortexResult<WriteSummary> {
646        self.runtime.block_on(async move {
647            self.options
648                .write(BlockingWriteAdapter(write), iter.into_array_stream())
649                .await
650        })
651    }
652
653    /// Create a blocking push-based writer for chunks with dtype `dtype`.
654    pub fn writer<'w, W: Write + Unpin + Send + 'w>(
655        self,
656        write: W,
657        dtype: DType,
658    ) -> BlockingWriter<'rt, 'w, B> {
659        BlockingWriter {
660            writer: self.options.writer(BlockingWriteAdapter(write), dtype),
661            runtime: self.runtime,
662        }
663    }
664}
665
666/// A blocking adapter around a [`Writer`], allowing incremental writing of arrays to a Vortex file.
667pub struct BlockingWriter<'rt, 'w, B: BlockingRuntime> {
668    runtime: &'rt B,
669    writer: Writer<'w>,
670}
671
672impl<B: BlockingRuntime> BlockingWriter<'_, '_, B> {
673    /// Push one array chunk into the file.
674    pub fn push(&mut self, chunk: ArrayRef) -> VortexResult<()> {
675        self.runtime.block_on(self.writer.push(chunk))
676    }
677
678    /// Returns the number of bytes written to the sink so far.
679    pub fn bytes_written(&self) -> u64 {
680        self.writer.bytes_written()
681    }
682
683    /// Returns the number of bytes currently buffered by layout strategies.
684    pub fn buffered_bytes(&self) -> u64 {
685        self.writer.buffered_bytes()
686    }
687
688    /// Finish writing and return the written file summary.
689    pub fn finish(self) -> VortexResult<WriteSummary> {
690        self.runtime.block_on(self.writer.finish())
691    }
692}
693
694// TODO(ngates): this blocking API may change, for now we just run blocking I/O inline.
695struct BlockingWriteAdapter<W>(W);
696
697impl<W: Write + Unpin + Send> VortexWrite for BlockingWriteAdapter<W> {
698    async fn write_all<B: IoBuf>(&mut self, buffer: B) -> io::Result<B> {
699        self.0.write_all(buffer.as_slice())?;
700        Ok(buffer)
701    }
702
703    fn flush(&mut self) -> impl Future<Output = io::Result<()>> + Send {
704        ready(self.0.flush())
705    }
706
707    fn shutdown(&mut self) -> impl Future<Output = io::Result<()>> + Send {
708        ready(Ok(()))
709    }
710}
711
712/// Summary returned after a Vortex file is written.
713pub struct WriteSummary {
714    footer: Footer,
715    size: u64,
716    // TODO(ngates): add a checksum
717}
718
719impl WriteSummary {
720    /// The footer of the written Vortex file.
721    pub fn footer(&self) -> &Footer {
722        &self.footer
723    }
724
725    /// The total size of the written Vortex file in bytes.
726    pub fn size(&self) -> u64 {
727        self.size
728    }
729
730    /// The total number of rows in the written Vortex file.
731    pub fn row_count(&self) -> u64 {
732        self.footer.row_count()
733    }
734
735    /// Returns the compressed size in bytes of each top-level column in schema order.
736    ///
737    /// A column's size includes every physical segment attributed to its layout subtree,
738    /// including auxiliary segments such as zone maps and dictionaries; see
739    /// [`Footer::compressed_field_sizes`] for the exact attribution semantics and for sizes of
740    /// nested fields. Bytes not attributable to a specific column (e.g. top-level struct
741    /// validity) are not included in any column's size.
742    ///
743    /// For a non-struct file, the returned vector contains a single entry for the root column.
744    pub fn compressed_column_sizes(&self) -> VortexResult<Vec<u64>> {
745        let sizes = self.footer.compressed_field_sizes()?;
746        let Some(fields) = self.footer.dtype().as_struct_fields_opt() else {
747            return Ok(vec![sizes.total()]);
748        };
749        Ok(fields
750            .names()
751            .iter()
752            .map(|name| {
753                sizes
754                    .get(&FieldPath::from_name(name.clone()))
755                    .unwrap_or_default()
756            })
757            .collect())
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use rstest::rstest;
764    use vortex_array::VTable;
765    use vortex_array::array_session;
766    use vortex_array::arrays::Bool;
767    use vortex_array::arrays::Primitive;
768    use vortex_buffer::ByteBuffer;
769    use vortex_edition::ComponentKind;
770    use vortex_edition::Edition;
771    use vortex_edition::EditionDeclaration;
772    use vortex_edition::EditionId;
773    use vortex_edition::EditionInclusion;
774    use vortex_edition::EditionMember;
775    use vortex_edition::EditionSession;
776    use vortex_edition::EditionSessionExt;
777    use vortex_session::registry::CachedId;
778
779    use super::*;
780
781    #[test]
782    fn array_context_only_permits_enabled_encodings() -> VortexResult<()> {
783        const EDITION: EditionId = EditionId::new("test", 2026, 7, 0);
784        static DECLARATION: EditionDeclaration = EditionDeclaration {
785            edition: Edition {
786                id: EDITION,
787                min_library_version: None,
788            },
789            added: &[EditionMember::array(&"vortex.primitive")],
790        };
791
792        let session = array_session().with::<EditionSession>();
793        session.register_edition(&DECLARATION)?;
794        session.enable_edition(EDITION)?;
795
796        let ctx = new_array_context(&session, true);
797        assert_eq!(ctx.to_ids(), [Primitive.id()]);
798        assert!(ctx.intern(&Bool.id()).is_none());
799        Ok(())
800    }
801
802    /// An edition may enable an encoding whose plugin is not registered on the session. Nothing
803    /// could serialize it, so it is neither seeded into the table nor offered to the compressor.
804    #[test]
805    fn array_context_skips_enabled_but_unregistered_encodings() -> VortexResult<()> {
806        const EDITION: EditionId = EditionId::new("test", 2026, 9, 0);
807        static DECLARATION: EditionDeclaration = EditionDeclaration {
808            edition: Edition {
809                id: EDITION,
810                min_library_version: None,
811            },
812            added: &[
813                EditionMember::array(&"vortex.primitive"),
814                EditionMember::array(&"vortex.alp"),
815            ],
816        };
817
818        let session = array_session().with::<EditionSession>();
819        session.register_edition(&DECLARATION)?;
820        session.enable_edition(EDITION)?;
821
822        let ctx = new_array_context(&session, true);
823        assert_eq!(ctx.to_ids(), [Primitive.id()]);
824        static ALP: CachedId = CachedId::new("vortex.alp");
825        assert!(ctx.intern(&ALP).is_none());
826        Ok(())
827    }
828
829    #[test]
830    fn disabling_editions_allows_all_registered_array_ids() {
831        let session = array_session();
832        let registered_ids = session
833            .arrays()
834            .registry()
835            .read(|registry| registry.keys().copied().sorted().collect::<Vec<_>>());
836
837        let ctx = new_array_context(&session, false);
838        assert_eq!(ctx.to_ids(), registered_ids);
839        assert!(ctx.intern(&Bool.id()).is_some());
840    }
841
842    /// This test edition declares only arrays, so every other kind must forbid all components.
843    #[test]
844    fn kind_filters_are_active_when_empty() -> VortexResult<()> {
845        const EDITION: EditionId = EditionId::new("test", 2026, 8, 0);
846        static ARRAYS_ONLY: EditionDeclaration = EditionDeclaration {
847            edition: Edition {
848                id: EDITION,
849                min_library_version: None,
850            },
851            added: &[EditionMember::array(&"vortex.primitive")],
852        };
853
854        let session = array_session().with::<EditionSession>();
855        session.register_edition(&ARRAYS_ONLY)?;
856        session.enable_edition(EDITION)?;
857        assert!(edition_filter(&session, ComponentKind::Layout).is_empty());
858        assert!(edition_filter(&session, ComponentKind::DType).is_empty());
859        assert!(edition_filter(&session, ComponentKind::Aggregate).is_empty());
860        assert!(
861            new_layout_context(&session, true)
862                .intern(&"vortex.flat".into())
863                .is_none()
864        );
865        assert!(
866            new_layout_context(&session, false)
867                .intern(&"vortex.flat".into())
868                .is_some()
869        );
870
871        session.editions().declare_inclusion(EditionInclusion::new(
872            ComponentKind::Aggregate,
873            "vortex.min",
874            EDITION,
875        ))?;
876        let allowed = edition_filter(&session, ComponentKind::Aggregate);
877        assert_eq!(allowed.len(), 1);
878        assert!(allowed.contains(&Id::from("vortex.min")));
879        Ok(())
880    }
881
882    #[test]
883    fn dtype_filter_checks_nested_extension_dtypes() -> VortexResult<()> {
884        use vortex_array::dtype::Nullability;
885        use vortex_array::extension::datetime::Date;
886        use vortex_array::extension::datetime::Time;
887        use vortex_array::extension::datetime::TimeUnit;
888
889        const EDITION: EditionId = EditionId::new("test", 2026, 8, 0);
890        static DECLARATION: EditionDeclaration = EditionDeclaration {
891            edition: Edition {
892                id: EDITION,
893                min_library_version: None,
894            },
895            added: &[EditionMember::dtype(&"vortex.date")],
896        };
897
898        let session = array_session().with::<EditionSession>();
899        session.register_edition(&DECLARATION)?;
900        session.enable_edition(EDITION)?;
901
902        let date = DType::Extension(Date::new(TimeUnit::Days, Nullability::NonNullable).erased());
903        let nested = DType::struct_([("date", date)], Nullability::NonNullable);
904        validate_dtype_editions(&session, &nested)?;
905
906        let time =
907            DType::Extension(Time::new(TimeUnit::Seconds, Nullability::NonNullable).erased());
908        let error = validate_dtype_editions(&session, &time)
909            .expect_err("vortex.time is not in the enabled edition");
910        assert!(error.to_string().contains("vortex.time"));
911        Ok(())
912    }
913
914    fn write_options_with_keys(keys: &[String]) -> VortexWriteOptions {
915        array_session().write_options().with_metadata_segments(
916            keys.iter()
917                .map(|key| (key.clone(), ByteBuffer::copy_from(b"value"))),
918        )
919    }
920
921    #[rstest]
922    #[case::empty_key(vec![String::new()], "non-empty")]
923    #[case::oversized_key(vec!["k".repeat(MAX_METADATA_KEY_BYTES + 1)], "keys must be at most")]
924    // The cap is on bytes, not characters.
925    #[case::oversized_multibyte_key(
926        vec!["é".repeat(MAX_METADATA_KEY_BYTES / "é".len() + 1)],
927        "keys must be at most"
928        )]
929    #[case::too_many_segments(
930        (0..=MAX_METADATA_SEGMENTS).map(|idx| format!("key-{idx}")).collect(),
931        "at most 16 metadata segments"
932        )]
933    fn validate_metadata_rejects(#[case] keys: Vec<String>, #[case] expected: &str) {
934        let Err(error) = write_options_with_keys(&keys).validate_metadata() else {
935            panic!("metadata must be rejected for {keys:?}");
936        };
937        assert!(
938            error.to_string().contains(expected),
939            "error should mention {expected:?}, got: {error}"
940        );
941    }
942
943    #[test]
944    fn validate_metadata_accepts_the_limits() -> VortexResult<()> {
945        // Distinct keys, each exactly at the key-length cap.
946        let keys = (0..MAX_METADATA_SEGMENTS)
947            .map(|idx| format!("{idx:0>width$}", width = MAX_METADATA_KEY_BYTES))
948            .collect::<Vec<_>>();
949        write_options_with_keys(&keys).validate_metadata()
950    }
951
952    #[test]
953    fn validate_metadata_accepts_no_metadata() -> VortexResult<()> {
954        write_options_with_keys(&[]).validate_metadata()
955    }
956}