bestool_alertd/tasks.rs
1use std::{collections::BTreeMap, sync::Arc, time::Duration};
2
3use futures::{future::BoxFuture, stream::BoxStream};
4use miette::Result;
5use serde_json::Value;
6
7use crate::{canopy::CanopyClient, context::InternalContext};
8
9/// Shared resources passed to background tasks on every tick.
10///
11/// The `reqwest::Client` is shared with the daemon's other consumers so its
12/// connection pool stays warm across tick intervals.
13#[derive(Clone)]
14pub struct TaskContext {
15 /// `None` on hosts with no Tamanu deployment (and therefore no database).
16 pub pg_pool: Option<bestool_postgres::pool::PgPool>,
17 pub http_client: reqwest::Client,
18 pub canopy_client: Option<Arc<CanopyClient>>,
19 /// Bumped on each reload request (SIGHUP/SIGUSR1); a task can
20 /// `reload.changed().await` to refresh its state without a restart.
21 pub reload: tokio::sync::watch::Receiver<u64>,
22 /// Handle to ask the daemon to restart itself, for a task that has replaced
23 /// the running binary. `None` in detached test contexts.
24 pub restart: Option<crate::daemon::RestartTrigger>,
25 /// Query parameters of the request, for HTTP endpoint handlers. Empty on the
26 /// periodic `run` tick.
27 pub query: BTreeMap<String, String>,
28}
29
30impl TaskContext {
31 pub(crate) fn from_internal(ctx: &InternalContext) -> Self {
32 Self {
33 pg_pool: ctx.pg_pool.clone(),
34 http_client: ctx.http_client.clone(),
35 canopy_client: ctx.canopy_client.clone(),
36 reload: ctx.reload.clone(),
37 restart: ctx.restart.clone(),
38 query: BTreeMap::new(),
39 }
40 }
41}
42
43/// A periodic background task registered with the daemon.
44///
45/// The daemon spawns one tokio task per registered plugin at startup and ticks
46/// it at `interval()`. Each tick counts as activity for the watchdog. Errors
47/// returned from `run` are logged but don't kill the daemon.
48///
49/// Tasks can optionally expose HTTP endpoints (e.g. to surface their latest
50/// computed state, or to trigger an on-demand re-run) via [`Self::http_endpoints`].
51/// The daemon mounts each at `/tasks/{task-name}/{endpoint-name}` and routes
52/// matching requests to the handler.
53pub trait BackgroundTask: Send + Sync + 'static {
54 fn name(&self) -> &'static str;
55 fn interval(&self) -> Duration;
56 fn run<'a>(&'a self, ctx: &'a TaskContext) -> BoxFuture<'a, Result<()>>;
57 /// Endpoints this task wants the daemon to expose under
58 /// `/tasks/{self.name()}/{endpoint.name}`.
59 ///
60 /// Default is "no endpoints". Endpoint handlers are 'static closures, so
61 /// they should capture an `Arc` of whatever shared state they need rather
62 /// than borrowing from `self`.
63 fn http_endpoints(&self) -> Vec<TaskEndpoint> {
64 Vec::new()
65 }
66}
67
68/// One HTTP endpoint a `BackgroundTask` exposes through the daemon.
69pub struct TaskEndpoint {
70 /// Name segment appended after `/tasks/{task}/` to form the URL path.
71 pub name: &'static str,
72 pub handler: TaskEndpointHandler,
73}
74
75/// Handler invoked when a request hits `/tasks/{task}/{endpoint}`.
76///
77/// The daemon hands the handler a fresh `TaskContext` (built from the
78/// daemon's own resources) and awaits the future.
79pub type TaskEndpointHandler =
80 Arc<dyn Fn(TaskContext) -> BoxFuture<'static, TaskEndpointResponse> + Send + Sync + 'static>;
81
82/// What an endpoint handler returns to the daemon for it to serialise.
83///
84/// `JsonLines` is for streaming: the daemon writes each yielded `Value` as a
85/// JSON-encoded line followed by `\n`, with `Content-Type:
86/// application/x-ndjson`. That's how the doctor's "recompute" endpoint
87/// surfaces per-check progress to consumers like `bestool tamanu doctor`.
88pub enum TaskEndpointResponse {
89 Json(Value),
90 JsonLines(BoxStream<'static, Value>),
91 /// 4xx / 5xx response with a plain-text body.
92 Error {
93 status: u16,
94 message: String,
95 },
96}