"""Tests that odds cache entries do not all expire on the same tick. Regression under test: every game's odds were cached with the same fixed TTL, and a slate is fetched in one pass, so they all went stale within milliseconds of each other. The next update then re-fetched the entire card serially. On a live device that pushed football-scoreboard's update() past the plugin manager's 30s timeout: 00:43:43 ERROR plugin football-scoreboard operation timed out after 30.0s 01:43:43 ERROR plugin football-scoreboard operation timed out after 30.0s Exactly one hour apart -- the odds TTL -- with a normal update taking ~5s. The update was killed part-way, so whichever games it had not reached kept no odds until the next cycle, which then hit the same wall. """ from unittest.mock import Mock from src.base_odds_manager import BaseOddsManager def _manager(): return BaseOddsManager(cache_manager=Mock(), config_manager=None) def _keys(n): return ["odds_espn_football_nfl_4018730%02d" % i for i in range(n)] class TestJitteredTtl: def test_different_keys_get_different_ttls(self): m = _manager() ttls = {m._jittered_ttl(3600, k) for k in _keys(50)} assert len(ttls) > 1, "a constant TTL is exactly the bug" def test_the_same_key_always_gets_the_same_ttl(self): # Derived from the key, not drawn at random, so a game keeps its slot # across restarts. A fresh draw each start would re-cluster the slate # the first time the process bounced. m = _manager() first = m._jittered_ttl(3600, "odds_espn_football_nfl_401") assert all(m._jittered_ttl(3600, "odds_espn_football_nfl_401") == first for _ in range(20)) assert _manager()._jittered_ttl(3600, "odds_espn_football_nfl_401") == first def test_ttl_stays_close_to_the_requested_interval(self): # Odds must not go stale far early or linger far too long. m = _manager() for k in _keys(200): ttl = m._jittered_ttl(3600, k) assert 3600 * 0.85 <= ttl <= 3600 * 1.15, ttl def test_spread_is_wide_enough_to_matter(self): # An hour interval should scatter expiries across many minutes, so only # a game or two comes due per update rather than the whole card. m = _manager() ttls = [m._jittered_ttl(3600, k) for k in _keys(200)] assert max(ttls) - min(ttls) > 600, ( "spread of %ds is too narrow to break up the stampede" % (max(ttls) - min(ttls))) def test_short_intervals_stay_positive(self): m = _manager() for interval in (1, 2, 5, 30): for k in _keys(20): assert m._jittered_ttl(interval, k) >= 1 def test_zero_and_negative_pass_through(self): # 0 often means "no expiry" to a cache; do not turn that into 1. m = _manager() assert m._jittered_ttl(0, "k") == 0 assert m._jittered_ttl(-1, "k") == -1 class TestNoStampede: def test_a_slate_fetched_together_does_not_expire_together(self): """The behaviour that actually matters, modelled over a full slate.""" m = _manager() interval = 3600 # 16 games, all fetched in the same pass at t=0. expiries = sorted(m._jittered_ttl(interval, k) for k in _keys(16)) # How many come due within any 60s update window? worst = max( sum(1 for e in expiries if t <= e < t + 60) for t in range(min(expiries), max(expiries) + 1, 10) ) assert worst < 16, "the whole slate still expires in one window" assert worst <= 6, ( "%d of 16 games still come due in a single 60s window" % worst) def test_ttl_reaches_the_cache_layer(self): """The jitter is pointless if the value never gets used.""" cache = Mock() cache.get_with_auto_strategy.return_value = None m = BaseOddsManager(cache_manager=cache, config_manager=None) # Force the no-odds path, which is the simplest write to observe. m._extract_espn_data = Mock(return_value=None) import src.base_odds_manager as mod real_get = mod.requests.get try: resp = Mock() resp.json.return_value = {} resp.raise_for_status.return_value = None mod.requests.get = Mock(return_value=resp) m.get_odds("football", "nfl", "401873042") finally: mod.requests.get = real_get assert cache.set.called ttl = cache.set.call_args.kwargs.get("ttl") assert ttl is not None, "ttl was not passed to the cache" # A single call may legitimately land on 3600, so the assertion is the # range; that the value varies is covered above. assert 3600 * 0.85 <= ttl <= 3600 * 1.15, ttl