Skip to main content

micromegas_analytics/lakehouse/
view_definition.rs

1//! The normalized shape of a DDL-defined (or seeded) materialized view -- the three queries plus
2//! every option -- and the one `validate_view_definition` function shared by the DDL executor
3//! (`rust/public/src/servers/view_ddl.rs`) and the registry loader (`view_registry.rs`), so a
4//! definition the loader would skip can never be accepted at `CREATE` time in the first place.
5
6use super::{
7    lakehouse_context::LakehouseContext,
8    materialized_view::MaterializedView,
9    partition_cache::NullPartitionProvider,
10    query::make_session_context,
11    read_scope::CallerContext,
12    session_configurator::{NoOpSessionConfigurator, SessionConfigurator},
13    sql_batch_view::{SqlBatchView, guarded_sql_options},
14    sql_partition_spec::plan_sorted_extract,
15    view::{ScanSortColumn, View},
16    view_factory::ViewFactory,
17};
18use crate::time::TimeRange;
19use anyhow::{Context, Result};
20use chrono::{TimeDelta, Utc};
21use datafusion::{
22    arrow::datatypes::{DataType, Schema, TimeUnit},
23    common::{
24        TableReference,
25        tree_node::{TreeNode, TreeNodeRecursion},
26    },
27    datasource::{DefaultTableSource, MemTable},
28    execution::runtime_env::RuntimeEnv,
29    logical_expr::{Expr, LogicalPlan, TableScan, Volatility, expr::ScalarFunction},
30};
31use micromegas_ingestion::data_lake_connection::DataLakeConnection;
32use serde::{Deserialize, Serialize};
33use std::sync::Arc;
34
35/// The non-query, non-`update_group` options a `ViewDefinition` carries, serialized as JSON in
36/// `lakehouse_view_set_definitions.view_options`. `update_group` lives in its own column instead
37/// (reload() sorts and the registry loader projects on it), and `definition_sql` is the verbatim
38/// DDL text kept for display/audit only -- neither belongs here.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct ViewOptions {
41    pub min_time_column: String,
42    pub max_time_column: String,
43    pub source_partition_delta: String,
44    pub merge_partition_delta: String,
45    pub merge_sort_order: Option<Vec<String>>,
46}
47
48/// The normalized shape of a `CREATE MATERIALIZED VIEW` definition -- every option plus the three
49/// queries -- independent of whether it arrived over DDL or was seeded by a migration
50/// (`log_stats_view.rs`'s `log_stats_view_definition`).
51#[derive(Debug, Clone, PartialEq)]
52pub struct ViewDefinition {
53    pub view_set_name: String,
54    pub extract_query: String,
55    pub count_src_query: String,
56    pub merge_partitions_query: String,
57    pub update_group: i32,
58    pub options: ViewOptions,
59}
60
61/// Parses a `"<n> <unit>"` string (e.g. `"1 day"`, `"2 hours"`) into a `TimeDelta`. Accepted units,
62/// singular or plural: `second(s)`, `minute(s)`, `hour(s)`, `day(s)`.
63pub fn parse_time_delta(s: &str) -> Result<TimeDelta> {
64    let s = s.trim();
65    let (num_str, unit) = s
66        .split_once(char::is_whitespace)
67        .with_context(|| format!("parse_time_delta: expected '<n> <unit>', got {s:?}"))?;
68    let n: i64 = num_str
69        .trim()
70        .parse()
71        .with_context(|| format!("parse_time_delta: invalid integer {num_str:?} in {s:?}"))?;
72    if n <= 0 {
73        anyhow::bail!("parse_time_delta: expected a positive integer, got {n} in {s:?}");
74    }
75    match unit.trim().to_lowercase().as_str() {
76        "second" | "seconds" => Ok(TimeDelta::seconds(n)),
77        "minute" | "minutes" => Ok(TimeDelta::minutes(n)),
78        "hour" | "hours" => Ok(TimeDelta::hours(n)),
79        "day" | "days" => Ok(TimeDelta::days(n)),
80        other => anyhow::bail!("parse_time_delta: unknown unit {other:?} in {s:?}"),
81    }
82}
83
84/// Fast, redundant, name-only pre-check -- also enforced, exhaustively, by
85/// [`validate_view_definition`], which remains the sole enforcement point since it is the only
86/// check that also runs on rows the registry loader reads back. `^[a-z_][a-z0-9_]{0,254}$`, not
87/// starting with `__` (which would collide with a view's own `__<name>__partitions` internal
88/// registration), and not `source` (substituted for `{source}` in every merge query and registered
89/// on every merge's session context).
90pub fn check_view_set_name_charset(name: &str) -> Result<()> {
91    let bytes = name.as_bytes();
92    let valid_charset = !bytes.is_empty()
93        && bytes.len() <= 255
94        && matches!(bytes[0], b'a'..=b'z' | b'_')
95        && bytes
96            .iter()
97            .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_'));
98    if !valid_charset {
99        anyhow::bail!("view set name {name:?} must match ^[a-z_][a-z0-9_]{{0,254}}$");
100    }
101    if name.starts_with("__") {
102        anyhow::bail!(
103            "view set name {name:?} must not start with '__' (reserved for internal partition tables)"
104        );
105    }
106    if name == "source" {
107        anyhow::bail!(
108            "view set name 'source' is reserved (substituted for '{{source}}' in merge queries)"
109        );
110    }
111    Ok(())
112}
113
114/// Builds the `SqlBatchView` a `ViewDefinition` describes. This *is* most of the validation --
115/// planning the extract query catches syntax errors, unknown tables and unknown columns, and
116/// yields the schema every later check inspects -- so callers (the DDL executor, the registry
117/// loader) must call this before [`validate_view_definition`], never after.
118pub async fn build_sql_batch_view(
119    def: &ViewDefinition,
120    runtime: Arc<RuntimeEnv>,
121    lake: Arc<DataLakeConnection>,
122    view_factory: Arc<ViewFactory>,
123    session_configurator: Arc<dyn SessionConfigurator>,
124) -> Result<SqlBatchView> {
125    let source_delta = parse_time_delta(&def.options.source_partition_delta)
126        .with_context(|| format!("{}: source_partition_delta", def.view_set_name))?;
127    let merge_delta = parse_time_delta(&def.options.merge_partition_delta)
128        .with_context(|| format!("{}: merge_partition_delta", def.view_set_name))?;
129    let view = SqlBatchView::new(
130        runtime,
131        Arc::new(def.view_set_name.clone()),
132        Arc::new(def.options.min_time_column.clone()),
133        Arc::new(def.options.max_time_column.clone()),
134        Arc::new(def.count_src_query.clone()),
135        Arc::new(def.extract_query.clone()),
136        Arc::new(def.merge_partitions_query.clone()),
137        lake,
138        view_factory,
139        session_configurator,
140        Some(def.update_group),
141        source_delta,
142        merge_delta,
143    )
144    .await
145    .with_context(|| format!("building SqlBatchView for '{}'", def.view_set_name))?;
146    match &def.options.merge_sort_order {
147        Some(columns) if !columns.is_empty() => view
148            .with_merge_sort_order(columns.iter().map(|c| Arc::new(c.clone())).collect())
149            .with_context(|| format!("'{}': with_merge_sort_order", def.view_set_name)),
150        _ => Ok(view),
151    }
152}
153
154fn is_stringy(dt: &DataType) -> bool {
155    matches!(dt, DataType::Utf8)
156        || matches!(dt, DataType::Dictionary(_, inner) if **inner == DataType::Utf8)
157}
158
159/// Mirrors `OwnershipRewrite::predicate_for`'s own precedence -- an `audience` field, if
160/// present, governs regardless of whether a correctly-typed `process_id` field sits beside it.
161fn check_audience_reachable(schema: &Schema) -> Result<()> {
162    if let Ok(field) = schema.field_with_name("audience") {
163        if !is_stringy(field.data_type()) {
164            anyhow::bail!(
165                "extract_query's 'audience' column must be Utf8 or Dictionary(_, Utf8), got {:?}; \
166                 project it as e.g. arrow_cast(max(audience), 'Dictionary(Int32, Utf8)')",
167                field.data_type()
168            );
169        }
170        return Ok(());
171    }
172    if let Ok(field) = schema.field_with_name("process_id") {
173        if !is_stringy(field.data_type()) {
174            anyhow::bail!(
175                "extract_query's 'process_id' column must be Utf8 or Dictionary(_, Utf8), got {:?}",
176                field.data_type()
177            );
178        }
179        return Ok(());
180    }
181    anyhow::bail!(
182        "extract_query's schema must carry an 'audience' column (preferred) or a 'process_id' \
183         column, so non-admin callers can be filtered by audience -- otherwise the view would \
184         silently serve every row to every caller with no error anywhere"
185    );
186}
187
188/// The resolved time column must exist and be a nanosecond timestamp.
189fn check_time_column(schema: &Schema, column: &str) -> Result<()> {
190    let field = schema
191        .field_with_name(column)
192        .with_context(|| format!("time column '{column}' not found in extract_query's schema"))?;
193    if !matches!(
194        field.data_type(),
195        DataType::Timestamp(TimeUnit::Nanosecond, _)
196    ) {
197        anyhow::bail!(
198            "time column '{column}' must be Timestamp(Nanosecond, _), got {:?}",
199            field.data_type()
200        );
201    }
202    Ok(())
203}
204
205/// `fetch_sql_partition_spec` requires a `count` field reachable as an `Int64Array`.
206fn check_count_query_schema(schema: &Schema) -> Result<()> {
207    let field = schema
208        .field_with_name("count")
209        .with_context(|| "count_src_query must project a 'count' column")?;
210    if field.data_type() != &DataType::Int64 {
211        anyhow::bail!(
212            "count_src_query's 'count' column must be Int64, got {:?}",
213            field.data_type()
214        );
215    }
216    Ok(())
217}
218
219/// Field names, types and order must match -- nullability and
220/// metadata are deliberately excluded (a `count(*)` extract column is non-nullable while its
221/// `sum(count)` merge counterpart is nullable, which the seeded `log_stats` calibration case
222/// exercises).
223fn check_schema_agrees(extract_schema: &Schema, merge_schema: &Schema) -> Result<()> {
224    let extract_fields: Vec<(&str, &DataType)> = extract_schema
225        .fields()
226        .iter()
227        .map(|f| (f.name().as_str(), f.data_type()))
228        .collect();
229    let merge_fields: Vec<(&str, &DataType)> = merge_schema
230        .fields()
231        .iter()
232        .map(|f| (f.name().as_str(), f.data_type()))
233        .collect();
234    if extract_fields != merge_fields {
235        anyhow::bail!(
236            "merge_partitions_query's output schema {merge_fields:?} disagrees with \
237             extract_query's {extract_fields:?} (name, type and order must match; nullability and \
238             metadata are ignored)"
239        );
240    }
241    Ok(())
242}
243
244/// Walks every `ScalarUDF` reachable from `plan` -- not just its top-level expressions,
245/// so a call nested inside another expression (e.g. `now()` inside `date_bin('1 minute',
246/// now())`) is still found -- and rejects the first whose `signature().volatility` is not
247/// `Immutable`.
248fn check_no_volatile_functions(plan: &LogicalPlan, label: &str) -> Result<()> {
249    let mut offender: Option<String> = None;
250    plan.apply_with_subqueries(|node| {
251        node.apply_expressions(|expr| {
252            expr.apply(|e| {
253                if offender.is_none()
254                    && let Expr::ScalarFunction(ScalarFunction { func, .. }) = e
255                    && func.signature().volatility != Volatility::Immutable
256                {
257                    offender = Some(func.name().to_string());
258                }
259                Ok(TreeNodeRecursion::Continue)
260            })
261        })
262    })?;
263    if let Some(name) = offender {
264        anyhow::bail!(
265            "{label} calls '{name}', a non-immutable function; its result would be frozen into a \
266             partition at materialization time (or, in count_src_query, corrupt freshness \
267             detection) instead of being recomputed"
268        );
269    }
270    Ok(())
271}
272
273/// The four mutating admin-gated table functions plus the three unconditionally-registered but
274/// still-mutating ones -- a stored scan against any of these would be re-executed under
275/// `CallerContext::maintenance()` on every daemon tick.
276const MUTATING_TABLE_FUNCTIONS: &[&str] = &[
277    "retire_partitions",
278    "materialize_partitions",
279    "regenerate_partitions",
280    "deny_queries",
281    "view_instance",
282    "process_spans",
283    "perfetto_trace_chunks",
284];
285
286/// Walks every `TableScan` reachable from `plan`, resolving each to a view set (to
287/// accumulate the highest `update_group` read) or to a banned table function name.
288///
289/// Returns a `datafusion::error::Result` (rather than this module's usual `anyhow::Result`) since
290/// it is called from inside `LogicalPlan::apply_with_subqueries`'s closure, which requires that
291/// exact type; callers outside that closure convert via `?` as usual (anyhow implements `From`
292/// for any `std::error::Error`, which `DataFusionError` is).
293fn walk_plan_for_scans(
294    plan: &LogicalPlan,
295    max_group: &mut Option<i32>,
296    mutating_hit: &mut Option<String>,
297) -> datafusion::error::Result<()> {
298    plan.apply_with_subqueries(|node| {
299        if let LogicalPlan::TableScan(ts) = node {
300            inspect_scan(ts, max_group, mutating_hit)?;
301        }
302        Ok(TreeNodeRecursion::Continue)
303    })?;
304    Ok(())
305}
306
307fn inspect_scan(
308    ts: &TableScan,
309    max_group: &mut Option<i32>,
310    mutating_hit: &mut Option<String>,
311) -> datafusion::error::Result<()> {
312    // Checked first, and unconditionally: `view_instance` resolves to a `MaterializedView` just
313    // like a well-behaved DDL view scan does (see `ViewInstanceTableFunction::call_with_args`),
314    // so the name check must run before the downcast branch below would otherwise `return Ok(())`
315    // and hide it.
316    // DataFusion's `TableFactor::Table` path (the one `foo('a', 'b')` call syntax actually takes,
317    // as opposed to the `TABLE(foo(...))` / `LATERAL` `TableFactor::Function` form) names the
318    // resulting scan `"{name}()"`, not `"{name}"` -- strip that suffix so the comparison below
319    // matches either form.
320    let raw_name = ts.table_name.table();
321    let leaf = raw_name
322        .strip_suffix("()")
323        .unwrap_or(raw_name)
324        .to_lowercase();
325    if mutating_hit.is_none() && MUTATING_TABLE_FUNCTIONS.contains(&leaf.as_str()) {
326        *mutating_hit = Some(leaf);
327        return Ok(());
328    }
329    if let Some(default_source) = ts.source.downcast_ref::<DefaultTableSource>()
330        && let Some(mat_view) = default_source
331            .table_provider
332            .downcast_ref::<MaterializedView>()
333    {
334        if let Some(group) = mat_view.get_view().get_update_group() {
335            *max_group = Some(max_group.map_or(group, |m| m.max(group)));
336        }
337        return Ok(());
338    }
339    // A scan against a DDL/base SqlBatchView's user-visible name (not its
340    // `__<name>__partitions` form) resolves through a view-backed logical plan rather than a
341    // `MaterializedView` directly -- recurse into it to find the real scan.
342    if let Some(inner) = ts.source.get_logical_plan() {
343        return walk_plan_for_scans(&inner, max_group, mutating_hit);
344    }
345    Ok(())
346}
347
348/// Builds a session context mirroring `SqlBatchView::new`'s own -- `CallerContext::maintenance()`
349/// over `factory`, with the real `session_configurator` -- for checks that must see exactly what
350/// the daemon will plan against (extract_query, count_src_query, and the `table_exist` probe).
351async fn build_full_validation_ctx(
352    runtime: Arc<RuntimeEnv>,
353    lake: Arc<DataLakeConnection>,
354    factory: Arc<ViewFactory>,
355    session_configurator: Arc<dyn SessionConfigurator>,
356) -> Result<datafusion::execution::context::SessionContext> {
357    let lakehouse = Arc::new(LakehouseContext::new(lake, runtime)?);
358    make_session_context(
359        lakehouse,
360        Arc::new(NullPartitionProvider {}),
361        None,
362        factory,
363        session_configurator,
364        CallerContext::maintenance(),
365    )
366    .await
367    .with_context(|| "make_session_context (full validation context)")
368}
369
370/// Builds the deliberately narrower session context `merge_partitions_query` is planned
371/// against: a non-admin caller (`CallerContext::internal()`) and `NoOpSessionConfigurator`
372/// regardless of what real configurator the deployment uses -- so a merge query naming an
373/// admin-gated UDTF or a `SessionConfigurator`-registered static table fails to plan here, for
374/// that reason, rather than resolving only because validation happened to run under a
375/// maintenance/admin caller.
376async fn build_narrow_validation_ctx(
377    runtime: Arc<RuntimeEnv>,
378    lake: Arc<DataLakeConnection>,
379    factory: Arc<ViewFactory>,
380) -> Result<datafusion::execution::context::SessionContext> {
381    let lakehouse = Arc::new(LakehouseContext::new(lake, runtime)?);
382    make_session_context(
383        lakehouse,
384        Arc::new(NullPartitionProvider {}),
385        None,
386        factory,
387        Arc::new(NoOpSessionConfigurator),
388        CallerContext::internal(),
389    )
390    .await
391    .with_context(|| "make_session_context (narrow validation context)")
392}
393
394/// Every check runs before a definition's row is written, in this one function shared by the DDL
395/// executor and the registry loader -- so a definition the loader would skip can never be
396/// accepted at `CREATE` time in the first place. `view` must already be the result of
397/// [`build_sql_batch_view`] over `def` -- constructing it *is* most of the validation (it plans
398/// the extract query and yields the schema every check below inspects). `factory` is the factory
399/// `view` was built against -- every other definition, for the DDL executor, or only the
400/// definitions in a strictly lower `update_group` processed so far, for the registry loader --
401/// which the update-group ordering check below reads.
402pub async fn validate_view_definition(
403    def: &ViewDefinition,
404    view: &SqlBatchView,
405    factory: &Arc<ViewFactory>,
406    runtime: Arc<RuntimeEnv>,
407    lake: Arc<DataLakeConnection>,
408    session_configurator: Arc<dyn SessionConfigurator>,
409) -> Result<()> {
410    let schema = view.get_file_schema();
411
412    check_view_set_name_charset(&def.view_set_name)?;
413    if factory.get_global_view(&def.view_set_name).is_some()
414        || factory.get_view_sets().contains_key(&def.view_set_name)
415    {
416        anyhow::bail!(
417            "'{}' is a code-driven view set and cannot be redefined by DDL",
418            def.view_set_name
419        );
420    }
421
422    // Placeholders, checked early since they're purely textual.
423    if !def.count_src_query.contains("{begin}") || !def.count_src_query.contains("{end}") {
424        anyhow::bail!("count_src_query must contain both '{{begin}}' and '{{end}}'");
425    }
426    if !def.merge_partitions_query.contains("{source}") {
427        anyhow::bail!("merge_partitions_query must contain '{{source}}'");
428    }
429
430    check_time_column(&schema, &def.options.min_time_column)?;
431    check_time_column(&schema, &def.options.max_time_column)?;
432
433    check_audience_reachable(&schema)?;
434
435    let full_ctx = build_full_validation_ctx(
436        runtime.clone(),
437        lake.clone(),
438        factory.clone(),
439        session_configurator.clone(),
440    )
441    .await?;
442    if full_ctx
443        .table_exist(def.view_set_name.as_str())
444        .unwrap_or(false)
445    {
446        anyhow::bail!(
447            "'{}' collides with a table already registered in the session (a static table?)",
448            def.view_set_name
449        );
450    }
451
452    // The merge query must plan, and its output schema must agree with the extract schema.
453    let narrow_ctx =
454        build_narrow_validation_ctx(runtime.clone(), lake.clone(), factory.clone()).await?;
455    let empty_source = Arc::new(
456        MemTable::try_new(schema.clone(), vec![vec![]])
457            .with_context(|| "building the empty source table for merge-query validation")?,
458    );
459    narrow_ctx
460        .register_table(
461            TableReference::Bare {
462                table: "source".into(),
463            },
464            empty_source,
465        )
466        .with_context(|| "registering the empty source table")?;
467    let merge_sql = def.merge_partitions_query.replace("{source}", "source");
468    let merge_df = narrow_ctx
469        .sql_with_options(&merge_sql, guarded_sql_options())
470        .await
471        .with_context(|| {
472            format!(
473                "planning merge_partitions_query for '{}'",
474                def.view_set_name
475            )
476        })?;
477    check_schema_agrees(&schema, merge_df.schema().as_arrow())?;
478
479    // The `{begin}`/`{end}`-substituted extract/count query texts the checks below all plan
480    // against. A zero-width range is enough -- these builds are never executed.
481    let now = Utc::now();
482    let now_str = now.to_rfc3339();
483    let extract_sql = def
484        .extract_query
485        .replace("{begin}", &now_str)
486        .replace("{end}", &now_str);
487    let count_sql = def
488        .count_src_query
489        .replace("{begin}", &now_str)
490        .replace("{end}", &now_str);
491
492    let extract_df = full_ctx
493        .sql_with_options(&extract_sql, guarded_sql_options())
494        .await
495        .with_context(|| format!("planning extract_query for '{}'", def.view_set_name))?;
496    let count_df = full_ctx
497        .sql_with_options(&count_sql, guarded_sql_options())
498        .await
499        .with_context(|| format!("planning count_src_query for '{}'", def.view_set_name))?;
500
501    check_count_query_schema(count_df.schema().as_arrow())?;
502
503    // Volatile-function and mutating-scan checks, over the three plans already built above --
504    // no extra planning cost.
505    let mut max_group: Option<i32> = None;
506    let mut mutating_hit: Option<String> = None;
507    for (label, plan) in [
508        ("extract_query", extract_df.logical_plan()),
509        ("merge_partitions_query", merge_df.logical_plan()),
510        ("count_src_query", count_df.logical_plan()),
511    ] {
512        check_no_volatile_functions(plan, label)?;
513        walk_plan_for_scans(plan, &mut max_group, &mut mutating_hit)?;
514    }
515    if let Some(name) = mutating_hit {
516        anyhow::bail!(
517            "'{}' scans '{name}', a mutating or session-scoped table function, which cannot be \
518             stored in a materialized-view definition",
519            def.view_set_name
520        );
521    }
522    if let Some(max_group) = max_group
523        && def.update_group <= max_group
524    {
525        anyhow::bail!(
526            "'{}' has update_group {}, which must be strictly greater than {max_group}, the \
527             highest update_group among the view sets it reads",
528            def.view_set_name,
529            def.update_group
530        );
531    }
532
533    // merge_sort_order, if declared, must apply and plan against extract_query.
534    if let Some(columns) = &def.options.merge_sort_order {
535        let scan_columns: Vec<ScanSortColumn> = columns
536            .iter()
537            .map(|c| ScanSortColumn {
538                column: Arc::new(c.clone()),
539                descending: false,
540            })
541            .collect();
542        let subject = format!("validation of '{}'", def.view_set_name);
543        let extract_df_for_sort = full_ctx
544            .sql_with_options(&extract_sql, guarded_sql_options())
545            .await
546            .with_context(|| format!("re-planning extract_query for '{}'", def.view_set_name))?;
547        plan_sorted_extract(
548            extract_df_for_sort,
549            Some(&scan_columns),
550            &subject,
551            TimeRange::new(now, now),
552        )
553        .await
554        .with_context(|| {
555            format!(
556                "'{}': merge_sort_order does not apply to extract_query",
557                def.view_set_name
558            )
559        })?;
560    }
561
562    Ok(())
563}