acta/read/reader.rs
1//! Synchronous discovery of a stable metadata snapshot.
2
3use std::fs::File;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use crate::error::{Error, ErrorContext, Result};
8use crate::format::constants::TRAILER_SIZE;
9use crate::format::data_frame;
10use crate::format::frame::{self, FrameRead};
11use crate::format::scan::{FileScan, ResumePoint};
12use crate::limits::Limits;
13use crate::schema::Schema;
14
15use super::block::{BlockMetadata, PrimaryBounds};
16use super::budget::ScanBudget;
17use super::decode;
18use super::identity::{self, FileIdentity};
19use super::scan::Scan;
20use super::tail::Tail;
21
22/// The width of the opaque file ID a v0.2 prologue carries.
23pub const FILE_ID_SIZE: usize = 16;
24
25/// File-level metadata retained by a [`Reader`] snapshot.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct FileMetadata {
28 format_version: (u16, u16),
29 feature_flags: u64,
30 file_id: [u8; FILE_ID_SIZE],
31 schema_id: u64,
32 file_size: u64,
33 last_good_offset: u64,
34 /// Complete frames in the snapshot, counting the schema frame. Carried
35 /// from the shared walk rather than recomputed, so full validation and
36 /// structural validation cannot drift apart if a future frame type stops
37 /// contributing exactly one data block per frame.
38 frame_count: u64,
39 block_count: u64,
40 total_rows: u64,
41 incomplete_tail: bool,
42 /// The sequence number the next appended data frame must carry.
43 ///
44 /// Carried from the walk rather than derived from `frame_count`. The two
45 /// agree only while every frame after the schema frame is a data frame,
46 /// and section 14 reserves checkpoint frames, which would occupy a
47 /// sequence number without contributing a data block. A refresh that
48 /// continued from a count would then expect the wrong sequence.
49 next_sequence: u64,
50 /// The base row ID the next appended data block must carry, when the file
51 /// enables implicit row IDs. Also carried from the walk rather than
52 /// re-derived from `total_rows`.
53 next_row_id: Option<u64>,
54 /// The commit trailer of the last frame this snapshot committed, exactly
55 /// as it was stored when the snapshot committed it.
56 ///
57 /// This is how a refresh knows the committed bytes under it are still its
58 /// own. See [`Reader::expect_own_committed_bytes`].
59 commit_anchor: [u8; TRAILER_SIZE],
60}
61
62impl FileMetadata {
63 /// The format version declared by the file prologue.
64 pub fn format_version(&self) -> (u16, u16) {
65 self.format_version
66 }
67
68 /// The prologue feature flags.
69 pub fn feature_flags(&self) -> u64 {
70 self.feature_flags
71 }
72
73 /// The opaque file ID from the prologue. It is identity metadata, not a
74 /// content hash.
75 pub fn file_id(&self) -> &[u8; FILE_ID_SIZE] {
76 &self.file_id
77 }
78
79 /// The schema ID shared by the schema and data frames.
80 pub fn schema_id(&self) -> u64 {
81 self.schema_id
82 }
83
84 /// The file length observed when the reader opened it.
85 pub fn file_size(&self) -> u64 {
86 self.file_size
87 }
88
89 /// The byte after the last complete committed frame.
90 pub fn last_good_offset(&self) -> u64 {
91 self.last_good_offset
92 }
93
94 /// The number of complete data blocks in this snapshot.
95 pub fn block_count(&self) -> u64 {
96 self.block_count
97 }
98
99 /// Complete frames in this snapshot, counting the schema frame.
100 ///
101 /// Internal: this exists so full validation can report the same frame
102 /// count the structural walk produced instead of deriving one from the
103 /// block count.
104 pub(crate) fn frame_count(&self) -> u64 {
105 self.frame_count
106 }
107
108 /// The checked sum of the row counts of all complete data blocks.
109 pub fn total_rows(&self) -> u64 {
110 self.total_rows
111 }
112
113 /// Whether a final append ended before its next frame was committed.
114 ///
115 /// False means the snapshot ends exactly after its last committed frame.
116 pub fn incomplete_tail(&self) -> bool {
117 self.incomplete_tail
118 }
119}
120
121/// A metadata-first, open-time snapshot of an Acta v0.2 file.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct Reader {
124 path: PathBuf,
125 limits: Limits,
126 schema: Arc<Schema>,
127 file_metadata: FileMetadata,
128 blocks: Vec<BlockMetadata>,
129 identity: FileIdentity,
130}
131
132/// What one [`Reader::refresh`] added to a snapshot.
133///
134/// The counts describe only the frames discovered by that call, never the
135/// frames the snapshot already held, so the sum of a report's
136/// [`blocks_added`](Self::blocks_added) across refreshes is the total number
137/// of blocks the refresh path has contributed.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139#[non_exhaustive]
140pub struct RefreshReport {
141 blocks_added: u64,
142 rows_added: u64,
143 previous_file_size: u64,
144 observed_file_size: u64,
145 incomplete_tail: bool,
146 /// Bytes of newly committed frames this refresh streamed to verify their
147 /// commit trailers. Internal: a [`Tail`] folds it into its own metrics so
148 /// the byte count it reports covers discovery as well as decoding.
149 frame_bytes_scanned: u64,
150}
151
152impl RefreshReport {
153 /// Complete data frames discovered and added by this refresh.
154 pub fn blocks_added(&self) -> u64 {
155 self.blocks_added
156 }
157
158 /// The checked sum of the row counts of the added blocks.
159 pub fn rows_added(&self) -> u64 {
160 self.rows_added
161 }
162
163 /// The file extent this snapshot held before the refresh.
164 pub fn previous_file_size(&self) -> u64 {
165 self.previous_file_size
166 }
167
168 /// The file extent the refresh observed.
169 pub fn observed_file_size(&self) -> u64 {
170 self.observed_file_size
171 }
172
173 /// Whether the refresh left a physically incomplete frame after the last
174 /// complete committed frame.
175 ///
176 /// True here does not mean the snapshot is damaged: an interrupted append
177 /// exposes no block and a later refresh may see that tail become a
178 /// complete committed frame.
179 pub fn incomplete_tail(&self) -> bool {
180 self.incomplete_tail
181 }
182
183 /// Bytes of newly committed frames this refresh streamed to verify their
184 /// commit trailers.
185 ///
186 /// Internal: a [`Tail`] folds this into its own metrics so the byte count
187 /// it reports covers discovery as well as decoding.
188 pub(crate) fn frame_bytes_scanned(&self) -> u64 {
189 self.frame_bytes_scanned
190 }
191}
192
193impl Reader {
194 /// Open a file and discover its complete committed schema and data frames.
195 ///
196 /// The file extent is captured before scanning, so later appends do not
197 /// alter this reader; reopen the path to obtain a newer snapshot. A final
198 /// frame the file ends inside is an interrupted append: it is excluded from
199 /// the snapshot and reported by [`FileMetadata::incomplete_tail`]. A frame
200 /// that is present in full but damaged is corruption and fails the open.
201 ///
202 /// Section 6.2 permits a metadata-only open to defer the frame body CRC.
203 /// This reader deliberately does not: every committed frame is checksummed
204 /// here, which costs one pass over the file and in exchange makes an
205 /// interrupted append distinguishable from damage at open time rather than
206 /// at first read.
207 pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
208 Self::open_with_limits(path, Limits::default())
209 }
210
211 /// Open a file with explicit frame, metadata, and snapshot limits.
212 pub fn open_with_limits<P: AsRef<Path>>(path: P, limits: Limits) -> Result<Self> {
213 let path = path.as_ref().to_owned();
214 // The identity is captured before discovery so refresh can later tell
215 // the file this snapshot came from from whatever else now sits at the
216 // path, without holding a descriptor for the life of the reader.
217 let identity = FileIdentity::capture(&path)
218 .map_err(|error| Error::io(error, None).with_context(ErrorContext::File))?;
219 let mut scan = FileScan::open(&path, limits)?;
220
221 let mut blocks: Vec<BlockMetadata> = Vec::new();
222 let mut total_rows = 0_u64;
223 let walk = scan.walk_data_frames(|frame, block| {
224 let offset = Some(frame.frame_offset);
225 let block_count = u64::try_from(blocks.len()).map_err(|_| {
226 Error::resource_limit("block count does not fit the configured limit", offset)
227 .with_context(ErrorContext::File)
228 })?;
229 if block_count >= limits.max_blocks() {
230 return Err(Error::resource_limit(
231 format!(
232 "data block count exceeds the {}-block limit",
233 limits.max_blocks()
234 ),
235 offset,
236 )
237 .with_context(ErrorContext::File));
238 }
239 blocks.try_reserve(1).map_err(|_| {
240 Error::resource_limit("unable to reserve block metadata", offset)
241 .with_context(ErrorContext::File)
242 })?;
243 total_rows = total_rows.checked_add(block.row_count).ok_or_else(|| {
244 Error::corruption("total row count overflow", offset)
245 .with_context(ErrorContext::File)
246 })?;
247 blocks.push(BlockMetadata::new(
248 frame.sequence,
249 frame.frame_offset,
250 frame.total_length,
251 block.row_count,
252 block.base_row_id,
253 block
254 .primary_bounds
255 .map(|(minimum, maximum)| PrimaryBounds::new(minimum, maximum)),
256 block.ts_sorted,
257 ));
258 Ok(())
259 })?;
260
261 let prologue = scan.prologue();
262 let block_count = u64::try_from(blocks.len()).map_err(|_| {
263 Error::resource_limit("block count does not fit the configured limit", None)
264 .with_context(ErrorContext::File)
265 })?;
266 let file_size = scan.file_size();
267 let schema = Arc::new(scan.schema().clone());
268 let schema_id = schema.schema_id();
269 // The schema frame is always complete by the time the walk runs, so a
270 // committed boundary always follows at least one frame and there is
271 // always an anchor to capture, even for a file with no data blocks.
272 let commit_anchor =
273 frame::read_commit_trailer(&mut scan.into_file(), file_size, walk.last_good_offset)?;
274 let file_metadata = FileMetadata {
275 format_version: prologue.format_version,
276 feature_flags: prologue.feature_flags,
277 file_id: prologue.file_id,
278 schema_id,
279 file_size,
280 last_good_offset: walk.last_good_offset,
281 frame_count: walk.frame_count,
282 block_count,
283 total_rows,
284 incomplete_tail: walk.incomplete_tail,
285 next_sequence: walk.next_sequence,
286 next_row_id: walk.next_row_id,
287 commit_anchor,
288 };
289
290 Ok(Self {
291 path,
292 limits,
293 schema,
294 file_metadata,
295 blocks,
296 identity,
297 })
298 }
299
300 /// Extend this snapshot with frames committed since it was opened or last
301 /// refreshed.
302 ///
303 /// Refresh starts at this reader's own committed boundary — its last good
304 /// offset, expected next sequence, and expected next implicit row ID — and
305 /// validates every frame it discovers beyond that boundary exactly as
306 /// [`Self::open`] validates a whole file: prefix and prefix CRC, bounded
307 /// lengths and checked arithmetic, header, payload, trailer, and body CRC,
308 /// frame type and sequence, schema ID, block metadata, implicit row-ID
309 /// continuity, and the configured resource limits. Newly discovered blocks
310 /// are appended to the snapshot in exact file/sequence order.
311 ///
312 /// The snapshot is mutated only after the discovery pass succeeds, so a
313 /// failed refresh leaves every field and block vector unchanged. A refresh
314 /// that finds no growth succeeds with zero additions; one that finds only
315 /// an incomplete next frame succeeds with zero additions and reports
316 /// `incomplete_tail`. Repeated refreshes never add the same frame twice,
317 /// and a no-growth refresh leaves the reported file size unchanged.
318 ///
319 /// A physically incomplete tail is an interrupted append, not corruption,
320 /// and may become a complete committed frame by the next refresh. A later
321 /// refresh also accepts a safe Stage 8b repair that shrank only that
322 /// uncommitted tail back to the previous `last_good_offset`, and it clears
323 /// the tail. A file that shrank below the committed boundary is refused
324 /// with [`ErrorKind::FileTruncated`](crate::ErrorKind::FileTruncated).
325 ///
326 /// # Refusing another file at the same path
327 ///
328 /// A refresh will not extend this snapshot from a file that is not the one
329 /// it came from, and it checks that in two ways because neither alone is
330 /// enough. It compares the path's current file-system identity against the
331 /// identity captured at open, which catches a replacement that unlinked
332 /// and recreated the path. And, because an in-place truncate-and-rewrite
333 /// keeps that identity while replacing every byte, it re-reads the commit
334 /// trailer of the last frame this snapshot committed — its length,
335 /// sequence, body CRC, own CRC, and commit magic — and refuses unless it
336 /// still matches. On growth it also compares the prologue and schema it
337 /// re-reads against the ones this snapshot holds. Any mismatch is
338 /// [`ErrorKind::FileReplaced`](crate::ErrorKind::FileReplaced), never a
339 /// silent adoption, and none of it depends on the v0.2 file ID, which the
340 /// deterministic writer stores as a non-unique zero.
341 ///
342 /// One boundary is worth stating plainly. A snapshot holding no data
343 /// blocks has only its prologue and schema frame to be recognised by, so a
344 /// replacement whose prologue and schema are byte-identical is accepted —
345 /// but such a file agrees with everything the snapshot has exposed, so
346 /// nothing it already reported can be contradicted.
347 ///
348 /// Refresh never truncates, repairs, or acquires the writer lock.
349 ///
350 /// ```no_run
351 /// # use acta::Reader;
352 /// let mut reader = Reader::open("data.acta")?;
353 /// let report = reader.refresh()?;
354 /// println!("{} new block(s)", report.blocks_added());
355 /// # Ok::<(), acta::Error>(())
356 /// ```
357 pub fn refresh(&mut self) -> Result<RefreshReport> {
358 let previous_file_size = self.file_metadata.file_size;
359 let boundary = self.file_metadata.last_good_offset;
360 self.identity.expect_unchanged(&self.path)?;
361
362 let mut file = File::open(&self.path)
363 .map_err(|error| Error::io(error, None).with_context(ErrorContext::File))?;
364 let observed_size = file
365 .metadata()
366 .map(|metadata| metadata.len())
367 .map_err(|error| Error::io(error, None).with_context(ErrorContext::File))?;
368 if observed_size < boundary {
369 return Err(Error::file_truncated(
370 format!(
371 "the file is {observed_size} bytes, below the committed boundary {boundary}"
372 ),
373 Some(observed_size),
374 )
375 .with_context(ErrorContext::File));
376 }
377 self.expect_own_committed_bytes(&mut file, observed_size)?;
378
379 if observed_size == boundary {
380 // No growth, or a Stage 8b repair has already removed the
381 // uncommitted tail back to this boundary. Either way the file ends
382 // exactly at the last complete frame, whose trailer the check
383 // above has just confirmed is still this snapshot's own, so the
384 // snapshot needs only its extent and tail state corrected; the
385 // committed counts are already what a walk would report.
386 self.file_metadata.file_size = observed_size;
387 self.file_metadata.incomplete_tail = false;
388 return Ok(RefreshReport {
389 blocks_added: 0,
390 rows_added: 0,
391 previous_file_size,
392 observed_file_size: observed_size,
393 incomplete_tail: false,
394 frame_bytes_scanned: 0,
395 });
396 }
397
398 // A successful discovery pass stages new blocks here and only commits
399 // them, together with the updated file metadata, once the whole walk
400 // has validated. A failure anywhere leaves this reader untouched.
401 let mut staged: Vec<BlockMetadata> = Vec::new();
402 let mut total_rows = self.file_metadata.total_rows;
403 // The handle the checks above used is the handle discovery reads, so
404 // there is no second open and no window between the two.
405 let mut scan = FileScan::from_file(file, self.limits)?;
406 self.expect_own_file_header(&scan)?;
407 let walk = scan.walk_data_frames_from(self.resume_point(), |frame, block| {
408 let offset = Some(frame.frame_offset);
409 let staged_count = u64::try_from(staged.len()).map_err(|_| {
410 Error::resource_limit("block count does not fit the configured limit", offset)
411 .with_context(ErrorContext::File)
412 })?;
413 let total_count = self
414 .file_metadata
415 .block_count
416 .checked_add(staged_count)
417 .ok_or_else(|| {
418 Error::corruption("block count overflow", offset)
419 .with_context(ErrorContext::File)
420 })?;
421 if total_count >= self.limits.max_blocks() {
422 return Err(Error::resource_limit(
423 format!(
424 "data block count exceeds the {}-block limit",
425 self.limits.max_blocks()
426 ),
427 offset,
428 )
429 .with_context(ErrorContext::File));
430 }
431 staged.try_reserve(1).map_err(|_| {
432 Error::resource_limit("unable to reserve block metadata", offset)
433 .with_context(ErrorContext::File)
434 })?;
435 total_rows = total_rows.checked_add(block.row_count).ok_or_else(|| {
436 Error::corruption("total row count overflow", offset)
437 .with_context(ErrorContext::File)
438 })?;
439 staged.push(BlockMetadata::new(
440 frame.sequence,
441 frame.frame_offset,
442 frame.total_length,
443 block.row_count,
444 block.base_row_id,
445 block
446 .primary_bounds
447 .map(|(minimum, maximum)| PrimaryBounds::new(minimum, maximum)),
448 block.ts_sorted,
449 ));
450 Ok(())
451 })?;
452
453 let blocks_added = u64::try_from(staged.len()).map_err(|_| {
454 Error::resource_limit("block count does not fit the configured limit", None)
455 .with_context(ErrorContext::File)
456 })?;
457 let rows_added = total_rows - self.file_metadata.total_rows;
458 let block_count = self
459 .file_metadata
460 .block_count
461 .checked_add(blocks_added)
462 .ok_or_else(|| {
463 Error::corruption("block count overflow", Some(walk.last_good_offset))
464 .with_context(ErrorContext::File)
465 })?;
466 // The walk bounded itself by the extent this scan captured, so the
467 // snapshot records that extent rather than the one stat'd before it:
468 // a file that grew between the two is described by what was read.
469 let file_size = scan.file_size();
470 let prologue = scan.prologue();
471 let schema_id = scan.schema().schema_id();
472 let commit_anchor =
473 frame::read_commit_trailer(&mut scan.into_file(), file_size, walk.last_good_offset)?;
474 let file_metadata = FileMetadata {
475 format_version: prologue.format_version,
476 feature_flags: prologue.feature_flags,
477 file_id: prologue.file_id,
478 schema_id,
479 file_size,
480 last_good_offset: walk.last_good_offset,
481 frame_count: walk.frame_count,
482 block_count,
483 total_rows,
484 incomplete_tail: walk.incomplete_tail,
485 next_sequence: walk.next_sequence,
486 next_row_id: walk.next_row_id,
487 commit_anchor,
488 };
489
490 // Everything that can fail has failed by now, so the snapshot changes
491 // in one step: a caller never observes extended blocks described by
492 // metadata that predates them.
493 self.blocks.extend(staged);
494 self.file_metadata = file_metadata;
495
496 Ok(RefreshReport {
497 blocks_added,
498 rows_added,
499 previous_file_size,
500 observed_file_size: file_size,
501 incomplete_tail: walk.incomplete_tail,
502 frame_bytes_scanned: walk.frame_bytes_scanned,
503 })
504 }
505
506 /// Refuse to continue unless the last frame this snapshot committed is
507 /// still the frame at its committed boundary.
508 ///
509 /// File-system identity cannot see an in-place truncate-and-rewrite, which
510 /// keeps the inode and replaces every byte, so identity alone would let a
511 /// refresh resume a walk inside a stranger's file and append its frames to
512 /// this snapshot. Sequence numbers cannot separate the two either: every
513 /// Acta file numbers its data frames from one, and the deterministic
514 /// writer gives them all the same zero file ID.
515 ///
516 /// The commit trailer can. It carries the frame's total length, sequence
517 /// number, body CRC, its own CRC, and the commit magic, so two frames
518 /// agree on all thirty-two bytes only if they are the same committed
519 /// frame. Re-reading just those bytes costs one read rather than a pass
520 /// over the file, which matters because a tail does this on every poll.
521 ///
522 /// The caller has already established that the file reaches the committed
523 /// boundary, so a failure to read the trailer at all is a real I/O
524 /// failure rather than evidence about which file this is, and is reported
525 /// as one.
526 fn expect_own_committed_bytes(&self, file: &mut File, file_size: u64) -> Result<()> {
527 let anchor =
528 frame::read_commit_trailer(file, file_size, self.file_metadata.last_good_offset)?;
529 if anchor != self.file_metadata.commit_anchor {
530 return Err(identity::replaced(&self.path));
531 }
532 Ok(())
533 }
534
535 /// Refuse to continue unless the prologue and schema a growing file
536 /// presents are the ones this snapshot was built from.
537 ///
538 /// The committed-bytes check above already rejects a replacement, so this
539 /// is defence in depth rather than the primary guard — but it is defence
540 /// worth having, because without it a refresh would validate newly
541 /// discovered frames against whatever schema the file now declares and
542 /// then record that schema's identity while [`Self::schema`] still
543 /// returned the old one, leaving a reader whose own two halves disagree.
544 fn expect_own_file_header(&self, scan: &FileScan) -> Result<()> {
545 let prologue = scan.prologue();
546 let matches = prologue.format_version == self.file_metadata.format_version
547 && prologue.feature_flags == self.file_metadata.feature_flags
548 && prologue.file_id == self.file_metadata.file_id
549 && scan.schema() == &*self.schema;
550 if !matches {
551 return Err(identity::replaced(&self.path));
552 }
553 Ok(())
554 }
555
556 /// Start a live tail over this reader.
557 ///
558 /// The tail begins after the blocks this snapshot already holds and polls
559 /// the path for newly committed frames without reopening or rescanning the
560 /// committed prefix. Existing blocks remain available through
561 /// [`Self::scan`].
562 ///
563 /// A poll returns `None` when nothing is committed yet, which is never the
564 /// end of the stream, so a tail is followed until the caller decides to
565 /// stop rather than until it runs out — that is why this loop is not a
566 /// `while let`.
567 ///
568 /// ```no_run
569 /// # use acta::Reader;
570 /// # fn keep_following() -> bool { true }
571 /// # fn wait_a_while() {}
572 /// let mut reader = Reader::open("data.acta")?;
573 /// let mut tail = reader.tail().project(["value"])?;
574 /// while keep_following() {
575 /// match tail.poll_next()? {
576 /// // one newly committed matching block per poll
577 /// Some(batch) => println!("{} new row(s)", batch.row_count()),
578 /// // nothing committed yet; the caller chooses how long to wait
579 /// None => wait_a_while(),
580 /// }
581 /// }
582 /// # Ok::<(), acta::Error>(())
583 /// ```
584 pub fn tail(&mut self) -> Tail<'_> {
585 Tail::new(self)
586 }
587
588 /// The immutable schema reconstructed from the schema frame.
589 pub fn schema(&self) -> &Schema {
590 &self.schema
591 }
592
593 /// File-level metadata captured at open time.
594 pub fn file_metadata(&self) -> &FileMetadata {
595 &self.file_metadata
596 }
597
598 /// Complete data blocks in file/sequence order.
599 pub fn blocks(&self) -> &[BlockMetadata] {
600 &self.blocks
601 }
602
603 /// The checked sum of rows in all complete blocks.
604 pub fn total_rows(&self) -> u64 {
605 self.file_metadata.total_rows()
606 }
607
608 /// The shared schema handle, so a scan can hand the same allocation to
609 /// every block decode instead of cloning the schema per block.
610 pub(crate) fn schema_handle(&self) -> &Arc<Schema> {
611 &self.schema
612 }
613
614 pub(crate) fn path(&self) -> &Path {
615 &self.path
616 }
617
618 pub(crate) fn limits(&self) -> Limits {
619 self.limits
620 }
621
622 /// The continuation state a refresh resumes from: this snapshot's last
623 /// committed byte offset, the sequence number the next data frame must
624 /// carry, the next implicit base row ID when the file enables them, and
625 /// the frame count reached so far.
626 ///
627 /// Every field is carried forward from the walk that produced this
628 /// snapshot rather than re-derived from the counters beside it. The two
629 /// would agree today, but only because every frame after the schema frame
630 /// is a data frame; section 14 reserves checkpoint frames, which would
631 /// take a sequence number without contributing a block or a row.
632 pub(crate) fn resume_point(&self) -> ResumePoint {
633 ResumePoint {
634 offset: self.file_metadata.last_good_offset,
635 sequence: self.file_metadata.next_sequence,
636 expected_base_row_id: self.file_metadata.next_row_id,
637 frame_count: self.file_metadata.frame_count,
638 }
639 }
640
641 /// Lazily decode the complete blocks in this snapshot in file order.
642 ///
643 /// The iterator reads one block only when its item is requested and
644 /// yields [`Result<crate::RecordBatch>`] so decode and I/O failures are
645 /// reported during iteration. The scan is isolated from later appends:
646 /// it uses the file extent and committed block list captured by this
647 /// reader when it was opened.
648 ///
649 /// By default every column is returned in schema order.
650 /// [`Scan::project`] restricts and reorders the columns, and
651 /// [`Scan::primary_range`] restricts the rows, pruning whole blocks by
652 /// their stored bounds before reading them. Both are configured against
653 /// the captured schema and fail immediately rather than during iteration.
654 pub fn scan(&self) -> Scan<'_> {
655 Scan::new(self)
656 }
657
658 /// Decode one complete data block in schema column order.
659 ///
660 /// The index is the zero-based position returned by [`Self::blocks`],
661 /// which is also file and sequence order; it is not the frame sequence
662 /// number, which starts at one. An index past the last block is a caller
663 /// mistake rather than a defect in the file, so it is reported as
664 /// [`ErrorKind::InvalidArgument`](crate::ErrorKind::InvalidArgument).
665 ///
666 /// Decoding uses the file extent captured by [`Self::open`], so a later
667 /// append cannot become part of this snapshot.
668 pub fn read_block(&self, index: usize) -> Result<crate::RecordBatch> {
669 let mut file = File::open(&self.path)
670 .map_err(|error| Error::io(error, None).with_context(ErrorContext::File))?;
671 self.decode_block_at(&mut file, index)
672 }
673
674 /// Decode one complete data block using an already-open file handle.
675 ///
676 /// Shared by [`Self::read_block`], which opens the file per call, and
677 /// [`Scan`], which opens it once and reuses the handle across the scan.
678 fn decode_block_at(&self, file: &mut File, index: usize) -> Result<crate::RecordBatch> {
679 let frame = self.frame_at(file, index)?;
680 let layout = data_frame::read_layout(
681 file,
682 &frame,
683 &self.schema,
684 self.file_metadata.feature_flags,
685 self.expected_base_row_id(index),
686 self.limits,
687 )?;
688 decode::decode_block(
689 file,
690 self.file_metadata.file_size,
691 &frame,
692 &layout,
693 Arc::clone(&self.schema),
694 self.limits,
695 )
696 }
697
698 /// Decode a selected set of columns from one complete data block. The
699 /// scan module owns planning, pruning, and row filtering; this helper only
700 /// performs the same frame/layout validation and physical stream decode as
701 /// the full-column path, with a shared cumulative scan budget.
702 pub(crate) fn decode_selected_block_at(
703 &self,
704 file: &mut File,
705 index: usize,
706 selection: &decode::Selection<'_>,
707 scan_budget: &mut ScanBudget,
708 ) -> Result<decode::DecodedBlock> {
709 let frame = self.frame_at(file, index)?;
710 // Reading the frame streamed its whole body to check the commit
711 // trailer, and the layout read below re-reads the block header. Both
712 // are bytes this scan spent on this block, whatever it goes on to
713 // decode from it.
714 scan_budget.record_bytes_read(frame.total_length)?;
715 scan_budget.record_bytes_read(frame.header_length)?;
716
717 let layout = data_frame::read_selected_layout(
718 file,
719 &frame,
720 &self.schema,
721 self.file_metadata.feature_flags,
722 self.expected_base_row_id(index),
723 self.limits,
724 &self.selected_column_ids(selection)?,
725 )?;
726 decode::decode_selected_block(
727 file,
728 self.file_metadata.file_size,
729 &frame,
730 &layout,
731 selection,
732 Some(scan_budget),
733 self.limits,
734 )
735 }
736
737 /// The column IDs a selection reads, in no particular order.
738 fn selected_column_ids(&self, selection: &decode::Selection<'_>) -> Result<Vec<u32>> {
739 let mut ids = Vec::new();
740 ids.try_reserve(selection.selected.len().saturating_add(1))
741 .map_err(|_| Error::resource_limit("unable to allocate selected column IDs", None))?;
742 let positions = selection.selected.iter().copied().chain(selection.primary);
743 for position in positions {
744 let column = self
745 .schema
746 .columns()
747 .get(position)
748 .ok_or_else(|| Error::internal("column index is outside the schema"))?;
749 if !ids.contains(&column.id()) {
750 ids.push(column.id());
751 }
752 }
753 Ok(ids)
754 }
755
756 /// Read and validate the frame holding the block at `index`.
757 fn frame_at(&self, file: &mut File, index: usize) -> Result<frame::FrameMetadata> {
758 let block = self.blocks.get(index).ok_or_else(|| {
759 Error::invalid_argument(format!(
760 "block index {index} is past the {} blocks in this snapshot",
761 self.blocks.len()
762 ))
763 })?;
764 match frame::read_frame(
765 file,
766 self.file_metadata.file_size,
767 block.file_offset(),
768 block.sequence(),
769 self.limits,
770 )? {
771 FrameRead::Complete(frame) => Ok(frame),
772 FrameRead::IncompleteTail => Err(Error::io(
773 std::io::Error::new(
774 std::io::ErrorKind::UnexpectedEof,
775 "snapshot frame is no longer complete",
776 ),
777 Some(block.file_offset()),
778 )
779 .with_context(ErrorContext::File)),
780 }
781 }
782
783 /// The base row ID the block at `index` must declare, when the file
784 /// enables implicit row IDs.
785 ///
786 /// The discovery walk already proved this value is the checked sum of the
787 /// row counts of every block before it, and refused the file otherwise, so
788 /// the stored value *is* that sum. Re-deriving it here would add no
789 /// guarantee and would make a scan quadratic in its block count — which a
790 /// long-lived [`Tail`] feels directly, since it accumulates blocks for as
791 /// long as it follows.
792 fn expected_base_row_id(&self, index: usize) -> Option<u64> {
793 self.blocks.get(index).and_then(BlockMetadata::base_row_id)
794 }
795}