Add Phase A: external HTTP cron jobs for snapshots, standings sync, and simulation
- Add POST /admin/jobs/run-daily-snapshots (replaces 24h setInterval in server/snapshots.ts) - Add POST /admin/jobs/sync-and-simulate (syncs standings every 2h; only simulates when standings actually changed by comparing gamesPlayed + leagueRank before upserting) - Add standingsLastChangedAt + lastSimulatedAt columns to sportsSeasons (migration 0118) - Add GET /healthz route for Docker healthcheck (pings DB, returns 503 on failure) - Add app/lib/cron-auth.ts shared X-Cron-Secret header auth helper - Remove startSnapshotSystem() from server/socket.ts (no more in-process setInterval) - Add Forgejo Actions scheduled workflows: daily-snapshots.yml (00:05 UTC) and sync-and-simulate.yml (every 2h) — require CRON_SECRET repo secret Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6ba8adab5b
commit
85bfc657e5
14 changed files with 6451 additions and 9 deletions
15
.forgejo/workflows/daily-snapshots.yml
Normal file
15
.forgejo/workflows/daily-snapshots.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
name: Daily snapshots
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '5 0 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Create daily standings snapshots
|
||||||
|
run: |
|
||||||
|
curl -sf -X POST https://brackt.com/admin/jobs/run-daily-snapshots \
|
||||||
|
-H "X-Cron-Secret: ${{ secrets.CRON_SECRET }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
| cat
|
||||||
15
.forgejo/workflows/sync-and-simulate.yml
Normal file
15
.forgejo/workflows/sync-and-simulate.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
name: Sync standings and simulate
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 */2 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Sync standings and run simulations where changed
|
||||||
|
run: |
|
||||||
|
curl -sf -X POST https://brackt.com/admin/jobs/sync-and-simulate \
|
||||||
|
-H "X-Cron-Secret: ${{ secrets.CRON_SECRET }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
| cat
|
||||||
7
app/lib/cron-auth.ts
Normal file
7
app/lib/cron-auth.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export function requireCronSecret(request: Request): void {
|
||||||
|
const secret = process.env.CRON_SECRET;
|
||||||
|
if (!secret) throw new Response("CRON_SECRET not configured", { status: 500 });
|
||||||
|
if (request.headers.get("x-cron-secret") !== secret) {
|
||||||
|
throw new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,11 @@ export default [
|
||||||
route("upcoming-events", "routes/upcoming-events.tsx"),
|
route("upcoming-events", "routes/upcoming-events.tsx"),
|
||||||
route("privacy-policy", "routes/privacy-policy.tsx"),
|
route("privacy-policy", "routes/privacy-policy.tsx"),
|
||||||
route("test-socket", "routes/test-socket.tsx"),
|
route("test-socket", "routes/test-socket.tsx"),
|
||||||
|
route("healthz", "routes/healthz.ts"),
|
||||||
|
|
||||||
|
// Cron job endpoints
|
||||||
|
route("admin/jobs/run-daily-snapshots", "routes/admin/jobs.run-daily-snapshots.ts"),
|
||||||
|
route("admin/jobs/sync-and-simulate", "routes/admin/jobs.sync-and-simulate.ts"),
|
||||||
|
|
||||||
// Admin routes
|
// Admin routes
|
||||||
route("admin", "routes/admin.tsx", [
|
route("admin", "routes/admin.tsx", [
|
||||||
|
|
|
||||||
29
app/routes/admin/jobs.run-daily-snapshots.ts
Normal file
29
app/routes/admin/jobs.run-daily-snapshots.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import { eq, or } from "drizzle-orm";
|
||||||
|
import * as schema from "~/database/schema";
|
||||||
|
import { createDailySnapshot } from "~/models/standings";
|
||||||
|
import { requireCronSecret } from "~/lib/cron-auth";
|
||||||
|
|
||||||
|
export async function action({ request }: { request: Request }) {
|
||||||
|
requireCronSecret(request);
|
||||||
|
|
||||||
|
const db = database();
|
||||||
|
const activeSeasons = await db.query.seasons.findMany({
|
||||||
|
where: or(eq(schema.seasons.status, "active"), eq(schema.seasons.status, "draft")),
|
||||||
|
});
|
||||||
|
|
||||||
|
let succeeded = 0;
|
||||||
|
const errors: { id: string; error: string }[] = [];
|
||||||
|
|
||||||
|
for (const season of activeSeasons) {
|
||||||
|
try {
|
||||||
|
await createDailySnapshot(season.id, db);
|
||||||
|
succeeded++;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({ id: season.id, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = errors.length > 0 && succeeded === 0 ? 500 : 200;
|
||||||
|
return Response.json({ total: activeSeasons.length, succeeded, errors }, { status });
|
||||||
|
}
|
||||||
72
app/routes/admin/jobs.sync-and-simulate.ts
Normal file
72
app/routes/admin/jobs.sync-and-simulate.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import * as schema from "~/database/schema";
|
||||||
|
import { findSportsSeasonsByStatus } from "~/models/sports-season";
|
||||||
|
import { syncStandings } from "~/services/standings-sync";
|
||||||
|
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
||||||
|
import { requireCronSecret } from "~/lib/cron-auth";
|
||||||
|
|
||||||
|
export async function action({ request }: { request: Request }) {
|
||||||
|
requireCronSecret(request);
|
||||||
|
|
||||||
|
const activeSeasons = await findSportsSeasonsByStatus("active");
|
||||||
|
|
||||||
|
const synced: string[] = [];
|
||||||
|
const unchanged: string[] = [];
|
||||||
|
const simulated: string[] = [];
|
||||||
|
const syncErrors: { id: string; error: string }[] = [];
|
||||||
|
const simErrors: { id: string; error: string }[] = [];
|
||||||
|
|
||||||
|
for (const season of activeSeasons) {
|
||||||
|
let changed = false;
|
||||||
|
try {
|
||||||
|
const result = await syncStandings(season.id);
|
||||||
|
changed = result.changed;
|
||||||
|
if (changed) {
|
||||||
|
synced.push(season.id);
|
||||||
|
} else {
|
||||||
|
unchanged.push(season.id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Sports without a sync adapter (F1, IndyCar, etc.) throw — skip gracefully
|
||||||
|
syncErrors.push({ id: season.id, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) continue;
|
||||||
|
|
||||||
|
// Only simulate if standings changed since last simulation
|
||||||
|
const current = await database()
|
||||||
|
.select({ standingsLastChangedAt: schema.sportsSeasons.standingsLastChangedAt, lastSimulatedAt: schema.sportsSeasons.lastSimulatedAt })
|
||||||
|
.from(schema.sportsSeasons)
|
||||||
|
.where(eq(schema.sportsSeasons.id, season.id))
|
||||||
|
.then((rows) => rows[0]);
|
||||||
|
|
||||||
|
const needsSim =
|
||||||
|
current?.standingsLastChangedAt &&
|
||||||
|
(!current.lastSimulatedAt || current.standingsLastChangedAt > current.lastSimulatedAt);
|
||||||
|
|
||||||
|
if (!needsSim) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runSportsSeasonSimulation(season.id);
|
||||||
|
await database()
|
||||||
|
.update(schema.sportsSeasons)
|
||||||
|
.set({ lastSimulatedAt: new Date() })
|
||||||
|
.where(eq(schema.sportsSeasons.id, season.id));
|
||||||
|
simulated.push(season.id);
|
||||||
|
} catch (err) {
|
||||||
|
// Seasons without a simulator config or that fail readiness checks throw — skip gracefully
|
||||||
|
simErrors.push({ id: season.id, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasErrors = syncErrors.length > 0 || simErrors.length > 0;
|
||||||
|
const hasSuccess = synced.length > 0 || unchanged.length > 0;
|
||||||
|
const status = hasErrors && !hasSuccess ? 500 : 200;
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ synced, unchanged, simulated, syncErrors, simErrors },
|
||||||
|
{ status }
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/routes/healthz.ts
Normal file
11
app/routes/healthz.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
try {
|
||||||
|
await database().execute(sql`SELECT 1`);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return Response.json({ ok: false }, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
|
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
|
||||||
import { upsertRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { upsertRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
|
|
@ -90,6 +90,7 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
|
||||||
|
|
||||||
const toUpsert: Parameters<typeof upsertRegularSeasonStandings>[0] = [];
|
const toUpsert: Parameters<typeof upsertRegularSeasonStandings>[0] = [];
|
||||||
const unmatchedRecords: typeof fetchedRecords = [];
|
const unmatchedRecords: typeof fetchedRecords = [];
|
||||||
|
const matchedParticipantIds: string[] = [];
|
||||||
|
|
||||||
for (const record of fetchedRecords) {
|
for (const record of fetchedRecords) {
|
||||||
// Strategy A: try externalId-first (bypasses name matching entirely after first sync)
|
// Strategy A: try externalId-first (bypasses name matching entirely after first sync)
|
||||||
|
|
@ -112,6 +113,7 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
matchedParticipantIds.push(participant.id);
|
||||||
toUpsert.push({
|
toUpsert.push({
|
||||||
participantId: participant.id,
|
participantId: participant.id,
|
||||||
sportsSeasonId,
|
sportsSeasonId,
|
||||||
|
|
@ -141,8 +143,37 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect whether standings actually changed by comparing gamesPlayed + leagueRank
|
||||||
|
// against current DB values before upserting.
|
||||||
|
let changed = false;
|
||||||
if (toUpsert.length > 0) {
|
if (toUpsert.length > 0) {
|
||||||
|
const currentRows = await db
|
||||||
|
.select({
|
||||||
|
participantId: schema.regularSeasonStandings.participantId,
|
||||||
|
gamesPlayed: schema.regularSeasonStandings.gamesPlayed,
|
||||||
|
leagueRank: schema.regularSeasonStandings.leagueRank,
|
||||||
|
})
|
||||||
|
.from(schema.regularSeasonStandings)
|
||||||
|
.where(inArray(schema.regularSeasonStandings.participantId, matchedParticipantIds));
|
||||||
|
|
||||||
|
const currentByParticipant = new Map(
|
||||||
|
currentRows.map((r) => [r.participantId, r])
|
||||||
|
);
|
||||||
|
|
||||||
|
changed = toUpsert.some((row) => {
|
||||||
|
const current = currentByParticipant.get(row.participantId);
|
||||||
|
if (!current) return true; // new row
|
||||||
|
return current.gamesPlayed !== row.gamesPlayed || current.leagueRank !== row.leagueRank;
|
||||||
|
});
|
||||||
|
|
||||||
await upsertRegularSeasonStandings(toUpsert);
|
await upsertRegularSeasonStandings(toUpsert);
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await db
|
||||||
|
.update(schema.sportsSeasons)
|
||||||
|
.set({ standingsLastChangedAt: new Date() })
|
||||||
|
.where(eq(schema.sportsSeasons.id, sportsSeasonId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist unmatched records so admin can resolve them without losing data on page reload
|
// Persist unmatched records so admin can resolve them without losing data on page reload
|
||||||
|
|
@ -155,5 +186,5 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
|
||||||
externalTeamId: r.externalTeamId,
|
externalTeamId: r.externalTeamId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { synced: toUpsert.length, unmatched };
|
return { synced: toUpsert.length, unmatched, changed };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,5 @@ export interface UnmatchedTeam {
|
||||||
export interface SyncResult {
|
export interface SyncResult {
|
||||||
synced: number;
|
synced: number;
|
||||||
unmatched: UnmatchedTeam[];
|
unmatched: UnmatchedTeam[];
|
||||||
|
changed: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -397,6 +397,9 @@ export const sportsSeasons = pgTable("sports_seasons", {
|
||||||
eloMaxRating: integer("elo_max_rating").default(1750),
|
eloMaxRating: integer("elo_max_rating").default(1750),
|
||||||
// Simulation status (prevents concurrent simulation runs)
|
// Simulation status (prevents concurrent simulation runs)
|
||||||
simulationStatus: simulationStatusEnum("simulation_status").notNull().default("idle"),
|
simulationStatus: simulationStatusEnum("simulation_status").notNull().default("idle"),
|
||||||
|
// Timestamps for automated sync-and-simulate job (change detection)
|
||||||
|
standingsLastChangedAt: timestamp("standings_last_changed_at"),
|
||||||
|
lastSimulatedAt: timestamp("last_simulated_at"),
|
||||||
// Controls the window during which this sport season appears in league creation/settings
|
// Controls the window during which this sport season appears in league creation/settings
|
||||||
draftOn: date("draft_on").notNull(),
|
draftOn: date("draft_on").notNull(),
|
||||||
draftOff: date("draft_off").notNull(),
|
draftOff: date("draft_off").notNull(),
|
||||||
|
|
|
||||||
4
drizzle/0118_chunky_vivisector.sql
Normal file
4
drizzle/0118_chunky_vivisector.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
ALTER TABLE "sports_seasons" ADD COLUMN "standings_last_changed_at" timestamp;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sports_seasons" ADD COLUMN "last_simulated_at" timestamp;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "season_template_sports_template_sport_unique" ON "season_template_sports" USING btree ("template_id","sport_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "season_templates_name_unique" ON "season_templates" USING btree ("name");
|
||||||
6249
drizzle/meta/0118_snapshot.json
Normal file
6249
drizzle/meta/0118_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -827,6 +827,13 @@
|
||||||
"when": 1780727815228,
|
"when": 1780727815228,
|
||||||
"tag": "0117_fix_picks_expires_at",
|
"tag": "0117_fix_picks_expires_at",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 118,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780890669286,
|
||||||
|
"tag": "0118_chunky_vivisector",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -420,13 +420,6 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
||||||
logger.error("Failed to start timer system:", error);
|
logger.error("Failed to start timer system:", error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the daily snapshot system
|
|
||||||
import("./snapshots").then(({ startSnapshotSystem }) => {
|
|
||||||
startSnapshotSystem();
|
|
||||||
}).catch((error) => {
|
|
||||||
logger.error("Failed to start snapshot system:", error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return io;
|
return io;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue