From 3ea1fd42df2a1713acb0ad04d3e6234fec3957fd Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Fri, 2 Oct 2026 17:08:41 -0400 Subject: [PATCH] perf(espn): remember settled day chunks between window refreshes Since ESPN started rejecting date ranges, every scoreboard's hourly Recent/Upcoming refresh re-asks its 22-day window (14 back, 7 ahead) one day at a time. Measured on hdpi 2026-10-02 (NFL, college football, MLB, college baseball, NHL): the hourly refresh was ~270 of 321 ESPN requests and ~21 of 24.6MB in the hour. Days that ended three or more days ago cannot change, and they were 68% of the window's bytes (6.9 of 10.2MB). _fetch_one_chunk now keeps a settled chunk (last day <= UTC today - 3) in memory for 24h as zlib-compressed JSON, keyed by URL, the other params and the chunk, and answers it from there. Both range paths go through it: fetch_espn_scoreboard and BackgroundDataService._fetch_in_date_chunks. Each hit is parsed afresh, failed and capped chunks are not stored, and the memory is bounded (512 entries / 8MB compressed). Against live ESPN, a second refresh of the five hdpi windows went from 111 requests and 10.18MB to 50 requests and 3.23MB with identical events; the memory held 60 entries in 585KB. Co-Authored-By: Claude Opus 5.5 --- src/common/espn_dates.py | 147 ++++++++++++++++++++++++++++++++++++++- test/conftest.py | 18 +++++ test/test_espn_dates.py | 100 ++++++++++++++++++++++++++ 3 files changed, 263 insertions(+), 2 deletions(-) diff --git a/src/common/espn_dates.py b/src/common/espn_dates.py index 3b34c9ea..b084a862 100644 --- a/src/common/espn_dates.py +++ b/src/common/espn_dates.py @@ -46,19 +46,39 @@ 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. + +Chunks whose days are long over are kept in memory between fetches. The +scoreboards re-fetch their whole Recent/Upcoming window (14 days back, 7 +ahead) every hour, and since ranges went away that is 22 day requests per +league. Measured on hdpi on 2026-10-02 (NFL, college football, MLB, college +baseball, NHL): the hourly window refresh was ~270 of 321 ESPN requests and +~21 of 24.6MB in the hour, and the 12 days that ended three or more days ago +were 68% of those bytes (6.9 of 10.2MB per copy of the five windows). A +settled chunk is answered from memory for ``SETTLED_CHUNK_TTL_SECONDS``, +stored as zlib-compressed JSON (~13x smaller than the body, and far smaller +than the parsed objects), so the hourly refresh only goes to ESPN for the +days that can still change. """ import contextvars +import json import logging import math import re import threading import time +import zlib +from collections import OrderedDict from concurrent.futures import ThreadPoolExecutor -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from functools import partial from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, cast +try: + import orjson +except ImportError: # optional; the stdlib parser gives the same objects + orjson = None + try: from src.common.json_body import response_json except ImportError: @@ -103,11 +123,37 @@ ESPN_CHUNK_WORKERS = 6 _range_lock = threading.Lock() _ranges_rejected_until = 0.0 +# A chunk is "settled" once its last day is this many UTC days back. ESPN +# files games under the US Eastern date, and a late West-coast game ends after +# midnight UTC; three days leaves a full day of margin past both, so nothing +# still being played, finalised or rescheduled is ever served from memory. +SETTLED_AFTER_DAYS = 3 + +# How long a settled chunk is trusted. A day's finals do not change, but a +# rare correction (or an empty answer during an ESPN outage) should not live +# forever: once a day is plenty, and still skips 23 of every 24 hourly asks. +SETTLED_CHUNK_TTL_SECONDS = 24 * 60 * 60 + +# Bounds on the settled-chunk memory. A settled day measured 90KB (NHL) to +# 990KB (a college-football Saturday) of JSON and 9-74KB compressed; the five +# windows on hdpi need 60 entries and ~0.55MB. The caps only matter for a +# board fetching whole past seasons. +SETTLED_CACHE_MAX_ENTRIES = 512 +SETTLED_CACHE_MAX_BYTES = 8 * 1024 * 1024 + +_settled_lock = threading.Lock() +# key -> (stored_at monotonic, compressed JSON) +_settled_chunks: "OrderedDict[Any, Tuple[float, bytes]]" = OrderedDict() +_settled_bytes = 0 + __all__ = [ "ESPN_MAX_LIMIT", "ESPN_CHUNK_WORKERS", "RANGE_RETRY_SECONDS", + "SETTLED_AFTER_DAYS", + "SETTLED_CHUNK_TTL_SECONDS", "clamp_espn_limit", + "clear_settled_chunk_cache", "parse_espn_date_range", "espn_date_chunks", "merge_scoreboard_payloads", @@ -199,6 +245,88 @@ def _days_of_month(chunk: str) -> List[str]: return days +def _utc_today() -> date: + return datetime.now(timezone.utc).date() + + +def _chunk_last_day(chunk: str) -> Optional[date]: + try: + if len(chunk) == 8: + return date(int(chunk[:4]), int(chunk[4:6]), int(chunk[6:])) + if len(chunk) == 6: + first = date(int(chunk[:4]), int(chunk[4:6]), 1) + return _first_of_next_month(first) - timedelta(days=1) + except ValueError: + pass + return None + + +def _settled_key(url: str, params: Dict[str, Any], chunk: str) -> Optional[Any]: + """Memory key for a chunk that can no longer change, else None.""" + last_day = _chunk_last_day(chunk) + if last_day is None: + return None + if last_day > _utc_today() - timedelta(days=SETTLED_AFTER_DAYS): + return None + # dates is the chunk itself and limit is always ESPN_MAX_LIMIT here; + # anything else (groups=80 for FBS, a team filter) changes the answer. + rest = tuple(sorted( + (str(k), str(v)) for k, v in params.items() if k not in ("dates", "limit") + )) + return (url, rest, chunk) + + +def _settled_get(key: Any) -> Optional[Dict[str, Any]]: + with _settled_lock: + entry = _settled_chunks.get(key) + if entry is None: + return None + if time.monotonic() - entry[0] > SETTLED_CHUNK_TTL_SECONDS: + _settled_drop(key) + return None + _settled_chunks.move_to_end(key) + blob = entry[1] + # Decompress and parse outside the lock: every hit gets its own objects, + # so a caller mutating its payload cannot reach another caller's. + body = zlib.decompress(blob) + return cast(Dict[str, Any], orjson.loads(body) if orjson else json.loads(body)) + + +def _settled_drop(key: Any) -> None: + """Remove one entry. Caller holds _settled_lock.""" + global _settled_bytes + entry = _settled_chunks.pop(key, None) + if entry is not None: + _settled_bytes -= len(entry[1]) + + +def _settled_put(key: Any, response: Any, payload: Dict[str, Any]) -> None: + global _settled_bytes + body = getattr(response, "content", None) + if not isinstance(body, (bytes, bytearray)): + body = json.dumps(payload).encode("utf-8") + blob = zlib.compress(bytes(body), 6) + if len(blob) > SETTLED_CACHE_MAX_BYTES: + return + with _settled_lock: + _settled_drop(key) + _settled_chunks[key] = (time.monotonic(), blob) + _settled_bytes += len(blob) + while _settled_chunks and ( + len(_settled_chunks) > SETTLED_CACHE_MAX_ENTRIES + or _settled_bytes > SETTLED_CACHE_MAX_BYTES + ): + _settled_drop(next(iter(_settled_chunks))) + + +def clear_settled_chunk_cache() -> None: + """Forget every remembered settled chunk (tests, or a manual refresh).""" + global _settled_bytes + with _settled_lock: + _settled_chunks.clear() + _settled_bytes = 0 + + def espn_date_chunks(start: date, end: date) -> List[str]: """Cover ``[start, end]`` inclusive with ``dates=`` values ESPN accepts. @@ -255,8 +383,16 @@ def _fetch_one_chunk( One bad chunk must not sink the rest of the season, so every error is logged and swallowed here rather than raised to the gather below. + + A chunk whose days are settled (see ``SETTLED_AFTER_DAYS``) is answered + from memory when it was fetched in the last day. """ try: + settled = _settled_key(url, params, chunk) + if settled is not None: + cached = _settled_get(settled) + if cached is not None: + return cached response = fetch_get( session, url, @@ -266,7 +402,14 @@ def _fetch_one_chunk( **_memo_kwargs(cache_max_age), ) response.raise_for_status() - return cast(Optional[Dict[str, Any]], response_json(response)) + payload = response_json(response) + if settled is not None and isinstance(payload, dict): + events = payload.get("events") + # A capped month is truncated and gets re-asked day by day; + # remembering it would only cost memory. + if isinstance(events, list) and len(events) < ESPN_MAX_LIMIT: + _settled_put(settled, response, payload) + return cast(Optional[Dict[str, Any]], payload) except Exception as exc: # noqa: BLE001 - see docstring if logger: logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc) diff --git a/test/conftest.py b/test/conftest.py index 99f4467c..62cd2a83 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -343,6 +343,24 @@ def _hermetic_unit_refresh(monkeypatch, tmp_path_factory): monkeypatch.setattr(unit_refresh, 'SYSTEMD_DIR', str(tmp_path_factory.getbasetemp() / 'no-systemd')) +@pytest.fixture(autouse=True) +def _forget_settled_espn_chunks(monkeypatch): + """src.common.espn_dates remembers past days process-wide; tests fake + different answers for the same dates, so none may inherit another's. + + "Today" is also pinned to 2000-01-01, so no date a test uses counts as + settled unless the test says so (by pinning _utc_today itself). Without + that, a test asking for last month twice passes while that month is + recent and fails once it is three days old: the second ask is answered + from memory.""" + from datetime import date + from src.common import espn_dates + monkeypatch.setattr(espn_dates, "_utc_today", lambda: date(2000, 1, 1)) + espn_dates.clear_settled_chunk_cache() + yield + espn_dates.clear_settled_chunk_cache() + + @pytest.fixture(autouse=True) def reset_logging(): """Reset logging configuration before each test.""" diff --git a/test/test_espn_dates.py b/test/test_espn_dates.py index 33fa0c5e..3d21eeac 100644 --- a/test/test_espn_dates.py +++ b/test/test_espn_dates.py @@ -39,6 +39,14 @@ def forget_rejected_ranges(monkeypatch): monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0) +@pytest.fixture(autouse=True) +def nothing_is_settled_yet(monkeypatch): + """Pin "today" before every date these tests use, so the settled-chunk + memory stays out of tests that are not about it whatever the real date. + TestSettledChunkCache moves it forward.""" + monkeypatch.setattr(espn_dates, "_utc_today", lambda: date(2000, 1, 1)) + + class FakeResponse: def __init__(self, status_code=200, payload=None): self.status_code = status_code @@ -489,3 +497,95 @@ class TestConcurrency: assert live["peak"] <= espn_dates.ESPN_CHUNK_WORKERS assert live["peak"] > 1, "chunks should actually overlap" + + +class TestSettledChunkCache: + """Days that ended three or more days ago are fetched once a day, not hourly. + + The scoreboards re-fetch a 22-day window every hour; on hdpi (2026-10-02) + the 12 settled days were 68% of that window's bytes. + """ + + TODAY = date(2026, 10, 2) + # The scoreboards' default window on that day: 14 back, 7 ahead. + WINDOW = "20260918-20261009" + + @pytest.fixture(autouse=True) + def frozen_today(self, monkeypatch): + monkeypatch.setattr(espn_dates, "_utc_today", lambda: self.TODAY) + + def _events(self): + days = [(9, d) for d in range(18, 31)] + [(10, d) for d in range(1, 10)] + return {"2026%02d%02d" % (m, d): [{"id": f"{m}-{d}"}] for m, d in days} + + def test_the_second_refresh_only_asks_for_unsettled_days(self): + session = FakeSession(self._events()) + first = fetch_espn_scoreboard(session, URL, params={"dates": self.WINDOW}) + session.calls.clear() + + second = fetch_espn_scoreboard(session, URL, params={"dates": self.WINDOW}) + + asked = sorted(call["dates"] for call in session.calls) + # Sep 29 is the last settled day (today minus three). + assert asked == ["20260930"] + ["202610%02d" % d for d in range(1, 10)] + assert second["events"] == first["events"] # same events, same order + assert len(second["events"]) == 22 + + def test_a_hit_is_a_fresh_copy(self): + session = FakeSession(self._events()) + fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW}) + hit = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW}) + hit["events"][0]["id"] = "mutated" + again = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW}) + assert again["events"][0]["id"] == "9-18" + + def test_other_params_are_part_of_the_key(self): + session = FakeSession(self._events()) + fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW, "groups": 80}) + session.calls.clear() + fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW}) + assert len(session.calls) == 22 # a different question, nothing reused + + def test_entries_expire_after_a_day(self, monkeypatch): + clock = [1000.0] + monkeypatch.setattr(espn_dates.time, "monotonic", lambda: clock[0]) + session = FakeSession(self._events()) + fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW}) + clock[0] += espn_dates.SETTLED_CHUNK_TTL_SECONDS + 1 + session.calls.clear() + fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW}) + assert len(session.calls) == 22 + + def test_failed_chunks_are_not_remembered(self): + session = FakeSession(self._events(), fail_chunks={"20260920"}) + fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW}) + session.fail_chunks.clear() + session.calls.clear() + data = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW}) + assert "20260920" in [call["dates"] for call in session.calls] + assert len(data["events"]) == 22 + + def test_a_capped_month_is_not_remembered_but_its_days_are(self): + full = [{"id": f"x{i}"} for i in range(ESPN_MAX_LIMIT)] + session = FakeSession({"202608": full, "20260801": [{"id": "d1"}]}) + fetch_espn_date_chunks(session, URL, params={"dates": "20260801-20260831"}) + session.calls.clear() + data = fetch_espn_date_chunks(session, URL, params={"dates": "20260801-20260831"}) + assert [call["dates"] for call in session.calls] == ["202608"] + assert [event["id"] for event in data["events"]] == ["d1"] + + def test_memory_is_bounded(self, monkeypatch): + monkeypatch.setattr(espn_dates, "SETTLED_CACHE_MAX_ENTRIES", 5) + session = FakeSession(self._events()) + fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW}) + assert len(espn_dates._settled_chunks) == 5 + assert espn_dates._settled_bytes == sum( + len(blob) for _, blob in espn_dates._settled_chunks.values()) + + def test_single_day_requests_are_untouched(self): + # The live path asks for today (or one day) as a plain request; that + # never goes through chunks or the memory. + session = FakeSession(self._events()) + for _ in range(2): + fetch_espn_scoreboard(session, URL, params={"dates": "20260918"}) + assert len(session.calls) == 2