Skip to main content

acta/read/
tail.rs

1//! Live, synchronous tail following over a refreshed reader.
2
3use std::fs::File;
4
5use crate::error::{Error, ErrorContext, Result};
6
7use super::budget::{ScanBudget, ScanMetrics};
8use super::reader::Reader;
9use super::scan::ScanPlan;
10
11/// A live tail over the frames appended after a reader's snapshot.
12///
13/// A tail is the mutable counterpart of a snapshot [`Scan`](crate::Scan): it
14/// begins after the blocks the reader already holds, and
15/// [`poll_next`](Self::poll_next) refreshes that reader and decodes one newly
16/// committed matching block per call. Polling is synchronous and never blocks:
17/// it sleeps on nothing, spawns no thread, and owns no timer, so a call that
18/// has nothing new returns `Ok(None)` immediately and the
19/// caller decides how long to wait before polling again. `None` means "pending
20/// now", never a permanent end of stream, so a tail does not implement
21/// [`Iterator`] or [`FusedIterator`](std::iter::FusedIterator).
22///
23/// `None` means only that, too. A block that prunes on its bounds or filters
24/// down to no rows is consumed inside the poll that reaches it, and the poll
25/// carries on to the next committed block, so a caller that waits on `None`
26/// is never waiting on data that has already arrived. Neither is a batch of
27/// zero rows ever reported in place of one.
28///
29/// A physically incomplete frame is never exposed: the tail leaves it
30/// undispatched and reports it through `incomplete_tail`, and a later poll
31/// sees it once a writer completes it.
32///
33/// Projection, reordered projection, empty projection, primary ranges, file
34/// order, and the treatment of a per-block decode error all match
35/// [`Scan`](crate::Scan), and the tail's cumulative scan limits apply across
36/// its whole lifetime rather than per poll. Cancellation is dropping the tail
37/// or stopping the polls; dropping performs no I/O and leaks no thread or
38/// handle.
39///
40/// The tail borrows its reader, so the reader cannot be refreshed through
41/// [`Reader::refresh`] while one of its tails exists. That is the same
42/// snapshot safety boundary [`Scan`](crate::Scan) already draws, kept on
43/// purpose rather than bypassed with interior mutation.
44#[derive(Debug)]
45pub struct Tail<'reader> {
46    reader: &'reader mut Reader,
47    plan: ScanPlan,
48    next_block: usize,
49    file: Option<File>,
50    budget: ScanBudget,
51}
52
53impl<'reader> Tail<'reader> {
54    /// Begin tailing after the reader's current committed blocks.
55    pub(crate) fn new(reader: &'reader mut Reader) -> Self {
56        let limits = reader.limits();
57        Self {
58            next_block: reader.blocks().len(),
59            plan: ScanPlan::new(reader.schema()),
60            reader,
61            file: None,
62            budget: ScanBudget::new(limits.max_rows_per_scan(), limits.max_decoded_scan_bytes()),
63        }
64    }
65
66    /// Select columns by exact schema name, preserving the requested order.
67    ///
68    /// This has exactly [`Scan::project`](crate::Scan::project)'s semantics:
69    /// the requested order becomes the batch column order, an empty list
70    /// yields zero-column batches that still carry their row counts, an
71    /// unknown or repeated name fails here rather than during polling, and
72    /// calling this again replaces the whole projection.
73    pub fn project<I, S>(mut self, columns: I) -> Result<Self>
74    where
75        I: IntoIterator<Item = S>,
76        S: AsRef<str>,
77    {
78        self.plan.project(self.reader.schema(), columns)?;
79        Ok(self)
80    }
81
82    /// Configure a typed half-open primary range, `[start, end)`.
83    ///
84    /// This has exactly [`Scan::primary_range`](crate::Scan::primary_range)'s
85    /// semantics: the range type must match the schema's primary column and an
86    /// empty range is legal. Blocks whose stored bounds cannot intersect the
87    /// range are polled through without a batch, and no global primary
88    /// ordering is ever inferred: a future block may overlap this range even
89    /// when none of the current blocks do.
90    pub fn primary_range(mut self, range: crate::PrimaryRange) -> Result<Self> {
91        self.plan.primary_range(self.reader.schema(), range)?;
92        Ok(self)
93    }
94
95    /// Keep committed block order and row order within each block explicit.
96    /// This is currently the tail's only ordering mode, matching
97    /// [`Scan::file_order`](crate::Scan::file_order).
98    pub fn file_order(self) -> Self {
99        self
100    }
101
102    /// Synchronously discover and decode the next newly committed matching
103    /// block, without sleeping or blocking.
104    ///
105    /// A newly committed block is returned as `Ok(Some(batch))`. `Ok(None)`
106    /// means the file currently holds no committed block this tail has not
107    /// already dealt with — "pending now", not the end of the stream, since
108    /// polling again later may yield data. Blocks that pruning or range
109    /// filtering removes are consumed on the way, so `None` is never returned
110    /// with matching work still queued and a caller may safely wait on it.
111    ///
112    /// A refresh or decode failure is returned as [`Err`]. A per-block decode
113    /// failure consumes that block and the tail continues with the next
114    /// committed block on a later poll, exactly as a snapshot
115    /// [`Scan`](crate::Scan) yields the damaged block as one error and keeps
116    /// going. A refresh failure leaves the tail's position untouched and is
117    /// retried by the next poll.
118    ///
119    /// Until a poll returns a decoded block it keeps refreshing the reader,
120    /// which keeps the file extent and commit boundary current. Once
121    /// undispatched blocks exist, polling decodes them first and only
122    /// refreshes again when they are exhausted, so no block is skipped and
123    /// none is decoded twice.
124    pub fn poll_next(&mut self) -> Result<Option<crate::RecordBatch>> {
125        loop {
126            // Drain, rather than return, the blocks that yield nothing: a
127            // pruned or fully filtered block is work this poll completed, and
128            // reporting it as `None` would tell the caller to wait for data
129            // that is already on disk.
130            while self.next_block < self.reader.blocks().len() {
131                if let Some(batch) = self.decode_next()? {
132                    return Ok(Some(batch));
133                }
134            }
135            let report = self.reader.refresh()?;
136            // Refresh reads and checksums every frame it discovers, which is
137            // this tail's I/O whether or not the block is later decoded.
138            self.budget
139                .record_bytes_read(report.frame_bytes_scanned())?;
140            if report.blocks_added() == 0 {
141                return Ok(None);
142            }
143        }
144    }
145
146    /// Return aggregate planning, stream, byte, and row counters collected
147    /// across this tail's lifetime.
148    ///
149    /// [`ScanMetrics::bytes_read`] covers both halves of a tail's work: the
150    /// frames each refresh streamed to verify their commit trailers, and the
151    /// bytes the decodes then read. A tail therefore reports more bytes than a
152    /// [`Scan`](crate::Scan) over the same blocks, because a scan never has to
153    /// discover them. The row and decoded-byte allowances from
154    /// [`Limits`](crate::Limits) still bound decoding only; discovery is
155    /// bounded by the file, not by the scan.
156    pub fn metrics(&self) -> ScanMetrics {
157        self.budget.metrics()
158    }
159
160    /// Decode the next undispatched block, or report that it yielded nothing.
161    ///
162    /// `Ok(None)` here means only "this block produced no batch"; deciding
163    /// what that means for the caller is [`Self::poll_next`]'s job.
164    fn decode_next(&mut self) -> Result<Option<crate::RecordBatch>> {
165        // The block is consumed before it is decoded, exactly as a snapshot
166        // [`Scan`](crate::Scan) consumes each block before reading it, so a
167        // decode failure is yielded as one error for that block and the tail
168        // continues with the next committed block on a later poll.
169        let index = self.next_block;
170        self.next_block += 1;
171        let block = self.reader.blocks()[index].clone();
172        let pruned = self.plan.should_prune(&block);
173        self.budget.record_block(pruned)?;
174        if pruned {
175            return Ok(None);
176        }
177
178        // Built from the fields it needs rather than from `&self`, so the file
179        // handle and the budget below stay independently borrowable.
180        let selection = self.plan.selection(self.reader.schema_handle());
181
182        let file = match &mut self.file {
183            Some(file) => file,
184            None => match File::open(self.reader.path()) {
185                Ok(file) => self.file.insert(file),
186                Err(error) => {
187                    return Err(Error::io(error, None).with_context(ErrorContext::File));
188                }
189            },
190        };
191
192        self.budget.charge_rows(block.row_count())?;
193        let decoded =
194            self.reader
195                .decode_selected_block_at(file, index, &selection, &mut self.budget)?;
196        let super::decode::DecodedBlock {
197            batch,
198            primary_values,
199            primary_sorted,
200        } = decoded;
201
202        let Some(range) = self.plan.range else {
203            self.budget.record_rows(batch.row_count())?;
204            return Ok(Some(batch));
205        };
206        let Some(primary_values) = primary_values else {
207            return Err(Error::internal(
208                "range tail did not decode its primary column",
209            ));
210        };
211        match self
212            .plan
213            .filter_batch(primary_sorted, batch, &primary_values, range)?
214        {
215            Some(batch) => {
216                self.budget.record_rows(batch.row_count())?;
217                Ok(Some(batch))
218            }
219            None => Ok(None),
220        }
221    }
222}