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
This commit is contained in:
Claude 2026-06-03 03:04:46 +00:00
parent a528aecc86
commit f36480c828
No known key found for this signature in database
15 changed files with 7028 additions and 554 deletions

View file

@ -35,6 +35,7 @@ interface UseDraftSocketEventsParams {
setConnectedTeams: (fn: (prev: Set<string>) => Set<string>) => void;
setQueue: (fn: (prev: QueueItem[]) => QueueItem[]) => void;
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
setPickTimerExpiresAt: (state: { teamId: string; expiresAt: number } | null) => void;
setIsOvernightPause: (value: boolean) => void;
setOvernightResumesAt: (value: Date | null) => void;
setWatchedParticipantIds: (value: Set<string>) => void;
@ -65,6 +66,7 @@ export function useDraftSocketEvents({
setConnectedTeams,
setQueue,
setTeamTimers,
setPickTimerExpiresAt,
setIsOvernightPause,
setOvernightResumesAt,
setWatchedParticipantIds,
@ -108,23 +110,27 @@ export function useDraftSocketEvents({
}
};
const handleTimerUpdate = (data: {
const handleTimerPickStarted = (data: {
seasonId: string;
teamId: string;
pickNumber: number;
expiresAt: number;
timeRemaining: number;
currentPickNumber: number | null;
overnightPauseActive?: boolean;
}) => {
setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining }));
setPickTimerExpiresAt({ teamId: data.teamId, expiresAt: data.expiresAt });
setIsOvernightPause(false);
setOvernightResumesAt(null);
};
const handleTimerOvernightPaused = (data: {
seasonId: string;
teamId: string;
resumesAtUTC?: number;
}) => {
setTeamTimers((prev) => {
if (prev[data.teamId] === data.timeRemaining) return prev;
return { ...prev, [data.teamId]: data.timeRemaining };
});
if (data.currentPickNumber !== null) {
setCurrentPick(data.currentPickNumber);
}
const pauseActive = data.overnightPauseActive ?? false;
setIsOvernightPause(pauseActive);
setOvernightResumesAt(pauseActive && data.resumesAtUTC ? new Date(data.resumesAtUTC) : null);
setPickTimerExpiresAt(null);
setIsOvernightPause(true);
setOvernightResumesAt(data.resumesAtUTC ? new Date(data.resumesAtUTC) : null);
};
const handleDraftPaused = () => setIsPaused(true);
@ -210,7 +216,7 @@ export function useDraftSocketEvents({
currentPickNumber: number;
isPaused: boolean;
status: string;
timers?: Array<{ teamId: string; timeRemaining: number }>;
timers?: Array<{ teamId: string; timeRemaining: number; expiresAt?: number }>;
queue?: QueueItem[];
watchlistParticipantIds?: string[];
}) => {
@ -225,6 +231,11 @@ export function useDraftSocketEvents({
data.timers?.forEach((t) => { updated[t.teamId] = t.timeRemaining; });
return updated;
});
// Restore client-side countdown for the active team on reconnect.
const activeTimer = data.timers.find((t) => t.expiresAt !== undefined);
if (activeTimer?.expiresAt) {
setPickTimerExpiresAt({ teamId: activeTimer.teamId, expiresAt: activeTimer.expiresAt });
}
}
if (data.queue) {
const q = data.queue;
@ -258,7 +269,8 @@ export function useDraftSocketEvents({
};
on("pick-made", handlePickMade as (data: unknown) => void);
on("timer-update", handleTimerUpdate as (data: unknown) => void);
on("timer-pick-started", handleTimerPickStarted as (data: unknown) => void);
on("timer-overnight-paused", handleTimerOvernightPaused as (data: unknown) => void);
on("draft-paused", handleDraftPaused as (data: unknown) => void);
on("draft-resumed", handleDraftResumed as (data: unknown) => void);
on("draft-completed", handleDraftCompleted as (data: unknown) => void);
@ -278,7 +290,8 @@ export function useDraftSocketEvents({
return () => {
off("pick-made", handlePickMade as (data: unknown) => void);
off("timer-update", handleTimerUpdate as (data: unknown) => void);
off("timer-pick-started", handleTimerPickStarted as (data: unknown) => void);
off("timer-overnight-paused", handleTimerOvernightPaused as (data: unknown) => void);
off("draft-paused", handleDraftPaused as (data: unknown) => void);
off("draft-resumed", handleDraftResumed as (data: unknown) => void);
off("draft-completed", handleDraftCompleted as (data: unknown) => void);

View file

@ -35,6 +35,9 @@ vi.mock("~/models/user", () => ({
vi.mock("~/models/audit-log", () => ({
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/server/timer", () => ({
rescheduleTimer: vi.fn().mockResolvedValue(undefined),
}));
// ── Fixtures ─────────────────────────────────────────────────────────────────
@ -367,27 +370,18 @@ describe("draft.force-manual-pick action", () => {
describe("chess_clock mode", () => {
it("adds the increment to the picking team's bank", async () => {
// Timer has 75s (from beforeEach mock); 75 + 30 = 105s after increment
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
await action({ request: defaultPickRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
);
});
it("writes the incremented timer to the DB", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
await action({ request: defaultPickRequest(), params: {}, context: ctx });
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
);
});
});
@ -401,41 +395,30 @@ describe("draft.force-manual-pick action", () => {
});
it("resets the picking team's timer to draftIncrementTime", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
await action({ request: defaultPickRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
);
});
it("writes the timer reset to the DB", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
await action({ request: defaultPickRequest(), params: {}, context: ctx });
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
);
});
});
// ── REGRESSION: Next team's timer must never be touched ──────────────────
// ── REGRESSION: rescheduleTimer should be called exactly once per pick ──────
it("REGRESSION: does not emit a timer-update for the next team", async () => {
it("REGRESSION: calls rescheduleTimer exactly once after a pick", async () => {
const { rescheduleTimer } = await import("~/server/timer");
await action({ request: defaultPickRequest(), params: {}, context: ctx });
const nextTeamTimerEmits = mockSocketIO.emit.mock.calls.filter(
([event, payload]: [string, any]) =>
event === "timer-update" && payload?.teamId === NEXT_TEAM_ID
);
expect(nextTeamTimerEmits).toHaveLength(0);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledTimes(1);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
});
});

View file

@ -7,6 +7,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { RouterContextProvider } from "react-router";
import { action } from "~/routes/api/draft.force-manual-pick";
import { rescheduleTimer } from "~/server/timer";
const ctx = {} as unknown as RouterContextProvider;
@ -41,6 +42,9 @@ vi.mock("~/models/user", () => ({
vi.mock("~/models/audit-log", () => ({
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/server/timer", () => ({
rescheduleTimer: vi.fn().mockResolvedValue(undefined),
}));
// ── Fixtures ──────────────────────────────────────────────────────────────────
@ -192,46 +196,35 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
});
describe("commissioner force pick", () => {
it("emits timer-update with bank + increment", async () => {
// Pre-pick bank: 75s. Expected after increment: 75 + 30 = 105s.
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
// Pre-pick bank: 75s (no picksExpiresAt). 75 + 30 = 105.
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
it("writes the timer update to the DB", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
// Two DB updates: timer row + season.currentPickNumber
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
);
});
it("accumulates a larger bank when more time was remaining", async () => {
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
// 100 + 30 = 130
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 130 }]);
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 })
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 130, picksExpiresAt: null, picksStartedAt: null })
);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
});
@ -247,28 +240,20 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
mockDb.query.commissioners.findFirst.mockResolvedValue(null);
});
it("emits timer-update with bank + increment", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
);
});
it("writes the timer update to the DB", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
it("writes the timer update to the DB", async () => {
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
);
});
});
@ -287,30 +272,23 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
it("resets bank to exactly draftIncrementTime", async () => {
// Pre-pick bank doesn't matter — standard always resets
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 5 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
it("does not accumulate time even when bank was large", async () => {
// Team had 25s left; standard mode resets to increment, never adds to prior balance
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 25 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
);
});
});
@ -327,32 +305,25 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
});
it("resets bank to exactly draftIncrementTime", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
it("uses custom draftIncrementTime when configured", async () => {
mockDb.query.seasons.findFirst.mockResolvedValue(
makeSeason({ draftTimerMode: "standard", draftInitialTime: 60, draftIncrementTime: 60 })
);
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 60 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 60 })
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 60, picksExpiresAt: null, picksStartedAt: null })
);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
});
});

View file

@ -7,6 +7,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { RouterContextProvider } from "react-router";
import { action } from "~/routes/api/draft.make-pick";
import { rescheduleTimer } from "~/server/timer";
const ctx = {} as unknown as RouterContextProvider;
@ -39,6 +40,9 @@ vi.mock("~/models/draft-utils", () => ({
vi.mock("~/models/user", () => ({
isUserAdmin: vi.fn(),
}));
vi.mock("~/server/timer", () => ({
rescheduleTimer: vi.fn().mockResolvedValue(undefined),
}));
// ── Fixtures ──────────────────────────────────────────────────────────────────
@ -188,44 +192,33 @@ describe("draft.make-pick action — timer mode behavior", () => {
describe("owner pick", () => {
// Auth default is OWNER_ID (team owner), no extra setup needed.
it("emits timer-update with bank + increment", async () => {
// DB returns 75 + 30 = 105 after the atomic add
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
// Pre-pick bank: 75s (no picksExpiresAt, falls back to timeRemaining). 75 + 30 = 105.
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
it("accumulates a larger bank when more time was remaining", async () => {
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
// 100 + 30 = 130
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 130 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 })
);
});
it("writes the timer update to the DB", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
expect.objectContaining({ timeRemaining: 130, picksExpiresAt: null, picksStartedAt: null })
);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
it("writes the timer update to the DB", async () => {
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
);
});
});
@ -246,28 +239,20 @@ describe("draft.make-pick action — timer mode behavior", () => {
]);
});
it("emits timer-update with bank + increment", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
);
});
it("writes the timer update to the DB", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
it("writes the timer update to the DB", async () => {
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
);
});
});
@ -290,28 +275,20 @@ describe("draft.make-pick action — timer mode behavior", () => {
]);
});
it("emits timer-update with bank + increment", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
);
});
it("writes the timer update to the DB", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
it("writes the timer update to the DB", async () => {
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
);
});
});
@ -327,30 +304,22 @@ describe("draft.make-pick action — timer mode behavior", () => {
});
it("owner pick — resets bank to exactly draftIncrementTime", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
it("owner pick — fast picker does NOT accumulate time (bank never exceeds increment)", async () => {
// Team had 25s left (picked quickly); standard mode ignores prior balance
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 25 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
);
});
@ -363,16 +332,12 @@ describe("draft.make-pick action — timer mode behavior", () => {
mockDraftSlots[1],
]);
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
it("admin pick — resets bank to exactly draftIncrementTime", async () => {
@ -385,32 +350,25 @@ describe("draft.make-pick action — timer mode behavior", () => {
mockDraftSlots[1],
]);
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
it("uses custom draftIncrementTime when configured", async () => {
mockDb.query.seasons.findFirst.mockResolvedValue(
makeSeason({ draftTimerMode: "standard", draftInitialTime: 90, draftIncrementTime: 90 })
);
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 90 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 90 })
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 90, picksExpiresAt: null, picksStartedAt: null })
);
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
});
});
});

View file

@ -1,7 +1,7 @@
import { auth } from "~/lib/auth.server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, sql } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { isUserAdmin } from "~/models/user";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
@ -13,6 +13,7 @@ import { enqueuePickNotification } from "~/services/discord";
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
import { rescheduleTimer } from "../../../server/timer";
import { logger } from "~/lib/logger";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
import type { ActionFunctionArgs } from "react-router";
@ -173,41 +174,29 @@ export async function action(args: ActionFunctionArgs) {
const isDraftComplete = nextPickNumber > totalPicks;
// Standard mode: reset to the per-pick time (so the next turn starts fresh).
// Chess clock: add the increment to the bank (same as any other pick type).
// Chess clock: add the increment to the actual remaining time (from picksExpiresAt).
// Also clear picksExpiresAt so the timer system knows this team's turn is over.
const incrementTime = season.draftIncrementTime || 30;
const timerSql = season.draftTimerMode === "standard"
? sql`${incrementTime}`
: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`;
const timerSnapshot = await db.query.draftTimers.findFirst({
where: and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)),
});
const pickMadeAt = Date.now();
const timeRemainingAtPick = timerSnapshot?.picksExpiresAt
? Math.max(0, Math.floor((timerSnapshot.picksExpiresAt.getTime() - pickMadeAt) / 1000))
: (timerSnapshot?.timeRemaining ?? 0);
const newTimeRemaining =
season.draftTimerMode === "standard" ? incrementTime : timeRemainingAtPick + incrementTime;
const [updatedTimer] = await db
.update(schema.draftTimers)
.set({ timeRemaining: timerSql, updatedAt: new Date() })
.where(
and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, teamId)
)
)
.set({ timeRemaining: newTimeRemaining, picksExpiresAt: null, picksStartedAt: null, updatedAt: new Date() })
.where(and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)))
.returning();
const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
if (!updatedTimer) {
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
}
// Emit timer update to all clients
try {
getSocketIO().to(`draft-${seasonId}`).emit("timer-update", {
seasonId,
teamId,
timeRemaining: newTimeRemaining,
currentPickNumber: nextPickNumber,
});
} catch (error) {
logger.error("Socket.IO timer-update error:", error);
}
// Next team's timer is unchanged — their bank carries forward as-is.
// The timer server loop will start counting down from their existing balance.
// rescheduleTimer (called below) will emit timer-pick-started for the next team.
// Update season's current pick number
await db
@ -322,6 +311,13 @@ export async function action(args: ActionFunctionArgs) {
}
}
// Reschedule the timer for the next team on the clock (runs after the full autodraft chain).
try {
await rescheduleTimer(seasonId);
} catch (err) {
logger.error("[ForceManualPick] rescheduleTimer failed:", err);
}
return Response.json({
success: true,
pick: draftPick,

View file

@ -2,7 +2,7 @@ import { auth } from "~/lib/auth.server";
import { getTeamForPick } from "~/lib/draft-order";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, sql } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { isUserAdmin } from "~/models/user";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
@ -10,6 +10,7 @@ import { getParticipantsForSeasonWithSports } from "~/models/season-participant"
import { getSeasonSportsSimple } from "~/models/season-sport";
import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils";
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
import { rescheduleTimer } from "../../../server/timer";
import { logger } from "~/lib/logger";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
import { sendOnTheClockEmail } from "~/services/draft-email.server";
@ -141,13 +142,18 @@ export async function action(args: ActionFunctionArgs) {
return Response.json({ error: reason }, { status: 400 });
}
// Snapshot the team's time bank before the pick (used for audit / pick history)
// Snapshot the team's time bank before the pick (used for audit / pick history).
// Use picksExpiresAt to compute actual remaining time rather than the stale timeRemaining.
const timerSnapshot = await db.query.draftTimers.findFirst({
where: and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
),
});
const pickMadeAt = Date.now();
const timeRemainingAtPick = timerSnapshot?.picksExpiresAt
? Math.max(0, Math.floor((timerSnapshot.picksExpiresAt.getTime() - pickMadeAt) / 1000))
: (timerSnapshot?.timeRemaining ?? 0);
// Create the draft pick
const [draftPick] = await db
@ -161,7 +167,7 @@ export async function action(args: ActionFunctionArgs) {
pickInRound,
pickedByUserId: userId,
pickedByType: isTeamOwner ? "owner" : commissionerRecord ? "commissioner" : "admin",
timeUsed: timerSnapshot?.timeRemaining ?? 0,
timeUsed: timeRemainingAtPick,
})
.returning();
@ -210,45 +216,33 @@ export async function action(args: ActionFunctionArgs) {
const isDraftComplete = nextPickNumber > totalPicks;
// Update the picking team's timer after their pick.
// Standard mode: always reset to the per-pick time, regardless of who picked.
// Chess clock: always add the increment (any pick type — owner, commissioner, or admin).
// Standard mode: always reset to the per-pick time.
// Chess clock: add the increment to the actual remaining time (computed from picksExpiresAt).
// Also clear picksExpiresAt so the timer system knows this team's turn is over.
const incrementTime = season.draftIncrementTime || 30;
let newTimeRemaining: number;
const newTimeRemaining =
season.draftTimerMode === "standard"
? incrementTime
: timeRemainingAtPick + incrementTime;
let updatedTimer: { timeRemaining: number } | undefined;
const timerUpdateSet = {
timeRemaining: newTimeRemaining,
picksExpiresAt: null as Date | null,
picksStartedAt: null as Date | null,
updatedAt: new Date(),
};
if (season.draftTimerMode === "standard") {
// Atomic reset so the timer loop cannot race with this write.
[updatedTimer] = await db
.update(schema.draftTimers)
.set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() })
.where(
and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
)
const [updatedTimer] = await db
.update(schema.draftTimers)
.set(timerUpdateSet)
.where(
and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
)
.returning();
newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
} else {
// Chess clock: earn the increment regardless of who made the pick (atomic add).
[updatedTimer] = await db
.update(schema.draftTimers)
.set({
timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`,
updatedAt: new Date(),
})
.where(
and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
)
)
.returning();
newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
}
)
.returning();
// If the timer row didn't exist yet, seed it.
if (!updatedTimer) {
await db.insert(schema.draftTimers).values({
seasonId,
@ -257,19 +251,8 @@ export async function action(args: ActionFunctionArgs) {
});
}
try {
getSocketIO().to(`draft-${seasonId}`).emit("timer-update", {
seasonId,
teamId: currentDraftSlot.teamId,
timeRemaining: newTimeRemaining,
currentPickNumber: nextPickNumber,
});
} catch (error) {
logger.error("Socket.IO timer-update error:", error);
}
// Next team's timer is unchanged — their bank carries forward as-is
// (no emit needed; the timer system will start decrementing their existing bank)
// rescheduleTimer (called below after the autodraft chain) will emit timer-pick-started
// for the next team — no need to emit timer-update here.
// Update season's current pick number (AFTER initializing next timer to prevent race condition)
await db
@ -353,6 +336,13 @@ export async function action(args: ActionFunctionArgs) {
.catch((err) => logger.error("On-the-clock email failed:", err));
}
// Reschedule the timer for the next team on the clock (runs after the full autodraft chain).
try {
await rescheduleTimer(seasonId);
} catch (err) {
logger.error("[Pick] rescheduleTimer failed:", err);
}
return Response.json({
success: true,
pick: draftPick,

View file

@ -493,6 +493,25 @@ export default function DraftRoom() {
}, []);
useEffect(() => () => { animationTimersRef.current.forEach(clearTimeout); }, []);
// Client-side countdown: updated every second from the server-provided expiresAt timestamp.
const [pickTimerExpiresAt, setPickTimerExpiresAt] = useState<{ teamId: string; expiresAt: number } | null>(() => {
// Seed from initial loader data if a timer is already running.
const active = timers.find((t) => t.picksExpiresAt && t.picksExpiresAt.getTime() > Date.now());
return active ? { teamId: active.teamId, expiresAt: active.picksExpiresAt!.getTime() } : null;
});
useEffect(() => {
if (!pickTimerExpiresAt) return;
const { teamId, expiresAt } = pickTimerExpiresAt;
const tick = () => {
const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
setTeamTimers((prev) => prev[teamId] === remaining ? prev : { ...prev, [teamId]: remaining });
};
tick(); // immediate update so display is accurate on mount
const id = setInterval(tick, 1_000);
return () => clearInterval(id);
}, [pickTimerExpiresAt, setTeamTimers]);
useDraftSocketEvents({
on,
off,
@ -514,6 +533,7 @@ export default function DraftRoom() {
setConnectedTeams,
setQueue,
setTeamTimers,
setPickTimerExpiresAt,
setIsOvernightPause,
setOvernightResumesAt,
setWatchedParticipantIds,

View file

@ -326,7 +326,9 @@ export const draftTimers = pgTable("draft_timers", {
teamId: uuid("team_id")
.notNull()
.references(() => teams.id, { onDelete: "cascade" }),
timeRemaining: integer("time_remaining").notNull(), // seconds
timeRemaining: integer("time_remaining").notNull(), // seconds (bank in chess_clock; reset value in standard)
picksExpiresAt: timestamp("picks_expires_at"), // when current pick's timer expires; null = not running
picksStartedAt: timestamp("picks_started_at"), // when current pick slot opened
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

View file

@ -0,0 +1,2 @@
ALTER TABLE "draft_timers" ADD COLUMN "picks_expires_at" timestamp;--> statement-breakpoint
ALTER TABLE "draft_timers" ADD COLUMN "picks_started_at" timestamp;

File diff suppressed because it is too large Load diff

View file

@ -799,6 +799,13 @@
"when": 1779579877169,
"tag": "0113_bent_banshee",
"breakpoints": true
},
{
"idx": 114,
"version": "7",
"when": 1780454064143,
"tag": "0114_panoramic_human_cannonball",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,251 @@
# 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:
1. **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.
2. **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)
1. **Draft timer survives web server deploys** — timer lives in a separate worker process that isn't restarted when the web server is.
2. **Multiple web server instances can safely coexist** — Socket.IO state shared via Redis adapter; timer runs only once (in the worker).
3. **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`:
```ts
// 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:
1. **On startup**, reads all active draft seasons from DB and calls `schedulePick(season)` for each.
2. **`schedulePick(season)`**:
- Looks up current team + their `picksExpiresAt` from DB.
- If already set: `setTimeout(onExpiry, picksExpiresAt - now)`.
- If null (pick just started or timer not set): calls `initPick(season)` to write `picksExpiresAt = now + timeRemaining` and then schedules the timeout.
3. **`onExpiry(seasonId, teamId, pickNumber)`**: triggers the autopick (same logic as today's `triggerAutoPick`). After pick resolves, calls `schedulePick` for the next team.
4. **Recovery interval**: a `setInterval(recovery, 30_000)` (not 1 s!) queries active drafts and re-calls `schedulePick` for 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`: store `expiresAt` in state, start a local `setInterval` to display countdown.
- On `draft-state-sync` (reconnect): server sends current `picksExpiresAt` so client can resume countdown.
- Remove any logic that relied on receiving `timer-update` every 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 errors
- `npm 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`:
```yaml
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(...)`:
```ts
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`:
```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:
```yaml
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:
```ts
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
```yaml
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:
```ts
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 |

View file

@ -328,15 +328,15 @@ describe('Timer Autodraft Integration', () => {
expect(0 + 30).toBe(30);
});
it('should emit timer-update after adding increment', async () => {
it('should emit timer-pick-started after scheduling next pick', async () => {
const seasonId = 'season-123';
const teamId = 'team-456';
mockSocketIO.to(`draft-${seasonId}`).emit('timer-update', {
seasonId, teamId, timeRemaining: 30, currentPickNumber: 1,
});
expect(mockSocketIO.emit).toHaveBeenCalledWith('timer-update', {
seasonId, teamId, timeRemaining: 30, currentPickNumber: 1,
mockSocketIO.to(`draft-${seasonId}`).emit('timer-pick-started', {
seasonId, teamId, pickNumber: 1, expiresAt: Date.now() + 30000, timeRemaining: 30,
});
expect(mockSocketIO.emit).toHaveBeenCalledWith('timer-pick-started',
expect.objectContaining({ seasonId, teamId, timeRemaining: 30 })
);
});
});
});

View file

@ -18,11 +18,17 @@ interface ServerToClientEvents {
"draft-started": (data: { seasonId: string; currentPickNumber: number }) => void;
"draft-completed": () => void;
"draft-room-closed": () => void;
"timer-update": (data: {
"timer-pick-started": (data: {
seasonId: string;
teamId: string;
timeRemaining: number;
currentPickNumber: number;
pickNumber: number;
expiresAt: number; // ms timestamp
timeRemaining: number; // seconds remaining at emit time
}) => void;
"timer-overnight-paused": (data: {
seasonId: string;
teamId: string;
resumesAtUTC?: number;
}) => void;
"autodraft-updated": (data: {
teamId: string;
@ -60,6 +66,7 @@ interface ServerToClientEvents {
timers: Array<{
teamId: string;
timeRemaining: number;
expiresAt?: number; // ms timestamp; present for the currently-active team
}>;
queue?: Array<{
id: string;
@ -283,10 +290,19 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
isPaused: seasonData.draftPaused || false,
status: seasonData.status,
picks,
timers: timerRows.map((t) => ({
teamId: t.teamId,
timeRemaining: t.timeRemaining,
})),
timers: timerRows.map((t) => {
const nowMs = Date.now();
const expiresAt = t.picksExpiresAt?.getTime();
const computedRemaining =
expiresAt && expiresAt > nowMs
? Math.max(0, Math.floor((expiresAt - nowMs) / 1000))
: t.timeRemaining;
return {
teamId: t.teamId,
timeRemaining: computedRemaining,
expiresAt: expiresAt && expiresAt > nowMs ? expiresAt : undefined,
};
}),
queue: teamId ? queueItems : undefined,
watchlistParticipantIds: teamId ? watchlistItems.map((w) => w.participantId) : undefined,
});

View file

@ -1,5 +1,5 @@
import * as schema from "~/database/schema";
import { eq, and, asc, sql, lte, isNotNull } from "drizzle-orm";
import { eq, and, asc, lte, isNotNull } from "drizzle-orm";
import type { InferSelectModel } from "drizzle-orm";
import { getSocketIO } from "./socket";
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
@ -8,17 +8,19 @@ import { logger } from "./logger";
import { db } from "./db";
import { startDraft } from "~/services/draft-autostart";
let timerInterval: NodeJS.Timeout | null = null;
let timerTickRunning = false;
// Per-season in-memory state
const pickTimeouts = new Map<string, NodeJS.Timeout>(); // seasonId → active setTimeout
const overnightResumeTimeouts = new Map<string, NodeJS.Timeout>(); // seasonId → resume setTimeout
const schedulingInProgress = new Set<string>(); // prevents concurrent scheduling
// Draft slots never change during an active draft — cache them to avoid
// a redundant query on every tick.
// Draft slots never change during an active draft — cache them to avoid a redundant query.
const draftSlotsCache = new Map<string, { teamId: string; draftOrder: number }[]>();
// Team timezone cache for per_user overnight pause mode (seasonId → teamId → timezone).
// Populated lazily; evicted when the season leaves active drafting.
const teamTimezoneCache = new Map<string, Map<string, string | null>>();
let recoveryInterval: NodeJS.Timeout | null = null;
async function getTeamTimezone(seasonId: string, teamId: string): Promise<string | null> {
let seasonMap = teamTimezoneCache.get(seasonId);
if (!seasonMap) {
@ -26,13 +28,11 @@ async function getTeamTimezone(seasonId: string, teamId: string): Promise<string
const rows = await db
.select({ teamId: schema.teams.id, timezone: schema.users.timezone })
.from(schema.teams)
.leftJoin(schema.users, sql`${schema.teams.ownerId}::uuid = ${schema.users.id}`)
.leftJoin(schema.users, eq(schema.teams.ownerId, schema.users.id))
.where(eq(schema.teams.seasonId, seasonId));
seasonMap = new Map(rows.map((r) => [r.teamId, r.timezone || null]));
} catch (err) {
// ownerId column is varchar; if any row still holds a non-UUID value
// (pre-migration remnant) the ::uuid cast throws. Degrade gracefully.
logger.error("[Timer] getTeamTimezone cast failed, skipping overnight check:", err);
logger.error("[Timer] getTeamTimezone failed:", err);
seasonMap = new Map();
}
teamTimezoneCache.set(seasonId, seasonMap);
@ -40,10 +40,6 @@ async function getTeamTimezone(seasonId: string, teamId: string): Promise<string
return seasonMap.get(teamId) ?? null;
}
/**
* Determine whether the current team's pick is in an overnight pause window.
* Returns { active: false } or { active: true, resumesAtUTC: number }.
*/
async function checkOvernightPause(
season: InferSelectModel<typeof schema.seasons>,
currentTeamId: string
@ -58,7 +54,6 @@ async function checkOvernightPause(
if (mode === "league") {
tz = season.overnightPauseTimezone || null;
} else {
// per_user: use the team owner's timezone, fall back to league timezone
tz = await getTeamTimezone(season.id, currentTeamId);
if (!tz) tz = season.overnightPauseTimezone || null;
}
@ -72,42 +67,16 @@ async function checkOvernightPause(
return { active: false };
}
/**
* Start the draft timer system
* Runs every second to update all active draft timers
*/
export function startDraftTimerSystem(): void {
if (timerInterval) {
logger.log("[Timer] Timer system already running");
return;
}
timerInterval = setInterval(async () => {
// Skip this tick if the previous one is still running (e.g. long autodraft chain)
// to prevent concurrent ticks from racing on the same pick slot.
if (timerTickRunning) return;
timerTickRunning = true;
try {
await updateDraftTimers();
} catch (error) {
logger.error("[Timer] Error updating draft timers:", error);
} finally {
timerTickRunning = false;
}
}, 1000);
logger.log("[Timer] Draft timer system started");
}
/**
* Stop the draft timer system
*/
export function stopDraftTimerSystem(): void {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
logger.log("[Timer] Draft timer system stopped");
async function getDraftSlotsCached(seasonId: string): Promise<{ teamId: string; draftOrder: number }[]> {
let slots = draftSlotsCache.get(seasonId);
if (!slots) {
slots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
orderBy: asc(schema.draftSlots.draftOrder),
});
draftSlotsCache.set(seasonId, slots);
}
return slots;
}
async function checkAndAutoStartDrafts(): Promise<void> {
@ -136,8 +105,6 @@ async function checkAndAutoStartDrafts(): Promise<void> {
logger.error(`[Timer] Auto-start failed for season ${season.id}: ${result.error}`);
if (result.error === "No draft slots found for this season") {
// Disable auto-start to stop the retry loop. The commissioner will see
// the setting is now off and can re-enable it once the order is set.
await db
.update(schema.seasons)
.set({ autoStartDraft: false })
@ -147,206 +114,32 @@ async function checkAndAutoStartDrafts(): Promise<void> {
}
}
/**
* Update all active draft timers
* Called every second by the timer interval
*/
async function updateDraftTimers(): Promise<void> {
await checkAndAutoStartDrafts();
const io = getSocketIO();
// Get all active drafts
const activeDrafts = await db.query.seasons.findMany({
where: eq(schema.seasons.status, "draft"),
});
if (activeDrafts.length === 0) {
draftSlotsCache.clear();
return;
}
// Evict cache entries for seasons no longer actively drafting.
const activeIds = new Set(activeDrafts.map((s) => s.id));
for (const cachedId of draftSlotsCache.keys()) {
if (!activeIds.has(cachedId)) draftSlotsCache.delete(cachedId);
}
for (const cachedId of teamTimezoneCache.keys()) {
if (!activeIds.has(cachedId)) teamTimezoneCache.delete(cachedId);
}
for (const season of activeDrafts) {
// Skip if draft is paused
if (season.draftPaused) {
continue;
}
const currentPickNumber = season.currentPickNumber ?? 1;
// Draft slots never change during an active draft — use the cache.
let draftSlots = draftSlotsCache.get(season.id);
if (!draftSlots) {
draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, season.id),
orderBy: asc(schema.draftSlots.draftOrder),
});
draftSlotsCache.set(season.id, draftSlots);
}
const totalTeams = draftSlots.length;
if (totalTeams === 0) continue;
const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
const currentDraftSlot = draftSlots.find(
(slot) => slot.draftOrder === pickInRound
);
if (!currentDraftSlot) {
continue;
}
const currentTeamId = currentDraftSlot.teamId;
// Get current team's timer
const timer = await db.query.draftTimers.findFirst({
where: and(
eq(schema.draftTimers.seasonId, season.id),
eq(schema.draftTimers.teamId, currentTeamId)
),
});
if (!timer) {
logger.warn(
`[Timer] No timer found for team ${currentTeamId} in season ${season.id}, creating with initial time`
);
// Standard mode seeds with the per-pick time; chess clock seeds with the full bank.
const initialTime = season.draftTimerMode === "standard"
? (season.draftIncrementTime || 30)
: (season.draftInitialTime || 120);
await db
.insert(schema.draftTimers)
.values({
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: initialTime,
});
// Emit timer update so clients are aware of the new timer
io.to(`draft-${season.id}`).emit("timer-update", {
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: initialTime,
currentPickNumber,
});
// Continue processing with the newly created timer on the next tick
continue;
}
// Fetch autodraft settings once — used both for while_on bypass and timer-expiry path.
const autodraftSettings = await db.query.autodraftSettings.findFirst({
where: and(
eq(schema.autodraftSettings.seasonId, season.id),
eq(schema.autodraftSettings.teamId, currentTeamId)
),
});
const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
// while_on means "pick immediately when it's my turn" — bypass the countdown.
const isWhileOn = shouldAutodraft && autodraftSettings?.mode === "while_on";
// Overnight pause: freeze timer for non-autodraft teams in their overnight window.
const overnightPause = await checkOvernightPause(season, currentTeamId);
const isOvernightFreeze = overnightPause.active && !shouldAutodraft;
// Trigger pick when: timer expired OR team is in while_on autodraft mode.
// The chain picks consecutive while_on teams after each pick, but it is capped at
// totalTeams iterations. The while_on bypass here fills the gap: the timer picks
// the next while_on team within one tick (≤1 s) rather than waiting for the full
// countdown to expire. If the chain already made this pick, executeAutoPick
// returns "Pick already made" which triggerAutoPick treats as a non-fatal no-op.
if (timer.timeRemaining <= 0 || isWhileOn) {
if (timer.timeRemaining <= 0) {
logger.log(
`[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})`
);
} else {
logger.log(
`[Timer] ⚡ while_on autodraft — immediate pick for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})`
);
// Emit 0 so clients see the timer hit zero before the pick-made event arrives.
io.to(`draft-${season.id}`).emit("timer-update", {
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: 0,
currentPickNumber,
overnightPauseActive: false,
});
}
const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null);
if (!success) {
logger.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`);
await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, season.id));
io.to(`draft-${season.id}`).emit("draft-paused", { seasonId: season.id, paused: true });
}
continue;
}
// Overnight freeze: skip decrement, notify clients to display the pause indicator.
if (isOvernightFreeze) {
io.to(`draft-${season.id}`).emit("timer-update", {
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: timer.timeRemaining,
currentPickNumber,
overnightPauseActive: true,
resumesAtUTC: overnightPause.resumesAtUTC,
});
continue;
}
// Atomically decrement timer (race-condition safe: uses DB-level update
// so concurrent increments from pick handlers are never overwritten)
const [updatedTimer] = await db
.update(schema.draftTimers)
.set({
timeRemaining: sql`GREATEST(${schema.draftTimers.timeRemaining} - 1, 0)`,
updatedAt: new Date(),
})
.where(eq(schema.draftTimers.id, timer.id))
.returning();
const newTimeRemaining = updatedTimer?.timeRemaining ?? 0;
// Emit timer update to all clients in the draft room
io.to(`draft-${season.id}`).emit("timer-update", {
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: newTimeRemaining,
currentPickNumber,
overnightPauseActive: false,
});
// If timer just hit 0, trigger auto-pick (next_pick mode or no autodraft)
if (newTimeRemaining === 0) {
const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null);
if (!success) {
logger.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`);
await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, season.id));
io.to(`draft-${season.id}`).emit("draft-paused", { seasonId: season.id, paused: true });
}
}
function cancelPickTimeout(seasonId: string): void {
const existing = pickTimeouts.get(seasonId);
if (existing) {
clearTimeout(existing);
pickTimeouts.delete(seasonId);
}
}
function cancelOvernightResumeTimeout(seasonId: string): void {
const existing = overnightResumeTimeouts.get(seasonId);
if (existing) {
clearTimeout(existing);
overnightResumeTimeouts.delete(seasonId);
}
}
async function pauseDraftOnError(seasonId: string, teamId: string): Promise<void> {
logger.error(`[Timer] Pausing draft ${seasonId} — pick failed for team ${teamId}`);
await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, seasonId));
try {
getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true });
} catch (err) {
logger.error("[Timer] Failed to emit draft-paused:", err);
}
}
/**
* Trigger an automatic pick when timer expires.
* Returns true if the pick succeeded (or was already made by another path),
* false if a real failure occurred that requires commissioner intervention.
*/
async function triggerAutoPick(
seasonId: string,
teamId: string,
@ -364,10 +157,7 @@ async function triggerAutoPick(
});
if (!result.success) {
// A race condition where the pick was already made is not a real failure
if (result.error === "Pick already made") {
return true;
}
if (result.error === "Pick already made") return true;
logger.error(`[Timer] Auto-pick failed: ${result.error}`);
return false;
}
@ -378,3 +168,273 @@ async function triggerAutoPick(
return false;
}
}
// Core scheduling logic — called after acquiring the schedulingInProgress lock.
async function _schedulePickForSeason(seasonId: string): Promise<void> {
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season || season.status !== "draft" || season.draftPaused) return;
const draftSlots = await getDraftSlotsCached(seasonId);
const totalTeams = draftSlots.length;
if (totalTeams === 0) return;
const currentPickNumber = season.currentPickNumber ?? 1;
const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
const currentDraftSlot = draftSlots.find((s) => s.draftOrder === pickInRound);
if (!currentDraftSlot) return;
const currentTeamId = currentDraftSlot.teamId;
const autodraftSettings = await db.query.autodraftSettings.findFirst({
where: and(
eq(schema.autodraftSettings.seasonId, seasonId),
eq(schema.autodraftSettings.teamId, currentTeamId)
),
});
const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
const isWhileOn = shouldAutodraft && autodraftSettings?.mode === "while_on";
if (isWhileOn) {
logger.log(`[Timer] ⚡ while_on autodraft — immediate pick for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})`);
try {
getSocketIO().to(`draft-${seasonId}`).emit("timer-pick-started", {
seasonId,
teamId: currentTeamId,
pickNumber: currentPickNumber,
expiresAt: Date.now(),
timeRemaining: 0,
});
} catch (_) { /* non-fatal */ }
const success = await triggerAutoPick(seasonId, currentTeamId, currentPickNumber, autodraftSettings ?? null);
if (!success) {
await pauseDraftOnError(seasonId, currentTeamId);
return;
}
// The chain may have advanced currentPickNumber past all while_on teams — reschedule.
await _schedulePickForSeason(seasonId);
return;
}
// Overnight pause: freeze timer; schedule a wakeup for when the window ends.
const overnightPause = await checkOvernightPause(season, currentTeamId);
const isOvernightFreeze = overnightPause.active && !shouldAutodraft;
if (isOvernightFreeze) {
try {
getSocketIO().to(`draft-${seasonId}`).emit("timer-overnight-paused", {
seasonId,
teamId: currentTeamId,
resumesAtUTC: overnightPause.resumesAtUTC,
});
} catch (_) { /* non-fatal */ }
if (overnightPause.resumesAtUTC) {
const msUntilResume = Math.max(0, overnightPause.resumesAtUTC - Date.now());
const resumeTimeout = setTimeout(async () => {
overnightResumeTimeouts.delete(seasonId);
await schedulePickForSeason(seasonId);
}, msUntilResume + 2_000); // 2 s buffer in case clocks drift
overnightResumeTimeouts.set(seasonId, resumeTimeout);
}
return;
}
// Get or create timer row for this team.
let timer = await db.query.draftTimers.findFirst({
where: and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, currentTeamId)
),
});
if (!timer) {
const initialTime =
season.draftTimerMode === "standard"
? (season.draftIncrementTime || 30)
: (season.draftInitialTime || 120);
const startedAt = new Date();
const expiresAt = new Date(startedAt.getTime() + initialTime * 1000);
await db.insert(schema.draftTimers).values({
seasonId,
teamId: currentTeamId,
timeRemaining: initialTime,
picksExpiresAt: expiresAt,
picksStartedAt: startedAt,
});
// Re-fetch to get the full row (including generated id)
timer = await db.query.draftTimers.findFirst({
where: and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, currentTeamId)
),
});
if (!timer) return; // unexpected
}
const now = Date.now();
let expiresAt: Date;
if (timer.picksExpiresAt && timer.picksExpiresAt.getTime() > now) {
// Timer was already running (e.g. process restarted mid-pick). Honour the existing expiry.
expiresAt = timer.picksExpiresAt;
} else {
// Fresh start of this team's turn (normal path after a pick or on startup).
const bank = timer.timeRemaining;
if (bank <= 0) {
// Bank depleted — trigger autopick immediately.
const success = await triggerAutoPick(
seasonId,
currentTeamId,
currentPickNumber,
shouldAutodraft ? (autodraftSettings ?? null) : null
);
if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; }
await _schedulePickForSeason(seasonId);
return;
}
expiresAt = new Date(now + bank * 1000);
await db
.update(schema.draftTimers)
.set({ picksExpiresAt: expiresAt, picksStartedAt: new Date() })
.where(
and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, currentTeamId)
)
);
}
const msUntilExpiry = expiresAt.getTime() - now;
const timeRemaining = Math.max(0, Math.ceil(msUntilExpiry / 1000));
// Tell clients to start their local countdown.
try {
getSocketIO().to(`draft-${seasonId}`).emit("timer-pick-started", {
seasonId,
teamId: currentTeamId,
pickNumber: currentPickNumber,
expiresAt: expiresAt.getTime(),
timeRemaining,
});
} catch (_) { /* non-fatal */ }
if (msUntilExpiry <= 0) {
// Already expired (e.g. picked up on recovery interval after a long pause).
const success = await triggerAutoPick(
seasonId,
currentTeamId,
currentPickNumber,
shouldAutodraft ? (autodraftSettings ?? null) : null
);
if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; }
await _schedulePickForSeason(seasonId);
return;
}
const timeout = setTimeout(async () => {
pickTimeouts.delete(seasonId);
logger.log(
`[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})`
);
const success = await triggerAutoPick(
seasonId,
currentTeamId,
currentPickNumber,
shouldAutodraft ? (autodraftSettings ?? null) : null
);
if (!success) {
await pauseDraftOnError(seasonId, currentTeamId);
return;
}
await schedulePickForSeason(seasonId);
}, msUntilExpiry);
pickTimeouts.set(seasonId, timeout);
}
async function schedulePickForSeason(seasonId: string): Promise<void> {
if (schedulingInProgress.has(seasonId)) return;
schedulingInProgress.add(seasonId);
try {
await _schedulePickForSeason(seasonId);
} catch (err) {
logger.error(`[Timer] schedulePickForSeason error for ${seasonId}:`, err);
} finally {
schedulingInProgress.delete(seasonId);
}
}
async function scheduleAllActiveDrafts(): Promise<void> {
await checkAndAutoStartDrafts();
const activeDrafts = await db.query.seasons.findMany({
where: eq(schema.seasons.status, "draft"),
});
// Evict caches for seasons no longer drafting.
const activeIds = new Set(activeDrafts.map((s) => s.id));
for (const id of draftSlotsCache.keys()) {
if (!activeIds.has(id)) draftSlotsCache.delete(id);
}
for (const id of teamTimezoneCache.keys()) {
if (!activeIds.has(id)) teamTimezoneCache.delete(id);
}
for (const season of activeDrafts) {
// Only schedule if not already scheduled (avoids duplicate timeouts).
if (!pickTimeouts.has(season.id) && !overnightResumeTimeouts.has(season.id)) {
await schedulePickForSeason(season.id);
}
}
}
export function startDraftTimerSystem(): void {
if (recoveryInterval) {
logger.log("[Timer] Timer system already running");
return;
}
// Schedule any in-progress drafts immediately on startup.
scheduleAllActiveDrafts().catch((err) =>
logger.error("[Timer] Error during startup scheduling:", err)
);
// Recovery interval: re-schedule any draft whose in-memory timeout was lost
// (process restart, overnight pause end, etc.). Runs every 30 s, not every 1 s.
recoveryInterval = setInterval(() => {
scheduleAllActiveDrafts().catch((err) =>
logger.error("[Timer] Error in recovery interval:", err)
);
}, 30_000);
logger.log("[Timer] Draft timer system started (event-driven)");
}
export function stopDraftTimerSystem(): void {
if (recoveryInterval) {
clearInterval(recoveryInterval);
recoveryInterval = null;
}
for (const t of pickTimeouts.values()) clearTimeout(t);
pickTimeouts.clear();
for (const t of overnightResumeTimeouts.values()) clearTimeout(t);
overnightResumeTimeouts.clear();
schedulingInProgress.clear();
draftSlotsCache.clear();
teamTimezoneCache.clear();
logger.log("[Timer] Draft timer system stopped");
}
/**
* Called by pick routes after a pick is made so the timer immediately
* reschedules for the next team rather than waiting for the recovery interval.
*/
export async function rescheduleTimer(seasonId: string): Promise<void> {
cancelPickTimeout(seasonId);
cancelOvernightResumeTimeout(seasonId);
draftSlotsCache.delete(seasonId); // pick may have changed the draft order state
await schedulePickForSeason(seasonId);
}