283 lines
14 KiB
Markdown
283 lines
14 KiB
Markdown
|
|
# 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 (30–60s) `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.
|