Heartbeat honesty: SYNC badge reads refreshed_at, not grade-lock updated_at
The "SIGNAL LIVE vs STALE 8h" contradiction was a field mismatch, not a dead pipeline. updated_at is the grade-LOCK time (advances only on a full snapshot, 5×/day — grades never change in-game, so it is intentionally stable). The SYNC badge measured the 20-min intraday cadence (expected_interval_s=1200) against that 5×/day field → structurally guaranteed STALE between slots even when intraday refreshes lines perfectly. - snapshotService: full snapshot now seeds refreshed_at at lock time - intradayRefresh already bumps refreshed_at every ~20 min (unchanged) - /api/snapshot/summary + GET /:sport now expose refreshed_at (was written to Redis but never serialized → no public liveness signal existed) - LiveLayer SYNC badge measures freshness from refreshed_at (fallback updated_at) Exposing refreshed_at also gives a public heartbeat probe: it advances every intraday slot, so pipeline liveness is verifiable without container logs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+14
-3
@@ -50,18 +50,29 @@ router.get('/summary', async (req, res) => {
|
|||||||
const reads = await Promise.all(SUMMARY_SPORTS.map(async (sp) => {
|
const reads = await Promise.all(SUMMARY_SPORTS.map(async (sp) => {
|
||||||
const snap = await cacheGet(`snapshot:${sp}:latest`);
|
const snap = await cacheGet(`snapshot:${sp}:latest`);
|
||||||
if (snap && Array.isArray(snap.grades)) {
|
if (snap && Array.isArray(snap.grades)) {
|
||||||
return { sport: sp, graded: snap.grades.length, updated_at: snap.updated_at || null };
|
return {
|
||||||
|
sport: sp,
|
||||||
|
graded: snap.grades.length,
|
||||||
|
updated_at: snap.updated_at || null,
|
||||||
|
refreshed_at: snap.refreshed_at || snap.updated_at || null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const env = await cacheGet(`grades:${sp}`);
|
const env = await cacheGet(`grades:${sp}`);
|
||||||
return {
|
return {
|
||||||
sport: sp,
|
sport: sp,
|
||||||
graded: env && Array.isArray(env.grades) ? env.grades.length : 0,
|
graded: env && Array.isArray(env.grades) ? env.grades.length : 0,
|
||||||
updated_at: (env && env.updated_at) || null,
|
updated_at: (env && env.updated_at) || null,
|
||||||
|
refreshed_at: (env && (env.refreshed_at || env.updated_at)) || null,
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
const graded = reads.reduce((n, r) => n + r.graded, 0);
|
const graded = reads.reduce((n, r) => n + r.graded, 0);
|
||||||
// ISO timestamps sort lexicographically — the max is the latest run.
|
// ISO timestamps sort lexicographically — the max is the latest run.
|
||||||
|
// `updated_at` = last grade LOCK (5×/day, intentionally stable). `refreshed_at`
|
||||||
|
// = last freshness heartbeat (grade lock OR intraday refresh, every ~20 min).
|
||||||
|
// The SYNC badge measures against refreshed_at; updated_at is exposed so the
|
||||||
|
// UI can distinguish "grades locked at X" from "lines synced at Y".
|
||||||
const updated_at = reads.map((r) => r.updated_at).filter(Boolean).sort().pop() || null;
|
const updated_at = reads.map((r) => r.updated_at).filter(Boolean).sort().pop() || null;
|
||||||
|
const refreshed_at = reads.map((r) => r.refreshed_at).filter(Boolean).sort().pop() || updated_at;
|
||||||
const sports = {};
|
const sports = {};
|
||||||
for (const r of reads) sports[r.sport] = r.graded;
|
for (const r of reads) sports[r.sport] = r.graded;
|
||||||
// Session 58 (Task 5) — the SYNC badge thresholds key off the pipeline's
|
// Session 58 (Task 5) — the SYNC badge thresholds key off the pipeline's
|
||||||
@@ -72,7 +83,7 @@ router.get('/summary', async (req, res) => {
|
|||||||
const expected_interval_s = Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) > 0
|
const expected_interval_s = Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) > 0
|
||||||
? Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) : 18000;
|
? Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) : 18000;
|
||||||
res.set('Cache-Control', 'public, max-age=30');
|
res.set('Cache-Control', 'public, max-age=30');
|
||||||
return res.json({ graded, updated_at, sports, expected_interval_s });
|
return res.json({ graded, updated_at, refreshed_at, sports, expected_interval_s });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[snapshot/summary]', err.message);
|
console.error('[snapshot/summary]', err.message);
|
||||||
return res.status(200).json({ graded: 0, updated_at: null, sports: {} });
|
return res.status(200).json({ graded: 0, updated_at: null, sports: {} });
|
||||||
@@ -92,7 +103,7 @@ router.get('/:sport', async (req, res) => {
|
|||||||
const enrich = (grades) => attachLast10Dots(attachOutcomes(grades, idx), roster, sport);
|
const enrich = (grades) => attachLast10Dots(attachOutcomes(grades, idx), roster, sport);
|
||||||
if (snap && Array.isArray(snap.grades)) {
|
if (snap && Array.isArray(snap.grades)) {
|
||||||
res.set('Cache-Control', 'public, max-age=30');
|
res.set('Cache-Control', 'public, max-age=30');
|
||||||
return res.json({ sport, updated_at: snap.updated_at, grades: enrich(snap.grades), deltas: snap.deltas || [] });
|
return res.json({ sport, updated_at: snap.updated_at, refreshed_at: snap.refreshed_at || snap.updated_at || null, grades: enrich(snap.grades), deltas: snap.deltas || [] });
|
||||||
}
|
}
|
||||||
// Fallback: the grades envelope (no deltas yet).
|
// Fallback: the grades envelope (no deltas yet).
|
||||||
const env = await cacheGet(`grades:${sport}`);
|
const env = await cacheGet(`grades:${sport}`);
|
||||||
|
|||||||
@@ -399,7 +399,13 @@ async function runSnapshot(sport, opts = {}) {
|
|||||||
|
|
||||||
// Lock: previous = old latest, latest = new, grades = enriched.
|
// Lock: previous = old latest, latest = new, grades = enriched.
|
||||||
if (prev) await deps.cacheSet(`snapshot:${sp}:previous`, prev, SNAP_TTL);
|
if (prev) await deps.cacheSet(`snapshot:${sp}:previous`, prev, SNAP_TTL);
|
||||||
const snapshot = { sport: sp, updated_at: ts, grades: enriched, deltas, gradeCount: enriched.length };
|
// `updated_at` = grade-LOCK time (advances only on a full snapshot, 5×/day —
|
||||||
|
// grades never change in-game, so this is intentionally stable). `refreshed_at`
|
||||||
|
// = the freshness heartbeat: seeded here at lock time, then bumped every
|
||||||
|
// intraday refresh (intradayRefreshService). The SYNC badge keys off
|
||||||
|
// refreshed_at — measuring the 20-min cadence against the grade-lock field is
|
||||||
|
// what produced the "SIGNAL LIVE vs STALE 8h" contradiction.
|
||||||
|
const snapshot = { sport: sp, updated_at: ts, refreshed_at: ts, grades: enriched, deltas, gradeCount: enriched.length };
|
||||||
await deps.cacheSet(`snapshot:${sp}:latest`, snapshot, SNAP_TTL);
|
await deps.cacheSet(`snapshot:${sp}:latest`, snapshot, SNAP_TTL);
|
||||||
// Session 59 — grades:{sport} must outlive the gap between cron runs (up to
|
// Session 59 — grades:{sport} must outlive the gap between cron runs (up to
|
||||||
// 5h) or team rosters / Explore / leaders go dark mid-day. SNAP_TTL (6h),
|
// 5h) or team rosters / Explore / leaders go dark mid-day. SNAP_TTL (6h),
|
||||||
|
|||||||
@@ -43,6 +43,28 @@ describe('GET /api/snapshot/summary', () => {
|
|||||||
expect(res.body.sports.nba).toBe(0); // off-season → honest zero
|
expect(res.body.sports.nba).toBe(0); // off-season → honest zero
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('refreshed_at is the freshness heartbeat, distinct from the grade-lock updated_at', async () => {
|
||||||
|
// updated_at = grade LOCK (5×/day, stable). refreshed_at = last intraday
|
||||||
|
// line refresh (~20 min). The SYNC badge measures against refreshed_at —
|
||||||
|
// measuring the 20-min cadence against the lock field caused "STALE 8h".
|
||||||
|
store['snapshot:mlb:latest'] = {
|
||||||
|
updated_at: '2026-07-10T18:00:00.000Z', // graded at 2pm ET
|
||||||
|
refreshed_at: '2026-07-10T21:40:00.000Z', // lines refreshed at 5:40pm ET
|
||||||
|
grades: [{ player: 'A' }],
|
||||||
|
};
|
||||||
|
const res = await request(mountApp()).get('/api/snapshot/summary');
|
||||||
|
expect(res.body.updated_at).toBe('2026-07-10T18:00:00.000Z');
|
||||||
|
expect(res.body.refreshed_at).toBe('2026-07-10T21:40:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshed_at falls back to updated_at when a snapshot predates the field', async () => {
|
||||||
|
// Old snapshots written before this fix have no refreshed_at — the freshness
|
||||||
|
// signal must still resolve (never null while a grade lock exists).
|
||||||
|
store['snapshot:mlb:latest'] = { updated_at: '2026-07-10T18:00:00.000Z', grades: [{ player: 'A' }] };
|
||||||
|
const res = await request(mountApp()).get('/api/snapshot/summary');
|
||||||
|
expect(res.body.refreshed_at).toBe('2026-07-10T18:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
test('falls back to the grades:{sport} envelope when no snapshot exists', async () => {
|
test('falls back to the grades:{sport} envelope when no snapshot exists', async () => {
|
||||||
store['grades:mlb'] = { updated_at: '2026-07-10T16:00:00.000Z', grades: [{ player: 'A' }, { player: 'B' }] };
|
store['grades:mlb'] = { updated_at: '2026-07-10T16:00:00.000Z', grades: [{ player: 'A' }, { player: 'B' }] };
|
||||||
const res = await request(mountApp()).get('/api/snapshot/summary');
|
const res = await request(mountApp()).get('/api/snapshot/summary');
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ const EKG = 'M0 12 H10 L13 4 L16 20 L19 12 H30 L33 9 L36 15 L39 12 H50';
|
|||||||
|
|
||||||
interface SnapshotSummary {
|
interface SnapshotSummary {
|
||||||
graded: number;
|
graded: number;
|
||||||
|
/** Grade-LOCK time — advances only on a full snapshot (5×/day). Intentionally
|
||||||
|
* stable (grades never change in-game); NOT the freshness signal. */
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
|
/** Freshness heartbeat — grade lock OR intraday line refresh (~every 20 min).
|
||||||
|
* The SYNC badge keys off THIS, not updated_at. */
|
||||||
|
refreshed_at: string | null;
|
||||||
/** The pipeline's expected cadence (s) — SYNC thresholds key off this. */
|
/** The pipeline's expected cadence (s) — SYNC thresholds key off this. */
|
||||||
expected_interval_s?: number;
|
expected_interval_s?: number;
|
||||||
}
|
}
|
||||||
@@ -84,6 +89,7 @@ export function HeartbeatBar() {
|
|||||||
setSummary({
|
setSummary({
|
||||||
graded: data.graded,
|
graded: data.graded,
|
||||||
updated_at: data.updated_at ?? null,
|
updated_at: data.updated_at ?? null,
|
||||||
|
refreshed_at: data.refreshed_at ?? data.updated_at ?? null,
|
||||||
expected_interval_s: Number(data.expected_interval_s) > 0 ? Number(data.expected_interval_s) : undefined,
|
expected_interval_s: Number(data.expected_interval_s) > 0 ? Number(data.expected_interval_s) : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -97,7 +103,11 @@ export function HeartbeatBar() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
void live.tick; // subscription drives the 1s clock re-render
|
void live.tick; // subscription drives the 1s clock re-render
|
||||||
const syncedAt = summary?.updated_at ? Date.parse(summary.updated_at) : NaN;
|
// Freshness is measured from the intraday heartbeat (refreshed_at), NOT the
|
||||||
|
// grade-lock time (updated_at). Measuring the 20-min cadence against the
|
||||||
|
// 5×/day lock field is what made a live pipeline read "STALE 8h".
|
||||||
|
const freshAt = summary?.refreshed_at ?? summary?.updated_at ?? null;
|
||||||
|
const syncedAt = freshAt ? Date.parse(freshAt) : NaN;
|
||||||
const elapsed = Number.isFinite(syncedAt) ? Date.now() - syncedAt : null;
|
const elapsed = Number.isFinite(syncedAt) ? Date.now() - syncedAt : null;
|
||||||
const expectedMs = (summary?.expected_interval_s ?? DEFAULT_EXPECTED_S) * 1000;
|
const expectedMs = (summary?.expected_interval_s ?? DEFAULT_EXPECTED_S) * 1000;
|
||||||
const tier: 'normal' | 'amber' | 'stale' =
|
const tier: 'normal' | 'amber' | 'stale' =
|
||||||
|
|||||||
Reference in New Issue
Block a user