Skip to main content

micromegas/servers/
maintenance.rs

1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use chrono::{DateTime, DurationRound};
4use chrono::{TimeDelta, Utc};
5use micromegas_analytics::audience::audience_column_mismatch;
6use micromegas_analytics::delete::delete_old_data;
7use micromegas_analytics::lakehouse::batch_update::materialize_partition_range;
8use micromegas_analytics::lakehouse::lakehouse_context::LakehouseContext;
9use micromegas_analytics::lakehouse::partition_cache::PartitionCache;
10use micromegas_analytics::lakehouse::temp::delete_expired_temporary_files;
11use micromegas_analytics::lakehouse::view::View;
12use micromegas_analytics::lakehouse::view_factory::ViewFactory;
13use micromegas_analytics::lakehouse::view_registry::ViewRegistry;
14use micromegas_analytics::response_writer::ResponseWriter;
15use micromegas_analytics::time::TimeRange;
16use micromegas_tracing::prelude::*;
17use std::future::Future;
18use std::sync::Arc;
19use std::time::Duration;
20use tokio::task::{JoinError, JoinSet};
21
22use super::cron_task::{CronTask, TaskCallback};
23use super::pg_stats::PgStatsTask;
24
25/// Resolves and sorts the view-carrying task's per-tick view list from `registry.current()`: the
26/// base plus every DDL-defined view with an `update_group`, sorted by that group. Computed fresh
27/// on every tick (rather than once at `daemon()` startup, as before DDL-defined views existed)
28/// so a newly created or dropped view set is picked up without a restart.
29fn sorted_views_from_registry(registry: &ViewRegistry) -> Arc<Vec<Arc<dyn View>>> {
30    let mut views = get_global_views_with_update_group(&registry.current());
31    views.sort_by_key(|v| v.get_update_group().unwrap_or(i32::MAX));
32    Arc::new(views)
33}
34
35/// Materializes all views within a given time range.
36///
37/// This function iterates through the provided views, materializing partitions
38/// for each view within the specified `insert_range` and `partition_time_delta`.
39///
40/// Per-view failures are isolated: a view whose `materialize_partition_range`
41/// call errors is logged, counted (`materialize_view_failure`), and skipped —
42/// every other view, in the same update group or a later one, still gets its
43/// own materialization attempt this pass. This is a deliberate cross-group
44/// policy ("continue anyway"): materialization is idempotent and re-attempted
45/// every tick, so letting later groups proceed even after an earlier-group
46/// failure can't corrupt anything, and it avoids reintroducing the
47/// group-granularity starvation this isolation is meant to fix. If any view
48/// failed, the pass still returns a single aggregated `Err` listing every
49/// failed view, so `CronTask`/`log_task_result` continues to record the pass
50/// as failed.
51#[span_fn]
52pub async fn materialize_all_views(
53    lakehouse: Arc<LakehouseContext>,
54    views: Arc<Vec<Arc<dyn View>>>,
55    insert_range: TimeRange,
56    partition_time_delta: TimeDelta,
57) -> Result<()> {
58    // A DDL-defined view set can be dropped between one tick and the next, so `views` is no
59    // longer guaranteed non-empty the way it was when computed once at daemon startup.
60    if views.is_empty() {
61        return Ok(());
62    }
63    let mut last_group = views.first().unwrap().get_update_group();
64    let mut partitions_all_views = Arc::new(
65        PartitionCache::fetch_overlapping_insert_range(&lakehouse.lake().db_pool, insert_range)
66            .await?,
67    );
68    let null_response_writer = Arc::new(ResponseWriter::new(None));
69    let mut failures = Vec::new();
70    for view in &*views {
71        if view.get_update_group() != last_group {
72            // Views in the same update group have no inter-dependencies: a
73            // SqlBatchView whose count_src_query/extract_query reads another
74            // registered view must put that view in an *earlier* group (see
75            // SqlBatchView::new's view_factory), so same-group views can
76            // always be materialized independently, in any order, with one's
77            // failure isolated from the other's.
78            last_group = view.get_update_group();
79            partitions_all_views = Arc::new(
80                PartitionCache::fetch_overlapping_insert_range(
81                    // we are fetching more partitions than we need, could be optimized
82                    &lakehouse.lake().db_pool,
83                    insert_range,
84                )
85                .await?,
86            );
87        }
88        let view_set_name = view.get_view_set_name();
89        let view_instance_id = view.get_view_instance_id();
90        if let Err(e) = materialize_partition_range(
91            partitions_all_views.clone(),
92            lakehouse.clone(),
93            view.clone(),
94            insert_range,
95            partition_time_delta,
96            null_response_writer.clone(),
97        )
98        .await
99        {
100            error!("materialize_all_views: {view_set_name} {view_instance_id} failed: {e:?}");
101            imetric!(
102                "materialize_view_failure",
103                "count",
104                view_set_name = view_set_name,
105                view_instance_id = view_instance_id,
106                1
107            );
108            failures.push(format!("{view_set_name} {view_instance_id}"));
109        }
110    }
111    if !failures.is_empty() {
112        anyhow::bail!(
113            "materialize_all_views: {} view(s) failed:\n{}",
114            failures.len(),
115            failures.join("\n")
116        );
117    }
118    Ok(())
119}
120
121/// task running once a day to materialize older partitions
122pub struct EveryDayTask {
123    pub lakehouse: Arc<LakehouseContext>,
124    pub view_registry: Arc<ViewRegistry>,
125}
126
127#[async_trait]
128impl TaskCallback for EveryDayTask {
129    #[span_fn]
130    async fn run(&self, task_scheduled_time: DateTime<Utc>) -> Result<()> {
131        let partition_time_delta = TimeDelta::days(1);
132        let trunc_task_time = task_scheduled_time.duration_trunc(partition_time_delta)?;
133        let begin_range = trunc_task_time - (partition_time_delta * 2);
134        let end_range = trunc_task_time;
135        let views = sorted_views_from_registry(&self.view_registry);
136        materialize_all_views(
137            self.lakehouse.clone(),
138            views,
139            TimeRange::new(begin_range, end_range),
140            partition_time_delta,
141        )
142        .await
143    }
144}
145
146/// Counts `blocks` rows whose `process_id` disagrees with their stream's own `process_id`,
147/// over the last hour. A block's own `audience` column governs its label regardless of
148/// `process_id`, so this is a plain data-integrity check rather than a security one, run from
149/// the maintenance role so it costs nothing on the hot ingestion path. Reported via
150/// `imetric!("block_stream_process_id_mismatch", "count", n)` and a `warn!` when non-zero --
151/// the healthy baseline is a flat zero.
152async fn measure_block_stream_process_id_mismatch(
153    pool: &sqlx::PgPool,
154    since: DateTime<Utc>,
155) -> Result<()> {
156    let count: i64 = sqlx::query_scalar(
157        "SELECT count(*) FROM blocks b
158         JOIN streams s ON s.stream_id = b.stream_id
159         WHERE b.process_id <> s.process_id
160         AND b.insert_time >= $1",
161    )
162    .bind(since)
163    .fetch_one(pool)
164    .await
165    .with_context(|| "counting block_stream_process_id_mismatch")?;
166    if count > 0 {
167        warn!(
168            "block_stream_process_id_mismatch: {count} block(s) in the last hour disagree with their stream's process_id"
169        );
170    }
171    imetric!("block_stream_process_id_mismatch", "count", count as u64);
172    Ok(())
173}
174
175/// Counts `blocks` rows whose own `audience` disagrees with their stream's or process's
176/// `audience`, over the last hour -- the live-Postgres counterpart to the per-partition
177/// `block_audience_mismatch_excluded` metric. Built from [`audience_column_mismatch`], the
178/// same comparison `blocks_view.rs`'s exclusion predicate uses, so the two can never drift
179/// apart. A non-zero reading is not necessarily a bug (it may be a re-pointed ingestion
180/// credential), but it always means telemetry was silently dropped from `blocks` and its
181/// downstream views, so it is worth a `warn!` regardless. Reported as
182/// `imetric!("block_audience_mismatch_rows", "count", n)`.
183async fn measure_block_audience_mismatch_rows(
184    pool: &sqlx::PgPool,
185    since: DateTime<Utc>,
186) -> Result<()> {
187    let predicate = format!(
188        "({} OR {})",
189        audience_column_mismatch("b", "s"),
190        audience_column_mismatch("b", "p"),
191    );
192    let sql = format!(
193        "SELECT count(*) FROM blocks b
194         JOIN streams s ON s.stream_id = b.stream_id
195         JOIN processes p ON p.process_id = b.process_id
196         WHERE {predicate}
197         AND b.insert_time >= $1"
198    );
199    let count: i64 = sqlx::query_scalar(&sql)
200        .bind(since)
201        .fetch_one(pool)
202        .await
203        .with_context(|| "counting block_audience_mismatch_rows")?;
204    if count > 0 {
205        warn!(
206            "block_audience_mismatch_rows: {count} block(s) in the last hour disagree with their \
207             stream's or process's audience -- silently dropped from blocks/log_entries/measures/log_stats \
208             by the audience-mismatch predicate"
209        );
210    }
211    imetric!("block_audience_mismatch_rows", "count", count as u64);
212    Ok(())
213}
214
215/// task running once an hour to materialize recent partitions
216pub struct EveryHourTask {
217    pub lakehouse: Arc<LakehouseContext>,
218    pub view_registry: Arc<ViewRegistry>,
219    pub retention_days: i32,
220}
221
222#[async_trait]
223impl TaskCallback for EveryHourTask {
224    #[span_fn]
225    async fn run(&self, task_scheduled_time: DateTime<Utc>) -> Result<()> {
226        delete_old_data(self.lakehouse.lake(), self.retention_days).await?;
227        delete_expired_temporary_files(self.lakehouse.lake().clone()).await?;
228
229        let since = task_scheduled_time - TimeDelta::hours(1);
230        measure_block_stream_process_id_mismatch(&self.lakehouse.lake().db_pool, since).await?;
231        measure_block_audience_mismatch_rows(&self.lakehouse.lake().db_pool, since).await?;
232
233        let partition_time_delta = TimeDelta::hours(1);
234        let trunc_task_time = task_scheduled_time.duration_trunc(partition_time_delta)?;
235        let begin_range = trunc_task_time - (partition_time_delta * 2);
236        let end_range = trunc_task_time;
237        let views = sorted_views_from_registry(&self.view_registry);
238        materialize_all_views(
239            self.lakehouse.clone(),
240            views,
241            TimeRange::new(begin_range, end_range),
242            partition_time_delta,
243        )
244        .await
245    }
246}
247
248/// task running once a minute to materialize recent partitions
249pub struct EveryMinuteTask {
250    pub lakehouse: Arc<LakehouseContext>,
251    pub view_registry: Arc<ViewRegistry>,
252}
253
254#[async_trait]
255impl TaskCallback for EveryMinuteTask {
256    #[span_fn]
257    async fn run(&self, task_scheduled_time: DateTime<Utc>) -> Result<()> {
258        let partition_time_delta = TimeDelta::minutes(1);
259        let trunc_task_time = task_scheduled_time.duration_trunc(partition_time_delta)?;
260        let begin_range = trunc_task_time - (partition_time_delta * 2);
261        // we only try to process a single partition per view
262        let end_range = trunc_task_time - partition_time_delta;
263        let views = sorted_views_from_registry(&self.view_registry);
264        materialize_all_views(
265            self.lakehouse.clone(),
266            views,
267            TimeRange::new(begin_range, end_range),
268            partition_time_delta,
269        )
270        .await
271    }
272}
273
274/// task running once a second to materialize newest partitions
275pub struct EverySecondTask {
276    pub lakehouse: Arc<LakehouseContext>,
277    pub view_registry: Arc<ViewRegistry>,
278}
279
280#[async_trait]
281impl TaskCallback for EverySecondTask {
282    #[span_fn]
283    async fn run(&self, task_scheduled_time: DateTime<Utc>) -> Result<()> {
284        let delay = Utc::now() - task_scheduled_time;
285        if delay > TimeDelta::seconds(10) {
286            // we don't want to accumulate too much delay - the minutes task will fill the missing data
287            warn!("skipping `seconds` task, delay={delay}");
288            return Ok(());
289        }
290        let partition_time_delta = TimeDelta::seconds(1);
291        let trunc_task_time = task_scheduled_time.duration_trunc(partition_time_delta)?;
292        let begin_range = trunc_task_time - (partition_time_delta * 2);
293        // we only try to process a single partition per view
294        let end_range = trunc_task_time - partition_time_delta;
295        let views = sorted_views_from_registry(&self.view_registry);
296        materialize_all_views(
297            self.lakehouse.clone(),
298            views,
299            TimeRange::new(begin_range, end_range),
300            partition_time_delta,
301        )
302        .await
303    }
304}
305
306/// Logs the outcome of a completed cron task.
307///
308/// The result is triply nested: the outer `JoinError` reports a panicked or
309/// cancelled task, the inner `JoinError` comes from the spawned future, and the
310/// innermost `Result` is the task callback's own outcome. Any error at any layer
311/// is logged; a fully successful run is a no-op.
312fn log_task_result(res: Result<Result<Result<()>, JoinError>, JoinError>) {
313    match res {
314        Ok(Ok(Ok(()))) => {}
315        Ok(Ok(Err(e))) => error!("{e:?}"),
316        Ok(Err(e)) => error!("{e:?}"),
317        Err(e) => error!("{e:?}"),
318    }
319}
320
321/// Awaits and logs every in-flight task, returning once the set is empty.
322///
323/// Used to drain currently running tasks before the loop exits on shutdown, so
324/// their work completes rather than being dropped.
325async fn drain_task_set(task_set: &mut JoinSet<Result<Result<()>, JoinError>>) {
326    while let Some(res) = task_set.join_next().await {
327        log_task_result(res);
328    }
329}
330
331/// Runs a collection of `CronTask`s until `shutdown` fires.
332///
333/// When `shutdown` completes, the loop stops scheduling new tasks and drains
334/// any currently running tasks before returning.
335pub async fn run_tasks_forever<F>(mut tasks: Vec<CronTask>, max_parallelism: usize, shutdown: F)
336where
337    F: Future<Output = ()>,
338{
339    tokio::pin!(shutdown);
340    let mut task_set = JoinSet::new();
341    loop {
342        let mut next_task_run = Utc::now() + TimeDelta::days(2);
343        for task in &mut tasks {
344            if task.get_next_run() < Utc::now() {
345                task_set.spawn(task.spawn().await);
346                if task_set.len() >= max_parallelism {
347                    tokio::select! {
348                        res = task_set.join_next() => {
349                            if let Some(res) = res {
350                                log_task_result(res);
351                            }
352                        }
353                        _ = &mut shutdown => {
354                            drain_task_set(&mut task_set).await;
355                            return;
356                        }
357                    }
358                }
359            }
360            let task_next_run = task.get_next_run();
361            if task_next_run < next_task_run {
362                next_task_run = task_next_run;
363            }
364        }
365        let time_until_next_task = next_task_run - Utc::now();
366        if time_until_next_task > TimeDelta::zero() {
367            match time_until_next_task
368                .to_std()
369                .with_context(|| "delay.to_std")
370            {
371                Ok(wait) => {
372                    tokio::select! {
373                        _ = tokio::time::sleep(wait) => {}
374                        _ = &mut shutdown => {
375                            drain_task_set(&mut task_set).await;
376                            return;
377                        }
378                    }
379                }
380                Err(e) => warn!("{e:?}"),
381            }
382        } else {
383            // No sleep needed, but still poll the shutdown future so the loop
384            // can exit even when tasks run longer than their period.
385            tokio::select! {
386                biased;
387                _ = &mut shutdown => {
388                    drain_task_set(&mut task_set).await;
389                    return;
390                }
391                _ = tokio::task::yield_now() => {}
392            }
393        }
394    }
395}
396
397/// Retrieves a list of global views that have an associated update group.
398///
399/// This function filters the global views provided by the `view_factory`,
400/// returning only those that are part of an update group.
401pub fn get_global_views_with_update_group(view_factory: &ViewFactory) -> Vec<Arc<dyn View>> {
402    view_factory
403        .get_global_views()
404        .iter()
405        .filter(|v| v.get_update_group().is_some())
406        .cloned()
407        .collect()
408}
409
410/// Starts the maintenance daemon, which runs various scheduled tasks.
411///
412/// This function initializes and spawns several `CronTask`s for daily, hourly, minute,
413/// and second-based maintenance operations, such as data materialization and cleanup,
414/// plus a once-a-minute collector that samples the metadata Postgres's `pg_stat_*`
415/// views for self-observability. All runner loops react to `shutdown`: they stop
416/// scheduling and drain in-flight tasks. A deadline arm forces return after `grace`
417/// even if tasks haven't drained.
418///
419/// # Arguments
420///
421/// * `lakehouse` - The lakehouse context with shared metadata cache.
422/// * `view_registry` - The shared registry (base views plus every DDL-defined global view);
423///   each view-carrying task resolves and sorts its own view list from `view_registry.current()`
424///   fresh on every tick, so a view set created or dropped after the daemon started is picked up
425///   without a restart.
426/// * `retention_days` - Delete lake data older than this many days (retention horizon).
427/// * `shutdown` - Future that completes when the process should begin shutting down.
428/// * `grace` - Maximum time to wait for in-flight tasks after the shutdown signal.
429pub async fn daemon<F>(
430    lakehouse: Arc<LakehouseContext>,
431    view_registry: Arc<ViewRegistry>,
432    retention_days: i32,
433    shutdown: F,
434    grace: Duration,
435) -> Result<()>
436where
437    F: Future<Output = ()> + Send + 'static,
438{
439    use super::shutdown::ShutdownFanout;
440
441    // Loaded once, synchronously, before the cron tasks are spawned -- failing startup on error,
442    // the way `migrate_lakehouse` already does -- so the daemon never materializes with a
443    // DDL-defined view set (or the now-seeded `log_stats`) silently missing from `current()`.
444    view_registry
445        .reload()
446        .await
447        .with_context(|| "initial view_registry reload")?;
448
449    let every_day = CronTask::new(
450        String::from("every_day"),
451        TimeDelta::days(1),
452        TimeDelta::hours(4),
453        Arc::new(EveryDayTask {
454            lakehouse: lakehouse.clone(),
455            view_registry: view_registry.clone(),
456        }),
457    )?;
458    let every_hour = CronTask::new(
459        String::from("every_hour"),
460        TimeDelta::hours(1),
461        TimeDelta::minutes(10),
462        Arc::new(EveryHourTask {
463            lakehouse: lakehouse.clone(),
464            view_registry: view_registry.clone(),
465            retention_days,
466        }),
467    )?;
468    let every_minute = CronTask::new(
469        String::from("every minute"),
470        TimeDelta::minutes(1),
471        TimeDelta::seconds(30),
472        Arc::new(EveryMinuteTask {
473            lakehouse: lakehouse.clone(),
474            view_registry: view_registry.clone(),
475        }),
476    )?;
477    let pg_stats = CronTask::new(
478        String::from("pg_stats"),
479        TimeDelta::minutes(1),
480        TimeDelta::seconds(15), // staggered from the materialization tasks' 30s offset
481        Arc::new(PgStatsTask {
482            lakehouse: lakehouse.clone(),
483        }),
484    )?;
485    let every_second = CronTask::new(
486        String::from("every second"),
487        TimeDelta::seconds(1),
488        TimeDelta::milliseconds(500),
489        Arc::new(EverySecondTask {
490            lakehouse,
491            view_registry: view_registry.clone(),
492        }),
493    )?;
494
495    let fanout = ShutdownFanout::new(shutdown);
496    let grace_secs = grace.as_secs();
497
498    // Keeps `view_registry.current()` fresh for every task above, independent of their own
499    // schedules.
500    view_registry.spawn_refresh_task(fanout.subscribe());
501
502    let mut runners = tokio::task::JoinSet::new();
503    runners.spawn(run_tasks_forever(vec![every_day], 1, fanout.subscribe()));
504    runners.spawn(run_tasks_forever(vec![every_hour], 1, fanout.subscribe()));
505    runners.spawn(run_tasks_forever(vec![every_minute], 5, fanout.subscribe()));
506    runners.spawn(run_tasks_forever(vec![every_second], 5, fanout.subscribe()));
507    runners.spawn(run_tasks_forever(vec![pg_stats], 1, fanout.subscribe()));
508
509    let deadline = {
510        let d = fanout.subscribe();
511        async move {
512            d.await;
513            tokio::time::sleep(grace).await;
514        }
515    };
516
517    tokio::select! {
518        _ = runners.join_all() => {
519            info!("daemon drain completed");
520        }
521        _ = deadline => {
522            warn!("daemon grace period of {grace_secs}s elapsed with work still in flight");
523        }
524    }
525    Ok(())
526}