1use std::{collections::HashMap, sync::Arc, time::Duration};
2
3use bestool_canopy::schema::CheckSeverity;
4use futures::{StreamExt, future::BoxFuture, stream::BoxStream};
5use jiff::Timestamp;
6use miette::{Result, miette};
7use serde_json::{Value, json};
8use tokio::sync::{Mutex, mpsc};
9use tracing::warn;
10
11use crate::doctor::{
12 self,
13 check::{Check, CheckStatus},
14 progress::DoctorEvent,
15 stat::{MetricsSnapshot, StatusCounts},
16};
17use crate::tasks::TaskEndpointHandler;
18use crate::{BackgroundTask, TaskContext, TaskEndpoint, TaskEndpointResponse};
19
20const DOCTOR_INTERVAL: Duration = Duration::from_secs(60);
21
22pub type BackupDispatch = Arc<dyn Fn(Vec<String>) + Send + Sync>;
28
29fn cap_check(check: Check, severities: Option<&HashMap<String, CheckSeverity>>) -> Check {
33 match severities {
34 Some(map) => {
35 let ceiling = doctor::sweep::severity_ceiling(map, check.name);
36 Check {
37 status: check.status.cap_to(ceiling),
38 ..check
39 }
40 }
41 None => check,
42 }
43}
44
45pub struct DoctorTask {
51 inner: Arc<DoctorTaskInner>,
52}
53
54struct DoctorTaskInner {
55 binary_version: String,
56 tamanu: Option<doctor::SweepTamanu>,
59 pg_version_cache: Mutex<Option<String>>,
63 latest: Mutex<Option<LatestSweep>>,
67 check_severities: Mutex<Option<HashMap<String, CheckSeverity>>>,
73 backup_dispatch: Option<BackupDispatch>,
76}
77
78#[derive(Clone)]
79struct LatestSweep {
80 computed_at: Timestamp,
81 sweep: doctor::SweepResult,
84}
85
86impl DoctorTask {
87 pub fn new(binary_version: String, tamanu: Option<doctor::SweepTamanu>) -> Self {
88 Self {
89 inner: Arc::new(DoctorTaskInner {
90 binary_version,
91 tamanu,
92 pg_version_cache: Mutex::new(None),
93 latest: Mutex::new(None),
94 check_severities: Mutex::new(None),
95 backup_dispatch: None,
96 }),
97 }
98 }
99
100 pub fn with_backup_dispatch(self, dispatch: BackupDispatch) -> Self {
104 let mut inner =
105 Arc::try_unwrap(self.inner).unwrap_or_else(|_| panic!("DoctorTask already shared"));
106 inner.backup_dispatch = Some(dispatch);
107 Self {
108 inner: Arc::new(inner),
109 }
110 }
111
112 pub fn metrics_handle(&self) -> DoctorMetricsHandle {
115 DoctorMetricsHandle {
116 inner: self.inner.clone(),
117 }
118 }
119}
120
121#[derive(Clone)]
127pub struct DoctorMetricsHandle {
128 inner: Arc<DoctorTaskInner>,
129}
130
131impl DoctorMetricsHandle {
132 pub async fn snapshot(&self) -> Option<MetricsSnapshot> {
135 let latest = self.inner.latest.lock().await.clone()?;
136 let sweep = self.inner.capped(latest.sweep).await;
137
138 let counts = census(&sweep.results);
139 let stats = sweep
140 .results
141 .iter()
142 .flat_map(|(check, _)| check.stats.iter().map(|stat| (check.name, stat.clone())))
143 .collect();
144
145 Some(MetricsSnapshot {
146 computed_at: latest.computed_at,
147 stats,
148 counts,
149 })
150 }
151}
152
153fn census(results: &[(Check, bool)]) -> StatusCounts {
156 let mut counts = StatusCounts::default();
157 for (check, _) in results {
158 match &check.status {
159 CheckStatus::Pass => counts.passing += 1,
160 CheckStatus::Warning(_) => counts.warning += 1,
161 CheckStatus::Fail(_) => counts.failing += 1,
162 CheckStatus::Skip(_) => counts.skipped += 1,
163 CheckStatus::Broken(_) => counts.broken += 1,
164 }
165 }
166 counts
167}
168
169impl DoctorTaskInner {
170 async fn run_sweep(
171 self: &Arc<Self>,
172 ctx: &TaskContext,
173 progress: Option<doctor::progress::ProgressSender>,
174 enable_heal: bool,
175 ) -> Result<doctor::SweepResult> {
176 let cached = self.pg_version_cache.lock().await.clone();
177 let sweep = doctor::perform_sweep(
182 &self.binary_version,
183 self.tamanu.clone(),
184 ctx.http_client.clone(),
185 &[],
186 &[],
187 cached,
188 progress,
189 ctx.canopy_client.clone(),
190 enable_heal,
191 )
192 .await?;
193
194 if let Some(ref version) = sweep.pg_version {
195 let mut guard = self.pg_version_cache.lock().await;
196 if guard.is_none() {
197 *guard = Some(version.clone());
198 }
199 }
200
201 let latest = LatestSweep {
202 computed_at: Timestamp::now(),
203 sweep: sweep.clone(),
204 };
205 *self.latest.lock().await = Some(latest);
206
207 Ok(sweep)
208 }
209
210 async fn severities_snapshot(&self) -> Option<HashMap<String, CheckSeverity>> {
212 self.check_severities.lock().await.clone()
213 }
214
215 async fn capped(&self, mut sweep: doctor::SweepResult) -> doctor::SweepResult {
218 if let Some(severities) = self.severities_snapshot().await {
219 sweep.apply_severities(&severities);
220 }
221 sweep
222 }
223
224 async fn tick(self: &Arc<Self>, ctx: &TaskContext) -> Result<()> {
225 let sweep = self.run_sweep(ctx, None, true).await?;
226
227 let Some(server_id) = sweep.server_id else {
228 warn!("no metaServerId available; skipping canopy status push");
229 return Ok(());
230 };
231
232 let Some(canopy) = ctx.canopy_client.as_ref() else {
233 warn!("no canopy client available; skipping canopy status push");
234 return Ok(());
235 };
236
237 let response = canopy
238 .status(&server_id, &sweep.payload)
239 .await
240 .map_err(|err| miette!("posting doctor status to canopy: {err}"))?;
241
242 *self.check_severities.lock().await = Some(response.check_severities);
246
247 let tags = response.tags.0.into_iter().collect();
251 if let Err(err) = bestool_tamanu::server_info::save_cached_tags(&tags) {
252 warn!(%err, "could not refresh tags cache from status response");
253 }
254
255 let backup_now = response.backup_now;
256
257 if !backup_now.is_empty() {
258 match &self.backup_dispatch {
259 Some(dispatch) => dispatch(backup_now),
260 None => warn!(
261 ?backup_now,
262 "canopy requested a backup but no backup dispatcher is configured"
263 ),
264 }
265 }
266
267 Ok(())
268 }
269
270 async fn endpoint_latest(self: Arc<Self>) -> TaskEndpointResponse {
273 let snapshot = self.latest.lock().await.clone();
274 match snapshot {
275 Some(s) => {
276 let sweep = self.capped(s.sweep).await;
277 TaskEndpointResponse::Json(json!({
278 "computedAt": s.computed_at.to_string(),
279 "serverId": sweep.server_id,
280 "payload": sweep.payload,
281 }))
282 }
283 None => TaskEndpointResponse::Error {
284 status: 503,
285 message: "no doctor sweep cached yet (daemon may have just started)".into(),
286 },
287 }
288 }
289
290 async fn endpoint_recompute(self: Arc<Self>, ctx: TaskContext) -> TaskEndpointResponse {
293 let (progress_tx, mut progress_rx) = mpsc::unbounded_channel::<DoctorEvent>();
294 let (out_tx, out_rx) = mpsc::unbounded_channel::<Value>();
295
296 let severities = self.severities_snapshot().await;
299
300 let task_self = self.clone();
301 tokio::spawn(async move {
302 let progress_forward_tx = out_tx.clone();
303 let stream_severities = severities.clone();
304 let forwarder = tokio::spawn(async move {
305 while let Some(event) = progress_rx.recv().await {
306 let DoctorEvent::Completed(check) = event;
307 let check = cap_check(check, stream_severities.as_ref());
308 let _ = progress_forward_tx.send(json!({
309 "event": "check",
310 "check": check.to_streaming_json(),
311 }));
312 }
313 });
314
315 match task_self.run_sweep(&ctx, Some(progress_tx), false).await {
316 Ok(mut sweep) => {
317 if let Some(severities) = &severities {
318 sweep.apply_severities(severities);
319 }
320 let _ = forwarder.await;
324 let _ = out_tx.send(json!({
325 "event": "done",
326 "computedAt": Timestamp::now().to_string(),
327 "serverId": sweep.server_id,
328 "payload": sweep.payload,
329 }));
330 }
331 Err(err) => {
332 let _ = forwarder.await;
333 let _ = out_tx.send(json!({
334 "event": "error",
335 "message": format!("{err:?}"),
336 }));
337 }
338 }
339 });
340
341 let stream: BoxStream<'static, Value> =
342 Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(out_rx).map(|v| v));
343 TaskEndpointResponse::JsonLines(stream)
344 }
345}
346
347impl BackgroundTask for DoctorTask {
348 fn name(&self) -> &'static str {
349 "doctor"
350 }
351
352 fn interval(&self) -> Duration {
353 DOCTOR_INTERVAL
354 }
355
356 fn run<'a>(&'a self, ctx: &'a TaskContext) -> BoxFuture<'a, Result<()>> {
357 let inner = self.inner.clone();
358 Box::pin(async move { inner.tick(ctx).await })
359 }
360
361 fn http_endpoints(&self) -> Vec<TaskEndpoint> {
362 let latest_handler: TaskEndpointHandler = {
363 let inner = self.inner.clone();
364 Arc::new(move |_ctx| {
365 let inner = inner.clone();
366 Box::pin(async move { inner.endpoint_latest().await })
367 })
368 };
369
370 let recompute_handler: TaskEndpointHandler = {
371 let inner = self.inner.clone();
372 Arc::new(move |ctx| {
373 let inner = inner.clone();
374 Box::pin(async move { inner.endpoint_recompute(ctx).await })
375 })
376 };
377
378 vec![
379 TaskEndpoint {
380 name: "latest",
381 handler: latest_handler,
382 },
383 TaskEndpoint {
384 name: "recompute",
385 handler: recompute_handler,
386 },
387 ]
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::doctor::check::CheckStatus;
395
396 #[test]
397 fn cap_check_applies_ceiling_when_present() {
398 let mut severities = HashMap::new();
399 severities.insert("disk_free".to_string(), CheckSeverity::Warn);
400 let check = Check::fail("disk_free", "1% free", "out of space");
401 let capped = cap_check(check, Some(&severities));
402 match capped.status {
403 CheckStatus::Warning(r) => assert_eq!(r, "out of space"),
404 other => panic!("expected Warning, got {other:?}"),
405 }
406 }
407
408 #[test]
409 fn cap_check_absent_check_defaults_to_warn() {
410 let check = Check::fail("brand_new", "bad", "reason");
413 let capped = cap_check(check, Some(&HashMap::new()));
414 assert!(matches!(capped.status, CheckStatus::Warning(_)));
415 }
416
417 #[test]
418 fn cap_check_no_mapping_is_a_noop() {
419 let check = Check::fail("disk_free", "1% free", "out of space");
420 let capped = cap_check(check, None);
421 assert!(matches!(capped.status, CheckStatus::Fail(_)));
422 }
423
424 #[test]
425 fn census_counts_each_status() {
426 let results = vec![
427 (Check::pass("a", ""), true),
428 (Check::pass("b", ""), true),
429 (Check::warning("c", "", "w"), true),
430 (Check::fail("d", "", "f"), true),
431 (Check::skip("e", "", "s"), true),
432 (Check::broken("g", "", "b"), true),
433 ];
434 let c = census(&results);
435 assert_eq!(c.passing, 2);
436 assert_eq!(c.warning, 1);
437 assert_eq!(c.failing, 1);
438 assert_eq!(c.skipped, 1);
439 assert_eq!(c.broken, 1);
440 assert_eq!(c.total(), 6);
441 assert_eq!(c.active(), 5);
443 }
444
445 #[test]
446 fn census_reflects_severity_capping() {
447 let mut sweep = doctor::SweepResult {
450 server_id: None,
451 results: vec![(Check::fail("disk_free", "1% free", "out of space"), true)],
452 overall: doctor::check::OverallResult::Failing,
453 payload: json!({}),
454 pg_version: None,
455 };
456 let mut severities = HashMap::new();
457 severities.insert("disk_free".to_string(), CheckSeverity::Warn);
458 sweep.apply_severities(&severities);
459
460 let c = census(&sweep.results);
461 assert_eq!(c.failing, 0);
462 assert_eq!(c.warning, 1);
463 }
464}