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[]> {
|
||||
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);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user