Skip to main content

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}