perf: cheap per-frame and per-fetch savings (#725)

Six small savings with no behaviour change: the odds fetch no longer pretty-prints every response for a debug line; the scroll integer-slice path drops a redundant full-frame np.ascontiguousarray; ledmatrix-web.service gets MALLOC_ARENA_MAX=2 like the display unit; core ESPN responses are parsed via response_json (orjson when installed); and the scroll frame stats go to INFO only for degraded windows plus a 5-minute heartbeat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-03 14:26:03 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7bb85c0356
commit ed0753c1ca
12 changed files with 261 additions and 32 deletions
+22 -13
View File
@@ -20,6 +20,7 @@ from typing import Dict, Any, Optional, List, cast
from src.common.api_helper import DEFAULT_HTTP_HEADERS
from src.common.fetch_service import fetch_get, share_connection_pool
from src.common.json_body import response_json
@@ -146,7 +147,7 @@ class BaseOddsManager:
if _is_no_odds_marker(cached_data):
self.logger.debug("Cached no-odds marker for %s", cache_key)
return None
self.logger.debug(f"Using cached odds from ESPN for {cache_key}")
self.logger.debug("Using cached odds from ESPN for %s", cache_key)
return cached_data
if time.monotonic() < self._skip_network_until:
@@ -159,7 +160,7 @@ class BaseOddsManager:
self._skip_network_until - time.monotonic())
return None
self.logger.debug(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
self.logger.debug("Cache miss - fetching fresh odds from ESPN for %s", cache_key)
try:
# Map league names to ESPN API format
@@ -173,26 +174,30 @@ class BaseOddsManager:
espn_league = league_mapping.get(league, league)
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
self.logger.debug(f"Requesting odds from URL: {url}")
self.logger.debug("Requesting odds from URL: %s", url)
# The response cache may answer only inside this caller's own
# interval, the age at which its cached odds expire anyway.
response = fetch_get(self.session, url, timeout=self.request_timeout,
cache_max_age=interval)
response.raise_for_status()
raw_data = response.json()
raw_data = response_json(response)
self._skip_network_until = 0.0 # reachable again
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
# Guarded, not just %-style: the json.dumps argument would still be
# built for every response with DEBUG off.
if self.logger.isEnabledFor(logging.DEBUG):
self.logger.debug("Received raw odds data from ESPN: %s",
json.dumps(raw_data, indent=2))
odds_data = self._extract_espn_data(raw_data)
if odds_data:
self.logger.debug(f"Successfully extracted odds data: {odds_data}")
self.logger.debug("Successfully extracted odds data: %s", odds_data)
self.cache_manager.set(cache_key, odds_data, ttl=interval)
self.logger.debug(f"Saved odds data to cache for {cache_key} with TTL {interval}s")
self.logger.debug("Saved odds data to cache for %s with TTL %ss", cache_key, interval)
else:
self.logger.debug(f"No odds data available for {cache_key}")
self.logger.debug("No odds data available for %s", cache_key)
# Cache the absence too, so the game is not re-requested
# on every update until the interval passes.
self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval)
@@ -226,12 +231,12 @@ class BaseOddsManager:
Returns:
Formatted odds data dictionary or None
"""
self.logger.debug(f"Extracting ESPN odds data. Data keys: {list(data.keys())}")
self.logger.debug("Extracting ESPN odds data. Data keys: %s", list(data.keys()))
if "items" in data and data["items"]:
self.logger.debug(f"Found {len(data['items'])} items in odds data")
self.logger.debug("Found %d items in odds data", len(data['items']))
item = data["items"][0]
self.logger.debug(f"First item keys: {list(item.keys())}")
self.logger.debug("First item keys: %s", list(item.keys()))
# The ESPN API returns odds data directly in the item, not in a
# providers array. ESPN sends explicit JSON nulls for absent
@@ -254,13 +259,17 @@ class BaseOddsManager:
.get("pointSpread") or {}).get("value")
}
}
self.logger.debug(f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}")
if self.logger.isEnabledFor(logging.DEBUG):
self.logger.debug("Returning extracted odds data: %s",
json.dumps(extracted_data, indent=2))
return extracted_data
# Check if this is a valid empty response or an unexpected structure
if "count" in data and data["count"] == 0 and "items" in data and data["items"] == []:
# This is a valid empty response - no odds available for this game
self.logger.debug(f"No odds available for this game. Response: {json.dumps(data, indent=2)}")
if self.logger.isEnabledFor(logging.DEBUG):
self.logger.debug("No odds available for this game. Response: %s",
json.dumps(data, indent=2))
return None
else:
# This is an unexpected response structure
+3 -2
View File
@@ -17,6 +17,7 @@ from src.common.espn_dates import (
store_espn_scoreboard_cache,
)
from src.common.fetch_service import fetch_get, fetch_post, share_connection_pool
from src.common.json_body import response_json
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, cast
import requests
@@ -157,7 +158,7 @@ class APIHelper:
response.raise_for_status()
# Parse JSON response
data: Dict[Any, Any] = response.json()
data: Dict[Any, Any] = response_json(response)
# Cache response if cache key provided
if cache_key and self.cache_manager:
@@ -304,7 +305,7 @@ class APIHelper:
)
response.raise_for_status()
return cast(Optional[Dict[Any, Any]], response.json())
return cast(Optional[Dict[Any, Any]], response_json(response))
except requests.exceptions.RequestException as e:
self.logger.error(f"POST request failed for {url}: {e}")
+52 -7
View File
@@ -28,6 +28,30 @@ import numpy as np
# long over one frame, so a sample this large is an idle gap between scrolls.
FPS_LOG_INTERVAL = 5.0
# The stats line goes to INFO only when a window is worth an operator's
# attention, as Vegas's FPS line does (src/vegas_mode/coordinator.py): every
# 5s from every scroller was most of the journal on a healthy rig. A window is
# degraded when its frame rate falls below this fraction of the rate it was
# locked to (1 / its own median frame time; same 0.9 as Vegas) ...
STATS_HEALTHY_FRACTION = 0.9
# ... or when more than this share of its frames stalled (past 1.5x the
# median). A 1% stall rate barely moves the mean, so the fps test alone would
# miss the judder this line exists to show.
STATS_DEGRADED_STALL_RATE = 0.01
# A healthy scroller still logs at INFO this often, so silence in the journal
# means stopped rather than fine. Every window is still logged at DEBUG.
STATS_HEARTBEAT_INTERVAL = 300.0
def frame_stats_degraded(stats: Dict[str, Any]) -> bool:
"""Whether one frame_stats() window is worth logging at INFO."""
n = stats["frames"]
if n == 0 or stats["median"] <= 0:
return False
locked_fps = 1.0 / stats["median"]
return (stats["fps"] < locked_fps * STATS_HEALTHY_FRACTION
or stats["stalls"] > n * STATS_DEGRADED_STALL_RATE)
def _rgb_pixels(item) -> np.ndarray:
"""An appended item's pixels as an RGB array, as pasting it would draw them."""
@@ -189,6 +213,11 @@ class ScrollHelper:
# Every frame time since the last stats line, so the 5s summary can
# report the tail rather than one arbitrary sample. Cleared on log.
self._window: list = []
# INFO-level stats bookkeeping (see STATS_HEARTBEAT_INTERVAL). Kept
# across reset_scroll(): a heartbeat per scroll start would bring the
# chatter back. 0.0 so the first window after start-up is at INFO.
self._stats_last_info_log = 0.0
self._stats_was_degraded = False
# Scrolling state management
self.is_scrolling = False
@@ -573,9 +602,12 @@ class ScrollHelper:
img_w = self.cached_array.shape[1]
if end_x <= img_w:
# Normal case: single contiguous slice (fastest path)
frame_array = np.ascontiguousarray(self.cached_array[:, start_x:end_x])
return Image.frombytes('RGB', _size, frame_array.tobytes())
# Normal case: single contiguous slice (fastest path). tobytes()
# on the column-slice view already returns C-order bytes, so
# ascontiguousarray() first only added a second full-frame copy.
return Image.frombytes(
'RGB', _size,
self.cached_array[:, start_x:end_x].tobytes())
else:
# Ensure frame buffer is allocated for all non-simple paths
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
@@ -1207,10 +1239,23 @@ class ScrollHelper:
# as an idle gap. There is nothing to report, and reporting the
# gap itself is the bug above.
if self._window:
self.logger.info(
"Scroll frame stats - %s",
format_frame_stats(self._window),
)
# INFO when degraded, on the window that recovers from it, and
# as a slow heartbeat; DEBUG otherwise.
degraded = frame_stats_degraded(frame_stats(self._window))
if (degraded or self._stats_was_degraded
or current_time - self._stats_last_info_log
>= STATS_HEARTBEAT_INTERVAL):
self.logger.info(
"Scroll frame stats - %s",
format_frame_stats(self._window),
)
self._stats_last_info_log = current_time
elif self.logger.isEnabledFor(logging.DEBUG):
self.logger.debug(
"Scroll frame stats - %s",
format_frame_stats(self._window),
)
self._stats_was_degraded = degraded
self.last_fps_log_time = current_time
self.frame_count = 0
self._window = []
+2 -1
View File
@@ -23,6 +23,7 @@ import requests
from typing import Any, Dict, List
from src.common.api_helper import DEFAULT_HTTP_HEADERS
from src.common.json_body import response_json
logger = logging.getLogger(__name__)
@@ -157,7 +158,7 @@ class DynamicTeamResolver:
response = requests.get(rankings_url, headers=dict(DEFAULT_HTTP_HEADERS),
timeout=self.request_timeout)
response.raise_for_status()
data = response.json()
data = response_json(response)
rankings = {}
rankings_data = data.get('rankings', [])
+3 -2
View File
@@ -21,6 +21,7 @@ from PIL.PngImagePlugin import PngInfo
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from src.common.api_helper import DEFAULT_HTTP_HEADERS
from src.common.json_body import response_json
from src.common.logo_helper import MAX_LOGO_BYTES
from src.common.permission_utils import (
ensure_directory_permissions,
@@ -481,7 +482,7 @@ class LogoDownloader:
logger.info(f"Fetching team data for {league} from ESPN API...")
response = self.session.get(api_url, params={'limit':1000},headers=self.headers, timeout=self.request_timeout)
response.raise_for_status()
data: Dict = response.json()
data: Dict = response_json(response)
logger.info(f"Successfully fetched team data for {league}")
return data
@@ -505,7 +506,7 @@ class LogoDownloader:
logger.info(f"Fetching team data for team {team_id} in {league} from ESPN API...")
response = self.session.get(f"{api_url}/{team_id}", headers=self.headers, timeout=self.request_timeout)
response.raise_for_status()
data: Dict = response.json()
data: Dict = response_json(response)
logger.info(f"Successfully fetched team data for {team_id} in {league}")
return data