ADR-018: Multi-Pod Safety Contract (Active-Active Hard Rules AA1-AA10)
Date: 2026-05-28 Status: Accepted Issue: #937 (this ADR) Implements / codifies: P0-9 Multi-Instance Active-Active Safety Bundle (13 sub-tasks, complete 2026-05-28) Authors: Tom Tang, Eusden, claude Related: ADR-013 (dedup keys), ADR-019 (data consistency), ADR-021 (throttler rate-limit โ renumbered from ADR-020 in #1229)
Contextโ
The h.work platform deploys as multi-instance active-active in staging and prod (PR #863 established the local 2-pod stack for verification). This is a hard architectural commitment โ N pods serve traffic simultaneously behind an LB with no sticky sessions; any pod can handle any request from any user. Pod restarts (rolling deploy, scaling event, ECS task drain) MUST NOT lose business state or produce duplicate side effects.
Without explicit rules, the natural Node.js single-process patterns (new Map() for cache, setInterval for cron, setImmediate for async work) break in surprising ways once N > 1. The damage modes are subtle (extra cron fires, message duplicates, lost in-flight work) and don't show up in single-pod tests.
This ADR codifies the 10 rules every active-active codebase change MUST follow ("hard rules" AA1-AA10), enumerates the banned patterns + approved alternatives, and links each rule to the sub-task that implements it.
Decision: 10 Hard Rulesโ
| # | Rule | Implemented by | Pre-existing or this session |
|---|---|---|---|
| AA1 | All in-memory state externalized to Redis or Postgres | 9a (Redis CB), 9d (Tavus dedup) | Mostly pre-existing; 9d cleaned up in #934 |
| AA2 | All cron / scheduled tasks run exactly once globally | 9b (Scheduler โ BullMQ), 9c (@Cron โ leader election) | Pre-existing |
| AA3 | All write operations idempotent | 9d / 9e / 9f / 9g | 9g + 9f delivered in #929 / #936 |
| AA4 | All async work persisted (BullMQ, not setImmediate) | 9e / 9f | 9f delivered in #936 |
| AA5 | All writes have race protection (PG unique + atomic UPDATE...WHERE...RETURNING) | 9g / 9j | Delivered in #929 / #932 |
| AA6 | LB has no sticky session | (Infra) ALB target group stickiness.enabled=false | External (Emanuele ECS PR) |
| AA7 | Health checks fast + independent of in-flight state | P0-2 (Redis adapter), P0-7 health probes | Mostly pre-existing |
| AA8 | Graceful shutdown โฅ 2ร max in-flight LLM timeout | SIGTERM handler in main.ts | Pre-existing; ECS stopTimeout โฅ 120s external |
| AA9 | DB connection pool across pods does not exceed PG max_connections | P0-1 RDS Proxy (pool ร pod_count โค Proxy_max ร 0.8) | External (Emanuele ECS PR) |
| AA10 | Secrets / config from a single source of truth | P0-6 AWS Secrets Manager | External (Emanuele ECS PR) |
Code-level rules (AA1-AA5, AA7-AA8) are 100% complete in dev branch as of 2026-05-28. Infra rules (AA6/AA9/AA10) ship with Emanuele's ECS PR.
Banned patterns โ approved replacementsโ
| โ Don't | โ Use | Why ban |
|---|---|---|
private xxxMap = new Map() for state-across-requests | Redis SET/HSET/SET NX | Per-pod Map is incoherent; pod A doesn't know about pod B's writes |
setInterval(...) cron | BullMQ Queue.add(name, data, { repeat: { cron: ... }, jobId: stable }) | Fires N times in N-pod deployment |
setImmediate(() => doWork()) for business work | BullMQ persistent queue (see channels-outbound.processor.ts) | Lost on pod restart between schedule + execution |
| Sticky-session assumptions (same user โ same pod) | None โ code must be pod-independent | ALB doesn't pin; any code assuming it is broken |
findOne โ check โ save for state transitions | UPDATE ... WHERE ... RETURNING * (atomic claim) | TOCTOU: two pods both pass check, both write |
INSERT then catch generic error | INSERT ... ON CONFLICT (key) DO NOTHING RETURNING * with explicit unique index | Catches the wrong exception class; misses concurrent inserts |
In-process counter (let n = 0) for rate limit / metric | Redis INCR + EXPIRE, or ThrottlerStorageRedisService | Each pod has its own counter โ effective limit ร N |
In-process distributed lock (Promise mutex) | PG pg_advisory_xact_lock(hashtext(key)) | Lock is process-local; other pods don't see it |
| In-process circuit breaker | Redis-backed CB (see circuit-breaker.util.ts:24) | Other pods don't know the breaker is open |
Stateful infrastructure layer (single source of truth)โ
Each piece of state lives in EXACTLY ONE of:
- Postgres โ durable business data (conversations, messages, users, audit log)
- Redis โ fast shared coordination (dedup, rate limits, leader election, Socket.io fan-out, circuit breaker state, throttler counters)
- R2 (Cloudflare object storage) โ large blobs, artifact storage, specialist manifests
- Haystack pgvector tables โ vector embeddings and chunk metadata
- BullMQ (backed by Redis) โ durable async work queue
- Per-Org Agent sidecar (EFS workspace) โ per-org agent runtime state
Nothing business-critical lives in pod memory. Pod memory holds only ephemeral request-scoped state (the current HTTP handler stack frame, DB connection pool, parsed JWT, etc.).
Options consideredโ
- Sticky sessions + per-pod state โ Rejected: blocks linear scale-out (you can't add a pod without bouncing sessions); also breaks under any rebalance event (cordon, drain, autoscale). Half the value of active-active comes from "any request to any pod"; sticky undoes that.
- Active-passive โ Rejected: half resources wasted; in-flight work lost on every switchover; switchover takes seconds-minutes (vs zero for active-active rolling deploy).
- Leader-only writes โ Rejected: write throughput bottleneck. Election storms during high churn.
- Per-feature opt-in (some features active-active, others sticky) โ Rejected: matrix of "which features work multi-pod" becomes a tribal-knowledge minefield. All or nothing is simpler to reason about.
Consequencesโ
Positive:
- Rolling deploy with zero downtime (CodeDeploy
minimumHealthyPercent=100) - Linear scale: add N pods โ Nร capacity (no leader bottleneck)
- Pod death = client transparent (LB removes failed target; in-flight work persists in BullMQ + reloads from PG)
- Verified by
scripts/load/socketio-fanout-test.mjs(Redis adapter cross-pod fan-out) +docs/runbooks/local-multi-pod.mdcross-pod tests
Negative:
- More boilerplate for "obvious" patterns (a counter is a Redis call, not
let n = 0) - All new code needs to be reviewed against these 10 rules; CI cannot fully enforce them
- Local dev complexity: 2-pod stack reveals bugs single-pod doesn't (the entire purpose of
docker-compose.multi-pod.yml)
Validationโ
- Local: cross-pod tests in
docs/runbooks/local-multi-pod.md(round-robin, pod-kill, leader election, Socket.io fan-out) โ all green as of 2026-05-28 - Staging:
scripts/test-nfr-smoke.shautomated probes (6 PASS / 0 FAIL / 8 SKIP on local; staging-only items defer) - Production: Emanuele ECS PR brings AA6/AA9/AA10 online
Decision authority + enforcementโ
PRs that change any of the following MUST cite this ADR + the relevant AA rule in the description:
- New
setInterval/setImmediate/ in-processMapfor cross-request state - Removal of
@InjectQueuein favor of direct invocation - Removal of any
WHERE status = :expectedatomic-claim predicate - Changes to the Redis client setup or BullMQ defaults
This is a workflow rule, not a CI gate โ reviewers must remember to ask "does this conflict with ADR-018?". Adding a lint check is a follow-up consideration.
Referenceโ
- MVP P0 NFR FINAL ยง"Active-Active Hard Constraints (10 rules)" โ original AA1-AA10 table
- CLAUDE.md ยง"Deployment Model" + ยง"Shared-state implementation patterns" โ operational guidance
docs/runbooks/local-multi-pod.mdโ verification procedures- ADR-013 (dedup keys), ADR-019 (data consistency contract)
- All P0-9 PRs: #870, #909, #911, #929, #932, #934, #936 + pre-existing 9a/9b/9c/9e/9h/9i/9k/9l/9m. Drift-fix follow-ups (2026-06-01 sweep): #1288, #1289, #1290, #1292, #1294, #1296.