Fix slow bracket save and stale projected points (#54)
## Summary - Swap side-effect order so `updateProbabilitiesAfterResult` runs before `recalculateAffectedLeagues` in `processMatchResult`, `processPlayoffEvent`, and the `set-round-winners` route — projected points now reflect the new result immediately instead of waiting on the next simulation. - Eliminate the N+1 EV lookup in `calculateTeamProjectedScore` by pre-fetching every EV the season needs in a single `inArray` query inside `recalculateStandings` and passing the map down. - Add `skipProbabilities` option to `processPlayoffEvent`, used from `autoCompleteRoundIfDone` (callers have already refreshed EVs), avoiding a redundant pass on round-completing saves. Fixes #31 ## Test plan - [x] `npm run typecheck` - [x] `npx vitest run app/models/__tests__/team-projected-score.test.ts app/models/__tests__/process-match-result.test.ts app/services/__tests__/probability-updater.test.ts` — 57/57 passing - [ ] Manual: save a round of bracket winners on a sports season with at least one fantasy league and confirm (a) the request returns faster and (b) the team standings show projected points updated to reflect the result without re-running the simulator 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: #54
This commit is contained in:
parent
b4c224b368
commit
a02f603575
3 changed files with 61 additions and 22 deletions
|
|
@ -26,7 +26,10 @@ import {
|
||||||
getQPStandings,
|
getQPStandings,
|
||||||
updateFinalRankings,
|
updateFinalRankings,
|
||||||
} from "./qualifying-points";
|
} from "./qualifying-points";
|
||||||
import { getParticipantEV } from "./participant-expected-value";
|
import {
|
||||||
|
getParticipantEV,
|
||||||
|
type ParticipantEV,
|
||||||
|
} from "./participant-expected-value";
|
||||||
import { calculateEV } from "~/services/ev-calculator";
|
import { calculateEV } from "~/services/ev-calculator";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -165,7 +168,7 @@ function getRoundConfig(
|
||||||
export async function processPlayoffEvent(
|
export async function processPlayoffEvent(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
providedDb?: ReturnType<typeof database>,
|
providedDb?: ReturnType<typeof database>,
|
||||||
options?: { skipRecalculate?: boolean }
|
options?: { skipRecalculate?: boolean; skipProbabilities?: boolean }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
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)
|
// Recalculate standings for all affected leagues (skipped when caller handles it)
|
||||||
if (!options?.skipRecalculate) {
|
if (!options?.skipRecalculate) {
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name, eventId });
|
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)
|
const sideEffectOptions = (eventName || eventId)
|
||||||
? { eventName, eventId, matchIds: matchId ? [matchId] : undefined }
|
? { eventName, eventId, matchIds: matchId ? [matchId] : undefined }
|
||||||
: 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 {
|
try {
|
||||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -498,6 +504,7 @@ export async function processMatchResult(
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1061,7 +1068,8 @@ export async function calculateTeamScore(
|
||||||
export async function calculateTeamProjectedScore(
|
export async function calculateTeamProjectedScore(
|
||||||
teamId: string,
|
teamId: string,
|
||||||
seasonId: string,
|
seasonId: string,
|
||||||
providedDb?: ReturnType<typeof database>
|
providedDb?: ReturnType<typeof database>,
|
||||||
|
evByParticipantId?: Map<string, ParticipantEV>
|
||||||
): Promise<{
|
): Promise<{
|
||||||
actualPoints: number;
|
actualPoints: number;
|
||||||
projectedPoints: number;
|
projectedPoints: number;
|
||||||
|
|
@ -1185,7 +1193,9 @@ export async function calculateTeamProjectedScore(
|
||||||
let evSum = 0;
|
let evSum = 0;
|
||||||
if (unfinishedParticipants.length > 0) {
|
if (unfinishedParticipants.length > 0) {
|
||||||
for (const { participantId, sportsSeasonId, floorPoints } of unfinishedParticipants) {
|
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) {
|
if (ev) {
|
||||||
// EV is already calculated with default scoring (100/70/50/40/25/25/15/15)
|
// 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),
|
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
|
// Calculate scores for all teams
|
||||||
const teamScores = await Promise.all(
|
const teamScores = await Promise.all(
|
||||||
teams.map(async (team) => {
|
teams.map(async (team) => {
|
||||||
const score = await calculateTeamScore(team.id, seasonId, db);
|
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 {
|
return {
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
totalPoints: score.totalPoints,
|
totalPoints: score.totalPoints,
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,9 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
.set({ playoffRound: round, updatedAt: new Date() })
|
.set({ playoffRound: round, updatedAt: new Date() })
|
||||||
.where(eq(schema.scoringEvents.id, eventId));
|
.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}`);
|
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.
|
// previously completed matches in the event.
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
const db = database();
|
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 {
|
try {
|
||||||
await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
|
await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error updating probabilities after batch round winners:`, 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);
|
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,9 @@ export async function updateProbabilitiesAfterResult(
|
||||||
pointsFor8th: 15,
|
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()) {
|
for (const [participantId, finalPosition] of finishedMap.entries()) {
|
||||||
try {
|
try {
|
||||||
const probs = createFinishedProbabilities(finalPosition);
|
const probs = createFinishedProbabilities(finalPosition);
|
||||||
|
|
@ -185,7 +188,8 @@ export async function updateProbabilitiesAfterResult(
|
||||||
// Recalculate ICM for unfinished participants
|
// Recalculate ICM for unfinished participants
|
||||||
const icmResults = calculateICMFromOdds(unfinishedOdds);
|
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()) {
|
for (const [participantId, icmResult] of icmResults.entries()) {
|
||||||
try {
|
try {
|
||||||
const probs = [
|
const probs = [
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue