Skip to main content

bhtune_runtime/
retention.rs

1//! Age-based deletion of old tune runs.
2//!
3//! The actual `DELETE` lives in [`bhtune_db::models::TuneRunRow::delete_matching`] (a single
4//! statement, which SQLite already treats as its own transaction); this module owns the one
5//! thing `bhtune-db` deliberately doesn't -- turning "N days" into a cutoff timestamp and
6//! logging what happened, since `bhtune-db` has no logging dependency of its own (see its
7//! crate doc comment).
8//!
9//! [`sweep_retention`] is the single code path shared by every caller that enforces the
10//! policy, so "what a `bhtune history prune` run deletes", "what `crate::db::open`'s startup
11//! sweep deletes", and "what `bhtune-server`'s periodic timer deletes" can never disagree:
12//!
13//! - `crate::db::open` calls it once, synchronously, on every startup of both binaries.
14//!   A failure here is propagated (`?`), matching how that function already treats a
15//!   failed template-seed as fatal: a one-shot CLI invocation failing fast and clearly beats
16//!   silently skipping a maintenance step that might be masking a real database problem.
17//! - `bhtune-server`'s `main.rs` additionally calls it on a periodic timer for as long as the
18//!   process keeps running, so a long-lived server doesn't have to be restarted just to have
19//!   its retention policy re-applied. Unlike the startup call, a failure there is logged and
20//!   the timer keeps ticking -- crashing a process that's actively serving HTTP requests (and
21//!   possibly mid-tune) over a background housekeeping error would be a far worse outcome
22//!   than one skipped sweep.
23//! - `bhtune history prune`'s non-`--dry-run` path calls it directly for an
24//!   immediately-requested, possibly policy-overriding one-off sweep.
25
26use bhtune_db::SqlitePool;
27use bhtune_db::models::{TuneRunFilter, TuneRunRow};
28use chrono::{DateTime, Duration, Utc};
29
30/// The `started_at` cutoff for a `days`-day retention policy evaluated at `now`: runs
31/// started at or before this instant are in scope for deletion. Pulled out of
32/// [`sweep_retention`] so `commands::history::prune`'s `--dry-run` preview can compute and
33/// display the exact same cutoff its non-dry-run sibling would actually delete against,
34/// without needing a database handle to do it.
35pub fn cutoff_for(days: u32, now: DateTime<Utc>) -> DateTime<Utc> {
36    now - Duration::days(i64::from(days))
37}
38
39fn deletion_log_is_info(deleted: u64) -> bool {
40    deleted > 0
41}
42
43/// Deletes every tune run with `started_at` at or before the `days`-day cutoff (see
44/// [`cutoff_for`]), along with -- via `ON DELETE CASCADE` -- its samples, results, and
45/// write-back audit rows. Returns the number of runs deleted.
46///
47/// Logs at INFO when something was actually deleted (so deletions are never silent) and at
48/// DEBUG otherwise, so a no-op sweep -- the common
49/// case for an install well under its retention window -- doesn't add log noise at the
50/// default level.
51pub async fn sweep_retention(
52    pool: &SqlitePool,
53    days: u32,
54    now: DateTime<Utc>,
55) -> anyhow::Result<u64> {
56    let cutoff = cutoff_for(days, now);
57    let deleted =
58        TuneRunRow::delete_matching(pool, &TuneRunFilter::default().with_started_before(cutoff))
59            .await?;
60    if deletion_log_is_info(deleted) {
61        tracing::info!(
62            deleted,
63            retention_days = days,
64            %cutoff,
65            "deleted tune runs past the configured retention policy"
66        );
67    } else {
68        tracing::debug!(retention_days = days, %cutoff, "retention sweep found no runs to delete");
69    }
70    Ok(deleted)
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use bhtune_core::{ControllerType, LoopConfig, ProcessType, built_in_templates};
77    use bhtune_db::connect_in_memory;
78    use bhtune_db::models::{TemplateOrigin, TuneDriver};
79
80    fn sample_config() -> LoopConfig {
81        LoopConfig {
82            process_type: ProcessType::Flow,
83            controller_type: ControllerType::Pi,
84            relay_amp_percent: 5.0,
85            num_cycles_skip: 1,
86            num_cycles_count: 2,
87            noise_protection_secs: 3,
88            mrft_delay_secs: 0,
89        }
90    }
91
92    async fn start_run_at(pool: &SqlitePool, started_at: DateTime<Utc>) -> i64 {
93        let template = built_in_templates().remove(0);
94        let tags = bhtune_core::LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &template);
95        TuneRunRow::start(
96            pool,
97            None,
98            "LIC-X",
99            TuneDriver::Simulator,
100            sample_config(),
101            TemplateOrigin::Builtin,
102            &template,
103            &tags,
104            started_at,
105        )
106        .await
107        .unwrap()
108        .id
109    }
110
111    #[test]
112    fn cutoff_for_subtracts_the_given_number_of_days() {
113        let now = Utc::now();
114        assert_eq!(cutoff_for(30, now), now - Duration::days(30));
115        assert_eq!(cutoff_for(0, now), now);
116    }
117
118    #[test]
119    fn deletion_log_is_info_only_when_runs_were_deleted() {
120        assert!(!deletion_log_is_info(0));
121        assert!(deletion_log_is_info(1));
122        assert!(deletion_log_is_info(u64::MAX));
123    }
124
125    #[tokio::test]
126    async fn sweep_retention_deletes_only_runs_at_or_before_the_cutoff() {
127        let pool = connect_in_memory().await.unwrap();
128        let now = Utc::now();
129        let old = start_run_at(&pool, now - Duration::days(45)).await;
130        let recent = start_run_at(&pool, now - Duration::days(1)).await;
131
132        let deleted = sweep_retention(&pool, 30, now).await.unwrap();
133        assert_eq!(deleted, 1);
134        assert!(TuneRunRow::get(&pool, old).await.unwrap().is_none());
135        assert!(TuneRunRow::get(&pool, recent).await.unwrap().is_some());
136    }
137
138    #[tokio::test]
139    async fn sweep_retention_with_nothing_past_the_cutoff_deletes_nothing() {
140        let pool = connect_in_memory().await.unwrap();
141        let now = Utc::now();
142        let recent = start_run_at(&pool, now).await;
143
144        let deleted = sweep_retention(&pool, 30, now).await.unwrap();
145        assert_eq!(deleted, 0);
146        assert!(TuneRunRow::get(&pool, recent).await.unwrap().is_some());
147    }
148}