diff --git a/server/src/ingest/cadence.test.ts b/server/src/ingest/cadence.test.ts new file mode 100644 index 0000000..b41b2bd --- /dev/null +++ b/server/src/ingest/cadence.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { POLL_IDLE_MS, POLL_INPLAY_MS, POLL_NEAR_MS, fallbackAllowed, pollDelay } from './cadence'; + +describe('pollDelay', () => { + it('polls fastest while a match is in play', () => { + expect(pollDelay(true, true)).toBe(POLL_INPLAY_MS); + expect(pollDelay(true, false)).toBe(POLL_INPLAY_MS); + }); + + it('polls at the near cadence inside a kickoff window', () => { + expect(pollDelay(false, true)).toBe(POLL_NEAR_MS); + }); + + it('idles when nothing is on', () => { + expect(pollDelay(false, false)).toBe(POLL_IDLE_MS); + }); + + it('tiers are ordered in-play < near < idle', () => { + expect(POLL_INPLAY_MS).toBeLessThan(POLL_NEAR_MS); + expect(POLL_NEAR_MS).toBeLessThan(POLL_IDLE_MS); + }); +}); + +describe('fallbackAllowed', () => { + it('blocks attempts inside the throttle interval', () => { + expect(fallbackAllowed(1_000_000, 1_010_000)).toBe(false); + }); + + it('allows attempts once the interval has passed', () => { + expect(fallbackAllowed(1_000_000, 1_015_000)).toBe(true); + expect(fallbackAllowed(0, 1_000_000)).toBe(true); + }); + + it('keeps the 8s in-play cadence under the 10 req/min football-data cap', () => { + // one attempt per 15s ⇒ 4 req/min even if every tick fails over + expect(fallbackAllowed(0, 15_000)).toBe(true); + expect(fallbackAllowed(0, 14_999)).toBe(false); + }); +}); diff --git a/server/src/ingest/cadence.ts b/server/src/ingest/cadence.ts new file mode 100644 index 0000000..7a94d07 --- /dev/null +++ b/server/src/ingest/cadence.ts @@ -0,0 +1,18 @@ +// Poll cadence decisions, kept pure (no db imports) so vitest can load them. + +export const POLL_INPLAY_MS = 8_000; +export const POLL_NEAR_MS = 20_000; +export const POLL_IDLE_MS = 10 * 60_000; + +/** Three tiers: in-play → 8s, inside a kickoff window → 20s, otherwise 10m. */ +export function pollDelay(anyLive: boolean, nearKickoff: boolean): number { + if (anyLive) return POLL_INPLAY_MS; + if (nearKickoff) return POLL_NEAR_MS; + return POLL_IDLE_MS; +} + +/** Throttle gate for the football-data fallback (hard 10 req/min free tier): + * allow at most one attempt per `intervalMs`, regardless of tick cadence. */ +export function fallbackAllowed(lastAttempt: number, now: number, intervalMs = 15_000): boolean { + return now - lastAttempt >= intervalMs; +} diff --git a/server/src/ingest/espn.ts b/server/src/ingest/espn.ts index 7110e73..8246e43 100644 --- a/server/src/ingest/espn.ts +++ b/server/src/ingest/espn.ts @@ -19,15 +19,17 @@ export type { EspnMatch, EspnSummary } from './espnNormalize'; export async function fetchEspnScoreboard(dateYYYYMMDD?: string): Promise { const url = `${BASE}/scoreboard${dateYYYYMMDD ? `?dates=${dateYYYYMMDD}` : ''}`; const body = await cachedFetch<{ events?: EspnEvent[] }>(SOURCE, `espn:sb:${dateYYYYMMDD ?? 'now'}`, url, { - ttl: 20_000, + ttl: 5_000, minInterval: 3_000, }); return normalizeScoreboard(body); } -export async function fetchEspnSummary(eventId: string): Promise { +// `live` drops the cache TTL so in-play stats/timeline actually refresh at the +// live cadence — a long TTL here is what made live pages minutes stale. +export async function fetchEspnSummary(eventId: string, live = false): Promise { const raw = await cachedFetch(SOURCE, `espn:summary:${eventId}`, `${BASE}/summary?event=${eventId}`, { - ttl: 5 * 60_000, + ttl: live ? 15_000 : 5 * 60_000, minInterval: 2_000, }); return normalizeSummary(raw); diff --git a/server/src/ingest/scheduler.ts b/server/src/ingest/scheduler.ts index 6807b2f..7b86582 100644 --- a/server/src/ingest/scheduler.ts +++ b/server/src/ingest/scheduler.ts @@ -1,4 +1,5 @@ import { TournamentState, type LiveMatch, type Source } from '../tournament'; +import { pollDelay, fallbackAllowed } from './cadence'; import { fetchEspnScoreboard, fetchEspnSummary } from './espn'; import { fetchEspnOdds } from './espnOdds'; import { fetchFootballData } from '../footballData'; @@ -12,9 +13,7 @@ import type { Fixture } from '../../../src/lib/types'; // source only means staler data. const LIVE_WINDOW_MS = 3 * 60 * 60 * 1000; -const POLL_LIVE_MS = 20_000; -const POLL_IDLE_MS = 10 * 60_000; -const ENRICH_MS = 20 * 60_000; +const ENRICH_MS = 5 * 60_000; const ODDS_MS = 3 * 60 * 60 * 1000; // odds sweep cadence (line-movement history) const ODDS_HORIZON_MS = 14 * 24 * 60 * 60 * 1000; @@ -66,6 +65,7 @@ export function startScheduler( }; // ---- live scoreboard ---- + let lastFdAttempt = 0; // throttles the fallback independently of tick cadence const liveTick = async (): Promise => { const t0 = Date.now(); let matches: LiveMatch[] = []; @@ -82,7 +82,8 @@ export function startScheduler( } catch (e) { logIngest('espn', 'scoreboard', false, Date.now() - t0, e instanceof Error ? e.message : String(e)); } - if (!matches.length && token) { + if (!matches.length && token && fallbackAllowed(lastFdAttempt, Date.now())) { + lastFdAttempt = Date.now(); try { matches = await fetchFootballData(token); source = 'football-data'; } catch { /* logged via health */ } } if (!matches.length && sofa) { @@ -99,7 +100,7 @@ export function startScheduler( if (f.status !== 'live') continue; const eid = getSourceMap(f.num, 'espn'); if (!eid) continue; - try { setMatchExt(f.num, await fetchEspnSummary(eid)); } catch { /* logged via health */ } + try { setMatchExt(f.num, await fetchEspnSummary(eid, true)); } catch { /* logged via health */ } } }; @@ -162,7 +163,7 @@ export function startScheduler( const scheduleLive = (): void => { if (stopped) return; - const delay = state.hasLiveActivity(LIVE_WINDOW_MS) ? POLL_LIVE_MS : POLL_IDLE_MS; + const delay = pollDelay(state.hasLive(), state.hasLiveActivity(LIVE_WINDOW_MS)); liveTimer = setTimeout(async () => { await liveTick().catch(() => {}); scheduleLive(); }, delay); }; const scheduleEnrich = (): void => { diff --git a/server/src/tournament.ts b/server/src/tournament.ts index 39c5b70..8116b1c 100644 --- a/server/src/tournament.ts +++ b/server/src/tournament.ts @@ -87,6 +87,11 @@ export class TournamentState { ); } + /** True only while a match is actually in play. */ + hasLive(): boolean { + return this.fixtures.some((f) => f.status === 'live'); + } + /** Directly set a fixture's score/status (used by the dev injection endpoint). */ applyScore( num: number, diff --git a/src/features/match/MatchPreviewPage.tsx b/src/features/match/MatchPreviewPage.tsx index 8591ca3..5c4fb84 100644 --- a/src/features/match/MatchPreviewPage.tsx +++ b/src/features/match/MatchPreviewPage.tsx @@ -106,7 +106,7 @@ export function MatchPreviewPage() { .then((d: MatchPreview) => alive && setPreview(d)) .catch(() => alive && setFailed(true)); void load(); - const id = setInterval(() => { if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') void load(); }, 20_000); + const id = setInterval(() => { if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') void load(); }, 10_000); return () => { alive = false; clearInterval(id); }; }, [numParam]); diff --git a/src/features/scoreboard/ScoreboardPage.tsx b/src/features/scoreboard/ScoreboardPage.tsx index 52978f3..aeb7970 100644 --- a/src/features/scoreboard/ScoreboardPage.tsx +++ b/src/features/scoreboard/ScoreboardPage.tsx @@ -77,7 +77,7 @@ export function ScoreboardPage() { let alive = true; const load = () => fetch('/api/scoreboard').then((r) => r.json()).then((d: ScoreboardData) => alive && setData(d)).catch(() => {}); void load(); - const id = setInterval(load, 60_000); + const id = setInterval(load, 30_000); return () => { alive = false; clearInterval(id); }; }, []);