Skip to main content

bestool_alertd/doctor/
task.rs

1use std::{sync::Arc, time::Duration};
2
3use futures::{StreamExt, future::BoxFuture, stream::BoxStream};
4use jiff::Timestamp;
5use miette::{Result, miette};
6use serde_json::{Value, json};
7use tokio::sync::{Mutex, mpsc};
8use tracing::warn;
9
10use crate::doctor::{self, progress::DoctorEvent};
11use crate::tasks::TaskEndpointHandler;
12use crate::{BackgroundTask, TaskContext, TaskEndpoint, TaskEndpointResponse};
13
14const DOCTOR_INTERVAL: Duration = Duration::from_secs(60);
15
16/// Invoked with the `backup_now` list from canopy's status response.
17///
18/// alertd has no backup logic of its own; the bestool binary supplies this to
19/// run the in-process backup driver. Fire-and-forget: the callback spawns its
20/// own work and guards against overlapping runs.
21pub type BackupDispatch = Arc<dyn Fn(Vec<String>) + Send + Sync>;
22
23/// Periodic doctor sweep, plus on-demand `latest` / `recompute` HTTP endpoints.
24///
25/// The outer struct just holds an `Arc<Inner>` so we can hand inner clones to
26/// the `'static` HTTP endpoint handlers without forcing the trait method
27/// `http_endpoints` to take `self: Arc<Self>`.
28pub struct DoctorTask {
29	inner: Arc<DoctorTaskInner>,
30}
31
32struct DoctorTaskInner {
33	binary_version: String,
34	/// `None` on hosts with no Tamanu deployment: sweeps still run (and post),
35	/// with all Tamanu-dependent checks skipped.
36	tamanu: Option<doctor::SweepTamanu>,
37	/// `SELECT version()` result, populated on the first tick that succeeds in
38	/// reaching the database. Stable for the lifetime of the PG instance, so we
39	/// reuse it across ticks instead of re-querying every minute.
40	pg_version_cache: Mutex<Option<String>>,
41	/// Latest sweep, captured on every successful tick. Served by the `latest`
42	/// HTTP endpoint so `bestool tamanu doctor` can read what the daemon
43	/// already computed instead of re-running the checks itself.
44	latest: Mutex<Option<LatestSweep>>,
45	/// Runs the backup driver for the types canopy asks for via `backup_now`.
46	/// `None` when backups aren't compiled in.
47	backup_dispatch: Option<BackupDispatch>,
48}
49
50#[derive(Clone)]
51struct LatestSweep {
52	computed_at: Timestamp,
53	payload: Value,
54	server_id: Option<String>,
55}
56
57impl DoctorTask {
58	pub fn new(binary_version: String, tamanu: Option<doctor::SweepTamanu>) -> Self {
59		Self {
60			inner: Arc::new(DoctorTaskInner {
61				binary_version,
62				tamanu,
63				pg_version_cache: Mutex::new(None),
64				latest: Mutex::new(None),
65				backup_dispatch: None,
66			}),
67		}
68	}
69
70	/// Attach the backup dispatcher invoked when canopy requests a backup.
71	///
72	/// Call right after [`DoctorTask::new`] (before the task is shared).
73	pub fn with_backup_dispatch(self, dispatch: BackupDispatch) -> Self {
74		let mut inner =
75			Arc::try_unwrap(self.inner).unwrap_or_else(|_| panic!("DoctorTask already shared"));
76		inner.backup_dispatch = Some(dispatch);
77		Self {
78			inner: Arc::new(inner),
79		}
80	}
81}
82
83impl DoctorTaskInner {
84	async fn run_sweep(
85		self: &Arc<Self>,
86		ctx: &TaskContext,
87		progress: Option<doctor::progress::ProgressSender>,
88	) -> Result<doctor::SweepResult> {
89		let cached = self.pg_version_cache.lock().await.clone();
90		let sweep = doctor::perform_sweep(
91			&self.binary_version,
92			self.tamanu.clone(),
93			ctx.http_client.clone(),
94			&[],
95			&[],
96			cached,
97			progress,
98		)
99		.await?;
100
101		if let Some(ref version) = sweep.pg_version {
102			let mut guard = self.pg_version_cache.lock().await;
103			if guard.is_none() {
104				*guard = Some(version.clone());
105			}
106		}
107
108		let latest = LatestSweep {
109			computed_at: Timestamp::now(),
110			payload: sweep.payload.clone(),
111			server_id: sweep.server_id.clone(),
112		};
113		*self.latest.lock().await = Some(latest);
114
115		Ok(sweep)
116	}
117
118	async fn tick(self: &Arc<Self>, ctx: &TaskContext) -> Result<()> {
119		let sweep = self.run_sweep(ctx, None).await?;
120
121		let Some(server_id) = sweep.server_id else {
122			warn!("no metaServerId available; skipping canopy status push");
123			return Ok(());
124		};
125
126		let Some(canopy) = ctx.canopy_client.as_ref() else {
127			warn!("no canopy client available; skipping canopy status push");
128			return Ok(());
129		};
130
131		let backup_now = canopy
132			.status(&server_id, &sweep.payload)
133			.await
134			.map_err(|err| miette!("posting doctor status to canopy: {err}"))?
135			.backup_now;
136
137		if !backup_now.is_empty() {
138			match &self.backup_dispatch {
139				Some(dispatch) => dispatch(backup_now),
140				None => warn!(
141					?backup_now,
142					"canopy requested a backup but no backup dispatcher is configured"
143				),
144			}
145		}
146
147		Ok(())
148	}
149
150	/// `GET /tasks/doctor/latest` — return the last sweep this daemon
151	/// computed, or 404 if it hasn't ticked yet.
152	async fn endpoint_latest(self: Arc<Self>) -> TaskEndpointResponse {
153		let snapshot = self.latest.lock().await.clone();
154		match snapshot {
155			Some(s) => TaskEndpointResponse::Json(json!({
156				"computedAt": s.computed_at.to_string(),
157				"serverId": s.server_id,
158				"payload": s.payload,
159			})),
160			None => TaskEndpointResponse::Error {
161				status: 503,
162				message: "no doctor sweep cached yet (daemon may have just started)".into(),
163			},
164		}
165	}
166
167	/// `GET /tasks/doctor/recompute` — drive a fresh sweep and stream each
168	/// progress event back as NDJSON. Final line is the full sweep result.
169	async fn endpoint_recompute(self: Arc<Self>, ctx: TaskContext) -> TaskEndpointResponse {
170		let (progress_tx, mut progress_rx) = mpsc::unbounded_channel::<DoctorEvent>();
171		let (out_tx, out_rx) = mpsc::unbounded_channel::<Value>();
172
173		let task_self = self.clone();
174		tokio::spawn(async move {
175			let progress_forward_tx = out_tx.clone();
176			let forwarder = tokio::spawn(async move {
177				while let Some(event) = progress_rx.recv().await {
178					let DoctorEvent::Completed(check) = event;
179					let _ = progress_forward_tx.send(json!({
180						"event": "check",
181						"check": check.to_streaming_json(),
182					}));
183				}
184			});
185
186			match task_self.run_sweep(&ctx, Some(progress_tx)).await {
187				Ok(sweep) => {
188					// Make sure all `Completed` events arrived before we emit
189					// `done` — perform_sweep drops the sender on return, which
190					// closes the forwarder loop above.
191					let _ = forwarder.await;
192					let _ = out_tx.send(json!({
193						"event": "done",
194						"computedAt": Timestamp::now().to_string(),
195						"serverId": sweep.server_id,
196						"payload": sweep.payload,
197					}));
198				}
199				Err(err) => {
200					let _ = forwarder.await;
201					let _ = out_tx.send(json!({
202						"event": "error",
203						"message": format!("{err:?}"),
204					}));
205				}
206			}
207		});
208
209		let stream: BoxStream<'static, Value> =
210			Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(out_rx).map(|v| v));
211		TaskEndpointResponse::JsonLines(stream)
212	}
213}
214
215impl BackgroundTask for DoctorTask {
216	fn name(&self) -> &'static str {
217		"doctor"
218	}
219
220	fn interval(&self) -> Duration {
221		DOCTOR_INTERVAL
222	}
223
224	fn run<'a>(&'a self, ctx: &'a TaskContext) -> BoxFuture<'a, Result<()>> {
225		let inner = self.inner.clone();
226		Box::pin(async move { inner.tick(ctx).await })
227	}
228
229	fn http_endpoints(&self) -> Vec<TaskEndpoint> {
230		let latest_handler: TaskEndpointHandler = {
231			let inner = self.inner.clone();
232			Arc::new(move |_ctx| {
233				let inner = inner.clone();
234				Box::pin(async move { inner.endpoint_latest().await })
235			})
236		};
237
238		let recompute_handler: TaskEndpointHandler = {
239			let inner = self.inner.clone();
240			Arc::new(move |ctx| {
241				let inner = inner.clone();
242				Box::pin(async move { inner.endpoint_recompute(ctx).await })
243			})
244		};
245
246		vec![
247			TaskEndpoint {
248				name: "latest",
249				handler: latest_handler,
250			},
251			TaskEndpoint {
252				name: "recompute",
253				handler: recompute_handler,
254			},
255		]
256	}
257}