1use 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#[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#[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
61pub 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
84pub 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
114pub 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
159fn 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
188fn 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
205fn 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
219fn 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
244fn 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
273const 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
286fn 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 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 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
348async 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
370async 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
394pub 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 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 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 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 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 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}