Skip to main content

bhtune_runtime/
db.rs

1//! Opens the CLI's database for mutating commands, seeds the built-in and user-catalog
2//! DCS/PLC templates, and runs the retention sweep. Read-only commands such as
3//! `bhtune check` use a separate connection that skips bootstrap and retention.
4
5use std::path::Path;
6
7use bhtune_core::DcsTemplate;
8use bhtune_db::SqlitePool;
9use bhtune_db::models::TemplateOrigin;
10
11/// Opens (creating if necessary) the database at `path`, running migrations, then upserts the
12/// built-in templates via [`bhtune_db::seed_builtin_templates`] so a fresh database is
13/// immediately usable without a separate setup step. If `user_templates` is `Some` (the
14/// caller found and parsed a user catalog file -- see `crate::config::load_user_templates`),
15/// those templates are additionally upserted with
16/// [`TemplateOrigin::Catalog`] via [`bhtune_db::seed_templates`]. `None` means no user
17/// catalog file was found at all, which is not an error and simply skips this second seed
18/// pass -- the common case, since most installs never create `templates.toml`.
19///
20/// If `retention_days` is `Some` (see `crate::config::resolve_retention_days`), also runs
21/// [`crate::retention::sweep_retention`] once before returning, shared by both binaries since
22/// both call this function.
23/// `None` (the default) skips the sweep entirely: no query, no log line, nothing -- matching
24/// "ships disabled by default (retain forever)". A sweep failure is propagated (`?`) rather
25/// than logged-and-ignored: unlike `bhtune-server`'s periodic re-sweep (which must not crash
26/// a process that may be mid-tune just because a housekeeping query failed), this runs before
27/// any command has done anything yet, so failing fast with a clear error is strictly better
28/// than silently proceeding on what might be a genuinely broken database.
29///
30/// Creates `path`'s parent directory tree first: `bhtune_db::connect`'s
31/// `SqliteConnectOptions::create_if_missing(true)` only creates the database *file*, not any
32/// missing parent directories -- necessary now that the default database path (see
33/// `crate::config::default_db_path_from`) is a nested, not-yet-existing platform directory
34/// (e.g. `~/.local/share/bhtune/`) on a genuinely fresh install.
35pub async fn open(
36    path: &Path,
37    user_templates: Option<Vec<DcsTemplate>>,
38    retention_days: Option<u32>,
39) -> anyhow::Result<SqlitePool> {
40    open_with_orphan_recovery(path, user_templates, retention_days, true).await
41}
42
43/// Opens the Demo database without running the Full-mode live-orphan recovery sweep.
44///
45/// Demo uses visitor-owned simulator runs only; its recovery and capability policy is
46/// intentionally separate from live OPC DA recovery.
47pub async fn open_demo(
48    path: &Path,
49    user_templates: Option<Vec<DcsTemplate>>,
50    retention_days: Option<u32>,
51) -> anyhow::Result<SqlitePool> {
52    open_with_orphan_recovery(path, user_templates, retention_days, false).await
53}
54
55async fn open_with_orphan_recovery(
56    path: &Path,
57    user_templates: Option<Vec<DcsTemplate>>,
58    retention_days: Option<u32>,
59    recover_live_orphans: bool,
60) -> anyhow::Result<SqlitePool> {
61    tracing::info!(db_path = %path.display(), "opening database");
62    ensure_parent_dir(path)?;
63    let pool = bhtune_db::connect(path).await?;
64    if recover_live_orphans {
65        let report = crate::tune::recover_startup_orphans(&pool).await?;
66        if report.marked_orphaned > 0
67            || report.interrupted_recoveries > 0
68            || report.retired_unrecoverable > 0
69            || report.skipped_for_live_owner
70        {
71            tracing::warn!(
72                examined = report.examined,
73                marked_orphaned = report.marked_orphaned,
74                recoverable = report.recoverable,
75                interrupted_recoveries = report.interrupted_recoveries,
76                retired_unrecoverable = report.retired_unrecoverable,
77                skipped_for_live_owner = report.skipped_for_live_owner,
78                exports = ?report.exports,
79                "completed live-orphan startup sweep"
80            );
81        }
82    }
83    let now = chrono::Utc::now();
84    let seeded = bhtune_db::seed_builtin_templates(&pool, now).await?;
85    tracing::debug!(templates = seeded.len(), "seeded built-in DCS templates");
86    if let Some(templates) = user_templates {
87        let seeded =
88            bhtune_db::seed_templates(&pool, templates, TemplateOrigin::Catalog, now).await?;
89        let count = seeded.len();
90        tracing::debug!(templates = count, "seeded user catalog DCS templates");
91    }
92    if let Some(days) = retention_days {
93        crate::retention::sweep_retention(&pool, days, now).await?;
94    }
95    Ok(pool)
96}
97
98/// Opens an existing database for inspection without creating directories, applying
99/// migrations, seeding templates, or running retention.
100pub async fn open_read_only(path: &Path) -> anyhow::Result<Option<SqlitePool>> {
101    if !tokio::fs::try_exists(path).await? {
102        return Ok(None);
103    }
104    Ok(Some(bhtune_db::connect_read_only(path).await?))
105}
106
107/// Creates `path`'s parent directory tree if it doesn't already exist. A no-op (not an
108/// error) for a bare filename with no directory component at all -- `Path::parent()` returns
109/// `Some("")` in that case, and `std::fs::create_dir_all("")` is a documented no-op success,
110/// so this never needs to special-case "no parent" separately from "empty parent".
111fn ensure_parent_dir(path: &Path) -> anyhow::Result<()> {
112    path.parent()
113        .map(|parent| {
114            std::fs::create_dir_all(parent)
115                .map_err(|e| anyhow::anyhow!("failed to create database directory {parent:?}: {e}"))
116        })
117        .transpose()
118        .map(|_| ())
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use bhtune_db::models::DcsTemplateRow;
125
126    fn sample_catalog_template() -> DcsTemplate {
127        let mut template = bhtune_core::built_in_templates().remove(0);
128        template.name = "Test Catalog Template".to_string();
129        template
130    }
131
132    #[tokio::test]
133    async fn open_seeds_builtin_templates_on_a_fresh_database() {
134        let dir = tempfile::tempdir().unwrap();
135        let path = dir.path().join("bhtune.db");
136        let pool = open(&path, None, None).await.unwrap();
137        let templates = bhtune_db::models::DcsTemplateRow::list(&pool)
138            .await
139            .unwrap();
140        assert_eq!(templates.len(), 4);
141    }
142
143    #[tokio::test]
144    async fn open_read_only_does_not_create_a_missing_database() {
145        let dir = tempfile::tempdir().unwrap();
146        let path = dir.path().join("missing.db");
147
148        assert!(open_read_only(&path).await.unwrap().is_none());
149        assert!(!path.exists());
150    }
151
152    #[tokio::test]
153    async fn open_read_only_opens_an_existing_database_without_seeding() {
154        let dir = tempfile::tempdir().unwrap();
155        let path = dir.path().join("existing.db");
156        bhtune_db::connect(&path).await.unwrap().close().await;
157
158        let pool = open_read_only(&path).await.unwrap().unwrap();
159        assert!(DcsTemplateRow::list(&pool).await.unwrap().is_empty());
160        pool.close().await;
161    }
162
163    #[tokio::test]
164    async fn open_is_idempotent_across_repeated_calls() {
165        let dir = tempfile::tempdir().unwrap();
166        let path = dir.path().join("bhtune.db");
167        open(&path, None, None).await.unwrap();
168        let pool = open(&path, None, None).await.unwrap();
169        let templates = bhtune_db::models::DcsTemplateRow::list(&pool)
170            .await
171            .unwrap();
172        assert_eq!(templates.len(), 4);
173    }
174
175    #[tokio::test]
176    async fn open_keeps_running_when_a_live_owner_blocks_the_startup_sweep() {
177        let dir = tempfile::tempdir().unwrap();
178        let path = dir.path().join("bhtune.db");
179        let pool = bhtune_db::connect(&path).await.unwrap();
180        let owner = crate::live_ownership::LiveOperationGuard::acquire(
181            &pool,
182            None,
183            bhtune_db::models::LiveOperationKind::OpcWrite,
184            "127.0.0.1:7602",
185            "Mock.Kepware.Sim",
186            "Simulation.Examples.MV",
187            Some(r#"{"kind":"opc_write","state":"pending"}"#.to_string()),
188        )
189        .await
190        .unwrap();
191
192        let reopened = open(&path, None, None).await.unwrap();
193        owner.release().await.unwrap();
194        reopened.close().await;
195        pool.close().await;
196    }
197
198    #[tokio::test]
199    async fn open_creates_missing_nested_parent_directories() {
200        let dir = tempfile::tempdir().unwrap();
201        let path = dir.path().join("nested").join("deeper").join("bhtune.db");
202        assert!(!path.parent().unwrap().exists());
203        let pool = open(&path, None, None).await.unwrap();
204        assert!(path.exists());
205        let templates = bhtune_db::models::DcsTemplateRow::list(&pool)
206            .await
207            .unwrap();
208        assert_eq!(templates.len(), 4);
209    }
210
211    #[tokio::test]
212    async fn open_seeds_user_catalog_templates_with_catalog_origin_when_provided() {
213        let dir = tempfile::tempdir().unwrap();
214        let path = dir.path().join("bhtune.db");
215        let pool = open(&path, Some(vec![sample_catalog_template()]), None)
216            .await
217            .unwrap();
218        let templates = DcsTemplateRow::list(&pool).await.unwrap();
219        assert_eq!(templates.len(), 5);
220        let seeded = templates
221            .iter()
222            .find(|t| t.template.name == "Test Catalog Template")
223            .expect("user catalog template should have been seeded");
224        assert_eq!(seeded.origin, TemplateOrigin::Catalog);
225    }
226
227    #[tokio::test]
228    async fn open_reseeding_the_same_user_catalog_is_idempotent() {
229        let dir = tempfile::tempdir().unwrap();
230        let path = dir.path().join("bhtune.db");
231        open(&path, Some(vec![sample_catalog_template()]), None)
232            .await
233            .unwrap();
234        let pool = open(&path, Some(vec![sample_catalog_template()]), None)
235            .await
236            .unwrap();
237        let templates = DcsTemplateRow::list(&pool).await.unwrap();
238        assert_eq!(templates.len(), 5);
239    }
240
241    #[tokio::test]
242    async fn open_sweeps_retention_on_startup_when_a_policy_is_configured() {
243        use bhtune_core::{ControllerType, LoopConfig, LoopTags, ProcessType};
244        use bhtune_db::models::{TuneDriver, TuneRunRow};
245
246        let dir = tempfile::tempdir().unwrap();
247        let path = dir.path().join("bhtune.db");
248        // First open with no retention policy, so the old run survives long enough to be
249        // created; a policy of `None` must never delete anything.
250        let pool = open(&path, None, None).await.unwrap();
251        let template = bhtune_core::built_in_templates().remove(0);
252        let tags = LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &template);
253        let config = LoopConfig {
254            process_type: ProcessType::Flow,
255            controller_type: ControllerType::Pi,
256            relay_amp_percent: 5.0,
257            num_cycles_skip: 1,
258            num_cycles_count: 2,
259            noise_protection_secs: 3,
260            mrft_delay_secs: 0,
261        };
262        let old_started_at = chrono::Utc::now() - chrono::Duration::days(400);
263        let old_run = TuneRunRow::start(
264            &pool,
265            None,
266            "LIC-X",
267            TuneDriver::Simulator,
268            config,
269            TemplateOrigin::Builtin,
270            &template,
271            &tags,
272            old_started_at,
273        )
274        .await
275        .unwrap();
276        pool.close().await;
277
278        // Reopening with a 30-day retention policy must delete the 400-day-old run.
279        let pool = open(&path, None, Some(30)).await.unwrap();
280        assert!(TuneRunRow::get(&pool, old_run.id).await.unwrap().is_none());
281    }
282
283    #[test]
284    fn ensure_parent_dir_is_a_no_op_for_a_bare_filename() {
285        // Doesn't touch the real filesystem at all -- `Path::new("bhtune.db").parent()` is
286        // `Some("")`, and `create_dir_all("")` is a documented no-op success -- so this is
287        // safe to call regardless of the test process's current working directory.
288        ensure_parent_dir(Path::new("bhtune.db")).unwrap();
289    }
290}