brackt/plans/zero-downtime-scaling.md
Claude c175d70084 Phase 1: Replace per-second timer tick with event-driven scheduler
Eliminates the setInterval(1s) + per-second DB write by storing picksExpiresAt
in draft_timers and using a targeted setTimeout per pick. Clients count down
locally from the expiresAt timestamp, removing server-pushed timer-update events.

Key changes:
- database/schema.ts: add picksExpiresAt and picksStartedAt to draft_timers
- server/timer.ts: full rewrite — schedulePickForSeason, rescheduleTimer,
  30s recovery interval instead of 1s tick, overnight-pause resume scheduling
- server/socket.ts: new timer-pick-started / timer-overnight-paused events,
  updated draft-state-sync to include expiresAt for reconnect recovery
- draft.make-pick / draft.force-manual-pick: compute actual remaining from
  picksExpiresAt at pick time; call rescheduleTimer after the autodraft chain
- useDraftSocketEvents: handle new timer events, restore countdown on reconnect
- $leagueId.draft.$seasonId: client-side countdown useEffect from expiresAt
- plans/zero-downtime-scaling.md: full 4-phase scaling plan for future reference

Resolves 2351 unit tests (all passing).

https://claude.ai/code/session_019k5J6Ty7uP5HxSx6CsbiBK
2026-06-05 22:51:43 -07:00

251 lines
10 KiB
Markdown

# 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 |