brackt/docs/agents/deadline-based-timer.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

282 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Deadline-based draft timer — implementation guide
Companion to [`docs/infrastructure-roadmap.md`](../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`:
```sql
ALTER TABLE seasons ADD COLUMN pick_deadline_at timestamptz;
```
Drizzle:
```ts
// 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.
```ts
// 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
```ts
// 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:
```ts
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:
```ts
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:
```ts
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 into `draftTimers.timeRemaining`, null out `seasons.pickDeadlineAt`, call `cancelDeadline(seasonId)`.
- **Resume:** call `setNextPickDeadline` with `budgetSeconds = 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`:
```ts
// 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:
```ts
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:
```ts
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).**
1. Add the column.
2. Update all five callsites to call `setNextPickDeadline` (which writes `pickDeadlineAt`).
3. Leave the 1-second `setInterval` running as the authoritative auto-pick path.
4. Add a metric: at each tick, log `actualPickDeadline = now() + timeRemaining` vs the stored `pickDeadlineAt`. They should agree to within ~1 second.
5. Observe a draft or two. Spot-check the metric.
**Stage 2 — flip authority.**
1. Replace `startDraftTimerSystem()` with `bootstrapScheduler()` in `server.ts`.
2. The scheduler is now authoritative; the 1-second interval is gone.
3. Roll out. Confirm `deadline-fire accuracy` metric (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`):
1. **Idempotency on restart.** Insert a draft with `pickDeadlineAt` in the past and an existing pick at `pickNumber`. Call `bootstrapScheduler()`. Assert no duplicate pick is created (relies on `executeAutoPick`'s existing `ON CONFLICT DO NOTHING`).
2. **Stale closure reschedule.** Schedule a deadline, mutate `pickDeadlineAt` in the DB to a later time, advance fake timers to the original deadline. Assert `fireDeadline` reschedules instead of picking.
3. **Pause during in-flight.** Schedule a deadline, set `draftPaused=true` in the DB, advance fake timers. Assert no pick is made.
4. **Helper invariant.** Every test that simulates a pick (manual or auto) asserts that *both* `seasons.pickDeadlineAt` and `draftTimers.timeRemaining` were updated. This catches future callsites that bypass `setNextPickDeadline`.
5. **`while_on` cascade.** Three teams all in `while_on` mode; one manual pick by team 1 should cascade to picks by teams 2 and 3 immediately, then schedule the next deadline.
6. **Overnight pause covers deadline.** Set an overnight window 5 minutes before the computed deadline. Assert the stored deadline equals `resumeAtUTC` (not `now + budget`) and the event payload has `frozen: 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:
```ts
// 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 schedule `setTimeout`s 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 in `server/scheduler.ts`.
- **Scheduled-draft auto-start.** `checkAndAutoStartDrafts` (currently called every tick) needs its own home. Easiest: a separate low-frequency (3060s) `setInterval` in `server.ts`. Rare event, doesn't need precision.
- **Pick-completion broadcast volume.** The existing `pick-made` event still fires per pick; this refactor doesn't change that. Only the per-second `timer-update` chatter goes away.