micromegas_analytics/lakehouse/log_stats_view.rs
1use super::{
2 session_configurator::NoOpSessionConfigurator,
3 sql_batch_view::SqlBatchView,
4 view_definition::{ViewDefinition, ViewOptions, build_sql_batch_view},
5 view_factory::ViewFactory,
6};
7use anyhow::Result;
8use datafusion::execution::runtime_env::RuntimeEnv;
9use micromegas_ingestion::data_lake_connection::DataLakeConnection;
10use std::sync::Arc;
11
12/// Query to count source rows in the time range by summing nb_objects from log blocks only.
13const COUNT_SRC_QUERY: &str = r#"
14 SELECT sum(nb_objects) as count
15 FROM blocks
16 WHERE array_has("streams.tags", 'log')
17 AND insert_time >= '{begin}'
18 AND insert_time < '{end}'
19 "#;
20
21// Transform query to aggregate logs by time bin, process, level, and target. No ORDER BY is
22// written here -- the extract path applies the sort from the with_merge_sort_order columns
23// below (see sql_partition_spec::plan_sorted_extract) before recording the
24// (time_bin, process_id, level, target) sort_order guarantee, never reaching this SQL text.
25//
26// `audience` joins the GROUP BY: `log_entries.audience` is a per-row stamp, and a single
27// `process_id` can still span two audiences, so grouping on it too keeps those rows separate
28// instead of letting `max(audience)` collapse them into one mislabelled row. It does **not**
29// join the declared `with_merge_sort_order` columns below -- see that builder's doc comment
30// for why an extra, unordered `GROUP BY` key degrades the merge query's `InputOrderMode` to
31// `PartiallySorted` rather than blocking streaming aggregation outright.
32const EXTRACT_QUERY: &str = r#"
33 SELECT date_bin('1 minute', time) as time_bin,
34 process_id,
35 level,
36 target,
37 count(*) as count,
38 arrow_cast(max(audience), 'Dictionary(Int32, Utf8)') as audience
39 FROM log_entries
40 WHERE insert_time >= '{begin}'
41 AND insert_time < '{end}'
42 GROUP BY process_id, level, target, time_bin, audience
43 "#;
44
45// Merge query to combine partitions. No ORDER BY is written here -- QueryMerger applies the
46// sort as a DataFusion logical-plan node from the with_merge_sort_order columns below, never
47// reaching this SQL text. `audience` joins this GROUP BY too, for the same reason as the
48// transform query above.
49const MERGE_QUERY: &str = r#"
50 SELECT time_bin,
51 process_id,
52 level,
53 target,
54 sum(count) as count,
55 arrow_cast(max(audience), 'Dictionary(Int32, Utf8)') as audience
56 FROM {source}
57 GROUP BY process_id, level, target, time_bin, audience
58 "#;
59
60/// `log_stats`'s definition, expressed as a `ViewDefinition` -- the single source of truth shared
61/// by the migration seed (`migration.rs`'s `upgrade_v9_to_v10`), the parser round-trip test, and
62/// [`make_log_stats_view`] below. `log_stats` is a `SqlBatchView` whose only distinction from a
63/// DDL-defined view is that its SQL lives in Rust rather than in a `lakehouse_view_set_definitions`
64/// row -- this function is what lets the migration seed that row.
65pub fn log_stats_view_definition() -> ViewDefinition {
66 ViewDefinition {
67 view_set_name: "log_stats".to_string(),
68 extract_query: EXTRACT_QUERY.to_string(),
69 count_src_query: COUNT_SRC_QUERY.to_string(),
70 merge_partitions_query: MERGE_QUERY.to_string(),
71 update_group: 3000,
72 options: ViewOptions {
73 min_time_column: "time_bin".to_string(),
74 max_time_column: "time_bin".to_string(),
75 source_partition_delta: "1 day".to_string(),
76 merge_partition_delta: "1 day".to_string(),
77 // Time first: keeps merged partitions time-local, preserving row-group pruning on
78 // time_bin for user queries. GROUP BY key order is irrelevant to streaming, so any
79 // prefix of these four columns would stream too -- this is the full declared order.
80 merge_sort_order: Some(vec![
81 "time_bin".to_string(),
82 "process_id".to_string(),
83 "level".to_string(),
84 "target".to_string(),
85 ]),
86 },
87 }
88}
89
90/// Assembles `log_stats_view_definition()`'s equivalent `CREATE MATERIALIZED VIEW log_stats
91/// WITH (...)` DDL text, for the migration seed's `definition_sql` column (kept for display and
92/// audit only -- never re-parsed). Each query is wrapped in `$$...$$`; none of them contains a
93/// `$$`, so no quote-escaping helper is needed.
94pub fn log_stats_ddl_text() -> String {
95 let def = log_stats_view_definition();
96 format!(
97 "CREATE MATERIALIZED VIEW {} WITH (\n\
98 \x20 extract_query = $${}$$,\n\
99 \x20 count_src_query = $${}$$,\n\
100 \x20 merge_partitions_query = $${}$$,\n\
101 \x20 update_group = {},\n\
102 \x20 time_column = '{}',\n\
103 \x20 source_partition_delta = '{}',\n\
104 \x20 merge_partition_delta = '{}',\n\
105 \x20 merge_sort_order = '{}'\n\
106 )",
107 def.view_set_name,
108 def.extract_query,
109 def.count_src_query,
110 def.merge_partitions_query,
111 def.update_group,
112 def.options.min_time_column,
113 def.options.source_partition_delta,
114 def.options.merge_partition_delta,
115 def.options
116 .merge_sort_order
117 .as_ref()
118 .map(|cols| cols.join(", "))
119 .unwrap_or_default(),
120 )
121}
122
123/// Creates a new `SqlBatchView` for log statistics aggregated by process, minute, level, and
124/// target -- `build_sql_batch_view` over [`log_stats_view_definition`], so `log_stats` is built
125/// through the exact same path a DDL-defined view is.
126pub async fn make_log_stats_view(
127 runtime: Arc<RuntimeEnv>,
128 lake: Arc<DataLakeConnection>,
129 view_factory: Arc<ViewFactory>,
130) -> Result<SqlBatchView> {
131 build_sql_batch_view(
132 &log_stats_view_definition(),
133 runtime,
134 lake,
135 view_factory,
136 Arc::new(NoOpSessionConfigurator),
137 )
138 .await
139}