1use bhtune_core::{
2 ControllerDirection, ControllerType, DcsTemplate, LoopConfig, LoopTags, ProcessType,
3};
4use chrono::{DateTime, Utc};
5use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool, sqlite::SqliteRow};
6
7use crate::{
8 convert::{enum_to_text, json_text, text_to_enum},
9 error::{DbError, DbResult},
10};
11
12use super::dcs_templates::TemplateOrigin;
13
14#[cfg(doc)]
15use super::{dcs_templates::DcsTemplateRow, loops::LoopRow, tune_results::TuneResultRow};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
23#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
24#[serde(rename_all = "snake_case")]
25pub enum TuneDriver {
26 Opcda,
27 Simulator,
28 Replay,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
33#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
34#[serde(rename_all = "snake_case")]
35pub enum TuneOutcome {
36 Running,
37 Completed,
38 Failed,
39 Aborted,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
44#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
45#[serde(rename_all = "snake_case")]
46pub enum TimingBasis {
47 SimulatedFixedStep,
50 LiveMonotonic,
53}
54
55#[derive(
58 Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
59)]
60#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
61#[serde(rename_all = "snake_case")]
62pub enum SamplingAdequacy {
63 Adequate,
65 Marginal,
67 #[default]
69 NotAssessed,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
74#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
75pub struct TimingSummary {
76 pub count: u64,
77 pub mean_ms: Option<f64>,
78 pub max_ms: Option<f64>,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
89#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
90pub struct PollLatencyMetrics {
91 pub pv_read: TimingSummary,
92 pub mv_write: TimingSummary,
93 pub mv_verification: TimingSummary,
94 pub sample_persist: TimingSummary,
95 pub tick_work: TimingSummary,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
104#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
105pub struct TimingMetrics {
106 pub basis: TimingBasis,
107 pub requested_interval_ms: u64,
108 pub sample_gap_count: u64,
109 pub mean_sample_gap_ms: Option<f64>,
110 pub max_sample_gap_ms: Option<f64>,
111 pub missed_poll_opportunity_count: u64,
114 pub measured_oscillation_period_ms: Option<f64>,
115 pub approximate_samples_per_period: Option<f64>,
116 #[serde(default)]
119 pub sampling_adequacy: SamplingAdequacy,
120 #[serde(default)]
122 pub poll_latency: Option<PollLatencyMetrics>,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
131#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
132pub struct EffectiveTuning {
133 pub mrft_delay_secs: u32,
134 pub poll_interval_ms: u64,
135 pub timeout_secs: u64,
136 pub op_timeout_secs: u64,
137 pub restore_timeout_secs: u64,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
146#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
147#[serde(rename_all = "snake_case")]
148pub enum RestoreStatus {
149 Confirmed,
151 Incomplete,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum TuneRecoveryState {
162 Eligible,
163 Running,
164 Confirmed,
165 Incomplete,
166 NotRecoverable,
167}
168
169#[derive(Debug, Clone, PartialEq, serde::Serialize)]
177pub struct TuneRunInitialReadings {
178 pub pv_ini: f32,
179 pub mv_ini: f32,
180 pub mv_range_low: f32,
181 pub mv_range_high: f32,
182 pub pv_range_high: f32,
183 pub pv_range_low: f32,
184 pub controller_direction: ControllerDirection,
185 pub mode_raw: Option<String>,
190 pub mode_attribute_raw: Option<String>,
193 pub setpoint_ini: Option<f32>,
199}
200
201#[derive(Debug, Clone, PartialEq, serde::Serialize)]
203pub struct TuneRunRow {
204 pub id: i64,
205 pub loop_id: Option<i64>,
206 pub demo_session_id: Option<i64>,
207 pub loop_name: String,
208 pub driver: TuneDriver,
209 pub opc_server: Option<String>,
214 pub bridge_host: Option<String>,
217 pub started_at: DateTime<Utc>,
218 pub completed_at: Option<DateTime<Utc>>,
219 pub outcome: TuneOutcome,
220 pub failure_reason: Option<String>,
221 pub config: LoopConfig,
224 pub template_origin: TemplateOrigin,
226 pub template: DcsTemplate,
234 pub tags: LoopTags,
237 pub request_json: String,
245 pub notes: Option<String>,
247 pub initial_readings: Option<TuneRunInitialReadings>,
248 pub allow_uncertain_quality: bool,
255 pub timing_metrics: Option<TimingMetrics>,
258 pub effective_tuning: Option<EffectiveTuning>,
262 pub gateway_compatibility_json: Option<String>,
267 pub restore_status: Option<RestoreStatus>,
271 pub restore_detail: Option<String>,
275 pub recovery_state: Option<TuneRecoveryState>,
278 pub recovery_evidence_json: Option<String>,
280 pub created_at: DateTime<Utc>,
281}
282
283#[derive(Debug, Clone, Default, PartialEq)]
287pub struct TuneRunFilter {
288 pub loop_id: Option<i64>,
289 pub demo_session_id: Option<i64>,
290 pub process_type: Option<ProcessType>,
291 pub controller_type: Option<ControllerType>,
292 pub outcome: Option<TuneOutcome>,
293 pub driver: Option<TuneDriver>,
294 pub opc_server: Option<String>,
297 pub bridge_host: Option<String>,
300 pub started_after: Option<DateTime<Utc>>,
302 pub started_before: Option<DateTime<Utc>>,
304 pub template_name: Option<String>,
305 pub template_origin: Option<TemplateOrigin>,
306}
307
308impl TuneRunFilter {
309 pub fn with_demo_session_id(mut self, demo_session_id: i64) -> TuneRunFilter {
310 self.demo_session_id = Some(demo_session_id);
311 self
312 }
313 pub fn with_loop_id(mut self, loop_id: i64) -> TuneRunFilter {
314 self.loop_id = Some(loop_id);
315 self
316 }
317
318 pub fn with_process_type(mut self, process_type: ProcessType) -> TuneRunFilter {
319 self.process_type = Some(process_type);
320 self
321 }
322
323 pub fn with_controller_type(mut self, controller_type: ControllerType) -> TuneRunFilter {
324 self.controller_type = Some(controller_type);
325 self
326 }
327
328 pub fn with_outcome(mut self, outcome: TuneOutcome) -> TuneRunFilter {
329 self.outcome = Some(outcome);
330 self
331 }
332
333 pub fn with_driver(mut self, driver: TuneDriver) -> TuneRunFilter {
334 self.driver = Some(driver);
335 self
336 }
337
338 pub fn with_opc_server(mut self, opc_server: impl Into<String>) -> TuneRunFilter {
339 self.opc_server = Some(opc_server.into());
340 self
341 }
342
343 pub fn with_bridge_host(mut self, bridge_host: impl Into<String>) -> TuneRunFilter {
344 self.bridge_host = Some(bridge_host.into());
345 self
346 }
347
348 pub fn with_started_after(mut self, started_after: DateTime<Utc>) -> TuneRunFilter {
349 self.started_after = Some(started_after);
350 self
351 }
352
353 pub fn with_started_before(mut self, started_before: DateTime<Utc>) -> TuneRunFilter {
354 self.started_before = Some(started_before);
355 self
356 }
357
358 pub fn with_template_name(mut self, template_name: impl Into<String>) -> TuneRunFilter {
359 self.template_name = Some(template_name.into());
360 self
361 }
362
363 pub fn with_template_origin(mut self, template_origin: TemplateOrigin) -> TuneRunFilter {
364 self.template_origin = Some(template_origin);
365 self
366 }
367}
368
369#[derive(Debug, Clone, Copy, PartialEq)]
372pub struct Pagination {
373 pub limit: i64,
374 pub offset: i64,
375}
376
377impl Pagination {
378 pub fn new(limit: i64, offset: i64) -> Pagination {
379 Pagination { limit, offset }
380 }
381
382 pub fn first(limit: i64) -> Pagination {
384 Pagination { limit, offset: 0 }
385 }
386}
387
388impl Default for Pagination {
389 fn default() -> Pagination {
391 Pagination {
392 limit: 50,
393 offset: 0,
394 }
395 }
396}
397
398impl TuneRunRow {
399 #[allow(clippy::too_many_arguments)]
411 pub async fn start(
412 pool: &SqlitePool,
413 loop_id: Option<i64>,
414 loop_name: &str,
415 driver: TuneDriver,
416 config: LoopConfig,
417 template_origin: TemplateOrigin,
418 template: &DcsTemplate,
419 tags: &LoopTags,
420 now: DateTime<Utc>,
421 ) -> DbResult<TuneRunRow> {
422 Self::start_with_demo_session(
423 pool,
424 None,
425 loop_id,
426 loop_name,
427 driver,
428 config,
429 template_origin,
430 template,
431 tags,
432 now,
433 )
434 .await
435 }
436
437 #[allow(clippy::too_many_arguments)]
438 pub async fn start_with_demo_session(
439 pool: &SqlitePool,
440 demo_session_id: Option<i64>,
441 loop_id: Option<i64>,
442 loop_name: &str,
443 driver: TuneDriver,
444 config: LoopConfig,
445 template_origin: TemplateOrigin,
446 template: &DcsTemplate,
447 tags: &LoopTags,
448 now: DateTime<Utc>,
449 ) -> DbResult<TuneRunRow> {
450 let template_snapshot_json = json_text("template snapshot", template)?;
451 let tags_json = json_text("loop tags", tags)?;
452
453 let row = sqlx::query(
454 r"
455 INSERT INTO tune_runs (
456 loop_id, demo_session_id, loop_name, driver, started_at, outcome,
457 process_type, controller_type, relay_amp_percent, num_cycles_skip,
458 num_cycles_count, noise_protection_secs, mrft_delay_secs,
459 template_name, template_origin, template_snapshot_json, tags_json,
460 created_at
461 )
462 SELECT ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
463 WHERE ? IS NULL OR EXISTS (
464 SELECT 1
465 FROM demo_sessions
466 WHERE id = ? AND revoked_at IS NULL AND expires_at > ?
467 )
468 RETURNING *
469 ",
470 )
471 .bind(loop_id)
472 .bind(demo_session_id)
473 .bind(loop_name)
474 .bind(enum_to_text(&driver)?)
475 .bind(now)
476 .bind(enum_to_text(&config.process_type)?)
477 .bind(enum_to_text(&config.controller_type)?)
478 .bind(config.relay_amp_percent)
479 .bind(config.num_cycles_skip)
480 .bind(config.num_cycles_count)
481 .bind(config.noise_protection_secs)
482 .bind(config.mrft_delay_secs)
483 .bind(&template.name)
484 .bind(enum_to_text(&template_origin)?)
485 .bind(template_snapshot_json)
486 .bind(tags_json)
487 .bind(now)
488 .bind(demo_session_id)
489 .bind(demo_session_id)
490 .bind(now)
491 .fetch_optional(pool)
492 .await
493 .map_err(DbError::Query)?
494 .ok_or_else(|| DbError::Query(sqlx::Error::RowNotFound))?;
495
496 row_to_tune_run(row)
497 }
498
499 #[allow(clippy::too_many_arguments)]
502 pub async fn start_owned(
503 pool: &SqlitePool,
504 demo_session_id: i64,
505 loop_name: &str,
506 config: LoopConfig,
507 template_origin: TemplateOrigin,
508 template: &DcsTemplate,
509 tags: &LoopTags,
510 now: DateTime<Utc>,
511 ) -> DbResult<TuneRunRow> {
512 Self::start_with_demo_session(
513 pool,
514 Some(demo_session_id),
515 None,
516 loop_name,
517 TuneDriver::Simulator,
518 config,
519 template_origin,
520 template,
521 tags,
522 now,
523 )
524 .await
525 }
526
527 pub async fn get_for_demo_session(
528 pool: &SqlitePool,
529 run_id: i64,
530 demo_session_id: i64,
531 ) -> DbResult<Option<TuneRunRow>> {
532 let row = sqlx::query("SELECT * FROM tune_runs WHERE id = ? AND demo_session_id = ?")
533 .bind(run_id)
534 .bind(demo_session_id)
535 .fetch_optional(pool)
536 .await
537 .map_err(DbError::Query)?;
538 row.map(row_to_tune_run).transpose()
539 }
540
541 pub async fn list_for_demo_session(
542 pool: &SqlitePool,
543 demo_session_id: i64,
544 pagination: Pagination,
545 ) -> DbResult<Vec<TuneRunRow>> {
546 let rows = sqlx::query(
547 "SELECT * FROM tune_runs WHERE demo_session_id = ? \
548 ORDER BY started_at DESC, id DESC LIMIT ? OFFSET ?",
549 )
550 .bind(demo_session_id)
551 .bind(pagination.limit)
552 .bind(pagination.offset)
553 .fetch_all(pool)
554 .await
555 .map_err(DbError::Query)?;
556 rows.into_iter().map(row_to_tune_run).collect()
557 }
558
559 pub async fn newest_for_demo_session(
560 pool: &SqlitePool,
561 demo_session_id: i64,
562 ) -> DbResult<Option<TuneRunRow>> {
563 let row = sqlx::query(
564 "SELECT * FROM tune_runs WHERE demo_session_id = ? \
565 ORDER BY started_at DESC, id DESC LIMIT 1",
566 )
567 .bind(demo_session_id)
568 .fetch_optional(pool)
569 .await
570 .map_err(DbError::Query)?;
571 row.map(row_to_tune_run).transpose()
572 }
573
574 pub async fn count_for_demo_session(pool: &SqlitePool, demo_session_id: i64) -> DbResult<i64> {
575 sqlx::query_scalar("SELECT COUNT(*) FROM tune_runs WHERE demo_session_id = ?")
576 .bind(demo_session_id)
577 .fetch_one(pool)
578 .await
579 .map_err(DbError::Query)
580 }
581
582 pub async fn count_demo_owned(pool: &SqlitePool) -> DbResult<i64> {
585 sqlx::query_scalar("SELECT COUNT(*) FROM tune_runs WHERE demo_session_id IS NOT NULL")
586 .fetch_one(pool)
587 .await
588 .map_err(DbError::Query)
589 }
590
591 pub async fn prune_terminal_for_demo_session(
595 pool: &SqlitePool,
596 demo_session_id: i64,
597 retain: u32,
598 ) -> DbResult<u64> {
599 let result = sqlx::query(
600 "DELETE FROM tune_runs \
601 WHERE id IN ( \
602 SELECT id FROM tune_runs \
603 WHERE demo_session_id = ? AND outcome <> 'running' \
604 ORDER BY started_at DESC, id DESC \
605 LIMIT -1 OFFSET ? \
606 )",
607 )
608 .bind(demo_session_id)
609 .bind(i64::from(retain))
610 .execute(pool)
611 .await
612 .map_err(DbError::Query)?;
613 Ok(result.rows_affected())
614 }
615
616 pub async fn prune_terminal_demo_owned(pool: &SqlitePool, retain: u32) -> DbResult<u64> {
619 let result = sqlx::query(
620 "WITH ranked AS ( \
621 SELECT id, ROW_NUMBER() OVER ( \
622 PARTITION BY demo_session_id ORDER BY started_at DESC, id DESC \
623 ) AS retention_rank \
624 FROM tune_runs \
625 WHERE demo_session_id IS NOT NULL AND outcome <> 'running' \
626 ) \
627 DELETE FROM tune_runs \
628 WHERE id IN (SELECT id FROM ranked WHERE retention_rank > ?)",
629 )
630 .bind(i64::from(retain))
631 .execute(pool)
632 .await
633 .map_err(DbError::Query)?;
634 Ok(result.rows_affected())
635 }
636
637 pub async fn count_rows_for_demo_session(
638 pool: &SqlitePool,
639 demo_session_id: i64,
640 ) -> DbResult<i64> {
641 sqlx::query_scalar(
642 "SELECT
643 (SELECT COUNT(*) FROM tune_runs WHERE demo_session_id = ?)
644 + (SELECT COUNT(*) FROM tune_samples WHERE run_id IN
645 (SELECT id FROM tune_runs WHERE demo_session_id = ?))
646 + (SELECT COUNT(*) FROM tune_results WHERE run_id IN
647 (SELECT id FROM tune_runs WHERE demo_session_id = ?))
648 + (SELECT COUNT(*) FROM tune_writes WHERE run_id IN
649 (SELECT id FROM tune_runs WHERE demo_session_id = ?))
650 + (SELECT COUNT(*) FROM tune_mv_actuations WHERE run_id IN
651 (SELECT id FROM tune_runs WHERE demo_session_id = ?))",
652 )
653 .bind(demo_session_id)
654 .bind(demo_session_id)
655 .bind(demo_session_id)
656 .bind(demo_session_id)
657 .bind(demo_session_id)
658 .fetch_one(pool)
659 .await
660 .map_err(DbError::Query)
661 }
662
663 pub async fn record_connection(
674 pool: &SqlitePool,
675 run_id: i64,
676 opc_server: Option<&str>,
677 bridge_host: Option<&str>,
678 request_json: &str,
679 ) -> DbResult<TuneRunRow> {
680 let row = sqlx::query(
681 r"
682 UPDATE tune_runs SET opc_server = ?, bridge_host = ?, request_json = ?
683 WHERE id = ?
684 RETURNING *
685 ",
686 )
687 .bind(opc_server)
688 .bind(bridge_host)
689 .bind(request_json)
690 .bind(run_id)
691 .fetch_one(pool)
692 .await
693 .map_err(DbError::Query)?;
694
695 row_to_tune_run(row)
696 }
697
698 pub async fn record_effective_tuning(
703 pool: &SqlitePool,
704 run_id: i64,
705 effective_tuning: EffectiveTuning,
706 ) -> DbResult<TuneRunRow> {
707 let effective_tuning_json = json_text("effective tuning", &effective_tuning)?;
708 let row = sqlx::query(
709 r"
710 UPDATE tune_runs SET effective_tuning_json = ?
711 WHERE id = ?
712 RETURNING *
713 ",
714 )
715 .bind(effective_tuning_json)
716 .bind(run_id)
717 .fetch_one(pool)
718 .await
719 .map_err(DbError::Query)?;
720
721 row_to_tune_run(row)
722 }
723
724 pub async fn record_gateway_compatibility(
735 pool: &SqlitePool,
736 run_id: i64,
737 gateway_compatibility_json: &str,
738 ) -> DbResult<TuneRunRow> {
739 let row = sqlx::query(
740 r"
741 UPDATE tune_runs SET gateway_compatibility_json = ?
742 WHERE id = ?
743 RETURNING *
744 ",
745 )
746 .bind(gateway_compatibility_json)
747 .bind(run_id)
748 .fetch_one(pool)
749 .await
750 .map_err(DbError::Query)?;
751
752 row_to_tune_run(row)
753 }
754
755 pub async fn update_notes(
760 pool: &SqlitePool,
761 run_id: i64,
762 notes: Option<&str>,
763 ) -> DbResult<TuneRunRow> {
764 let row = sqlx::query(
765 r"
766 UPDATE tune_runs SET notes = ?
767 WHERE id = ?
768 RETURNING *
769 ",
770 )
771 .bind(notes)
772 .bind(run_id)
773 .fetch_one(pool)
774 .await
775 .map_err(DbError::Query)?;
776
777 row_to_tune_run(row)
778 }
779
780 pub async fn record_initial_readings(
787 pool: &SqlitePool,
788 run_id: i64,
789 readings: TuneRunInitialReadings,
790 ) -> DbResult<TuneRunRow> {
791 let row = sqlx::query(
792 r"
793 UPDATE tune_runs SET
794 pv_ini = ?, mv_ini = ?, mv_range_low = ?, mv_range_high = ?,
795 pv_range_high = ?, pv_range_low = ?, controller_direction = ?,
796 mode_raw = ?, mode_attribute_raw = ?, setpoint_ini = ?
797 WHERE id = ?
798 RETURNING *
799 ",
800 )
801 .bind(readings.pv_ini)
802 .bind(readings.mv_ini)
803 .bind(readings.mv_range_low)
804 .bind(readings.mv_range_high)
805 .bind(readings.pv_range_high)
806 .bind(readings.pv_range_low)
807 .bind(enum_to_text(&readings.controller_direction)?)
808 .bind(readings.mode_raw)
809 .bind(readings.mode_attribute_raw)
810 .bind(readings.setpoint_ini)
811 .bind(run_id)
812 .fetch_one(pool)
813 .await
814 .map_err(DbError::Query)?;
815
816 row_to_tune_run(row)
817 }
818
819 pub async fn record_allow_uncertain_quality(
825 pool: &SqlitePool,
826 run_id: i64,
827 allow_uncertain_quality: bool,
828 ) -> DbResult<TuneRunRow> {
829 let row = sqlx::query(
830 r"
831 UPDATE tune_runs SET allow_uncertain_quality = ?
832 WHERE id = ?
833 RETURNING *
834 ",
835 )
836 .bind(allow_uncertain_quality)
837 .bind(run_id)
838 .fetch_one(pool)
839 .await
840 .map_err(DbError::Query)?;
841
842 row_to_tune_run(row)
843 }
844
845 pub async fn record_timing_metrics(
852 pool: &SqlitePool,
853 run_id: i64,
854 metrics: TimingMetrics,
855 ) -> DbResult<TuneRunRow> {
856 let metrics_json = json_text("timing metrics", &metrics)?;
857 let row = sqlx::query(
858 r"
859 UPDATE tune_runs SET timing_metrics_json = ?
860 WHERE id = ?
861 RETURNING *
862 ",
863 )
864 .bind(metrics_json)
865 .bind(run_id)
866 .fetch_one(pool)
867 .await
868 .map_err(DbError::Query)?;
869
870 row_to_tune_run(row)
871 }
872
873 pub async fn record_restore_status(
884 pool: &SqlitePool,
885 run_id: i64,
886 status: RestoreStatus,
887 detail: Option<&str>,
888 ) -> DbResult<TuneRunRow> {
889 let row = sqlx::query(
890 r"
891 UPDATE tune_runs SET restore_status = ?, restore_detail = ?
892 WHERE id = ?
893 RETURNING *
894 ",
895 )
896 .bind(enum_to_text(&status)?)
897 .bind(detail)
898 .bind(run_id)
899 .fetch_one(pool)
900 .await
901 .map_err(DbError::Query)?;
902
903 row_to_tune_run(row)
904 }
905
906 pub async fn complete(
909 pool: &SqlitePool,
910 run_id: i64,
911 completed_at: DateTime<Utc>,
912 ) -> DbResult<TuneRunRow> {
913 let row = sqlx::query(
914 "UPDATE tune_runs SET outcome = 'completed', completed_at = ? WHERE id = ? RETURNING *",
915 )
916 .bind(completed_at)
917 .bind(run_id)
918 .fetch_one(pool)
919 .await
920 .map_err(DbError::Query)?;
921
922 row_to_tune_run(row)
923 }
924
925 pub async fn complete_with_timing_metrics(
929 pool: &SqlitePool,
930 run_id: i64,
931 completed_at: DateTime<Utc>,
932 timing_metrics: Option<TimingMetrics>,
933 ) -> DbResult<TuneRunRow> {
934 Self::set_terminal_outcome_with_timing_metrics(
935 pool,
936 run_id,
937 completed_at,
938 TuneOutcome::Completed,
939 timing_metrics,
940 None,
941 )
942 .await
943 }
944
945 pub async fn fail(
949 pool: &SqlitePool,
950 run_id: i64,
951 completed_at: DateTime<Utc>,
952 failure_reason: &str,
953 ) -> DbResult<TuneRunRow> {
954 let row = sqlx::query(
955 r"
956 UPDATE tune_runs SET outcome = 'failed', completed_at = ?, failure_reason = ?
957 WHERE id = ?
958 RETURNING *
959 ",
960 )
961 .bind(completed_at)
962 .bind(failure_reason)
963 .bind(run_id)
964 .fetch_one(pool)
965 .await
966 .map_err(DbError::Query)?;
967
968 row_to_tune_run(row)
969 }
970
971 pub async fn abort(
974 pool: &SqlitePool,
975 run_id: i64,
976 completed_at: DateTime<Utc>,
977 ) -> DbResult<TuneRunRow> {
978 let row = sqlx::query(
979 "UPDATE tune_runs SET outcome = 'aborted', completed_at = ? WHERE id = ? RETURNING *",
980 )
981 .bind(completed_at)
982 .bind(run_id)
983 .fetch_one(pool)
984 .await
985 .map_err(DbError::Query)?;
986
987 row_to_tune_run(row)
988 }
989
990 pub async fn abort_with_timing_metrics(
993 pool: &SqlitePool,
994 run_id: i64,
995 completed_at: DateTime<Utc>,
996 timing_metrics: Option<TimingMetrics>,
997 ) -> DbResult<TuneRunRow> {
998 Self::set_terminal_outcome_with_timing_metrics(
999 pool,
1000 run_id,
1001 completed_at,
1002 TuneOutcome::Aborted,
1003 timing_metrics,
1004 None,
1005 )
1006 .await
1007 }
1008
1009 pub async fn abort_with_timing_metrics_and_reason(
1015 pool: &SqlitePool,
1016 run_id: i64,
1017 completed_at: DateTime<Utc>,
1018 timing_metrics: Option<TimingMetrics>,
1019 reason: &str,
1020 ) -> DbResult<TuneRunRow> {
1021 Self::set_terminal_outcome_with_timing_metrics(
1022 pool,
1023 run_id,
1024 completed_at,
1025 TuneOutcome::Aborted,
1026 timing_metrics,
1027 Some(reason),
1028 )
1029 .await
1030 }
1031
1032 async fn set_terminal_outcome_with_timing_metrics(
1033 pool: &SqlitePool,
1034 run_id: i64,
1035 completed_at: DateTime<Utc>,
1036 outcome: TuneOutcome,
1037 timing_metrics: Option<TimingMetrics>,
1038 failure_reason: Option<&str>,
1039 ) -> DbResult<TuneRunRow> {
1040 let timing_metrics_json = timing_metrics
1041 .map(|metrics| json_text("timing metrics", &metrics))
1042 .transpose()?;
1043 let row = sqlx::query(
1044 r"
1045 UPDATE tune_runs
1046 SET outcome = ?, completed_at = ?, timing_metrics_json = ?, failure_reason = ?
1047 WHERE id = ?
1048 RETURNING *
1049 ",
1050 )
1051 .bind(enum_to_text(&outcome)?)
1052 .bind(completed_at)
1053 .bind(timing_metrics_json)
1054 .bind(failure_reason)
1055 .bind(run_id)
1056 .fetch_one(pool)
1057 .await
1058 .map_err(DbError::Query)?;
1059
1060 row_to_tune_run(row)
1061 }
1062
1063 pub async fn get(pool: &SqlitePool, id: i64) -> DbResult<Option<TuneRunRow>> {
1065 let row = sqlx::query("SELECT * FROM tune_runs WHERE id = ?")
1066 .bind(id)
1067 .fetch_optional(pool)
1068 .await
1069 .map_err(DbError::Query)?;
1070 row.map(row_to_tune_run).transpose()
1071 }
1072
1073 pub async fn list(
1076 pool: &SqlitePool,
1077 filter: &TuneRunFilter,
1078 pagination: Pagination,
1079 ) -> DbResult<Vec<TuneRunRow>> {
1080 let mut builder: QueryBuilder<Sqlite> = QueryBuilder::new("SELECT * FROM tune_runs");
1081 push_filter(&mut builder, filter)?;
1082 builder.push(" ORDER BY started_at DESC LIMIT ");
1083 builder.push_bind(pagination.limit);
1084 builder.push(" OFFSET ");
1085 builder.push_bind(pagination.offset);
1086
1087 let rows = builder
1088 .build()
1089 .fetch_all(pool)
1090 .await
1091 .map_err(DbError::Query)?;
1092 rows.into_iter().map(row_to_tune_run).collect()
1093 }
1094
1095 pub async fn count(pool: &SqlitePool, filter: &TuneRunFilter) -> DbResult<i64> {
1098 let mut builder: QueryBuilder<Sqlite> = QueryBuilder::new("SELECT COUNT(*) FROM tune_runs");
1099 push_filter(&mut builder, filter)?;
1100 builder
1101 .build_query_scalar::<i64>()
1102 .fetch_one(pool)
1103 .await
1104 .map_err(DbError::Query)
1105 }
1106
1107 pub async fn count_deletable_matching(
1110 pool: &SqlitePool,
1111 filter: &TuneRunFilter,
1112 ) -> DbResult<i64> {
1113 let mut builder: QueryBuilder<Sqlite> = QueryBuilder::new("SELECT COUNT(*) FROM tune_runs");
1114 push_filter(&mut builder, filter)?;
1115 push_deletable_recovery_guard(&mut builder);
1116 builder
1117 .build_query_scalar::<i64>()
1118 .fetch_one(pool)
1119 .await
1120 .map_err(DbError::Query)
1121 }
1122
1123 pub async fn delete_matching(pool: &SqlitePool, filter: &TuneRunFilter) -> DbResult<u64> {
1137 let mut builder: QueryBuilder<Sqlite> = QueryBuilder::new("DELETE FROM tune_runs");
1138 push_filter(&mut builder, filter)?;
1139 push_deletable_recovery_guard(&mut builder);
1140 let result = builder
1141 .build()
1142 .execute(pool)
1143 .await
1144 .map_err(DbError::Query)?;
1145 Ok(result.rows_affected())
1146 }
1147
1148 pub async fn delete(pool: &SqlitePool, id: i64) -> DbResult<bool> {
1155 let result = sqlx::query(
1156 "DELETE FROM tune_runs WHERE id = ? AND (recovery_state IS NULL OR recovery_state NOT IN ('eligible', 'running', 'incomplete'))",
1157 )
1158 .bind(id)
1159 .execute(pool)
1160 .await
1161 .map_err(DbError::Query)?;
1162 Ok(result.rows_affected() > 0)
1163 }
1164
1165 pub async fn delete_for_demo_session(
1166 pool: &SqlitePool,
1167 id: i64,
1168 demo_session_id: i64,
1169 ) -> DbResult<bool> {
1170 let result = sqlx::query("DELETE FROM tune_runs WHERE id = ? AND demo_session_id = ?")
1171 .bind(id)
1172 .bind(demo_session_id)
1173 .execute(pool)
1174 .await
1175 .map_err(DbError::Query)?;
1176 Ok(result.rows_affected() > 0)
1177 }
1178}
1179
1180fn push_deletable_recovery_guard(builder: &mut QueryBuilder<Sqlite>) {
1181 builder.push(
1182 " AND (recovery_state IS NULL OR recovery_state NOT IN ('eligible', 'running', 'incomplete'))",
1183 );
1184}
1185
1186fn push_filter(builder: &mut QueryBuilder<Sqlite>, filter: &TuneRunFilter) -> DbResult<()> {
1191 builder.push(" WHERE 1=1");
1194
1195 if let Some(loop_id) = filter.loop_id {
1196 builder.push(" AND loop_id = ").push_bind(loop_id);
1197 }
1198 if let Some(demo_session_id) = filter.demo_session_id {
1199 builder
1200 .push(" AND demo_session_id = ")
1201 .push_bind(demo_session_id);
1202 }
1203 if let Some(process_type) = filter.process_type {
1204 builder
1205 .push(" AND process_type = ")
1206 .push_bind(enum_to_text(&process_type)?);
1207 }
1208 if let Some(controller_type) = filter.controller_type {
1209 builder
1210 .push(" AND controller_type = ")
1211 .push_bind(enum_to_text(&controller_type)?);
1212 }
1213 if let Some(outcome) = filter.outcome {
1214 builder
1215 .push(" AND outcome = ")
1216 .push_bind(enum_to_text(&outcome)?);
1217 }
1218 if let Some(driver) = filter.driver {
1219 builder
1220 .push(" AND driver = ")
1221 .push_bind(enum_to_text(&driver)?);
1222 }
1223 if let Some(opc_server) = &filter.opc_server {
1224 builder
1225 .push(" AND opc_server = ")
1226 .push_bind(opc_server.clone());
1227 }
1228 if let Some(bridge_host) = &filter.bridge_host {
1229 builder
1230 .push(" AND bridge_host = ")
1231 .push_bind(bridge_host.clone());
1232 }
1233 if let Some(started_after) = filter.started_after {
1234 builder.push(" AND started_at >= ").push_bind(started_after);
1235 }
1236 if let Some(started_before) = filter.started_before {
1237 builder
1238 .push(" AND started_at <= ")
1239 .push_bind(started_before);
1240 }
1241 if let Some(template_name) = &filter.template_name {
1242 builder
1243 .push(" AND template_name = ")
1244 .push_bind(template_name.clone());
1245 }
1246 if let Some(template_origin) = filter.template_origin {
1247 builder
1248 .push(" AND template_origin = ")
1249 .push_bind(enum_to_text(&template_origin)?);
1250 }
1251 Ok(())
1252}
1253
1254fn row_to_tune_run(row: SqliteRow) -> DbResult<TuneRunRow> {
1255 let pv_ini: Option<f32> = row.try_get("pv_ini").map_err(DbError::Query)?;
1256 let initial_readings = match pv_ini {
1257 Some(pv_ini) => {
1258 let controller_direction: String = row
1259 .try_get("controller_direction")
1260 .map_err(DbError::Query)?;
1261 Some(TuneRunInitialReadings {
1262 pv_ini,
1263 mv_ini: row.try_get("mv_ini").map_err(DbError::Query)?,
1264 mv_range_low: row.try_get("mv_range_low").map_err(DbError::Query)?,
1265 mv_range_high: row.try_get("mv_range_high").map_err(DbError::Query)?,
1266 pv_range_high: row.try_get("pv_range_high").map_err(DbError::Query)?,
1267 pv_range_low: row.try_get("pv_range_low").map_err(DbError::Query)?,
1268 controller_direction: text_to_enum("controller_direction", &controller_direction)?,
1269 mode_raw: row.try_get("mode_raw").map_err(DbError::Query)?,
1270 mode_attribute_raw: row.try_get("mode_attribute_raw").map_err(DbError::Query)?,
1271 setpoint_ini: row.try_get("setpoint_ini").map_err(DbError::Query)?,
1272 })
1273 }
1274 None => None,
1275 };
1276
1277 let process_type: String = row.try_get("process_type").map_err(DbError::Query)?;
1278 let controller_type: String = row.try_get("controller_type").map_err(DbError::Query)?;
1279 let config = LoopConfig {
1280 process_type: text_to_enum("process_type", &process_type)?,
1281 controller_type: text_to_enum("controller_type", &controller_type)?,
1282 relay_amp_percent: row.try_get("relay_amp_percent").map_err(DbError::Query)?,
1283 num_cycles_skip: row.try_get("num_cycles_skip").map_err(DbError::Query)?,
1284 num_cycles_count: row.try_get("num_cycles_count").map_err(DbError::Query)?,
1285 noise_protection_secs: row
1286 .try_get("noise_protection_secs")
1287 .map_err(DbError::Query)?,
1288 mrft_delay_secs: row.try_get("mrft_delay_secs").map_err(DbError::Query)?,
1289 };
1290
1291 let driver: String = row.try_get("driver").map_err(DbError::Query)?;
1292 let outcome: String = row.try_get("outcome").map_err(DbError::Query)?;
1293
1294 let restore_status_text: Option<String> =
1295 row.try_get("restore_status").map_err(DbError::Query)?;
1296 let restore_status = restore_status_text
1297 .map(|text| text_to_enum("restore_status", &text))
1298 .transpose()?;
1299 let recovery_state_text: Option<String> =
1300 row.try_get("recovery_state").map_err(DbError::Query)?;
1301 let recovery_state = recovery_state_text
1302 .as_deref()
1303 .map(|text| text_to_enum("recovery_state", text))
1304 .transpose()?;
1305
1306 let template_origin: String = row.try_get("template_origin").map_err(DbError::Query)?;
1307 let template_snapshot_json: String = row
1308 .try_get("template_snapshot_json")
1309 .map_err(DbError::Query)?;
1310 let tags_json: String = row.try_get("tags_json").map_err(DbError::Query)?;
1311 let request_json: String = row.try_get("request_json").map_err(DbError::Query)?;
1312 let timing_metrics_json: Option<String> =
1313 row.try_get("timing_metrics_json").map_err(DbError::Query)?;
1314 let effective_tuning_json: Option<String> = row
1315 .try_get("effective_tuning_json")
1316 .map_err(DbError::Query)?;
1317 let gateway_compatibility_json: Option<String> = row
1318 .try_get("gateway_compatibility_json")
1319 .map_err(DbError::Query)?;
1320 let template: DcsTemplate =
1321 serde_json::from_str(&template_snapshot_json).map_err(|source| {
1322 DbError::InvalidJsonShape {
1323 column: "template_snapshot_json",
1324 source,
1325 }
1326 })?;
1327 let tags: LoopTags =
1328 serde_json::from_str(&tags_json).map_err(|source| DbError::InvalidJsonShape {
1329 column: "tags_json",
1330 source,
1331 })?;
1332 let timing_metrics = timing_metrics_json
1333 .map(|json| {
1334 serde_json::from_str(&json).map_err(|source| DbError::InvalidJsonShape {
1335 column: "timing_metrics_json",
1336 source,
1337 })
1338 })
1339 .transpose()?;
1340 let effective_tuning = effective_tuning_json
1341 .map(|json| {
1342 serde_json::from_str(&json).map_err(|source| DbError::InvalidJsonShape {
1343 column: "effective_tuning_json",
1344 source,
1345 })
1346 })
1347 .transpose()?;
1348
1349 Ok(TuneRunRow {
1350 id: row.try_get("id").map_err(DbError::Query)?,
1351 loop_id: row.try_get("loop_id").map_err(DbError::Query)?,
1352 demo_session_id: row.try_get("demo_session_id").map_err(DbError::Query)?,
1353 loop_name: row.try_get("loop_name").map_err(DbError::Query)?,
1354 driver: text_to_enum("driver", &driver)?,
1355 opc_server: row.try_get("opc_server").map_err(DbError::Query)?,
1356 bridge_host: row.try_get("bridge_host").map_err(DbError::Query)?,
1357 started_at: row.try_get("started_at").map_err(DbError::Query)?,
1358 completed_at: row.try_get("completed_at").map_err(DbError::Query)?,
1359 outcome: text_to_enum("outcome", &outcome)?,
1360 failure_reason: row.try_get("failure_reason").map_err(DbError::Query)?,
1361 config,
1362 template_origin: text_to_enum("template_origin", &template_origin)?,
1363 template,
1364 tags,
1365 request_json,
1366 notes: row.try_get("notes").map_err(DbError::Query)?,
1367 initial_readings,
1368 allow_uncertain_quality: row
1369 .try_get("allow_uncertain_quality")
1370 .map_err(DbError::Query)?,
1371 timing_metrics,
1372 effective_tuning,
1373 gateway_compatibility_json,
1374 restore_status,
1375 restore_detail: row.try_get("restore_detail").map_err(DbError::Query)?,
1376 recovery_state,
1377 recovery_evidence_json: row
1378 .try_get("recovery_evidence_json")
1379 .map_err(DbError::Query)?,
1380 created_at: row.try_get("created_at").map_err(DbError::Query)?,
1381 })
1382}
1383#[cfg(test)]
1386mod tests {
1387 use super::*;
1388 use crate::convert::{enum_to_text, text_to_enum};
1389
1390 async fn sample_run() -> (crate::SqlitePool, i64) {
1391 let pool = crate::connect_in_memory().await.unwrap();
1392 let template = bhtune_core::built_in_templates().remove(0);
1393 let tags = bhtune_core::LoopTags::derive_from_pv_tag("Unit1.FIC101.PV", &template);
1394 let config = bhtune_core::LoopConfig {
1395 process_type: bhtune_core::ProcessType::Flow,
1396 controller_type: bhtune_core::ControllerType::Pi,
1397 relay_amp_percent: 5.0,
1398 num_cycles_skip: 1,
1399 num_cycles_count: 2,
1400 noise_protection_secs: 3,
1401 mrft_delay_secs: 0,
1402 };
1403 let run = TuneRunRow::start(
1404 &pool,
1405 None,
1406 "Unit1.FIC101.PV",
1407 TuneDriver::Simulator,
1408 config,
1409 TemplateOrigin::Builtin,
1410 &template,
1411 &tags,
1412 chrono::Utc::now(),
1413 )
1414 .await
1415 .unwrap();
1416 (pool, run.id)
1417 }
1418
1419 #[test]
1420 fn tune_driver_round_trips_and_matches_check_constraint() {
1421 let cases = [
1422 (TuneDriver::Opcda, "opcda"),
1423 (TuneDriver::Simulator, "simulator"),
1424 (TuneDriver::Replay, "replay"),
1425 ];
1426 for (variant, text) in cases {
1427 assert_eq!(enum_to_text(&variant).unwrap(), text);
1428 assert_eq!(text_to_enum::<TuneDriver>("driver", text).unwrap(), variant);
1429 }
1430 }
1431
1432 #[test]
1433 fn tune_outcome_round_trips_and_matches_check_constraint() {
1434 let cases = [
1435 (TuneOutcome::Running, "running"),
1436 (TuneOutcome::Completed, "completed"),
1437 (TuneOutcome::Failed, "failed"),
1438 (TuneOutcome::Aborted, "aborted"),
1439 ];
1440 for (variant, text) in cases {
1441 assert_eq!(enum_to_text(&variant).unwrap(), text);
1442 assert_eq!(
1443 text_to_enum::<TuneOutcome>("outcome", text).unwrap(),
1444 variant
1445 );
1446 }
1447 }
1448
1449 #[tokio::test]
1450 async fn record_restore_status_round_trips_both_status_shapes() {
1451 let (pool, run_id) = sample_run().await;
1452 let confirmed =
1453 TuneRunRow::record_restore_status(&pool, run_id, RestoreStatus::Confirmed, None)
1454 .await
1455 .unwrap();
1456 assert_eq!(confirmed.restore_status, Some(RestoreStatus::Confirmed));
1457 assert_eq!(confirmed.restore_detail, None);
1458
1459 let incomplete = TuneRunRow::record_restore_status(
1460 &pool,
1461 run_id,
1462 RestoreStatus::Incomplete,
1463 Some("MV restore failed"),
1464 )
1465 .await
1466 .unwrap();
1467 assert_eq!(incomplete.restore_status, Some(RestoreStatus::Incomplete));
1468 assert_eq!(
1469 incomplete.restore_detail.as_deref(),
1470 Some("MV restore failed")
1471 );
1472 }
1473
1474 #[tokio::test]
1475 async fn tune_run_delete_reports_both_existing_and_missing_ids() {
1476 let (pool, run_id) = sample_run().await;
1477 assert!(TuneRunRow::delete(&pool, run_id).await.unwrap());
1478 assert!(!TuneRunRow::delete(&pool, run_id).await.unwrap());
1479 }
1480
1481 #[tokio::test]
1482 async fn deletion_preserves_runs_with_unresolved_live_recovery() {
1483 for recovery_state in ["eligible", "running", "incomplete"] {
1484 let (pool, run_id) = sample_run().await;
1485 sqlx::query("UPDATE tune_runs SET outcome = 'failed', recovery_state = ? WHERE id = ?")
1486 .bind(recovery_state)
1487 .bind(run_id)
1488 .execute(&pool)
1489 .await
1490 .unwrap();
1491
1492 assert_eq!(
1493 TuneRunRow::count_deletable_matching(&pool, &TuneRunFilter::default())
1494 .await
1495 .unwrap(),
1496 0
1497 );
1498 assert_eq!(
1499 TuneRunRow::delete_matching(&pool, &TuneRunFilter::default())
1500 .await
1501 .unwrap(),
1502 0
1503 );
1504 assert!(!TuneRunRow::delete(&pool, run_id).await.unwrap());
1505 assert!(TuneRunRow::get(&pool, run_id).await.unwrap().is_some());
1506 }
1507 }
1508
1509 #[tokio::test]
1510 async fn malformed_tune_run_json_is_reported_with_the_respective_column() {
1511 for (column, expected) in [
1512 ("template_snapshot_json", "template_snapshot_json"),
1513 ("tags_json", "tags_json"),
1514 ("timing_metrics_json", "timing_metrics_json"),
1515 ("effective_tuning_json", "effective_tuning_json"),
1516 ] {
1517 let (pool, run_id) = sample_run().await;
1518 let query = match column {
1519 "template_snapshot_json" => {
1520 sqlx::query("UPDATE tune_runs SET template_snapshot_json = ? WHERE id = ?")
1521 }
1522 "tags_json" => sqlx::query("UPDATE tune_runs SET tags_json = ? WHERE id = ?"),
1523 "timing_metrics_json" => {
1524 sqlx::query("UPDATE tune_runs SET timing_metrics_json = ? WHERE id = ?")
1525 }
1526 "effective_tuning_json" => {
1527 sqlx::query("UPDATE tune_runs SET effective_tuning_json = ? WHERE id = ?")
1528 }
1529 _ => unreachable!(),
1530 };
1531 query
1532 .bind("\"wrong-shape\"")
1533 .bind(run_id)
1534 .execute(&pool)
1535 .await
1536 .unwrap();
1537 let err = TuneRunRow::get(&pool, run_id).await.unwrap_err();
1538 assert!(matches!(
1539 err,
1540 DbError::InvalidJsonShape { column: actual, .. } if actual == expected
1541 ));
1542 }
1543 }
1544}