Skip to main content

micromegas_analytics/lakehouse/
view.rs

1use super::{
2    batch_update::PartitionCreationStrategy,
3    dataframe_time_bounds::DataFrameTimeBounds,
4    lakehouse_context::LakehouseContext,
5    materialized_view::MaterializedView,
6    merge::{MergeQueryResult, PartitionMerger, QueryMerger},
7    partition::Partition,
8    partition_cache::PartitionCache,
9    partitioned_execution_plan::ScanOrdering,
10    session_configurator::NoOpSessionConfigurator,
11    view_factory::ViewFactory,
12};
13use crate::{response_writer::Logger, time::TimeRange};
14use anyhow::Result;
15use async_trait::async_trait;
16use chrono::{DateTime, TimeDelta, Utc};
17use datafusion::{
18    arrow::datatypes::Schema, common::TableReference, logical_expr::Expr, prelude::*,
19};
20use micromegas_ingestion::data_lake_connection::DataLakeConnection;
21use std::fmt::Debug;
22use std::sync::Arc;
23
24/// A trait for defining a partition specification.
25#[async_trait]
26pub trait PartitionSpec: Send + Sync + Debug {
27    /// Returns true if the partition is empty.
28    fn is_empty(&self) -> bool;
29    /// Returns a hash of the source data.
30    fn get_source_data_hash(&self) -> Vec<u8>;
31    /// Writes the partition to the data lake.
32    async fn write(&self, lake: Arc<DataLakeConnection>, logger: Arc<dyn Logger>) -> Result<()>;
33}
34
35/// Metadata about a view.
36#[derive(Debug, Clone)]
37pub struct ViewMetadata {
38    pub view_set_name: Arc<String>,
39    pub view_instance_id: Arc<String>,
40    pub file_schema_hash: Vec<u8>,
41}
42
43/// A column an ordering is expressed over (ascending unless `descending`).
44#[derive(Clone, Debug)]
45pub struct ScanSortColumn {
46    pub column: Arc<String>,
47    pub descending: bool,
48}
49
50/// A trait for defining a view.
51#[async_trait]
52pub trait View: std::fmt::Debug + Send + Sync {
53    /// name of the table from the user's perspective
54    fn get_view_set_name(&self) -> Arc<String>;
55
56    /// get_view_instance_id can be a process_id, a stream_id or 'global'.
57    fn get_view_instance_id(&self) -> Arc<String>;
58
59    /// make_batch_partition_spec determines what should be found in an up to date partition.
60    /// The resulting PartitionSpec can be used to validate existing partitions are create a new one.
61    async fn make_batch_partition_spec(
62        &self,
63        lakehouse: Arc<LakehouseContext>,
64        existing_partitions: Arc<PartitionCache>,
65        insert_range: TimeRange,
66    ) -> Result<Arc<dyn PartitionSpec>>;
67
68    /// get_file_schema_hash returns a hash (can be a version number, version string, etc.) that allows
69    /// to identify out of date partitions.
70    fn get_file_schema_hash(&self) -> Vec<u8>;
71
72    /// get_file_schema returns the schema of the partition file in object storage
73    fn get_file_schema(&self) -> Arc<Schema>;
74
75    /// jit_update creates or updates process-specific partitions before a query
76    async fn jit_update(
77        &self,
78        lakehouse: Arc<LakehouseContext>,
79        query_range: Option<TimeRange>,
80    ) -> Result<()>;
81
82    /// make_time_filter returns a set of expressions that will filter out the rows of the partition
83    /// outside the time range requested.
84    fn make_time_filter(&self, _begin: DateTime<Utc>, _end: DateTime<Utc>) -> Result<Vec<Expr>>;
85
86    // a view must provide a way to compute the time bounds of a DataFrame corresponding to its schema
87    fn get_time_bounds(&self) -> Arc<dyn DataFrameTimeBounds>;
88
89    /// register the table in the SessionContext
90    async fn register_table(&self, ctx: &SessionContext, table: MaterializedView) -> Result<()> {
91        let view_set_name = self.get_view_set_name().to_string();
92        ctx.register_table(
93            TableReference::Bare {
94                table: view_set_name.into(),
95            },
96            Arc::new(table),
97        )?;
98        Ok(())
99    }
100
101    async fn merge_partitions(
102        &self,
103        lakehouse: Arc<LakehouseContext>,
104        partitions_to_merge: Arc<Vec<Partition>>,
105        partitions_all_views: Arc<PartitionCache>,
106        insert_range: TimeRange,
107    ) -> Result<MergeQueryResult> {
108        let merge_query = Arc::new(String::from("SELECT * FROM source;"));
109        let empty_view_factory = Arc::new(ViewFactory::new(vec![]));
110        let merger = QueryMerger::new(
111            empty_view_factory,
112            Arc::new(NoOpSessionConfigurator),
113            self.get_file_schema(),
114            merge_query,
115        );
116        merger
117            .execute_merge_query(
118                lakehouse,
119                partitions_to_merge,
120                partitions_all_views,
121                insert_range,
122            )
123            .await
124    }
125
126    /// Returns the sort guarantee a merge of `partitions_to_merge` will actually produce, to be
127    /// recorded as the resulting partition's `Partition::sort_order` (see
128    /// `merge::create_merged_partition`).
129    ///
130    /// This is a distinct concept from `get_scan_output_ordering()`: that one is a trusted
131    /// scan-ordering declaration consumed during physical planning, while this one is a record of
132    /// what a specific merge actually produced, computed purely from the input partitions (before
133    /// `merge_partitions` runs) and independent of whether DataFusion's elision optimization
134    /// happened to succeed for this particular run.
135    ///
136    /// Default: `None` -- no guarantee recorded, ignoring the argument.
137    fn get_merged_partition_sort_order(
138        &self,
139        _partitions_to_merge: &[Partition],
140    ) -> Option<Vec<String>> {
141        None
142    }
143
144    /// tells the daemon which view should be materialized and in what order
145    fn get_update_group(&self) -> Option<i32>;
146
147    /// allow the view to subdivide the requested partition
148    fn get_max_partition_time_delta(&self, _strategy: &PartitionCreationStrategy) -> TimeDelta {
149        TimeDelta::days(1)
150    }
151
152    /// Declares an ordering the view's partition scan *already* emits, letting DataFusion
153    /// elide redundant `Sort` nodes for queries that `ORDER BY` these columns.
154    ///
155    /// **A large or memory-heavy merge is not, on its own, a reason to declare an ordering here.**
156    /// Every `QueryMerger` merge scans its source partitions with a single sequential reader
157    /// regardless of what this method returns (`make_merge_session_context`,
158    /// `tasks/1491_merge_scan_memory_plan.md`), so a view with no natural sort contract gets a
159    /// bounded-memory merge for free, with no ordering guarantee to maintain. Reach for a declared
160    /// ordering only when a *query-side* need justifies it -- eliding a `Sort` for queries that
161    /// already `ORDER BY` these columns, or (via `PerFile`) a streaming k-way merge that must
162    /// preserve per-file order across an aggregation -- not to bound a merge's memory.
163    ///
164    /// Returning a non-`Unordered` value is a correctness contract the view must guarantee, and
165    /// its shape depends on which variant is returned:
166    /// - `Concatenated { columns, .. }`: rows within each partition file are already sorted by
167    ///   `columns`, the leading column is the view's min-event-time column, and partition
168    ///   event-time ranges are non-overlapping (so files concatenate in globally-sorted order).
169    ///   For `ThreadSpansView`, the *rows-sorted-within-a-file* half is obtained from JIT
170    ///   partitions being grouped under `jit_partitions::BlockOrder::EventTime`:
171    ///   `group_blocks_into_partitions` sorts a segment's blocks by event time and only cuts at
172    ///   insert-safe points (see its docs), so within a segment blocks land in event order rather
173    ///   than relying on an unenforced assumption about registration order, and
174    ///   `thread_spans_view::ensure_begin_non_decreasing` checks the resulting batch at write
175    ///   time.
176    ///
177    ///   The *partition-ranges-don't-overlap* half is **not** established by that grouping alone --
178    ///   a partition's declared event-time bounds come from its blocks' `begin_ticks`/`end_ticks`,
179    ///   not from the rows -- but by design both producers' consecutive blocks now *touch* exactly:
180    ///   - Both `micromegas_tracing::dispatch`'s four flush paths and the Unreal producer
181    ///     (`MicromegasTracing/Private/Dispatch.cpp`) take a single shared timestamp per flush and
182    ///     use it for both the outgoing block's close and the replacement block's `begin`, so
183    ///     `block[k].end_ticks == block[k+1].begin_ticks` by construction. This is the design intent
184    ///     the shared boundary stamp exists for: it lets call trees merge seamlessly across the cut
185    ///     (`group_contiguous_block_chains` treats touching blocks as chain-connected).
186    ///   - Data from `micromegas_tracing` versions predating that fix (or any other producer that
187    ///     stamps two separate timestamps) can still strictly overlap at block boundaries -- this is
188    ///     a legacy producer bug, not a supported producer shape, but the server tolerates it: the
189    ///     view records `max_sort_key_time` (the true max leading-sort-column value, exact per
190    ///     partition), and `partitioned_execution_plan::partition_bounds` reads that in preference
191    ///     to `max_event_time` for the non-overlap check, which the swap-window argument in
192    ///     `tasks/completed/thread_spans_segment_boundary_overlap_plan.md` shows can never trip for cuts at
193    ///     block boundaries of either producer. Partitions written before that column existed (or by
194    ///     a view that never populates it) fall back to the old, looser `max_event_time` bound, which
195    ///     is self-healing rather than a permanent gap: `ThreadSpansView::SCHEMA_VERSION`'s bump makes
196    ///     every pre-existing partition stale by schema hash, so it rebuilds -- carrying
197    ///     `max_sort_key_time` -- automatically on its next query, no admin `retire_partitions` call
198    ///     required.
199    ///
200    ///   Two residual caveats therefore remain, both backstopped by
201    ///   `sort_and_check_non_overlapping` (`partitioned_execution_plan.rs`) failing the query
202    ///   loudly rather than returning wrong rows: an insert-time inversion straddling a JIT
203    ///   *segment* boundary (segments are still grouped independently, see
204    ///   `generate_stream_jit_partitions`), which produces a genuine row-level overlap that the
205    ///   check correctly rejects; and TSC-frequency re-estimation drift across materialization
206    ///   epochs for `tsc_frequency == 0` processes, which can skew bounds written under different
207    ///   converters.
208    /// - `PerFile { columns }`: rows within each partition file are already sorted, ascending, by
209    ///   `columns`, but partitions may overlap each other arbitrarily on those columns. A false
210    ///   declaration here is not merely mis-ordered rows but, under order-aware aggregation, wrong
211    ///   aggregate results (groups closed early, duplicate group keys) -- see
212    ///   `SqlBatchView::with_merge_sort_order`. What makes declaring this safe is the
213    ///   recorded-`sort_order` certification gate inside `make_partitioned_execution_plan`: every
214    ///   non-empty partition must certify `columns` before the declaration reaches DataFusion at
215    ///   all.
216    ///
217    /// Default: `Unordered` (no declared ordering — DataFusion sorts as usual).
218    fn get_scan_output_ordering(&self) -> ScanOrdering {
219        ScanOrdering::Unordered
220    }
221}
222
223impl dyn View {
224    pub fn get_meta(&self) -> ViewMetadata {
225        ViewMetadata {
226            view_set_name: self.get_view_set_name(),
227            view_instance_id: self.get_view_instance_id(),
228            file_schema_hash: self.get_file_schema_hash(),
229        }
230    }
231}