From 623fb1dfc0ed3e6c24de8b5a4541760c571bb98c Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Tue, 26 May 2026 21:23:40 -0700 Subject: [PATCH] Fix slow bracket save and stale projected points Saving a bracket result was slow and left the projected-points column showing pre-result EVs until the next simulation. Root causes: - Side effects ran in the wrong order: recalculateAffectedLeagues (which reads EVs to compute projected points) fired before updateProbabilitiesAfterResult wrote the new EVs. - calculateTeamProjectedScore issued one getParticipantEV query per unfinished pick, multiplied by every team in every affected league. Changes: - Swap side-effect order in processMatchResult, processPlayoffEvent, and the set-round-winners route so probabilities update first. - Pre-fetch every EV referenced by a season's draft picks in a single inArray query inside recalculateStandings and pass the map down to calculateTeamProjectedScore (with a getParticipantEV fallback). - Add skipProbabilities option to processPlayoffEvent and use it from autoCompleteRoundIfDone, where the caller has already refreshed EVs. Fixes #31 Co-Authored-By: Claude Opus 4.7 --- app/models/scoring-calculator.ts | 69 ++++++++++++++----- ...sons.$id.events.$eventId.bracket.server.ts | 8 ++- app/services/probability-updater.ts | 6 +- 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index dfd5a5c..8efff96 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -26,7 +26,10 @@ import { getQPStandings, updateFinalRankings, } from "./qualifying-points"; -import { getParticipantEV } from "./participant-expected-value"; +import { + getParticipantEV, + type ParticipantEV, +} from "./participant-expected-value"; import { calculateEV } from "~/services/ev-calculator"; /** @@ -165,7 +168,7 @@ function getRoundConfig( export async function processPlayoffEvent( eventId: string, providedDb?: ReturnType, - options?: { skipRecalculate?: boolean } + options?: { skipRecalculate?: boolean; skipProbabilities?: boolean } ): Promise { const db = providedDb || database(); @@ -358,23 +361,25 @@ export async function processPlayoffEvent( } } + // Update probabilities first so the standings recalc reads fresh EVs. + if (!options?.skipProbabilities) { + try { + await updateProbabilitiesAfterResult(event.sportsSeasonId, true); + logger.log( + `[ScoringCalculator] Auto-updated probabilities for sports season ${event.sportsSeasonId}` + ); + } catch (error) { + logger.error( + `[ScoringCalculator] Failed to auto-update probabilities for sports season ${event.sportsSeasonId}:`, + error + ); + } + } + // Recalculate standings for all affected leagues (skipped when caller handles it) if (!options?.skipRecalculate) { await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name, eventId }); } - - // Auto-trigger probability recalculation after result - try { - await updateProbabilitiesAfterResult(event.sportsSeasonId, true); - logger.log( - `[ScoringCalculator] Auto-updated probabilities for sports season ${event.sportsSeasonId}` - ); - } catch (error) { - logger.error( - `[ScoringCalculator] Failed to auto-update probabilities for sports season ${event.sportsSeasonId}:`, - error - ); - } } /** @@ -489,7 +494,8 @@ export async function processMatchResult( const sideEffectOptions = (eventName || eventId) ? { eventName, eventId, matchIds: matchId ? [matchId] : undefined } : undefined; - await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions); + // Update probabilities first so the standings recalc reads fresh EVs and + // projected points reflect the new result. try { await updateProbabilitiesAfterResult(sportsSeasonId, true); } catch (error) { @@ -498,6 +504,7 @@ export async function processMatchResult( error ); } + await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions); } } @@ -1061,7 +1068,8 @@ export async function calculateTeamScore( export async function calculateTeamProjectedScore( teamId: string, seasonId: string, - providedDb?: ReturnType + providedDb?: ReturnType, + evByParticipantId?: Map ): Promise<{ actualPoints: number; projectedPoints: number; @@ -1185,7 +1193,9 @@ export async function calculateTeamProjectedScore( let evSum = 0; if (unfinishedParticipants.length > 0) { for (const { participantId, sportsSeasonId, floorPoints } of unfinishedParticipants) { - const ev = await getParticipantEV(participantId, sportsSeasonId); + const ev = evByParticipantId + ? evByParticipantId.get(participantId) ?? null + : await getParticipantEV(participantId, sportsSeasonId); if (ev) { // EV is already calculated with default scoring (100/70/50/40/25/25/15/15) @@ -1317,11 +1327,32 @@ export async function recalculateStandings( where: eq(schema.teams.seasonId, seasonId), }); + // Pre-fetch every EV referenced by this season's draft picks in one query, then + // pass the map into calculateTeamProjectedScore to avoid an N+1 per team. + const evByParticipantId = new Map(); + const allPicks = await db.query.draftPicks.findMany({ + where: eq(schema.draftPicks.seasonId, seasonId), + columns: { participantId: true }, + with: { participant: { columns: { sportsSeasonId: true } } }, + }); + if (allPicks.length > 0) { + const sportsSeasonIds = Array.from( + new Set(allPicks.map((p) => p.participant.sportsSeasonId)) + ); + const evRows = await db + .select() + .from(schema.seasonParticipantExpectedValues) + .where(inArray(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonIds)); + for (const ev of evRows) { + evByParticipantId.set(ev.participantId, ev); + } + } + // Calculate scores for all teams const teamScores = await Promise.all( teams.map(async (team) => { const score = await calculateTeamScore(team.id, seasonId, db); - const projected = await calculateTeamProjectedScore(team.id, seasonId, db); + const projected = await calculateTeamProjectedScore(team.id, seasonId, db, evByParticipantId); return { teamId: team.id, totalPoints: score.totalPoints, diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index e6d97a4..7a10943 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -238,7 +238,9 @@ export async function action({ request, params }: Route.ActionArgs) { .set({ playoffRound: round, updatedAt: new Date() }) .where(eq(schema.scoringEvents.id, eventId)); - await processPlayoffEvent(eventId, db, { skipRecalculate: true }); + // skipProbabilities=true: callers (set-winner / set-round-winners) already ran + // updateProbabilitiesAfterResult before reaching here, so re-running would be wasted. + await processPlayoffEvent(eventId, db, { skipRecalculate: true, skipProbabilities: true }); logger.log(`[AutoComplete] Round "${round}" auto-completed for event ${eventId}`); } @@ -444,12 +446,14 @@ export async function action({ request, params }: Route.ActionArgs) { // previously completed matches in the event. if (successCount > 0) { const db = database(); - await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds }); + // Update probabilities first so recalculateAffectedLeagues reads fresh EVs + // when computing projected points. try { await updateProbabilitiesAfterResult(event.sportsSeasonId, true); } catch (error) { logger.error(`Error updating probabilities after batch round winners:`, error); } + await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds }); await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db); } diff --git a/app/services/probability-updater.ts b/app/services/probability-updater.ts index b6fd7cc..a3a7511 100644 --- a/app/services/probability-updater.ts +++ b/app/services/probability-updater.ts @@ -143,6 +143,9 @@ export async function updateProbabilitiesAfterResult( pointsFor8th: 15, }; + // Sequential: upsertParticipantEV calls syncVorpForSeason internally, which + // reads and rewrites every seasonParticipants row for this sportsSeasonId. + // Running these in parallel would race on that shared state. for (const [participantId, finalPosition] of finishedMap.entries()) { try { const probs = createFinishedProbabilities(finalPosition); @@ -185,7 +188,8 @@ export async function updateProbabilitiesAfterResult( // Recalculate ICM for unfinished participants const icmResults = calculateICMFromOdds(unfinishedOdds); - // Update each unfinished participant + // Sequential for the same reason as the finished loop above: + // upsertParticipantEV rewrites shared per-season state via syncVorpForSeason. for (const [participantId, icmResult] of icmResults.entries()) { try { const probs = [ -- 2.45.3