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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user