Update infrastructure roadmap: Phase C uses Redis adapter not sticky sessions
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m39s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m23s
🚀 Deploy / 🐳 Build (push) Successful in 1m10s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s

19 room-targeted socket emit callsites need cross-instance fan-out — the
Redis adapter handles them all automatically. Drops sticky sessions and
the custom drain script in favour of compose update_config order:start-first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-06-08 01:09:00 -07:00
parent 968aa72992
commit 164c2adfb7

View file

@ -109,32 +109,37 @@ Non-secret config (NODE_ENV, PGBOUNCER, APP_URL, etc.) stays inline in the compo
Requires Phase B (no `container_name`, healthcheck, pinned tag). Requires Phase B (no `container_name`, healthcheck, pinned tag).
Run 2 web containers behind Traefik with sticky sessions. Rolling deploy takes one replica out of rotation, upgrades it, puts it back — other replica keeps serving. Run 2 web containers behind Traefik, with the **Redis Socket.IO adapter** handling cross-instance fan-out. Rolling deploy uses Docker Compose's `update_config` to bring up the new replica before stopping the old one.
**Why Redis (not sticky sessions + polling/LISTEN/NOTIFY):** there are 19 room-targeted `getSocketIO().to(draft-${seasonId}).emit()` callsites across routes, models, and the timer. With sticky sessions, two different browsers watching the same draft (phone + laptop) can land on different instances — every one of those 19 emits needs to reach both. Polling introduces unacceptable latency for live draft events (pause, force-pick, timer). LISTEN/NOTIFY would require instrumenting all 19 callsites. The `@socket.io/redis-adapter` intercepts all of them automatically with zero callsite changes, and lets us drop sticky sessions entirely (simpler deploy). Redis is a single ~5MB container — at this point it's clearly justified.
**Compose changes:** **Compose changes:**
```yaml ```yaml
redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- internal
# On the brackt service:
deploy: deploy:
replicas: 2 replicas: 2
update_config:
labels: parallelism: 1
- "traefik.http.services.brackt.loadbalancer.sticky.cookie=true" delay: 10s
- "traefik.http.services.brackt.loadbalancer.sticky.cookie.name=brackt_instance" order: start-first # new replica healthy before old one stops
environment:
- REDIS_URL=redis://redis:6379
# (remove sticky session labels — not needed with Redis adapter)
``` ```
Socket.IO clients must return to the same instance (sticky cookie). No Redis adapter needed at current scale — the second instance is purely for deploy rotation, not capacity. **App change** (`server/socket.ts`): install `@socket.io/redis-adapter` + `redis`, attach the adapter when `REDIS_URL` is set. Gate on env var so dev works without Redis.
**Rolling deploy script:** **Rolling deploy:** `docker compose up -d` with `order: start-first` handles it automatically — no custom drain script needed.
1. Scale down replica A (remove from Traefik pool)
2. Wait for drain (or 5s hard cut — reconnect works)
3. Pull new image on A, `docker compose up` A
4. Healthcheck passes → A back in pool
5. Repeat for B
**Cross-instance broadcasts** (admin action on instance B needs to reach draft clients on instance A): use a pending-broadcasts table polled every 5s by each instance. With deadline-based timers the volume is tiny. Postgres `LISTEN/NOTIFY` is the upgrade path if polling proves too slow — requires a session-mode pgbouncer pool. **Files:** `.production-info/docker-compose-production.yml`, `server/socket.ts`, `package.json`.
**Files:** `.production-info/docker-compose-production.yml` (replicas + sticky labels), rolling deploy script. **Verification:** start two instances locally, open two draft-room tabs, confirm pick events reach both. Rolling-deploy in production; clients see at most one brief reconnect.
**Verification:** rolling-deploy both instances during a staging draft; clients see at most one brief reconnect.
--- ---
@ -146,14 +151,6 @@ After Phase 1, the "tick" is an idle process that wakes only at pick deadlines.
--- ---
### Phase E — Defer: Socket.IO Redis adapter
Only needed if a single draft's clients need to span multiple web instances (e.g., 50+ concurrent connections per draft, or geo-distributed). Not relevant at current scale.
If/when: add Redis, `@socket.io/redis-adapter`, drop sticky sessions. ~1-day change if Phases BC are clean.
---
## Sequencing summary ## Sequencing summary
| Phase | Status | Effort | Key win | | Phase | Status | Effort | Key win |
@ -161,11 +158,10 @@ If/when: add Redis, `@socket.io/redis-adapter`, drop sticky sessions. ~1-day cha
| 1 | ✅ Done | — | Deadline-based timer; eliminates 1s tick | | 1 | ✅ Done | — | Deadline-based timer; eliminates 1s tick |
| A | ✅ Done | — | External cron; automated standings sync + sim; /healthz | | A | ✅ Done | — | External cron; automated standings sync + sim; /healthz |
| B | 🔜 Next | ~1d | Compose delivered by CI; pinned tags; healthcheck; unblocks C | | B | 🔜 Next | ~1d | Compose delivered by CI; pinned tags; healthcheck; unblocks C |
| C | Blocked on B | ~23d | Zero-downtime rolling deploys | | C | Blocked on B | ~2d | Zero-downtime rolling deploys via replicas + Redis adapter |
| D | Optional | ~1wk | Deploy web without restarting timer process | | D | Optional | ~1wk | Deploy web without restarting timer process |
| E | Deferred | ~1d | Cross-instance socket fan-out (not needed yet) |
**Total to zero-downtime deploys: ~34 days of focused work** (Phases B + C). **Total to zero-downtime deploys: ~3 days of focused work** (Phases B + C).
--- ---