Much fresher live data: 8s in-play polling, live-TTL summary fetches

The live staleness bug: liveTick refetched ESPN summaries every 20s but
the cache TTL was 5 minutes, so in-play stats/timeline barely moved.
Summaries now take a live flag (15s TTL in play), the scoreboard TTL
drops 20s->5s, and polling gets three tiers (8s in-play / 20s near
kickoff / 10m idle) via a pure, tested cadence module. The
football-data fallback is throttled to one attempt per 15s so the 8s
cadence can never breach its 10 req/min cap. Enrichment runs every 5m
(was 20m); client refetch intervals tighten to 10s (live match page)
and 30s (scoreboard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 23:18:38 +02:00
parent dda8e1ae42
commit a3f529746f
7 changed files with 76 additions and 11 deletions
+39
View File
@@ -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);
});
});
+18
View File
@@ -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;
}
+5 -3
View File
@@ -19,15 +19,17 @@ export type { EspnMatch, EspnSummary } from './espnNormalize';
export async function fetchEspnScoreboard(dateYYYYMMDD?: string): Promise<EspnMatch[]> {
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<EspnSummary> {
// `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<EspnSummary> {
const raw = await cachedFetch<RawSummary>(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);
+7 -6
View File
@@ -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<void> => {
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 => {
+5
View File
@@ -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,