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 { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
||||
import { getUserDisplayName } from "~/models/user";
|
||||
import { createDailySnapshot } from "~/models/standings";
|
||||
|
||||
/**
|
||||
* Core scoring calculation engine
|
||||
|
|
@ -1282,6 +1283,12 @@ export async function recalculateAffectedLeagues(
|
|||
|
||||
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
|
||||
const afterStandings = await db.query.teamStandings.findMany({
|
||||
where: eq(schema.teamStandings.seasonId, seasonId),
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@ export async function getSevenDayStandingsChange(
|
|||
const snapshots = await db.query.teamStandingsSnapshots.findMany({
|
||||
where: and(
|
||||
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> {
|
||||
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({
|
||||
where: eq(schema.teamStandings.seasonId, seasonId),
|
||||
});
|
||||
|
||||
for (const standing of standings) {
|
||||
// Check if snapshot already exists for today
|
||||
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,
|
||||
await db.transaction(async (tx) => {
|
||||
for (const standing of standings) {
|
||||
const snapshotData = {
|
||||
totalPoints: standing.totalPoints,
|
||||
rank: standing.currentRank,
|
||||
firstPlaceCount: standing.firstPlaceCount,
|
||||
|
|
@ -366,15 +355,26 @@ export async function createDailySnapshot(
|
|||
seventhPlaceCount: standing.seventhPlaceCount,
|
||||
eighthPlaceCount: standing.eighthPlaceCount,
|
||||
participantsRemaining: standing.participantsRemaining,
|
||||
// Phase 5.4: Copy projected points tracking
|
||||
actualPoints: standing.actualPoints,
|
||||
projectedPoints: standing.projectedPoints,
|
||||
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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -25,9 +25,13 @@ import {
|
|||
processPlayoffEvent,
|
||||
processMatchResult,
|
||||
recalculateAffectedLeagues,
|
||||
recalculateStandings,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
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 {
|
||||
createGroupsForEvent,
|
||||
addMembersToGroup,
|
||||
|
|
@ -157,7 +161,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
const participantsInBracket = new Set(participantIds);
|
||||
|
||||
// Mark participants NOT in the bracket as eliminated
|
||||
const { setParticipantResult } = await import("~/models/participant-result");
|
||||
for (const participant of allParticipants) {
|
||||
if (!participantsInBracket.has(participant.id)) {
|
||||
await setParticipantResult(
|
||||
|
|
@ -474,9 +477,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
// Validate round order: ensure previous rounds are complete
|
||||
const { findParticipantResultsBySportsSeasonId } = await import(
|
||||
"~/models/participant-result"
|
||||
);
|
||||
const existingResults = await findParticipantResultsBySportsSeasonId(
|
||||
params.id
|
||||
);
|
||||
|
|
@ -529,9 +529,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
.where(eq(schema.scoringEvents.id, params.eventId));
|
||||
|
||||
// Get a fantasy season ID for scoring calculation
|
||||
const { findSeasonSportsBySportsSeasonId } = await import(
|
||||
"~/models/season-sport"
|
||||
);
|
||||
const seasonSports = await findSeasonSportsBySportsSeasonId(params.id);
|
||||
if (seasonSports.length === 0) {
|
||||
return {
|
||||
|
|
@ -579,7 +576,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
// Mark participants NOT in the bracket as eliminated
|
||||
const { setParticipantResult } = await import("~/models/participant-result");
|
||||
let eliminatedCount = 0;
|
||||
|
||||
for (const participant of allParticipants) {
|
||||
|
|
@ -636,7 +632,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
// Get all participants in this sports season
|
||||
const { findParticipantsBySportsSeasonId } = await import("~/models/participant");
|
||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
|
||||
// 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)
|
||||
const { setParticipantResult } = await import("~/models/participant-result");
|
||||
for (const participant of allParticipants) {
|
||||
if (!participantsInMatches.has(participant.id)) {
|
||||
await setParticipantResult(
|
||||
|
|
@ -678,12 +672,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
.where(eq(schema.scoringEvents.id, params.eventId));
|
||||
|
||||
// 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);
|
||||
|
||||
for (const seasonSport of seasonSports) {
|
||||
await recalculateStandings(seasonSport.seasonId, db);
|
||||
await createDailySnapshot(seasonSport.seasonId, db);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -832,7 +825,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
|
||||
// Mark eliminated group participants with finalPosition = 0
|
||||
const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
|
||||
const { setParticipantResult } = await import("~/models/participant-result");
|
||||
for (const participantId of eliminatedIds) {
|
||||
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 { findParticipantsBySportsSeasonId } from "~/models/participant";
|
||||
import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator";
|
||||
import { createDailySnapshot } from "~/models/standings";
|
||||
import { database } from "~/database/context";
|
||||
import { participantEvSnapshots, seasonSports } from "~/database/schema";
|
||||
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." };
|
||||
}
|
||||
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).` };
|
||||
} catch (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
|
||||
participantsFinished: integer("participants_finished"), // Count of finished participants
|
||||
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)
|
||||
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,
|
||||
"tag": "0050_chief_peter_quill",
|
||||
"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
|
||||
*/
|
||||
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})`);
|
||||
|
||||
|
|
@ -77,21 +78,8 @@ async function createDailySnapshots(): Promise<void> {
|
|||
|
||||
for (const season of activeSeasons) {
|
||||
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);
|
||||
console.log(`[Snapshots] ✅ Created snapshot for season ${season.id}`);
|
||||
console.log(`[Snapshots] ✅ Upserted snapshot for season ${season.id}`);
|
||||
} catch (error) {
|
||||
console.error(`[Snapshots] Error creating snapshot for season ${season.id}:`, error);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue