Skip to main content

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 (the noeviction requirement 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:

EnvTotal keysMemorymarket:bifrostmarket:betfair-exLive bifrost booksRatio
prod845,063766 MB545,758247,68345,29812.0×
dev593,0211.07 GB170,502357,84021,1998.0×
staging338,401434 MB132,936142,06062,5252.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 — noeviction stays 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​

ReaderBehaviour on missExposure to expiry
settlement.tsnever reads Redisnone
orderService.ts:1277, :1772reject bet + Slack criticallive markets only
BetfairAdapter:1868, BifrostAdapter:1366DOMAIN_INTEGRITY throwlive markets only
ordersPreview.ts (17 sites)404 declinelive markets only
tournamentMarketViewService:540fails closed — market hiddenlive markets only
orders.ts, agent.tsmiss-tolerant, falls back to stored namecosmetic

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:

Providerp50p95p99max
betfair-ex1.1d6.0d6.8d13.9d
bifrost15.0d16.0d48.0d247.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 metaShare
< 1 day33.5%
1–3 days43.5%
3–7 days23.0%
older than 7 days0.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. populateMarketMetaCache is called with the full mapped market list every cycle; its seenMarketIds set is a per-call batch dedup, not a process-lifetime filter.
  • Bifrost did not. BifrostAdapter filters to markets.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 — SET without KEEPTTL only 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 must EXPIRE, never DEL, so a wrong keep-set self-heals on the next refresh.
  • noeviction must stay. A Redis maxmemory ceiling should be added so OOM alerts rather than crashes (Phase 3).
  • Lowering MARKET_META_TTL_SECONDS toward 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 miss warn in getMarketMeta is 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.
  • UNLINK the 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.