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}