bestool_alertd/doctor/
task.rs1use 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
16pub type BackupDispatch = Arc<dyn Fn(Vec<String>) + Send + Sync>;
22
23pub struct DoctorTask {
29 inner: Arc<DoctorTaskInner>,
30}
31
32struct DoctorTaskInner {
33 binary_version: String,
34 tamanu: Option<doctor::SweepTamanu>,
37 pg_version_cache: Mutex<Option<String>>,
41 latest: Mutex<Option<LatestSweep>>,
45 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 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 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 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 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}