Skip to content

refactor(alertd): move the daemon into the bestool binary (K2) - #890

Merged
passcod merged 33 commits into
mainfrom
workhorse/k2
Sep 15, 2026
Merged

passcod merged 33 commits into
mainfrom
workhorse/k2

Conversation

@passcod

@passcod passcod commented Sep 14, 2026

Copy link
Copy Markdown
Member

🦸 Review Hero

  • Run Review Hero

Leave the checks and their machinery in bestool-alertd, and move the daemon
that schedules them up into bestool, behind the existing `alertd` feature.

A consumer that embeds the crate to harvest checks no longer pulls in the
HTTP server, the backup registry, the Windows service registration, or the
child-confinement code along with them. The crate loses its direct axum,
tower-http, tokio-stream, sd-notify, win32job and windows-service
dependencies, plus bestool-kopia, which it had stopped using.

The daemon, its server, its tasks and its backups now live in
crates/bestool/src/alertd/, with the clap CLI staying in actions/alertd.rs.
`bestool tamanu doctor` keeps working with the daemon feature off.

Since removing `run`, `DaemonConfig` and `BackgroundTask` from the public API
is a major bump regardless, `doctor::` is flattened to the crate root in the
same bump: `doctor::checks::all()` becomes `checks::all()`.

VERSION, http_builder and http_client stay behind, so the outbound
User-Agent remains bestool-alertd's version rather than the binary's.

The postgres-less CI job now runs the whole lib test binary, since the
module path it filtered on is gone and the crate no longer holds the HTTP
server tests that the filter existed to exclude.

No behaviour changes.

Co-authored-by: Claude <noreply@anthropic.com>
Comment thread crates/bestool/Cargo.toml
Comment thread crates/bestool/src/alertd.rs Outdated
@review-hero

review-hero Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary (round 1)
9 agents reviewed this PR | 1 critical | 1 suggestion | 0 nitpicks | Filtering: consensus 3 voters

Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`crates/bestool/Cargo.toml:116`: The `alertd` feature no longer compiles on its own. The daemon code moved into `crates/bestool/src/alertd/`, which uses `bestool_postgres::pool::PgPool` unconditionally (`alertd.rs:40`, `alertd/context.rs:12`, `alertd/tasks.rs:22`) and `bestool_tamanu` unconditionally (`alertd/http_server/endpoints/seedling.rs:2`, `alertd/doctor.rs:330` which needs `bestool-tamanu/canopy-registration` for `save_cached_tags`, plus `bestool_tamanu::pm2` on Windows). Both crates are *optional* deps of `bestool` and the new `alertd` feature list only adds `bestool-alertd`, `bestool-canopy`, `axum`, `tower-http`, `tokio-stream`, `sd-notify`, `win32job`, `windows-service`. Previously this code lived in `bestool-alertd`, which depends on both unconditionally, so the gap didn't exist. Building `--no-default-features --features alertd` (an explicitly supported config: see the comment at line 154-158 "a Tamanu-less build can still enable `alertd` alone", and the `#[cfg(not(feature = "alertd-tamanu"))] build_config` in `actions/alertd.rs`) now fails with unresolved crates `bestool_postgres`/`bestool_tamanu`. The card's build matrix only verified defaults and `alertd` *off*, so CI won't catch it. Fix: add `"dep:bestool-postgres"`, `"__tamanu"` (or at minimum `"dep:bestool-tamanu"` + the `node-semver` used by `alertd/doctor.rs` tests) and `"bestool-tamanu/canopy-registration"` to the `alertd` feature, and add a `--no-default-features --features alertd` check job.

-------

`crates/bestool/src/alertd.rs:87`: `DaemonConfig`'s hand-written `Debug` prints `database_url` verbatim, and that string is the Tamanu postgres URL built in `build_config` (`tamanu.database_url`), which carries the database password (`postgres://user:pass@host/db`). The field doc even claims it is "retained for redacted display", and the sibling `device_key_pem` is wrapped in `Redacted` precisely "so debug-logging the config can't leak the key" — so this field is the one hole in that policy. Any future `debug!(?daemon_config)` (or a panic message formatting the config) writes the DB credentials to the daemon's log. Since the move has already reshaped this type, make the invariant hold: either store it as `Redacted<String>`, or emit a redacted form in the `Debug` impl (e.g. parse and blank the password, or print only host/dbname) rather than the raw URL.

passcod and others added 3 commits September 14, 2026 21:29
The daemon moved into bestool, where bestool-postgres, bestool-tamanu and
node-semver are optional deps; the alertd feature didn't enable them. It
built before because the code lived in bestool-alertd, which depends on
them unconditionally.

A Tamanu-less host running the daemon alone is an explicitly supported
configuration, and nothing in CI built it, so add a job that does and gate
tests-pass on it.

Co-authored-by: Claude <noreply@anthropic.com>
The field's own doc said "retained for redacted display" while the
hand-written Debug printed the postgres URL verbatim, password and all, so
any debug-logging of the config would leak it. The sibling device key was
already wrapped for exactly this reason.

Nothing reads the field to connect; it exists only for display.

Co-authored-by: Claude <noreply@anthropic.com>
@review-hero

review-hero Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦸 Review Hero (could not post inline comments — showing here instead)

crates/bestool/src/alertd/http_server/endpoints/metrics.rs:77

[Bugs & Correctness] critical

InternalContext::restart is now #[cfg(windows)] (crates/bestool/src/alertd/context.rs:21), but this test's struct literal sets restart: None unconditionally. On non-Windows this is a compile error (struct InternalContext has no field named restart), so cargo test / cargo clippy --all-targets on Linux fails to build the bestool test target. The sibling helper at http_server/test_utils.rs:19-20 got the #[cfg(windows)] attribute; this one was missed — a plain cargo check (no --tests) wouldn't have caught it. Add #[cfg(windows)] above the restart: None, line here too.


crates/bestool/src/alertd.rs:125

[Design & Architecture] suggestion

with_binary_version is now dead plumbing. DaemonConfig::new already defaults binary_version to env!("CARGO_PKG_VERSION"), and both remaining call sites (actions/alertd.rs:700 and :747) pass exactly env!("CARGO_PKG_VERSION").to_string() — i.e. the same value, resolved in the same crate. The builder existed only because the config used to live in a different crate and couldn't see bestool's version; that reason is gone with the move. Drop the method and the two call sites (and consider making the field a &'static str rather than an owned String cloned per status render).


crates/alertd/Cargo.toml:15

[Design & Architecture] suggestion

The move leaves VERSION, http_builder, and http_client behind in the now checks-only crate, but nothing inside crates/alertd/src calls them any more — every check takes a client from its context (ctx.http_client), and the only callers are crates/bestool/src/alertd/{commands,daemon}.rs. So the crate documented as "the checks and nothing that schedules them" still owns the daemon's HTTP client factory, and the daemon reaches back across the seam for it. The stated reason (keeping the UA at bestool-alertd/<alertd version>) only needs the version string: expose pub const USER_AGENT: &str (or keep VERSION) here and build the client in crate::alertd, which is where the daemon's HTTP concerns now live.


crates/bestool/src/alertd/tasks.rs:32

[Design & Architecture] suggestion

restart becoming #[cfg(windows)] makes TaskContext's shape platform-dependent, so its construction sites need matching cfgs in four files (tasks.rs twice, context.rs, daemon.rs, http_server/test_utils.rs) and any future cross-platform task that wants a restart must cfg-gate too. The sibling field in the same struct (pg_pool) solves the identical "unused after the move" problem with #[expect(dead_code, reason = ...)], which keeps the type uniform. Prefer one strategy for both — expect(dead_code) on restart costs one attribute instead of five cfgs and keeps TaskContext the same type everywhere.


crates/bestool/src/alertd/tasks.rs:22

[Performance] suggestion

pg_pool is now formally dead (#[expect(dead_code)]) yet the pool is still cloned from DaemonConfig into InternalContext and again into every TaskContext (daemon.rs:93,144; tasks.rs:41), so the daemon keeps a mobc pool alive for the whole process lifetime with no reader. build_config uses the pool for one-off setup queries, so by the time the daemon starts it holds live idle connections (mobc default max_idle, recycled every max_lifetime = 3600s) against postgres that nothing will ever check out — while each doctor sweep opens its own connection from the URL instead. On a host with several deployments that is a per-daemon idle backend held open forever for nothing. Since the move has made the unused-ness explicit and machine-checked, this is the cheap moment to drop the field from DaemonConfig/InternalContext/TaskContext and let build_config's pool drop when it returns; re-plumbing it is trivial if a task ever claims it.


crates/bestool/src/alertd.rs:87

[Security] suggestion

DaemonConfig's Debug impl prints database_url verbatim while the field's own doc claims it is "retained for redacted display". The value is the Tamanu connection URL built in build_config, which carries the postgres user and password, so any debug!("{config:?}") (or a ?config span field added later) writes DB credentials into the daemon's logs — logs which on a Tamanu host are typically readable well beyond whoever may read the Tamanu config. device_key_pem right beside it is correctly wrapped in Redacted; database_url is not. Since this file is where the type now lives, worth closing the gap while moving it: either store the URL as Redacted<String> (or a redacting newtype that strips userinfo) or, at minimum, redact it in the Debug impl rather than passing the raw string to .field(...).

@review-hero

review-hero Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary (round 2)
11 agents reviewed this PR | 1 failed | 1 critical | 5 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 4 below threshold, 1 suppressed

Below consensus threshold (4 unique issues not confirmed by majority)
Location Agent Severity Comment
crates/alertd/README.md:1 Design & Architecture nitpick After this move the crate named bestool-alertd contains no alertd — its own README describes it as the check registry plus sweep machinery, and the daemon it was named for now lives in bestool....
crates/bestool/Cargo.toml:122 Design & Architecture suggestion The alertd feature now requires bestool-tamanu plus its seedling and canopy-registration features, which contradicts the comment three lines above it ("bestool-canopy is core, not Tamanu-ga...
crates/bestool/src/alertd/doctor.rs:11 Design & Architecture nitpick Importing the checks crate as self as doctor inside a module that is itself crate::alertd::doctor gives two different things the name doctor in the same file: doctor::perform_sweep is the c...
crates/bestool/src/alertd/tasks.rs:22 Design & Architecture suggestion TaskContext::pg_pool is now confirmed dead (the expect(dead_code) says so) and TaskContext is no longer a public library type — it's pub(crate)-reachable only, so there is no external imple...
Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`crates/bestool/src/alertd/http_server/endpoints/metrics.rs:77`: `InternalContext::restart` is now `#[cfg(windows)]` (crates/bestool/src/alertd/context.rs:21), but this test's struct literal sets `restart: None` unconditionally. On non-Windows this is a compile error (`struct InternalContext has no field named restart`), so `cargo test` / `cargo clippy --all-targets` on Linux fails to build the bestool test target. The sibling helper at http_server/test_utils.rs:19-20 got the `#[cfg(windows)]` attribute; this one was missed — a plain `cargo check` (no `--tests`) wouldn't have caught it. Add `#[cfg(windows)]` above the `restart: None,` line here too.

-------

`crates/bestool/src/alertd.rs:125`: `with_binary_version` is now dead plumbing. `DaemonConfig::new` already defaults `binary_version` to `env!("CARGO_PKG_VERSION")`, and both remaining call sites (actions/alertd.rs:700 and :747) pass exactly `env!("CARGO_PKG_VERSION").to_string()` — i.e. the same value, resolved in the same crate. The builder existed only because the config used to live in a different crate and couldn't see bestool's version; that reason is gone with the move. Drop the method and the two call sites (and consider making the field a `&'static str` rather than an owned `String` cloned per status render).

-------

`crates/alertd/Cargo.toml:15`: The move leaves `VERSION`, `http_builder`, and `http_client` behind in the now checks-only crate, but nothing inside `crates/alertd/src` calls them any more — every check takes a client from its context (`ctx.http_client`), and the only callers are `crates/bestool/src/alertd/{commands,daemon}.rs`. So the crate documented as "the checks and nothing that schedules them" still owns the daemon's HTTP client factory, and the daemon reaches back across the seam for it. The stated reason (keeping the UA at `bestool-alertd/<alertd version>`) only needs the version string: expose `pub const USER_AGENT: &str` (or keep `VERSION`) here and build the client in `crate::alertd`, which is where the daemon's HTTP concerns now live.

-------

`crates/bestool/src/alertd/tasks.rs:32`: `restart` becoming `#[cfg(windows)]` makes `TaskContext`'s shape platform-dependent, so its construction sites need matching cfgs in four files (tasks.rs twice, context.rs, daemon.rs, http_server/test_utils.rs) and any future cross-platform task that wants a restart must cfg-gate too. The sibling field in the same struct (`pg_pool`) solves the identical "unused after the move" problem with `#[expect(dead_code, reason = ...)]`, which keeps the type uniform. Prefer one strategy for both — `expect(dead_code)` on `restart` costs one attribute instead of five cfgs and keeps `TaskContext` the same type everywhere.

-------

`crates/bestool/src/alertd/tasks.rs:22`: `pg_pool` is now formally dead (`#[expect(dead_code)]`) yet the pool is still cloned from `DaemonConfig` into `InternalContext` and again into every `TaskContext` (daemon.rs:93,144; tasks.rs:41), so the daemon keeps a mobc pool alive for the whole process lifetime with no reader. `build_config` uses the pool for one-off setup queries, so by the time the daemon starts it holds live idle connections (mobc default max_idle, recycled every `max_lifetime` = 3600s) against postgres that nothing will ever check out — while each doctor sweep opens its own connection from the URL instead. On a host with several deployments that is a per-daemon idle backend held open forever for nothing. Since the move has made the unused-ness explicit and machine-checked, this is the cheap moment to drop the field from `DaemonConfig`/`InternalContext`/`TaskContext` and let `build_config`'s pool drop when it returns; re-plumbing it is trivial if a task ever claims it.

-------

`crates/bestool/src/alertd.rs:87`: `DaemonConfig`'s `Debug` impl prints `database_url` verbatim while the field's own doc claims it is "retained for redacted display". The value is the Tamanu connection URL built in `build_config`, which carries the postgres user and password, so any `debug!("{config:?}")` (or a `?config` span field added later) writes DB credentials into the daemon's logs — logs which on a Tamanu host are typically readable well beyond whoever may read the Tamanu config. `device_key_pem` right beside it is correctly wrapped in `Redacted`; `database_url` is not. Since this file is where the type now lives, worth closing the gap while moving it: either store the URL as `Redacted<String>` (or a redacting newtype that strips userinfo) or, at minimum, redact it in the `Debug` impl rather than passing the raw string to `.field(...)`.

passcod and others added 6 commits September 14, 2026 21:45
The daemon opened a connection pool at startup and threaded it through
DaemonConfig, InternalContext and TaskContext, and then nothing read it: the
sweep opened its own connection with connect_one every tick. A daemon
sweeping every minute paid for a fresh connect each time while the pool sat
unused. The dead field was the visible end of an unfinished wire.

perform_sweep now takes an optional pool. The daemon passes its own, so a
sweep checks a connection out and returns it when the last check drops it.
The one-shot doctor CLI has no pool and still opens its own via connect_one.
CheckContext::db holds a SweepDb, either of those, deref'ing to the same
client, so checks read it through ctx.db() without caring which — which also
settles the as_ref/as_deref split at the call sites.

db_connect still opens its own connection to measure connect latency, so a
pool that can't hand one out cannot mask the database being down. A failed
acquire warns and skips the DB-dependent checks, as a failed connect_one did,
and with no pool at all the sweep falls back to connect_one every tick, so
postgres is still not required for the daemon to start.

Co-authored-by: Claude <noreply@anthropic.com>
Both call sites passed env!("CARGO_PKG_VERSION").to_string(), which is what
DaemonConfig::new already defaults to — the same value, resolved in the same
crate. The setter only existed because the config used to live in
bestool-alertd, which couldn't see the binary's version; the move settles
that.

Co-authored-by: Claude <noreply@anthropic.com>
The move left http_builder and http_client in the checks crate, where
nothing called them: every check takes a client from its context, and the
only callers were the daemon's own commands and startup. So the crate
documented as the checks and nothing that schedules them still owned the
daemon's client factory, and the daemon reached back across the seam for it.

The clients are built in crate::alertd now. Only USER_AGENT stays behind,
because the point of keeping it there is that it carries the checks crate's
version rather than the binary's; a test in that crate holds the two
together so rebuilding it from the wrong CARGO_PKG_VERSION can't pass.

Co-authored-by: Claude <noreply@anthropic.com>
A sweep had two ways to open its database connection: the daemon's pool, or
connect_one when there wasn't one. Keep only the pool. connect_one was
itself a create_pool that took one connection and dropped the pool, so the
doctor CLI building its own pool costs it nothing and puts both callers on
the same path; the SweepDb enum that spanned the two ways is gone with the
second.

The pool moves from DaemonConfig to the doctor task. Building one needs the
database up, so a daemon started while postgres is down cannot be handed
one and has to build it later: the task builds its pool on the first sweep
that reaches the database, keyed by URL so an in-place upgrade rebuilds it,
and retries each tick until then. Startup no longer touches the database at
all, which is a better fit for a daemon whose job includes alerting on that
database being down.

db_connect still opens its own connection with tokio_postgres::connect, so
a pool that can't hand one out cannot mask an outage.

The endpoint tests no longer need DATABASE_URL: they built a pool only to
fill a field that no longer exists, and the endpoints they cover report on
the daemon rather than the database.

Co-authored-by: Claude <noreply@anthropic.com>
Left behind when InternalContext lost its pool and gained a Windows-only
restart handle. The build that verified the previous commit read the fixed
file from the working tree, but a stale fsmonitor entry meant git staged the
old blob, so the commit went out without it.

Co-authored-by: Claude <noreply@anthropic.com>
A sweep took one connection and shared it between every DB check. The checks
already run concurrently, so sharing meant their queries pipelined onto a
single backend: a sweep cost the sum of its database work rather than the
longest piece of it. A pool that only ever hands out one connection is not
doing anything.

CheckContext now holds the pool and ctx.db() acquires per check, returning
the connection when the check ends. The pool bounds how many run at once, so
a check that has to wait simply starts later. The sweep keeps one connection
of its own for setup — server kind, Tamanu version, server facts — and that
acquire is where a single warning for an unreachable database comes from;
db_connect still opens its own connection and is what reports the outage.

grades_a_seeded_gap_against_central seeded its fixture in an uncommitted
transaction and relied on the check sharing that connection to see it. It
commits the seed and deletes it afterwards now, which is what a check reading
through its own connection requires, and deletes the probe rows before
seeding too so a run that dies before its cleanup can't poison the next.

Co-authored-by: Claude <noreply@anthropic.com>
Comment thread crates/bestool/src/alertd/doctor.rs
Comment thread crates/alertd/src/checks.rs
Comment thread crates/alertd/src/sweep.rs
Comment thread crates/alertd/src/checks/fhir_materialisation.rs Outdated
@review-hero

review-hero Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary (round 3)
7 agents reviewed this PR | 2 failed | 1 critical | 3 suggestions | 0 nitpicks | Filtering: consensus 3 voters

Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`crates/bestool/src/alertd/doctor.rs:257`: `pool_for` holds the `pg_pool` mutex across `create_pool(...).await`, which performs real network I/O (and, on TLS failure, retries with SSL disabled). While postgres is down the pool is never cached, so every 60s tick re-enters this path under the lock, and a connect that hangs on an unreachable host blocks any concurrent sweep — notably the `/tasks/doctor/recompute` endpoint, which runs `run_sweep` independently of the tick — for the full connect duration. Build the pool outside the lock (take the lock only to read the cached entry and to store the result), or add a short-circuit/backoff so a known-unreachable URL isn't retried under the mutex on every tick.

-------

`crates/alertd/src/checks.rs:136`: Per-check acquisition contends with an unsized pool: `run_checks_concurrently` spawns every check at once (no concurrency cap), and 22 of them now call `ctx.db()` simultaneously, but `create_pool` uses mobc's `Pool::builder()` defaults — `max_open = 10`, `get_timeout = 30s` — and one of those slots is held by the sweep's `setup_db` for the whole sweep (sweep.rs:576). So at most 9 of 22 DB checks run at a time, and the rest queue; an on-demand `/tasks/doctor/recompute` overlapping the 60s tick doubles the queue. The fallout isn't just latency: when an acquire times out, `db()` returns `None`, and `db_version`, `migrations`, `fhir_jobs`, `fhir_workers` and `sync_sessions` turn that into `Check::fail("no DB connection")` — a pool-contention stall becomes a spurious database-down alert, with only a `debug!` line to explain it. Size the pool for the number of DB checks (e.g. `max_open` >= the DB check count, or bound check concurrency to the pool size), and distinguish an acquire timeout from "no pool" so contention degrades to a skip/warning rather than a FAIL.

-------

`crates/alertd/src/sweep.rs:576`: `setup_db` is acquired before the checks are built and stays alive until `collect_server_facts` at line 737, so it holds a pool slot across the entire check phase — the exact window where 22 checks are competing for connections. It is only used for `detect_kind`/`current_version` (before the checks) and `collect_server_facts` (after). Dropping the guard once the setup queries are done and re-acquiring for `collect_server_facts` gives that slot back to the checks for the contended part of the sweep.

-------

`crates/alertd/src/checks/fhir_materialisation.rs:620`: The seeded-gap test switched from a rolled-back transaction to committed writes plus an unscoped `DELETE`, so running the test suite now permanently mutates whatever database answers at `postgresql://localhost/tamanu-central`. `DELETE FROM settings WHERE key = 'fhir.worker.resourceMaterialisationEnabled.Patient'` is not probe-scoped: it runs *before* the seed, so a deployment that had that setting configured loses it, and the test's own re-insert sets it to `'true'` regardless of what the operator had chosen. The test only runs when it can reach that database — exactly the case where it is a real Tamanu central deployment (a dev box or ops host with a local Tamanu, not just a CI scratch DB), and the CLEANUP-first design means a crashed run leaves the setting deleted and the fake `fhir-materialisation-probe` patient committed. Restrict the destructive work to rows the test owns: read and restore the prior `settings` row (or use a probe-specific key and stub the lookup) rather than deleting the production key, and gate the whole test behind an explicit opt-in env var (e.g. `BESTOOL_TEST_DESTRUCTIVE_DB=1`) so merely having a reachable local Tamanu database is not enough to trigger writes against it.

passcod and others added 5 commits September 15, 2026 12:02
Committing the fixture was necessary once checks read through their own
connection, but it left the test mutating whatever database answers at the
central URL. CI has no tamanu-central database, so the only machines where
this ran were developer and ops boxes with a live Tamanu — exactly where the
writes do damage. The settings delete was not probe-scoped either: it removed
an operator's configured value and the re-insert replaced it with 'true'.

The test now reads the setting first and restores it verbatim, or removes the
row only when it created one, and is gated behind BESTOOL_TEST_DESTRUCTIVE_DB
so a reachable local Tamanu is no longer enough to trigger writes against it.

Co-authored-by: Claude <noreply@anthropic.com>
Checks take a connection each and all run at once, but the pool kept mobc's
default of ten. With twenty-two DB checks the rest queued, and an acquire
that timed out returned None — which db_version, migrations, fhir_jobs,
fhir_workers and sync_sessions report as a failed check. Pool contention
would have raised a database-down alert.

Sizing the pool to the fan-out means a failed acquire means what it did
before: the database is unusable. max_idle stays well below max_open, so the
burst lasts a sweep rather than holding a backend per check open between them.

Co-authored-by: Claude <noreply@anthropic.com>
…s run

It was acquired for detect_kind and current_version, then held until the
server-facts query at the end — occupying a pool slot through the whole phase
where every check is asking for one. It is dropped after the setup queries
now, and the facts query takes a connection again once the checks are done.

Co-authored-by: Claude <noreply@anthropic.com>
pool_for held the lock across create_pool, which talks to the database and
can sit for the connect timeout when the host is unreachable. A concurrent
sweep — /tasks/doctor/recompute runs independently of the tick — stalled for
the whole of it, every tick, for as long as postgres was down.

The lock is taken to read the cached pool and again to store the result. If
two sweeps race, whichever pool is already cached for the URL wins, so a pool
with live connections is never replaced.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
Comment thread crates/alertd/src/sweep.rs Outdated
Comment thread crates/alertd/src/checks.rs Outdated
Comment thread crates/bestool/src/alertd/doctor.rs Outdated
Comment thread crates/alertd/src/checks/fhir_materialisation.rs Outdated
Comment thread crates/bestool/src/alertd.rs Outdated
Comment thread crates/alertd/src/sweep.rs
Comment thread crates/bestool/src/alertd.rs Outdated
Comment thread crates/alertd/src/checks.rs Outdated
@review-hero

review-hero Bot commented Sep 15, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary (round 4)
12 agents reviewed this PR | 2 critical | 6 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 5 below threshold, 1 suppressed

Below consensus threshold (5 unique issues not confirmed by majority)
Location Agent Severity Comment
crates/alertd/src/checks.rs:154 Bugs & Correctness suggestion CheckContext::db() collapses "there is no pool" and "the acquire failed/timed out" into the same None, and five checks (db_version, migrations, fhir_jobs, fhir_workers, sync_sessions)...
crates/alertd/src/checks/fhir_materialisation.rs:626 Design & Architecture nitpick The rewrite left the test's 20-line doc comment attached to the restore helper instead of the test: it now reads as if restore is the thing gated behind BESTOOL_TEST_DESTRUCTIVE_DB, while `gr...
crates/alertd/src/sweep.rs:744 Performance suggestion The sweep now starts ~17 new postgres connections simultaneously (checks are all spawned at once and the pool only keeps 4 idle) in the same instant that the db_connect check opens its own raw co...
crates/bestool/Cargo.toml:116 Design & Architecture suggestion The alertd feature now enables dep:bestool-tamanu plus two of its features unconditionally, which collapses the alertd / alertd-tamanu split this block documents: alertd-tamanu is left ad...
crates/bestool/src/alertd/doctor.rs:263 Performance suggestion The cached pool is shared by the periodic tick and by on-demand sweeps from /tasks/doctor/recompute (what bestool tamanu doctor --fresh drives), but it is sized for exactly one sweep's fan-out....
Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`crates/alertd/src/sweep.rs:624`: The sweep hands every check the pool even when it has just proved the database is unreachable. `setup_db` failing (line 576-585) means `pool.get()` errored, yet `pool: pg_pool.clone()` is still put in the `CheckContext`, so all ~22 DB checks call `ctx.db()` and each repeats the same failing acquire. `PgPool::get` uses mobc's `get_timeout` (30s by default, as the plan notes), there is no per-check timeout in `run_checks_concurrently`, and the sweep's result — including the `db_connect` FAIL that is the whole point of the daemon when postgres is down — is only reported once the slowest check returns. So a postgres outage now costs up to the setup acquire plus another full acquire timeout per sweep, versus a single fast-failing `connect_one` before this change, and it contradicts the stated criterion "A host with no reachable database has no pool, and DB checks skip rather than waiting on an acquire that cannot succeed". Fix: `pool: db_reachable.then(|| pg_pool.clone()).flatten()`, so a sweep that could not get a setup connection runs the checks as poolless (they skip immediately) and the next tick retries.

-------

`crates/alertd/src/checks.rs:138`: `max_open: 32` is sized to the check fan-out, but the pool points at the *production* Tamanu database, which the app itself is also connecting to. Where a sweep used to cost one connection per tick, it now bursts to ~22-32 backends every 60 seconds; `bestool tamanu doctor` builds a second 32-wide pool with the same `POOL_SIZE` (crates/bestool/src/actions/tamanu/doctor.rs:167), and the daemon's tick can overlap with it. Against a deployment tuned to the pgtune baseline of `max_connections = 100` (crates/postgres/src/pgtune.rs:375) shared with Tamanu's own pools, an operator running `doctor` while the daemon sweeps can consume 60+ slots and push Postgres to `FATAL: sorry, too many clients already` — the healthcheck causing the outage it exists to report. Consider bounding the concurrency of DB-taking checks (e.g. a semaphore of 6-8) rather than sizing the pool to the full fan-out, or deriving `max_open` from the cluster's actual `max_connections`.

-------

`crates/bestool/src/alertd/doctor.rs:293`: The error branch of `pool_for` does the opposite of what its comment claims. The comment says a concurrent sweep may have cached a good pool meanwhile — but the guard `guard.as_ref().is_some_and(|(url, _)| url == database_url)` matches exactly that case and clears it. Scenario: the tick and a `recompute` both miss the cache and both call `create_pool_sized`; the tick succeeds and caches a pool; the recompute's build fails transiently and wipes the freshly cached good pool, so the next sweep pays a full rebuild against a database that was reachable all along. Since the early-return at the top already guarantees any cache entry for this URL was written after our miss, the failure branch should leave the cache alone entirely.

-------

`crates/alertd/src/checks/fhir_materialisation.rs:678`: The restore path captures `prior` from the settings table on entry, but that value can be this test's own leftover. If a run is interrupted (panic in `super::run`, Ctrl-C, CI timeout) after the `UPDATE ... SET value = 'true'` but before `restore`, the deployment is left with the setting forced on; the next run then reads `prior = 'true'` and faithfully "restores" it, making the change permanent on a live Tamanu. The probe patient is protected against exactly this with the pre-seed `DELETE`, but the setting is not. Recording the intended prior value out-of-band (e.g. a sentinel row, or refusing to run when the setting already reads `true` with no other evidence) would close the gap.

-------

`crates/bestool/src/alertd.rs:36`: `DaemonConfig::database_url` is now a field with no consumer. Nothing reads it to connect (the doctor task resolves its own URL from `SweepTamanu` and builds the pool itself), so its only use is the hand-written `Debug` impl — and because it is wrapped in `Redacted`, which exists precisely to keep values out of `Debug` output, that impl now prints a redaction marker. The field carries information to nobody: it is threaded from `build_config` through `DaemonConfig::new` purely to be hidden again. This is the same shape as `binary_version`'s `with_binary_version` setter that this PR deleted for being vestigial once the crate boundary went away. Either drop the field and the `DaemonConfig::new` parameter, or — if the intent was for an operator to see *which* database the daemon is pointed at — store a redacted-by-construction display form (host/dbname, password stripped) as a plain `String` so the `Debug` line actually says something.

-------

`crates/alertd/src/sweep.rs:566`: `perform_sweep` is now nine positional parameters, four of which are `Option<...>` and two of which (`progress: Option<ProgressSender>`, `canopy: Option<Arc<CanopyClient>>`) are distinguishable only by type. The new `pg_pool` argument is appended at the end, so every call site grows a bare `None` or `pg_pool` with no indication at the call of what it binds to — see the two test call sites that now end `false, None`. This crate already depends on `bon` and already uses it for `SweepContext::builder()`, so the idiom is established: giving `perform_sweep` a builder (or folding the sweep's inputs into a single `SweepRequest` struct) would make the call sites self-describing and make the next added parameter a non-event rather than another positional slot.

-------

`crates/bestool/src/alertd.rs:77`: Now that `DaemonConfig` lives in the same crate as the binary, `binary_version` is always `env!("CARGO_PKG_VERSION")` — `with_binary_version` was removed, so no call site can set anything else — yet it is still threaded as runtime `String` data through `DaemonConfig` → `daemon::run_with_shutdown` → `start_server(.., binary_version)` → `ServerState` → `/status`. It was a field only because it used to cross a crate boundary. Making it a `const` the status endpoint reads directly removes a field, a parameter, and three clones, and `DoctorTask::new(env!("CARGO_PKG_VERSION"), ..)` at the same call site stops being a second independent copy of the same constant.

-------

`crates/alertd/src/checks.rs:139`: `max_idle: 4` against `max_open: 32` means mobc drops every connection past the fourth as each check hands it back, so a sweep that used 22 connections tears down ~18 and re-establishes them on the next tick — roughly 18 connects/minute (TCP + TLS handshake + auth + a forked backend each), where before the move a sweep cost exactly one connect. That undercuts the reason the pool was wired up in the first place (the plan's "a daemon sweeping every minute paid for a fresh connect each time"). mobc's builder has `max_idle_lifetime`, so a better shape is `max_idle` near the fan-out plus an idle lifetime of a few minutes: connections survive tick-to-tick when sweeps are one minute apart, and still age out so the footprint between bursts stays small.

passcod and others added 6 commits September 15, 2026 12:23
The sweep handed every check the pool even when its own setup acquire had
just failed, so all twenty-two DB checks repeated that failing acquire and
each waited out the connect timeout in turn. The sweep's result — including
the db_connect failure that is the point of the daemon when postgres is down
— only lands once the slowest check returns, so an outage cost the setup
acquire plus another full timeout per sweep, where before this branch a
connect_one failed fast.

The checks get the pool only once a connection has actually come out of it.
Without one they skip immediately, as the card said they would, and the next
tick tries again.

Co-authored-by: Claude <noreply@anthropic.com>
Sizing the pool to the check fan-out fixed queueing by making the sweep burst
twenty-odd backends a minute at the deployment's own database — the one the
application is also connecting to. `bestool tamanu doctor` opens a second
pool that can overlap with the daemon's, so against a cluster on the usual
hundred connections, shared with Tamanu's pools, the healthcheck could have
caused the outage it exists to report.

Eight connections, and checks queue for one. They still run several at a time
rather than pipelining onto a single connection, and their queries are short.
Waiting is safe now that a database which is actually down leaves the checks
with no pool at all rather than something to queue for.

Idle connections now outlive the gap between sweeps, so a minute-by-minute
daemon reuses them instead of reconnecting, and age out when it goes quiet.

Co-authored-by: Claude <noreply@anthropic.com>
The failure branch cleared the cache entry for the URL it had just failed on,
which is exactly the entry a concurrent sweep may have written after our own
miss. A transient failure in a recompute could throw away the pool the tick
had just built, and the next sweep would rebuild against a database that was
reachable the whole time.

Co-authored-by: Claude <noreply@anthropic.com>
Saving and restoring the setting read whatever was there on entry, which can
be the test's own value from a run interrupted between forcing it on and
cleaning up. The next run would then read 'true' as the deployment's prior
value and faithfully restore it, making the change permanent.

The test owns the setting for its duration or it doesn't run: a row already
present is either the deployment's configuration, which isn't ours to edit,
or that leftover, and the two can't be told apart. It declines and says how
to clear it. Everything it touches it created.

This also puts the test's doc comment back on the test, where the previous
round's edit left it describing the restore helper instead.

Co-authored-by: Claude <noreply@anthropic.com>
database_url was threaded in from build_config only to be hidden again: the
doctor task resolves its own URL and builds its own pool, so nothing read it
to connect, and its only consumer was a Debug impl that — because the field
is wrapped in Redacted — printed a redaction marker. It carried information
to nobody.

binary_version was always this binary's own version once the config stopped
crossing a crate boundary, the same shape as the with_binary_version setter
removed last round. It is a const now, which the status line and the doctor
task both read, so the two stop being independent copies of one value.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
@review-hero

review-hero Bot commented Sep 15, 2026

Copy link
Copy Markdown

🦸 Review Hero (could not post inline comments — showing here instead)

crates/alertd/src/sweep.rs:576

[Bugs & Correctness] suggestion

db_reachable is derived solely from whether the setup acquire succeeded, and that acquire is subject to the same 30s pool-contention timeout as the checks. If all 8 connections are held when a second sweep starts (the daemon tick and an on-demand recompute share one pool), the setup pool.get() errors, db_reachable becomes false, and the sweep hands the checks no pool at all and skips the server-facts query — so the whole sweep reports as if postgres were down when it is merely busy. Consider retrying the setup acquire, or treating a timeout error distinctly from a connect error (mobc reports Error::Timeout separately from Error::Inner) so only a real connect failure suppresses the pool.


crates/alertd/src/checks.rs:145

[Bugs & Correctness] critical

The pool is capped at 8 connections, but run_checks_concurrently (sweep.rs:519) tokio::spawns every selected check with no concurrency bound, and ~22 of them call ctx.db(). Checks therefore queue on pool.get(), which uses mobc's default 30s acquire timeout. When the queue wait exceeds that, db() returns None — and db_version, migrations, fhir_jobs, fhir_workers and sync_sessions turn None into Check::fail(..., "no DB connection"), i.e. a database-down alert on a database that is up. The doc comment argues queueing is safe because "a database that is actually down leaves the checks with no pool at all", but that only covers the hard-down case: a live-but-slow cluster (a check like fhir_materialisation or sync_snapshot_tables holding its connection through a long query) starves the others and produces a false outage alert. This is the same regression the third round identified, reintroduced by shrinking the pool back below the fan-out. Either bound check concurrency to max_open so no check can ever time out waiting, or have db() distinguish a queue timeout from an unusable database and make the five fail-on-None checks skip (or report contention) rather than fail in that case.


crates/alertd/src/checks/fhir_materialisation.rs:234

[Performance] suggestion

This check now holds one of only eight pool slots for the whole of a sequential per-resource loop: for every discovered resource it runs has_any_row and then measure one after another, each an aggregate over the upstream and fhir.* tables. On a large deployment that is the sweep's long pole, and because the pool is now a bounded shared budget it also sets how long every other DB check waits for a slot (see the acquire-timeout issue). Now that connections are per-check rather than a single shared client, the measures can be issued with bounded concurrency (each taking its own connection from ctx, e.g. futures::stream::iter(...).buffer_unordered(N)), which cuts the check from the sum of its queries to roughly the longest one.


crates/alertd/src/checks/pg_tuning.rs:312

[Performance] suggestion

The pooled connection is held for the whole function body, well past the last query. pg_tuning runs one query_one, then keeps the slot through System::new_with_specifics (blocking memory refresh), bottom_up_aslr_findings().await, and all the grading. With only 8 slots shared by 22 checks, that occupancy directly lengthens the queue for everything else. Drop the connection once the last query returns (drop(client) after building settings, or scope the query in a block); the same applies to sync_session_errors, which holds its connection through accumulate after both queries have completed.


crates/alertd/src/checks/fhir_materialisation.rs:661

[Security] suggestion

The seeded-gap test commits a real write to whatever database answers at the central URL (on an ops box, the live Tamanu), and its cleanup is not failure-safe. The settings row enabling fhir.worker.resourceMaterialisationEnabled.Patient is inserted first (line 661), and everything that follows can abort before the cleanup at line 687: the .expect("clearing any stale probe should succeed") (672), the probe insert, a panic inside super::run(ctx), or the test process being killed/timed out. Any of those leaves FHIR Patient materialisation switched on permanently for that deployment, changing what the real FHIR worker does. The new "decline if the setting already exists" guard makes this worse, not better: a rerun will refuse rather than clean up, so the leftover is never removed by the test itself. Suggest either seeding the setting last (after the probe row is in place, so the only fallible step between seed and cleanup is run), or holding the setting in an RAII guard whose Drop issues the delete so an unwind still restores it.


crates/bestool/src/alertd/doctor.rs:270

[Security] suggestion

pool_for calls bestool_postgres::pool::create_pool_sized, which on an auth failure with no password in the URL falls through to a blocking rpassword::prompt_password("Password: ") (crates/postgres/src/pool.rs). That interactive prompt is now reachable from inside the doctor background task on every tick. Running bestool alertd run in the foreground on a host whose Tamanu database URL carries no password (peer/ident auth that starts rejecting, or a rotated credential) makes the sweep block indefinitely on a terminal read inside an async task, so no checks complete, nothing is posted to canopy, and the watchdog eventually kills the daemon — exactly when the database is the thing that needs reporting. This contradicts the invariant that postgres must never be required for alertd to keep running. Suggest giving the daemon a non-interactive pool constructor (a flag on PoolSize/create_pool_sized, or a create_pool_no_prompt) that returns the auth error instead of prompting, and using it here and in the daemon's sweep path.


crates/alertd/src/checks.rs:162

[Performance] critical

db() collapses "the pool timed out under contention" into the same None that means "no database". With max_open: 8 and ~20 DB-backed checks all calling ctx.db() at once, the last dozen queue on mobc's semaphore, and mobc's default acquire timeout (30s, not configured here) turns a slow-but-alive database into Err — which five checks (db_version, migrations, fhir_jobs, fhir_workers, sync_sessions) convert into Check::fail("no DB connection"). The safety argument in the plan ("a database that is actually down leaves the checks with no pool at all") only covers a total outage at setup time; a database that is merely loaded — exactly when the sweep matters — starves its own checks and raises a database-down alert caused by the sweep itself. Distinguish the two: use get_timeout with an explicit budget and, on timeout (mobc::Error::Timeout), return a value the checks render as a skip rather than a failure, so contention can never masquerade as an outage.

@review-hero

review-hero Bot commented Sep 15, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary (round 5)
9 agents reviewed this PR | 2 critical | 5 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 1 below threshold

Below consensus threshold (1 unique issue not confirmed by majority)
Location Agent Severity Comment
crates/alertd/src/checks/fhir_materialisation.rs:688 Security nitpick The cleanup builds SQL by string-interpolating PROBE and SETTING with format!, while every other statement in the same test (lines 649, 661, 670, 674) correctly uses bound parameters. Both va...
Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`crates/alertd/src/sweep.rs:576`: `db_reachable` is derived solely from whether the setup acquire succeeded, and that acquire is subject to the same 30s pool-contention timeout as the checks. If all 8 connections are held when a second sweep starts (the daemon tick and an on-demand `recompute` share one pool), the setup `pool.get()` errors, `db_reachable` becomes false, and the sweep hands the checks no pool at all *and* skips the server-facts query — so the whole sweep reports as if postgres were down when it is merely busy. Consider retrying the setup acquire, or treating a timeout error distinctly from a connect error (mobc reports `Error::Timeout` separately from `Error::Inner`) so only a real connect failure suppresses the pool.

-------

`crates/alertd/src/checks.rs:145`: The pool is capped at 8 connections, but `run_checks_concurrently` (sweep.rs:519) `tokio::spawn`s every selected check with no concurrency bound, and ~22 of them call `ctx.db()`. Checks therefore queue on `pool.get()`, which uses mobc's default 30s acquire timeout. When the queue wait exceeds that, `db()` returns `None` — and `db_version`, `migrations`, `fhir_jobs`, `fhir_workers` and `sync_sessions` turn `None` into `Check::fail(..., "no DB connection")`, i.e. a database-down alert on a database that is up. The doc comment argues queueing is safe because "a database that is actually down leaves the checks with no pool at all", but that only covers the hard-down case: a live-but-slow cluster (a check like `fhir_materialisation` or `sync_snapshot_tables` holding its connection through a long query) starves the others and produces a false outage alert. This is the same regression the third round identified, reintroduced by shrinking the pool back below the fan-out. Either bound check concurrency to `max_open` so no check can ever time out waiting, or have `db()` distinguish a queue timeout from an unusable database and make the five fail-on-None checks skip (or report contention) rather than fail in that case.

-------

`crates/alertd/src/checks/fhir_materialisation.rs:234`: This check now holds one of only eight pool slots for the whole of a sequential per-resource loop: for every discovered resource it runs `has_any_row` and then `measure` one after another, each an aggregate over the upstream and `fhir.*` tables. On a large deployment that is the sweep's long pole, and because the pool is now a bounded shared budget it also sets how long every other DB check waits for a slot (see the acquire-timeout issue). Now that connections are per-check rather than a single shared client, the measures can be issued with bounded concurrency (each taking its own connection from `ctx`, e.g. `futures::stream::iter(...).buffer_unordered(N)`), which cuts the check from the sum of its queries to roughly the longest one.

-------

`crates/alertd/src/checks/pg_tuning.rs:312`: The pooled connection is held for the whole function body, well past the last query. `pg_tuning` runs one `query_one`, then keeps the slot through `System::new_with_specifics` (blocking memory refresh), `bottom_up_aslr_findings().await`, and all the grading. With only 8 slots shared by 22 checks, that occupancy directly lengthens the queue for everything else. Drop the connection once the last query returns (`drop(client)` after building `settings`, or scope the query in a block); the same applies to `sync_session_errors`, which holds its connection through `accumulate` after both queries have completed.

-------

`crates/alertd/src/checks/fhir_materialisation.rs:661`: The seeded-gap test commits a real write to whatever database answers at the central URL (on an ops box, the live Tamanu), and its cleanup is not failure-safe. The `settings` row enabling `fhir.worker.resourceMaterialisationEnabled.Patient` is inserted first (line 661), and everything that follows can abort before the cleanup at line 687: the `.expect("clearing any stale probe should succeed")` (672), the probe insert, a panic inside `super::run(ctx)`, or the test process being killed/timed out. Any of those leaves FHIR Patient materialisation switched on permanently for that deployment, changing what the real FHIR worker does. The new "decline if the setting already exists" guard makes this worse, not better: a rerun will refuse rather than clean up, so the leftover is never removed by the test itself. Suggest either seeding the setting last (after the probe row is in place, so the only fallible step between seed and cleanup is `run`), or holding the setting in an RAII guard whose `Drop` issues the delete so an unwind still restores it.

-------

`crates/bestool/src/alertd/doctor.rs:270`: `pool_for` calls `bestool_postgres::pool::create_pool_sized`, which on an auth failure with no password in the URL falls through to a blocking `rpassword::prompt_password("Password: ")` (crates/postgres/src/pool.rs). That interactive prompt is now reachable from inside the doctor background task on every tick. Running `bestool alertd run` in the foreground on a host whose Tamanu database URL carries no password (peer/ident auth that starts rejecting, or a rotated credential) makes the sweep block indefinitely on a terminal read inside an async task, so no checks complete, nothing is posted to canopy, and the watchdog eventually kills the daemon — exactly when the database is the thing that needs reporting. This contradicts the invariant that postgres must never be required for alertd to keep running. Suggest giving the daemon a non-interactive pool constructor (a flag on `PoolSize`/`create_pool_sized`, or a `create_pool_no_prompt`) that returns the auth error instead of prompting, and using it here and in the daemon's sweep path.

-------

`crates/alertd/src/checks.rs:162`: `db()` collapses "the pool timed out under contention" into the same `None` that means "no database". With `max_open: 8` and ~20 DB-backed checks all calling `ctx.db()` at once, the last dozen queue on mobc's semaphore, and mobc's default acquire timeout (30s, not configured here) turns a slow-but-alive database into `Err` — which five checks (`db_version`, `migrations`, `fhir_jobs`, `fhir_workers`, `sync_sessions`) convert into `Check::fail("no DB connection")`. The safety argument in the plan ("a database that is actually down leaves the checks with no pool at all") only covers a total outage at setup time; a database that is merely loaded — exactly when the sweep matters — starves its own checks and raises a database-down alert caused by the sweep itself. Distinguish the two: use `get_timeout` with an explicit budget and, on timeout (`mobc::Error::Timeout`), return a value the checks render as a skip rather than a failure, so contention can never masquerade as an outage.

passcod and others added 4 commits September 15, 2026 12:40
The pool has been sized up and back down across three rounds, because no
single number satisfies both constraints: large enough that the checks never
queue means bursting twenty-odd backends a minute at a production database,
and small enough to be a safe budget means they do queue. The defect was on
the other axis. A queue that times out is indistinguishable from a database
that is unreachable — db() hands back None either way, and five checks report
that as a failure — so contention, or simply a slow cluster, raised a
database-down alert on a database that was up.

The pool waits instead of giving up. A failed acquire now means what it says,
and the size can be what it should be: a budget, not a slot per check. A busy
sweep takes longer, which is the right trade for something whose job is to
report whether the database is healthy — and a sweep that cannot take a
connection at all still hands the checks no pool, so they skip at once rather
than queueing for something that will not arrive.

Co-authored-by: Claude <noreply@anthropic.com>
pg_tuning held a slot through a blocking memory refresh, a kernel probe and
all its grading, having made its only query; sync_session_errors held one
through the arithmetic after both of its. With a small shared pool that
occupancy is what everything else queues behind.

Co-authored-by: Claude <noreply@anthropic.com>
Every other statement in the test binds its values; the cleanup interpolated
them into the SQL, which is the one shape you don't want to see near a DELETE
even when both values are compile-time constants.

Co-authored-by: Claude <noreply@anthropic.com>
Comment thread crates/alertd/src/checks/fhir_materialisation.rs Outdated
Comment thread crates/alertd/src/checks.rs Outdated
Comment thread crates/alertd/src/checks.rs Outdated
Comment thread crates/bestool/src/alertd/doctor.rs
@review-hero

review-hero Bot commented Sep 15, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary (round 6)
9 agents reviewed this PR | 1 critical | 3 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 5 below threshold

Below consensus threshold (5 unique issues not confirmed by majority)
Location Agent Severity Comment
crates/alertd/src/checks.rs:537 Bugs & Correctness suggestion test_support::connect builds the test pool with create_pool (mobc defaults: 10 open, 30s get_timeout), not POOL_SIZE. Every DB-backed test — including `concurrent_checks_get_their_own_conne...
crates/alertd/src/checks/fhir_materialisation.rs:234 Performance suggestion This check now holds one of only 8 pooled slots across a fully sequential loop of up to two round-trips per resource (has_any_row then measure) over all of RESOURCES, so it is by far the long...
crates/alertd/src/sweep.rs:630 Bugs & Correctness suggestion db_reachable is latched from the single setup acquire at the start of the sweep, and the pool is then handed to the checks on the strength of it. The guard only covers "the database was reachable...
crates/alertd/src/sweep.rs:756 Performance suggestion pool.get().await.ok() for the facts connection inherits the pool's get_timeout: None, so it also waits without a deadline. db_reachable was decided before the checks ran; if postgres went dow...
crates/bestool/src/alertd/doctor.rs:270 Performance nitpick While postgres is unreachable, every 60s tick calls create_pool_sized, which constructs a fresh mobc Pool (and can loop once more for the SSL fallback) and then drops it on failure. That is a n...
Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`crates/alertd/src/checks/fhir_materialisation.rs:661`: The settings row is inserted first, before the two probe-patient statements, and both of those `.expect(...)`. A panic there (schema drift in `patients`, a NOT NULL column added upstream, a transient error) leaves `fhir.worker.resourceMaterialisationEnabled.Patient = true` committed on a live deployment — the test's stated purpose is to never edit a setting it didn't create, but this window does exactly that, and it actually turns on Patient materialisation for the deployment's FHIR worker. It also poisons every later run, since the `existing.is_none()` guard then aborts. Seed the probe patient first and the setting last (so an early failure leaves only a row the next run's own `DELETE FROM patients WHERE id = $1` cleans up), or hold the seeding in a guard that runs the cleanup on unwind.

-------

`crates/alertd/src/checks.rs:157`: `get_timeout: None` makes every pool acquire unbounded, and nothing else in the path is bounded either: `PgConnectionManager`'s tokio-postgres config sets no `connect_timeout`, and no connection sets `statement_timeout`. `perform_sweep` spawns all ~22 checks at once (sweep.rs:509, unbounded `FuturesUnordered`) against 8 slots, so a single check whose query blocks (a lock wait, a slow `sync_snapshot_tables` count, a cluster that has stopped answering but keeps the TCP connection open) holds its slot forever and every queued check waits behind it forever. The sweep future never resolves, so the doctor task's tick loop (bestool/src/alertd/daemon.rs:251-262) never comes back round to `record_activity()`, and the watchdog kills the daemon ~10 minutes later — into a restart loop that re-hangs. Meanwhile nothing is posted to canopy, so a degraded database produces silence rather than the alert the daemon exists to raise. Previously the 30s `get_timeout` guaranteed the sweep completed and the DB checks reported. Suggest keeping a deadline that is comfortably longer than a healthy query (so contention isn't misread as an outage) but finite — e.g. `Some(120s)` — or bounding the sweep as a whole, rather than removing the bound entirely.

-------

`crates/alertd/src/checks.rs:155`: `max_idle: 8` equals `max_open: 8`, while `PoolSize`'s own doc (postgres/src/pool.rs:106) says a bursty caller should keep `max_idle` "well below" `max_open` so the footprint between bursts stays small. With `max_idle_lifetime: 300s` and the daemon sweeping every 60s, idle connections are re-touched before they can age out, so the daemon permanently parks up to 8 backends on the deployment's own database — and `bestool tamanu doctor` opens a second pool with the same constant, so an operator running doctor while the daemon sweeps can sit at 16 held backends against a cluster typically capped at 100 and shared with Tamanu's own pools. Since the acquire now waits rather than failing, a smaller `max_idle` (2–3) costs only a reconnect on the wide part of a sweep and keeps the resting footprint proportional to what the sweep actually uses.

-------

`crates/bestool/src/alertd/doctor.rs:270`: Pool creation moved from startup (`build_config`) into the per-tick doctor task, which drags `create_pool_sized`'s interactive password prompt into the daemon's hot path. `create_pool_sized` → on an auth error (SQLSTATE 28000/28P01) with no password in the URL, calls `rpassword::prompt_password("Password: ")` (crates/postgres/src/pool.rs:199). The deployed config is exactly the one that triggers it: services/bestool-alertd.service documents peer auth over the socket with no password in `TAMANU_DATABASE_URL`, so `config.get_password()` is `None`. If the pg_ident mapping or the read-only role breaks, every sweep tick hits the prompt. Under systemd there is no controlling terminal so `/dev/tty` fails and it errors out, but `bestool alertd run` in a shell (the documented way to run it by hand) will block on stdin indefinitely — the sweep never completes, nothing is posted to canopy, and the watchdog kills the daemon 10 minutes later. That is a monitoring blind spot triggered precisely when DB auth is broken. Suggest a non-interactive variant (e.g. a `PoolSize`/flag that suppresses the prompt) for the daemon path, so an auth failure returns an error the sweep can report instead of waiting on a human.

passcod and others added 4 commits September 15, 2026 12:58
create_pool asks for a password on the terminal when authentication fails and
the URL carries none — which is exactly the deployed shape: the unit file
documents peer auth over the socket with no password to keep in sync. Moving
pool creation into the per-tick sweep put that prompt in the daemon's hot
path. Under systemd it errors for want of a terminal, but `bestool alertd run`
from a shell blocks on stdin: the sweep never finishes, nothing reaches
canopy, and the watchdog restarts it ten minutes later. Broken database auth
would silence the monitoring rather than be reported by it.

The daemon asks for a pool that never prompts, so an auth failure comes back
as an error the sweep can report. The interactive doctor keeps its prompt.

Co-authored-by: Claude <noreply@anthropic.com>
Removing the acquire deadline last round stopped contention being misreported
as a database outage, but left nothing bounding the sweep: a check stuck on a
lock holds its slot, every queued check waits behind it, and the sweep never
returns. Nothing is posted to canopy and the watchdog restarts the daemon into
the same hang — silence, from the thing whose job is to report trouble.

Three things have to hold together, and moving one number up and down kept
trading one for another: the pool has to stay a small budget against a
production database, ordinary queueing must not be reportable as an outage,
and the sweep has to finish. A deadline far longer than any healthy sweep and
still finite holds all three. With these slots and the checks' short queries
ordinary queueing finishes in well under a second; two minutes means the
database has stopped answering, which is worth reporting as such.

Idle connections drop to two, so the footprint at rest is proportional to what
a sweep actually leaves behind rather than to its widest moment.

Co-authored-by: Claude <noreply@anthropic.com>
The setting was written before the probe patient, and the statements between
them can fail — schema drift, a new NOT NULL column, a transient error. That
window left materialisation switched on for the deployment's FHIR worker,
which is the one thing this test is not allowed to do, and poisoned every
later run because the guard then refuses.

The patient goes first now. Everything before the setting can fail without
consequence: the probe is a row only this test creates, and the next run's own
delete clears it.

The test pool is also built with the sweep's own settings, so the DB-backed
tests exercise the pool the daemon actually uses.

Co-authored-by: Claude <noreply@anthropic.com>
max_idle: 2,
max_idle_lifetime: Some(std::time::Duration::from_secs(300)),
get_timeout: Some(std::time::Duration::from_secs(120)),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bugs & Correctness] critical

get_timeout: None removes the only bound on connection establishment, not just on queueing. Nothing in bestool-postgres sets a connect_timeout on the tokio-postgres config (grep: no connect_timeout/keepalive anywhere in crates/postgres/src), so mobc's get_timeout was the sole deadline on pool.get() — and get() covers opening a new connection, not merely waiting for a free slot. Failure scenario: the postgres host becomes unreachable in a way that drops packets (firewall DROP, vanished VM, stalled TLS handshake) rather than refusing. Each acquire now blocks for the full OS SYN-retry window (~130s) or indefinitely instead of failing at 30s, and the sweep does these serially before any check runs: pool_forcreate_pool_sizedcheck_poolpool.get(), then perform_sweep's setup acquire, then up to 8 slot-filling connects with 22 checks queueing behind them with no deadline. The tick can easily exceed the 10-minute watchdog, so the daemon is killed and restarted before db_connect ever posts its FAIL — precisely the outage alertd exists to report (AGENTS.md: postgres must never be required for alertd to alert on the database being down). The reasoning in the doc comment ("a failed acquire means the database could not be connected to") depends on the acquire actually failing. Keep the unbounded wait for slot contention but bound the connect: set connect_timeout on the config in create_pool_sized (or give POOL_SIZE a generous-but-finite get_timeout and distinguish mobc::Error::Timeout from Error::Inner in CheckContext::db, so contention and unreachability stay separable).

.await
.expect("enabling the resource should succeed");

let check = super::run(ctx).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bugs & Correctness] suggestion

The cleanup isn't failure-safe, and the new "decline if the setting already exists" guard turns a single interrupted run into a permanent block. The setting is inserted first (line ~676), then the probe patient; if the patient INSERT fails (a NOT NULL column added upstream, a changed enum for sex, …) the expect panics and the setting row survives. Likewise, cleaned_up ?-chains the two deletes, so a failure deleting the patient skips the settings delete entirely. Either way the next run hits assert!(existing.is_none()) and the test can never run again until someone removes the row by hand on a live deployment. Insert the setting last (after the patient is seeded), and run both deletes independently — collect their results rather than short-circuiting — so the setting is always removed.

);
}
let Some(client) = ctx.db.as_ref() else {
let Some(client) = ctx.db().await else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Performance] suggestion

This check takes one pooled connection at line 202 and holds it through the whole per-resource loop: schema discovery, then for each of the 9 RESOURCES a possible has_any_row plus a measure gap/lag query — ~18 sequential round trips, several of them aggregate scans over large materialisation tables. Under the new model that slot is 1/8 of the sweep's entire budget, and since POOL_SIZE.get_timeout is None the other checks queue behind it with no deadline, so this single check sets the floor on sweep latency for every DB check that starts after it. The sibling checks changed in this PR (pg_tuning, sync_session_errors) already adopt the fix: drop the connection once the querying phase is done. Here the cheapest version is to scope the connection to the measurement loop and drop it before the grading/aggregation below, and ideally re-acquire per resource rather than holding one across all nine.

@review-hero

review-hero Bot commented Sep 15, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary (round 7)
9 agents reviewed this PR | 1 critical | 2 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 5 below threshold

Below consensus threshold (5 unique issues not confirmed by majority)
Location Agent Severity Comment
crates/alertd/src/checks.rs:535 Bugs & Correctness nitpick test_support::connect builds the test pool with create_pool (default PoolSize: 10 connections, 30s get_timeout) rather than create_pool_sized(..., POOL_SIZE). So `concurrent_checks_get_th...
crates/alertd/src/checks/reporting_roles.rs:58 Bugs & Correctness nitpick The pooled connection is acquired before the role.is_empty() guard at line 65, so this check can take one of the sweep's eight slots and hand it straight back without issuing a query. That matter...
crates/bestool/src/alertd/doctor.rs:270 Bugs & Correctness suggestion pool_for calls create_pool_sized, which on an auth error with no password in the URL falls into rpassword::prompt_password (crates/postgres/src/pool.rs) — a blocking, synchronous terminal r...
crates/bestool/src/alertd/doctor.rs:311 Security suggestion endpoint_recompute has no concurrency guard, and each on-demand sweep now draws from the same 8-connection pool with no acquire deadline. Previously every check's acquire gave up after mobc's 30s...
crates/postgres/src/pool.rs:105 Bugs & Correctness nitpick The PoolSize doc contradicts its only non-default caller and recommends the sizing this PR deliberately backed out of. It tells a fan-out caller to size max_open "to its own concurrency so call...
Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`crates/alertd/src/checks.rs:157`: `get_timeout: None` removes the only bound on *connection establishment*, not just on queueing. Nothing in `bestool-postgres` sets a `connect_timeout` on the tokio-postgres config (grep: no `connect_timeout`/`keepalive` anywhere in `crates/postgres/src`), so mobc's `get_timeout` was the sole deadline on `pool.get()` — and `get()` covers opening a new connection, not merely waiting for a free slot. Failure scenario: the postgres host becomes unreachable in a way that drops packets (firewall DROP, vanished VM, stalled TLS handshake) rather than refusing. Each acquire now blocks for the full OS SYN-retry window (~130s) or indefinitely instead of failing at 30s, and the sweep does these serially before any check runs: `pool_for` → `create_pool_sized` → `check_pool` → `pool.get()`, then `perform_sweep`'s setup acquire, then up to 8 slot-filling connects with 22 checks queueing behind them with no deadline. The tick can easily exceed the 10-minute watchdog, so the daemon is killed and restarted before `db_connect` ever posts its FAIL — precisely the outage alertd exists to report (AGENTS.md: postgres must never be required for alertd to alert on the database being down). The reasoning in the doc comment ("a failed acquire means the database could not be connected to") depends on the acquire actually failing. Keep the unbounded wait for slot contention but bound the connect: set `connect_timeout` on the config in `create_pool_sized` (or give `POOL_SIZE` a generous-but-finite `get_timeout` and distinguish `mobc::Error::Timeout` from `Error::Inner` in `CheckContext::db`, so contention and unreachability stay separable).

-------

`crates/alertd/src/checks/fhir_materialisation.rs:687`: The cleanup isn't failure-safe, and the new "decline if the setting already exists" guard turns a single interrupted run into a permanent block. The setting is inserted first (line ~676), then the probe patient; if the patient INSERT fails (a NOT NULL column added upstream, a changed enum for `sex`, …) the `expect` panics and the setting row survives. Likewise, `cleaned_up` `?`-chains the two deletes, so a failure deleting the patient skips the settings delete entirely. Either way the next run hits `assert!(existing.is_none())` and the test can never run again until someone removes the row by hand on a live deployment. Insert the setting last (after the patient is seeded), and run both deletes independently — collect their results rather than short-circuiting — so the setting is always removed.

-------

`crates/alertd/src/checks/fhir_materialisation.rs:202`: This check takes one pooled connection at line 202 and holds it through the whole per-resource loop: schema discovery, then for each of the 9 `RESOURCES` a possible `has_any_row` plus a `measure` gap/lag query — ~18 sequential round trips, several of them aggregate scans over large materialisation tables. Under the new model that slot is 1/8 of the sweep's entire budget, and since `POOL_SIZE.get_timeout` is `None` the other checks queue behind it with no deadline, so this single check sets the floor on sweep latency for every DB check that starts after it. The sibling checks changed in this PR (`pg_tuning`, `sync_session_errors`) already adopt the fix: drop the connection once the querying phase is done. Here the cheapest version is to scope the connection to the measurement loop and drop it before the grading/aggregation below, and ideally re-acquire per resource rather than holding one across all nine.

passcod and others added 4 commits September 15, 2026 13:09
The acquire deadline covers opening a connection as well as waiting for a free
one, and nothing set a connect timeout, so a host that drops packets rather
than refusing absorbs the whole deadline on a single connect. Every tick pays
it — the pool build, then the sweep's setup acquire — which delays the outage
report the daemon exists to make, on the one occasion it matters most.

The two waits are not the same question. A busy database deserves patience; an
unreachable one deserves none. Connecting gets a short bound, so it fails fast
and the queueing deadline keeps its generosity.

Co-authored-by: Claude <noreply@anthropic.com>
The cleanup chained its two deletes, so a failure removing the probe patient
skipped the settings delete entirely — leaving materialisation switched on for
the deployment, and blocking every later run through the guard that refuses
when the setting exists. Both deletes run now, independently of each other.

The check also held its connection through grading, having made up to two
aggregate queries per resource. It is the sweep's long pole, so that slot was
what everything still queueing waited on; it goes back when the querying ends,
as pg_tuning and sync_session_errors already do.

Co-authored-by: Claude <noreply@anthropic.com>
@passcod passcod changed the title refactor(alertd)!: move the daemon into the bestool binary (K2) refactor(alertd): move the daemon into the bestool binary (K2) Sep 15, 2026
@passcod
passcod added this pull request to the merge queue Sep 15, 2026
Merged via the queue into main with commit 3623333 Sep 15, 2026
18 checks passed
@passcod
passcod deleted the workhorse/k2 branch September 15, 2026 04:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant