Session 22: Tracker-driven quota guard, configurable cache TTL (1hr default), opt-in odds prewarmer (1505 tests)
This commit is contained in:
+156
-1
@@ -4,7 +4,162 @@
|
||||
2026-06-12
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v21.0 — Every external HTTP call tracked + ntfy alerts (Session 21)
|
||||
SHIP BUILD v22.0 — Tracker-driven quota guard, env-configurable cache TTL, opt-in odds prewarmer (Session 22)
|
||||
|
||||
## Session 22 (2026-06-12) — SHIPPED
|
||||
|
||||
Plumbed the cache + quota machinery so the platform can survive a
|
||||
free-tier (500 credits/month) odds-api budget. Honest scope:
|
||||
Chrome Claude's diagnosis ("pollers write to one key, API reads
|
||||
from another") didn't hold up under trace — the keys it pointed
|
||||
at were internal sentinels in `cascadeService` and
|
||||
`lineMovementService`, not duplicate caches. No PM2 poller ever
|
||||
fed the odds cache. The actual root cause is that the cache is
|
||||
populated *on-demand* by `getOdds` itself, and when odds-api
|
||||
fails the cache stays empty.
|
||||
|
||||
After confirming the trace with the user, the agreed scope was:
|
||||
|
||||
1. Replace the legacy stale quota guard with Session 20's tracker
|
||||
2. Make the cache TTL env-configurable (default raised from 15min
|
||||
to 1h)
|
||||
3. Build an opt-in odds prewarmer script
|
||||
|
||||
### PHASE 1 — Trace (honest scope correction)
|
||||
|
||||
Grepped for `odds:players:*` and `odds:baseline_set:*` — both
|
||||
are written by `cascadeService.detectScratches` and
|
||||
`lineMovementService.processNewOdds` AFTER a successful
|
||||
`getOdds()` call, as internal sentinels for scratch detection
|
||||
and opening-line baseline capture respectively. Neither is a
|
||||
duplicate cache feed.
|
||||
|
||||
Documented in BUILD-STATE so future operators don't re-chase
|
||||
the same false lead.
|
||||
|
||||
### PHASE 3 — Tracker-driven quota guard
|
||||
|
||||
`src/services/oddsService.js#getOdds` previously checked
|
||||
`getQuotaRemaining(redis)` — a Redis hash that only the file
|
||||
itself updated, so it drifted (Chrome Claude observed 46 in the
|
||||
hash while reality was 7). The check is now delegated to
|
||||
Session 20's `quotaTracker.getQuotaStatus('odds-api')`, which:
|
||||
|
||||
- is synced from `x-requests-remaining` / `x-requests-used` on
|
||||
every successful odds-api call (via gateway.fetch's
|
||||
syncHeadersFrom hook)
|
||||
- BLOCKs at ≥95% (matches the WARN/BLOCK constants the
|
||||
dashboard surfaces)
|
||||
- fails OPEN when Redis is degraded so a Redis hiccup doesn't
|
||||
take down the platform
|
||||
|
||||
The 429 error now attaches `quotaStatus` to the thrown Error so
|
||||
operators inspecting the response can see the actual `used /
|
||||
limit / pct` that triggered the block.
|
||||
|
||||
Three new tests in `tests/unit/oddsService.test.js`:
|
||||
- 80% (WARN, not BLOCK) → call proceeds
|
||||
- 96% (BLOCK) → 429 thrown with `quotaStatus` attached
|
||||
- 95% (BLOCK boundary) → axios.get never invoked
|
||||
|
||||
The legacy `getQuotaRemaining` / `updateQuota` machinery stays
|
||||
exported for now — other call sites (the `/api/odds/*` route
|
||||
layer pulls `quota_remaining` straight out of the response
|
||||
envelope) still rely on the hash being populated. The hash is
|
||||
a redundant signal; the tracker is the decision.
|
||||
|
||||
### Env-configurable cache TTL
|
||||
|
||||
`oddsService.CACHE_TTL` is now resolved from
|
||||
`ODDS_CACHE_TTL_SECONDS` at module load, falling back to a new
|
||||
default of **3600 seconds (1 hour)** — up from the legacy 900s.
|
||||
|
||||
Rationale: each cache miss fans out to (1 + N) upstream calls,
|
||||
costing 5–10 credits per refresh. At 15-min TTL across 4 sports
|
||||
that's ~3,840 credits/day — an order of magnitude over the free
|
||||
tier's 500/month. At 1h TTL it's ~960/day — still over, but a
|
||||
factor of 4 closer. Operators on the free tier with many sports
|
||||
should bump to 7200 (2h) via Coolify.
|
||||
|
||||
Bounds-checked: rejects overrides <60 (would shred credits) and
|
||||
>86400 (would hold stale forever); both fall back to 3600.
|
||||
|
||||
`getConfiguredCacheTTL` exported for direct test coverage. Five
|
||||
new tests pin the parser.
|
||||
|
||||
### Opt-in odds prewarmer
|
||||
|
||||
`scripts/odds-prefetch.js` calls `getOdds(sport)` for each
|
||||
configured sport to warm the cache out-of-band. **Gated by
|
||||
`ODDS_PREWARM=1`** — the first thing main() does is check the
|
||||
flag and bail out with exit code 2 if unset. This is a
|
||||
hard safety: at the free tier the script would blow the
|
||||
monthly budget if run accidentally.
|
||||
|
||||
CLI:
|
||||
```
|
||||
ODDS_PREWARM=1 node scripts/odds-prefetch.js --sports=nba,mlb
|
||||
ODDS_PREWARM=1 node scripts/odds-prefetch.js --dry-run
|
||||
```
|
||||
|
||||
Returns a structured summary including credits spent (computed
|
||||
as the delta between pre-run and post-run tracker reads). The
|
||||
script bails the moment the tracker reports `allowed:false`
|
||||
mid-run, so subsequent sports don't add to the bleeding.
|
||||
|
||||
Module-exports `main` and `__internals.parseArgs` for testing.
|
||||
11 unit tests cover gating, dry-run, happy path, credit-delta
|
||||
calculation, mid-run block, and per-sport error isolation.
|
||||
|
||||
### PHASE 4 — Poller frequency review
|
||||
|
||||
Audit complete: the existing PM2 pollers (`poller.js` for
|
||||
NBA/WNBA/MLB) hit ESPN scoreboards — free, no quota. The 60s
|
||||
default is correct for ESPN. The soccer poller (`soccer.js`)
|
||||
already received quota-aware tick-skipping in Session 20.
|
||||
|
||||
No changes — the spec's "60s → 900s" change would have applied
|
||||
to a hypothetical odds-api poller that doesn't exist.
|
||||
|
||||
### Honest scope flags
|
||||
|
||||
- **The actual production 503 is NOT fully fixed by this
|
||||
session.** This session changes the *cost ceiling* (4x lower
|
||||
per-cache-miss) and the *quota check accuracy* (tracker, not
|
||||
drifting hash). It does NOT change the fundamental constraint
|
||||
that the free 500-credit/month tier cannot serve live props
|
||||
across 3+ sports continuously. The real fix is a tier upgrade
|
||||
or accepting longer cache (4h+).
|
||||
- The prewarmer is **deliberately not wired to cron or PM2**.
|
||||
When/if the account upgrades, the operator can schedule it
|
||||
manually. Auto-mounting it would silently spend credits.
|
||||
- The `getQuotaRemaining` legacy hash is **kept**, not removed.
|
||||
Other call paths (the routes' response envelope) consume it
|
||||
for the `quota_remaining` field. Removing requires migrating
|
||||
those consumers — out of scope for a "make the guard
|
||||
trustworthy" pass.
|
||||
|
||||
### Battery
|
||||
|
||||
- Express suite: **116 passed / 1505 tests** (+19 over
|
||||
baseline 1476 → 1486 → 1505). 11 prewarmer + 5 TTL parser
|
||||
+ 3 tracker guard.
|
||||
- Web build: clean.
|
||||
|
||||
### Files changed (Session 22)
|
||||
|
||||
**Created:**
|
||||
- `scripts/odds-prefetch.js`
|
||||
- `tests/unit/oddsPrefetch.test.js`
|
||||
|
||||
**Modified:**
|
||||
- `src/services/oddsService.js` — `getConfiguredCacheTTL`
|
||||
+ tracker-driven preflight guard + new module exports
|
||||
- `tests/unit/oddsService.test.js` — 3 tracker tests + 5 TTL
|
||||
parser tests + 1 informational default-TTL test, removed
|
||||
the legacy `hgetall.remaining:0` block test
|
||||
|
||||
---
|
||||
|
||||
## Session 21 (2026-06-12) — SHIPPED
|
||||
|
||||
|
||||
Reference in New Issue
Block a user