From a5dbae5d50aa10407cef60ff8a0abf0ef4d15c9c Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 13:21:54 -0400 Subject: [PATCH] Serialize forced coordinator refreshes per entry NestQuestCoordinator.async_refresh holds a per-entry lock so overlapping service-path refreshes never interleave; the queued pass always publishes after the in-flight one. --- custom_components/nestquest/coordinator.py | 20 ++++++++++++++++- tests/test_events.py | 25 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 7d8e79b..08f9ad9 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -25,6 +25,7 @@ the admin header degrades to a harmless zero instead of guessing. """ from __future__ import annotations +import asyncio import datetime from dataclasses import dataclass from zoneinfo import ZoneInfo @@ -97,7 +98,16 @@ class NestQuestSnapshot: class NestQuestCoordinator(DataUpdateCoordinator): - """Shared refresh cycle for every NestQuest entity.""" + """Shared refresh cycle for every NestQuest entity. + + Forced refreshes (the service paths pushing an immediate update + after a completion) are SERIALIZED per entry: overlapping + complete/uncomplete calls must not interleave their snapshot + passes, or an older pass could publish a stale snapshot AFTER a + newer one and leave entities wrong until the next poll. Real + HA's coordinator already serializes internally; the lock keeps + the same guarantee on the stand-in and costs nothing upstream. + """ def __init__( self, @@ -122,6 +132,14 @@ class NestQuestCoordinator(DataUpdateCoordinator): ) self.entry_id = entry_id self.database = database + self._refresh_lock = asyncio.Lock() + + async def async_refresh(self) -> None: + """One refresh at a time; a queued refresh runs AFTER the + in-flight one publishes, so the last snapshot always reflects + the latest mutation.""" + async with self._refresh_lock: + await super().async_refresh() def _local_now(self) -> datetime.datetime: time_zone = ZoneInfo(self.hass.config.time_zone) diff --git a/tests/test_events.py b/tests/test_events.py index b15c50a..829f4ea 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -352,3 +352,28 @@ async def test_uncompletion_updates_entities_immediately( assert completed_after_uncomplete == 0, ( "sensor must reflect the reversal without a manual refresh" ) + + +async def test_forced_refreshes_serialize_no_stale_publish( + hass, make_entry +) -> None: + """Overlapping forced refreshes never interleave: a slow pass + pauses mid-snapshot, and the queued pass still publishes AFTER + it, so the last published snapshot reflects the latest mutation.""" + import asyncio + + entry, child, instances = await _setup_and_seed(hass, make_entry) + coordinator = entry.runtime_data.coordinator + order: list[str] = [] + + async def _slow_update(): + order.append("start") + await asyncio.sleep(0.01) + order.append("end") + return coordinator.data + + coordinator._async_update_data = _slow_update + await asyncio.gather(coordinator.async_refresh(), coordinator.async_refresh()) + assert order == ["start", "end", "start", "end"], ( + "overlapping refreshes must serialize: " + str(order) + )