Fix standings snapshot upsert: atomic writes, correct date, and event-driven updates (#177)

- Add unique index on (team_id, season_id, snapshot_date) in team_standings_snapshots
  so concurrent writes can't produce duplicate rows (migration 0051)
- Rewrite createDailySnapshot to use INSERT ... ON CONFLICT DO UPDATE inside a
  transaction, replacing the SELECT-then-insert/skip pattern (N+1 queries, race-prone)
- Remove early-exit guard in server/snapshots.ts background job so it always
  upserts rather than skipping seasons that already have a snapshot for today
- Call createDailySnapshot in recalculateAffectedLeagues after recalculateStandings,
  so every bracket score update refreshes today's snapshot; wrapped in try/catch so
  a snapshot failure can't block Discord notifications
- Also call createDailySnapshot from the admin rescore and finalize-bracket actions
- Fix UTC date bug: replace toISOString().split('T')[0] with local date parts in
  both standings.ts and server/snapshots.ts (and the 7-day lookback query)
- Convert all dynamic await import() calls in bracket.server.ts to static imports

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-18 22:15:28 -07:00 committed by GitHub
parent 09b9b9bdad
commit a054247a55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 53 additions and 51 deletions

View file

@ -7,6 +7,7 @@ import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord"; import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates"; import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import { getUserDisplayName } from "~/models/user"; import { getUserDisplayName } from "~/models/user";
import { createDailySnapshot } from "~/models/standings";
/** /**
* Core scoring calculation engine * Core scoring calculation engine
@ -1282,6 +1283,12 @@ export async function recalculateAffectedLeagues(
await recalculateStandings(seasonId, db); await recalculateStandings(seasonId, db);
try {
await createDailySnapshot(seasonId, db);
} catch (err) {
console.error(`[ScoringCalculator] Snapshot failed for season ${seasonId}:`, err);
}
// Check if any team's score changed — if so, send Discord notification // Check if any team's score changed — if so, send Discord notification
const afterStandings = await db.query.teamStandings.findMany({ const afterStandings = await db.query.teamStandings.findMany({
where: eq(schema.teamStandings.seasonId, seasonId), where: eq(schema.teamStandings.seasonId, seasonId),

View file

@ -304,7 +304,7 @@ export async function getSevenDayStandingsChange(
const snapshots = await db.query.teamStandingsSnapshots.findMany({ const snapshots = await db.query.teamStandingsSnapshots.findMany({
where: and( where: and(
eq(schema.teamStandingsSnapshots.seasonId, seasonId), eq(schema.teamStandingsSnapshots.seasonId, seasonId),
eq(schema.teamStandingsSnapshots.snapshotDate, sevenDaysAgo.toISOString().split("T")[0]) eq(schema.teamStandingsSnapshots.snapshotDate, `${sevenDaysAgo.getFullYear()}-${String(sevenDaysAgo.getMonth() + 1).padStart(2, "0")}-${String(sevenDaysAgo.getDate()).padStart(2, "0")}`)
), ),
}); });
@ -334,27 +334,16 @@ export async function createDailySnapshot(
): Promise<void> { ): Promise<void> {
const db = providedDb || database(); const db = providedDb || database();
const today = new Date().toISOString().split("T")[0]; const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const standings = await db.query.teamStandings.findMany({ const standings = await db.query.teamStandings.findMany({
where: eq(schema.teamStandings.seasonId, seasonId), where: eq(schema.teamStandings.seasonId, seasonId),
}); });
for (const standing of standings) { await db.transaction(async (tx) => {
// Check if snapshot already exists for today for (const standing of standings) {
const existing = await db.query.teamStandingsSnapshots.findFirst({ const snapshotData = {
where: and(
eq(schema.teamStandingsSnapshots.teamId, standing.teamId),
eq(schema.teamStandingsSnapshots.seasonId, seasonId),
eq(schema.teamStandingsSnapshots.snapshotDate, today)
),
});
if (!existing) {
await db.insert(schema.teamStandingsSnapshots).values({
teamId: standing.teamId,
seasonId,
snapshotDate: today,
totalPoints: standing.totalPoints, totalPoints: standing.totalPoints,
rank: standing.currentRank, rank: standing.currentRank,
firstPlaceCount: standing.firstPlaceCount, firstPlaceCount: standing.firstPlaceCount,
@ -366,15 +355,26 @@ export async function createDailySnapshot(
seventhPlaceCount: standing.seventhPlaceCount, seventhPlaceCount: standing.seventhPlaceCount,
eighthPlaceCount: standing.eighthPlaceCount, eighthPlaceCount: standing.eighthPlaceCount,
participantsRemaining: standing.participantsRemaining, participantsRemaining: standing.participantsRemaining,
// Phase 5.4: Copy projected points tracking
actualPoints: standing.actualPoints, actualPoints: standing.actualPoints,
projectedPoints: standing.projectedPoints, projectedPoints: standing.projectedPoints,
participantsFinished: standing.participantsFinished, participantsFinished: standing.participantsFinished,
}); };
}
}
console.log(`[Standings] Created daily snapshot for season ${seasonId}`); await tx
.insert(schema.teamStandingsSnapshots)
.values({ teamId: standing.teamId, seasonId, snapshotDate: today, ...snapshotData })
.onConflictDoUpdate({
target: [
schema.teamStandingsSnapshots.teamId,
schema.teamStandingsSnapshots.seasonId,
schema.teamStandingsSnapshots.snapshotDate,
],
set: snapshotData,
});
}
});
console.log(`[Standings] Upserted daily snapshot for season ${seasonId}`);
} }
/** /**

View file

@ -25,9 +25,13 @@ import {
processPlayoffEvent, processPlayoffEvent,
processMatchResult, processMatchResult,
recalculateAffectedLeagues, recalculateAffectedLeagues,
recalculateStandings,
} from "~/models/scoring-calculator"; } from "~/models/scoring-calculator";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates"; import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
import { setParticipantResult, findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
import { createDailySnapshot } from "~/models/standings";
import { import {
createGroupsForEvent, createGroupsForEvent,
addMembersToGroup, addMembersToGroup,
@ -157,7 +161,6 @@ export async function action({ request, params }: Route.ActionArgs) {
const participantsInBracket = new Set(participantIds); const participantsInBracket = new Set(participantIds);
// Mark participants NOT in the bracket as eliminated // Mark participants NOT in the bracket as eliminated
const { setParticipantResult } = await import("~/models/participant-result");
for (const participant of allParticipants) { for (const participant of allParticipants) {
if (!participantsInBracket.has(participant.id)) { if (!participantsInBracket.has(participant.id)) {
await setParticipantResult( await setParticipantResult(
@ -474,9 +477,6 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
// Validate round order: ensure previous rounds are complete // Validate round order: ensure previous rounds are complete
const { findParticipantResultsBySportsSeasonId } = await import(
"~/models/participant-result"
);
const existingResults = await findParticipantResultsBySportsSeasonId( const existingResults = await findParticipantResultsBySportsSeasonId(
params.id params.id
); );
@ -529,9 +529,6 @@ export async function action({ request, params }: Route.ActionArgs) {
.where(eq(schema.scoringEvents.id, params.eventId)); .where(eq(schema.scoringEvents.id, params.eventId));
// Get a fantasy season ID for scoring calculation // Get a fantasy season ID for scoring calculation
const { findSeasonSportsBySportsSeasonId } = await import(
"~/models/season-sport"
);
const seasonSports = await findSeasonSportsBySportsSeasonId(params.id); const seasonSports = await findSeasonSportsBySportsSeasonId(params.id);
if (seasonSports.length === 0) { if (seasonSports.length === 0) {
return { return {
@ -579,7 +576,6 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
// Mark participants NOT in the bracket as eliminated // Mark participants NOT in the bracket as eliminated
const { setParticipantResult } = await import("~/models/participant-result");
let eliminatedCount = 0; let eliminatedCount = 0;
for (const participant of allParticipants) { for (const participant of allParticipants) {
@ -636,7 +632,6 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
// Get all participants in this sports season // Get all participants in this sports season
const { findParticipantsBySportsSeasonId } = await import("~/models/participant");
const allParticipants = await findParticipantsBySportsSeasonId(params.id); const allParticipants = await findParticipantsBySportsSeasonId(params.id);
// Get participants in matches // Get participants in matches
@ -660,7 +655,6 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
// Assign 0 points to participants not in the bracket (Q20) // Assign 0 points to participants not in the bracket (Q20)
const { setParticipantResult } = await import("~/models/participant-result");
for (const participant of allParticipants) { for (const participant of allParticipants) {
if (!participantsInMatches.has(participant.id)) { if (!participantsInMatches.has(participant.id)) {
await setParticipantResult( await setParticipantResult(
@ -678,12 +672,11 @@ export async function action({ request, params }: Route.ActionArgs) {
.where(eq(schema.scoringEvents.id, params.eventId)); .where(eq(schema.scoringEvents.id, params.eventId));
// Recalculate standings for all affected fantasy seasons // Recalculate standings for all affected fantasy seasons
const { recalculateStandings } = await import("~/models/scoring-calculator");
const { findSeasonSportsBySportsSeasonId } = await import("~/models/season-sport");
const seasonSports = await findSeasonSportsBySportsSeasonId(params.id); const seasonSports = await findSeasonSportsBySportsSeasonId(params.id);
for (const seasonSport of seasonSports) { for (const seasonSport of seasonSports) {
await recalculateStandings(seasonSport.seasonId, db); await recalculateStandings(seasonSport.seasonId, db);
await createDailySnapshot(seasonSport.seasonId, db);
} }
return { return {
@ -832,7 +825,6 @@ export async function action({ request, params }: Route.ActionArgs) {
// Mark eliminated group participants with finalPosition = 0 // Mark eliminated group participants with finalPosition = 0
const eliminatedIds = await getEliminatedParticipantIds(params.eventId); const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
const { setParticipantResult } = await import("~/models/participant-result");
for (const participantId of eliminatedIds) { for (const participantId of eliminatedIds) {
await setParticipantResult(participantId, event.sportsSeasonId, 0); await setParticipantResult(participantId, event.sportsSeasonId, 0);
} }

View file

@ -6,6 +6,7 @@ import type { Route } from "./+types/admin.sports-seasons.$id";
import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season"; import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season";
import { findParticipantsBySportsSeasonId } from "~/models/participant"; import { findParticipantsBySportsSeasonId } from "~/models/participant";
import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator"; import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator";
import { createDailySnapshot } from "~/models/standings";
import { database } from "~/database/context"; import { database } from "~/database/context";
import { participantEvSnapshots, seasonSports } from "~/database/schema"; import { participantEvSnapshots, seasonSports } from "~/database/schema";
import { eq, desc } from "drizzle-orm"; import { eq, desc } from "drizzle-orm";
@ -104,6 +105,7 @@ export async function action(args: Route.ActionArgs) {
return { success: true, intent: "rescore", message: "No linked fantasy seasons found — nothing to rescore." }; return { success: true, intent: "rescore", message: "No linked fantasy seasons found — nothing to rescore." };
} }
await Promise.all(links.map((link) => recalculateStandings(link.seasonId, db))); await Promise.all(links.map((link) => recalculateStandings(link.seasonId, db)));
await Promise.all(links.map((link) => createDailySnapshot(link.seasonId, db)));
return { success: true, intent: "rescore", message: `Rescored ${links.length} linked season(s).` }; return { success: true, intent: "rescore", message: `Rescored ${links.length} linked season(s).` };
} catch (error) { } catch (error) {
console.error("Error rescoring:", error); console.error("Error rescoring:", error);

View file

@ -543,7 +543,11 @@ export const teamStandingsSnapshots = pgTable("team_standings_snapshots", {
projectedPoints: decimal("projected_points", { precision: 10, scale: 2 }), // actualPoints + EVs of unfinished projectedPoints: decimal("projected_points", { precision: 10, scale: 2 }), // actualPoints + EVs of unfinished
participantsFinished: integer("participants_finished"), // Count of finished participants participantsFinished: integer("participants_finished"), // Count of finished participants
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
}); }, (t) => ({
uniqueTeamSeasonDate: uniqueIndex("team_standings_snapshots_unique").on(
t.teamId, t.seasonId, t.snapshotDate
),
}));
// Expected value tracking (sports-season-specific) // Expected value tracking (sports-season-specific)
export const participantExpectedValues = pgTable("participant_expected_values", { export const participantExpectedValues = pgTable("participant_expected_values", {

View file

@ -0,0 +1,2 @@
CREATE UNIQUE INDEX IF NOT EXISTS "team_standings_snapshots_unique"
ON "team_standings_snapshots" ("team_id", "season_id", "snapshot_date");

View file

@ -358,6 +358,13 @@
"when": 1773821310661, "when": 1773821310661,
"tag": "0050_chief_peter_quill", "tag": "0050_chief_peter_quill",
"breakpoints": true "breakpoints": true
},
{
"idx": 51,
"version": "7",
"when": 1773900000000,
"tag": "0051_add_standings_snapshot_unique_index",
"breakpoints": true
} }
] ]
} }

View file

@ -56,7 +56,8 @@ export function stopSnapshotSystem(): void {
* Only creates snapshots if they don't already exist for today * Only creates snapshots if they don't already exist for today
*/ */
async function createDailySnapshots(): Promise<void> { async function createDailySnapshots(): Promise<void> {
const today = new Date().toISOString().split("T")[0]; const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
console.log(`[Snapshots] Checking for snapshots to create (${today})`); console.log(`[Snapshots] Checking for snapshots to create (${today})`);
@ -77,21 +78,8 @@ async function createDailySnapshots(): Promise<void> {
for (const season of activeSeasons) { for (const season of activeSeasons) {
try { try {
// Check if we already have a snapshot for today for any team in this season
const existingSnapshot = await db.query.teamStandingsSnapshots.findFirst({
where: eq(schema.teamStandingsSnapshots.seasonId, season.id),
orderBy: (snapshots, { desc }) => [desc(snapshots.snapshotDate)],
});
// If the most recent snapshot is from today, skip this season
if (existingSnapshot && existingSnapshot.snapshotDate === today) {
console.log(`[Snapshots] Snapshot already exists for season ${season.id} on ${today}`);
continue;
}
// Create snapshot using the standings model function
await createDailySnapshot(season.id, db); await createDailySnapshot(season.id, db);
console.log(`[Snapshots] ✅ Created snapshot for season ${season.id}`); console.log(`[Snapshots] ✅ Upserted snapshot for season ${season.id}`);
} catch (error) { } catch (error) {
console.error(`[Snapshots] Error creating snapshot for season ${season.id}:`, error); console.error(`[Snapshots] Error creating snapshot for season ${season.id}:`, error);
} }