From 836b8c73d5b4d46821786432f667948e383a385f Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 29 Aug 2026 14:42:10 -0400 Subject: [PATCH] MLB odds cache is keyed on the baseball date, not the UTC date A cache entry answers "what is the market for THIS SLATE". For MLB the slate is a BASEBALL DATE in America/New_York -- the same date gameBinder, retentionService, ledgerService and read_natural_key all use. The key was built from the UTC calendar date, so between 00:00Z and Eastern midnight the key advanced while the slate did not: 2026-08-29T01:03Z = 2026-08-28 21:03 ET slate date 2026-08-28, key looked up odds:mlb:2026-08-29 A value written earlier that evening under odds:mlb:2026-08-28 was then unreachable -- not expired, ADDRESSED WRONG. CAUSAL HONESTY: this is NOT retroactively the cause of the failed 01:03Z canary. That run also used an 84-minute-old observation whose cache had passed its 1h TTL. Two independent reasons; the date defect is real but not proven counterfactual. MLB ONLY. Every other sport keeps the UTC basis -- their date semantics are unproven here and rekeying a cache they already write and read consistently would invalidate live entries for no demonstrated defect. Symmetry is structural, not conventional: the three readers that built the key inline now ask `oddsService.getCacheKey(sport)`, so writer and readers cannot diverge. The ET date comes from `scheduleService.gameDateET` -- the repository's own Intl/America\/New_York primitive, now exported -- so DST is the zone database's business and never offset arithmetic. An unresolvable clock REFUSES rather than falling back to the other basis. TTL truth is kept separate: a correctly addressed but expired entry still misses, and CACHE_TTL is unchanged at 3600. Provider priority, quota policy, retries, EARLY_RETURN_ODDS_ERROR semantics, lineage lookup, game-date repair, canonical participant and intraday belief integrity are all untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8 --- src/routes/bookComparison.js | 4 +- src/services/contentTemplateService.js | 5 +- src/services/oddsService.js | 50 ++++++- src/services/scheduleService.js | 17 ++- tests/unit/mlbOddsCacheDate.test.js | 182 +++++++++++++++++++++++++ 5 files changed, 246 insertions(+), 12 deletions(-) create mode 100644 tests/unit/mlbOddsCacheDate.test.js diff --git a/src/routes/bookComparison.js b/src/routes/bookComparison.js index c8611b6..b00e241 100644 --- a/src/routes/bookComparison.js +++ b/src/routes/bookComparison.js @@ -38,9 +38,9 @@ async function readGroupedProps(sport) { const store = await cacheGet(`bookprices:${sport}`); if (store && Array.isArray(store.props) && store.props.length) return store.props; - const utcDate = new Date().toISOString().split('T')[0]; + // The writer owns the key: MLB is keyed on its ET baseball date. const cache = - (await cacheGet(`odds:${sport}:${utcDate}`)) ?? + (await cacheGet(require('../services/oddsService').getCacheKey(sport))) ?? (await cacheGet(`odds:${sport}`)); const flat = cache && Array.isArray(cache.props) ? cache.props : []; const by = new Map(); diff --git a/src/services/contentTemplateService.js b/src/services/contentTemplateService.js index 3d8abc2..4f79b03 100644 --- a/src/services/contentTemplateService.js +++ b/src/services/contentTemplateService.js @@ -370,8 +370,9 @@ function defaultCollectors() { }, getMovers: (sport) => lineSnapshotService.getBiggestMovers(sport, { limit: 10 }), getBestLines: async (sport) => { - const utc = new Date().toISOString().split('T')[0]; - const cache = (await cacheGet(`odds:${sport}:${utc}`)) ?? (await cacheGet(`odds:${sport}`)); + // The writer owns the key: MLB is keyed on its ET baseball date. + const key = require('./oddsService').getCacheKey(sport); + const cache = (await cacheGet(key)) ?? (await cacheGet(`odds:${sport}`)); const props = Array.isArray(cache?.props) ? cache.props : []; return bookComparisonService.bestLines(props, { limit: 10 }); }, diff --git a/src/services/oddsService.js b/src/services/oddsService.js index d9a51f1..77fb4ce 100644 --- a/src/services/oddsService.js +++ b/src/services/oddsService.js @@ -199,9 +199,52 @@ function getMarketsForSport(sport) { const BOOKMAKERS = 'draftkings,fanduel,betmgm,caesars,fanatics,bet365,hardrockbet,pointsbet,betrivers'; -function getCacheKey(sport) { - const now = new Date(); - const date = now.toISOString().split('T')[0]; // UTC date +/** + * ── CACHE DATE BASIS, PER SPORT ────────────────────────────────────────── + * A cache entry answers "what is the market for THIS SLATE". For MLB the slate + * is a BASEBALL DATE in America/New_York — that is the date `gameBinder`, + * `retentionService`, `ledgerService` and the natural key all use. The cache + * key was built from the UTC calendar date instead, so between 00:00Z and + * Eastern midnight (04:00Z under EDT, 05:00Z under EST) the key advanced while + * the slate did not: + * + * 2026-08-29T01:03Z = 2026-08-28 21:03 ET + * slate date 2026-08-28 key looked up odds:mlb:2026-08-29 + * + * A value written earlier that evening under `odds:mlb:2026-08-28` was then + * unreachable — not expired, ADDRESSED WRONG. That is a cache IDENTITY defect + * and it is repaired here for MLB only. + * + * DELIBERATELY NOT A MULTI-SPORT CONVERSION. Every other sport keeps the UTC + * basis: their date semantics are UNPROVEN here, and changing a key they are + * already writing and reading consistently would invalidate live caches for no + * demonstrated defect. Classification is recorded in the tranche audit. + * + * The ET date comes from `scheduleService.gameDateET` — the repository's own + * `Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' })` primitive, + * so DST is handled by the zone database and never by offset arithmetic. + * Required lazily: `oddsService` is loaded early and this keeps the edge + * runtime-only. + * + * @param {string} sport + * @param {Date} [now] injectable clock — deterministic midnight/DST fixtures + */ +const ET_DATE_SPORTS = Object.freeze(['mlb']); + +function getCacheKey(sport, now = new Date()) { + const sp = String(sport || '').toLowerCase(); + if (ET_DATE_SPORTS.includes(sp)) { + const { gameDateET } = require('./scheduleService'); + const stamp = now instanceof Date + ? (Number.isNaN(now.getTime()) ? null : now.toISOString()) + : now; + const et = stamp ? gameDateET(stamp) : null; + if (et) return `odds:${sport}:${et}`; + // An unparseable clock must not silently fall back to a DIFFERENT date + // basis for the same sport — that is the very asymmetry being repaired. + throw new TypeError(`getCacheKey: could not resolve an ET date for ${sport}`); + } + const date = (now instanceof Date ? now : new Date(now)).toISOString().split('T')[0]; // UTC date return `odds:${sport}:${date}`; } @@ -565,6 +608,7 @@ module.exports = { fetchEventsFromApi, fetchEventOddsFromApi, getCacheKey, + ET_DATE_SPORTS, SPORT_KEYS, SOCCER_SPORT_KEYS, // Wave 2A — futures/outrights keys (separate budget; futuresService only). diff --git a/src/services/scheduleService.js b/src/services/scheduleService.js index f7a6c03..a5d557f 100644 --- a/src/services/scheduleService.js +++ b/src/services/scheduleService.js @@ -166,13 +166,16 @@ async function enrichFlags(sport, date, games) { if (!Array.isArray(games) || games.length === 0) return games; const ymd = String(date).replace(/-/g, ''); - // odds-api props cache — oddsService writes `odds:{sport}:{utcDate}` - // as `{ updated_at, props, spreads }`. The slate `date` is ET, so try - // the ET key first then the UTC key (they differ only past midnight). - const utcDate = new Date().toISOString().split('T')[0]; + // Props cache — `oddsService.getCacheKey(sport)` owns the key. For MLB that + // is now the ET BASEBALL DATE; every other sport is still the UTC date. The + // requested slate `date` is tried FIRST because it is more specific than + // "today" (it can be a future or past slate), then the writer's own key. + // Lazy require: the key belongs to the writer, and asking it removes any + // chance of a reader inventing a different date basis. + const writerKey = require('./oddsService').getCacheKey(sport); const oddsCache = (await cacheGet(`odds:${sport}:${date}`)) ?? - (await cacheGet(`odds:${sport}:${utcDate}`)) ?? + (await cacheGet(writerKey)) ?? (await cacheGet(`odds:${sport}`)); const hasOdds = hasPropsData(oddsCache); @@ -261,6 +264,10 @@ module.exports = { getSchedule, enrichFlags, todayET, + // The ET calendar date of an INSTANT. `todayET()` is this applied to now; + // exporting it lets the odds cache key be derived for an injected clock + // without a sixteenth copy of the America/New_York formatter. + gameDateET, getGameSummary, __internals: { normalizeEvent, fetchScheduleFromEspn, hasPropsData, hasLinesData, ESPN_SPORT_PATHS }, }; diff --git a/tests/unit/mlbOddsCacheDate.test.js b/tests/unit/mlbOddsCacheDate.test.js new file mode 100644 index 0000000..bc659f3 --- /dev/null +++ b/tests/unit/mlbOddsCacheDate.test.js @@ -0,0 +1,182 @@ +// MLB ODDS CACHE DATE CONTRACT. +// +// A cache entry answers "what is the market for THIS SLATE". For MLB the slate +// is a BASEBALL DATE in America/New_York -- the same date `gameBinder`, +// `retentionService`, `ledgerService` and `read_natural_key` all use. The key +// was built from the UTC calendar date, so between 00:00Z and Eastern midnight +// the key advanced while the slate did not, and a value written earlier that +// evening became unreachable: not expired, ADDRESSED WRONG. +// +// TTL truth is deliberately kept SEPARATE. A correctly keyed but expired entry +// must still miss. + +const odds = require('../../src/services/oddsService'); +const schedule = require('../../src/services/scheduleService'); + +const at = (iso) => new Date(iso); + +describe('MLB is keyed on the ET baseball date', () => { + test('the UTC-midnight window: the key must not advance before Eastern midnight', () => { + // EDT: Eastern midnight is 04:00Z. + expect(odds.getCacheKey('mlb', at('2026-08-28T23:59:59Z'))).toBe('odds:mlb:2026-08-28'); + expect(odds.getCacheKey('mlb', at('2026-08-29T00:00:00Z'))).toBe('odds:mlb:2026-08-28'); + // The exact instant of the failed canary. + expect(odds.getCacheKey('mlb', at('2026-08-29T01:03:00Z'))).toBe('odds:mlb:2026-08-28'); + expect(odds.getCacheKey('mlb', at('2026-08-29T03:59:59Z'))).toBe('odds:mlb:2026-08-28'); + // Eastern midnight: NOW it advances. + expect(odds.getCacheKey('mlb', at('2026-08-29T04:00:00Z'))).toBe('odds:mlb:2026-08-29'); + }); + + test('EST: Eastern midnight is 05:00Z, and nothing hardcodes an offset', () => { + // January -- standard time. A fixed -04:00 would roll an hour early here. + expect(odds.getCacheKey('mlb', at('2026-01-15T23:59:59Z'))).toBe('odds:mlb:2026-01-15'); + expect(odds.getCacheKey('mlb', at('2026-01-16T00:00:00Z'))).toBe('odds:mlb:2026-01-15'); + expect(odds.getCacheKey('mlb', at('2026-01-16T04:00:00Z'))).toBe('odds:mlb:2026-01-15'); + expect(odds.getCacheKey('mlb', at('2026-01-16T04:59:59Z'))).toBe('odds:mlb:2026-01-15'); + expect(odds.getCacheKey('mlb', at('2026-01-16T05:00:00Z'))).toBe('odds:mlb:2026-01-16'); + }); + + test('the DST transitions themselves, read from the zone database', () => { + // Spring forward 2026-03-08 (EST -> EDT). The night BEFORE still rolls at 05:00Z. + expect(odds.getCacheKey('mlb', at('2026-03-08T04:59:59Z'))).toBe('odds:mlb:2026-03-07'); + expect(odds.getCacheKey('mlb', at('2026-03-08T05:00:00Z'))).toBe('odds:mlb:2026-03-08'); + // The night AFTER is EDT, so it rolls at 04:00Z. + expect(odds.getCacheKey('mlb', at('2026-03-09T03:59:59Z'))).toBe('odds:mlb:2026-03-08'); + expect(odds.getCacheKey('mlb', at('2026-03-09T04:00:00Z'))).toBe('odds:mlb:2026-03-09'); + // Fall back 2026-11-01 (EDT -> EST). + expect(odds.getCacheKey('mlb', at('2026-11-01T03:59:59Z'))).toBe('odds:mlb:2026-10-31'); + expect(odds.getCacheKey('mlb', at('2026-11-01T04:00:00Z'))).toBe('odds:mlb:2026-11-01'); + expect(odds.getCacheKey('mlb', at('2026-11-02T04:59:59Z'))).toBe('odds:mlb:2026-11-01'); + expect(odds.getCacheKey('mlb', at('2026-11-02T05:00:00Z'))).toBe('odds:mlb:2026-11-02'); + }); + + test('the ET date comes from the repository primitive, not offset arithmetic', () => { + // Same instant, same answer, through the schedule module's own helper. + for (const iso of ['2026-08-29T01:03:00Z', '2026-01-16T04:30:00Z', '2026-03-08T06:00:00Z']) { + expect(odds.getCacheKey('mlb', at(iso))).toBe(`odds:mlb:${schedule.gameDateET(iso)}`); + } + }); + + test('an unresolvable clock REFUSES rather than silently using the other basis', () => { + expect(() => odds.getCacheKey('mlb', new Date('not-a-date'))).toThrow(/could not resolve an ET date/); + }); +}); + +describe('no other sport changed', () => { + test('every non-MLB sport keeps the UTC basis, including across Eastern midnight', () => { + for (const sp of ['nba', 'wnba', 'nfl', 'nhl', 'soccer', 'ncaab']) { + expect(odds.getCacheKey(sp, at('2026-08-29T01:03:00Z'))).toBe(`odds:${sp}:2026-08-29`); + expect(odds.getCacheKey(sp, at('2026-08-28T23:59:59Z'))).toBe(`odds:${sp}:2026-08-28`); + } + expect(odds.ET_DATE_SPORTS).toEqual(['mlb']); + }); +}); + +describe('writer / reader symmetry', () => { + // The defect class is an ET writer with a UTC reader (or the reverse). The + // only durable guarantee is that BOTH ask the same function, so the readers + // are asserted to call it rather than to construct a key of their own. + const fs = require('fs'); + const path = require('path'); + const strip = (f) => fs.readFileSync(path.join(__dirname, '../../', f), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + + test('no reader builds an odds cache key from its own date', () => { + for (const f of ['src/services/contentTemplateService.js', 'src/routes/bookComparison.js', + 'src/services/scheduleService.js']) { + const src = strip(f); + expect(src).toMatch(/getCacheKey\(sport\)/); + // The hand-rolled `odds:${sport}:${someLocalDate}` shape must be gone. + expect(src).not.toMatch(/`odds:\$\{sport\}:\$\{utc/); + } + }); + + test('the writer and every reader resolve the SAME key for one instant', () => { + const now = at('2026-08-29T01:03:00Z'); + const writer = odds.getCacheKey('mlb', now); + expect(writer).toBe('odds:mlb:2026-08-28'); + // Readers call getCacheKey with no clock, so prove the no-clock default is + // the same function of "now" rather than a second implementation. + expect(odds.getCacheKey('mlb')).toBe(`odds:mlb:${schedule.todayET()}`); + }); +}); + +describe('TTL truth is independent of key truth', () => { + // A correctly addressed entry that has expired must still miss. Repairing an + // address must never be allowed to look like extending freshness. + const makeRedis = (store) => ({ + get: async (k) => (Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null), + }); + + test('correct key + live entry -> HIT; expired -> MISS; wrong date -> MISS', async () => { + const key = odds.getCacheKey('mlb', at('2026-08-29T01:03:00Z')); + const live = makeRedis({ [key]: JSON.stringify({ updated_at: 'x', props: [{ p: 1 }] }) }); + expect(await live.get(key)).toBeTruthy(); + + // Expiry is Redis's, not ours: an absent key is a miss however correct it is. + const expired = makeRedis({}); + expect(await expired.get(key)).toBeNull(); + + // Yesterday's entry does not answer today's lookup. + const yesterday = makeRedis({ 'odds:mlb:2026-08-27': JSON.stringify({ props: [{ p: 1 }] }) }); + expect(await yesterday.get(key)).toBeNull(); + }); + + test('CACHE_TTL is untouched by this repair', () => { + expect(odds.CACHE_TTL).toBe(3600); + }); +}); + +describe('cross-baseball-date isolation', () => { + test('date N cache cannot satisfy date N+1 once Eastern midnight passes', () => { + const beforeMidnight = odds.getCacheKey('mlb', at('2026-08-29T03:59:59Z')); + const afterMidnight = odds.getCacheKey('mlb', at('2026-08-29T04:00:00Z')); + expect(beforeMidnight).toBe('odds:mlb:2026-08-28'); + expect(afterMidnight).toBe('odds:mlb:2026-08-29'); + expect(beforeMidnight).not.toBe(afterMidnight); + }); + + test('the ROOT-CAUSE fixture: the value written that evening is now reachable', () => { + // 2026-08-28 21:30 ET, inside TTL, written for baseball date 2026-08-28. + const writeKey = odds.getCacheKey('mlb', at('2026-08-29T01:30:00Z')); + // The 01:03Z-style reader, 27 minutes later, same baseball date. + const readKey = odds.getCacheKey('mlb', at('2026-08-29T01:57:00Z')); + expect(writeKey).toBe('odds:mlb:2026-08-28'); + expect(readKey).toBe(writeKey); // POST-REPAIR: same address. + // PRE-REPAIR both sides used the UTC date, which had already rolled. + const preRepair = at('2026-08-29T01:57:00Z').toISOString().split('T')[0]; + expect(`odds:mlb:${preRepair}`).toBe('odds:mlb:2026-08-29'); + expect(`odds:mlb:${preRepair}`).not.toBe(writeKey); + }); +}); + +describe('preservation: this is a cache IDENTITY repair only', () => { + const fs = require('fs'); + const path = require('path'); + const src = (f) => fs.readFileSync(path.join(__dirname, '../../', f), 'utf8'); + + test('provider priority, quota and retry policy are untouched', () => { + const s = src('src/services/oddsService.js'); + expect(s).toContain("require('./adapters/proplineAdapter')"); + expect(s).toContain("require('./quotaTracker')"); + expect(odds.CACHE_TTL).toBe(3600); + }); + + test('the frozen repairs are still present', () => { + expect(src('src/services/gameBinder.js')).toContain('if (!p.game_date) p.game_date = etFast;'); + expect(src('src/services/event/eventIdentity.js')).toMatch(/ids\.size !== 1/); + expect(src('src/services/intradayRefreshService.js')).toContain('BELIEF_FIELDS'); + // Lineage lookup design is frozen in this tranche. + const ret = src('src/services/retentionService.js'); + expect(ret).toContain('familyScopesFrom'); + expect(ret).toContain('isValidLineageAction'); + expect(ret).toContain("label: 'attachLineage.fetchExisting'"); + }); + + test('intraday still resolves its odds through the same acquisition seam', () => { + const s = src('src/services/intradayRefreshService.js'); + expect(s).toContain("require('./oddsService').getOdds"); + // No belief mutation was reintroduced. + expect(s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')).not.toMatch(/applyRevision/); + }); +});