## Summary - **Timer bank broadcasts**: emit `timer-bank-updated` after every pick so all clients immediately see the updated bank instead of waiting for the next `timer-pick-started` - **Increment accuracy**: capture `pickMadeAt` at route entry (before auth/DB overhead) and use `Math.ceil` so credited seconds always match the client countdown display - **Race condition fix**: hold `schedulingInProgress` lock for the full timer callback to prevent the recovery interval from scheduling a duplicate timeout mid-pick - **force-autopick fix**: call `rescheduleTimer` so the next team's clock starts immediately instead of waiting for the old timeout to naturally expire - **adjust-time-bank fix**: for on-clock teams, shift `picksExpiresAt` by the adjustment and reschedule so the client countdown updates; block adjustments that would reduce the bank to zero - **New socket events**: `timer-pick-started`, `timer-overnight-paused`, `timer-bank-updated` with full type definitions; removed dead `timer-update` event - **Reconnect sync**: `draft-state-sync` now includes `expiresAt` for the active timer and `isOvernightPause` state so reconnecting clients see accurate countdown and pause banner immediately without a page reload - **Room closure countdown**: capture client-side timestamp when draft completes so the "Room closes in X" countdown actually ticks down before the loader revalidates with `draftCompletedAt` - **Countdown interval**: run at 500ms with `Math.ceil` to prevent skipped seconds under event loop pressure - **Overnight pause UX**: `canPick` only blocks on commissioner pause — overnight pause freezes the timer but the on-clock player can still pick early - **Overnight pause refactor**: extract `checkOvernightPause` to `server/overnight-pause-check.ts`, breaking the `timer↔socket` circular import and sharing the timezone cache across both callers with correct eviction - **PostgreSQL type fix**: cast `varchar` owner ID to `uuid` in `getTeamTimezone` join ## Test plan - [ ] Manual pick: all clients see bank increment immediately after pick - [ ] Timeout pick: all clients see bank update (0 → increment); next clock starts within ~1s - [ ] Force-autopick: next team's clock starts immediately; no "Pick already made" log - [ ] Force-manual-pick: all clients see bank increment - [ ] Pause while clock running: countdown freezes on all clients - [ ] Resume: clock continues from frozen value - [ ] adjust-time-bank on on-clock team: countdown shifts immediately - [ ] adjust-time-bank to zero: returns 400 error - [ ] Reconnect (socket disconnect/connect): countdown resumes for correct team - [ ] Hard refresh mid-draft: on-clock indicator and countdown correct immediately - [ ] Draft complete: "Room closes in X" counts down - [ ] Overnight pause: banner shows, pick buttons still enabled, timer frozen - [ ] `npm run test:run` — all 158 files / 2351 tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: #72
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 |