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::delete::delete_old_data;
6use micromegas_analytics::lakehouse::batch_update::materialize_partition_range;
7use micromegas_analytics::lakehouse::lakehouse_context::LakehouseContext;
8use micromegas_analytics::lakehouse::partition_cache::PartitionCache;
9use micromegas_analytics::lakehouse::temp::delete_expired_temporary_files;
10use micromegas_analytics::lakehouse::view::View;
11use micromegas_analytics::lakehouse::view_factory::ViewFactory;
12use micromegas_analytics::response_writer::ResponseWriter;
13use micromegas_analytics::time::TimeRange;
14use micromegas_tracing::prelude::*;
15use std::future::Future;
16use std::sync::Arc;
17use std::time::Duration;
18use tokio::task::{JoinError, JoinSet};
19
20use super::cron_task::{CronTask, TaskCallback};
21use super::pg_stats::PgStatsTask;
22
23type Views = Arc<Vec<Arc<dyn View>>>;
24
25/// Materializes all views within a given time range.
26///
27/// This function iterates through the provided views, materializing partitions
28/// for each view within the specified `insert_range` and `partition_time_delta`.
29#[span_fn]
30pub async fn materialize_all_views(
31    lakehouse: Arc<LakehouseContext>,
32    views: Views,
33    insert_range: TimeRange,
34    partition_time_delta: TimeDelta,
35) -> Result<()> {
36    let mut last_group = views.first().unwrap().get_update_group();
37    let mut partitions_all_views = Arc::new(
38        PartitionCache::fetch_overlapping_insert_range(&lakehouse.lake().db_pool, insert_range)
39            .await?,
40    );
41    let null_response_writer = Arc::new(ResponseWriter::new(None));
42    for view in &*views {
43        if view.get_update_group() != last_group {
44            // views in the same group should have no inter-dependencies
45            last_group = view.get_update_group();
46            partitions_all_views = Arc::new(
47                PartitionCache::fetch_overlapping_insert_range(
48                    // we are fetching more partitions than we need, could be optimized
49                    &lakehouse.lake().db_pool,
50                    insert_range,
51                )
52                .await?,
53            );
54        }
55        materialize_partition_range(
56            partitions_all_views.clone(),
57            lakehouse.clone(),
58            view.clone(),
59            insert_range,
60            partition_time_delta,
61            null_response_writer.clone(),
62        )
63        .await?;
64    }
65    Ok(())
66}
67
68/// task running once a day to materialize older partitions
69pub struct EveryDayTask {
70    pub lakehouse: Arc<LakehouseContext>,
71    pub views: Views,
72}
73
74#[async_trait]
75impl TaskCallback for EveryDayTask {
76    #[span_fn]
77    async fn run(&self, task_scheduled_time: DateTime<Utc>) -> Result<()> {
78        let partition_time_delta = TimeDelta::days(1);
79        let trunc_task_time = task_scheduled_time.duration_trunc(partition_time_delta)?;
80        let begin_range = trunc_task_time - (partition_time_delta * 2);
81        let end_range = trunc_task_time;
82        materialize_all_views(
83            self.lakehouse.clone(),
84            self.views.clone(),
85            TimeRange::new(begin_range, end_range),
86            partition_time_delta,
87        )
88        .await
89    }
90}
91
92/// task running once an hour to materialize recent partitions
93pub struct EveryHourTask {
94    pub lakehouse: Arc<LakehouseContext>,
95    pub views: Views,
96    pub retention_days: i32,
97}
98
99#[async_trait]
100impl TaskCallback for EveryHourTask {
101    #[span_fn]
102    async fn run(&self, task_scheduled_time: DateTime<Utc>) -> Result<()> {
103        delete_old_data(self.lakehouse.lake(), self.retention_days).await?;
104        delete_expired_temporary_files(self.lakehouse.lake().clone()).await?;
105
106        let partition_time_delta = TimeDelta::hours(1);
107        let trunc_task_time = task_scheduled_time.duration_trunc(partition_time_delta)?;
108        let begin_range = trunc_task_time - (partition_time_delta * 2);
109        let end_range = trunc_task_time;
110        materialize_all_views(
111            self.lakehouse.clone(),
112            self.views.clone(),
113            TimeRange::new(begin_range, end_range),
114            partition_time_delta,
115        )
116        .await
117    }
118}
119
120/// task running once a minute to materialize recent partitions
121pub struct EveryMinuteTask {
122    pub lakehouse: Arc<LakehouseContext>,
123    pub views: Views,
124}
125
126#[async_trait]
127impl TaskCallback for EveryMinuteTask {
128    #[span_fn]
129    async fn run(&self, task_scheduled_time: DateTime<Utc>) -> Result<()> {
130        let partition_time_delta = TimeDelta::minutes(1);
131        let trunc_task_time = task_scheduled_time.duration_trunc(partition_time_delta)?;
132        let begin_range = trunc_task_time - (partition_time_delta * 2);
133        // we only try to process a single partition per view
134        let end_range = trunc_task_time - partition_time_delta;
135        materialize_all_views(
136            self.lakehouse.clone(),
137            self.views.clone(),
138            TimeRange::new(begin_range, end_range),
139            partition_time_delta,
140        )
141        .await
142    }
143}
144
145/// task running once a second to materialize newest partitions
146pub struct EverySecondTask {
147    pub lakehouse: Arc<LakehouseContext>,
148    pub views: Views,
149}
150
151#[async_trait]
152impl TaskCallback for EverySecondTask {
153    #[span_fn]
154    async fn run(&self, task_scheduled_time: DateTime<Utc>) -> Result<()> {
155        let delay = Utc::now() - task_scheduled_time;
156        if delay > TimeDelta::seconds(10) {
157            // we don't want to accumulate too much delay - the minutes task will fill the missing data
158            warn!("skipping `seconds` task, delay={delay}");
159            return Ok(());
160        }
161        let partition_time_delta = TimeDelta::seconds(1);
162        let trunc_task_time = task_scheduled_time.duration_trunc(partition_time_delta)?;
163        let begin_range = trunc_task_time - (partition_time_delta * 2);
164        // we only try to process a single partition per view
165        let end_range = trunc_task_time - partition_time_delta;
166        materialize_all_views(
167            self.lakehouse.clone(),
168            self.views.clone(),
169            TimeRange::new(begin_range, end_range),
170            partition_time_delta,
171        )
172        .await
173    }
174}
175
176/// Logs the outcome of a completed cron task.
177///
178/// The result is triply nested: the outer `JoinError` reports a panicked or
179/// cancelled task, the inner `JoinError` comes from the spawned future, and the
180/// innermost `Result` is the task callback's own outcome. Any error at any layer
181/// is logged; a fully successful run is a no-op.
182fn log_task_result(res: Result<Result<Result<()>, JoinError>, JoinError>) {
183    match res {
184        Ok(Ok(Ok(()))) => {}
185        Ok(Ok(Err(e))) => error!("{e:?}"),
186        Ok(Err(e)) => error!("{e:?}"),
187        Err(e) => error!("{e:?}"),
188    }
189}
190
191/// Awaits and logs every in-flight task, returning once the set is empty.
192///
193/// Used to drain currently running tasks before the loop exits on shutdown, so
194/// their work completes rather than being dropped.
195async fn drain_task_set(task_set: &mut JoinSet<Result<Result<()>, JoinError>>) {
196    while let Some(res) = task_set.join_next().await {
197        log_task_result(res);
198    }
199}
200
201/// Runs a collection of `CronTask`s until `shutdown` fires.
202///
203/// When `shutdown` completes, the loop stops scheduling new tasks and drains
204/// any currently running tasks before returning.
205pub async fn run_tasks_forever<F>(mut tasks: Vec<CronTask>, max_parallelism: usize, shutdown: F)
206where
207    F: Future<Output = ()>,
208{
209    tokio::pin!(shutdown);
210    let mut task_set = JoinSet::new();
211    loop {
212        let mut next_task_run = Utc::now() + TimeDelta::days(2);
213        for task in &mut tasks {
214            if task.get_next_run() < Utc::now() {
215                task_set.spawn(task.spawn().await);
216                if task_set.len() >= max_parallelism {
217                    tokio::select! {
218                        res = task_set.join_next() => {
219                            if let Some(res) = res {
220                                log_task_result(res);
221                            }
222                        }
223                        _ = &mut shutdown => {
224                            drain_task_set(&mut task_set).await;
225                            return;
226                        }
227                    }
228                }
229            }
230            let task_next_run = task.get_next_run();
231            if task_next_run < next_task_run {
232                next_task_run = task_next_run;
233            }
234        }
235        let time_until_next_task = next_task_run - Utc::now();
236        if time_until_next_task > TimeDelta::zero() {
237            match time_until_next_task
238                .to_std()
239                .with_context(|| "delay.to_std")
240            {
241                Ok(wait) => {
242                    tokio::select! {
243                        _ = tokio::time::sleep(wait) => {}
244                        _ = &mut shutdown => {
245                            drain_task_set(&mut task_set).await;
246                            return;
247                        }
248                    }
249                }
250                Err(e) => warn!("{e:?}"),
251            }
252        } else {
253            // No sleep needed, but still poll the shutdown future so the loop
254            // can exit even when tasks run longer than their period.
255            tokio::select! {
256                biased;
257                _ = &mut shutdown => {
258                    drain_task_set(&mut task_set).await;
259                    return;
260                }
261                _ = tokio::task::yield_now() => {}
262            }
263        }
264    }
265}
266
267/// Retrieves a list of global views that have an associated update group.
268///
269/// This function filters the global views provided by the `view_factory`,
270/// returning only those that are part of an update group.
271pub fn get_global_views_with_update_group(view_factory: &ViewFactory) -> Vec<Arc<dyn View>> {
272    view_factory
273        .get_global_views()
274        .iter()
275        .filter(|v| v.get_update_group().is_some())
276        .cloned()
277        .collect()
278}
279
280/// Starts the maintenance daemon, which runs various scheduled tasks.
281///
282/// This function initializes and spawns several `CronTask`s for daily, hourly, minute,
283/// and second-based maintenance operations, such as data materialization and cleanup,
284/// plus a once-a-minute collector that samples the metadata Postgres's `pg_stat_*`
285/// views for self-observability. All runner loops react to `shutdown`: they stop
286/// scheduling and drain in-flight tasks. A deadline arm forces return after `grace`
287/// even if tasks haven't drained.
288///
289/// # Arguments
290///
291/// * `lakehouse` - The lakehouse context with shared metadata cache.
292/// * `views_to_update` - A vector of views that need to be updated by the daemon.
293/// * `retention_days` - Delete lake data older than this many days (retention horizon).
294/// * `shutdown` - Future that completes when the process should begin shutting down.
295/// * `grace` - Maximum time to wait for in-flight tasks after the shutdown signal.
296pub async fn daemon<F>(
297    lakehouse: Arc<LakehouseContext>,
298    mut views_to_update: Vec<Arc<dyn View>>,
299    retention_days: i32,
300    shutdown: F,
301    grace: Duration,
302) -> Result<()>
303where
304    F: Future<Output = ()> + Send + 'static,
305{
306    use super::shutdown::ShutdownFanout;
307
308    views_to_update.sort_by_key(|v| v.get_update_group().unwrap_or(i32::MAX));
309    let views = Arc::new(views_to_update);
310
311    let every_day = CronTask::new(
312        String::from("every_day"),
313        TimeDelta::days(1),
314        TimeDelta::hours(4),
315        Arc::new(EveryDayTask {
316            lakehouse: lakehouse.clone(),
317            views: views.clone(),
318        }),
319    )?;
320    let every_hour = CronTask::new(
321        String::from("every_hour"),
322        TimeDelta::hours(1),
323        TimeDelta::minutes(10),
324        Arc::new(EveryHourTask {
325            lakehouse: lakehouse.clone(),
326            views: views.clone(),
327            retention_days,
328        }),
329    )?;
330    let every_minute = CronTask::new(
331        String::from("every minute"),
332        TimeDelta::minutes(1),
333        TimeDelta::seconds(30),
334        Arc::new(EveryMinuteTask {
335            lakehouse: lakehouse.clone(),
336            views: views.clone(),
337        }),
338    )?;
339    let pg_stats = CronTask::new(
340        String::from("pg_stats"),
341        TimeDelta::minutes(1),
342        TimeDelta::seconds(15), // staggered from the materialization tasks' 30s offset
343        Arc::new(PgStatsTask {
344            lakehouse: lakehouse.clone(),
345        }),
346    )?;
347    let every_second = CronTask::new(
348        String::from("every second"),
349        TimeDelta::seconds(1),
350        TimeDelta::milliseconds(500),
351        Arc::new(EverySecondTask { lakehouse, views }),
352    )?;
353
354    let fanout = ShutdownFanout::new(shutdown);
355    let grace_secs = grace.as_secs();
356
357    let mut runners = tokio::task::JoinSet::new();
358    runners.spawn(run_tasks_forever(vec![every_day], 1, fanout.subscribe()));
359    runners.spawn(run_tasks_forever(vec![every_hour], 1, fanout.subscribe()));
360    runners.spawn(run_tasks_forever(vec![every_minute], 5, fanout.subscribe()));
361    runners.spawn(run_tasks_forever(vec![every_second], 5, fanout.subscribe()));
362    runners.spawn(run_tasks_forever(vec![pg_stats], 1, fanout.subscribe()));
363
364    let deadline = {
365        let d = fanout.subscribe();
366        async move {
367            d.await;
368            tokio::time::sleep(grace).await;
369        }
370    };
371
372    tokio::select! {
373        _ = runners.join_all() => {
374            info!("daemon drain completed");
375        }
376        _ = deadline => {
377            warn!("daemon grace period of {grace_secs}s elapsed with work still in flight");
378        }
379    }
380    Ok(())
381}