Skip to main content

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}