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
10 KiB
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:
- 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.
- 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)
- Draft timer survives web server deploys — timer lives in a separate worker process that isn't restarted when the web server is.
- Multiple web server instances can safely coexist — Socket.IO state shared via Redis adapter; timer runs only once (in the worker).
- 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:
// 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:
- On startup, reads all active draft seasons from DB and calls
schedulePick(season)for each. schedulePick(season):- Looks up current team + their
picksExpiresAtfrom DB. - If already set:
setTimeout(onExpiry, picksExpiresAt - now). - If null (pick just started or timer not set): calls
initPick(season)to writepicksExpiresAt = now + timeRemainingand then schedules the timeout.
- Looks up current team + their
onExpiry(seasonId, teamId, pickNumber): triggers the autopick (same logic as today'striggerAutoPick). After pick resolves, callsschedulePickfor the next team.- Recovery interval: a
setInterval(recovery, 30_000)(not 1 s!) queries active drafts and re-callsschedulePickfor 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: storeexpiresAtin state, start a localsetIntervalto display countdown. - On
draft-state-sync(reconnect): server sends currentpicksExpiresAtso client can resume countdown. - Remove any logic that relied on receiving
timer-updateevery 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 errorsnpm 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:
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(...):
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:
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:
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:
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
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:
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 |