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
14 KiB
Deadline-based draft timer — implementation guide
Companion to docs/infrastructure-roadmap.md Phase 1. This is the "how" doc; the roadmap is the "why."
The shape of the change
Today, server/timer.ts runs a 1-second setInterval that decrements draftTimers.timeRemaining and broadcasts timer-update events. Everything else (manual picks, admin adjustments, pause/resume) just writes timeRemaining; the tick is the only thing that knows about "time."
After this change, time becomes a deadline (a timestamptz) stored on the seasons row. The server schedules a single setTimeout per active draft, sleeps until the deadline, and fires the auto-pick. Per-second writes and per-second broadcasts go away.
Schema
One new nullable column on seasons:
ALTER TABLE seasons ADD COLUMN pick_deadline_at timestamptz;
Drizzle:
// app/database/schema.ts — seasons table
pickDeadlineAt: timestamp("pick_deadline_at", { withTimezone: true }),
Semantics:
- NULL when the draft is paused, pre-draft, completed, or before the first deadline has been written.
- Set to an absolute UTC timestamp when the current pick is actively counting down.
draftTimers.timeRemaining keeps its existing meaning (per-team remaining seconds, used for chess-clock bank and as "saved seconds while paused"). No other schema changes.
The single helper every callsite uses
The risk in this refactor is forgetting one of the four+ places that write draftTimers today. Mitigate by funneling all deadline writes through one helper.
// app/models/draft-timer.ts
export interface SetNextPickDeadlineParams {
seasonId: string;
teamId: string; // the team whose pick this is (for chess-clock bank update)
budgetSeconds: number; // seconds until the deadline
tx: DbOrTx; // pass the enclosing transaction
}
export async function setNextPickDeadline({
seasonId, teamId, budgetSeconds, tx,
}: SetNextPickDeadlineParams): Promise<{ deadline: Date }> {
const now = new Date();
const deadline = new Date(now.getTime() + budgetSeconds * 1000);
// 1. Write the deadline on the season.
await tx.update(schema.seasons)
.set({ pickDeadlineAt: deadline })
.where(eq(schema.seasons.id, seasonId));
// 2. Reset the team's timeRemaining so clients/admin tools that read it stay
// consistent. Chess-clock callers should pre-compute the new bank and pass it
// as budgetSeconds; this function does not know the mode.
await tx.update(schema.draftTimers)
.set({ timeRemaining: budgetSeconds, updatedAt: now })
.where(and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, teamId),
));
return { deadline };
}
The scheduler module (below) consumes the returned { deadline } to (re)register the in-memory setTimeout and emit the socket event. Keep DB writes inside tx; do the in-memory + socket work after the transaction commits to avoid scheduling against an aborted write.
The scheduler module
// server/scheduler.ts (rename target for the rewritten server/timer.ts)
const timers = new Map<string, { deadline: Date; handle: NodeJS.Timeout }>();
export function scheduleDeadline(seasonId: string, deadline: Date): void {
const existing = timers.get(seasonId);
if (existing) clearTimeout(existing.handle);
const delay = Math.max(0, deadline.getTime() - Date.now());
const handle = setTimeout(() => fireDeadline(seasonId, deadline), delay);
timers.set(seasonId, { deadline, handle });
}
export function cancelDeadline(seasonId: string): void {
const existing = timers.get(seasonId);
if (!existing) return;
clearTimeout(existing.handle);
timers.delete(seasonId);
}
async function fireDeadline(seasonId: string, expected: Date): Promise<void> {
// Re-read state — the closure may be stale.
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season) return; // gone
if (season.status !== "draft") return; // completed/cancelled
if (season.draftPaused) return; // paused mid-flight
if (!season.pickDeadlineAt) return; // paused or cleared
if (season.pickDeadlineAt.getTime() !== expected.getTime()) {
// Someone changed the deadline (admin adjust, etc). Reschedule, don't fire.
return scheduleDeadline(seasonId, season.pickDeadlineAt);
}
// Deadline still valid — run the auto-pick.
await runAutoPickForCurrentTeam(seasonId);
}
On boot, hydrate from the DB:
export async function bootstrapScheduler(): Promise<void> {
const active = await db.query.seasons.findMany({
where: and(
eq(schema.seasons.status, "draft"),
isNotNull(schema.seasons.pickDeadlineAt),
),
});
for (const s of active) {
scheduleDeadline(s.id, s.pickDeadlineAt!);
}
}
Already-expired deadlines fire immediately (Math.max(0, …) → setTimeout(0)), and executeAutoPick's existing idempotency ((seasonId, pickNumber) uniqueness with ON CONFLICT DO NOTHING in app/models/draft-utils.ts:629) protects against duplicate firing if the crash happened mid-pick.
Callsite migration
Five sites need to call setNextPickDeadline after this change:
1. Draft auto-start — app/services/draft-autostart.ts
Today: deletes timer rows, re-seeds them with timeRemaining: initialTime. After the draft transitions to status='draft', call the helper for pick #1's team:
const firstTeamId = /* pick 1's team from draftSlots */;
const { deadline } = await setNextPickDeadline({
seasonId, teamId: firstTeamId, budgetSeconds: initialTime, tx,
});
// After commit:
scheduleDeadline(seasonId, deadline);
emitDeadlineEvent(seasonId, deadline);
2. Manual pick — app/routes/api/draft.make-pick.ts
Today (:216-262): updates next picker's timeRemaining to incrementTime (standard) or bank + incrementTime (chess clock). Replace those raw UPDATE statements with a call to setNextPickDeadline for the next team, passing the computed budget. Inside the same transaction as the pick insert.
3. Auto-pick — server/timer.ts rewrite
The old triggerAutoPick path becomes runAutoPickForCurrentTeam (called from fireDeadline). After executeAutoPick succeeds, compute the next picker's budget the same way draft.make-pick.ts does and call setNextPickDeadline. If the just-picked draft completes (no next pick), call cancelDeadline and clear pickDeadlineAt.
Move while_on cascade here: after setting the next deadline, if the next team is while_on autodraft, fire runAutoPickForCurrentTeam immediately (recursive call), capped at totalTeams iterations. Same semantics as today's tick loop.
4. Admin time-bank adjust — app/routes/api/draft.adjust-time-bank.ts
Today (:76-78): writes new timeRemaining. After the rewrite, if the team being adjusted is the current picker, also update pickDeadlineAt to reflect the new budget:
if (teamId === currentPickerTeamId && !season.draftPaused) {
await setNextPickDeadline({ seasonId, teamId, budgetSeconds: newTime, tx });
scheduleDeadline(seasonId, /* new deadline */);
}
For non-current teams, only timeRemaining changes (no deadline impact).
5. Pause / Resume
- Pause: compute remaining =
pickDeadlineAt − now(), write intodraftTimers.timeRemaining, null outseasons.pickDeadlineAt, callcancelDeadline(seasonId). - Resume: call
setNextPickDeadlinewithbudgetSeconds = current draftTimers.timeRemaining. Same code path as a fresh pick start.
Overnight pause
Existing logic in server/timer.ts (checkOvernightPause, isInOvernightWindow, getOvernightResumeUTC) ports cleanly. When setNextPickDeadline is about to write a deadline that falls inside an overnight window, instead write deadline = resumeAtUTC, and include a frozen: true flag in the socket event so the UI shows the pause indicator. The scheduler doesn't need to know — it just wakes up at resumeAtUTC.
Edge: if a pause window crosses an admin time-bank adjust, the recompute path naturally re-evaluates the window. Always go through setNextPickDeadline.
Client side
Socket event
Replace the per-second timer-update with a one-shot deadline-update:
// shared types
interface DeadlineUpdate {
seasonId: string;
teamId: string;
pickNumber: number;
deadline: string; // ISO timestamp
serverNow: string; // ISO timestamp at emit time
frozen?: boolean; // overnight pause
resumesAtUTC?: number; // when frozen
}
(You can keep the timer-update name if you'd rather minimize churn — there's exactly one consumer, useDraftSocketEvents.ts:261. Renaming makes the migration grep-able; not renaming makes the diff smaller. Suggest renaming.)
Handler
useDraftSocketEvents.ts:handleTimerUpdate becomes:
const handleDeadlineUpdate = (data: DeadlineUpdate) => {
const offset = new Date(data.serverNow).getTime() - Date.now();
setTimerState({
deadlineMs: new Date(data.deadline).getTime(),
serverOffsetMs: offset,
pickNumber: data.pickNumber,
frozen: data.frozen ?? false,
});
};
Countdown rendering
Wherever the draft room renders the countdown, compute locally:
const remainingMs = timerState.deadlineMs - (Date.now() + timerState.serverOffsetMs);
const remainingSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
Drive re-renders via requestAnimationFrame (smoother) or a 1Hz setInterval (cheaper, matches today's perceived cadence). Force a recompute on visibilitychange → visible so tab-backgrounded users get an accurate countdown on return.
Loader
The draft room loader (app/routes/leagues/$leagueId.draft.$seasonId.tsx) must return pickDeadlineAt (and a server timestamp) alongside the existing payload. The reconnect path (useDraftSocket's revalidate on reconnect) then re-initializes the timer without waiting for a socket event.
Migration strategy
Two-stage deploy to keep correctness rollback-able:
Stage 1 — shadow writes (1 day in production).
- Add the column.
- Update all five callsites to call
setNextPickDeadline(which writespickDeadlineAt). - Leave the 1-second
setIntervalrunning as the authoritative auto-pick path. - Add a metric: at each tick, log
actualPickDeadline = now() + timeRemainingvs the storedpickDeadlineAt. They should agree to within ~1 second. - Observe a draft or two. Spot-check the metric.
Stage 2 — flip authority.
- Replace
startDraftTimerSystem()withbootstrapScheduler()inserver.ts. - The scheduler is now authoritative; the 1-second interval is gone.
- Roll out. Confirm
deadline-fire accuracymetric (actual vs scheduled fire time) stays under 100ms.
Rollback: revert the deploy. The pickDeadlineAt column remains but is unused; timeRemaining semantics are unchanged, so the old tick resumes correctly.
Tests
New tests (add alongside existing server/__tests__/timer-*.test.ts):
- Idempotency on restart. Insert a draft with
pickDeadlineAtin the past and an existing pick atpickNumber. CallbootstrapScheduler(). Assert no duplicate pick is created (relies onexecuteAutoPick's existingON CONFLICT DO NOTHING). - Stale closure reschedule. Schedule a deadline, mutate
pickDeadlineAtin the DB to a later time, advance fake timers to the original deadline. AssertfireDeadlinereschedules instead of picking. - Pause during in-flight. Schedule a deadline, set
draftPaused=truein the DB, advance fake timers. Assert no pick is made. - Helper invariant. Every test that simulates a pick (manual or auto) asserts that both
seasons.pickDeadlineAtanddraftTimers.timeRemainingwere updated. This catches future callsites that bypasssetNextPickDeadline. while_oncascade. Three teams all inwhile_onmode; one manual pick by team 1 should cascade to picks by teams 2 and 3 immediately, then schedule the next deadline.- Overnight pause covers deadline. Set an overnight window 5 minutes before the computed deadline. Assert the stored deadline equals
resumeAtUTC(notnow + budget) and the event payload hasfrozen: true.
Existing tests under server/__tests__/ and app/models/__tests__/executeAutoPick.timer.test.ts should still pass — verify before flipping authority.
Observability
Replace the Phase 0 "tick drift" metric:
// At fireDeadline entry:
const drift = Date.now() - expectedDeadline.getTime();
logger.log("[Scheduler] deadline fired", { seasonId, driftMs: drift });
driftMs > 500 is the new "tick drift > 1100ms." Anything above a few hundred ms means the event loop was blocked at the moment of firing — worth investigating but not user-visible (the re-read guard handles arbitrary drift correctly).
What this does not solve
- Multi-instance scheduling. Two web instances both calling
bootstrapScheduler()would both schedulesetTimeouts for the same draft, leading to duplicate auto-pick attempts. Idempotency saves correctness but you'd get duplicate broadcasts. Phase 5 problem, not Phase 1; document the "single scheduler instance" assumption inserver/scheduler.ts. - Scheduled-draft auto-start.
checkAndAutoStartDrafts(currently called every tick) needs its own home. Easiest: a separate low-frequency (30–60s)setIntervalinserver.ts. Rare event, doesn't need precision. - Pick-completion broadcast volume. The existing
pick-madeevent still fires per pick; this refactor doesn't change that. Only the per-secondtimer-updatechatter goes away.