import * as schema from "~/database/schema"; 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"; import { checkOvernightPause, evictOvernightPauseCache, overnightPauseCacheKeys, clearAllOvernightPauseCaches } from "./overnight-pause-check"; import { logger } from "./logger"; import { db } from "./db"; import { startDraft } from "~/services/draft-autostart"; // Per-season in-memory state const pickTimeouts = new Map(); // seasonId → active setTimeout const overnightResumeTimeouts = new Map(); // seasonId → resume setTimeout const schedulingInProgress = new Set(); // prevents concurrent scheduling // Draft slots never change during an active draft — cache them to avoid a redundant query. const draftSlotsCache = new Map(); let recoveryInterval: NodeJS.Timeout | null = null; 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 { const io = getSocketIO(); const seasonsToStart = await db.query.seasons.findMany({ where: and( eq(schema.seasons.status, "pre_draft"), eq(schema.seasons.autoStartDraft, true), isNotNull(schema.seasons.draftDateTime), lte(schema.seasons.draftDateTime, new Date()) ), }); for (const season of seasonsToStart) { logger.log(`[Timer] Auto-starting draft for season ${season.id}`); const result = await startDraft({ seasonId: season.id, actorUserId: "system", actorDisplayName: "System (auto-start)", db, io, }); if (result.success) continue; if (result.error === "Draft already started or completed") continue; logger.error(`[Timer] Auto-start failed for season ${season.id}: ${result.error}`); if (result.error === "No draft slots found for this season") { await db .update(schema.seasons) .set({ autoStartDraft: false }) .where(eq(schema.seasons.id, season.id)); logger.log(`[Timer] Disabled autoStartDraft for season ${season.id} — no draft order set`); } } } 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 { 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)); const timers = await onDraftPaused(seasonId); try { getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true, timers }); } catch (err) { logger.error("[Timer] Failed to emit draft-paused:", err); } } async function triggerAutoPick( seasonId: string, teamId: string, pickNumber: number, autodraftSettings: InferSelectModel | null ): Promise { try { const result = await executeAutoPick({ seasonId, teamId, pickNumber, triggeredBy: "timer", autodraftSettings, db, }); if (!result.success) { if (result.error === "Pick already made") return true; logger.error(`[Timer] Auto-pick failed: ${result.error}`); return false; } return true; } catch (error) { logger.error("[Timer] Error in triggerAutoPick:", error); return false; } } // Core scheduling logic — called after acquiring the schedulingInProgress lock. // Iterative rather than recursive so while_on chains and bank-depleted runs don't // build unbounded call-stack depth or bypass the outer lock via re-entry. async function _schedulePickForSeason(seasonId: string): Promise { const MAX_CONSECUTIVE_AUTOPICKS = 100; // safety cap against infinite loops on data bugs for (let iteration = 0; iteration < MAX_CONSECUTIVE_AUTOPICKS; iteration++) { 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; } continue; // re-read season state — currentPickNumber has advanced } // 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 and loop to schedule the next team. const success = await triggerAutoPick( seasonId, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null ); if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; } continue; } 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; } continue; } const timeout = setTimeout(async () => { pickTimeouts.delete(seasonId); // Hold the schedulingInProgress lock for the entire pick+reschedule sequence. // Without this, the recovery interval (which checks !pickTimeouts.has) could fire // between the delete above and the DB commit inside triggerAutoPick, read a stale // currentPickNumber, and schedule a duplicate timeout for the same pick. if (schedulingInProgress.has(seasonId)) { logger.warn(`[Timer] Skipping expired-timer callback for ${seasonId} — scheduling already in progress`); return; } schedulingInProgress.add(seasonId); try { 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); } catch (err) { logger.error(`[Timer] Error in timer callback for ${seasonId}:`, err); } finally { schedulingInProgress.delete(seasonId); } }, msUntilExpiry); pickTimeouts.set(seasonId, timeout); return; // timer is set — done until it fires } logger.error(`[Timer] Hit max consecutive auto-picks (${MAX_CONSECUTIVE_AUTOPICKS}) for season ${seasonId}`); } async function schedulePickForSeason(seasonId: string): Promise { 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 { await checkAndAutoStartDrafts(); const activeDrafts = await db.query.seasons.findMany({ where: eq(schema.seasons.status, "draft"), }); // Evict caches for seasons no longer drafting. // Union both cache key sets so overnight-pause entries populated by socket.ts // (without a corresponding draftSlotsCache entry) are also evicted. const activeIds = new Set(activeDrafts.map((s) => s.id)); const staleIds = new Set([...draftSlotsCache.keys(), ...overnightPauseCacheKeys()].filter((id) => !activeIds.has(id))); for (const id of staleIds) { draftSlotsCache.delete(id); evictOvernightPauseCache(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(); clearAllOvernightPauseCaches(); 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 { cancelPickTimeout(seasonId); cancelOvernightResumeTimeout(seasonId); await schedulePickForSeason(seasonId); } /** * Called by the pause route. Cancels the in-memory timeout and snapshots the * remaining time into timeRemaining so resume starts from exactly where it left off. */ export async function onDraftPaused(seasonId: string): Promise> { cancelPickTimeout(seasonId); cancelOvernightResumeTimeout(seasonId); const now = Date.now(); const activeTimers = await db.query.draftTimers.findMany({ where: and( eq(schema.draftTimers.seasonId, seasonId), isNotNull(schema.draftTimers.picksExpiresAt), ), }); const snapshots = await Promise.all( activeTimers .filter((t) => t.picksExpiresAt !== null) .map(async (timer) => { const remaining = Math.max(0, Math.floor(((timer.picksExpiresAt?.getTime() ?? now) - now) / 1000)); await db .update(schema.draftTimers) .set({ timeRemaining: remaining, picksExpiresAt: null, picksStartedAt: null, updatedAt: new Date() }) .where(eq(schema.draftTimers.id, timer.id)); return { teamId: timer.teamId, timeRemaining: remaining }; }) ); return snapshots; } /** * Called by the rollback route. Cancels any pending timeout and resets all timer * rows to the initial bank. Does NOT reschedule — the rollback leaves the draft * paused so the commissioner can review before resuming. */ export async function onDraftRolledBack(seasonId: string, initialTime: number): Promise { cancelPickTimeout(seasonId); cancelOvernightResumeTimeout(seasonId); draftSlotsCache.delete(seasonId); await db .update(schema.draftTimers) .set({ timeRemaining: initialTime, picksExpiresAt: null, picksStartedAt: null, updatedAt: new Date() }) .where(eq(schema.draftTimers.seasonId, seasonId)); }