mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* perf(sports): fetch ESPN date chunks concurrently Since ESPN started rejecting `dates=YYYYMMDD-YYYYMMDD` on 2026-09-15, one season request became a chunk per month -- and a month over the 500-event cap becomes a request per day. A cold college-baseball season is about 130 requests, and they went out one at a time. That is slower than the 20s budget `_update_plugins()` shares across every plugin at startup, so scoreboards were logging `update() timed out` on first run and being deferred to the scheduled tick with nothing on the panel. Measured on a Pi 4 against live ESPN, March+April college baseball (63 requests, 3101 events): 11.2s sequential, 1.6s concurrent. Over a whole boot that moved football-scoreboard, ledmatrix-flights and birdnet-go inside the budget -- 13 plugins deferred before, 10 after. Chunks now go out six at a time, in two passes: months and edge days first, then the days of any month that came back capped. Six keeps the shared Session under requests' default pool_maxsize of 10, so no connection is discarded. Merged events still follow `espn_date_chunks` order -- a capped month's days are spliced back into its own slot -- so the payload does not depend on which request won the race. Request order is no longer significant, so the three tests that pinned it compare the chunks as a set and keep asserting the merged event order, which is the part callers actually see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(sports): drop capped month payloads before fetching their days Review of the concurrent chunk fetch found it raised the worst-case peak memory more than the concurrency explains. The old loop discarded a month that came back at the 500-event cap the moment it saw it; the rewrite kept every capped month alive in `results`/`slots` until all of their day requests had finished. Measured on a Pi 4 fetching 20260201-20260531 college baseball (four capped months, 5462 events), peak RSS growth over the call: sequential (main) 83 MB concurrent, months retained 121 MB (+43) concurrent, one worker 108 MB -- the retention alone was +25 concurrent, months dropped 98-100 MB (+16) docs/LOW_MEMORY_BOARDS.md puts a 1 GB Pi 3B+ at under 200 MB of headroom, where running out makes the board unreachable until a power cycle, so the difference matters. The remaining +16 MB is six responses parsing at once; three workers saved about 6 MB more, within run-to-run noise, so the worker count stays at six. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(sports): state what ESPN_CHUNK_WORKERS was measured to do, not more The comment claimed the sequential fetch made scoreboards blow the 20s startup update() timeout. A boot on this branch still deferred 12 plugins and timed out baseball-scoreboard while its season fetches took 0.74s and 1.12s: the startup budget is spent on other per-plugin work. Say what was measured -- 17.7s sequential, 2.6-3.3s concurrent -- and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
217 lines
8.0 KiB
Python
217 lines
8.0 KiB
Python
"""BackgroundDataService recovers from ESPN rejecting a date range.
|
|
|
|
This is the path that broke on the Pi: NFLRecentManager submits the whole
|
|
season as ``dates=20260801-20270301``, the service fetches it on a worker
|
|
thread, and from 2026-09-15 every one of those fetches came back
|
|
``400 Client Error: Bad Request``. The service must re-ask in chunks and cache
|
|
the merged season rather than mark it failed.
|
|
"""
|
|
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.background_data_service import BackgroundDataService
|
|
from src.common import espn_dates
|
|
from src.common.espn_dates import (
|
|
ESPN_MAX_LIMIT,
|
|
fetch_espn_scoreboard,
|
|
parse_espn_date_range,
|
|
)
|
|
|
|
URL = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard"
|
|
|
|
|
|
class FakeResponse:
|
|
def __init__(self, status_code, payload=None):
|
|
self.status_code = status_code
|
|
self._payload = payload if payload is not None else {"events": []}
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
def raise_for_status(self):
|
|
if self.status_code >= 400:
|
|
raise RuntimeError(str(self.status_code) + " Client Error: Bad Request")
|
|
|
|
|
|
class RangeRejectingSession:
|
|
"""Rejects day ranges the way ESPN has since 2026-09-15."""
|
|
|
|
def __init__(self, events_by_chunk=None, fail_chunks=()):
|
|
self.calls = []
|
|
self.events_by_chunk = events_by_chunk or {}
|
|
self.fail_chunks = set(fail_chunks)
|
|
|
|
def get(self, url, params=None, headers=None, timeout=None):
|
|
params = dict(params or {})
|
|
dates = str(params.get("dates", ""))
|
|
self.calls.append(params)
|
|
if parse_espn_date_range(dates) is not None:
|
|
return FakeResponse(400)
|
|
if dates in self.fail_chunks:
|
|
return FakeResponse(500)
|
|
return FakeResponse(200, {"events": self.events_by_chunk.get(dates, [])})
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def ranges_not_yet_rejected(monkeypatch):
|
|
# The "ranges are rejected" memo is process-wide; every test starts clean.
|
|
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
|
|
|
|
|
|
@pytest.fixture
|
|
def cache():
|
|
manager = MagicMock()
|
|
manager.get.return_value = None # always a miss: force the fetch path
|
|
return manager
|
|
|
|
|
|
@pytest.fixture
|
|
def service(cache):
|
|
svc = BackgroundDataService(cache, max_workers=1, request_timeout=5)
|
|
yield svc
|
|
svc.shutdown(wait=False)
|
|
|
|
|
|
def submit_and_wait(service, session, dates, limit=1000):
|
|
results = []
|
|
with patch.object(service, "session", session):
|
|
request_id = service.submit_fetch_request(
|
|
sport="nfl",
|
|
year=2026,
|
|
url=URL,
|
|
cache_key="nfl_schedule_2026",
|
|
params={"dates": dates, "limit": limit},
|
|
max_retries=0,
|
|
callback=results.append,
|
|
)
|
|
deadline = time.time() + 5
|
|
while not service.is_request_complete(request_id) and time.time() < deadline:
|
|
time.sleep(0.02)
|
|
assert results, "the fetch never completed"
|
|
return results[0]
|
|
|
|
|
|
def test_the_service_advertises_that_it_handles_ranges():
|
|
# Plugins read this to decide whether to submit a season range here or
|
|
# fetch it themselves on a core that predates the fix.
|
|
assert BackgroundDataService.handles_espn_date_ranges is True
|
|
|
|
|
|
def test_a_rejected_season_is_recovered_and_cached(service, cache):
|
|
session = RangeRejectingSession(
|
|
{"202609": [{"id": "a"}, {"id": "b"}], "20261001": [{"id": "c"}]}
|
|
)
|
|
result = submit_and_wait(service, session, "20260901-20261001")
|
|
|
|
assert result.success, result.error
|
|
cached_key, cached_payload = cache.set.call_args[0][:2]
|
|
assert cached_key == "nfl_schedule_2026"
|
|
assert [event["id"] for event in cached_payload["events"]] == ["a", "b", "c"]
|
|
|
|
|
|
def test_a_full_season_costs_chunks_not_one_request_per_day(service):
|
|
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
|
submit_and_wait(service, session, "20260801-20270301")
|
|
sent = [call["dates"] for call in session.calls]
|
|
# Eight chunks rather than 213 per-day requests. They are fetched
|
|
# concurrently, so the range is the only one pinned to a position.
|
|
assert sent[0] == "20260801-20270301"
|
|
assert sorted(sent[1:]) == [
|
|
"202608",
|
|
"202609",
|
|
"202610",
|
|
"202611",
|
|
"202612",
|
|
"202701",
|
|
"202702",
|
|
"20270301",
|
|
]
|
|
|
|
|
|
def test_the_limit_that_truncates_never_reaches_espn(service):
|
|
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
|
submit_and_wait(service, session, "20260901-20260930", limit=1000)
|
|
assert all(call["limit"] == ESPN_MAX_LIMIT for call in session.calls)
|
|
|
|
|
|
def test_a_non_scoreboard_endpoint_keeps_its_limit(service):
|
|
# /teams needs limit=1000: college football has 762 teams, and limit=500
|
|
# returns 500 of them. Only scoreboards truncate above 500.
|
|
session = RangeRejectingSession()
|
|
with patch.object(service, "session", session):
|
|
request_id = service.submit_fetch_request(
|
|
sport="ncaa_fb",
|
|
year=2026,
|
|
url="https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams",
|
|
cache_key="ncaa_fb_teams",
|
|
params={"limit": 1000},
|
|
max_retries=0,
|
|
)
|
|
deadline = time.time() + 5
|
|
while not service.is_request_complete(request_id) and time.time() < deadline:
|
|
time.sleep(0.02)
|
|
assert session.calls[0]["limit"] == 1000
|
|
|
|
|
|
def test_losing_every_chunk_is_a_failure_not_an_empty_season(service, cache):
|
|
session = RangeRejectingSession(fail_chunks={"202609"})
|
|
result = submit_and_wait(service, session, "20260901-20260930")
|
|
# An empty payload would be cached and read as "no games all year".
|
|
assert not result.success
|
|
cache.set.assert_not_called()
|
|
|
|
|
|
def test_a_400_on_a_single_day_is_still_a_failure(service, cache):
|
|
class AlwaysBad(RangeRejectingSession):
|
|
def get(self, url, params=None, headers=None, timeout=None):
|
|
self.calls.append(dict(params or {}))
|
|
return FakeResponse(400)
|
|
|
|
session = AlwaysBad()
|
|
result = submit_and_wait(service, session, "20260913")
|
|
assert not result.success
|
|
assert len(session.calls) == 1
|
|
cache.set.assert_not_called()
|
|
|
|
|
|
def test_a_rejection_seen_by_the_service_is_remembered_for_scoreboards(service):
|
|
submit_and_wait(service, RangeRejectingSession({"202609": [{"id": "a"}]}),
|
|
"20260901-20260930")
|
|
|
|
# A live scoreboard asking for a range next must not spend a doomed 400.
|
|
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
|
data = fetch_espn_scoreboard(session, URL, params={"dates": "20260901-20260930"})
|
|
assert [call["dates"] for call in session.calls] == ["202609"]
|
|
assert [event["id"] for event in data["events"]] == ["a"]
|
|
|
|
|
|
def test_a_rejection_seen_by_a_scoreboard_skips_the_range_in_the_service(service, cache):
|
|
fetch_espn_scoreboard(RangeRejectingSession(), URL,
|
|
params={"dates": "20260901-20260930"})
|
|
|
|
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
|
result = submit_and_wait(service, session, "20260901-20261001")
|
|
assert result.success, result.error
|
|
assert [call["dates"] for call in session.calls] == ["202609", "20261001"]
|
|
|
|
|
|
def test_known_rejection_with_every_chunk_failing_asks_the_range_once(service, cache):
|
|
espn_dates._note_range_rejected()
|
|
session = RangeRejectingSession(fail_chunks={"202609"})
|
|
result = submit_and_wait(service, session, "20260901-20260930")
|
|
|
|
assert not result.success
|
|
# Chunks once, then the range for a real error -- not the chunks again.
|
|
assert [call["dates"] for call in session.calls] == ["202609", "20260901-20260930"]
|
|
cache.set.assert_not_called()
|
|
|
|
|
|
def test_ranges_are_tried_again_once_the_memo_expires(service, monkeypatch):
|
|
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", time.monotonic() - 1)
|
|
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
|
submit_and_wait(service, session, "20260901-20260930")
|
|
assert [call["dates"] for call in session.calls] == ["20260901-20260930", "202609"]
|