# Plan: Zero-Downtime Deploys + Multi-Server Scaling ## Context Currently the draft timer (`server/timer.ts`) runs as a `setInterval` every second inside the web server process. This causes two problems: 1. **Deploys interrupt active drafts** — when the web server restarts during a rolling deploy, the timer stops, Socket.IO clients disconnect, and users see a "reconnecting" banner mid-draft. 2. **Cannot run multiple web server instances** — if two instances both run the timer tick, they independently decrement `timeRemaining` (2x speed) and both attempt to trigger autopicks on the same pick slot. Additionally, `server/snapshots.ts` has the same per-instance `setInterval` problem (daily snapshots would be created multiple times). There is no Redis, no distributed locking, and no graceful shutdown handler today. ## Three Goals (in priority order) 1. **Draft timer survives web server deploys** — timer lives in a separate worker process that isn't restarted when the web server is. 2. **Multiple web server instances can safely coexist** — Socket.IO state shared via Redis adapter; timer runs only once (in the worker). 3. **Cron jobs (standings sync, daily snapshots) run reliably once** — managed inside the same worker. --- ## Architecture Target ``` ┌─────────────────────┐ Redis (pub/sub + ┌─────────────────────┐ │ Web Server(s) │ Socket.IO adapter) │ Timer Worker │ │ - HTTP / SSR │◄──────────────────────►│ - Draft timers │ │ - Socket.IO │ │ - Daily snapshots │ │ - React Router │ │ - Standings crons │ └─────────────────────┘ └─────────────────────┘ │ │ └───────────────────── PostgreSQL ──────────────┘ ``` Socket.IO uses `@socket.io/redis-adapter` so any instance can broadcast to any client room. The timer worker connects to the same Redis bus and emits into draft rooms — web servers relay those to connected clients. --- ## Phase 1: Event-Driven Timer (stop per-second DB writes) ← START HERE ✅ **Goal**: replace the `setInterval(1000)` + per-second `UPDATE draftTimers SET timeRemaining = GREATEST(timeRemaining - 1, 0)` with a model where the server stores *when the timer expires* and uses a single `setTimeout`. ### 1a. DB schema change Add two nullable columns to `draft_timers`: ```ts // database/schema.ts picksExpiresAt: timestamp("picks_expires_at"), // null = paused or pick just made picksStartedAt: timestamp("picks_started_at"), // when current pick slot opened ``` **Migration**: `npm run db:generate && npm run db:migrate` Keep `timeRemaining` — it is still the authoritative bank value in chess-clock mode and is needed when pausing/unpausing to know how much time to restore. ### 1b. Timer logic rewrite (`server/timer.ts`) Replace `setInterval(fn, 1000)` with a scheduler that: 1. **On startup**, reads all active draft seasons from DB and calls `schedulePick(season)` for each. 2. **`schedulePick(season)`**: - Looks up current team + their `picksExpiresAt` from DB. - If already set: `setTimeout(onExpiry, picksExpiresAt - now)`. - If null (pick just started or timer not set): calls `initPick(season)` to write `picksExpiresAt = now + timeRemaining` and then schedules the timeout. 3. **`onExpiry(seasonId, teamId, pickNumber)`**: triggers the autopick (same logic as today's `triggerAutoPick`). After pick resolves, calls `schedulePick` for the next team. 4. **Recovery interval**: a `setInterval(recovery, 30_000)` (not 1 s!) queries active drafts and re-calls `schedulePick` for any whose in-memory timeout was lost (e.g., process restart). This is the only periodic DB query. **Overnight pause handling**: when an overnight pause window activates, cancel the in-memory `setTimeout`, set `picksExpiresAt = null` in DB, record remaining time in `timeRemaining`. When the window ends, restore via `initPick`. The recovery interval detects this automatically on restart. **Chess clock mode**: `timeRemaining` stores the bank; `picksExpiresAt = now + timeRemaining` when a pick starts. `picksStartedAt` tracks when the pick opened. On pick completion, calculate actual elapsed: `bankUsed = now - picksStartedAt`, new `timeRemaining = timeRemaining - bankUsed`. ### 1c. Socket.IO event change Stop emitting `timer-update` every second. Instead emit: | Event | When | Payload | |---|---|---| | `timer-pick-started` | Pick slot opens | `{ seasonId, teamId, pickNumber, expiresAt, timeRemaining }` | | `timer-paused` | Draft paused or overnight pause | `{ seasonId, teamId, overnightPauseActive, resumesAtUTC? }` | | `timer-resumed` | Pause lifted | `{ seasonId, teamId, expiresAt, timeRemaining }` | | `pick-made` | Already exists | no change | Client (`app/hooks/useDraftSocketEvents.ts`) counts down locally from `expiresAt` using a client-side `setInterval`. Remove the server-driven timer display logic. ### 1d. Client update - On `timer-pick-started`: store `expiresAt` in state, start a local `setInterval` to display countdown. - On `draft-state-sync` (reconnect): server sends current `picksExpiresAt` so client can resume countdown. - Remove any logic that relied on receiving `timer-update` every second. ### Files changed in Phase 1 | File | Change | |---|---| | `database/schema.ts` | Add `picksExpiresAt`, `picksStartedAt` to `draftTimers` | | `server/timer.ts` | Rewrite: event-driven scheduler, 30s recovery interval | | `server/socket.d.ts` | Add `timer-pick-started`, `timer-paused`, `timer-resumed`; remove `timer-update` | | `server/socket.ts` | Emit new events on draft state sync (reconnect path) | | `app/hooks/useDraftSocketEvents.ts` | Handle new events, remove `timer-update` handler | | `app/routes/leagues/$leagueId.draft.$seasonId.tsx` | Client-side countdown from `expiresAt` | **Verification**: - `npm run typecheck` — no errors - `npm run test:run` — passes - Manual: start a draft, watch picks countdown without seeing any "timer-update" Socket.IO events in browser devtools. Confirm pick fires at correct time. Confirm reconnect restores correct remaining time. --- ## Phase 2: Separate Worker Process **Goal**: timer runs in a process that is not restarted during web server deploys. ### 2a. Add Redis to infrastructure Add to `docker-compose.yml`: ```yaml redis: image: redis:7-alpine restart: unless-stopped volumes: - redis_data:/data ``` Add env var `REDIS_URL` (default `redis://localhost:6379`). Install: `npm install ioredis @socket.io/redis-adapter` ### 2b. Socket.IO Redis adapter on web servers In `server/socket.ts`, after `new Server(...)`: ```ts import { createAdapter } from "@socket.io/redis-adapter"; import { createClient } from "ioredis"; const pub = createClient(process.env.REDIS_URL); const sub = pub.duplicate(); io.adapter(createAdapter(pub, sub)); ``` This means any server instance can broadcast to any client in any room. Remove the `connectedTeams` in-memory Map — replace with Redis-backed presence or accept best-effort on reconnect. ### 2c. New worker entry point Create `worker/index.ts`: ```ts import { startDraftTimerSystem } from "../server/timer"; import { startSnapshotSystem } from "../server/snapshots"; startDraftTimerSystem(); startSnapshotSystem(); ``` **For Socket.IO emission from the worker**: use direct Redis pub/sub (simpler than a second Socket.IO server). Add a `server/timer-events.ts` module with `publishTimerEvent(channel, payload)` (worker side) and `subscribeTimerEvents(io)` (web server side). ### 2d. Docker Compose Add worker service: ```yaml worker: image: ${REGISTRY}/brackt:${TAG} command: node dist/worker.js depends_on: [db, redis] environment: *app-env restart: unless-stopped ``` ### Files to change in Phase 2 | File | Change | |---|---| | `docker-compose.yml` | Add `redis` and `worker` services | | `server/socket.ts` | Add Redis adapter; add timer-event subscription | | `server/timer.ts` | Emit via Redis pub/sub instead of `getSocketIO()` | | `server/snapshots.ts` | Move to worker only (remove import from `server.ts`) | | `worker/index.ts` | New file: starts timer + snapshots | | `Dockerfile` | Copy `worker/` dir; add `dist/worker.js` output | | `package.json` | Add `build:worker` script | | `.forgejo/workflows/deploy.yml` | Deploy worker service alongside app | --- ## Phase 3: Zero-Downtime Web Server Deploy **Goal**: rolling restart of web servers doesn't disconnect clients abruptly. ### 3a. Graceful shutdown In `server.ts`, add SIGTERM handler: ```ts process.on("SIGTERM", () => { httpServer.close(() => process.exit(0)); io.close(() => process.exit(0)); setTimeout(() => process.exit(0), 10_000); }); ``` ### 3b. Health check endpoint Add `GET /health` → `{ status: "ok" }` to `server/app.ts`. ### 3c. Docker config ```yaml app: healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/health"] interval: 10s start_period: 15s stop_grace_period: 15s ``` --- ## Phase 4: Cron Infrastructure in Worker **Goal**: standings sync and other periodic tasks run on a schedule, once. Install `node-cron` in the worker and schedule jobs: ```ts cron.schedule("0 3 * * *", () => syncAllStandings(db)); ``` Use a Postgres advisory lock if multiple workers are ever needed. --- ## Implementation Order ``` Phase 1 (event-driven timer) — no infrastructure needed, pure code change ↓ Phase 2 (worker + Redis) — adds Redis, separates worker process ↓ Phase 3 (graceful shutdown + health check) — deploy strategy improvement ↓ Phase 4 (cron in worker) — scheduling infrastructure ``` ## Key Existing Code to Preserve/Reuse | Code | Path | Notes | |---|---|---| | `startDraftTimerSystem()` / `stopDraftTimerSystem()` | `server/timer.ts` | Keep the public API; replace internals | | `triggerAutoPick()` | `server/timer.ts` | Unchanged logic, just called from `setTimeout` instead of `setInterval` | | `checkOvernightPause()` | `server/timer.ts` | Keep as-is | | `executeAutoPick()` | `app/models/draft-utils.ts` | Unchanged | | `getSocketIO()` | `server/socket.ts` | Used in web server; worker will bypass via Redis pub/sub in Phase 2 | | `draftSlotsCache` | `server/timer.ts` | Keep — still valid optimization for the recovery check |