From bcf387d902c1b66fb0ee515644104e050a39178c Mon Sep 17 00:00:00 2001 From: Joshua Date: Mon, 28 Sep 2026 22:21:53 -0400 Subject: [PATCH] Re-plan the missed sweep when the panel adopts a time zone The scheduler loaded settings once per cycle and slept on them until the old rollover, so a zone adopted from Home Assistant after start-up left a UTC host sweeping at UTC midnight (20:00 in New York), marking quests missed hours early. `MissedSweepScheduler.settings_changed` now wakes the sleeping loop when the stored zone or rollover time differs from the one it planned with; the panel's household clock calls it after adopting a reported zone. Before sweeping, the loop re-reads the settings and re-plans instead if they no longer match its plan. Tests: the scheduler re-plans for New York midnight when a panel request adopts the zone mid-sleep, and skips a stale sweep when the zone changed without a wake-up. The admin-precedence and invalid-header tests now prove adoption first, and the SSE client test asserts the timezone header rides beside the Authorization header. Task: 6351b36d-0eef-4e7d-bf5a-96b754cbc883 Co-Authored-By: Claude Opus 5.5 --- api/routes_panel.py | 5 +- api/scheduler.py | 54 ++++++++++++- tests/test_api_household_clock.py | 125 +++++++++++++++++++++++++++++- tests/test_sse_subscription.py | 18 +++++ 4 files changed, 196 insertions(+), 6 deletions(-) diff --git a/api/routes_panel.py b/api/routes_panel.py index 872dba8..9e06cc1 100644 --- a/api/routes_panel.py +++ b/api/routes_panel.py @@ -198,7 +198,9 @@ async def _household_clock( Home Assistant's ``time_zone`` in :data:`PANEL_TIMEZONE_HEADER`; until the admin chooses a zone it is adopted and persisted (:func:`nestquest_core.settings_store.adopt_reported_timezone`), so - the scheduler and the admin plane follow it too. An invalid + the admin plane follows it too, and the already-sleeping missed-sweep + scheduler is told (:meth:`MissedSweepScheduler.settings_changed`) so + it re-plans for the household rollover. An invalid reported zone is logged and ignored — the panel keeps serving on the stored settings. """ @@ -208,6 +210,7 @@ async def _household_clock( settings = await core_settings_store.adopt_reported_timezone( database, reported ) + request.app.state.sweep_scheduler.settings_changed(settings) except ValueError: LOGGER.warning( "Ignoring invalid %s header value %r", diff --git a/api/scheduler.py b/api/scheduler.py index 30498ea..e97de58 100644 --- a/api/scheduler.py +++ b/api/scheduler.py @@ -13,6 +13,14 @@ local date when unset) and publishes each returned ``nestquest_quest_missed`` event on the app's ONE transition publisher (the same SSE stream every route publishes through). +A household zone adopted from Home Assistant while the loop is asleep +must not leave it waiting for the OLD zone's rollover (a UTC host would +otherwise sweep at UTC midnight — 20:00 in New York — marking quests +missed hours early). :meth:`MissedSweepScheduler.settings_changed` +wakes the sleeping loop to re-plan whenever the zone or rollover time +it planned with has moved, and the loop re-reads the settings before +sweeping and re-plans instead if they no longer match its plan. + All sweep policy lives in the core (the watermark, the ``[watermark, today)`` window, the no-completion-event rule): this scheduler only decides WHEN. A same-night rerun — e.g. two workers, @@ -124,6 +132,11 @@ class MissedSweepScheduler: ) self._sleep = sleep if sleep is not None else asyncio.sleep self._task: asyncio.Task | None = None + #: ``(timezone, day_rollover_time)`` the pending sweep was + #: planned with; ``None`` until the first plan. + self._planned: tuple[str, str] | None = None + #: Set by :meth:`settings_changed` to cut the current wait short. + self._wake = asyncio.Event() def start(self) -> None: """Start the background loop (idempotent: a running one stays).""" @@ -142,6 +155,18 @@ class MissedSweepScheduler: pass self._task = None + def settings_changed(self, settings: object) -> None: + """Re-plan the pending sweep if ``settings`` moved its rollover. + + Called with freshly stored settings (e.g. a zone adopted from + Home Assistant). A differing ``timezone`` or + ``day_rollover_time`` wakes the sleeping loop, which reloads the + settings and schedules the next sweep for the new household + rollover; an unchanged plan is left sleeping. + """ + if (settings.timezone, settings.day_rollover_time) != self._planned: + self._wake.set() + @property def running(self) -> bool: """Whether the background loop task is alive.""" @@ -150,6 +175,9 @@ class MissedSweepScheduler: async def _run_forever(self) -> None: """Schedule → sweep → repeat, forever (until cancelled).""" while True: + # Cleared BEFORE the settings read, so a change stored after + # the read still wakes this cycle's wait. + self._wake.clear() try: settings = await core_settings_store.load_settings( self._database @@ -168,6 +196,8 @@ class MissedSweepScheduler: ) await self._sleep(_RETRY_BACKOFF_SECONDS) continue + plan = (settings.timezone, settings.day_rollover_time) + self._planned = plan # The host may run UTC: the rollover is a household wall-clock # time, so the clock read is re-expressed in the stored zone. delay = _delay_seconds( @@ -178,8 +208,17 @@ class MissedSweepScheduler: settings.day_rollover_time, delay, ) - await self._sleep(delay) + if not await self._sleep_unless_woken(delay): + continue try: + # Re-read before sweeping: a zone or rollover change the + # wake-up missed means this is not the household's + # rollover any more, so re-plan instead of sweeping. + settings = await core_settings_store.load_settings( + self._database + ) + if (settings.timezone, settings.day_rollover_time) != plan: + continue await self._run_once( settings.household_now(self._clock()).date() ) @@ -194,6 +233,19 @@ class MissedSweepScheduler: ) await self._sleep(_RETRY_BACKOFF_SECONDS) + async def _sleep_unless_woken(self, delay: float) -> bool: + """Wait ``delay``; ``False`` if :meth:`settings_changed` woke it.""" + sleeper = asyncio.ensure_future(self._sleep(delay)) + waker = asyncio.ensure_future(self._wake.wait()) + try: + await asyncio.wait( + {sleeper, waker}, return_when=asyncio.FIRST_COMPLETED + ) + return sleeper.done() + finally: + sleeper.cancel() + waker.cancel() + async def _run_once(self, today: datetime.date) -> None: """Run ONE sweep for ``today`` and publish what it built. diff --git a/tests/test_api_household_clock.py b/tests/test_api_household_clock.py index 80e5404..2879403 100644 --- a/tests/test_api_household_clock.py +++ b/tests/test_api_household_clock.py @@ -31,6 +31,7 @@ from zoneinfo import ZoneInfo import pytest from api import routes_admin, routes_panel +from api import scheduler as scheduler_module from api.database import _executor from api.scheduler import MissedSweepScheduler, _delay_seconds from tests.admin_jwt_harness import ( @@ -417,17 +418,28 @@ async def test_reported_zone_follows_home_assistant_changes( assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE +async def _adopt_new_york(household: SimpleNamespace) -> None: + """Prove automatic adoption first, so later assertions can't pass + on code that ignores the reported zone altogether.""" + await _panel_pack_bag(household, HOUSEHOLD_ZONE) + stored = await _stored_settings(household) + assert stored["timezone"] == HOUSEHOLD_ZONE + assert stored["timezone_configured"] is False + + @pytest.mark.parametrize("chosen", ["America/Los_Angeles", ""]) async def test_admin_chosen_zone_wins_over_reported_zone( household: SimpleNamespace, chosen: str ) -> None: + await _adopt_new_york(household) response = await household.client.patch( "/api/v1/admin/settings", json={"timezone": chosen, "timezone_configured": True}, headers=_admin_headers(), ) assert response.status_code == 200 - await _panel_pack_bag(household, HOUSEHOLD_ZONE) + # A conflicting report is no longer adopted. + await _panel_pack_bag(household, "America/Chicago") stored = await _stored_settings(household) assert stored["timezone"] == chosen assert stored["timezone_configured"] is True @@ -436,8 +448,113 @@ async def test_admin_chosen_zone_wins_over_reported_zone( async def test_invalid_reported_zone_is_ignored( household: SimpleNamespace, ) -> None: + await _adopt_new_york(household) household.clock["now"] = _utc(13, 59, 59) _today_iso, instance = await _panel_pack_bag(household, "Mars/Olympus_Mons") - # Host-local (UTC) behaviour, unchanged store. - assert instance["overdue"] is True - assert (await _stored_settings(household))["timezone"] == "" + # Still read in the adopted zone: 09:59:59 EDT is not overdue. + assert instance["overdue"] is False + assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE + + +# --- the scheduler follows an adopted zone (task 6351b36d) ------------------- +# +# On a UTC host with no stored zone the scheduler first plans for UTC +# midnight; a zone adopted while it sleeps must move the sweep to the +# household's midnight rather than sweep at 20:00 EDT. + +#: 13:00 UTC (09:00 EDT) to UTC midnight. +_UTC_MIDNIGHT_DELAY = 11 * 3600 +#: 13:00 UTC (09:00 EDT) to New York midnight (04:00 UTC). +_NEW_YORK_MIDNIGHT_DELAY = 15 * 3600 + + +async def test_scheduler_replans_when_panel_adopts_zone_while_sleeping( + temp_db_path: str, +) -> None: + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + app_state = runner.app.state + await app_state.sweep_scheduler.stop() + delays: list[float] = [] + slept = asyncio.Condition() + + async def sleep_stub(seconds: float) -> None: + async with slept: + delays.append(seconds) + slept.notify_all() + await asyncio.Event().wait() + + scheduler = MissedSweepScheduler( + app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub + ) + app_state.sweep_scheduler = scheduler + scheduler.start() + try: + async with slept: + await asyncio.wait_for( + slept.wait_for(lambda: len(delays) == 1), timeout=5 + ) + response = await client.get( + "/api/v1/panel/snapshot", + headers=_reporting_panel_headers(HOUSEHOLD_ZONE), + ) + assert response.status_code == 200 + async with slept: + await asyncio.wait_for( + slept.wait_for(lambda: len(delays) == 2), timeout=5 + ) + finally: + await scheduler.stop() + assert delays == [ + pytest.approx(_UTC_MIDNIGHT_DELAY), + pytest.approx(_NEW_YORK_MIDNIGHT_DELAY), + ] + + +async def test_scheduler_rechecks_settings_before_sweeping( + temp_db_path: str, monkeypatch +) -> None: + """A zone stored without a wake-up still stops the stale sweep.""" + database = _core_db.NestQuestDatabase(_executor) + await database.open(temp_db_path) + try: + await _core_migrations.apply_migrations(database) + sweeps: list[datetime.date] = [] + + async def sweep_stub(_database, *, today): + sweeps.append(today) + return [] + + monkeypatch.setattr( + scheduler_module.core_sweep, "run_missed_sweep", sweep_stub + ) + delays: list[float] = [] + replanned = asyncio.Event() + + async def sleep_stub(seconds: float) -> None: + delays.append(seconds) + if len(delays) == 1: + # The UTC rollover "arrives" after the zone was stored + # behind the scheduler's back. + await _settings_store.adopt_reported_timezone( + database, HOUSEHOLD_ZONE + ) + return + replanned.set() + await asyncio.Event().wait() + + scheduler = MissedSweepScheduler( + database, clock=lambda: _utc(13, 0), sleep=sleep_stub + ) + scheduler.start() + try: + await asyncio.wait_for(replanned.wait(), timeout=5) + finally: + await scheduler.stop() + assert sweeps == [] + assert delays == [ + pytest.approx(_UTC_MIDNIGHT_DELAY), + pytest.approx(_NEW_YORK_MIDNIGHT_DELAY), + ] + finally: + await database.close() diff --git a/tests/test_sse_subscription.py b/tests/test_sse_subscription.py index 0a5c26d..4b1552a 100644 --- a/tests/test_sse_subscription.py +++ b/tests/test_sse_subscription.py @@ -351,6 +351,24 @@ async def test_stream_events_yields_documented_frames_and_sends_bearer() -> None assert call["json"] is None +async def test_stream_events_reports_household_timezone() -> None: + """The SSE GET carries Home Assistant's zone beside the Bearer token + (task 6351b36d), like the snapshot and completion requests.""" + frame = _frame_text(*DOCUMENTED_FRAMES[0]) + transport = _StreamTransport(_StreamingResponse(200, [frame])) + client = NestQuestApiClient( + BASE_URL, TOKEN, transport, household_timezone="America/New_York" + ) + + frames = [frame async for frame in client.stream_events()] + + assert frames == [DOCUMENTED_FRAMES[0]] + assert transport.calls[0]["headers"] == { + "Authorization": f"Bearer {TOKEN}", + "X-NestQuest-Timezone": "America/New_York", + } + + async def test_stream_events_buffers_lines_across_chunks_and_joins_data() -> None: """Chunk-split lines parse, and multi-line data joins per SSE rules.""" first_type, first_payload = DOCUMENTED_FRAMES[0]