Skip to main content

micromegas_analytics/lakehouse/
merge.rs

1use super::{
2    lakehouse_context::LakehouseContext,
3    partition::Partition,
4    partition_cache::{PartitionCache, QueryPartitionProvider},
5    partition_source_data::hash_to_object_count,
6    partitioned_execution_plan::{
7        ScanOrdering, assert_ordering_satisfied, assert_single_partition, sort_exprs,
8    },
9    partitioned_table_provider::PartitionedTableProvider,
10    query::make_session_context,
11    read_scope::CallerContext,
12    session_configurator::SessionConfigurator,
13    view::{ScanSortColumn, View},
14    view_factory::ViewFactory,
15    write_partition::{PartitionRowSet, RetireMatch, write_partition_from_rows},
16};
17use crate::{response_writer::Logger, time::TimeRange};
18use anyhow::{Context, Result};
19use async_trait::async_trait;
20use datafusion::{
21    arrow::datatypes::Schema,
22    common::TableReference,
23    execution::SendableRecordBatchStream,
24    physical_plan::{displayable, execute_stream},
25    prelude::*,
26};
27use futures::stream::StreamExt;
28use micromegas_tracing::prelude::*;
29use std::fmt::Debug;
30use std::sync::Arc;
31use std::time::Instant;
32use xxhash_rust::xxh32::xxh32;
33
34/// A merge is a bounded, streaming rewrite of a fixed set of files whose consumer is a single
35/// writer task (`create_merged_partition` -> `write_partition_from_rows`), not a query that
36/// benefits from scan parallelism. Left at DataFusion's default (`true`), `EnsureRequirements`
37/// splits the source scan into `target_partitions` byte-range file groups and `execute_stream`
38/// coalesces them, so the reader working set is multiplied by the host's core count for no
39/// throughput the writer can absorb. Forcing one sequential file group here fixes that for the
40/// `Unordered` and `Concatenated` shapes, which both start from a single file group spanning every
41/// input partition: left unsplit, one reader works through that group's files one at a time instead
42/// of `target_partitions` concurrent byte-range readers. `PerFile` already builds one file group per
43/// input partition, and this setting is load-bearing there too: left at `true`,
44/// `repartition_file_groups` (via `repartition_preserving_order`) splits each of those k
45/// per-partition groups into `target_partitions` byte-range groups whenever k < `target_partitions`,
46/// so this setting is what keeps `PerFile` at one reader per input file for its k-way ordered merge.
47/// Downstream parallelism is untouched: a merge query with a GROUP BY still gets its round-robin
48/// fan-out above the scan.
49pub async fn make_merge_session_context(
50    lakehouse: Arc<LakehouseContext>,
51    part_provider: Arc<dyn QueryPartitionProvider>,
52    query_range: Option<TimeRange>,
53    view_factory: Arc<ViewFactory>,
54    configurator: Arc<dyn SessionConfigurator>,
55    caller: CallerContext,
56) -> Result<SessionContext> {
57    let ctx = make_session_context(
58        lakehouse,
59        part_provider,
60        query_range,
61        view_factory,
62        configurator,
63        caller,
64    )
65    .await?;
66    ctx.state_ref()
67        .write()
68        .config_mut()
69        .options_mut()
70        .optimizer
71        .repartition_file_scans = false;
72    Ok(ctx)
73}
74
75/// The outcome of running a merge query.
76pub struct MergeQueryResult {
77    /// The merged rows.
78    pub stream: SendableRecordBatchStream,
79    /// Whether the merger's declared scan ordering (if any) was honored by the physical plan
80    /// without falling back to a buffering `Sort` node. Always `true` for the concatenate
81    /// strategy's undeclared shape (`ScanOrdering::Unordered`) -- there is no declared ordering to
82    /// defeat -- and only ever computed dynamically for the two strategies that declare one. This
83    /// drives only a memory-regression warning; it never gates the recorded `sort_order` (see
84    /// `View::get_merged_partition_sort_order`).
85    ///
86    /// For the sort-merge strategy (`ScanOrdering::PerFile`), a surviving `SortPreservingMergeExec`
87    /// is the *expected* operator, not a regression -- only a surviving `SortExec` means the
88    /// streaming shape was lost. For the concatenate strategy's declared shape
89    /// (`ScanOrdering::Concatenated`), either operator surviving is a regression.
90    pub ordering_honored: bool,
91}
92
93/// A trait for merging partitions.
94#[async_trait]
95pub trait PartitionMerger: Send + Sync + Debug {
96    /// Executes the merge query.
97    async fn execute_merge_query(
98        &self,
99        lakehouse: Arc<LakehouseContext>,
100        partitions_to_merge: Arc<Vec<Partition>>,
101        partitions_all_views: Arc<PartitionCache>,
102        insert_range: TimeRange,
103    ) -> Result<MergeQueryResult>;
104}
105
106/// A `PartitionMerger` that executes a SQL query to merge partitions.
107#[derive(Debug)]
108pub struct QueryMerger {
109    view_factory: Arc<ViewFactory>,
110    session_configurator: Arc<dyn SessionConfigurator>,
111    file_schema: Arc<Schema>,
112    query: Arc<String>,
113    merge_scan_ordering: ScanOrdering,
114}
115
116impl QueryMerger {
117    pub fn new(
118        view_factory: Arc<ViewFactory>,
119        session_configurator: Arc<dyn SessionConfigurator>,
120        file_schema: Arc<Schema>,
121        query: Arc<String>,
122    ) -> Self {
123        Self {
124            view_factory,
125            session_configurator,
126            file_schema,
127            query,
128            merge_scan_ordering: ScanOrdering::Unordered,
129        }
130    }
131
132    /// Declares an ordering the merge's source scan already satisfies (see
133    /// `PartitionedTableProvider::with_scan_ordering`), letting DataFusion elide the merge query's
134    /// `Sort` node instead of buffering. Default: `Unordered` (matching today's behavior for every
135    /// existing caller).
136    pub fn with_merge_scan_ordering(mut self, ordering: ScanOrdering) -> Self {
137        self.merge_scan_ordering = ordering;
138        self
139    }
140
141    /// The concatenating merge path: one sequential reader over one file group, covering both
142    /// `ScanOrdering::Unordered` and `ScanOrdering::Concatenated` -- they build and execute the
143    /// identical scan, differing only in whether `declared` is `true`, i.e. whether the
144    /// resulting order is declared to DataFusion.
145    /// `repartition_file_scans = false` is applied to every merge by the caller's
146    /// `make_merge_session_context` session, not here; this method just builds the physical plan
147    /// once, inspects it, and executes that exact plan -- never planning or building twice.
148    /// `declared` gates `assert_single_partition` and the `SortExec`/`SortPreservingMergeExec`
149    /// plan-string check, both of which exist to protect a declared ordering: with nothing
150    /// declared there is nothing to protect, and `ordering_honored` is unconditionally `true`,
151    /// matching `MergeQueryResult::ordering_honored`'s documented default for an undeclared
152    /// ordering.
153    async fn execute_concatenated_merge(
154        &self,
155        ctx: &SessionContext,
156        declared: bool,
157        insert_range: TimeRange,
158    ) -> Result<MergeQueryResult> {
159        let df = ctx.sql(&self.query).await?;
160        let task_ctx = Arc::new(df.task_ctx());
161        let plan = df
162            .create_physical_plan()
163            .await
164            .with_context(|| "creating physical plan for merge query")?;
165
166        if declared {
167            assert_single_partition(
168                &plan,
169                &format!("merge query {:?}", self.query),
170                insert_range,
171                "executing it would coalesce partitions and destroy the declared ordering. This \
172                 likely means repartition_file_scans did not take effect.",
173            )?;
174        }
175
176        let ordering_honored = if declared {
177            let plan_str = displayable(plan.as_ref()).indent(true).to_string();
178            let honored =
179                !plan_str.contains("SortExec") && !plan_str.contains("SortPreservingMergeExec");
180            if !honored {
181                warn!(
182                    "merge query {:?} (insert_range=[{}, {}]) did not elide its declared \
183                     ordering -- the merge will still produce a correctly ordered result, but \
184                     it will buffer in memory instead of streaming. Plan:\n{plan_str}",
185                    self.query,
186                    insert_range.begin.to_rfc3339(),
187                    insert_range.end.to_rfc3339()
188                );
189            }
190            honored
191        } else {
192            true
193        };
194
195        let stream =
196            execute_stream(plan, task_ctx).with_context(|| "executing merge query plan")?;
197        Ok(MergeQueryResult {
198            stream,
199            ordering_honored,
200        })
201    }
202
203    /// The `PerFile` k-way merge path: four optimizer settings keep the merge
204    /// a bounded-memory streaming k-way merge instead of fanning out to `target_partitions` --
205    /// `repartition_file_scans = false` is the fifth, applied to every merge by the caller's
206    /// `make_merge_session_context` session rather than set here -- an unconditional logical-plan `DataFrame::sort`
207    /// makes the declared ordering a property of the query plan rather than of an optimizer
208    /// preference (so a wrong or missing merge-query sort is not representable), and three checks
209    /// -- two hard, one warn-only -- run against the physical plan before it is executed.
210    async fn execute_sorted_merge(
211        &self,
212        ctx: &SessionContext,
213        columns: &[ScanSortColumn],
214        insert_range: TimeRange,
215    ) -> Result<MergeQueryResult> {
216        {
217            let state = ctx.state_ref();
218            let mut state = state.write();
219            let optimizer = &mut state.config_mut().options_mut().optimizer;
220            optimizer.enable_round_robin_repartition = false;
221            optimizer.repartition_aggregations = false;
222            optimizer.prefer_existing_sort = true;
223            optimizer.repartition_joins = false;
224        }
225
226        let df = ctx.sql(&self.query).await?;
227        let df = df.sort(sort_exprs(columns))?;
228        let task_ctx = Arc::new(df.task_ctx());
229        let plan = df
230            .create_physical_plan()
231            .await
232            .with_context(|| "creating physical plan for per-file merge query")?;
233
234        // Check 1 (hard): execute_stream would otherwise coalesce partitions and destroy order.
235        assert_single_partition(
236            &plan,
237            &format!("merge query {:?}", self.query),
238            insert_range,
239            "executing it would coalesce partitions and destroy the declared ordering. This \
240             likely means repartition_aggregations or enable_round_robin_repartition did not \
241             take effect, or the merge query is missing a collapsing operator (e.g. a \
242             SortPreservingMergeExec) to bring the per-file partitions back to one.",
243        )?;
244
245        // Check 2 (hard): the plan's output ordering must satisfy the declared columns -- a
246        // defensive assertion that the sort_order about to be recorded is truthful. This has no
247        // author-reachable failure path under this design (the sort above is applied
248        // unconditionally): it is a backstop against a future DataFusion regression, not against
249        // author error.
250        assert_ordering_satisfied(
251            &plan,
252            columns,
253            "per-file merge",
254            &format!("merge query {:?}", self.query),
255            insert_range,
256            &format!(
257                "the declared per-file merge columns {columns:?}; refusing to record a false \
258                 sort_order guarantee."
259            ),
260        )?;
261
262        // Check 3 (warn-only): a SortPreservingMergeExec is the *expected* operator here -- only
263        // a surviving SortExec signals the streaming shape regressed (memory, not correctness --
264        // check 2 already proved the output order).
265        let plan_str = displayable(plan.as_ref()).indent(true).to_string();
266        let ordering_honored = !plan_str.contains("SortExec");
267        if !ordering_honored {
268            warn!(
269                "merge query {:?} (insert_range=[{}, {}]) did not elide its declared per-file \
270                 ordering -- the merge will still produce a correctly ordered result, but it will \
271                 buffer in memory instead of streaming. Plan:\n{plan_str}",
272                self.query,
273                insert_range.begin.to_rfc3339(),
274                insert_range.end.to_rfc3339()
275            );
276        }
277
278        let stream = execute_stream(plan, task_ctx)
279            .with_context(|| "executing per-file merge query plan")?;
280        Ok(MergeQueryResult {
281            stream,
282            ordering_honored,
283        })
284    }
285}
286
287#[async_trait]
288impl PartitionMerger for QueryMerger {
289    async fn execute_merge_query(
290        &self,
291        lakehouse: Arc<LakehouseContext>,
292        partitions_to_merge: Arc<Vec<Partition>>,
293        partitions_all_views: Arc<PartitionCache>,
294        insert_range: TimeRange,
295    ) -> Result<MergeQueryResult> {
296        let reader_factory = lakehouse.reader_factory().clone();
297        let ctx = make_merge_session_context(
298            lakehouse.clone(),
299            partitions_all_views,
300            Some(insert_range),
301            self.view_factory.clone(),
302            self.session_configurator.clone(),
303            CallerContext::maintenance(),
304        )
305        .await?;
306        let src_table = PartitionedTableProvider::with_scan_ordering(
307            self.file_schema.clone(),
308            reader_factory,
309            partitions_to_merge,
310            self.merge_scan_ordering.clone(),
311        );
312        ctx.register_table(
313            TableReference::Bare {
314                table: "source".into(),
315            },
316            Arc::new(src_table),
317        )?;
318
319        match &self.merge_scan_ordering {
320            // Sort-merge: k ordered readers, collapsed by a SortPreservingMergeExec.
321            ScanOrdering::PerFile { columns } => {
322                self.execute_sorted_merge(&ctx, columns, insert_range).await
323            }
324            // Concatenate: one sequential reader over one file group. `Unordered` and
325            // `Concatenated` are the same strategy -- they differ only in whether the resulting
326            // order is declared, which is what gates the checks inside. Spelled out rather than a
327            // wildcard so a future `ScanOrdering` variant fails to compile here instead of
328            // silently taking this arm.
329            other @ (ScanOrdering::Unordered | ScanOrdering::Concatenated { .. }) => {
330                self.execute_concatenated_merge(
331                    &ctx,
332                    other.declares_concatenated_ordering(),
333                    insert_range,
334                )
335                .await
336            }
337        }
338    }
339}
340
341fn partition_set_stats(
342    view: Arc<dyn View>,
343    filtered_partitions: &[Partition],
344) -> Result<(i64, i64)> {
345    let mut sum_size: i64 = 0;
346    let mut source_hash: i64 = 0;
347    let latest_file_schema_hash = view.get_file_schema_hash();
348    for p in filtered_partitions {
349        // for some time all the hashes will actually be the number of events in the source data
350        // when views have different hash algos, we should delegate to the view the creation of the merged hash
351        source_hash = if p.source_data_hash.len() == std::mem::size_of::<i64>() {
352            source_hash + hash_to_object_count(&p.source_data_hash)?
353        } else {
354            //previous hash algo
355            xxh32(&p.source_data_hash, source_hash as u32).into()
356        };
357
358        sum_size += p.file_size;
359
360        if p.view_metadata.file_schema_hash != latest_file_schema_hash {
361            anyhow::bail!(
362                "incompatible file schema with [{},{}]",
363                p.begin_insert_time().to_rfc3339(),
364                p.end_insert_time().to_rfc3339()
365            );
366        }
367    }
368    Ok((sum_size, source_hash))
369}
370
371/// Creates a merged partition from a set of existing partitions.
372pub async fn create_merged_partition(
373    partitions_to_merge: Arc<PartitionCache>,
374    partitions_all_views: Arc<PartitionCache>,
375    lakehouse: Arc<LakehouseContext>,
376    view: Arc<dyn View>,
377    insert_range: TimeRange,
378    logger: Arc<dyn Logger>,
379) -> Result<()> {
380    let view_set_name = &view.get_view_set_name();
381    let view_instance_id = &view.get_view_instance_id();
382    let desc = format!(
383        "[{}, {}] {view_set_name} {view_instance_id}",
384        insert_range.begin.to_rfc3339(),
385        insert_range.end.to_rfc3339()
386    );
387    // we are not looking for intersecting partitions, but only those that fit completely in the range
388    // otherwise we'd get duplicated records
389    let mut filtered_partitions = partitions_to_merge
390        .filter_inside_range(view_set_name, view_instance_id, insert_range)
391        .partitions;
392    if filtered_partitions.len() != partitions_to_merge.len() {
393        warn!("partitions_to_merge was not filtered properly");
394    }
395    if filtered_partitions.len() < 2 {
396        logger
397            .write_log_entry(format!("{desc}: not enough partitions to merge"))
398            .await
399            .with_context(|| "writing log")?;
400        return Ok(());
401    }
402    let (sum_size, source_hash) = partition_set_stats(view.clone(), &filtered_partitions)
403        .with_context(|| "partition_set_stats")?;
404    logger
405        .write_log_entry(format!(
406            "{desc}: merging {} partitions sum_size={sum_size}",
407            filtered_partitions.len()
408        ))
409        .await
410        .with_context(|| "write_log_entry")?;
411    let merge_start = Instant::now();
412    filtered_partitions.sort_by_key(|p| p.begin_insert_time());
413    // Computed before merge_partitions runs: a pure function of the input slice alone.
414    let merged_sort_order = view.get_merged_partition_sort_order(&filtered_partitions);
415    let merge_result = view
416        .merge_partitions(
417            lakehouse.clone(),
418            Arc::new(filtered_partitions),
419            partitions_all_views,
420            insert_range,
421        )
422        .await
423        .with_context(|| "view.merge_partitions")?;
424    // A defeated ordering elision (ordering_honored: false) is already warned about, with the
425    // offending plan, inside QueryMerger::execute_merge_query.
426    let mut merged_stream = merge_result.stream;
427    let (tx, rx) = tokio::sync::mpsc::channel(1);
428    let view_copy = view.clone();
429    let lake = lakehouse.lake().clone();
430    let join_handle = spawn_with_context(write_partition_from_rows(
431        lake,
432        view_copy.get_meta(),
433        view_copy.get_file_schema(),
434        insert_range,
435        source_hash.to_le_bytes().to_vec(),
436        merged_sort_order,
437        RetireMatch::Containment,
438        Vec::new(),
439        rx,
440        logger.clone(),
441    ));
442    let compute_time_bounds = view.get_time_bounds();
443    let ctx =
444        SessionContext::new_with_config_rt(SessionConfig::default(), lakehouse.runtime().clone());
445    let stream_result: Result<()> = async {
446        while let Some(rb_res) = merged_stream.next().await {
447            let rb = rb_res.with_context(|| "receiving record_batch from stream")?;
448            let event_time_range = compute_time_bounds
449                .get_time_bounds(ctx.read_batch(rb.clone()).with_context(|| "read_batch")?)
450                .await?;
451            tx.send(Ok(PartitionRowSet::new(event_time_range, rb, None)))
452                .await
453                .with_context(|| "sending partition row set")?;
454        }
455        Ok(())
456    }
457    .await;
458
459    match stream_result {
460        Ok(()) => {
461            drop(tx);
462            join_handle.await??;
463            logger
464                .write_log_entry(format!(
465                    "{desc}: merge completed in {:.3}s",
466                    merge_start.elapsed().as_secs_f64()
467                ))
468                .await
469                .with_context(|| "write_log_entry")?;
470            Ok(())
471        }
472        Err(e) => {
473            warn!("aborting merge partition write for {desc}: {e:?}");
474            let _ = tx.send(Err(anyhow::anyhow!("merge stream aborted"))).await;
475            drop(tx);
476            match join_handle.await {
477                Ok(Ok(())) => {}
478                Ok(Err(writer_err)) => {
479                    debug!("merge writer task error during abort: {writer_err:?}");
480                }
481                Err(join_err) => {
482                    warn!("merge writer task panicked during abort: {join_err:?}");
483                }
484            }
485            Err(e)
486        }
487    }
488}