Adopt Home Assistant's time zone for the API household clock (task 6351b36d) (#275)
Co-authored-by: Joshua <joshua@cnjmail.com> Co-committed-by: Joshua <joshua@cnjmail.com>
This commit was merged in pull request #275.
This commit is contained in:
+100
-13
@@ -13,6 +13,27 @@ 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.
|
||||
|
||||
Guarantee: no sweep runs for a plan a committed adoption has replaced.
|
||||
An adopting writer holds :attr:`MissedSweepScheduler.settings_lock`
|
||||
across its write AND its :meth:`~MissedSweepScheduler.settings_changed`
|
||||
call; the loop holds the same lock across its plan read (read, record
|
||||
the plan, clear the wake flag) and across the pre-sweep re-read plus
|
||||
the sweep itself. So an adoption commits either before a plan read
|
||||
(which then sees it), after one (``settings_changed`` compares against
|
||||
that exact plan and wakes the wait), or after the sweep — never between
|
||||
a read and what the loop does with it. The lock is never held across
|
||||
the wait, and the loop takes nothing else before it, so a waiting
|
||||
panel request cannot deadlock: it is delayed at most by one settings
|
||||
read or one sweep.
|
||||
|
||||
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 +145,14 @@ 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()
|
||||
#: Serialises settings adoption with planning and sweeping (see
|
||||
#: the module docstring's guarantee).
|
||||
self.settings_lock = asyncio.Lock()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the background loop (idempotent: a running one stays)."""
|
||||
@@ -142,6 +171,20 @@ 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), with :attr:`settings_lock` held across the write
|
||||
and this call so the comparison is against the loop's current
|
||||
plan, never a read still in flight. 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."""
|
||||
@@ -151,12 +194,18 @@ class MissedSweepScheduler:
|
||||
"""Schedule → sweep → repeat, forever (until cancelled)."""
|
||||
while True:
|
||||
try:
|
||||
settings = await core_settings_store.load_settings(
|
||||
self._database
|
||||
)
|
||||
rollover = datetime.time.fromisoformat(
|
||||
settings.day_rollover_time
|
||||
)
|
||||
# Under the lock no adoption can commit between this read
|
||||
# and the recorded plan, so any later one wakes the wait.
|
||||
async with self.settings_lock:
|
||||
self._wake.clear()
|
||||
settings = await core_settings_store.load_settings(
|
||||
self._database
|
||||
)
|
||||
rollover = datetime.time.fromisoformat(
|
||||
settings.day_rollover_time
|
||||
)
|
||||
plan = (settings.timezone, settings.day_rollover_time)
|
||||
self._planned = plan
|
||||
except Exception:
|
||||
# Not silent: the failure is logged and retried after
|
||||
# the backoff, so one bad settings document or a
|
||||
@@ -178,15 +227,30 @@ class MissedSweepScheduler:
|
||||
settings.day_rollover_time,
|
||||
delay,
|
||||
)
|
||||
await self._sleep(delay)
|
||||
try:
|
||||
await self._run_once(
|
||||
settings.household_now(self._clock()).date()
|
||||
)
|
||||
if not await self._sleep_unless_woken(delay):
|
||||
continue
|
||||
# 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. The
|
||||
# lock keeps an adoption from committing between this
|
||||
# check and the sweep.
|
||||
async with self.settings_lock:
|
||||
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()
|
||||
)
|
||||
except Exception:
|
||||
# Not silent either: the sweep error is logged and the
|
||||
# loop backs off before the next cycle, whose watermark
|
||||
# re-read makes an already-swept day a no-op.
|
||||
# Not silent either: a failed wait or sweep is logged and
|
||||
# the loop backs off before the next cycle, whose
|
||||
# watermark re-read makes an already-swept day a no-op.
|
||||
LOGGER.exception(
|
||||
"NestQuest scheduled missed sweep failed; retrying in "
|
||||
"%gs",
|
||||
@@ -194,6 +258,29 @@ 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.
|
||||
|
||||
Both children are cancelled AND awaited before returning (or
|
||||
before a cancellation propagates), so none outlives :meth:`stop`;
|
||||
a sleep that failed re-raises its exception here for the loop's
|
||||
retry policy.
|
||||
"""
|
||||
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
|
||||
)
|
||||
finally:
|
||||
slept = sleeper.done()
|
||||
sleeper.cancel()
|
||||
waker.cancel()
|
||||
await asyncio.gather(sleeper, waker, return_exceptions=True)
|
||||
if slept:
|
||||
sleeper.result()
|
||||
return slept
|
||||
|
||||
async def _run_once(self, today: datetime.date) -> None:
|
||||
"""Run ONE sweep for ``today`` and publish what it built.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user