Eight touch-disjoint waves landed on develop before this release: - W-INSIGHTS-HOT + W-RECONCILE-INSIGHTS — extract the timeout-stub persist helper and wire all seven *-status routes through it, closing the recurring ~14 s warm response the iOS client sees on dashboard open once the AI provider has stalled once that day. - W-DELETED-2 — close the remaining three soft-delete reader-tier gaps (export bundle, gamification achievements, doctor-report aggregator) with a five-route integration-test pin. - W-ORG — types consolidation into src/types/analytics.ts and src/types/backups.ts; prompt directory unification. - W-FRONTEND-FACTORY — queryKey factory expansion + ESLint rule lift. - W-IOS-COORD — POST /api/auth/check-user four-branch onboarding discovery, identifier matched verbatim, per-IP rate-limited. - W-SIMPLIFIER — today-intake projection helper extract; unused tx? params dropped; 13 knip-flagged dead exports trimmed. - W-PERF-OPS — v1.4.39 W-SUM legacy-NULL UNION discovery arm retired. - W-PROCESS-DOCS — pg.Pool sizing operator doc; ESLint rule formalisation; knip CI gate staged for v1.4.42 flip. No migration, no env-var change, no API contract break for iOS v0.5.4. The new check-user endpoint is an additive surface. AP-2 .p8 key is installed; SB-5 time-sensitive APNs is live as of this deploy. pnpm test --run green at 4732 passing / 1 skipped. pnpm typecheck, pnpm lint, pnpm knip green.
5.5 KiB
Scaling: web/worker container split (v1.4 G3)
The default docker compose up launches a single container that runs
both the Next.js HTTP server AND the pg-boss worker. That works fine
for a personal deployment but is hard to scale horizontally because
every replica also runs the full reminder/insight cron schedule.
v1.4 introduces an environment-variable gate so the same image can be deployed in three modes:
HEALTHLOG_PROCESS_TYPE |
Web | Worker | Use when |
|---|---|---|---|
all (default) |
✅ | ✅ | single-host self-hosting |
web |
✅ | ❌ | scaling HTTP horizontally |
worker |
❌ | ✅ | dedicated job worker |
How to split
- Edit
docker-compose.yml: setHEALTHLOG_PROCESS_TYPE=webon theappservice and uncomment theapp-workerservice block at the bottom (it ships withHEALTHLOG_PROCESS_TYPE=workerbaked in). - Make sure both containers see the same
DATABASE_URL,ENCRYPTION_KEY(orENCRYPTION_KEYS), andAPI_TOKEN_HMAC_KEY. docker compose up -d --build.
Both containers connect to the same Postgres. pg-boss claims jobs atomically via row-level locking, so it is safe to run multiple worker replicas — they will share the load.
Healthchecks
app(web) healthcheck:wget /api/versionevery 30s.app-workerdoes not expose a port; its liveness is reported into theworker_statustable on every reminder pass and surfaced through/api/admin/worker-status.
The web container does NOT depend on the worker, and vice versa, so
neither container's startup can deadlock the other. The shared
dependency is Postgres; both wait on db: condition: service_healthy.
Caveats
- Background tasks that touch user-scoped Wide Events (e.g. reminder
notifications) emit telemetry from the worker; configure
LOKI_*env vars on the worker if you want them. - The off-host backup job runs in the worker only. The admin
POST /api/admin/backup/testendpoint runs in the web container and exercises the same S3 credentials — setBACKUP_*env vars on BOTH. - The startup gate (
assertSubsystemEnabled) refuses to boot the reminder worker whenHEALTHLOG_PROCESS_TYPE=web, so an accidental cross-mode invocation aborts immediately instead of silently doubling cron load.
Postgres connection-pool sizing (v1.4.40 W-POOL)
Every container that boots the HealthLog image opens its own
pg.Pool against the configured DATABASE_URL. The pool ceiling is
20 connections per container by default (raised from the library
default of 10 in v1.4.40 to keep the dashboard's analytics fan-out
from starving every other query during a cold mount).
Plan total Postgres slots as container_count × DATABASE_POOL_MAX.
A stock Postgres 16 container ships with max_connections = 100, so
the safe envelope is:
| Web replicas | Worker replicas | Total containers | Pool slots used | Headroom under 100 |
|---|---|---|---|---|
| 1 | 0 | 1 | 20 | 80 |
| 1 | 1 | 2 | 40 | 60 |
| 2 | 1 | 3 | 60 | 40 |
| 3 | 1 | 4 | 80 | 20 |
| 4 | 1 | 5 | 100 | 0 (do not exceed) |
Once the table tips into the "0 headroom" row, every other client of
the same Postgres — psql sessions, ad-hoc backups, the Prisma CLI
during a migration deploy — will get FATAL: sorry, too many clients already. Either raise max_connections on the Postgres side, lower
DATABASE_POOL_MAX, or front the database with PgBouncer in
transaction-pooling mode.
Overriding the per-container pool ceiling
Set DATABASE_POOL_MAX to a positive integer on every container that
needs a non-default ceiling — both web and worker pick up the same
env var:
# docker-compose.yml — example for a 6-container deployment
services:
app:
image: ghcr.io/mbombeck/healthlog:latest
environment:
DATABASE_POOL_MAX: "12" # 6 containers × 12 = 72 < 100
HEALTHLOG_PROCESS_TYPE: web
Rules of thumb:
- Each web replica's hottest path (
/api/analyticsfan-out, capped atp-limit(4)per v1.4.40 W-POOL) consumes 4 concurrent slots. KeepDATABASE_POOL_MAX ≥ 8so a single power-user request never exhausts the pool of one container. - Worker replicas mostly use 1 connection per active pg-boss job;
DATABASE_POOL_MAX = 8is plenty unless you raised the pg-bossteamConcurrency. - If you raise Postgres
max_connectionspast 100, prefer raisingDATABASE_POOL_MAXover adding more containers — fewer, fatter Node processes amortise the V8 footprint better than many thin ones.
Why the default is 20
The v1.4.39 empirical cold-mount trace showed thick /api/analytics
holding ≥ 8 of the 10 default pool slots for 6.5 s on a 347 k-row
tenant, starving every other dashboard query for the duration. The
20-slot default — paired with the p-limit(4) cap on the analytics
fan-out — keeps 16 slots free for the rest of the dashboard while
still sitting well under Postgres's 100-slot stock ceiling.
The implementation lives in src/lib/db.ts → getPoolMax(); the
rationale and the audit trail live in
.planning/round-v1439-empirical-trace.md § B2 and the v1.4.40
W-POOL phase report.