acta/read/scan.rs
1//! Lazy scan planning, pruning, and row filtering.
2
3use std::fs::File;
4use std::sync::Arc;
5
6use crate::error::{Error, Result};
7use crate::schema::{LogicalType, Schema};
8
9use super::block::BlockMetadata;
10use super::budget::{ScanBudget, ScanMetrics};
11use super::reader::Reader;
12
13/// A typed half-open range over the schema's primary column.
14///
15/// The two variants are separate types rather than one integer range because
16/// the endpoints mean different things: a timestamp endpoint is a raw value in
17/// the column's stored unit, and a `date32` endpoint is a signed day number.
18/// A range is only accepted against a primary column of the matching kind.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum PrimaryRange {
21 /// A range over a `timestamp64` primary column, in its stored unit.
22 Timestamp {
23 /// The first value the range includes.
24 start: i64,
25 /// The first value past the range.
26 end: i64,
27 },
28 /// A range over a `date32` primary column, in signed days.
29 Date32 {
30 /// The first day the range includes.
31 start: i32,
32 /// The first day past the range.
33 end: i32,
34 },
35}
36
37impl PrimaryRange {
38 /// Construct a range of raw values in the primary timestamp column's
39 /// declared storage unit: `[start, end)`.
40 pub fn timestamp(start: i64, end: i64) -> Self {
41 Self::Timestamp { start, end }
42 }
43
44 /// Construct a half-open range of signed `date32` day values: `[start,
45 /// end)`.
46 pub fn date32(start: i32, end: i32) -> Self {
47 Self::Date32 { start, end }
48 }
49
50 fn bounds(self) -> (i64, i64) {
51 match self {
52 Self::Timestamp { start, end } => (start, end),
53 Self::Date32 { start, end } => (i64::from(start), i64::from(end)),
54 }
55 }
56}
57
58/// The projection and primary-range configuration shared by a snapshot
59/// [`Scan`] and a live [`Tail`](crate::Tail).
60///
61/// Both consumers walk the same committed [`BlockMetadata`] list and decode
62/// the same blocks, so projection, ordering, and pruning decisions live here
63/// once instead of drifting apart in two iterators. The plan knows nothing
64/// about which blocks exist; its caller decides that.
65#[derive(Debug)]
66pub(crate) struct ScanPlan {
67 pub(crate) projection: Vec<usize>,
68 pub(crate) output_schema: Arc<Schema>,
69 pub(crate) range: Option<PrimaryRange>,
70}
71
72impl ScanPlan {
73 /// A plan over every column of `schema`, in schema order.
74 pub(crate) fn new(schema: &Schema) -> Self {
75 let projection: Vec<usize> = (0..schema.column_count()).collect();
76 let output_schema = projected_schema(schema, &projection);
77 Self {
78 projection,
79 output_schema,
80 range: None,
81 }
82 }
83
84 /// Select columns by exact schema name, preserving the requested order.
85 ///
86 /// The requested order becomes the batch column order, and projected
87 /// columns keep their schema IDs. An empty list is valid and yields
88 /// zero-column batches that still carry their row counts. An unknown name,
89 /// or the same name twice, is a caller mistake and fails here. Calling
90 /// this again replaces the whole projection.
91 pub(crate) fn project<I, S>(&mut self, schema: &Schema, columns: I) -> Result<()>
92 where
93 I: IntoIterator<Item = S>,
94 S: AsRef<str>,
95 {
96 let mut projection = Vec::new();
97 for name in columns {
98 let name = name.as_ref();
99 let index = schema
100 .columns()
101 .iter()
102 .position(|column| column.name() == name)
103 .ok_or_else(|| {
104 Error::invalid_argument(format!("unknown projected column {name}"))
105 })?;
106 if projection.contains(&index) {
107 return Err(Error::invalid_argument(format!(
108 "projected column {name} was requested more than once"
109 )));
110 }
111 projection.push(index);
112 }
113 self.output_schema = projected_schema(schema, &projection);
114 self.projection = projection;
115 Ok(())
116 }
117
118 /// Configure a typed half-open primary range, `[start, end)`.
119 ///
120 /// A missing primary column, a range whose type does not match it, and a
121 /// start greater than its end are all rejected here, against the captured
122 /// schema, before any block is read. An empty range where `start == end`
123 /// is legal and returns no rows without decoding anything. Calling this
124 /// again replaces the range.
125 pub(crate) fn primary_range(&mut self, schema: &Schema, range: PrimaryRange) -> Result<()> {
126 let (start, end) = range.bounds();
127 if start > end {
128 return Err(Error::invalid_argument(
129 "primary range start must not be greater than its end",
130 ));
131 }
132 let primary = schema.primary_column().ok_or_else(|| {
133 Error::invalid_argument("primary ranges require a schema primary column")
134 })?;
135 let compatible = matches!(
136 (range, primary.logical_type()),
137 (
138 PrimaryRange::Timestamp { .. },
139 LogicalType::Timestamp { .. }
140 ) | (PrimaryRange::Date32 { .. }, LogicalType::Date32)
141 );
142 if !compatible {
143 return Err(Error::invalid_argument(format!(
144 "primary range type does not match primary column {}",
145 primary.name()
146 )));
147 }
148 self.range = Some(range);
149 Ok(())
150 }
151
152 /// The one column a consumer may decode without projecting it: the
153 /// primary, when a range needs its values to filter rows.
154 fn internal_primary(&self, schema: &Schema) -> Option<usize> {
155 super::decode::primary_index(schema)
156 .filter(|index| self.range.is_some() || self.projection.contains(index))
157 }
158
159 /// The decode selection this plan requests for one block.
160 pub(crate) fn selection<'a>(
161 &'a self,
162 source_schema: &'a Arc<Schema>,
163 ) -> super::decode::Selection<'a> {
164 super::decode::Selection {
165 source_schema: Arc::clone(source_schema),
166 output_schema: Arc::clone(&self.output_schema),
167 selected: &self.projection,
168 primary: self.internal_primary(source_schema),
169 }
170 }
171
172 /// Whether a block's stored primary bounds exclude every range value.
173 pub(crate) fn should_prune(&self, block: &BlockMetadata) -> bool {
174 let Some(range) = self.range else {
175 return false;
176 };
177 let (start, end) = range.bounds();
178 if start == end {
179 return true;
180 }
181 let Some(bounds) = block.primary_bounds() else {
182 return false;
183 };
184 bounds.max() < start || bounds.min() >= end
185 }
186
187 /// Keep the rows of one decoded block that the range covers.
188 ///
189 /// The choice between a binary search and a linear pass is made from what
190 /// the decode established about the values in hand, not from block
191 /// metadata captured earlier. The two are separate reads of the file, and
192 /// a binary search over values that are not really ordered would return
193 /// arbitrary boundaries rather than an error.
194 pub(crate) fn filter_batch(
195 &self,
196 primary_sorted: bool,
197 batch: crate::RecordBatch,
198 primary_values: &[i64],
199 range: PrimaryRange,
200 ) -> Result<Option<crate::RecordBatch>> {
201 let (start, end) = range.bounds();
202 if start == end {
203 return Ok(None);
204 }
205 if primary_sorted {
206 let first = primary_values.partition_point(|value| *value < start);
207 let last = primary_values.partition_point(|value| *value < end);
208 if first == last {
209 return Ok(None);
210 }
211 return Ok(Some(batch.slice(first, last)));
212 }
213
214 let mut indices = Vec::new();
215 indices.try_reserve(primary_values.len()).map_err(|_| {
216 Error::resource_limit("unable to allocate unsorted range selection", None)
217 })?;
218 indices.extend(
219 primary_values
220 .iter()
221 .enumerate()
222 .filter_map(|(index, value)| (*value >= start && *value < end).then_some(index)),
223 );
224 if indices.is_empty() {
225 return Ok(None);
226 }
227 Ok(Some(batch.take(&indices)))
228 }
229}
230
231/// A lazy, sequential scan over the committed blocks in a reader snapshot.
232///
233/// Configuration validates only the captured schema. Frame bodies and streams
234/// are not read until iteration reaches a block that survives planning.
235///
236/// # Errors during iteration
237///
238/// A failure is yielded as an item rather than ending the scan, and the scan
239/// then continues with the next block: one damaged block does not hide the
240/// blocks after it. Two consequences are worth planning for. A caller that
241/// wants to stop at the first failure has to stop itself. And a scan that has
242/// exhausted a cumulative allowance from [`Limits`](crate::Limits) fails every
243/// remaining candidate block in turn, because the allowance stays spent, so
244/// such a scan yields an error per remaining block rather than one error.
245///
246/// [`Self::metrics`] stays readable across all of this, and reports the work
247/// done up to that point.
248#[derive(Debug)]
249pub struct Scan<'reader> {
250 pub(crate) reader: &'reader Reader,
251 pub(crate) next_index: usize,
252 pub(crate) file: Option<File>,
253 pub(crate) plan: ScanPlan,
254 pub(crate) budget: ScanBudget,
255}
256
257impl<'reader> Scan<'reader> {
258 pub(crate) fn new(reader: &'reader Reader) -> Self {
259 Self {
260 reader,
261 next_index: 0,
262 file: None,
263 plan: ScanPlan::new(reader.schema()),
264 budget: ScanBudget::new(
265 reader.limits().max_rows_per_scan(),
266 reader.limits().max_decoded_scan_bytes(),
267 ),
268 }
269 }
270
271 /// Select columns by exact schema name, preserving the requested order.
272 ///
273 /// The requested order becomes the batch column order, and projected
274 /// columns keep their schema IDs. An empty list is valid and yields
275 /// zero-column batches that still carry their row counts. An unknown name,
276 /// or the same name twice, is a caller mistake and fails here rather than
277 /// during iteration. Calling this again replaces the whole projection.
278 ///
279 /// ```no_run
280 /// # use acta::Reader;
281 /// let reader = Reader::open("data.acta")?;
282 /// for batch in reader.scan().project(["timestamp", "value"])? {
283 /// let batch = batch?;
284 /// assert_eq!(batch.schema().column_count(), 2);
285 /// }
286 /// # Ok::<(), acta::Error>(())
287 /// ```
288 pub fn project<I, S>(mut self, columns: I) -> Result<Self>
289 where
290 I: IntoIterator<Item = S>,
291 S: AsRef<str>,
292 {
293 self.plan.project(self.reader.schema(), columns)?;
294 Ok(self)
295 }
296
297 /// Configure a typed half-open primary range, `[start, end)`.
298 ///
299 /// A missing primary column, a range whose type does not match it, and a
300 /// start greater than its end are all rejected here, against the captured
301 /// schema, before any block is read. An empty range where `start == end`
302 /// is legal and returns no rows without decoding anything. The primary
303 /// column is decoded to filter rows whether or not it is projected, but it
304 /// is only returned when it is. Calling this again replaces the range.
305 ///
306 /// ```no_run
307 /// # use acta::{PrimaryRange, Reader};
308 /// let reader = Reader::open("data.acta")?;
309 /// let scan = reader
310 /// .scan()
311 /// .project(["value"])?
312 /// .primary_range(PrimaryRange::timestamp(1_700_000_000_000_000, 1_700_003_600_000_000))?;
313 /// # Ok::<(), acta::Error>(())
314 /// ```
315 pub fn primary_range(mut self, range: PrimaryRange) -> Result<Self> {
316 self.plan.primary_range(self.reader.schema(), range)?;
317 Ok(self)
318 }
319
320 /// Keep committed block order and row order within each block explicit.
321 /// This is currently the scan's only ordering mode.
322 pub fn file_order(self) -> Self {
323 self
324 }
325
326 /// The number of blocks left that pruning has not already excluded.
327 ///
328 /// This is an upper bound on the items the scan can still yield, not a
329 /// count of them: a block whose bounds overlap the range may still hold no
330 /// matching row, and is then skipped without an item. That is also why
331 /// [`Scan`] is not an
332 /// [`ExactSizeIterator`](std::iter::ExactSizeIterator) and why this is not
333 /// called `len`.
334 pub fn remaining_candidate_blocks(&self) -> usize {
335 self.remaining_candidates()
336 }
337
338 /// Return aggregate planning, stream, byte, and row counters collected so
339 /// far. Counters remain readable after an iterator has yielded an item
340 /// error; later blocks remain independently iterable.
341 pub fn metrics(&self) -> ScanMetrics {
342 self.budget.metrics()
343 }
344
345 fn remaining_candidates(&self) -> usize {
346 self.reader.blocks()[self.next_index..]
347 .iter()
348 .filter(|block| !self.plan.should_prune(block))
349 .count()
350 }
351}
352
353impl Iterator for Scan<'_> {
354 type Item = Result<crate::RecordBatch>;
355
356 fn next(&mut self) -> Option<Self::Item> {
357 while self.next_index < self.reader.blocks().len() {
358 let index = self.next_index;
359 self.next_index += 1;
360 let block = &self.reader.blocks()[index];
361 let pruned = self.plan.should_prune(block);
362 if let Err(error) = self.budget.record_block(pruned) {
363 return Some(Err(error));
364 }
365 if pruned {
366 continue;
367 }
368
369 // Built from the fields it needs rather than from `&self`, so the
370 // file handle and the budget below stay independently borrowable.
371 let selection = self.plan.selection(self.reader.schema_handle());
372
373 let file = match &mut self.file {
374 Some(file) => file,
375 None => match File::open(self.reader.path()) {
376 Ok(file) => self.file.insert(file),
377 Err(error) => {
378 return Some(Err(
379 Error::io(error, None).with_context(crate::ErrorContext::File)
380 ));
381 }
382 },
383 };
384
385 if let Err(error) = self.budget.charge_rows(block.row_count()) {
386 return Some(Err(error));
387 }
388
389 let decoded =
390 self.reader
391 .decode_selected_block_at(file, index, &selection, &mut self.budget);
392 match decoded {
393 Err(error) => return Some(Err(error)),
394 Ok(decoded) => {
395 let super::decode::DecodedBlock {
396 batch,
397 primary_values,
398 primary_sorted,
399 } = decoded;
400 let Some(range) = self.plan.range else {
401 if let Err(error) = self.budget.record_rows(batch.row_count()) {
402 return Some(Err(error));
403 }
404 return Some(Ok(batch));
405 };
406 let Some(primary_values) = primary_values else {
407 return Some(Err(Error::internal(
408 "range scan did not decode its primary column",
409 )));
410 };
411 match self
412 .plan
413 .filter_batch(primary_sorted, batch, &primary_values, range)
414 {
415 Ok(Some(batch)) => {
416 if let Err(error) = self.budget.record_rows(batch.row_count()) {
417 return Some(Err(error));
418 }
419 return Some(Ok(batch));
420 }
421 Ok(None) => continue,
422 Err(error) => return Some(Err(error)),
423 }
424 }
425 }
426 }
427 None
428 }
429
430 fn size_hint(&self) -> (usize, Option<usize>) {
431 (0, Some(self.remaining_candidates()))
432 }
433}
434
435impl std::iter::FusedIterator for Scan<'_> {}
436
437fn projected_schema(schema: &Schema, projection: &[usize]) -> Arc<Schema> {
438 let columns = projection
439 .iter()
440 .map(|&index| schema.columns()[index].clone())
441 .collect();
442 let primary = schema.primary_column_id().filter(|id| {
443 projection
444 .iter()
445 .any(|&index| schema.columns()[index].id() == *id)
446 });
447 Arc::new(Schema::new(schema.schema_id(), columns, primary))
448}