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}