Dependency-loss / degraded-mode exercise (NATS)
ROADMAP.md's Stage 5 "Reliability and support" section calls for "database-loss,
dependency-loss, credential-leak, and outage exercises." This document records a real
dependency-loss exercise against NATS (JetStream), the message bus cmd/relay publishes
domain events to. It was run once, on 2026-07-31, against fully isolated infrastructure —
not the shared local dev stack other engineers/agents rely on — and every finding below is
a real, observed result, not a hypothesis about what the code "should" do.
Why NATS, and why isolated
NATS was chosen because /readyz's exact scope is easy to state precisely (it pings
Postgres only — see internal/server/server.go) and cheap to falsify: does the API
actually notice when the one dependency /readyz doesn't check is gone? Postgres was not
used for this exercise because every other component (including /readyz itself) already
depends on it directly — killing it isn't a targeted single-dependency test, it's just an
outage of everything at once, and isn't something that can be done safely against the
shared instance other agents were using concurrently.
Nothing shared was touched. The exercise used:
- A second, throwaway NATS container (
buddora_dr_drill_nats, plainnats:2.10-alpine -js -m 8222, ports24222/28222— distinct from the sharedbuddora_nats_1on4222/8222), so stopping it could not affect any other process using the realbuddora_nats_1. - A throwaway scratch Postgres database (
buddora_dr_drill, created on the shared Postgres server the same way the backup-restore drill's scratch database is — see Backup & Restore — migrated withapi migrate upand dropped at the end), so no shared table or row was read, written, or locked. - Freshly started, isolated
cmd/apiandcmd/relayprocesses (go run, not the containers other agents were talking to), bound to unused ports (:18080–:18083) and pointed at the two throwaway resources above viaDATABASE_URL/NATS_URL, withAPI_DEV_AUTH_BYPASS=true(permitted only becauseAPI_ENV=development) so a real authenticated write could be issued without provisioning Keycloak users, andOPENFGA_API_URLleft unset socmd/apifell back to its built-in in-memory authorization stub instead of calling the shared OpenFGA instance. - The shared stack (
buddora_postgres_1,buddora_nats_1, and everything else ininfrastructure/docker/docker-compose.yml) was confirmed running, untouched, before and after (podman psoutput identical modulo container uptime).
What was run, in order, with real timestamps
All times UTC unless noted.
- Started
buddora_dr_drill_nats; confirmed reachable (curl http://localhost:28222/varz). - Created
buddora_dr_drill, ranDATABASE_URL=...buddora_dr_drill... go run ./cmd/api migrate up— applied all 15 migrations cleanly. - Started an isolated
cmd/api(:18080) andcmd/relay(health:18081), both pointed atbuddora_dr_drill+buddora_dr_drill_nats. Both came up healthy/ready (GET /readyz→200 {"status":"ready"}; relayGET /healthz→200). - Baseline (NATS up):
POST /v1/safety/lost-reportswith a realX-Debug-Actorheader →201 Created, id1321ae10-.... The relay published it to JetStream and marked it published 178 ms later (occurred_at07:10:31.485694,published_at07:10:31.663046), confirming the happy path end-to-end before touching anything. - Outage start:
podman stop buddora_dr_drill_natsat07:10:45. Confirmedbuddora_nats_1(the shared container) was untouched and stillUpthroughout. - While NATS was down:
GET /readyzon the isolated API → still200 {"status":"ready"}.GET /healthzon the isolated relay → still200.POST /v1/safety/lost-reports(a real write, different actor) → still201 Created, idd519acc0-...,createdAt07:10:51Z.- The relay's own log recorded real, repeated failures on this row:
(~5 s between attempts — the JetStream publish call blocks for an ack up to its default timeout before the relay's transaction rolls back and the row is picked up again on the next 1 s poll tick.)07:10:56 ERROR relay: publish failed, will retry id=d519acc0-... error="events: publish to events.animal.lost_reported.v1: context deadline exceeded"07:10:56 ERROR relay: poll failed error="events: publish to events.animal.lost_reported.v1: context deadline exceeded"07:11:01 ERROR relay: publish failed, will retry id=d519acc0-... error="... context deadline exceeded"07:11:01 ERROR relay: poll failed error="... context deadline exceeded"07:11:06 ERROR relay: publish failed, will retry id=d519acc0-... error="... context deadline exceeded"07:11:11 ERROR relay: publish failed, will retry id=d519acc0-... error="... context deadline exceeded"
outbox_eventfor the new row:published_at IS NULL, confirmed via direct query while the failures above were in progress.
- Recovery:
podman start buddora_dr_drill_nats. The relay's very next poll cycle published the stuck row without any operator action —07:11:16 INFO relay: published batch count=1. No manual requeue was needed; this is the expected/designed self-healing path (POST /v1/admin/outbox/{id}/requeueexists for events still stuck after the dependency recovers, not for this case — see the Runbooks). - Cold start during an outage (the most important finding — stopped
buddora_dr_drill_natsagain to test this): started a brand-newcmd/relayprocess pointed at the still-down isolated NATS. It did not start a health server, did not begin polling, and did not stay up in a degraded state — it exited immediately:The same test against a brand-newERROR fatal error="relay: connect to NATS: events: connect to NATS: nats: no servers available for connection"exit status 1cmd/apiproduced the identical outcome:Both processes connect to NATS exactly once, at startup, and treat a failed connection as fatal rather than degrading — this is explicit inERROR fatal error="event publisher: events: connect to NATS: nats: no servers available for connection"exit status 1cmd/api/main.go's comment above the call ("a deployment that cannot reach NATS has a real infrastructure problem independent of whether admin requeue is ever used"), so it is a deliberate choice, not an oversight. But it has a real operational consequence worth stating plainly: an already-running API/relay tolerates a NATS outage gracefully (writes keep succeeding, health checks stay green, the backlog self-heals); a NATS outage that coincides with a restart, deploy, crash-loop, or autoscale event takes the whole API down, not just event delivery. These are two different severities and the current health checks cannot distinguish which one you're in from the outside — only process logs can. - Cleanup: killed the isolated
cmd/api/cmd/relayprocesses, stopped and removedbuddora_dr_drill_nats, droppedbuddora_dr_drill. Re-confirmed the shared stack (podman ps) was identical to before the exercise.
Findings, stated plainly
- The outbox pattern works as designed. A full NATS outage does not fail writes and does not lose events — it just delays their fan-out, and the delay self-heals with no operator action once NATS is reachable again.
- Neither
/readyznorcmd/relay's/healthzdetects a NATS outage in an already-running process. Both only ping Postgres. The only real signal during a live outage is the outbox backlog itself (GET /v1/admin/outbox/stuck), which makes backlog monitoring/alerting load-bearing, not a nice-to-have, for actually noticing this class of outage before a customer does (e.g. before "notification dispatch: 95% within 60 seconds," one of ROADMAP.md's own reliability targets, silently starts failing). - A NATS outage is fatal to process startup, for both
cmd/apiandcmd/relay. This is the finding most worth escalating: it means NATS is a hard availability dependency for the whole API today, not just for event delivery, specifically at restart/deploy/autoscale time. Whether that's the intended tradeoff for this stage is a real product/ops decision to make consciously (a nil-publisher fallback with a background reconnect loop is the usual alternative, at the cost of the write path being able to appear healthy while quietly never delivering anything), not something this exercise resolves — it just surfaces the gap with real evidence instead of leaving it as an assumption on either side.
Honesty about scale and what this does and doesn't prove
- This proves real, observed behavior of the actual code under a real (if small and short-lived) dependency outage — not a simulation, not a code-reading guess.
- It does not prove production-scale resilience: one API replica, one relay replica, a single-node NATS with no clustering, a ~1-request backlog, and an outage lasting under a minute. Production JetStream deployments are typically clustered specifically so a single-node loss doesn't cause the "NATS is completely unreachable" scenario tested here at all; this exercise deliberately tested the worse case (total unavailability) to see what happens if clustering/failover isn't in place or has itself failed.
- It exercised exactly one dependency (NATS). Postgres-loss, Redis-loss, MinIO-loss, OpenFGA-loss, and Keycloak-loss are separate, undone exercises — see the Stage 5 exit gate in ROADMAP.md ("Run database-loss, dependency-loss, credential-leak, and outage exercises").
- The database-loss and credential-leak exercises referenced by ROADMAP.md's Stage 5 checklist are covered elsewhere: database-loss by the real backup/restore drill in Backup & Restore, and credential-leak by the "Credential leak / suspected compromised admin account" runbook in the Runbooks, which documents the real, already-shipped account-suspension mitigation rather than a simulated exercise (suspending a real account for a drill would create a real audit trail entry against a real user, which isn't warranted for a mechanism that's already directly testable by reading the code and its tests).