Skip to main content

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}