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:
Kev
2026-07-10 17:00:29 -04:00
parent d09a06c054
commit 2ae8a5697e
21 changed files with 1607 additions and 11 deletions
+48 -1
View File
@@ -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 };