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}