Session 56: Full audit — PropLine + boxscore + pipeline + sport coverage (2289 tests)
Research (verified against live MLB Stats / ESPN / The Odds APIs): - specs/propline-audit.md — every stat_type mapped against our 4-layer pipeline; real MLB boxscore fields; sport coverage status; pipeline gap analysis. - specs/vyndr-roadmap.md — priority-ordered Sessions 57–64 + coverage targets. - scripts/propline-audit.js + specs/audit-data/ (raw capture). Headline bug: oddsNormalizer mapped batter_rbis → 'rbis' while the whole grade/feature/outcome chain keys on 'rbi' — every PropLine RBI prop silently failed to grade AND settle. Fixed (+ regression test). Phase 4 — wired missing MLB stats end-to-end: - PropLine MLB markets 6 → 12 (+runs, walks, doubles, earned_runs, hits_allowed, outs — same request, no extra quota). - doubles/outs/triples added to featureCache + outcomeService MLB_LOG_FIELD and all three grade whitelists (analyze/scan/validation.py). Phase 6 — pipeline resilience: - opsNotify.js: ntfy alerts (never throws, test-disabled). Snapshot success/ stale/failure alerts; retry-once on hard odds error (not on empty slate). - Missed-cron watchdog (mostRecentExpectedSlot/isSnapshotOverdue); status probe now returns `overdue`. Coverage truth: MLB is the only end-to-end-live sport; outcome settlement is MLB-only (WNBA/NBA/soccer never settle) — documented as the #1 roadmap gap. Backend 2276 → 2289 tests (+13). Web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,30 @@ const HOURS_UTC = (process.env.SNAPSHOT_HOURS_UTC || '14,19,22,1,3')
|
||||
.map((n) => parseInt(n, 10))
|
||||
.filter((n) => Number.isInteger(n) && n >= 0 && n <= 23);
|
||||
|
||||
// Session 56 — missed-cron detection (pure, testable).
|
||||
// The latest scheduled hour:00 (UTC) at or before `date`. null if none in 48h.
|
||||
function mostRecentExpectedSlot(date, hours = HOURS_UTC) {
|
||||
const d = new Date(date.getTime());
|
||||
d.setUTCMinutes(0, 0, 0);
|
||||
for (let i = 0; i < 48; i += 1) {
|
||||
if (hours.includes(d.getUTCHours())) return new Date(d.getTime());
|
||||
d.setUTCHours(d.getUTCHours() - 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// True when we're >graceMin past the most recent expected slot AND the last
|
||||
// recorded snapshot predates that slot (i.e. the run was missed). Never fires on
|
||||
// cold boot (no lastSnapshotIso) — we don't cry wolf before the first snapshot.
|
||||
function isSnapshotOverdue(lastSnapshotIso, now = new Date(), hours = HOURS_UTC, graceMin = 30) {
|
||||
const slot = mostRecentExpectedSlot(now, hours);
|
||||
if (!slot) return false;
|
||||
if (now.getTime() - slot.getTime() < graceMin * 60_000) return false;
|
||||
if (!lastSnapshotIso) return false;
|
||||
const last = new Date(lastSnapshotIso).getTime();
|
||||
return Number.isFinite(last) && last < slot.getTime();
|
||||
}
|
||||
|
||||
function startSnapshotScheduler(opts = {}) {
|
||||
if (process.env.SNAPSHOT_CRON !== '1') {
|
||||
// Session 52 — log the disarmed state so container logs make it unambiguous
|
||||
@@ -31,10 +55,33 @@ function startSnapshotScheduler(opts = {}) {
|
||||
// real (now-completed) results BEFORE grading the fresh slate, so the accuracy
|
||||
// record reflects yesterday's games each cycle.
|
||||
const settleAll = opts.settleAllOutcomes || require('./services/outcomeService').settleAllOutcomes;
|
||||
const notify = opts.notify || require('./utils/opsNotify').notify;
|
||||
const cacheGet = opts.cacheGet || require('./utils/redis').cacheGet;
|
||||
const now = opts.now || (() => new Date());
|
||||
let lastFiredSlot = null;
|
||||
let lastOverdueSlot = null;
|
||||
|
||||
// Session 56 — missed-cron watchdog. Runs every minute (independent of the
|
||||
// fire schedule): if a scheduled slot came and went without a snapshot, alert
|
||||
// ONCE per missed slot.
|
||||
const checkOverdue = async () => {
|
||||
try {
|
||||
const d = now();
|
||||
const slot = mostRecentExpectedSlot(d);
|
||||
if (!slot) return;
|
||||
const slotKey = slot.toISOString();
|
||||
if (slotKey === lastOverdueSlot) return; // already alerted for this slot
|
||||
const latest = await cacheGet('snapshot:mlb:latest');
|
||||
const lastTs = latest && latest.updated_at;
|
||||
if (isSnapshotOverdue(lastTs, d)) {
|
||||
lastOverdueSlot = slotKey;
|
||||
await notify(`⚠️ Snapshot OVERDUE — expected ${slot.getUTCHours()}:00 UTC, last was ${lastTs || 'never'}`, { title: 'VYNDR pipeline', priority: 'high', tags: ['warning'] });
|
||||
}
|
||||
} catch { /* watchdog must never throw */ }
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
await checkOverdue();
|
||||
const d = now();
|
||||
if (d.getUTCMinutes() !== 0) return;
|
||||
const h = d.getUTCHours();
|
||||
@@ -64,4 +111,4 @@ function startSnapshotScheduler(opts = {}) {
|
||||
return { interval, tick };
|
||||
}
|
||||
|
||||
module.exports = { startSnapshotScheduler, HOURS_UTC };
|
||||
module.exports = { startSnapshotScheduler, HOURS_UTC, mostRecentExpectedSlot, isSnapshotOverdue };
|
||||
|
||||
Reference in New Issue
Block a user