mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-12 06:08:05 +00:00
fix(odds): stagger cache expiry so a slate does not all go stale at once
Observed on a live device, exactly one hour apart:
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
One hour is the odds TTL, and that is the whole story. Every game is
cached with the same fixed lifetime, and a slate is fetched in a single
pass, so they all go stale within milliseconds of each other. The next
update then re-fetches the entire card serially -- one ESPN request per
game -- and a normal 5s update runs past the plugin manager's 30s
timeout and is killed part-way through. Whichever games it had not
reached kept no odds until the next cycle, which hit the same wall.
Offset each entry's TTL by up to +-15%, derived from its cache key. An
hour then scatters expiry across about seventeen minutes; over a 16-game
slate the worst 60s window falls from all 16 to a handful.
Deriving the offset from the key rather than drawing it at random is
deliberate. It is stable, so a game keeps its slot across restarts,
where a fresh draw each start would re-cluster the whole slate the first
time the process bounced. It also keeps a random generator out of a path
where nothing is security-sensitive, which is what a scanner flagged.
Deliberately not a change to the fetching itself. Making it concurrent
would also fit inside the timeout, but it multiplies load on ESPN at the
moment of expiry, which is the behaviour that caused this. Spreading the
expiry removes the burst instead of absorbing it.
Four tests on main pinned the TTL as exactly equal to the interval;
they now assert the interval is honoured within the jitter band, keeping
what each was actually about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
This commit is contained in:
co-authored by
Claude Opus 5
parent
ca26c1b83b
commit
3eb8602b61
@@ -118,14 +118,29 @@ class TestGetOdds:
|
||||
mock_get.assert_not_called()
|
||||
assert manager.is_odds_available(result) is False
|
||||
|
||||
@staticmethod
|
||||
def _assert_cached(cache_manager, key, value, around):
|
||||
"""The cache write, with a TTL near `around` rather than equal to it.
|
||||
|
||||
Odds TTLs are jittered so a slate fetched in one pass does not expire
|
||||
on the same tick; asserting equality here would pin the stampede that
|
||||
killed football-scoreboard's update() once an hour. The interval must
|
||||
still be honoured, so the range is tight.
|
||||
"""
|
||||
cache_manager.set.assert_called_once()
|
||||
args, kwargs = cache_manager.set.call_args
|
||||
assert args[0] == key
|
||||
assert args[1] == value
|
||||
assert around * 0.85 <= kwargs['ttl'] <= around * 1.15, kwargs['ttl']
|
||||
|
||||
def test_success_caches_extracted_data_with_interval_ttl(
|
||||
self, manager, cache_manager, mock_get):
|
||||
result = manager.get_odds('football', 'nfl', '401',
|
||||
update_interval_seconds=100)
|
||||
|
||||
assert result == FULL_EXTRACTED
|
||||
cache_manager.set.assert_called_once_with(
|
||||
'odds_espn_football_nfl_401', FULL_EXTRACTED, ttl=100)
|
||||
self._assert_cached(cache_manager, 'odds_espn_football_nfl_401',
|
||||
FULL_EXTRACTED, around=100)
|
||||
|
||||
def test_no_odds_caches_sentinel(self, manager, cache_manager, mock_get):
|
||||
mock_get.return_value = _make_response({'count': 0, 'items': []})
|
||||
@@ -133,8 +148,8 @@ class TestGetOdds:
|
||||
result = manager.get_odds('football', 'nfl', '401')
|
||||
|
||||
assert result is None
|
||||
cache_manager.set.assert_called_once_with(
|
||||
'odds_espn_football_nfl_401', {'no_odds': True}, ttl=3600)
|
||||
self._assert_cached(cache_manager, 'odds_espn_football_nfl_401',
|
||||
{'no_odds': True}, around=3600)
|
||||
|
||||
def test_zero_interval_falls_back_to_default(
|
||||
self, manager, cache_manager, mock_get):
|
||||
@@ -142,7 +157,8 @@ class TestGetOdds:
|
||||
# treats an explicit 0 as falsy, so the 3600 default wins.
|
||||
manager.get_odds('football', 'nfl', '401', update_interval_seconds=0)
|
||||
|
||||
assert cache_manager.set.call_args.kwargs['ttl'] == 3600
|
||||
# Still the 3600 default, now jittered around it rather than exact.
|
||||
assert 3600 * 0.85 <= cache_manager.set.call_args.kwargs['ttl'] <= 3600 * 1.15
|
||||
|
||||
def test_request_exception_falls_back_to_stale_cache(
|
||||
self, manager, cache_manager, mock_get):
|
||||
@@ -205,8 +221,8 @@ class TestExtractEspnData:
|
||||
'home_team_odds': {'money_line': None, 'spread_odds': None},
|
||||
'away_team_odds': {'money_line': None, 'spread_odds': None},
|
||||
}
|
||||
cache_manager.set.assert_called_once_with(
|
||||
'odds_espn_football_nfl_401', result, ttl=3600)
|
||||
TestGetOdds._assert_cached(cache_manager, 'odds_espn_football_nfl_401',
|
||||
result, around=3600)
|
||||
assert manager.is_odds_available(result) is False
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user