ADR-0031 — Market metadata is a bounded cache, not a permanent store
- Status: Accepted
- Date: 2026-08-17
- Supersedes: the "no TTL, ever" clause of the eviction-protection note in
backend/src/services/marketMetaCache.ts(thenoevictionrequirement itself stands) - Related: ADR-0025 (Price Format travels with every price), ADR-0021 (quarantine over guess)
Context
marketMetaCache writes one Redis key per market, market:<bookmaker>:<marketId>, at
catalogue-refresh time. The key is deterministic, so every update overwrites in place —
there is no versioning and no per-delta key. Key count therefore grows only with new
market ids.
It grew without limit, because the module had a write path and no delete path at all.
No function in it removes a key, and no job in backend/src/jobs/ sweeps the prefix.
BifrostCache.sweepExpired() does evict the live cluster hashes — which is why
bifrost:books stays pinned at ~45k — but it never touches the downstream market:*
sink that it feeds.
Measured 2026-08-16:
| Env | Total keys | Memory | market:bifrost | market:betfair-ex | Live bifrost books | Ratio |
|---|---|---|---|---|---|---|
| prod | 845,063 | 766 MB | 545,758 | 247,683 | 45,298 | 12.0× |
| dev | 593,021 | 1.07 GB | 170,502 | 357,840 | 21,199 | 8.0× |
| staging | 338,401 | 434 MB | 132,936 | 142,060 | 62,525 | 2.1× |
793,441 of prod's 845,063 keys (93.9%) are market:* with TTL = -1. All three
environments run maxmemory 0 with noeviction, so growth is unbounded and an OOM is a
hard stop rather than an eviction. Staging's low ratio despite carrying more live
markets than prod is the proof that this is time-accumulation, not traffic volume.
Decision
Every setMarketMeta write carries an expiry (config.marketMeta.ttlSeconds, default
14 days), re-applied on every write.
This is retention, not eviction. The distinction is load-bearing:
- Eviction would let Redis drop an arbitrary key under memory pressure. Still
forbidden —
noevictionstays mandatory. A live market must never lose its meta because some unrelated key set grew. - Retention ages out a key that nobody rewrote. Since the refresh cycle rewrites every market the provider still publishes, an expiry is a statement about the provider's catalogue, not about time.
'EX' and not 'KEEPTTL': the deadline must slide forward on each write. KEEPTTL
would freeze the original deadline and kill markets that are still live.
Why this is safe
1. Settlement never reads this cache
settlement.ts imports getMarketMeta as import type only. It reconstructs market
shape from the persisted Order columns — outcomeSpace, numOutcomes, outcomeIndex,
marketName, line — all written at placement time. Its own comment states it:
"reconstructed directly from the stored Order columns — no Redis MarketMeta read, no reconstruction guesswork."
An expired key therefore cannot produce a wrong settlement, a wrong payout, or a wrong balance. This is the order-first property that makes the whole decision available.
2. The readers that DO fail only ask about live markets
| Reader | Behaviour on miss | Exposure to expiry |
|---|---|---|
settlement.ts | never reads Redis | none |
orderService.ts:1277, :1772 | reject bet + Slack critical | live markets only |
BetfairAdapter:1868, BifrostAdapter:1366 | DOMAIN_INTEGRITY throw | live markets only |
ordersPreview.ts (17 sites) | 404 decline | live markets only |
tournamentMarketViewService:540 | fails closed — market hidden | live markets only |
orders.ts, agent.ts | miss-tolerant, falls back to stored name | cosmetic |
Every fail-closed reader is asking about a market currently on offer. Those are exactly the keys the refresh keeps alive.
3. The refresh margin is enormous
CATALOGUE_REFRESH_INTERVAL_MS defaults to 300,000 ms (5 minutes). A 14-day TTL is
4,032 refresh cycles of margin. The job can be down for days without a live market
losing its meta.
4. Lead time is not the risk — freshness is
Provider lead time (first-seen → match start, prod, n = 54,722) says Bifrost, not Betfair, opens markets far ahead:
| Provider | p50 | p95 | p99 | max |
|---|---|---|---|---|
| betfair-ex | 1.1d | 6.0d | 6.8d | 13.9d |
| bifrost | 15.0d | 16.0d | 48.0d | 247.0d |
Lead time never accrues against the clock provided the refresh actually re-stamps.
Sampling 400 markets live in bifrost:books and reading the age of their meta:
| Age of meta | Share |
|---|---|
| < 1 day | 33.5% |
| 1–3 days | 43.5% |
| 3–7 days | 23.0% |
| older than 7 days | 0.0% (0 of 331) |
This measurement was confounded and must not be read as proof. The prod backend had
restarted 4 hours before the sample (started 2026-08-16T14:05Z, RestartCount=0), and
BifrostAdapter.writtenMarketMetaIds is process-local — empty on boot — so that first
refresh rewrote every Bifrost market. The histogram shows the effect of the restart, not
of steady-state re-stamping.
Reading the code rather than the data:
- Betfair re-stamps.
populateMarketMetaCacheis called with the full mapped market list every cycle; itsseenMarketIdsset is a per-call batch dedup, not a process-lifetime filter. - Bifrost did not.
BifrostAdapterfilters tomarkets.filter(m => !this.writtenMarketMetaIds.has(m.id))— "markets we haven't seen yet in this process lifecycle", as its own comment says. After the first successful write a market is never written again while the process lives. With a bare TTL, a still-published Bifrost market would expire mid-life, and placement would decline it while the catalogue gate hid it. Prod deploys are manual and infrequent, so a process outliving a 14-day TTL is ordinary, not hypothetical.
So the TTL alone is not sufficient. touchMarketMetaTtl slides the deadline on exactly
the markets that filter skips — one pipelined EXPIRE batch per refresh, no payload
rewrite and no re-run of the dictionary bettability gate, preserving the reason the
filter exists. An entry whose key has already gone (EXPIRE → 0) is reported back and
dropped from writtenMarketMetaIds, so the next cycle performs a full write. The only
way to stay expired is to stop refreshing — which is precisely when expiry is correct.
Why 14 days and not 7
The measurement supports 7 (it would expire 91.4% of keys and lose nothing). 14 days is chosen because the entire safety margin otherwise rests on a single sample, of a single provider, on a single day. 14d still expires 83.1% of the backlog and costs ~8% more retained keys. The margin is cheap; buy it.
Consequences
market:*growth is bounded from this commit forward. Steady state ≈ the live catalogue plus 14 days of tail.- The existing 793,441 no-TTL keys are unaffected —
SETwithoutKEEPTTLonly stamps a TTL on keys that get rewritten, and an orphan is by definition never rewritten. Draining the backlog needs its own one-shot pass (Phase 2), which mustEXPIRE, neverDEL, so a wrong keep-set self-heals on the next refresh. noevictionmust stay. A Redismaxmemoryceiling should be added so OOM alerts rather than crashes (Phase 3).- Lowering
MARKET_META_TTL_SECONDStoward the refresh interval re-couples market visibility to job uptime. Treat any value below a few days as a breaking change. - The
market meta cache misswarn ingetMarketMetais the alarm for a wrong TTL. A flat line across a full TTL window is the proof.
Alternatives rejected
- Delete by "market finished". Requires a reliable finished signal per provider and a join from key → fixture → status. More moving parts, and it fails open (a missed signal keeps the key forever) instead of failing safe.
UNLINKthe computed orphan set. Immediate reclaim, but a mistake is unrecoverable and silently makes a live market unbettable. Expiry self-heals; deletion does not.- LRU/LFU eviction on the prefix. Rejected for the reason the original note gives: eviction is memory-pressure-driven and arbitrary, so it can drop a live market.