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 <noreply@anthropic.com>
This commit is contained in:
parent
789408e428
commit
b81089879c
3 changed files with 74 additions and 31 deletions
|
|
@ -141,6 +141,7 @@ function makeMockDb(seasonOverrides: Record<string, unknown> = {}) {
|
||||||
},
|
},
|
||||||
insert: vi.fn().mockReturnThis(),
|
insert: vi.fn().mockReturnThis(),
|
||||||
values: vi.fn().mockReturnThis(),
|
values: vi.fn().mockReturnThis(),
|
||||||
|
onConflictDoNothing: vi.fn().mockReturnThis(),
|
||||||
returning: vi.fn(), // configured per-test
|
returning: vi.fn(), // configured per-test
|
||||||
update: vi.fn().mockReturnThis(),
|
update: vi.fn().mockReturnThis(),
|
||||||
set: vi.fn().mockReturnThis(),
|
set: vi.fn().mockReturnThis(),
|
||||||
|
|
|
||||||
|
|
@ -619,7 +619,9 @@ export async function executeAutoPick(params: {
|
||||||
logger.warn(`[AutoPick] No timer found for team ${teamId} in season ${seasonId}`);
|
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
|
const [draftPick] = await db
|
||||||
.insert(schema.draftPicks)
|
.insert(schema.draftPicks)
|
||||||
.values({
|
.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)
|
// Records the team's bank balance at the moment the pick was made (seconds remaining)
|
||||||
timeUsed: currentTimer ? currentTimer.timeRemaining : undefined,
|
timeUsed: currentTimer ? currentTimer.timeRemaining : undefined,
|
||||||
})
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
.returning();
|
.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(
|
logger.log(
|
||||||
`[AutoPick] Pick created - ${triggeredBy} triggered - Pick ${pickNumber} - Participant ${participantId}`
|
`[AutoPick] Pick created - ${triggeredBy} triggered - Pick ${pickNumber} - Participant ${participantId}`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ const client = postgres(connectionString);
|
||||||
const db = drizzle(client, { schema });
|
const db = drizzle(client, { schema });
|
||||||
|
|
||||||
let timerInterval: NodeJS.Timeout | null = null;
|
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<string, { teamId: string; draftOrder: number }[]>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the draft timer system
|
* Start the draft timer system
|
||||||
|
|
@ -28,10 +33,16 @@ export function startDraftTimerSystem(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
timerInterval = setInterval(async () => {
|
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 {
|
try {
|
||||||
await updateDraftTimers();
|
await updateDraftTimers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("[Timer] Error updating draft timers:", error);
|
logger.error("[Timer] Error updating draft timers:", error);
|
||||||
|
} finally {
|
||||||
|
timerTickRunning = false;
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
|
@ -62,7 +73,14 @@ async function updateDraftTimers(): Promise<void> {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (activeDrafts.length === 0) {
|
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) {
|
for (const season of activeDrafts) {
|
||||||
|
|
@ -71,13 +89,17 @@ async function updateDraftTimers(): Promise<void> {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPickNumber = season.currentPickNumber || 1;
|
const currentPickNumber = season.currentPickNumber ?? 1;
|
||||||
|
|
||||||
// Get draft slots to determine whose turn it is
|
// Draft slots never change during an active draft — use the cache.
|
||||||
const draftSlots = await db.query.draftSlots.findMany({
|
let draftSlots = draftSlotsCache.get(season.id);
|
||||||
where: eq(schema.draftSlots.seasonId, season.id),
|
if (!draftSlots) {
|
||||||
orderBy: asc(schema.draftSlots.draftOrder),
|
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;
|
const totalTeams = draftSlots.length;
|
||||||
if (totalTeams === 0) continue;
|
if (totalTeams === 0) continue;
|
||||||
|
|
@ -129,21 +151,40 @@ async function updateDraftTimers(): Promise<void> {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If timer is at 0 or below, check autodraft settings and trigger auto-pick
|
// Fetch autodraft settings once — used both for while_on bypass and timer-expiry path.
|
||||||
if (timer.timeRemaining <= 0) {
|
const autodraftSettings = await db.query.autodraftSettings.findFirst({
|
||||||
logger.log(
|
where: and(
|
||||||
`[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})`
|
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
|
// Trigger pick when: timer expired OR team is in while_on autodraft mode.
|
||||||
const autodraftSettings = await db.query.autodraftSettings.findFirst({
|
// The chain picks consecutive while_on teams after each pick, but it is capped at
|
||||||
where: and(
|
// totalTeams iterations. The while_on bypass here fills the gap: the timer picks
|
||||||
eq(schema.autodraftSettings.seasonId, season.id),
|
// the next while_on team within one tick (≤1 s) rather than waiting for the full
|
||||||
eq(schema.autodraftSettings.teamId, currentTeamId)
|
// 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) {
|
||||||
const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
|
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);
|
const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null);
|
||||||
|
|
||||||
|
|
@ -177,16 +218,8 @@ async function updateDraftTimers(): Promise<void> {
|
||||||
currentPickNumber,
|
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) {
|
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);
|
const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null);
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue