Skip to main content

bhtune_cli/
retention.rs

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