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}