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}