mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 23:05:10 +00:00
perf(sports): LRU-bound the decoded logo cache (#559)
SportsCore._logo_cache was a plain dict keyed by team abbreviation with no eviction. Its entries are not file bytes but decoded RGBA thumbnails sized to display*1.5 -- roughly 36KB on a 256x64 panel, more for wide wordmarks -- and assets/sports/ncaa_logos ships 307 of them. A plugin that walked a full league held the whole league resident: about 11-18MB per manager instance, and a league runs three (live/recent/upcoming) that each keep their own cache, so the same logos were duplicated across them. On the 1GB Pi 3B+ this was measured on, one board was sitting at 439MB resident with ~290MB available, so tens of megabytes of duplicated league logos is real money. Bounded to 64 entries, which holds a full "other games" cycle (on the order of 20 games, 40 teams) without thrashing while capping the cache well below a 307-team league. Eviction is LRU rather than clear-when-full, using the OrderedDict/popitem pattern the neighbouring caches in this codebase already use (_IMAGE_CACHE_MAX, _FIT_CACHE_MAX, _TEXT_WIDTH_CACHE_MAX). That ordering matters: the logos on screen right now are precisely the ones that must not be discarded, so a cache hit moves the entry to the end. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from collections import OrderedDict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
@@ -167,7 +168,14 @@ class SportsCore(ABC):
|
|||||||
self.session.mount("https://", adapter)
|
self.session.mount("https://", adapter)
|
||||||
self.session.mount("http://", adapter)
|
self.session.mount("http://", adapter)
|
||||||
|
|
||||||
self._logo_cache = {}
|
# LRU-bounded: entries are decoded RGBA thumbnails, not file bytes.
|
||||||
|
# Each is up to display_width*1.5 x display_height*1.5 -- about 36KB on
|
||||||
|
# a 256x64 panel, more for wide wordmarks. The key is a team
|
||||||
|
# abbreviation and assets/sports/ncaa_logos alone ships 307 of them, so
|
||||||
|
# an unbounded dict here held the whole league: ~11-18MB per manager
|
||||||
|
# instance, and a league runs three (live/recent/upcoming) each with
|
||||||
|
# its own cache. That is real money on a 1GB Pi.
|
||||||
|
self._logo_cache: "OrderedDict[str, Image.Image]" = OrderedDict()
|
||||||
|
|
||||||
# Font caches for _load_custom_font_from_element_config: per-frame
|
# Font caches for _load_custom_font_from_element_config: per-frame
|
||||||
# callers (font-ladder walks) resolve the same (name, size) over and
|
# callers (font-ladder walks) resolve the same (name, size) over and
|
||||||
@@ -560,11 +568,17 @@ class SportsCore(ABC):
|
|||||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||||
draw.text((x, y), text, font=font, fill=fill)
|
draw.text((x, y), text, font=font, fill=fill)
|
||||||
|
|
||||||
|
#: Decoded logos to keep. A scroll of "other games" shows on the order of
|
||||||
|
#: 20 games (40 teams), so this holds a full cycle without thrashing while
|
||||||
|
#: capping the cache well below a 307-team league.
|
||||||
|
_LOGO_CACHE_MAX = 64
|
||||||
|
|
||||||
def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]:
|
def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]:
|
||||||
"""Load and resize a team logo, with caching and automatic download if missing."""
|
"""Load and resize a team logo, with caching and automatic download if missing."""
|
||||||
self.logger.debug(f"Logo path: {logo_path}")
|
self.logger.debug(f"Logo path: {logo_path}")
|
||||||
if team_abbrev in self._logo_cache:
|
if team_abbrev in self._logo_cache:
|
||||||
self.logger.debug(f"Using cached logo for {team_abbrev}")
|
self.logger.debug(f"Using cached logo for {team_abbrev}")
|
||||||
|
self._logo_cache.move_to_end(team_abbrev)
|
||||||
return self._logo_cache[team_abbrev]
|
return self._logo_cache[team_abbrev]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -604,6 +618,8 @@ class SportsCore(ABC):
|
|||||||
max_height = int(self.display_height * 1.5)
|
max_height = int(self.display_height * 1.5)
|
||||||
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
||||||
self._logo_cache[team_abbrev] = logo
|
self._logo_cache[team_abbrev] = logo
|
||||||
|
while len(self._logo_cache) > self._LOGO_CACHE_MAX:
|
||||||
|
self._logo_cache.popitem(last=False)
|
||||||
return logo
|
return logo
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Tests that SportsCore's decoded-logo cache is LRU-bounded.
|
||||||
|
|
||||||
|
``self._logo_cache`` was a plain dict keyed by team abbreviation, with no
|
||||||
|
eviction. The entries are decoded RGBA thumbnails sized to display*1.5 -- about
|
||||||
|
36KB on a 256x64 panel, more for wide wordmarks -- and
|
||||||
|
assets/sports/ncaa_logos ships 307 of them. A plugin that walked a full league
|
||||||
|
therefore held the whole league in memory: roughly 11-18MB per manager
|
||||||
|
instance, and a league runs three of them (live/recent/upcoming) each with its
|
||||||
|
own cache. On a 1GB Pi 3B+ with ~290MB available that is worth recovering.
|
||||||
|
|
||||||
|
The bound has to be LRU rather than "clear when full": the logos on screen
|
||||||
|
right now are exactly the ones that must not be thrown away.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# src.base_classes.sports transitively imports the hardware matrix driver.
|
||||||
|
sys.modules.setdefault("rgbmatrix", MagicMock())
|
||||||
|
|
||||||
|
from src.base_classes.sports import SportsCore # noqa: E402
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("test_sports_logo_cache_bounded")
|
||||||
|
|
||||||
|
|
||||||
|
class _StubSports(SportsCore):
|
||||||
|
def _fetch_data(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_game_details(self, game_event):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def host(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.base_classes.sports.core.get_background_service",
|
||||||
|
lambda *args, **kwargs: MagicMock())
|
||||||
|
# Never reach for the network: every logo these tests ask for exists.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.base_classes.sports.core.download_missing_logo",
|
||||||
|
lambda *a, **k: None)
|
||||||
|
|
||||||
|
display_manager = MagicMock()
|
||||||
|
display_manager.matrix.width = 128
|
||||||
|
display_manager.matrix.height = 32
|
||||||
|
display_manager.width = 128
|
||||||
|
display_manager.height = 32
|
||||||
|
display_manager.image = Image.new("RGB", (128, 32))
|
||||||
|
cache_manager = MagicMock()
|
||||||
|
cache_manager.cache_dir = str(tmp_path)
|
||||||
|
instance = _StubSports({"timezone": "UTC"}, display_manager,
|
||||||
|
cache_manager, LOGGER, "nhl")
|
||||||
|
instance._logo_dir = tmp_path
|
||||||
|
return instance, tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _load(host, abbrev):
|
||||||
|
"""Create a real logo file for `abbrev` and load it through the cache."""
|
||||||
|
instance, tmp_path = host
|
||||||
|
path = tmp_path / f"{abbrev}.png"
|
||||||
|
if not path.exists():
|
||||||
|
Image.new("RGBA", (64, 64), (1, 2, 3, 255)).save(path)
|
||||||
|
return instance._load_and_resize_logo(abbrev, abbrev, path, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheCacheIsBounded:
|
||||||
|
def test_it_never_exceeds_the_limit(self, host):
|
||||||
|
instance, _ = host
|
||||||
|
limit = instance._LOGO_CACHE_MAX
|
||||||
|
for i in range(limit + 40):
|
||||||
|
assert _load(host, f"T{i}") is not None
|
||||||
|
# The regression: this grew to limit + 40, and for NCAA to 307.
|
||||||
|
assert len(instance._logo_cache) == limit
|
||||||
|
|
||||||
|
def test_the_limit_is_smaller_than_a_real_league(self, host):
|
||||||
|
"""ncaa_logos ships 307 files; the cap has to be well under that."""
|
||||||
|
instance, _ = host
|
||||||
|
assert instance._LOGO_CACHE_MAX < 307
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvictionIsLRUNotArbitrary:
|
||||||
|
def test_the_least_recently_used_goes_first(self, host):
|
||||||
|
instance, _ = host
|
||||||
|
limit = instance._LOGO_CACHE_MAX
|
||||||
|
for i in range(limit):
|
||||||
|
_load(host, f"T{i}")
|
||||||
|
_load(host, "NEW")
|
||||||
|
assert "T0" not in instance._logo_cache, "oldest should have been evicted"
|
||||||
|
assert "NEW" in instance._logo_cache
|
||||||
|
|
||||||
|
def test_touching_an_entry_saves_it(self, host):
|
||||||
|
instance, _ = host
|
||||||
|
limit = instance._LOGO_CACHE_MAX
|
||||||
|
for i in range(limit):
|
||||||
|
_load(host, f"T{i}")
|
||||||
|
_load(host, "T0") # a cache hit -- T0 is on screen again
|
||||||
|
_load(host, "NEW") # forces one eviction
|
||||||
|
|
||||||
|
assert "T0" in instance._logo_cache, "a logo in use was thrown away"
|
||||||
|
assert "T1" not in instance._logo_cache, "T1 was the true LRU entry"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHitsStillAvoidDiskWork:
|
||||||
|
def test_a_second_request_returns_the_cached_object(self, host, monkeypatch):
|
||||||
|
instance, _ = host
|
||||||
|
first = _load(host, "TB")
|
||||||
|
|
||||||
|
def _boom(*a, **k):
|
||||||
|
raise AssertionError("cache hit re-opened the file from disk")
|
||||||
|
|
||||||
|
monkeypatch.setattr(Image, "open", _boom)
|
||||||
|
assert _load(host, "TB") is first
|
||||||
Reference in New Issue
Block a user