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:
parent
09b9b9bdad
commit
a054247a55
8 changed files with 53 additions and 51 deletions
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
for (const standing of standings) {
|
for (const standing of standings) {
|
||||||
// Check if snapshot already exists for today
|
const snapshotData = {
|
||||||
const existing = await db.query.teamStandingsSnapshots.findFirst({
|
|
||||||
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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] Created daily snapshot for season ${seasonId}`);
|
console.log(`[Standings] Upserted daily snapshot for season ${seasonId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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", {
|
||||||
|
|
|
||||||
2
drizzle/0051_add_standings_snapshot_unique_index.sql
Normal file
2
drizzle/0051_add_standings_snapshot_unique_index.sql
Normal 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");
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue