Fix slow bracket save and stale projected points #54

Merged
chrisp merged 1 commit from fix/bracket-save-projected-points into main 2026-05-27 04:28:11 +00:00
3 changed files with 61 additions and 22 deletions

View file

@ -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<typeof database>,
options?: { skipRecalculate?: boolean }
options?: { skipRecalculate?: boolean; skipProbabilities?: boolean }
): Promise<void> {
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<typeof database>
providedDb?: ReturnType<typeof database>,
evByParticipantId?: Map<string, ParticipantEV>
): 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<string, ParticipantEV>();
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,

View file

@ -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);
}

View file

@ -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 = [