1use std::net::SocketAddr;
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, RwLock};
16use std::time::Duration;
17
18use bhtune_runtime::{config, db, logging};
19
20#[cfg(unix)]
21use anyhow::Context;
22
23use crate::active_run::ActiveRun;
24use crate::{AppState, build_router};
25
26const SHUTDOWN_RUN_CANCEL_TIMEOUT: Duration = Duration::from_secs(35);
31
32const RETENTION_SWEEP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
38
39pub struct BoundServer {
46 listener: tokio::net::TcpListener,
47 app: axum::Router,
48 active_run: ActiveRun,
49 log_guard: anyhow::Result<tracing_appender::non_blocking::WorkerGuard>,
55}
56
57pub async fn build_server(config_path: Option<&Path>) -> anyhow::Result<BoundServer> {
65 let loaded_config = config::load_config_store(config_path)
66 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
67 let config = loaded_config.config.clone();
68 let mode =
69 config::resolve_server_mode(std::env::var("BHTUNE_SERVER_MODE").ok().as_deref(), &config)
70 .map_err(|error| anyhow::anyhow!(error))?;
71 let demo_policy = if mode == config::ServerMode::Demo {
72 config::resolve_demo_policy_from_config(&config).map_err(|error| anyhow::anyhow!(error))?
73 } else {
74 config::DemoPolicy::default()
75 };
76 let bind_addr = config::resolve_bind_addr(std::env::var("BHTUNE_BIND").ok(), &config);
77 let addr: SocketAddr = bind_addr
78 .parse()
79 .map_err(|e| anyhow::anyhow!("invalid bind address '{bind_addr}': {e}"))?;
80 let allowed_origin = config::resolve_origin(
81 std::env::var("BHTUNE_ORIGIN").ok(),
82 &config,
83 &bind_addr,
84 mode,
85 )
86 .map_err(|error| anyhow::anyhow!(error))?;
87 if mode == config::ServerMode::Demo {
88 config::validate_demo_trusted_proxy(config.trusted_proxy.as_deref())
89 .map_err(|error| anyhow::anyhow!(error))?;
90 }
91
92 let default_log_dir = config::default_log_dir_from(
93 std::env::var("XDG_DATA_HOME").ok().as_deref(),
94 std::env::var("HOME").ok().as_deref(),
95 std::env::var("APPDATA").ok().as_deref(),
96 cfg!(target_os = "windows"),
97 );
98 let log_settings = logging::resolve_log_settings(
99 std::env::var("RUST_LOG").ok(),
100 None,
101 None,
102 None,
103 &config.log,
104 &default_log_dir,
105 );
106 let log_guard = logging::init_tracing(&log_settings);
107
108 let db_path = config::resolve_db_path(
109 std::env::var("BHTUNE_DB").ok().map(PathBuf::from),
110 &config,
111 std::env::var("XDG_DATA_HOME").ok().as_deref(),
112 std::env::var("HOME").ok().as_deref(),
113 std::env::var("APPDATA").ok().as_deref(),
114 cfg!(target_os = "windows"),
115 );
116 let user_templates = config::load_user_templates(
117 std::env::var("BHTUNE_TEMPLATES").ok().map(PathBuf::from),
118 &config,
119 std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
120 std::env::var("HOME").ok().as_deref(),
121 std::env::var("APPDATA").ok().as_deref(),
122 cfg!(target_os = "windows"),
123 )?;
124 let retention_days = config::resolve_retention_days(
125 std::env::var("BHTUNE_RETENTION_DAYS")
126 .ok()
127 .and_then(|s| s.parse().ok()),
128 &config,
129 );
130 let pool = if mode == config::ServerMode::Demo {
131 db::open_demo(&db_path, user_templates, retention_days).await?
132 } else {
133 db::open(&db_path, user_templates, retention_days).await?
134 };
135 if mode == config::ServerMode::Demo {
136 let now = chrono::Utc::now();
137 bhtune_db::models::DemoSessionRow::recover_running_demo_runs(&pool, now).await?;
138 bhtune_db::models::DemoSessionRow::cleanup_expired(&pool, now).await?;
139 bhtune_db::models::TuneRunRow::prune_terminal_demo_owned(
140 &pool,
141 demo_policy.retained_runs_per_visitor,
142 )
143 .await?;
144 }
145 let preflight_database = if mode == config::ServerMode::Full {
146 db::open_read_only(&db_path).await?
147 } else {
148 None
149 };
150
151 let config_store = Arc::new(RwLock::new(loaded_config));
152 spawn_retention_sweeper(pool.clone(), config_store.clone());
153
154 let state = AppState::for_mode_with_network_config(
155 pool,
156 config_store,
157 mode,
158 demo_policy,
159 allowed_origin,
160 config.trusted_proxy.clone(),
161 )
162 .with_preflight_database(preflight_database);
163 if mode == config::ServerMode::Demo {
164 spawn_demo_cleanup(state.clone());
165 }
166 let active_run = state.active_run.clone();
167 let app = build_router(state);
168 let listener = tokio::net::TcpListener::bind(addr).await?;
169 let local_addr = listener.local_addr()?;
174 tracing::info!(%local_addr, "bhtune-server listening");
175 println!("bhtune-server listening on http://{local_addr}");
176
177 Ok(BoundServer {
178 listener,
179 app,
180 active_run,
181 log_guard,
182 })
183}
184
185pub async fn serve(
190 server: BoundServer,
191 shutdown: impl Future<Output = ()> + Send + 'static,
192) -> anyhow::Result<()> {
193 let BoundServer {
199 listener,
200 app,
201 active_run,
202 log_guard: _log_guard,
203 } = server;
204
205 serve_http(
206 axum::serve(
207 listener,
208 app.into_make_service_with_connect_info::<SocketAddr>(),
209 )
210 .with_graceful_shutdown(shutdown),
211 active_run,
212 )
213 .await
214}
215
216async fn serve_http(
217 server: impl std::future::IntoFuture<Output = std::io::Result<()>>,
218 active_run: ActiveRun,
219) -> anyhow::Result<()> {
220 server.into_future().await?;
221 active_run
226 .cancel_and_wait(SHUTDOWN_RUN_CANCEL_TIMEOUT)
227 .await;
228
229 Ok(())
230}
231
232pub fn shutdown_signal() -> anyhow::Result<impl std::future::Future<Output = ()> + Send + 'static> {
243 #[cfg(unix)]
244 {
245 let mut interrupt =
246 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
247 .context("failed to install SIGINT handler")?;
248 let mut terminate =
249 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
250 .context("failed to install SIGTERM handler")?;
251
252 Ok(async move {
253 tokio::select! {
254 _ = interrupt.recv() => {},
255 _ = terminate.recv() => {},
256 }
257 tracing::info!("shutdown signal received, draining in-flight requests");
258 })
259 }
260 #[cfg(not(unix))]
261 {
262 Ok(async {
263 if let Err(error) = tokio::signal::ctrl_c().await {
264 tracing::error!(%error, "failed to install Ctrl+C handler; waiting indefinitely");
265 std::future::pending::<()>().await;
266 }
267 tracing::info!("shutdown signal received, draining in-flight requests");
268 })
269 }
270}
271
272fn spawn_retention_sweeper(
283 pool: bhtune_db::SqlitePool,
284 config_store: Arc<RwLock<bhtune_runtime::config::LoadedConfigStore>>,
285) {
286 tokio::spawn(async move {
287 let mut interval = tokio::time::interval(RETENTION_SWEEP_INTERVAL);
288 interval.tick().await;
291 loop {
292 interval.tick().await;
293 retention_tick_live(&pool, &config_store).await;
294 }
295 });
296}
297
298fn spawn_demo_cleanup(state: AppState) {
302 let interval_duration = Duration::from_secs(state.demo_policy.cleanup_interval_secs);
303 tokio::spawn(async move {
304 let mut interval = tokio::time::interval(interval_duration);
305 loop {
306 interval.tick().await;
307 demo_cleanup_tick(&state).await;
308 }
309 });
310}
311
312async fn demo_cleanup_tick(state: &AppState) {
313 let now = chrono::Utc::now();
314 state.demo_runtime.cleanup(now, state.demo_policy).await;
315 if let Err(error) = bhtune_db::models::DemoSessionRow::cleanup_expired(&state.pool, now).await {
316 tracing::warn!(error = %error, "demo session cleanup failed; will retry next interval");
317 }
318 if let Err(error) = bhtune_db::models::TuneRunRow::prune_terminal_demo_owned(
319 &state.pool,
320 state.demo_policy.retained_runs_per_visitor,
321 )
322 .await
323 {
324 tracing::warn!(error = %error, "demo history cleanup failed; will retry next interval");
325 }
326}
327
328async fn retention_tick_live(
329 pool: &bhtune_db::SqlitePool,
330 config_store: &Arc<RwLock<bhtune_runtime::config::LoadedConfigStore>>,
331) {
332 let config = match config_store.read() {
333 Ok(store) => store.config.clone(),
334 Err(_) => {
335 tracing::warn!("configuration store lock poisoned; skipping retention sweep");
336 return;
337 }
338 };
339 let env_days = std::env::var("BHTUNE_RETENTION_DAYS")
340 .ok()
341 .and_then(|value| value.parse().ok());
342 let Some(days) = bhtune_runtime::config::resolve_retention_days(env_days, &config) else {
343 return;
344 };
345 retention_tick(pool, days).await;
346}
347
348async fn retention_tick(pool: &bhtune_db::SqlitePool, days: u32) {
355 let now = chrono::Utc::now();
356 if let Err(e) = bhtune_runtime::retention::sweep_retention(pool, days, now).await {
357 tracing::warn!(error = %e, "periodic retention sweep failed; will retry next interval");
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use bhtune_db::connect_in_memory;
365
366 #[test]
367 fn shutdown_and_retention_intervals_match_the_documented_policy() {
368 assert_eq!(SHUTDOWN_RUN_CANCEL_TIMEOUT, Duration::from_secs(35));
369 assert_eq!(RETENTION_SWEEP_INTERVAL, Duration::from_secs(24 * 60 * 60));
370 }
371
372 #[tokio::test]
373 async fn build_server_propagates_a_malformed_config_file() {
374 let dir = tempfile::tempdir().unwrap();
375 let path = dir.path().join("bhtune.toml");
376 std::fs::write(&path, "retention_days = [not valid toml").unwrap();
377
378 let result = build_server(Some(&path)).await;
379 assert!(result.is_err());
380 let error = result.err().unwrap();
381 assert!(error.to_string().contains("failed to parse config file"));
382 }
383
384 #[tokio::test]
385 async fn build_server_propagates_a_missing_explicit_template_catalog() {
386 let dir = tempfile::tempdir().unwrap();
387 let config_path = dir.path().join("bhtune.toml");
388 let templates_path = dir.path().join("missing-templates.toml");
389 std::fs::write(&config_path, format!("templates = {templates_path:?}\n")).unwrap();
390
391 let result = build_server(Some(&config_path)).await;
392 assert!(result.is_err());
393 let error = result.err().unwrap();
394 assert!(error.to_string().contains("templates file not found"));
395 }
396
397 #[tokio::test]
398 async fn build_server_fails_with_recreate_guidance_for_pre_squash_history() {
399 use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
400
401 let dir = tempfile::tempdir().unwrap();
402 let config_path = dir.path().join("bhtune.toml");
403 let db_path = dir.path().join("pre-squash.db");
404 let pool = SqlitePoolOptions::new()
405 .max_connections(1)
406 .connect_with(
407 SqliteConnectOptions::new()
408 .filename(&db_path)
409 .create_if_missing(true),
410 )
411 .await
412 .unwrap();
413 sqlx::query(
414 "CREATE TABLE _sqlx_migrations (\
415 version BIGINT PRIMARY KEY, description TEXT NOT NULL, \
416 installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, \
417 success BOOLEAN NOT NULL, checksum BLOB NOT NULL, execution_time BIGINT NOT NULL\
418 )",
419 )
420 .execute(&pool)
421 .await
422 .unwrap();
423 sqlx::query(
424 "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) \
425 VALUES (1, 'initial schema', TRUE, zeroblob(48), 0)",
426 )
427 .execute(&pool)
428 .await
429 .unwrap();
430 pool.close().await;
431 std::fs::write(
432 &config_path,
433 format!(
434 "server_mode = \"full\"\nbind = \"127.0.0.1:0\"\ndb = {db_path:?}\n\
435 [log]\ndir = {:?}\n",
436 dir.path().join("logs")
437 ),
438 )
439 .unwrap();
440
441 let error = build_server(Some(&config_path)).await.err().unwrap();
442 let message = error.to_string();
443 assert!(message.contains(&db_path.display().to_string()));
444 assert!(message.contains("incompatible migration history"));
445 assert!(message.contains("back it up"));
446 assert!(message.contains("new database file"));
447 assert!(!message.contains("failed to run database migrations"));
448 }
449
450 #[tokio::test]
451 async fn build_server_rejects_an_insecure_non_loopback_demo_origin_before_opening_the_db() {
452 let dir = tempfile::tempdir().unwrap();
453 let config_path = dir.path().join("bhtune.toml");
454 let db_path = dir.path().join("must-not-exist.db");
455 std::fs::write(
456 &config_path,
457 format!(
458 "server_mode = \"demo\"\norigin = \"http://demo.example\"\n\
459 bind = \"127.0.0.1:0\"\ndb = {db_path:?}\n"
460 ),
461 )
462 .unwrap();
463
464 let error = build_server(Some(&config_path)).await.err().unwrap();
465
466 assert!(error.to_string().contains("must use HTTPS"));
467 assert!(!db_path.exists());
468 }
469
470 #[tokio::test]
471 async fn build_server_rejects_an_invalid_demo_policy_before_opening_the_db() {
472 let dir = tempfile::tempdir().unwrap();
473 let config_path = dir.path().join("bhtune.toml");
474 let db_path = dir.path().join("must-not-exist.db");
475 std::fs::write(
476 &config_path,
477 format!(
478 "server_mode = \"demo\"\nbind = \"127.0.0.1:0\"\ndb = {db_path:?}\n\
479 [demo]\nmax_active_runs_global = 9\n"
480 ),
481 )
482 .unwrap();
483
484 let error = build_server(Some(&config_path)).await.err().unwrap();
485
486 assert!(
487 error
488 .to_string()
489 .contains("demo.max_active_runs_global must be exactly 8")
490 );
491 assert!(!db_path.exists());
492 }
493
494 #[tokio::test]
495 async fn build_server_rejects_an_invalid_demo_trusted_proxy_before_opening_the_db() {
496 let dir = tempfile::tempdir().unwrap();
497 let config_path = dir.path().join("bhtune.toml");
498 let db_path = dir.path().join("must-not-exist.db");
499 std::fs::write(
500 &config_path,
501 format!(
502 "server_mode = \"demo\"\norigin = \"http://localhost\"\n\
503 bind = \"127.0.0.1:0\"\n\
504 trusted_proxy = \"proxy.example\"\ndb = {db_path:?}\n"
505 ),
506 )
507 .unwrap();
508
509 let error = build_server(Some(&config_path)).await.err().unwrap();
510
511 assert!(error.to_string().contains("trusted_proxy"));
512 assert!(!db_path.exists());
513 }
514
515 #[tokio::test]
516 async fn build_server_starts_demo_cleanup_before_serving() {
517 let dir = tempfile::tempdir().unwrap();
518 let config_path = dir.path().join("bhtune.toml");
519 let db_path = dir.path().join("bhtune.db");
520 std::fs::write(
521 &config_path,
522 format!(
523 "server_mode = \"demo\"\norigin = \"http://localhost\"\n\
524 db = {db_path:?}\nbind = \"127.0.0.1:0\"\n"
525 ),
526 )
527 .unwrap();
528
529 let server = build_server(Some(&config_path)).await.unwrap();
530 serve(server, async {}).await.unwrap();
531 }
532
533 async fn insert_old_run(pool: &bhtune_db::SqlitePool) -> i64 {
534 use bhtune_core::{ControllerType, LoopConfig, LoopTags, ProcessType, built_in_templates};
535 use bhtune_db::models::{TemplateOrigin, TuneDriver, TuneRunRow};
536
537 let template = built_in_templates().remove(0);
538 let tags = LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &template);
539 let config = LoopConfig {
540 process_type: ProcessType::Flow,
541 controller_type: ControllerType::Pi,
542 relay_amp_percent: 5.0,
543 num_cycles_skip: 1,
544 num_cycles_count: 2,
545 noise_protection_secs: 3,
546 mrft_delay_secs: 0,
547 };
548 let old_started_at = chrono::Utc::now() - chrono::Duration::days(100);
549 TuneRunRow::start(
550 pool,
551 None,
552 "LIC-X",
553 TuneDriver::Simulator,
554 config,
555 TemplateOrigin::Builtin,
556 &template,
557 &tags,
558 old_started_at,
559 )
560 .await
561 .unwrap()
562 .id
563 }
564
565 #[tokio::test]
566 async fn retention_tick_deletes_runs_past_the_cutoff_and_logs_nothing_fatal() {
567 let pool = connect_in_memory().await.unwrap();
568 let old_run_id = insert_old_run(&pool).await;
569
570 retention_tick(&pool, 30).await;
571
572 assert!(
573 bhtune_db::models::TuneRunRow::get(&pool, old_run_id)
574 .await
575 .unwrap()
576 .is_none()
577 );
578 }
579
580 #[tokio::test]
581 async fn retention_tick_on_a_pool_with_no_matching_runs_is_a_silent_no_op() {
582 let pool = connect_in_memory().await.unwrap();
583 retention_tick(&pool, 30).await;
586 }
587
588 #[tokio::test]
589 async fn build_server_completes_startup_and_can_serve_until_shutdown() {
590 let dir = tempfile::tempdir().unwrap();
591 let config_path = dir.path().join("bhtune.toml");
592 let db_path = dir.path().join("bhtune.db");
593 let log_dir = dir.path().join("logs");
594 std::fs::write(
595 &config_path,
596 format!("db = {db_path:?}\nbind = \"127.0.0.1:0\"\n[log]\ndir = {log_dir:?}\n"),
597 )
598 .unwrap();
599
600 let server = build_server(Some(&config_path)).await.unwrap();
601 serve(server, async {}).await.unwrap();
602 }
603
604 #[tokio::test]
605 async fn serve_propagates_an_http_server_error() {
606 let error = serve_http(
607 async {
608 Err(std::io::Error::new(
609 std::io::ErrorKind::BrokenPipe,
610 "injected accept failure",
611 ))
612 },
613 ActiveRun::default(),
614 )
615 .await
616 .unwrap_err();
617
618 assert!(error.to_string().contains("injected accept failure"));
619 }
620
621 #[tokio::test]
622 async fn retention_tick_live_runs_when_retention_is_configured() {
623 let pool = connect_in_memory().await.unwrap();
624 let old_run_id = insert_old_run(&pool).await;
625 let store = Arc::new(RwLock::new(bhtune_runtime::config::LoadedConfigStore {
626 path: None,
627 missing_is_allowed: true,
628 original_raw: None,
629 config: bhtune_runtime::config::BhtuneConfig {
630 retention_days: Some(30),
631 ..Default::default()
632 },
633 revision: "revision".to_string(),
634 toml_allow_uncertain_quality: None,
635 toml_tuning: Default::default(),
636 tuning_sources: bhtune_runtime::config::tuning_config_sources(
637 &bhtune_runtime::config::TuningConfig::default(),
638 ),
639 }));
640
641 retention_tick_live(&pool, &store).await;
642
643 assert!(
644 bhtune_db::models::TuneRunRow::get(&pool, old_run_id)
645 .await
646 .unwrap()
647 .is_none()
648 );
649 }
650
651 #[tokio::test]
652 async fn retention_tick_live_skips_when_retention_is_disabled() {
653 let pool = connect_in_memory().await.unwrap();
654 let store = Arc::new(RwLock::new(bhtune_runtime::config::LoadedConfigStore {
655 path: None,
656 missing_is_allowed: true,
657 original_raw: None,
658 config: Default::default(),
659 revision: "revision".to_string(),
660 toml_allow_uncertain_quality: None,
661 toml_tuning: Default::default(),
662 tuning_sources: bhtune_runtime::config::tuning_config_sources(
663 &bhtune_runtime::config::TuningConfig::default(),
664 ),
665 }));
666
667 retention_tick_live(&pool, &store).await;
668 }
669
670 #[tokio::test]
671 async fn retention_tick_live_skips_when_config_store_lock_is_poisoned() {
672 let pool = connect_in_memory().await.unwrap();
673 let store = Arc::new(RwLock::new(bhtune_runtime::config::LoadedConfigStore {
674 path: None,
675 missing_is_allowed: true,
676 original_raw: None,
677 config: Default::default(),
678 revision: "revision".to_string(),
679 toml_allow_uncertain_quality: None,
680 toml_tuning: Default::default(),
681 tuning_sources: bhtune_runtime::config::tuning_config_sources(
682 &bhtune_runtime::config::TuningConfig::default(),
683 ),
684 }));
685 let poisoned = Arc::clone(&store);
686 std::thread::spawn(move || {
687 let _guard = poisoned.write().unwrap();
688 panic!("poison configuration store");
689 })
690 .join()
691 .unwrap_err();
692
693 retention_tick_live(&pool, &store).await;
694 }
695
696 #[tokio::test]
697 async fn retention_tick_live_logs_and_returns_when_sweep_fails() {
698 let pool = connect_in_memory().await.unwrap();
699 pool.close().await;
700 let store = Arc::new(RwLock::new(bhtune_runtime::config::LoadedConfigStore {
701 path: None,
702 missing_is_allowed: true,
703 original_raw: None,
704 config: bhtune_runtime::config::BhtuneConfig {
705 retention_days: Some(30),
706 ..Default::default()
707 },
708 revision: "revision".to_string(),
709 toml_allow_uncertain_quality: None,
710 toml_tuning: Default::default(),
711 tuning_sources: bhtune_runtime::config::tuning_config_sources(
712 &bhtune_runtime::config::TuningConfig::default(),
713 ),
714 }));
715
716 retention_tick_live(&pool, &store).await;
717 }
718
719 #[tokio::test]
720 async fn demo_cleanup_tick_succeeds_and_retries_database_failures() {
721 let state = crate::test_support::in_memory_state().await;
722 demo_cleanup_tick(&state).await;
723
724 state.pool.close().await;
725 demo_cleanup_tick(&state).await;
726 }
727
728 #[tokio::test(start_paused = true)]
729 async fn spawned_demo_cleanup_runs_on_the_policy_interval() {
730 tokio::time::resume();
731 let state = crate::test_support::in_memory_state().await;
732 tokio::time::pause();
733 spawn_demo_cleanup(state);
734 tokio::task::yield_now().await;
735 tokio::time::advance(Duration::from_secs(
736 bhtune_runtime::config::DEMO_CLEANUP_INTERVAL_SECS,
737 ))
738 .await;
739 tokio::time::resume();
740 tokio::task::yield_now().await;
741 }
742
743 #[tokio::test(start_paused = true)]
744 async fn spawned_retention_sweeper_runs_a_periodic_tick() {
745 tokio::time::resume();
746 let pool = connect_in_memory().await.unwrap();
747 let old_run_id = insert_old_run(&pool).await;
748 tokio::time::pause();
749 let store = Arc::new(RwLock::new(bhtune_runtime::config::LoadedConfigStore {
750 path: None,
751 missing_is_allowed: true,
752 original_raw: None,
753 config: bhtune_runtime::config::BhtuneConfig {
754 retention_days: Some(30),
755 ..Default::default()
756 },
757 revision: "revision".to_string(),
758 toml_allow_uncertain_quality: None,
759 toml_tuning: Default::default(),
760 tuning_sources: bhtune_runtime::config::tuning_config_sources(
761 &bhtune_runtime::config::TuningConfig::default(),
762 ),
763 }));
764
765 spawn_retention_sweeper(pool.clone(), store);
766 tokio::task::yield_now().await;
767 tokio::time::advance(RETENTION_SWEEP_INTERVAL + Duration::from_secs(1)).await;
768 tokio::time::resume();
769 for _ in 0..3 {
770 tokio::task::yield_now().await;
771 }
772
773 assert!(
774 bhtune_db::models::TuneRunRow::get(&pool, old_run_id)
775 .await
776 .unwrap()
777 .is_none()
778 );
779 }
780}