mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
feat(fetch): one ESPN scoreboard cache key and a max-age response cache (fetch service stage 2) (#728)
Fetch service stage 2: one ESPN scoreboard cache key shared across the sports base classes (legacy keys still read), and a max-age response cache in the fetch service. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -175,7 +175,10 @@ class BaseOddsManager:
|
||||
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}")
|
||||
|
||||
response = fetch_get(self.session, url, timeout=self.request_timeout)
|
||||
# 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()
|
||||
|
||||
|
||||
+46
-13
@@ -10,7 +10,12 @@ import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from src.common.espn_dates import ESPN_MAX_LIMIT
|
||||
from src.common.espn_dates import (
|
||||
ESPN_MAX_LIMIT,
|
||||
espn_scoreboard_cache_key,
|
||||
read_espn_scoreboard_cache,
|
||||
store_espn_scoreboard_cache,
|
||||
)
|
||||
from src.common.fetch_service import fetch_get, fetch_post, share_connection_pool
|
||||
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, cast
|
||||
|
||||
@@ -117,6 +122,14 @@ class APIHelper:
|
||||
Returns:
|
||||
Response data as dictionary or None if request fails
|
||||
"""
|
||||
return self._get(url, params, headers, timeout, cache_key, cache_ttl,
|
||||
cache_ttl if cache_key else None)
|
||||
|
||||
def _get(self, url: str, params: Optional[Dict], headers: Optional[Dict],
|
||||
timeout: Optional[int], cache_key: Optional[str], cache_ttl: int,
|
||||
cache_max_age: Optional[float]) -> Optional[Dict]:
|
||||
""":meth:`get`, saying how old a response the fetch service's short
|
||||
response cache may hand back (``cache_max_age``, the caller's TTL)."""
|
||||
if cache_key and self.cache_manager:
|
||||
cached = self._get_from_cache(cache_key, cache_ttl)
|
||||
if cached is not None:
|
||||
@@ -138,7 +151,8 @@ class APIHelper:
|
||||
url,
|
||||
params=params,
|
||||
headers=request_headers,
|
||||
timeout=timeout or self.default_timeout
|
||||
timeout=timeout or self.default_timeout,
|
||||
cache_max_age=cache_max_age,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -167,22 +181,23 @@ class APIHelper:
|
||||
sport: Sport name (e.g., 'basketball', 'football')
|
||||
league: League name (e.g., 'nba', 'nfl')
|
||||
date: Date in YYYYMMDD format (defaults to today)
|
||||
cache_key: Cache key for response
|
||||
cache_ttl: Cache time-to-live in seconds
|
||||
|
||||
cache_key: Cache key for response. By default the canonical
|
||||
``espn_scoreboard_cache_key(sport, league, date)``, shared
|
||||
with every other consumer of this scoreboard, with the key
|
||||
this used before (``espn_{sport}_{league}_{date}``) read as a
|
||||
fallback for one release. An explicit key works as before.
|
||||
cache_ttl: Cache time-to-live in seconds. A shared entry is
|
||||
returned only while it is at most this old.
|
||||
|
||||
Returns:
|
||||
ESPN API response data or None if request fails
|
||||
"""
|
||||
if date is None:
|
||||
date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
|
||||
# Build URL
|
||||
url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard"
|
||||
|
||||
# Build cache key if not provided
|
||||
if cache_key is None:
|
||||
cache_key = f"espn_{sport}_{league}_{date}"
|
||||
|
||||
|
||||
# Set parameters
|
||||
# limit above 500 makes ESPN truncate instead of erroring: college
|
||||
# football came back with 25 of 68 games. See src/common/espn_dates.py.
|
||||
@@ -190,8 +205,26 @@ class APIHelper:
|
||||
'dates': date,
|
||||
'limit': ESPN_MAX_LIMIT
|
||||
}
|
||||
|
||||
return self.get(url, params=params, cache_key=cache_key, cache_ttl=cache_ttl)
|
||||
|
||||
if cache_key is not None:
|
||||
return self.get(url, params=params, cache_key=cache_key, cache_ttl=cache_ttl)
|
||||
|
||||
legacy_key = f"espn_{sport}_{league}_{date}"
|
||||
try:
|
||||
shared_key = espn_scoreboard_cache_key(sport, league, date)
|
||||
except ValueError:
|
||||
# Not a path or date the canonical key covers: the old key.
|
||||
return self.get(url, params=params, cache_key=legacy_key, cache_ttl=cache_ttl)
|
||||
if self.cache_manager:
|
||||
cached = read_espn_scoreboard_cache(
|
||||
self.cache_manager, shared_key, cache_ttl, legacy_keys=(legacy_key,))
|
||||
if cached is not None:
|
||||
self.logger.debug(f"Using cached response for {shared_key}")
|
||||
return cast(Dict[Any, Any], cached)
|
||||
data = self._get(url, params, None, None, None, cache_ttl, cache_ttl)
|
||||
if data is not None and self.cache_manager:
|
||||
store_espn_scoreboard_cache(self.cache_manager, shared_key, data)
|
||||
return data
|
||||
|
||||
def fetch_espn_standings(self, sport: str, league: str,
|
||||
cache_key: Optional[str] = None,
|
||||
|
||||
+281
-10
@@ -30,15 +30,34 @@ Once a range has been rejected, later ranges skip straight to chunks for
|
||||
``RANGE_RETRY_SECONDS`` instead of spending a doomed request first -- live
|
||||
scoreboards ask every 30 seconds. After that the range is tried again, so the
|
||||
workaround retires itself if ESPN reverts.
|
||||
|
||||
ONE CACHE KEY PER SCOREBOARD
|
||||
----------------------------
|
||||
The same ESPN scoreboard used to be cached under a different key by every
|
||||
consumer: odds-ticker as ``scoreboard_data_{sport}_{league}_{date}``,
|
||||
``APIHelper`` as ``espn_{sport}_{league}_{date}``, the scoreboards as
|
||||
``{sport_key}_schedule_{window}`` -- so two plugins showing the same league
|
||||
fetched and stored it twice. :func:`espn_scoreboard_cache_key` is the one
|
||||
name for "this sport/league scoreboard for these dates", and
|
||||
:func:`get_espn_scoreboard` (or :func:`read_espn_scoreboard_cache` and
|
||||
:func:`store_espn_scoreboard_cache` around :func:`fetch_espn_scoreboard`)
|
||||
is the cache-through read every consumer can share. A read never returns an
|
||||
entry older than the reader's own ``max_age``, whoever wrote it and whatever
|
||||
ttl they stored with it. Old keys are passed as ``legacy_keys`` and read
|
||||
after the canonical one, so an upgrade does not refetch everything at once;
|
||||
they can go one release after the one that added this.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
from functools import partial
|
||||
from typing import Any, Dict, List, Optional, Tuple, cast
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, cast
|
||||
|
||||
try:
|
||||
from src.common.json_body import response_json
|
||||
@@ -51,18 +70,23 @@ except ImportError:
|
||||
try:
|
||||
# The core fetch service: counts, per-host budget, merging of identical
|
||||
# requests. Same call, same result and errors as ``session.get``.
|
||||
from src.common.fetch_service import fetch_get, pinned_caller
|
||||
from src.common.fetch_service import fetch_get, get_fetch_service, pinned_caller
|
||||
_COUNTS_FETCHES = True
|
||||
except ImportError:
|
||||
# Bundled copies on cores without it call the session directly.
|
||||
import contextlib
|
||||
|
||||
def fetch_get(session: Any, url: str, *, share_in_flight: bool = True,
|
||||
**kwargs: Any) -> Any:
|
||||
cache_max_age: Optional[float] = None, **kwargs: Any) -> Any:
|
||||
return session.get(url, **kwargs)
|
||||
|
||||
def pinned_caller() -> Any:
|
||||
return contextlib.nullcontext()
|
||||
|
||||
_COUNTS_FETCHES = False
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Above this, ESPN returns a truncated list instead of an error. See module
|
||||
# docstring: 500 is the largest value measured to return complete data.
|
||||
ESPN_MAX_LIMIT = 500
|
||||
@@ -89,8 +113,23 @@ __all__ = [
|
||||
"merge_scoreboard_payloads",
|
||||
"fetch_espn_date_chunks",
|
||||
"fetch_espn_scoreboard",
|
||||
"ESPN_SCOREBOARD_URL",
|
||||
"espn_scoreboard_url",
|
||||
"espn_scoreboard_cache_key",
|
||||
"espn_scoreboard_cache_key_for_url",
|
||||
"read_espn_scoreboard_cache",
|
||||
"store_espn_scoreboard_cache",
|
||||
"get_espn_scoreboard",
|
||||
]
|
||||
|
||||
#: The site-API scoreboard every sport and league shares.
|
||||
ESPN_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard"
|
||||
_ESPN_HOST_URL = "https://site.api.espn.com/"
|
||||
|
||||
_PATH_PART = re.compile(r"^[a-z0-9][a-z0-9.\-]*$")
|
||||
_DATES = re.compile(r"^\d{4}(?:\d{2}(?:\d{2})?)?$|^\d{8}-\d{8}$")
|
||||
_SCOREBOARD_PATH = re.compile(r"/sports/([^/?#]+)/([^/?#]+)/scoreboard/?$")
|
||||
|
||||
|
||||
def clamp_espn_limit(params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Return a copy of ``params`` with any ``limit`` over 500 pulled back to 500."""
|
||||
@@ -129,6 +168,12 @@ def parse_espn_date_range(dates: Any) -> Optional[Tuple[date, date]]:
|
||||
return start, end
|
||||
|
||||
|
||||
def _memo_kwargs(cache_max_age: Optional[float]) -> Dict[str, Any]:
|
||||
"""``cache_max_age`` for fetch_get, only when the caller gave one, so a
|
||||
call that did not say is the call it always was."""
|
||||
return {} if cache_max_age is None else {"cache_max_age": cache_max_age}
|
||||
|
||||
|
||||
def _ranges_known_rejected() -> bool:
|
||||
with _range_lock:
|
||||
return time.monotonic() < _ranges_rejected_until
|
||||
@@ -204,6 +249,7 @@ def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
|
||||
|
||||
def _fetch_one_chunk(
|
||||
session, url: str, params: Dict[str, Any], headers, timeout, logger, chunk: str,
|
||||
cache_max_age: Optional[float] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""GET a single ``dates=`` chunk, or None when it failed.
|
||||
|
||||
@@ -217,6 +263,7 @@ def _fetch_one_chunk(
|
||||
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
**_memo_kwargs(cache_max_age),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return cast(Optional[Dict[str, Any]], response_json(response))
|
||||
@@ -228,7 +275,7 @@ def _fetch_one_chunk(
|
||||
|
||||
def _fetch_chunks(
|
||||
session, url: str, params: Dict[str, Any], headers, timeout, logger,
|
||||
chunks: List[str],
|
||||
chunks: List[str], cache_max_age: Optional[float] = None,
|
||||
) -> List[Optional[Dict[str, Any]]]:
|
||||
"""Fetch every chunk, returning payloads positionally aligned with ``chunks``.
|
||||
|
||||
@@ -246,6 +293,7 @@ def _fetch_chunks(
|
||||
return []
|
||||
fetch = partial(
|
||||
_fetch_one_chunk, session, url, params, headers, timeout, logger,
|
||||
cache_max_age=cache_max_age,
|
||||
)
|
||||
if len(chunks) == 1:
|
||||
return [fetch(chunks[0])]
|
||||
@@ -268,6 +316,7 @@ def fetch_espn_date_chunks(
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 15,
|
||||
logger=None,
|
||||
cache_max_age: Optional[float] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch a ``YYYYMMDD-YYYYMMDD`` window as month and day chunks.
|
||||
|
||||
@@ -299,7 +348,7 @@ def fetch_espn_date_chunks(
|
||||
)
|
||||
|
||||
results = _fetch_chunks(
|
||||
session, url, params, headers, timeout, logger, chunks,
|
||||
session, url, params, headers, timeout, logger, chunks, cache_max_age,
|
||||
)
|
||||
attempted = len(chunks)
|
||||
|
||||
@@ -331,7 +380,7 @@ def fetch_espn_date_chunks(
|
||||
days = [day for index in sorted(capped) for day in capped[index]]
|
||||
attempted += len(days)
|
||||
by_day = dict(zip(days, _fetch_chunks(
|
||||
session, url, params, headers, timeout, logger, days,
|
||||
session, url, params, headers, timeout, logger, days, cache_max_age,
|
||||
)))
|
||||
for index, month_days in capped.items():
|
||||
slots[index] = [by_day.get(day) for day in month_days]
|
||||
@@ -364,6 +413,7 @@ def fetch_espn_scoreboard(
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 15,
|
||||
logger=None,
|
||||
cache_max_age: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""GET an ESPN scoreboard, re-asking in month/day chunks if a range 400s.
|
||||
|
||||
@@ -373,6 +423,10 @@ def fetch_espn_scoreboard(
|
||||
and later ranges go straight to chunks for ``RANGE_RETRY_SECONDS``. A 400 on
|
||||
a non-range request, any other error, and a range whose every chunk fails
|
||||
all raise as before.
|
||||
|
||||
``cache_max_age`` is the oldest response, in seconds, the caller takes
|
||||
from the fetch service's short response cache (its own TTL; 0 always
|
||||
asks ESPN). None leaves it to the service default.
|
||||
"""
|
||||
params = clamp_espn_limit(params)
|
||||
is_range = parse_espn_date_range(params.get("dates")) is not None
|
||||
@@ -381,7 +435,7 @@ def fetch_espn_scoreboard(
|
||||
if is_range and _ranges_known_rejected():
|
||||
data = fetch_espn_date_chunks(
|
||||
session, url, params=params, headers=headers,
|
||||
timeout=timeout, logger=logger,
|
||||
timeout=timeout, logger=logger, cache_max_age=cache_max_age,
|
||||
)
|
||||
if data is not None:
|
||||
return data
|
||||
@@ -389,7 +443,8 @@ def fetch_espn_scoreboard(
|
||||
# real error to log, without spending the chunks a second time.
|
||||
chunks_tried = True
|
||||
|
||||
response = fetch_get(session, url, params=params, headers=headers, timeout=timeout)
|
||||
response = fetch_get(session, url, params=params, headers=headers, timeout=timeout,
|
||||
**_memo_kwargs(cache_max_age))
|
||||
if is_range and response.status_code == 400 and not chunks_tried:
|
||||
_note_range_rejected()
|
||||
if logger:
|
||||
@@ -400,9 +455,225 @@ def fetch_espn_scoreboard(
|
||||
)
|
||||
data = fetch_espn_date_chunks(
|
||||
session, url, params=params, headers=headers,
|
||||
timeout=timeout, logger=logger,
|
||||
timeout=timeout, logger=logger, cache_max_age=cache_max_age,
|
||||
)
|
||||
if data is not None:
|
||||
return data
|
||||
response.raise_for_status()
|
||||
return cast(Dict[str, Any], response_json(response))
|
||||
|
||||
|
||||
# --- one cache key per scoreboard --------------------------------------------------
|
||||
|
||||
def espn_scoreboard_url(sport: str, league: str) -> str:
|
||||
"""The site-API scoreboard URL for an ESPN ``sport`` / ``league`` path."""
|
||||
return ESPN_SCOREBOARD_URL.format(sport=_path_part(sport, "sport"),
|
||||
league=_path_part(league, "league"))
|
||||
|
||||
|
||||
def _path_part(value: Any, what: str) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not _PATH_PART.match(text):
|
||||
raise ValueError(f"not an ESPN {what} path segment: {value!r}")
|
||||
return text
|
||||
|
||||
|
||||
def _day(value: Any) -> str:
|
||||
if isinstance(value, (date, datetime)):
|
||||
return value.strftime("%Y%m%d")
|
||||
text = str(value).strip()
|
||||
if len(text) != 8 or not text.isdigit():
|
||||
raise ValueError(f"not an ESPN day (YYYYMMDD): {value!r}")
|
||||
return text
|
||||
|
||||
|
||||
def _dates_part(dates: Any) -> str:
|
||||
"""``dates`` as ESPN spells it, or ``current`` for no ``dates`` at all."""
|
||||
if dates is None or dates == "":
|
||||
return "current"
|
||||
if isinstance(dates, (date, datetime)):
|
||||
return _day(dates)
|
||||
if isinstance(dates, (tuple, list)):
|
||||
if len(dates) != 2:
|
||||
raise ValueError(f"a date range is (start, end): {dates!r}")
|
||||
start, end = _day(dates[0]), _day(dates[1])
|
||||
return start if start == end else f"{start}-{end}"
|
||||
text = str(dates).strip()
|
||||
if isinstance(dates, bool) or not _DATES.match(text):
|
||||
raise ValueError(
|
||||
f"not an ESPN dates value (YYYY, YYYYMM, YYYYMMDD or "
|
||||
f"YYYYMMDD-YYYYMMDD): {dates!r}")
|
||||
return text
|
||||
|
||||
|
||||
def espn_scoreboard_cache_key(sport: str, league: str, dates: Any = None) -> str:
|
||||
"""The one cache key for an ESPN scoreboard, whoever caches it.
|
||||
|
||||
``sport`` and ``league`` are ESPN's own path segments -- ``football`` /
|
||||
``college-football``, ``soccer`` / ``eng.1`` -- not a plugin's
|
||||
``sport_key``, so every plugin showing a league names it the same way.
|
||||
``dates`` is what the request sends as ``dates=``: ``"YYYYMMDD"``,
|
||||
``"YYYYMM"``, ``"YYYY"``, ``"YYYYMMDD-YYYYMMDD"``, a ``date``, or a
|
||||
``(start, end)`` pair of either; None is the undated "current"
|
||||
scoreboard. Anything else raises ValueError rather than invent a key.
|
||||
|
||||
The key says nothing about ``limit``: a cached copy is meant to be a
|
||||
whole one (the helpers here always ask for ``ESPN_MAX_LIMIT``).
|
||||
"""
|
||||
return (f"espn_scoreboard_{_path_part(sport, 'sport')}_"
|
||||
f"{_path_part(league, 'league')}_{_dates_part(dates)}")
|
||||
|
||||
|
||||
def espn_scoreboard_cache_key_for_url(url: str, dates: Any = None) -> Optional[str]:
|
||||
""":func:`espn_scoreboard_cache_key` for a scoreboard URL, or None when
|
||||
``url`` is not ``.../sports/{sport}/{league}/scoreboard``."""
|
||||
match = _SCOREBOARD_PATH.search(str(url or "").split("?", 1)[0])
|
||||
if match is None:
|
||||
return None
|
||||
try:
|
||||
return espn_scoreboard_cache_key(match.group(1), match.group(2), dates)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _note_cache_hit(legacy: bool, avoided_request: bool = True) -> None:
|
||||
if not _COUNTS_FETCHES:
|
||||
return
|
||||
try:
|
||||
get_fetch_service().note_cache_hit(
|
||||
_ESPN_HOST_URL, legacy=legacy, avoided_request=avoided_request)
|
||||
except Exception: # noqa: BLE001 - counting never breaks a read
|
||||
_logger.debug("could not count a scoreboard cache hit", exc_info=True)
|
||||
|
||||
|
||||
def _fresh_cached(cache_manager: Any, key: str, max_age: Optional[float],
|
||||
now: float) -> Tuple[Optional[Dict[str, Any]], Optional[float]]:
|
||||
"""The data cached under ``key`` if it is at most ``max_age`` seconds
|
||||
old, and its age (None when the cache does not say).
|
||||
|
||||
The age is the stored record's own timestamp, checked here: CacheManager
|
||||
lets a ttl stored by the writer override the reader's max_age, and its
|
||||
memory tier times an entry from when it was loaded, not written. A key
|
||||
shared by readers with different TTLs can rely on neither.
|
||||
"""
|
||||
reader = getattr(cache_manager, "get_cached_data", None)
|
||||
limit = None if max_age is None else max(1, int(math.ceil(max_age)))
|
||||
if not callable(reader):
|
||||
# A cache without records (a test double, a plugin's own store).
|
||||
value = cache_manager.get(key, max_age=limit)
|
||||
return (value if isinstance(value, dict) else None), None
|
||||
record = reader(key, max_age=limit, memory_ttl=limit)
|
||||
if not isinstance(record, dict):
|
||||
return None, None
|
||||
if "data" not in record:
|
||||
return record, None # unwrapped; the cache already judged it by mtime
|
||||
stamp = record.get("timestamp")
|
||||
age: Optional[float] = None
|
||||
if not isinstance(stamp, bool) and isinstance(stamp, (int, float)):
|
||||
age = max(0.0, now - float(stamp))
|
||||
if max_age is not None and (age is None or age > max_age):
|
||||
return None, None
|
||||
data = record["data"]
|
||||
return (data if isinstance(data, dict) else None), age
|
||||
|
||||
|
||||
def read_espn_scoreboard_cache(
|
||||
cache_manager: Any,
|
||||
key: str,
|
||||
max_age: Optional[float],
|
||||
legacy_keys: Iterable[str] = (),
|
||||
now: Optional[float] = None,
|
||||
accept: Optional[Callable[[Dict[str, Any], Optional[float]], bool]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""The cached scoreboard under ``key``, or under the first of
|
||||
``legacy_keys`` that has one, if it is at most ``max_age`` seconds old.
|
||||
|
||||
None on a miss, a stale entry, ``max_age`` of 0 or less, no cache
|
||||
manager, or any cache error -- a read never raises. ``max_age=None``
|
||||
takes an entry of any age. ``accept(data, age_seconds)`` can turn down
|
||||
an entry the age alone would allow (a payload holding a live game wants
|
||||
a shorter limit); ``age_seconds`` is None when the cache cannot say. A
|
||||
hit is counted in the fetch statistics (``cache_hits``;
|
||||
``legacy_cache_hits`` too for an old key).
|
||||
"""
|
||||
if cache_manager is None:
|
||||
return None
|
||||
if max_age is not None and max_age <= 0:
|
||||
return None
|
||||
clock = time.time() if now is None else now
|
||||
for index, candidate in enumerate([key, *legacy_keys]):
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
data, age = _fresh_cached(cache_manager, candidate, max_age, clock)
|
||||
if data is not None and accept is not None and not accept(data, age):
|
||||
data = None
|
||||
except Exception: # noqa: BLE001 - a broken cache is a miss
|
||||
_logger.debug("scoreboard cache read failed for %s", candidate, exc_info=True)
|
||||
continue
|
||||
if data is not None:
|
||||
_note_cache_hit(legacy=index > 0)
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def store_espn_scoreboard_cache(cache_manager: Any, key: str, data: Any) -> None:
|
||||
"""Cache a fetched scoreboard under ``key``. Never raises.
|
||||
|
||||
No ttl is stored: each reader applies its own ``max_age`` (a live
|
||||
reader 30 s, a schedule reader an hour), and a stored ttl would
|
||||
override theirs in CacheManager.
|
||||
"""
|
||||
if cache_manager is None or data is None:
|
||||
return
|
||||
try:
|
||||
cache_manager.set(key, data)
|
||||
except Exception: # noqa: BLE001 - the caller still has its data
|
||||
_logger.warning("Could not cache scoreboard %s", key, exc_info=True)
|
||||
|
||||
|
||||
def get_espn_scoreboard(
|
||||
session: Any,
|
||||
sport: str,
|
||||
league: str,
|
||||
dates: Any = None,
|
||||
*,
|
||||
cache_manager: Any = None,
|
||||
max_age: Optional[float] = 300,
|
||||
legacy_keys: Iterable[str] = (),
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 15,
|
||||
logger: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""An ESPN scoreboard through the shared cache, fetched on a miss.
|
||||
|
||||
Reads :func:`espn_scoreboard_cache_key` (then ``legacy_keys``) and
|
||||
returns an entry at most ``max_age`` seconds old. Otherwise it fetches
|
||||
with :func:`fetch_espn_scoreboard` -- ``limit=ESPN_MAX_LIMIT``, ranges
|
||||
split as ESPN needs -- caches the result under the canonical key and
|
||||
returns it. ``max_age=0`` always fetches (and still caches, for other
|
||||
readers). Errors raise exactly as :func:`fetch_espn_scoreboard` does,
|
||||
and nothing is cached then. ``session=None`` uses the fetch service's
|
||||
pooled session for the ESPN host.
|
||||
"""
|
||||
key = espn_scoreboard_cache_key(sport, league, dates)
|
||||
cached = read_espn_scoreboard_cache(cache_manager, key, max_age, legacy_keys)
|
||||
if cached is not None:
|
||||
return cast(Dict[str, Any], cached)
|
||||
params: Dict[str, Any] = {"limit": ESPN_MAX_LIMIT}
|
||||
spelled = _dates_part(dates)
|
||||
if spelled != "current":
|
||||
params["dates"] = spelled
|
||||
data = fetch_espn_scoreboard(
|
||||
session,
|
||||
espn_scoreboard_url(sport, league),
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
logger=logger,
|
||||
# The response cache must not hand back anything older than the
|
||||
# cache read above would have accepted.
|
||||
cache_max_age=None if max_age is None else max(0.0, float(max_age)),
|
||||
)
|
||||
store_espn_scoreboard_cache(cache_manager, key, data)
|
||||
return data
|
||||
|
||||
+238
-12
@@ -46,9 +46,25 @@ own conditional headers gets the raw answer. (ESPN sent no validators when
|
||||
this was written -- see the PR that added this module -- so on ESPN the store
|
||||
stays empty and costs nothing.)
|
||||
|
||||
**Response cache (stage 2).** A 200 that says ``Cache-Control: max-age=N``
|
||||
is kept in memory for those N seconds (less its ``Age``), and an identical
|
||||
GET inside that window is answered from it without a request. ESPN sends
|
||||
max-age (1-496 s measured on 2026-10-02, most of it under 10 s) and no
|
||||
validators, so this is the only revalidation-free reuse ESPN allows. It
|
||||
never hands a caller a response older than the caller accepts: a caller
|
||||
says how old with ``cache_max_age`` (``fetch_get(..., cache_max_age=ttl)``;
|
||||
0 skips the cache), and one that does not say gets at most
|
||||
``response_cache.default_max_age`` (30 s). ``no-store``, ``no-cache``,
|
||||
``private``, ``Vary: *`` and ``Set-Cookie`` responses are never kept.
|
||||
Identical means what the validator store keys on: URL, query, effective
|
||||
headers and, for a session with cookies or auth, the session.
|
||||
|
||||
**Counters.** Requests, merged requests, bytes, 304s, errors, HTTP errors,
|
||||
adapter retries, throttled requests and seconds waited, per plugin and per
|
||||
host. :class:`FetchStatsPublisher` publishes them for the web interface
|
||||
adapter retries, throttled requests and seconds waited, plus requests
|
||||
answered without the network: ``memo_hits`` (the response cache) and
|
||||
``cache_hits`` / ``legacy_cache_hits`` (a shared ESPN scoreboard cache entry,
|
||||
counted by ``src/common/espn_dates.py``). Per plugin and per host.
|
||||
:class:`FetchStatsPublisher` publishes them for the web interface
|
||||
(``GET /api/v3/plugins/fetch-stats``).
|
||||
|
||||
CALLER IDENTITY
|
||||
@@ -68,7 +84,8 @@ Counters are kept per plugin without plugins saying who they are:
|
||||
3. Otherwise the request is the core's own (``"core"``).
|
||||
|
||||
Nothing here raises on account of bookkeeping: a failure in counting,
|
||||
keying or the validator store falls back to a plain ``session.get``.
|
||||
keying, the validator store or the response cache falls back to a plain
|
||||
``session.get``.
|
||||
|
||||
Core-internal for now (stage 1). Plugins reach it through ``APIHelper`` and
|
||||
``espn_dates``; a plugin-facing API comes with stage 3.
|
||||
@@ -143,8 +160,22 @@ DEFAULT_CONFIG: Mapping[str, Any] = {
|
||||
"max_bytes": 4 * 1024 * 1024,
|
||||
"max_entry_bytes": 1024 * 1024,
|
||||
},
|
||||
# Stage 2: responses ESPN calls fresh (Cache-Control: max-age), reused
|
||||
# for identical GETs. A college-football Saturday is ~1 MB decoded, so
|
||||
# one entry may be 2 MB; months (5-7 MB) are never kept.
|
||||
"response_cache": {
|
||||
"enabled": True,
|
||||
"default_max_age": 30,
|
||||
"max_entries": 64,
|
||||
"max_bytes": 6 * 1024 * 1024,
|
||||
"max_entry_bytes": 2 * 1024 * 1024,
|
||||
},
|
||||
}
|
||||
|
||||
#: However long a server says a response stays fresh, it is not kept longer
|
||||
#: than this: the cache is for requests that coincide, not for storage.
|
||||
_RESPONSE_CACHE_CEILING = 600.0
|
||||
|
||||
#: Connection pools kept per shared adapter (one per host) and connections
|
||||
#: kept per pool. Larger than requests' 10 because one adapter now serves
|
||||
#: every core Session with its retry policy: three background workers each
|
||||
@@ -171,6 +202,9 @@ _COUNTER_FIELDS = (
|
||||
"overruns", # requests that went after max_wait_seconds anyway
|
||||
"bytes", # decoded response body bytes received
|
||||
"wait_seconds", # time spent waiting for host budgets
|
||||
"memo_hits", # answered from the response cache (max-age); nothing sent
|
||||
"cache_hits", # scoreboard fetches answered from a shared ESPN cache entry
|
||||
"legacy_cache_hits", # cache reads answered from a pre-stage-2 key (any helper)
|
||||
)
|
||||
|
||||
|
||||
@@ -393,6 +427,117 @@ class _ValidatorStore:
|
||||
return {"entries": len(self._entries), "bytes": self._bytes}
|
||||
|
||||
|
||||
# --- response cache (Cache-Control: max-age) --------------------------------------
|
||||
|
||||
@dataclass
|
||||
class _Fresh:
|
||||
response: requests.Response
|
||||
stored_at: float
|
||||
#: Seconds after stored_at the server said the response stays fresh.
|
||||
lifetime: float
|
||||
size: int
|
||||
|
||||
|
||||
class _ResponseCache:
|
||||
"""LRU of finished 200 responses, each kept for its server max-age.
|
||||
|
||||
:meth:`get` answers only while the entry is younger than both its own
|
||||
lifetime and the caller's limit, so nobody is handed a response older
|
||||
than they asked for. Expired entries are dropped as they are met and on
|
||||
every insert, so the cache holds only what is still fresh.
|
||||
"""
|
||||
|
||||
def __init__(self, max_entries: int, max_bytes: int, max_entry_bytes: int,
|
||||
clock: Callable[[], float]) -> None:
|
||||
self.max_entries = max_entries
|
||||
self.max_bytes = max_bytes
|
||||
self.max_entry_bytes = max_entry_bytes
|
||||
self._clock = clock
|
||||
self._entries: "OrderedDict[Any, _Fresh]" = OrderedDict()
|
||||
self._bytes = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, key: Any, max_age: float) -> Optional[requests.Response]:
|
||||
with self._lock:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
age = self._clock() - entry.stored_at
|
||||
if age < 0 or age >= entry.lifetime:
|
||||
self._drop_locked(key)
|
||||
return None
|
||||
if age > max_age:
|
||||
return None # fresh for someone less strict; kept
|
||||
self._entries.move_to_end(key)
|
||||
clone: requests.Response = _clone_response(entry.response)
|
||||
return clone
|
||||
|
||||
def put(self, key: Any, response: requests.Response, lifetime: float,
|
||||
size: int) -> None:
|
||||
with self._lock:
|
||||
self._drop_locked(key)
|
||||
if size > self.max_entry_bytes or self.max_entries <= 0 or lifetime <= 0:
|
||||
return
|
||||
now = self._clock()
|
||||
for old_key in [k for k, e in self._entries.items()
|
||||
if now - e.stored_at >= e.lifetime]:
|
||||
self._drop_locked(old_key)
|
||||
self._entries[key] = _Fresh(_clone_response(response), now, lifetime, size)
|
||||
self._bytes += size
|
||||
while self._entries and (len(self._entries) > self.max_entries
|
||||
or self._bytes > self.max_bytes):
|
||||
_, old = self._entries.popitem(last=False)
|
||||
self._bytes -= old.size
|
||||
|
||||
def _drop_locked(self, key: Any) -> None:
|
||||
old = self._entries.pop(key, None)
|
||||
if old is not None:
|
||||
self._bytes -= old.size
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._entries.clear()
|
||||
self._bytes = 0
|
||||
|
||||
def stats(self) -> Dict[str, int]:
|
||||
with self._lock:
|
||||
return {"entries": len(self._entries), "bytes": self._bytes}
|
||||
|
||||
|
||||
def _cache_directives(value: Optional[str]) -> Dict[str, Optional[str]]:
|
||||
directives: Dict[str, Optional[str]] = {}
|
||||
for part in (value or "").split(","):
|
||||
name, _, arg = part.strip().partition("=")
|
||||
if name:
|
||||
directives[name.strip().lower()] = arg.strip().strip('"') if arg else None
|
||||
return directives
|
||||
|
||||
|
||||
def _fresh_for(response: Any) -> Optional[float]:
|
||||
"""Seconds a finished response stays fresh by its own headers, or None
|
||||
when it must not be reused: not a 200 with its body read, ``no-store``,
|
||||
``no-cache``, ``private``, ``Vary: *``, ``Set-Cookie``, or no max-age."""
|
||||
if _status_of(response) != 200 or _body_of(response) is None:
|
||||
return None
|
||||
directives = _cache_directives(_str_header(response, "Cache-Control"))
|
||||
if {"no-store", "no-cache", "private"} & set(directives):
|
||||
return None
|
||||
if (_str_header(response, "Vary") or "").strip() == "*":
|
||||
return None
|
||||
if _str_header(response, "Set-Cookie"):
|
||||
return None
|
||||
try:
|
||||
max_age = int(directives.get("max-age") or "")
|
||||
except ValueError:
|
||||
return None
|
||||
try:
|
||||
age = int(_str_header(response, "Age") or 0)
|
||||
except ValueError:
|
||||
age = 0
|
||||
fresh = float(min(max_age - max(age, 0), _RESPONSE_CACHE_CEILING))
|
||||
return fresh if fresh > 0 else None
|
||||
|
||||
|
||||
# --- helpers ------------------------------------------------------------------------
|
||||
|
||||
def _host_of(url: Any) -> str:
|
||||
@@ -580,6 +725,9 @@ class FetchService:
|
||||
self.max_wait_seconds = 2.0
|
||||
self._rate_limits: Dict[str, Tuple[float, float]] = {}
|
||||
self._validators = _ValidatorStore(0, 0, 0)
|
||||
self.response_cache = True
|
||||
self.default_max_age = 30.0
|
||||
self._fresh = _ResponseCache(0, 0, 0, clock)
|
||||
self._applied: Optional[str] = None
|
||||
self.configure(config)
|
||||
|
||||
@@ -635,6 +783,14 @@ class FetchService:
|
||||
store = merged.get("validator_store")
|
||||
store = store if isinstance(store, Mapping) else {}
|
||||
default_store = DEFAULT_CONFIG["validator_store"]
|
||||
fresh = merged.get("response_cache")
|
||||
if fresh is not None and not isinstance(fresh, Mapping):
|
||||
logger.warning("fetch_service.response_cache is not an object; using the defaults")
|
||||
fresh = fresh if isinstance(fresh, Mapping) else {}
|
||||
default_fresh = DEFAULT_CONFIG["response_cache"]
|
||||
self.response_cache = fresh.get("enabled") is not False
|
||||
self.default_max_age = _as_float(fresh.get("default_max_age"),
|
||||
float(default_fresh["default_max_age"]))
|
||||
with self._lock:
|
||||
self._rate_limits = limits
|
||||
self._buckets.clear()
|
||||
@@ -643,6 +799,12 @@ class FetchService:
|
||||
_as_int(store.get("max_bytes"), default_store["max_bytes"]),
|
||||
_as_int(store.get("max_entry_bytes"), default_store["max_entry_bytes"]),
|
||||
)
|
||||
self._fresh = _ResponseCache(
|
||||
_as_int(fresh.get("max_entries"), default_fresh["max_entries"]),
|
||||
_as_int(fresh.get("max_bytes"), default_fresh["max_bytes"]),
|
||||
_as_int(fresh.get("max_entry_bytes"), default_fresh["max_entry_bytes"]),
|
||||
self._clock,
|
||||
)
|
||||
self.change_count += 1
|
||||
|
||||
def describe_config(self) -> Dict[str, Any]:
|
||||
@@ -655,6 +817,8 @@ class FetchService:
|
||||
"conditional_get": self.conditional_get,
|
||||
"max_wait_seconds": self.max_wait_seconds,
|
||||
"rate_limits": limits,
|
||||
"response_cache": self.response_cache,
|
||||
"default_max_age": self.default_max_age,
|
||||
}
|
||||
|
||||
def _limit_for(self, host: str) -> Optional[Tuple[float, float]]:
|
||||
@@ -723,7 +887,7 @@ class FetchService:
|
||||
# -- requests --
|
||||
|
||||
def get(self, session: Any, url: str, *, share_in_flight: bool = True,
|
||||
**kwargs: Any) -> Any:
|
||||
cache_max_age: Optional[float] = None, **kwargs: Any) -> Any:
|
||||
"""``session.get(url, **kwargs)`` through the service.
|
||||
|
||||
Same return value, same exceptions, and ``session.get`` is called
|
||||
@@ -734,6 +898,11 @@ class FetchService:
|
||||
than joining an identical one in flight -- for a caller that may
|
||||
retry *because* an earlier request hung (BackgroundDataService
|
||||
cancels and replaces a fetch) and must not be handed that one.
|
||||
|
||||
``cache_max_age`` is the oldest response, in seconds, the caller
|
||||
will take from the response cache (its own TTL); 0 always asks the
|
||||
network. None means ``response_cache.default_max_age``. A response
|
||||
is never reused past the max-age its server gave it either.
|
||||
"""
|
||||
transport = session if session is not None else self.session_for(url)
|
||||
if not self.enabled:
|
||||
@@ -744,8 +913,24 @@ class FetchService:
|
||||
logger.debug("fetch_service could not key a request to %s", url, exc_info=True)
|
||||
request = _Request(plugin=self._caller(), host=_host_of(url))
|
||||
|
||||
reusable = (self.response_cache and request.representation is not None
|
||||
and not _has_conditional_headers(kwargs.get("headers")))
|
||||
if reusable:
|
||||
try:
|
||||
limit = self._accepted_age(cache_max_age)
|
||||
cached = self._fresh.get(request.representation, limit) if limit > 0 else None
|
||||
except Exception:
|
||||
logger.debug("fetch_service response cache lookup failed", exc_info=True)
|
||||
cached = None
|
||||
if cached is not None:
|
||||
self._count(request.plugin, request.host, memo_hits=1)
|
||||
return cached
|
||||
|
||||
if request.flight is None or not self.single_flight or not share_in_flight:
|
||||
return self._send_get(transport, url, kwargs, request)
|
||||
response = self._send_get(transport, url, kwargs, request)
|
||||
if reusable:
|
||||
self._remember(request, response)
|
||||
return response
|
||||
|
||||
with self._lock:
|
||||
flight = self._inflight.get(request.flight)
|
||||
@@ -764,6 +949,8 @@ class FetchService:
|
||||
try:
|
||||
response = self._send_get(transport, url, kwargs, request)
|
||||
flight.response = response
|
||||
if reusable:
|
||||
self._remember(request, response)
|
||||
return response
|
||||
except BaseException as error:
|
||||
flight.error = error
|
||||
@@ -790,6 +977,40 @@ class FetchService:
|
||||
except Exception:
|
||||
logger.debug("fetch_service could not count a merged request", exc_info=True)
|
||||
|
||||
def note_cache_hit(self, url: Any = None, *, legacy: bool = False,
|
||||
avoided_request: bool = True,
|
||||
plugin_id: Optional[str] = None) -> None:
|
||||
"""Count a read answered from a shared cache entry instead of the
|
||||
network (``espn_dates``). ``legacy`` marks a read from a key that
|
||||
predates the canonical one; ``avoided_request=False`` counts only
|
||||
that, for a read that was never going to fetch on a miss."""
|
||||
try:
|
||||
self._count(plugin_id or self._caller(), _host_of(url),
|
||||
cache_hits=int(avoided_request), legacy_cache_hits=int(legacy))
|
||||
except Exception:
|
||||
logger.debug("fetch_service could not count a cache hit", exc_info=True)
|
||||
|
||||
def _accepted_age(self, cache_max_age: Any) -> float:
|
||||
"""The oldest cached response this call accepts, in seconds."""
|
||||
if cache_max_age is None:
|
||||
return self.default_max_age
|
||||
if isinstance(cache_max_age, bool) or not isinstance(cache_max_age, (int, float)):
|
||||
return self.default_max_age
|
||||
value = float(cache_max_age)
|
||||
return value if math.isfinite(value) and value > 0 else 0.0
|
||||
|
||||
def _remember(self, request: _Request, response: Any) -> None:
|
||||
"""Keep a finished response for its server max-age. Never raises."""
|
||||
try:
|
||||
lifetime = _fresh_for(response)
|
||||
if lifetime is None:
|
||||
return
|
||||
body = _body_of(response)
|
||||
self._fresh.put(request.representation, response, lifetime,
|
||||
len(body) if body is not None else 0)
|
||||
except Exception:
|
||||
logger.debug("fetch_service could not keep a response", exc_info=True)
|
||||
|
||||
def _caller(self) -> str:
|
||||
return current_plugin_id() or CORE
|
||||
|
||||
@@ -927,10 +1148,11 @@ class FetchService:
|
||||
per_plugin[name] += value
|
||||
per_host[name] += value
|
||||
self._totals[name] += value
|
||||
if changes.get("requests") or changes.get("merged"):
|
||||
asked = int(changes.get("requests", 0) + changes.get("merged", 0)
|
||||
+ changes.get("memo_hits", 0) + changes.get("cache_hits", 0))
|
||||
if asked:
|
||||
hosts = self._plugin_hosts.setdefault(plugin, {})
|
||||
hosts[host] = hosts.get(host, 0) + int(changes.get("requests", 0)
|
||||
+ changes.get("merged", 0))
|
||||
hosts[host] = hosts.get(host, 0) + asked
|
||||
self.change_count += 1
|
||||
|
||||
def reset_counters(self) -> None:
|
||||
@@ -942,12 +1164,14 @@ class FetchService:
|
||||
self.change_count += 1
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Counters, validators, budgets and in-flight table (tests)."""
|
||||
"""Counters, validators, response cache, budgets and in-flight
|
||||
table (tests)."""
|
||||
self.reset_counters()
|
||||
with self._lock:
|
||||
self._buckets.clear()
|
||||
self._inflight.clear()
|
||||
self._validators.clear()
|
||||
self._fresh.clear()
|
||||
|
||||
def snapshot(self) -> Dict[str, Any]:
|
||||
"""Counters since the service started, JSON-ready."""
|
||||
@@ -971,6 +1195,7 @@ class FetchService:
|
||||
"plugins": plugins,
|
||||
"hosts": hosts,
|
||||
"validators": self._validators.stats(),
|
||||
"response_cache": self._fresh.stats(),
|
||||
"config": self.describe_config(),
|
||||
}
|
||||
|
||||
@@ -1005,10 +1230,11 @@ def configure_fetch_service(config: Any) -> FetchService:
|
||||
|
||||
|
||||
def fetch_get(session: Any, url: str, *, share_in_flight: bool = True,
|
||||
**kwargs: Any) -> Any:
|
||||
"""``session.get(url, **kwargs)`` through the process's FetchService."""
|
||||
cache_max_age: Optional[float] = None, **kwargs: Any) -> Any:
|
||||
"""``session.get(url, **kwargs)`` through the process's FetchService.
|
||||
``cache_max_age``: see :meth:`FetchService.get`."""
|
||||
return get_fetch_service().get(session, url, share_in_flight=share_in_flight,
|
||||
**kwargs)
|
||||
cache_max_age=cache_max_age, **kwargs)
|
||||
|
||||
|
||||
def fetch_post(session: Any, url: str, **kwargs: Any) -> Any:
|
||||
|
||||
@@ -40,6 +40,20 @@ listed here.
|
||||
- ``live_games``, read with ``getattr`` -- ``_needs_previous_day``.
|
||||
- ``background_service``, read with ``getattr`` --
|
||||
``_background_fetches_espn_ranges``.
|
||||
- ``sport`` and ``league`` (ESPN's path segments, e.g. ``football`` /
|
||||
``nfl``) -- ``_schedule_cache_key``, and ``_fetch_season_directly`` when
|
||||
it is given no key and cannot read one from its URL.
|
||||
|
||||
THE SCHEDULE CACHE KEY (fetch service stage 2)
|
||||
----------------------------------------------
|
||||
``_schedule_cache_key`` names a schedule window with the canonical
|
||||
``espn_scoreboard_cache_key`` instead of a plugin-built
|
||||
``{sport_key}_schedule_{window}``, and ``_cached_schedule`` reads it with the
|
||||
old key as a fallback for one release, so an upgrade serves the copy already
|
||||
on disk instead of refetching every league at once. The canonical key
|
||||
carries the window's dates, so it moves on a day as the window slides; a
|
||||
miss on it also deletes the copy for the day before, so a league keeps one
|
||||
window file instead of a week of them.
|
||||
|
||||
Add it as a base of the plugin's ``SportsCore``, e.g.
|
||||
``class SportsCore(SportsFetchMixin, SportsCoreSharedMixin,
|
||||
@@ -50,9 +64,31 @@ constant on the plugin's own class still wins over the mixin's.
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, ClassVar, Dict, Optional
|
||||
from typing import Any, ClassVar, Dict, Iterable, Optional
|
||||
|
||||
from src.common.espn_dates import ESPN_MAX_LIMIT, fetch_espn_scoreboard
|
||||
from src.common.espn_dates import (
|
||||
ESPN_MAX_LIMIT,
|
||||
espn_scoreboard_cache_key,
|
||||
espn_scoreboard_cache_key_for_url,
|
||||
fetch_espn_scoreboard,
|
||||
parse_espn_date_range,
|
||||
)
|
||||
from src.common.fetch_service import get_fetch_service
|
||||
|
||||
_ESPN_SITE = "https://site.api.espn.com/"
|
||||
|
||||
|
||||
def _previous_window_key(cache_key: str) -> Optional[str]:
|
||||
"""The canonical key of the same window one day earlier, or None when
|
||||
``cache_key`` is not a canonical day-range key."""
|
||||
head, sep, dates = cache_key.rpartition("_")
|
||||
if not sep or not head.startswith("espn_scoreboard_"):
|
||||
return None
|
||||
span = parse_espn_date_range(dates)
|
||||
if span is None:
|
||||
return None
|
||||
start, end = (day - timedelta(days=1) for day in span)
|
||||
return f"{head}_{start.strftime('%Y%m%d')}-{end.strftime('%Y%m%d')}"
|
||||
|
||||
|
||||
class SportsFetchMixin:
|
||||
@@ -65,6 +101,8 @@ class SportsFetchMixin:
|
||||
cache_manager: Any
|
||||
logger: logging.Logger
|
||||
_games_lock: threading.RLock
|
||||
sport: str
|
||||
league: str
|
||||
|
||||
#: How many games past the one on screen keep their odds warm. One is
|
||||
#: enough for the line to be ready when the rotation advances; more just
|
||||
@@ -154,18 +192,66 @@ class SportsFetchMixin:
|
||||
service = getattr(self, "background_service", None)
|
||||
return bool(getattr(service, "handles_espn_date_ranges", False))
|
||||
|
||||
def _schedule_cache_key(self, datestring: str) -> str:
|
||||
"""The canonical cache key for this league's schedule over
|
||||
``datestring`` (``espn_scoreboard_cache_key``)."""
|
||||
return espn_scoreboard_cache_key(self.sport, self.league, datestring)
|
||||
|
||||
def _cached_schedule(self, cache_key: str, legacy_keys: Iterable[str] = ()) -> Any:
|
||||
"""What ``self.cache_manager.get(cache_key)`` returns, falling back
|
||||
to each of ``legacy_keys`` (the plugin's pre-canonical keys) in turn.
|
||||
|
||||
The same read the managers made before -- same default max age, a
|
||||
stored ttl still wins -- so moving to the canonical key changes
|
||||
where a schedule is cached, not for how long. A read from an old key
|
||||
is counted (``legacy_cache_hits``) so it is visible when the
|
||||
fallback can go. A miss on the canonical key also deletes the same
|
||||
window's copy from the day before (see the module docstring).
|
||||
"""
|
||||
cached = self.cache_manager.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
self._retire_previous_window(cache_key)
|
||||
for legacy in legacy_keys:
|
||||
if not legacy or legacy == cache_key:
|
||||
continue
|
||||
cached = self.cache_manager.get(legacy)
|
||||
if cached:
|
||||
try:
|
||||
get_fetch_service().note_cache_hit(
|
||||
_ESPN_SITE, legacy=True, avoided_request=False)
|
||||
except Exception: # noqa: BLE001 - counting never breaks a read
|
||||
pass
|
||||
return cached
|
||||
return None
|
||||
|
||||
def _retire_previous_window(self, cache_key: str) -> None:
|
||||
previous = _previous_window_key(cache_key)
|
||||
delete = getattr(self.cache_manager, "delete", None)
|
||||
if previous is None or not callable(delete):
|
||||
return
|
||||
try:
|
||||
delete(previous)
|
||||
except Exception as e: # noqa: BLE001 - housekeeping only
|
||||
self.logger.debug(f"Could not delete old schedule copy {previous}: {e}")
|
||||
|
||||
def _fetch_season_directly(
|
||||
self,
|
||||
url: str,
|
||||
datestring: str,
|
||||
cache_key: str,
|
||||
cache_key: Optional[str],
|
||||
label: str,
|
||||
ttl: Optional[int] = None,
|
||||
) -> Optional[Dict]:
|
||||
"""Fetch a season schedule on this thread, in chunks ESPN accepts, and cache it.
|
||||
|
||||
``label`` names the schedule in log lines, e.g. ``"2026 season"``.
|
||||
``cache_key=None`` caches it under the canonical key
|
||||
(``espn_scoreboard_cache_key`` for ``url``'s sport and league).
|
||||
"""
|
||||
if cache_key is None:
|
||||
cache_key = (espn_scoreboard_cache_key_for_url(url, datestring)
|
||||
or self._schedule_cache_key(datestring))
|
||||
try:
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session,
|
||||
|
||||
Reference in New Issue
Block a user