Skip to main content

acta/write/
writer.rs

1//! Writer lifecycle, buffering coordination, and sink handling.
2
3use std::fmt;
4use std::fs::{File, OpenOptions};
5use std::io::{self, Write};
6use std::path::Path;
7use std::sync::Arc;
8
9use crate::batch::RecordBatch;
10use crate::error::{Error, ErrorContext, Result};
11use crate::format::constants::{
12    FIRST_BASE_ROW_ID, FIRST_DATA_FRAME_SEQUENCE, ROW_IDS_FEATURE, UNAVAILABLE_BASE_ROW_ID,
13};
14use crate::format::scan::FileScan;
15use crate::limits::Limits;
16use crate::lock::acquire_writer_lock;
17#[cfg(unix)]
18use crate::lock::release_writer_lock;
19use crate::schema::Schema;
20
21use super::api::{WriteAccounting, WriteSummary, WriterOptions, WriterStatistics};
22use super::buffer::{BlockBuffer, block_size_with_statistics};
23use super::encode::{build_data_frame_with_statistics, validate_encoding};
24use super::framing::{build_prologue, build_schema_frame, write_initial};
25use super::input::{validate_batch_input, validate_options, validate_schema};
26use super::invalid_batch;
27
28/// The file operations a [`Writer`] performs.
29///
30/// Naming them lets a test substitute a sink that fails on demand, which is the
31/// only way to reach the poisoned state without an unwritable disk. Production
32/// always writes to a [`File`].
33pub(super) trait Sink: Write + Send + Sync {
34    fn sync(&mut self) -> io::Result<()>;
35
36    fn release_lock(&mut self) -> io::Result<()> {
37        Ok(())
38    }
39}
40
41impl Sink for File {
42    fn sync(&mut self) -> io::Result<()> {
43        self.sync_all()
44    }
45
46    #[cfg(unix)]
47    fn release_lock(&mut self) -> io::Result<()> {
48        release_writer_lock(self)
49    }
50}
51
52/// The continuation point shared by newly-created and reopened append
53/// sessions. `file_length` is the last complete physical file boundary; a
54/// failed write poisons the writer before this state can be advanced.
55///
56/// Every write goes to a handle opened in append mode, so this is accounting
57/// and framing state rather than a seek target: an offset that fell behind the
58/// real end of the file could misreport a length, but it could never place a
59/// new frame over a committed one.
60#[derive(Debug, Clone, Copy)]
61pub(super) struct AppendState {
62    pub(super) file_length: u64,
63    pub(super) next_sequence: u64,
64    pub(super) next_row_id: u64,
65}
66
67impl AppendState {
68    pub(super) fn new(file_length: u64) -> Self {
69        Self {
70            file_length,
71            next_sequence: FIRST_DATA_FRAME_SEQUENCE,
72            next_row_id: FIRST_BASE_ROW_ID,
73        }
74    }
75}
76
77/// A synchronous buffered writer for Acta v0.2 append sessions.
78///
79/// [`Writer::create`] exclusively creates its path and [`Writer::open`] resumes
80/// an existing complete file. They differ only in how the first
81/// the append state is established: `create` writes a prologue and schema frame
82/// and starts at sequence one, while `open` reconstructs the schema and
83/// continuation point from the file. From there both hold the same locked
84/// handle and enter the same append engine, which buffers appended nonempty
85/// [`RecordBatch`] values until a row or byte target causes a data frame to be
86/// published. `flush` publishes the final partial buffer, while `sync` and
87/// `finish` additionally request durability. A partial write or durability
88/// failure poisons the writer; dropping it performs no explicit I/O and may
89/// discard only uncommitted buffered rows.
90///
91/// Every write is an operating-system append, so a writer can only ever extend
92/// a file. No path in this type truncates, rewrites, or repairs committed
93/// bytes.
94///
95/// ```
96/// use std::sync::Arc;
97/// use acta::{
98///     Array, Column, LogicalType, PrimitiveArray, RecordBatch, Schema, Writer, WriterOptions,
99/// };
100///
101/// let schema = Schema::new(
102///     1,
103///     vec![Column::new(1, "value", LogicalType::Int64, false)],
104///     None,
105/// );
106/// let batch = RecordBatch::try_new(
107///     Arc::new(schema.clone()),
108///     vec![Array::Int64(PrimitiveArray::new(vec![1, 2, 3], None))],
109///     3,
110/// )?;
111///
112/// let path = std::env::temp_dir().join("acta-writer-doc-example.acta");
113/// let _ = std::fs::remove_file(&path);
114///
115/// let mut writer = Writer::create(&path, schema, WriterOptions::default())?;
116/// writer.append(batch)?;
117/// let summary = writer.finish()?;
118/// assert_eq!(summary.rows_written(), 3);
119/// assert_eq!(summary.blocks_written(), 1);
120///
121/// let _ = std::fs::remove_file(&path);
122/// # Ok::<(), acta::Error>(())
123/// ```
124#[must_use = "a Writer must be finished or explicitly dropped"]
125pub struct Writer {
126    sink: Box<dyn Sink>,
127    schema: Arc<Schema>,
128    options: WriterOptions,
129    state: AppendState,
130    buffer: BlockBuffer,
131    published_rows: u64,
132    published_bytes: u64,
133    durable_rows: u64,
134    durable_bytes: u64,
135    blocks_written: u64,
136    poisoned: bool,
137}
138
139impl Writer {
140    /// Exclusively create `path`, write its prologue and schema frame, and
141    /// return a writer ready for data blocks.
142    ///
143    /// The path must not already exist. This never opens, appends to, or
144    /// overwrites an existing file, and it never removes one: only a file this
145    /// call itself created can be cleaned up, and only when initializing it
146    /// fails.
147    ///
148    /// # Writer exclusion
149    ///
150    /// The returned writer holds a cooperative exclusive lock on the file until
151    /// it is finished or dropped. A second `acta` writer on the same file, in
152    /// this process or another, fails immediately with
153    /// [`ErrorKind::WriterLocked`](crate::ErrorKind::WriterLocked) instead of
154    /// waiting. Readers never take the lock and are never blocked by it: it is
155    /// advisory on Unix, and on Windows it covers a single byte past the end of
156    /// the addressable file rather than the data.
157    ///
158    /// Its limits are worth stating plainly. Specification section 14 defers
159    /// writer-locking protocols to a later format version, so this is a
160    /// convention among `acta` writers rather than part of the format; no other
161    /// Acta implementation participates in it, and no lock constrains a process
162    /// that simply opens the path and writes. It is also unreliable on network
163    /// filesystems, where advisory locks are emulated or absent. Targets that
164    /// are neither Unix nor Windows have no lock primitive here at all, and
165    /// both this method and [`Self::open`] refuse to construct a writer there.
166    pub fn create<P: AsRef<Path>>(path: P, schema: Schema, options: WriterOptions) -> Result<Self> {
167        validate_schema(&schema)?;
168        validate_options(options)?;
169        validate_encoding(&schema, options.encoding)?;
170        let feature_flags = if options.row_ids { ROW_IDS_FEATURE } else { 0 };
171        let prologue = build_prologue(feature_flags);
172        let schema_frame = build_schema_frame(&schema)?;
173        let file_length = u64::try_from(prologue.len() + schema_frame.len()).map_err(|_| {
174            Error::resource_limit("initial Acta file length does not fit this platform", None)
175                .with_context(ErrorContext::File)
176        })?;
177
178        let path = path.as_ref();
179        let mut file = OpenOptions::new()
180            .read(true)
181            .append(true)
182            .create_new(true)
183            .open(path)
184            .map_err(|error| Error::io(error, None).with_context(ErrorContext::File))?;
185        // `create_new` succeeded, so every arm below owns a path this call
186        // brought into existence a moment ago. No preexisting file can reach
187        // one of them.
188        if let Err(error) = acquire_writer_lock(&file) {
189            // A writer that wins the lock in the window between `create_new`
190            // and this call finds an empty file, fails its own discovery, and
191            // releases. Both callers then fail and neither leaves a file
192            // behind. Closing the window would need an atomic create-and-lock
193            // that no portable API offers.
194            drop(file);
195            let _ = std::fs::remove_file(path);
196            return Err(error);
197        }
198        if let Err(error) = write_initial(&mut file, &prologue, &schema_frame) {
199            // Leaving the partial file behind would turn a transient failure
200            // into a permanent one: creation is exclusive, so the natural retry
201            // would find the path already taken.
202            drop(file);
203            let _ = std::fs::remove_file(path);
204            return Err(error);
205        }
206
207        Ok(Self::from_append_session(
208            Box::new(file),
209            schema,
210            options,
211            AppendState::new(file_length),
212        ))
213    }
214
215    /// Open an existing complete Acta file and continue appending to it.
216    ///
217    /// The file is authoritative. Its schema is reconstructed and returned by
218    /// [`Self::schema`], and `options.row_ids` must agree with the row-ID
219    /// feature its prologue declares, because reopening can neither enable nor
220    /// disable that feature. Codec, encoding, statistics, and block-size
221    /// options apply to the blocks this session writes and leave existing
222    /// blocks and file-level features untouched.
223    ///
224    /// The path must already exist; this never creates one, and there is no
225    /// open-or-create behavior.
226    ///
227    /// # What opening validates
228    ///
229    /// Opening performs structural and whole-frame validation in one linear
230    /// pass: the prologue, the schema frame, and for every complete frame its
231    /// prefix, header, trailer, and body CRC, along with sequence continuity
232    /// and the implicit row-ID chain. It is *not*
233    /// [`ValidationLevel::Full`](crate::ValidationLevel::Full) — no stream is
234    /// decoded and no statistic is verified. Because every committed byte is
235    /// checksummed, the cost is proportional to the size of the file, and a
236    /// long series of small appends pays it once per session.
237    ///
238    /// A file ending inside an unfinished frame is refused with
239    /// [`ErrorKind::IncompleteTail`](crate::ErrorKind::IncompleteTail) rather
240    /// than truncated, and damage to a frame that is present in full stays
241    /// [`ErrorKind::Corruption`](crate::ErrorKind::Corruption). Recovery is a
242    /// separate, explicit operation. No failure path here writes, truncates, or
243    /// repairs a single byte.
244    ///
245    /// # Writer exclusion
246    ///
247    /// This takes the same cooperative exclusive lock as [`Self::create`], with
248    /// the same scope and the same limits; see that method. The lock is taken
249    /// before any discovery, and the handle it is taken on is the handle this
250    /// writer appends with, so there is no window between validating a path and
251    /// writing to it.
252    ///
253    /// ```
254    /// use std::sync::Arc;
255    /// use acta::{
256    ///     Array, Column, LogicalType, PrimitiveArray, RecordBatch, Schema, Writer, WriterOptions,
257    /// };
258    ///
259    /// let schema = Schema::new(
260    ///     1,
261    ///     vec![Column::new(1, "value", LogicalType::Int64, false)],
262    ///     None,
263    /// );
264    /// let path = std::env::temp_dir().join("acta-writer-open-doc-example.acta");
265    /// let _ = std::fs::remove_file(&path);
266    /// Writer::create(&path, schema, WriterOptions::default())?.finish()?;
267    ///
268    /// let mut writer = Writer::open(&path, WriterOptions::default())?;
269    /// // The reconstructed schema is what new batches must match.
270    /// let schema = Arc::clone(writer.schema());
271    /// let batch = RecordBatch::try_new(
272    ///     schema,
273    ///     vec![Array::Int64(PrimitiveArray::new(vec![1, 2, 3], None))],
274    ///     3,
275    /// )?;
276    /// writer.append(batch)?;
277    /// let summary = writer.finish()?;
278    /// assert_eq!(summary.rows_written(), 3);
279    /// assert_eq!(summary.last_sequence(), Some(1));
280    ///
281    /// let _ = std::fs::remove_file(&path);
282    /// # Ok::<(), acta::Error>(())
283    /// ```
284    pub fn open<P: AsRef<Path>>(path: P, options: WriterOptions) -> Result<Self> {
285        Self::open_internal(path.as_ref(), None, Limits::default(), options)
286    }
287
288    /// Open an existing complete Acta file for append and require its schema to
289    /// equal `expected_schema` exactly.
290    ///
291    /// Equality covers the schema ID, the column count and order, and every
292    /// column's ID, name, logical type and type parameters, and nullability,
293    /// along with the primary-column selection. A difference in any of them
294    /// fails with
295    /// [`ErrorKind::SchemaMismatch`](crate::ErrorKind::SchemaMismatch) before
296    /// the file is walked and before a writer exists, so no byte is written.
297    ///
298    /// This is a convenience over [`Self::open`] followed by comparing
299    /// [`Self::schema`]; everything [`Self::open`] documents applies here.
300    pub fn open_with_schema<P: AsRef<Path>>(
301        path: P,
302        expected_schema: &Schema,
303        options: WriterOptions,
304    ) -> Result<Self> {
305        Self::open_internal(
306            path.as_ref(),
307            Some(expected_schema),
308            Limits::default(),
309            options,
310        )
311    }
312
313    /// Open an existing complete Acta file for append under explicit [`Limits`].
314    ///
315    /// The bounds apply to the declared sizes this call reads out of the
316    /// existing file, exactly as they do for
317    /// [`Reader::open_with_limits`](crate::Reader::open_with_limits). Without
318    /// this, a file whose frames exceed [`Limits::default`] is readable but not
319    /// appendable. The blocks this writer goes on to produce are still bounded
320    /// by the format defaults, so it cannot emit a frame that an ordinary
321    /// reader would refuse.
322    ///
323    /// To combine a schema guard with custom limits, open with this method and
324    /// compare [`Self::schema`] before appending.
325    pub fn open_with_limits<P: AsRef<Path>>(
326        path: P,
327        limits: Limits,
328        options: WriterOptions,
329    ) -> Result<Self> {
330        Self::open_internal(path.as_ref(), None, limits, options)
331    }
332
333    fn open_internal(
334        path: &Path,
335        expected_schema: Option<&Schema>,
336        limits: Limits,
337        options: WriterOptions,
338    ) -> Result<Self> {
339        validate_options(options)?;
340
341        let file = OpenOptions::new()
342            .read(true)
343            .append(true)
344            .open(path)
345            .map_err(|error| Error::io(error, None).with_context(ErrorContext::File))?;
346        acquire_writer_lock(&file)?;
347
348        // One handle for the whole operation: opened, locked, lent to discovery
349        // below, and taken back to append with. It is never duplicated and the
350        // path is never reopened, so discovery reads exactly the bytes the
351        // appends will extend.
352        let mut scan = FileScan::from_file(file, limits)?;
353
354        // The prologue and schema frame answer every cheap guard, so all of
355        // them run before the walk reads the rest of the file. A caller that
356        // named the wrong schema or the wrong row-ID option learns so without
357        // paying for a checksum pass over gigabytes.
358        let schema = scan.schema().clone();
359        if let Some(expected_schema) = expected_schema {
360            if expected_schema != &schema {
361                return Err(Error::schema_mismatch(
362                    "the expected schema does not match the existing file",
363                )
364                .with_context(ErrorContext::Header));
365            }
366        }
367        validate_schema(&schema)?;
368        validate_encoding(&schema, options.encoding)?;
369        let row_ids_enabled = scan.prologue().feature_flags & ROW_IDS_FEATURE != 0;
370        if options.row_ids != row_ids_enabled {
371            return Err(super::invalid_option(
372                "WriterOptions::row_ids must match the existing file",
373            ));
374        }
375
376        let walk = scan.walk_data_frames(|_frame, _block| Ok(()))?;
377        if walk.incomplete_tail {
378            return Err(Error::incomplete_tail(
379                "cannot append to a file with an incomplete tail; recover it explicitly first",
380                Some(walk.last_good_offset),
381            )
382            .with_context(ErrorContext::File));
383        }
384
385        let file_size = scan.file_size();
386        let file = scan.into_file();
387        let file_length = file
388            .metadata()
389            .map(|metadata| metadata.len())
390            .map_err(|error| Error::io(error, None).with_context(ErrorContext::File))?;
391        // A complete walk always ends exactly at the extent it captured, so
392        // this compares that extent against the file as it stands now. A length
393        // that moved means something outside this crate's lock wrote to the
394        // file while it was being opened.
395        if file_length != file_size {
396            return Err(Error::io(
397                io::Error::other("the file changed while it was being opened for append"),
398                Some(file_length),
399            )
400            .with_context(ErrorContext::File));
401        }
402        Ok(Self::from_append_session(
403            Box::new(file),
404            schema,
405            options,
406            AppendState {
407                file_length,
408                next_sequence: walk.next_sequence,
409                next_row_id: walk.next_row_id.unwrap_or(FIRST_BASE_ROW_ID),
410            },
411        ))
412    }
413
414    /// Enter the append engine at an arbitrary continuation point over a test
415    /// sink, which is how a reopened session is reached without a real file.
416    #[cfg(test)]
417    pub(super) fn new(
418        sink: Box<dyn Sink>,
419        schema: Schema,
420        options: WriterOptions,
421        state: AppendState,
422    ) -> Self {
423        Self::from_append_session(sink, schema, options, state)
424    }
425
426    /// The schema every batch appended to this writer must match.
427    ///
428    /// For a writer from [`Self::open`] this is the schema reconstructed from
429    /// the file, which is authoritative. Clone it to build compatible
430    /// [`RecordBatch`] values:
431    ///
432    /// ```no_run
433    /// # use std::sync::Arc;
434    /// # let writer: acta::Writer = unimplemented!();
435    /// let schema = Arc::clone(writer.schema());
436    /// ```
437    pub fn schema(&self) -> &Arc<Schema> {
438        &self.schema
439    }
440
441    /// Enter the one append engine used by both public initialization paths.
442    fn from_append_session(
443        sink: Box<dyn Sink>,
444        schema: Schema,
445        options: WriterOptions,
446        state: AppendState,
447    ) -> Self {
448        let buffer = BlockBuffer::new_with_statistics(schema.column_count(), options.statistics);
449        Self {
450            sink,
451            schema: Arc::new(schema),
452            options,
453            state,
454            buffer,
455            // Every counter below describes this session alone and so starts at
456            // zero even for a reopened file. The file-global continuation point
457            // is `state`, which `open` reconstructs from the existing bytes.
458            published_rows: 0,
459            published_bytes: 0,
460            durable_rows: 0,
461            durable_bytes: 0,
462            blocks_written: 0,
463            poisoned: false,
464        }
465    }
466
467    /// Append one nonempty batch to the bounded block buffer.
468    ///
469    /// The batch schema must exactly match [`Self::schema`], which is the
470    /// schema passed to [`Self::create`] or, for a reopened writer, the one
471    /// reconstructed from the file.
472    /// Batch-shape and value errors are returned as
473    /// [`crate::ErrorKind::InvalidArgument`] before anything is buffered, so a
474    /// rejected batch leaves the writer exactly as it found it.
475    ///
476    /// Reaching a block target publishes a frame from inside this call, so an
477    /// append is not all-or-nothing against I/O. A failed publication poisons
478    /// the writer and reports the failure, but blocks published earlier in the
479    /// same call stay on disk and stay counted: compare
480    /// [`WriteAccounting::total_rows`] across the call to see how much of the
481    /// batch was accepted. A poisoned writer refuses further work, so the
482    /// unaccepted rows are not retried.
483    pub fn append(&mut self, batch: RecordBatch) -> Result<()> {
484        self.ensure_healthy()?;
485        if batch.schema() != self.schema.as_ref() {
486            return Err(invalid_batch(
487                "the batch schema does not match the writer schema",
488            ));
489        }
490        if batch.row_count() == 0 {
491            return Err(invalid_batch("an Acta data block cannot be empty"));
492        }
493
494        // Validate the whole input before buffering any of it, so a batch this
495        // writer rejects cannot leave a partially accepted prefix behind. Only
496        // a failed publication can, which the documentation above states.
497        validate_batch_input(&self.schema, &batch)?;
498
499        // A batch that fits whole is moved into the buffer. Slicing it would
500        // copy every value for nothing, and this is the ordinary case.
501        if self.fitting_prefix(&batch, 0)? == batch.row_count() {
502            self.buffer.push(&self.schema, batch);
503            return self.publish_if_complete();
504        }
505
506        let total = batch.row_count();
507        let mut consumed = 0;
508        while consumed < total {
509            let take = self.fitting_prefix(&batch, consumed)?;
510            if take == 0 {
511                self.publish_buffer()?;
512                continue;
513            }
514            let end = consumed + take;
515            self.buffer.push(&self.schema, batch.slice(consumed, end));
516            consumed = end;
517            self.publish_if_complete()?;
518        }
519        Ok(())
520    }
521
522    /// Publish buffered rows as a data frame and flush the operating-system
523    /// file handle. This does not request durable storage.
524    pub fn flush(&mut self) -> Result<()> {
525        self.ensure_healthy()?;
526        self.publish_buffer()?;
527        if let Err(error) = self.sink.flush() {
528            return self.poison_io(error, self.state.file_length);
529        }
530        Ok(())
531    }
532
533    /// Flush and request durable storage for all bytes written so far.
534    pub fn sync(&mut self) -> Result<()> {
535        self.flush()?;
536        if let Err(error) = self.sink.sync() {
537            return self.poison_io(error, self.state.file_length);
538        }
539        self.durable_rows = self.published_rows;
540        self.durable_bytes = self.published_bytes;
541        Ok(())
542    }
543
544    /// Flush, synchronize, consume the writer, and return write accounting.
545    ///
546    /// This is the only way to end a writer without losing buffered rows,
547    /// because `Drop` performs no I/O. A failure here therefore ends the file
548    /// at its last published block and discards any rows still buffered along
549    /// with the writer.
550    pub fn finish(mut self) -> Result<WriteSummary> {
551        self.sync()?;
552        self.sink
553            .release_lock()
554            .map_err(|error| Error::io(error, None).with_context(ErrorContext::File))?;
555        Ok(WriteSummary {
556            rows_written: self.published_rows,
557            blocks_written: self.blocks_written,
558            bytes_written: self.state.file_length,
559            last_sequence: (self.blocks_written != 0).then_some(self.state.next_sequence - 1),
560            accounting: self.accounting(),
561        })
562    }
563
564    /// The current buffered, published, durable, and total data accounting.
565    pub fn accounting(&self) -> WriteAccounting {
566        let buffered_rows = self.buffer.row_count();
567        let buffered_bytes = self.buffer.frame_bytes();
568        WriteAccounting {
569            buffered_rows,
570            buffered_bytes,
571            published_rows: self.published_rows,
572            published_bytes: self.published_bytes,
573            durable_rows: self.durable_rows,
574            durable_bytes: self.durable_bytes,
575            total_rows: self.published_rows.saturating_add(buffered_rows),
576            total_bytes: self.published_bytes.saturating_add(buffered_bytes),
577        }
578    }
579
580    /// How many rows from `start` the block being assembled can still take.
581    ///
582    /// Zero means the buffer must be published first. A single row that no
583    /// empty block could ever hold is reported as an error instead, so the
584    /// caller cannot loop publishing an empty buffer.
585    fn fitting_prefix(&self, batch: &RecordBatch, start: usize) -> Result<usize> {
586        let capacity = self
587            .options
588            .row_block_target
589            .saturating_sub(self.buffer.row_count());
590        let max_rows =
591            (batch.row_count() - start).min(usize::try_from(capacity).unwrap_or(usize::MAX));
592        if max_rows == 0 {
593            return Ok(0);
594        }
595
596        let mut footprints = self.buffer.footprints().to_vec();
597        let mut rows = self.buffer.row_count();
598        let mut accepted = 0;
599        for offset in 0..max_rows {
600            for (footprint, array) in footprints.iter_mut().zip(batch.columns()) {
601                footprint.add_row(array, start + offset);
602            }
603            rows += 1;
604            let size = block_size_with_statistics(
605                &self.schema,
606                &footprints,
607                rows,
608                self.options.statistics,
609            );
610            if !size.within_format_limits(rows) {
611                break;
612            }
613            // A row too large for the byte target is still written, as a block
614            // of its own, rather than split across blocks or refused.
615            let unavoidably_oversize = self.buffer.is_empty() && offset == 0;
616            if size.frame_bytes > self.options.byte_block_target && !unavoidably_oversize {
617                break;
618            }
619            accepted = offset + 1;
620        }
621
622        if accepted == 0 && self.buffer.is_empty() {
623            return Err(self.unwritable_row_error(batch, start));
624        }
625        Ok(accepted)
626    }
627
628    /// Explain why an empty block cannot hold even one row.
629    ///
630    /// The statistics policy is charged against the frame header budget, so a
631    /// schema the encoding alone could write can become unwritable once a wide
632    /// `fixed_binary` column has to carry a min/max pair twice its width.
633    /// Naming that case separately keeps the caller from hunting for an
634    /// oversize row that does not exist, and points at the option that caused
635    /// it. This runs only on the failure path, where one more pricing pass
636    /// costs nothing.
637    fn unwritable_row_error(&self, batch: &RecordBatch, start: usize) -> Error {
638        // The buffer is empty here, so its footprints are all zero and this
639        // prices exactly the one row that was refused.
640        let mut footprints = self.buffer.footprints().to_vec();
641        for (footprint, array) in footprints.iter_mut().zip(batch.columns()) {
642            footprint.add_row(array, start);
643        }
644        let without_statistics =
645            block_size_with_statistics(&self.schema, &footprints, 1, WriterStatistics::None);
646        if without_statistics.within_format_limits(1) {
647            return invalid_batch(
648                "the block statistics this writer would generate do not fit the frame \
649                 header limit; this schema needs WriterStatistics::None",
650            );
651        }
652        invalid_batch("a single row exceeds the largest block this format version allows")
653    }
654
655    /// Publish the buffer once it has reached either configured target.
656    fn publish_if_complete(&mut self) -> Result<()> {
657        if self.buffer.row_count() >= self.options.row_block_target
658            || self.buffer.frame_bytes() >= self.options.byte_block_target
659        {
660            return self.publish_buffer();
661        }
662        Ok(())
663    }
664
665    /// Write the buffered rows as one data frame, and clear the buffer only
666    /// once those bytes have reached the sink.
667    fn publish_buffer(&mut self) -> Result<()> {
668        if self.buffer.is_empty() {
669            return Ok(());
670        }
671        let raw_bytes = self.buffer.frame_bytes();
672        let row_count = self.buffer.row_count();
673        let base_row_id = if self.options.row_ids {
674            self.state.next_row_id
675        } else {
676            UNAVAILABLE_BASE_ROW_ID
677        };
678        let frame = build_data_frame_with_statistics(
679            &self.schema,
680            &self.buffer.rows(),
681            self.options,
682            base_row_id,
683            self.state.next_sequence,
684        )?;
685        let frame_length = u64::try_from(frame.len()).map_err(|_| {
686            Error::resource_limit("data frame length does not fit this platform", None)
687                .with_context(ErrorContext::Frame {
688                    sequence: self.state.next_sequence,
689                })
690        })?;
691        if let Err(error) = self.sink.write_all(&frame) {
692            return self.poison_io(error, self.state.file_length);
693        }
694        self.buffer.clear();
695        self.commit(frame_length, raw_bytes, row_count)
696    }
697
698    /// Advance the accounting for a frame that is already on disk.
699    ///
700    /// Every counter here describes committed bytes, so any one of them
701    /// overflowing leaves the writer unable to describe its own file. That is
702    /// the same inability to continue safely that a partial write causes, and
703    /// it is treated the same way. Keeping the four boundaries together also
704    /// keeps a half-advanced writer from existing at all.
705    fn commit(&mut self, frame_length: u64, raw_bytes: u64, row_count: u64) -> Result<()> {
706        let advanced_row_id = if self.options.row_ids {
707            self.state.next_row_id.checked_add(row_count)
708        } else {
709            Some(self.state.next_row_id)
710        };
711        let (
712            Some(file_length),
713            Some(blocks_written),
714            Some(next_sequence),
715            Some(next_row_id),
716            Some(published_rows),
717            Some(published_bytes),
718        ) = (
719            self.state.file_length.checked_add(frame_length),
720            self.blocks_written.checked_add(1),
721            self.state.next_sequence.checked_add(1),
722            advanced_row_id,
723            self.published_rows.checked_add(row_count),
724            self.published_bytes.checked_add(raw_bytes),
725        )
726        else {
727            self.poisoned = true;
728            return Err(Error::resource_limit(
729                "the writer can no longer account for the bytes it has written",
730                Some(self.state.file_length),
731            )
732            .with_context(ErrorContext::File));
733        };
734        self.state.file_length = file_length;
735        self.blocks_written = blocks_written;
736        self.state.next_sequence = next_sequence;
737        self.state.next_row_id = next_row_id;
738        self.published_rows = published_rows;
739        self.published_bytes = published_bytes;
740        Ok(())
741    }
742
743    fn ensure_healthy(&self) -> Result<()> {
744        if self.poisoned {
745            return Err(
746                Error::poisoned("the writer cannot continue after a partial I/O failure")
747                    .with_context(ErrorContext::File),
748            );
749        }
750        Ok(())
751    }
752
753    fn poison_io<T>(&mut self, error: io::Error, offset: u64) -> Result<T> {
754        self.poisoned = true;
755        Err(Error::io(error, Some(offset)).with_context(ErrorContext::File))
756    }
757}
758
759/// The sink is opaque, so the accounting is what a debug rendering can show.
760impl fmt::Debug for Writer {
761    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
762        formatter
763            .debug_struct("Writer")
764            .field("schema", &self.schema)
765            .field("options", &self.options)
766            .field("state", &self.state)
767            .field("buffered_rows", &self.buffer.row_count())
768            .field("buffered_bytes", &self.buffer.frame_bytes())
769            .field("published_rows", &self.published_rows)
770            .field("published_bytes", &self.published_bytes)
771            .field("durable_rows", &self.durable_rows)
772            .field("durable_bytes", &self.durable_bytes)
773            .field("blocks_written", &self.blocks_written)
774            .field("poisoned", &self.poisoned)
775            .finish_non_exhaustive()
776    }
777}