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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -19,15 +19,17 @@ export type { EspnMatch, EspnSummary } from './espnNormalize';
|
|||||||
export async function fetchEspnScoreboard(dateYYYYMMDD?: string): Promise<EspnMatch[]> {
|
export async function fetchEspnScoreboard(dateYYYYMMDD?: string): Promise<EspnMatch[]> {
|
||||||
const url = `${BASE}/scoreboard${dateYYYYMMDD ? `?dates=${dateYYYYMMDD}` : ''}`;
|
const url = `${BASE}/scoreboard${dateYYYYMMDD ? `?dates=${dateYYYYMMDD}` : ''}`;
|
||||||
const body = await cachedFetch<{ events?: EspnEvent[] }>(SOURCE, `espn:sb:${dateYYYYMMDD ?? 'now'}`, url, {
|
const body = await cachedFetch<{ events?: EspnEvent[] }>(SOURCE, `espn:sb:${dateYYYYMMDD ?? 'now'}`, url, {
|
||||||
ttl: 20_000,
|
ttl: 5_000,
|
||||||
minInterval: 3_000,
|
minInterval: 3_000,
|
||||||
});
|
});
|
||||||
return normalizeScoreboard(body);
|
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}`, {
|
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,
|
minInterval: 2_000,
|
||||||
});
|
});
|
||||||
return normalizeSummary(raw);
|
return normalizeSummary(raw);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { TournamentState, type LiveMatch, type Source } from '../tournament';
|
import { TournamentState, type LiveMatch, type Source } from '../tournament';
|
||||||
|
import { pollDelay, fallbackAllowed } from './cadence';
|
||||||
import { fetchEspnScoreboard, fetchEspnSummary } from './espn';
|
import { fetchEspnScoreboard, fetchEspnSummary } from './espn';
|
||||||
import { fetchEspnOdds } from './espnOdds';
|
import { fetchEspnOdds } from './espnOdds';
|
||||||
import { fetchFootballData } from '../footballData';
|
import { fetchFootballData } from '../footballData';
|
||||||
@@ -12,9 +13,7 @@ import type { Fixture } from '../../../src/lib/types';
|
|||||||
// source only means staler data.
|
// source only means staler data.
|
||||||
|
|
||||||
const LIVE_WINDOW_MS = 3 * 60 * 60 * 1000;
|
const LIVE_WINDOW_MS = 3 * 60 * 60 * 1000;
|
||||||
const POLL_LIVE_MS = 20_000;
|
const ENRICH_MS = 5 * 60_000;
|
||||||
const POLL_IDLE_MS = 10 * 60_000;
|
|
||||||
const ENRICH_MS = 20 * 60_000;
|
|
||||||
const ODDS_MS = 3 * 60 * 60 * 1000; // odds sweep cadence (line-movement history)
|
const ODDS_MS = 3 * 60 * 60 * 1000; // odds sweep cadence (line-movement history)
|
||||||
const ODDS_HORIZON_MS = 14 * 24 * 60 * 60 * 1000;
|
const ODDS_HORIZON_MS = 14 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
@@ -66,6 +65,7 @@ export function startScheduler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ---- live scoreboard ----
|
// ---- live scoreboard ----
|
||||||
|
let lastFdAttempt = 0; // throttles the fallback independently of tick cadence
|
||||||
const liveTick = async (): Promise<void> => {
|
const liveTick = async (): Promise<void> => {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
let matches: LiveMatch[] = [];
|
let matches: LiveMatch[] = [];
|
||||||
@@ -82,7 +82,8 @@ export function startScheduler(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logIngest('espn', 'scoreboard', false, Date.now() - t0, e instanceof Error ? e.message : String(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 */ }
|
try { matches = await fetchFootballData(token); source = 'football-data'; } catch { /* logged via health */ }
|
||||||
}
|
}
|
||||||
if (!matches.length && sofa) {
|
if (!matches.length && sofa) {
|
||||||
@@ -99,7 +100,7 @@ export function startScheduler(
|
|||||||
if (f.status !== 'live') continue;
|
if (f.status !== 'live') continue;
|
||||||
const eid = getSourceMap(f.num, 'espn');
|
const eid = getSourceMap(f.num, 'espn');
|
||||||
if (!eid) continue;
|
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 => {
|
const scheduleLive = (): void => {
|
||||||
if (stopped) return;
|
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);
|
liveTimer = setTimeout(async () => { await liveTick().catch(() => {}); scheduleLive(); }, delay);
|
||||||
};
|
};
|
||||||
const scheduleEnrich = (): void => {
|
const scheduleEnrich = (): void => {
|
||||||
|
|||||||
@@ -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). */
|
/** Directly set a fixture's score/status (used by the dev injection endpoint). */
|
||||||
applyScore(
|
applyScore(
|
||||||
num: number,
|
num: number,
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function MatchPreviewPage() {
|
|||||||
.then((d: MatchPreview) => alive && setPreview(d))
|
.then((d: MatchPreview) => alive && setPreview(d))
|
||||||
.catch(() => alive && setFailed(true));
|
.catch(() => alive && setFailed(true));
|
||||||
void load();
|
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); };
|
return () => { alive = false; clearInterval(id); };
|
||||||
}, [numParam]);
|
}, [numParam]);
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function ScoreboardPage() {
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
const load = () => fetch('/api/scoreboard').then((r) => r.json()).then((d: ScoreboardData) => alive && setData(d)).catch(() => {});
|
const load = () => fetch('/api/scoreboard').then((r) => r.json()).then((d: ScoreboardData) => alive && setData(d)).catch(() => {});
|
||||||
void load();
|
void load();
|
||||||
const id = setInterval(load, 60_000);
|
const id = setInterval(load, 30_000);
|
||||||
return () => { alive = false; clearInterval(id); };
|
return () => { alive = false; clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user