kasl/db/server_pulse.rs
1//! The last pulse this machine sent to kasl-server, and how it went.
2//!
3//! The watcher sends the pulse and `kasl server status` reports on it, and
4//! the two are different processes: this row is how one tells the other. It
5//! holds the latest attempt and nothing behind it - the server keeps only the
6//! latest claim, and a local history of when someone was at their desk would
7//! be the very record the pulse was designed not to leave (ADR 0014 in
8//! kasl-server).
9//!
10//! ```rust,no_run
11//! # fn main() -> anyhow::Result<()> {
12//! use chrono::{Duration, Utc};
13//! use kasl::api::kasl_server::AgentState;
14//! use kasl::db::server_pulse::ServerPulse;
15//!
16//! let mut pulse = ServerPulse::new()?;
17//! let now = Utc::now();
18//! pulse.record_accepted(now, now + Duration::seconds(60), AgentState::Working, 180, 0)?;
19//!
20//! if let Some(last) = pulse.last()? {
21//! println!("last attempt at {}", last.attempted_at);
22//! }
23//! # Ok(())
24//! # }
25//! ```
26
27use crate::api::kasl_server::AgentState;
28use crate::db::db::Db;
29use anyhow::Result;
30use chrono::{DateTime, Utc};
31use rusqlite::{Connection, OptionalExtension};
32
33/// An accepted pulse replaces everything, the error included.
34const RECORD_ACCEPTED: &str = "INSERT INTO server_pulse
35 (id, attempted_at, next_at, sent_at, state, stale_after_seconds, clock_skew_seconds, error)
36 VALUES (1, ?1, ?2, ?1, ?3, ?4, ?5, NULL)
37 ON CONFLICT(id) DO UPDATE SET
38 attempted_at = excluded.attempted_at,
39 next_at = excluded.next_at,
40 sent_at = excluded.sent_at,
41 state = excluded.state,
42 stale_after_seconds = excluded.stale_after_seconds,
43 clock_skew_seconds = excluded.clock_skew_seconds,
44 error = NULL";
45
46/// A failed attempt leaves the last accepted pulse in place: "the last one
47/// that arrived" is what someone reading a failure needs next.
48const RECORD_FAILED: &str = "INSERT INTO server_pulse (id, attempted_at, next_at, error)
49 VALUES (1, ?1, ?2, ?3)
50 ON CONFLICT(id) DO UPDATE SET
51 attempted_at = excluded.attempted_at,
52 next_at = excluded.next_at,
53 error = excluded.error";
54
55const SELECT_LAST: &str = "SELECT attempted_at, next_at, sent_at, state, stale_after_seconds, clock_skew_seconds, error FROM server_pulse WHERE id = 1";
56
57const DELETE_ALL: &str = "DELETE FROM server_pulse";
58
59/// The latest attempt, as the watcher recorded it.
60#[derive(Debug, Clone, PartialEq)]
61pub struct PulseRecord {
62 /// When the watcher last tried, whatever came of it.
63 pub attempted_at: DateTime<Utc>,
64
65 /// When it means to try next. A watcher that is gone stops moving this,
66 /// which is how a reader tells "quiet because nothing changed" from
67 /// "quiet because nobody is sending".
68 pub next_at: DateTime<Utc>,
69
70 /// When a pulse last arrived; `None` until one has.
71 pub sent_at: Option<DateTime<Utc>>,
72
73 /// The state that pulse claimed.
74 pub state: Option<AgentState>,
75
76 /// How long the server believes a pulse, from its last answer.
77 pub stale_after_seconds: Option<i64>,
78
79 /// This machine's clock against the server's, from its last answer.
80 pub clock_skew_seconds: Option<i64>,
81
82 /// Why the last attempt did not arrive; `None` when it did.
83 pub error: Option<String>,
84}
85
86/// Access to the pulse row.
87pub struct ServerPulse {
88 pub conn: Connection,
89}
90
91impl ServerPulse {
92 /// Opens the database; the table comes from the migrations.
93 pub fn new() -> Result<Self> {
94 Ok(ServerPulse { conn: Db::new()?.conn })
95 }
96
97 /// Records a pulse the server accepted.
98 pub fn record_accepted(
99 &mut self,
100 at: DateTime<Utc>,
101 next_at: DateTime<Utc>,
102 state: AgentState,
103 stale_after_seconds: i64,
104 clock_skew_seconds: i64,
105 ) -> Result<()> {
106 self.conn.execute(
107 RECORD_ACCEPTED,
108 rusqlite::params![at, next_at, state.as_str(), stale_after_seconds, clock_skew_seconds],
109 )?;
110 Ok(())
111 }
112
113 /// Records an attempt that did not arrive, and why.
114 pub fn record_failed(&mut self, at: DateTime<Utc>, next_at: DateTime<Utc>, error: &str) -> Result<()> {
115 self.conn.execute(RECORD_FAILED, rusqlite::params![at, next_at, error])?;
116 Ok(())
117 }
118
119 /// The latest attempt, or `None` when there has been none since the pulse
120 /// was last turned on.
121 pub fn last(&self) -> Result<Option<PulseRecord>> {
122 let record = self
123 .conn
124 .query_row(SELECT_LAST, [], |row| {
125 let state: Option<String> = row.get(3)?;
126 Ok(PulseRecord {
127 attempted_at: row.get(0)?,
128 next_at: row.get(1)?,
129 sent_at: row.get(2)?,
130 state: state.as_deref().and_then(AgentState::parse),
131 stale_after_seconds: row.get(4)?,
132 clock_skew_seconds: row.get(5)?,
133 error: row.get(6)?,
134 })
135 })
136 .optional()?;
137 Ok(record)
138 }
139
140 /// Forgets the record, so what is reported next belongs to a pulse sent
141 /// after this moment rather than to one from before it was turned off.
142 pub fn clear(&mut self) -> Result<()> {
143 self.conn.execute(DELETE_ALL, [])?;
144 Ok(())
145 }
146}