Skip to main content

max / makenotwork

3.3 KB · 115 lines History Blame Raw
1 //! Incident storage: open, close, and the atomic close-and-open used when a
2 //! target moves between two non-operational statuses.
3
4 use super::{Result, SqlitePool};
5 use tracing::instrument;
6
7 #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)]
8 pub struct IncidentRow {
9 pub id: i64,
10 pub target: String,
11 pub started_at: String,
12 pub ended_at: Option<String>,
13 pub duration_secs: Option<i64>,
14 pub from_status: String,
15 pub to_status: String,
16 }
17
18 #[instrument(skip_all)]
19 pub async fn insert_incident(
20 pool: &SqlitePool,
21 target: &str,
22 from_status: &str,
23 to_status: &str,
24 ) -> Result<i64> {
25 let now = chrono::Utc::now().to_rfc3339();
26 let result = sqlx::query(
27 "INSERT INTO incidents (target, started_at, from_status, to_status)
28 VALUES (?, ?, ?, ?)",
29 )
30 .bind(target)
31 .bind(&now)
32 .bind(from_status)
33 .bind(to_status)
34 .execute(pool)
35 .await?;
36 Ok(result.last_insert_rowid())
37 }
38
39 #[instrument(skip_all)]
40 pub async fn close_open_incidents(pool: &SqlitePool, target: &str) -> Result<u64> {
41 let now = chrono::Utc::now().to_rfc3339();
42 let result = sqlx::query(
43 "UPDATE incidents SET ended_at = ?, duration_secs = CAST((julianday(?) - julianday(started_at)) * 86400 AS INTEGER)
44 WHERE target = ? AND ended_at IS NULL",
45 )
46 .bind(&now)
47 .bind(&now)
48 .bind(target)
49 .execute(pool)
50 .await?;
51 Ok(result.rows_affected())
52 }
53
54 /// Close any open incidents for `target` and open a new one, atomically. A status
55 /// change like `error -> unreachable` closes the old incident and opens the new
56 /// one; done as two pool calls, a crash between them left the old incident closed
57 /// and the new one never recorded (fuzz-2026-07-06 crash atomicity). One tx.
58 #[instrument(skip_all)]
59 pub async fn close_and_open_incident(
60 pool: &SqlitePool,
61 target: &str,
62 from_status: &str,
63 to_status: &str,
64 ) -> Result<()> {
65 let now = chrono::Utc::now().to_rfc3339();
66 let mut tx = pool.begin().await?;
67 sqlx::query(
68 "UPDATE incidents SET ended_at = ?, duration_secs = CAST((julianday(?) - julianday(started_at)) * 86400 AS INTEGER)
69 WHERE target = ? AND ended_at IS NULL",
70 )
71 .bind(&now)
72 .bind(&now)
73 .bind(target)
74 .execute(&mut *tx)
75 .await?;
76 sqlx::query(
77 "INSERT INTO incidents (target, started_at, from_status, to_status) VALUES (?, ?, ?, ?)",
78 )
79 .bind(target)
80 .bind(&now)
81 .bind(from_status)
82 .bind(to_status)
83 .execute(&mut *tx)
84 .await?;
85 tx.commit().await?;
86 Ok(())
87 }
88
89 #[instrument(skip_all)]
90 pub async fn get_open_incident(pool: &SqlitePool, target: &str) -> Result<Option<IncidentRow>> {
91 Ok(sqlx::query_as::<_, IncidentRow>(
92 "SELECT id, target, started_at, ended_at, duration_secs, from_status, to_status
93 FROM incidents WHERE target = ? AND ended_at IS NULL ORDER BY id DESC LIMIT 1",
94 )
95 .bind(target)
96 .fetch_optional(pool)
97 .await?)
98 }
99
100 #[instrument(skip_all)]
101 pub async fn get_recent_incidents(
102 pool: &SqlitePool,
103 target: &str,
104 limit: i64,
105 ) -> Result<Vec<IncidentRow>> {
106 Ok(sqlx::query_as::<_, IncidentRow>(
107 "SELECT id, target, started_at, ended_at, duration_secs, from_status, to_status
108 FROM incidents WHERE target = ? ORDER BY id DESC LIMIT ?",
109 )
110 .bind(target)
111 .bind(limit)
112 .fetch_all(pool)
113 .await?)
114 }
115