micromegas_analytics/lakehouse/
partitioned_execution_plan.rs1use super::{partition::Partition, reader_factory::ReaderFactory, view::ScanSortColumn};
2use crate::{dfext::predicate::filters_to_predicate, time::datetime_to_scalar};
3use chrono::{DateTime, Utc};
4use datafusion::{
5 arrow::{compute::SortOptions, datatypes::SchemaRef},
6 catalog::{Session, memory::DataSourceExec},
7 common::stats::Precision,
8 datasource::{
9 listing::PartitionedFile,
10 physical_plan::{FileScanConfigBuilder, ParquetSource},
11 },
12 execution::object_store::ObjectStoreUrl,
13 physical_expr::{LexOrdering, PhysicalSortExpr},
14 physical_plan::{ColumnStatistics, ExecutionPlan, Statistics},
15 prelude::*,
16};
17use micromegas_tracing::prelude::*;
18use std::sync::Arc;
19
20#[derive(Clone, Copy, Debug)]
22pub enum OrderingBounds {
23 EventTime,
25 InsertTime,
27}
28
29fn partition_bounds(
33 p: &Partition,
34 bounds: OrderingBounds,
35) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
36 match bounds {
37 OrderingBounds::EventTime => p.min_event_time().zip(p.max_event_time()),
38 OrderingBounds::InsertTime => Some((p.begin_insert_time(), p.end_insert_time())),
39 }
40}
41
42fn sort_and_check_non_overlapping(
56 mut partitions: Vec<&Partition>,
57 bounds: OrderingBounds,
58) -> datafusion::error::Result<Vec<&Partition>> {
59 partitions.sort_by(|a, b| {
60 partition_bounds(a, bounds)
61 .map(|(begin, _)| begin)
62 .cmp(&partition_bounds(b, bounds).map(|(begin, _)| begin))
63 .then_with(|| a.file_path.cmp(&b.file_path))
64 });
65 for pair in partitions.windows(2) {
66 let prev = pair[0];
67 let next = pair[1];
68 if let (Some((_, prev_max)), Some((next_min, _))) = (
69 partition_bounds(prev, bounds),
70 partition_bounds(next, bounds),
71 ) && prev_max > next_min
72 {
73 return Err(datafusion::error::DataFusionError::Execution(format!(
74 "declared scan ordering violated: partition {:?} (range ending {prev_max}) overlaps partition {:?} (range starting {next_min}). \
75 For event-time ordering this can happen when a stream's blocks were registered out of event-time order, or -- for tsc_frequency == 0 processes -- when TSC-frequency \
76 re-estimation drifted across materialization epochs spanning a clock adjustment (see the ordering-invariant notes on View::get_scan_output_ordering in view.rs). \
77 Retire the affected stream's partitions so they rebuild with a single, consistent time converter.",
78 prev.file_path, next.file_path
79 )));
80 }
81 }
82 Ok(partitions)
83}
84
85fn attach_ordering_statistics(
91 mut file: PartitionedFile,
92 schema: &SchemaRef,
93 leading_column: &ScanSortColumn,
94 partition: &Partition,
95 bounds: OrderingBounds,
96) -> datafusion::error::Result<PartitionedFile> {
97 let mut stats = Statistics::new_unknown(schema);
98 if let Some((min_time, max_time)) = partition_bounds(partition, bounds) {
99 let idx = schema.index_of(&leading_column.column)?;
100 stats.column_statistics[idx] = ColumnStatistics::new_unknown()
101 .with_min_value(Precision::Inexact(datetime_to_scalar(min_time)))
102 .with_max_value(Precision::Inexact(datetime_to_scalar(max_time)));
103 }
104 file = file.with_statistics(Arc::new(stats));
105 Ok(file)
106}
107
108fn make_lex_ordering(
111 schema: &SchemaRef,
112 output_ordering: &[ScanSortColumn],
113) -> datafusion::error::Result<Option<LexOrdering>> {
114 let sort_exprs = output_ordering
115 .iter()
116 .map(|c| {
117 let col =
118 datafusion::physical_expr::expressions::Column::new_with_schema(&c.column, schema)?;
119 Ok(PhysicalSortExpr::new(
120 Arc::new(col),
121 SortOptions {
122 descending: c.descending,
123 nulls_first: c.descending,
128 },
129 ))
130 })
131 .collect::<datafusion::error::Result<Vec<_>>>()?;
132 Ok(LexOrdering::new(sort_exprs))
133}
134
135#[span_fn]
145#[expect(clippy::too_many_arguments)]
146pub fn make_partitioned_execution_plan(
147 schema: SchemaRef,
148 reader_factory: Arc<ReaderFactory>,
149 state: &dyn Session,
150 projection: Option<&Vec<usize>>,
151 filters: &[Expr],
152 limit: Option<usize>,
153 partitions: Arc<Vec<Partition>>,
154 output_ordering: &[ScanSortColumn],
155 ordering_bounds: OrderingBounds,
156) -> datafusion::error::Result<Arc<dyn ExecutionPlan>> {
157 let predicate = filters_to_predicate(schema.clone(), state, filters)?;
158
159 let non_empty_partitions: Vec<&Partition> =
160 partitions.iter().filter(|p| !p.is_empty()).collect();
161 let non_empty_partitions = if output_ordering.is_empty() {
162 non_empty_partitions
163 } else {
164 sort_and_check_non_overlapping(non_empty_partitions, ordering_bounds)?
165 };
166
167 let mut file_group = vec![];
168 for part in &non_empty_partitions {
169 let file_path = part.file_path.as_ref().ok_or_else(|| {
170 datafusion::error::DataFusionError::Internal(format!(
171 "non-empty partition has no file_path: num_rows={}",
172 part.num_rows
173 ))
174 })?;
175 let mut pf = PartitionedFile::new(file_path, part.file_size as u64);
176 if let Some(leading_column) = output_ordering.first() {
177 pf = attach_ordering_statistics(pf, &schema, leading_column, part, ordering_bounds)?;
178 }
179 file_group.push(pf);
180 }
181
182 if file_group.is_empty() {
184 use datafusion::physical_plan::empty::EmptyExec;
185 let projected_schema = if let Some(projection) = projection {
186 Arc::new(schema.project(projection)?)
187 } else {
188 schema
189 };
190 return Ok(Arc::new(EmptyExec::new(projected_schema)));
191 }
192
193 let object_store_url = ObjectStoreUrl::parse("obj://lakehouse/").unwrap();
194 let source = Arc::new(
195 ParquetSource::new(schema.clone())
196 .with_predicate(predicate)
197 .with_parquet_file_reader_factory(reader_factory),
198 );
199 let mut builder = FileScanConfigBuilder::new(object_store_url, source)
200 .with_limit(limit)
201 .with_projection_indices(projection.cloned())?
202 .with_file_groups(vec![file_group.into()]);
203
204 if let Some(lex) = make_lex_ordering(&schema, output_ordering)? {
205 builder = builder.with_output_ordering(vec![lex]);
206 }
207 let file_scan_config = builder.build();
208 Ok(Arc::new(DataSourceExec::new(Arc::new(file_scan_config))))
209}