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}