From b81089879c9ce51a07f4c2f77d82638734887252 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:09:38 -0700 Subject: [PATCH] Fix while_on autodraft stalling between chain bursts, fixes #190 (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chain (checkAndTriggerNextAutodraft) picks consecutive while_on teams immediately after a pick, but is capped at totalTeams iterations. Once the cap is hit, the next while_on team had to wait for the full timer countdown before their pick fired — which users experienced as autodraft intermittently not working. Root cause fix: the timer loop now detects while_on mode and triggers the pick immediately (≤1 s) regardless of time remaining, bypassing the countdown. A timer-update with timeRemaining:0 is emitted first so clients don't see a frozen timer. If the chain already handled the pick, executeAutoPick returns "Pick already made" and the timer moves on harmlessly. Additional fixes: - Add timerTickRunning guard so concurrent setInterval ticks (when a chain takes >1 s) don't race on the same pick slot - Use onConflictDoNothing on the draft pick INSERT in executeAutoPick so a DB unique-constraint collision is treated as "Pick already made" rather than pausing the draft - Cache draftSlots per season in the timer loop (slots never change during an active draft) — eliminates one DB query per tick - currentPickNumber || 1 → ?? 1 Co-authored-by: Claude Sonnet 4.6 --- .../__tests__/executeAutoPick.timer.test.ts | 1 + app/models/draft-utils.ts | 11 ++- server/timer.ts | 93 +++++++++++++------ 3 files changed, 74 insertions(+), 31 deletions(-) diff --git a/app/models/__tests__/executeAutoPick.timer.test.ts b/app/models/__tests__/executeAutoPick.timer.test.ts index ade15eb..ca1cd42 100644 --- a/app/models/__tests__/executeAutoPick.timer.test.ts +++ b/app/models/__tests__/executeAutoPick.timer.test.ts @@ -141,6 +141,7 @@ function makeMockDb(seasonOverrides: Record = {}) { }, insert: vi.fn().mockReturnThis(), values: vi.fn().mockReturnThis(), + onConflictDoNothing: vi.fn().mockReturnThis(), returning: vi.fn(), // configured per-test update: vi.fn().mockReturnThis(), set: vi.fn().mockReturnThis(), diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index c93611e..8097736 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -619,7 +619,9 @@ export async function executeAutoPick(params: { logger.warn(`[AutoPick] No timer found for team ${teamId} in season ${seasonId}`); } - // Create the draft pick + // Create the draft pick — use ON CONFLICT DO NOTHING so that concurrent timer + // ticks racing to the same pick slot are handled atomically at the DB level + // rather than relying on the TOCTOU pre-check above. const [draftPick] = await db .insert(schema.draftPicks) .values({ @@ -634,8 +636,15 @@ export async function executeAutoPick(params: { // Records the team's bank balance at the moment the pick was made (seconds remaining) timeUsed: currentTimer ? currentTimer.timeRemaining : undefined, }) + .onConflictDoNothing() .returning(); + if (!draftPick) { + // Another concurrent path already committed this pick + logger.log(`[AutoPick] Pick ${pickNumber} already made (conflict on insert), skipping`); + return { success: false, error: "Pick already made" }; + } + logger.log( `[AutoPick] Pick created - ${triggeredBy} triggered - Pick ${pickNumber} - Participant ${participantId}` ); diff --git a/server/timer.ts b/server/timer.ts index 09d0c98..753a407 100644 --- a/server/timer.ts +++ b/server/timer.ts @@ -16,6 +16,11 @@ const client = postgres(connectionString); const db = drizzle(client, { schema }); let timerInterval: NodeJS.Timeout | null = null; +let timerTickRunning = false; + +// Draft slots never change during an active draft — cache them to avoid +// a redundant query on every tick. +const draftSlotsCache = new Map(); /** * Start the draft timer system @@ -28,10 +33,16 @@ export function startDraftTimerSystem(): void { } 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); @@ -62,7 +73,14 @@ async function updateDraftTimers(): Promise { }); if (activeDrafts.length === 0) { - return; // No active drafts + 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 season of activeDrafts) { @@ -71,13 +89,17 @@ async function updateDraftTimers(): Promise { continue; } - const currentPickNumber = season.currentPickNumber || 1; + const currentPickNumber = season.currentPickNumber ?? 1; - // Get draft slots to determine whose turn it is - const draftSlots = await db.query.draftSlots.findMany({ - where: eq(schema.draftSlots.seasonId, season.id), - orderBy: asc(schema.draftSlots.draftOrder), - }); + // 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; @@ -129,21 +151,40 @@ async function updateDraftTimers(): Promise { continue; } - // If timer is at 0 or below, check autodraft settings and trigger auto-pick - if (timer.timeRemaining <= 0) { - logger.log( - `[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})` - ); + // 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"; - // Check if team has autodraft enabled - 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; + // 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, + }); + } const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null); @@ -177,16 +218,8 @@ async function updateDraftTimers(): Promise { currentPickNumber, }); - // If timer just hit 0, trigger auto-pick + // If timer just hit 0, trigger auto-pick (next_pick mode or no autodraft) if (newTimeRemaining === 0) { - 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; const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null); if (!success) {