Skip to main content

micromegas_analytics/lakehouse/
partitioned_execution_plan.rs

1use super::{partition::Partition, reader_factory::ReaderFactory, view::ScanSortColumn};
2use crate::{
3    dfext::predicate::filters_to_predicate,
4    time::{TimeRange, datetime_to_scalar},
5};
6use anyhow::Context;
7use chrono::{DateTime, Utc};
8use datafusion::{
9    arrow::{compute::SortOptions, datatypes::SchemaRef},
10    catalog::{Session, memory::DataSourceExec},
11    common::stats::Precision,
12    datasource::{
13        listing::PartitionedFile,
14        physical_plan::{FileScanConfigBuilder, ParquetSource},
15    },
16    execution::object_store::ObjectStoreUrl,
17    logical_expr::SortExpr,
18    physical_expr::{LexOrdering, PhysicalSortExpr},
19    physical_plan::{ColumnStatistics, ExecutionPlan, Statistics},
20    prelude::*,
21};
22use micromegas_tracing::prelude::*;
23use std::sync::Arc;
24
25/// Which pair of bounds on `Partition` a declared ordering's leading column is checked against.
26#[derive(Clone, Copy, Debug)]
27pub enum OrderingBounds {
28    /// `min_event_time()` / `max_event_time()` -- `Option`, absent for empty partitions.
29    EventTime,
30    /// `begin_insert_time()` / `end_insert_time()` -- always present.
31    InsertTime,
32}
33
34/// Reads the pair of bounds a declared ordering's leading column is checked against, per
35/// `OrderingBounds`. `InsertTime` bounds are always present; `EventTime` bounds are `None` for
36/// empty partitions (callers are expected to have already filtered those out).
37///
38/// The `EventTime` upper bound prefers `max_sort_key_time` (the partition's true recorded max of
39/// the leading sort column, e.g. `begin` for `thread_spans`) and falls back to `max_event_time`
40/// (the max span *end*, a merely conservative stand-in) when it is `None` -- true for every
41/// partition written before that column existed, and for any view that never declares a
42/// `Concatenated` event-time ordering. This single change point upgrades all three consumers of
43/// `partition_bounds` coherently: the sort key stays `min_event_time`; the non-overlap check below
44/// compares the previous partition's *true* max `begin` against the next's block-derived min
45/// (which can never strictly overlap for cuts at block boundaries of a producer using a shared
46/// flush timestamp); and `attach_ordering_statistics` attaches a tighter, exact `begin` max
47/// statistic. The fallback preserves bit-for-bit behavior for legacy partitions.
48fn partition_bounds(
49    p: &Partition,
50    bounds: OrderingBounds,
51) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
52    match bounds {
53        OrderingBounds::EventTime => p
54            .min_event_time()
55            .zip(p.max_sort_key_time().or(p.max_event_time())),
56        OrderingBounds::InsertTime => Some((p.begin_insert_time(), p.end_insert_time())),
57    }
58}
59
60/// Sorts the non-empty partitions by their leading-column bound ascending (tiebreak `file_path`)
61/// and verifies that adjacent partitions' ranges do not overlap. This makes the declared scan
62/// ordering self-contained: the file group is guaranteed to concatenate in globally-sorted order,
63/// independent of the order the partition cache returned.
64///
65/// Returns an error if any adjacent pair overlaps: the declared ordering cannot be honored, so we
66/// fail loudly instead of silently emitting a mis-ordered scan. For `OrderingBounds::EventTime`,
67/// block-boundary tick overlap no longer trips this check for partitions carrying
68/// `max_sort_key_time` (`partition_bounds` reads it in preference to `max_event_time`): the
69/// producer now stamps one shared timestamp per flush (matching the Unreal producer), so
70/// consecutive blocks touch exactly, and the recorded bound reflects that. Legacy partitions
71/// written before that column existed fall back to the old, looser `max_event_time` bound and so
72/// remain a residual until they are rebuilt -- which happens automatically on their next query,
73/// since `ThreadSpansView::SCHEMA_VERSION`'s bump makes every pre-existing partition stale by
74/// schema hash; this is a self-healing residual, not an admin-retire dependency. The two remaining
75/// residual causes are: an insert-time inversion straddling a JIT segment boundary (a genuine
76/// row-level overlap, correctly rejected); and TSC-frequency estimation drift across
77/// materialization epochs (for `tsc_frequency == 0` processes whose blocks were materialized under
78/// different clock estimates) -- fixed the same way, by retiring the affected stream's partitions
79/// so they rebuild with a single, consistent converter. See the ordering-invariant notes on
80/// `View::get_scan_output_ordering`. For `OrderingBounds::InsertTime` an overlap indicates a
81/// genuine partitioning bug -- input partitions are expected to be non-overlapping in insert_time
82/// by construction.
83fn sort_and_check_non_overlapping(
84    mut partitions: Vec<&Partition>,
85    bounds: OrderingBounds,
86) -> datafusion::error::Result<Vec<&Partition>> {
87    partitions.sort_by(|a, b| {
88        partition_bounds(a, bounds)
89            .map(|(begin, _)| begin)
90            .cmp(&partition_bounds(b, bounds).map(|(begin, _)| begin))
91            .then_with(|| a.file_path.cmp(&b.file_path))
92    });
93    for pair in partitions.windows(2) {
94        let prev = pair[0];
95        let next = pair[1];
96        if let (Some((_, prev_max)), Some((next_min, _))) = (
97            partition_bounds(prev, bounds),
98            partition_bounds(next, bounds),
99        ) && prev_max > next_min
100        {
101            return Err(datafusion::error::DataFusionError::Internal(format!(
102                "declared scan ordering violated: partition {:?} (range ending {prev_max}) overlaps partition {:?} (range starting {next_min}). \
103                 If either partition predates max_sort_key_time (schema v8), this heals itself on its next query: a schema-hash bump makes it stale and it \
104                 rebuilds automatically, carrying the exact bound, with no admin action needed. Otherwise, for event-time ordering the remaining causes are an \
105                 insert-time inversion straddling a JIT segment boundary, or -- for tsc_frequency == 0 processes -- TSC-frequency re-estimation drift across \
106                 materialization epochs spanning a clock adjustment; both are fixed by retiring the affected stream's partitions so they rebuild with a single, \
107                 consistent time converter. See the rustdoc on sort_and_check_non_overlapping (partitioned_execution_plan.rs) and the ordering-invariant notes on \
108                 View::get_scan_output_ordering in view.rs for the full cause list.",
109                prev.file_path, next.file_path
110            )));
111        }
112    }
113    Ok(partitions)
114}
115
116/// Attaches the leading `output_ordering` column's min/max statistics to a `PartitionedFile`,
117/// using `Precision::Inexact` since the bounds read from `Partition` (per `OrderingBounds`) are
118/// not necessarily the column's exact min/max -- though for `EventTime` bounds when
119/// `max_sort_key_time` is recorded, the attached max happens to be exact; `Precision::Inexact` is
120/// still correct to declare, since an exact statistic is a legal special case of an inexact one.
121/// DataFusion's multi-file-group ordering validation (`is_ordering_valid_for_file_groups`)
122/// requires these statistics to be present -- without them the declared ordering is silently
123/// dropped for any file group with more than one file.
124fn attach_ordering_statistics(
125    mut file: PartitionedFile,
126    schema: &SchemaRef,
127    leading_column: &ScanSortColumn,
128    partition: &Partition,
129    bounds: OrderingBounds,
130) -> datafusion::error::Result<PartitionedFile> {
131    let mut stats = Statistics::new_unknown(schema);
132    if let Some((min_time, max_time)) = partition_bounds(partition, bounds) {
133        let idx = schema.index_of(&leading_column.column)?;
134        stats.column_statistics[idx] = ColumnStatistics::new_unknown()
135            .with_min_value(Precision::Inexact(datetime_to_scalar(min_time)))
136            .with_max_value(Precision::Inexact(datetime_to_scalar(max_time)));
137    }
138    file = file.with_statistics(Arc::new(stats));
139    Ok(file)
140}
141
142/// How a partition scan's declared output ordering is realized.
143///
144/// Two scan shapes exist: a single sequential file group spanning every input partition
145/// (`Unordered`, `Concatenated`) and one file group per input partition (`PerFile`, scanned by k
146/// readers for a downstream `SortPreservingMergeExec`). `Unordered` and `Concatenated` build and
147/// execute the identical single-file-group scan -- they differ only in whether the resulting order
148/// is declared to DataFusion, not in scan shape or execution strategy (see
149/// `ScanOrdering::declares_concatenated_ordering`).
150#[derive(Clone, Debug)]
151pub enum ScanOrdering {
152    /// No declared ordering (today's default).
153    Unordered,
154    /// All files form one sequential file group that concatenates in globally-sorted order.
155    /// Requires non-overlapping leading-column bounds (checked against `bounds`).
156    Concatenated {
157        columns: Vec<ScanSortColumn>,
158        bounds: OrderingBounds,
159    },
160    /// Each file is internally sorted by `columns`; files may overlap arbitrarily. The scan
161    /// yields one ordered plan partition per file, for a downstream `SortPreservingMergeExec`.
162    /// `columns` should be non-empty -- `SqlBatchView::with_merge_sort_order` rejects an empty
163    /// list at construction, but this enum and `View::get_scan_output_ordering` are public, so
164    /// that is not the only construction path. An empty `columns` is still safe: it never
165    /// certifies (see `Partition::certifies_sort_order`), so `make_partitioned_execution_plan`
166    /// degrades it to `Unordered` for any non-empty partition rather than planning and recording a
167    /// vacuous ordering.
168    PerFile { columns: Vec<ScanSortColumn> },
169}
170
171impl ScanOrdering {
172    /// True when this ordering declares a concatenating scan's global order (`Concatenated`).
173    /// `PerFile` returns `false` because a per-file ordering only becomes a global one through a
174    /// downstream merge -- it is the other strategy, not an undeclared version of this one. A
175    /// bool, not the columns themselves: nothing on the concatenating path inspects the declared
176    /// columns, only whether an ordering was declared at all (see
177    /// `QueryMerger::execute_concatenated_merge` in `merge.rs`).
178    pub fn declares_concatenated_ordering(&self) -> bool {
179        matches!(self, ScanOrdering::Concatenated { .. })
180    }
181}
182
183/// Builds the `DataFrame::sort` expressions for `columns`, pairing each with `!descending` for
184/// `asc` so the logical sort matches the `nulls_first: c.descending` physical ordering
185/// `make_lex_ordering` declares below -- the two must stay in lockstep or
186/// `assert_ordering_satisfied` stops matching the sort that was actually applied.
187pub fn sort_exprs(columns: &[ScanSortColumn]) -> Vec<SortExpr> {
188    columns
189        .iter()
190        .map(|c| {
191            Expr::Column(datafusion::common::Column::new_unqualified(
192                c.column.as_str(),
193            ))
194            .sort(!c.descending, c.descending)
195        })
196        .collect()
197}
198
199/// Builds the `LexOrdering` declaring the already-satisfied output ordering of the scan, matching
200/// DataFusion's default `ORDER BY` semantics (ASC NULLS LAST unless `descending`).
201pub fn make_lex_ordering(
202    schema: &SchemaRef,
203    output_ordering: &[ScanSortColumn],
204) -> datafusion::error::Result<Option<LexOrdering>> {
205    let sort_exprs = output_ordering
206        .iter()
207        .map(|c| {
208            let col =
209                datafusion::physical_expr::expressions::Column::new_with_schema(&c.column, schema)?;
210            Ok(PhysicalSortExpr::new(
211                Arc::new(col),
212                SortOptions {
213                    descending: c.descending,
214                    // Match DataFusion's default ORDER BY semantics: ASC NULLS LAST, DESC NULLS
215                    // FIRST. Hardcoding `false` here would declare `DESC NULLS LAST`, which fails
216                    // to satisfy a descending query's `DESC NULLS FIRST` requirement and silently
217                    // keeps a redundant Sort.
218                    nulls_first: c.descending,
219                },
220            ))
221        })
222        .collect::<datafusion::error::Result<Vec<_>>>()?;
223    Ok(LexOrdering::new(sort_exprs))
224}
225
226/// Bails unless `plan` is a single-partition physical plan. `subject` names the query for the
227/// error message (e.g. `format!("merge query {:?}", query)` or `format!("extract query for
228/// {view}")`); `reason` supplies the full, call-site-specific trailing sentence(s) explaining what
229/// a non-single-partition plan means here -- executing such a plan would coalesce partitions and
230/// silently destroy a declared ordering before it is safe to record or execute. Shared by the
231/// query-execution paths that must verify this before executing:
232/// `QueryMerger::execute_concatenated_merge` (only when its ordering is declared),
233/// `QueryMerger::execute_sorted_merge`, and `sql_partition_spec::plan_sorted_extract`.
234pub fn assert_single_partition(
235    plan: &Arc<dyn ExecutionPlan>,
236    subject: &str,
237    insert_range: TimeRange,
238    reason: &str,
239) -> anyhow::Result<()> {
240    let partition_count = plan.properties().output_partitioning().partition_count();
241    if partition_count != 1 {
242        anyhow::bail!(
243            "{subject} (insert_range=[{}, {}]) produced a {partition_count}-partition physical \
244             plan; {reason}",
245            insert_range.begin.to_rfc3339(),
246            insert_range.end.to_rfc3339()
247        );
248    }
249    Ok(())
250}
251
252/// Bails unless `plan`'s output ordering satisfies the declared `columns`, defensively
253/// re-verifying that a `sort_order` guarantee about to be recorded on a fresh partition is
254/// truthful. `label` is a short noun phrase used in the intermediate error-context messages (e.g.
255/// `"per-file merge"` or `"extract-query"`); `subject` names the query for the bail message;
256/// `reason` supplies the full, call-site-specific trailing text of the bail message (what was
257/// declared, and any guidance for diagnosing a mismatch). Shared by the two paths that record a
258/// `sort_order` guarantee: `QueryMerger::execute_sorted_merge` and
259/// `sql_partition_spec::plan_sorted_extract`. `QueryMerger::execute_concatenated_merge` does not
260/// call this -- its ordering is a structural property of the sorted, non-overlapping file group
261/// rather than a query-plan sort DataFusion could get wrong.
262pub fn assert_ordering_satisfied(
263    plan: &Arc<dyn ExecutionPlan>,
264    columns: &[ScanSortColumn],
265    label: &str,
266    subject: &str,
267    insert_range: TimeRange,
268    reason: &str,
269) -> anyhow::Result<()> {
270    let lex = make_lex_ordering(&plan.schema(), columns)
271        .with_context(|| format!("building the declared {label} ordering"))?
272        .with_context(|| format!("declared {label} columns must be non-empty"))?;
273    let ordering_satisfied = plan
274        .properties()
275        .equivalence_properties()
276        .ordering_satisfy(lex)
277        .with_context(|| format!("checking {label} plan output ordering"))?;
278    if !ordering_satisfied {
279        anyhow::bail!(
280            "{subject} (insert_range=[{}, {}]) produced a physical plan whose output ordering \
281             does not satisfy {reason}",
282            insert_range.begin.to_rfc3339(),
283            insert_range.end.to_rfc3339()
284        );
285    }
286    Ok(())
287}
288
289/// Creates a partitioned execution plan for scanning Parquet files.
290///
291/// `scan_ordering` declares how the scan's already-satisfied ordering (see
292/// `View::get_scan_output_ordering`), if any, is realized:
293/// - `Unordered`: no ordering is declared to DataFusion.
294/// - `Concatenated { columns, bounds }`: the file group is sorted by the leading column's bound
295///   (read per `bounds`) and checked for non-overlap (erroring if violated), per-file min/max
296///   statistics are attached so DataFusion accepts the declared ordering, and the ordering is
297///   attached to the resulting `FileScanConfig` so `EnsureRequirements` can elide a redundant `Sort`
298///   node.
299/// - `PerFile { columns }`: gated by `Partition::certifies_sort_order` -- if every non-empty
300///   partition's recorded `sort_order` certifies `columns`, each non-empty partition becomes its
301///   own single-file group (no overlap check, no per-file statistics -- DataFusion's multi-file-group
302///   ordering validation only needs those to prove cross-file order *within* a group, and
303///   single-file groups pass trivially), all declaring the same `columns` ordering for a
304///   downstream `SortPreservingMergeExec`. If any non-empty partition fails to certify, this
305///   degrades to the same plan shape as `Unordered`.
306#[span_fn]
307#[expect(clippy::too_many_arguments)]
308pub fn make_partitioned_execution_plan(
309    schema: SchemaRef,
310    reader_factory: Arc<ReaderFactory>,
311    state: &dyn Session,
312    projection: Option<&Vec<usize>>,
313    filters: &[Expr],
314    limit: Option<usize>,
315    partitions: Arc<Vec<Partition>>,
316    scan_ordering: &ScanOrdering,
317) -> datafusion::error::Result<Arc<dyn ExecutionPlan>> {
318    let predicate = filters_to_predicate(schema.clone(), state, filters)?;
319
320    let non_empty_partitions: Vec<&Partition> =
321        partitions.iter().filter(|p| !p.is_empty()).collect();
322
323    // A PerFile declaration that any non-empty partition does not certify degrades to Unordered:
324    // both consumers (user-query scans and merge scans) fall back to sorting rather than trust a
325    // stale or uncertified guarantee.
326    let scan_ordering = match scan_ordering {
327        ScanOrdering::PerFile { columns }
328            if !non_empty_partitions
329                .iter()
330                .all(|p| p.certifies_sort_order(columns)) =>
331        {
332            &ScanOrdering::Unordered
333        }
334        other => other,
335    };
336
337    match scan_ordering {
338        ScanOrdering::Unordered => build_unordered_or_concatenated_plan(
339            schema,
340            reader_factory,
341            predicate,
342            projection,
343            limit,
344            non_empty_partitions,
345            None,
346        ),
347        ScanOrdering::Concatenated { columns, bounds } => {
348            let non_empty_partitions =
349                sort_and_check_non_overlapping(non_empty_partitions, *bounds)?;
350            build_unordered_or_concatenated_plan(
351                schema,
352                reader_factory,
353                predicate,
354                projection,
355                limit,
356                non_empty_partitions,
357                Some((columns.as_slice(), *bounds)),
358            )
359        }
360        ScanOrdering::PerFile { columns } => build_per_file_plan(
361            schema,
362            reader_factory,
363            predicate,
364            projection,
365            limit,
366            non_empty_partitions,
367            columns,
368        ),
369    }
370}
371
372/// Builds the `Unordered` (`ordering: None`) and `Concatenated` (`ordering: Some((columns,
373/// bounds))`) scan shapes: every non-empty partition in one sequential file group.
374fn build_unordered_or_concatenated_plan(
375    schema: SchemaRef,
376    reader_factory: Arc<ReaderFactory>,
377    predicate: Arc<dyn datafusion::physical_expr::PhysicalExpr>,
378    projection: Option<&Vec<usize>>,
379    limit: Option<usize>,
380    non_empty_partitions: Vec<&Partition>,
381    ordering: Option<(&[ScanSortColumn], OrderingBounds)>,
382) -> datafusion::error::Result<Arc<dyn ExecutionPlan>> {
383    let mut file_group = vec![];
384    for part in &non_empty_partitions {
385        let file_path = part.file_path.as_ref().ok_or_else(|| {
386            datafusion::error::DataFusionError::Internal(format!(
387                "non-empty partition has no file_path: num_rows={}",
388                part.num_rows
389            ))
390        })?;
391        let mut pf = PartitionedFile::new(file_path, part.file_size as u64);
392        if let Some((output_ordering, ordering_bounds)) = ordering
393            && let Some(leading_column) = output_ordering.first()
394        {
395            pf = attach_ordering_statistics(pf, &schema, leading_column, part, ordering_bounds)?;
396        }
397        file_group.push(pf);
398    }
399
400    // If all partitions are empty, return EmptyExec with projected schema
401    if file_group.is_empty() {
402        return empty_exec(schema, projection);
403    }
404
405    let object_store_url = ObjectStoreUrl::parse("obj://lakehouse/").unwrap();
406    let source = Arc::new(
407        ParquetSource::new(schema.clone())
408            .with_predicate(predicate)
409            .with_parquet_file_reader_factory(reader_factory),
410    );
411    let mut builder = FileScanConfigBuilder::new(object_store_url, source)
412        .with_limit(limit)
413        .with_projection_indices(projection.cloned())?
414        .with_file_groups(vec![file_group.into()]);
415
416    if let Some((output_ordering, _)) = ordering
417        && let Some(lex) = make_lex_ordering(&schema, output_ordering)?
418    {
419        builder = builder.with_output_ordering(vec![lex]);
420    }
421    let file_scan_config = builder.build();
422    Ok(Arc::new(DataSourceExec::new(Arc::new(file_scan_config))))
423}
424
425/// Builds the `PerFile` scan shape: one single-file file group per non-empty partition, all
426/// declaring the same `columns` ordering. No overlap check and no per-file statistics -- see the
427/// module-level rustdoc on `ScanOrdering::PerFile`.
428fn build_per_file_plan(
429    schema: SchemaRef,
430    reader_factory: Arc<ReaderFactory>,
431    predicate: Arc<dyn datafusion::physical_expr::PhysicalExpr>,
432    projection: Option<&Vec<usize>>,
433    limit: Option<usize>,
434    non_empty_partitions: Vec<&Partition>,
435    columns: &[ScanSortColumn],
436) -> datafusion::error::Result<Arc<dyn ExecutionPlan>> {
437    let mut file_groups = vec![];
438    for part in &non_empty_partitions {
439        let file_path = part.file_path.as_ref().ok_or_else(|| {
440            datafusion::error::DataFusionError::Internal(format!(
441                "non-empty partition has no file_path: num_rows={}",
442                part.num_rows
443            ))
444        })?;
445        let pf = PartitionedFile::new(file_path, part.file_size as u64);
446        file_groups.push(vec![pf].into());
447    }
448
449    if file_groups.is_empty() {
450        return empty_exec(schema, projection);
451    }
452
453    let object_store_url = ObjectStoreUrl::parse("obj://lakehouse/").unwrap();
454    let source = Arc::new(
455        ParquetSource::new(schema.clone())
456            .with_predicate(predicate)
457            .with_parquet_file_reader_factory(reader_factory),
458    );
459    let mut builder = FileScanConfigBuilder::new(object_store_url, source)
460        .with_limit(limit)
461        .with_projection_indices(projection.cloned())?
462        .with_file_groups(file_groups);
463
464    if let Some(lex) = make_lex_ordering(&schema, columns)? {
465        builder = builder.with_output_ordering(vec![lex]);
466    }
467    let file_scan_config = builder.build();
468    Ok(Arc::new(DataSourceExec::new(Arc::new(file_scan_config))))
469}
470
471fn empty_exec(
472    schema: SchemaRef,
473    projection: Option<&Vec<usize>>,
474) -> datafusion::error::Result<Arc<dyn ExecutionPlan>> {
475    use datafusion::physical_plan::empty::EmptyExec;
476    let projected_schema = if let Some(projection) = projection {
477        Arc::new(schema.project(projection)?)
478    } else {
479        schema
480    };
481    Ok(Arc::new(EmptyExec::new(projected_schema)))
482}