From 164c2adfb70c2767c1dedbe66c94a39a06f36da9 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Mon, 8 Jun 2026 01:09:00 -0700 Subject: [PATCH] Update infrastructure roadmap: Phase C uses Redis adapter not sticky sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/infrastructure-roadmap.md | 50 ++++++++++++++++------------------ 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/docs/infrastructure-roadmap.md b/docs/infrastructure-roadmap.md index f58d30d..2343e12 100644 --- a/docs/infrastructure-roadmap.md +++ b/docs/infrastructure-roadmap.md @@ -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). -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:** ```yaml +redis: + image: redis:7-alpine + restart: unless-stopped + networks: + - internal + +# On the brackt service: deploy: replicas: 2 - -labels: - - "traefik.http.services.brackt.loadbalancer.sticky.cookie=true" - - "traefik.http.services.brackt.loadbalancer.sticky.cookie.name=brackt_instance" + update_config: + parallelism: 1 + delay: 10s + 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:** -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 +**Rolling deploy:** `docker compose up -d` with `order: start-first` handles it automatically — no custom drain script needed. -**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:** rolling-deploy both instances during a staging draft; clients see at most one brief reconnect. +**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. --- @@ -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 B–C are clean. - ---- - ## Sequencing summary | 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 | | A | ✅ Done | — | External cron; automated standings sync + sim; /healthz | | B | 🔜 Next | ~1d | Compose delivered by CI; pinned tags; healthcheck; unblocks C | -| C | Blocked on B | ~2–3d | 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 | -| E | Deferred | ~1d | Cross-instance socket fan-out (not needed yet) | -**Total to zero-downtime deploys: ~3–4 days of focused work** (Phases B + C). +**Total to zero-downtime deploys: ~3 days of focused work** (Phases B + C). ---