Refactor: Pipeline Keymaster refreshes through dirty lock scopes - #695
Refactor: Pipeline Keymaster refreshes through dirty lock scopes#695tykeal wants to merge 14 commits into
Conversation
32d0a26 to
e3fd989
Compare
e3fd989 to
73ff643
Compare
73ff643 to
805b6f3
Compare
Refresh completion and mutations now notify only the per-lock coordinators whose data actually changed. Those changes are detected via sanitized before and after lock snapshots so unchanged lock state no longer wakes unrelated coordinators. Refactor _async_update_data() into async_refresh_all_locks() and async_refresh_lock(), with each returning the dirty entry-ID set produced by the refresh. Remove the manager's global listener leg and collapse the previous dual pending-notification handles into a single pending handle. Scope all 15 mutation call sites to specific config entry IDs. Refresh health transitions still fan out to every lock because availability is a global state, not per-lock data. Entity setters that mutate local state now mark their entry externally dirty so a later debounced refresh cannot lose the local change. Seed each per-lock coordinator's health from the manager when it is constructed. Refreshes remain sequential, and coordinator.data remains a dict mirror of kmlocks. Closes FutureTense#682 Assisted-by: GitHub Copilot CLI 1.0.75 (Claude Opus 5, model claude-opus-5) Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
805b6f3 to
6715f58
Compare
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #695 +/- ##
==========================================
+ Coverage 84.14% 93.96% +9.82%
==========================================
Files 10 42 +32
Lines 801 5403 +4602
Branches 0 30 +30
==========================================
+ Hits 674 5077 +4403
- Misses 127 326 +199
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This comment was marked as outdated.
This comment was marked as outdated.
Overlapping manager refreshes could reset the single refresh dirty-set slot while another refresh was still in flight. If that happened, dirty entry IDs recorded by the earlier refresh could be discarded before the refresh-completion fan-out was scheduled. Track active refreshes and only initialize the batch state for the first refresh in a batch. Record dirty entry IDs additively, consume the batch when refresh-completion notification work is scheduled, and keep the unknown sentinel path so missing dirty-set data still falls back to all-lock notification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Assisted-by: GitHub Copilot CLI 1.0.75 (Claude Opus 5, model claude-opus-5) Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
|
CRITICAL 1: the premise does not hold. targets = list(self._lock_coordinators) if all_entry_ids else list(entry_ids)
for entry_id in targets:
self._push_lock_coordinator_update(That target set is built from valid = {entry_id for entry_id in entry_ids if entry_id in self.kmlocks}
self._pending_notify_entry_ids |= valid
self._pending_notify_all_entry_ids |= all_entry_idsThe suggested identity check would suppress real notifications because locks are mutated in place, e.g. kmlock.lock_state = lock_stateCRITICAL 2: the premise does not hold. The raw arm intentionally bypasses the keymaster override at dispatching_snapshots.append(hass.bus._dispatching)
try:
DataUpdateCoordinator.async_update_listeners(coordinator)That arm asserts the old nested fan-out path trips the guard at CRITICAL 3: valid. Fixed in WARNING 1: verified the removed fields are gone as expected; no action needed. WARNING 2: verified WARNING 3: verified |
Always re-raise CancelledError from scoped refresh work after recording manager health state so asyncio cancellation is not converted into a normal failed refresh result. Assisted-by: GitHub Copilot CLI 1.0.75 (Claude Opus 5, model claude-opus-5) Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Ensure coordinator shutdown still cancels timers, clears lock coordinators, and runs the base coordinator shutdown when flushing pending save data fails. Preserve pending save IDs for retry and let cancellation propagate after cleanup. Assisted-by: GitHub Copilot CLI 1.0.75 (Claude Opus 5, model claude-opus-5) Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Suppressed comment A ( async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.async_add_listener(
self._handle_coordinator_update, self.coordinator_context
)
)It does not call Suppressed comment B ( |
Restore the pre-await Home Assistant stop listener unsubscribe so an EVENT_HOMEASSISTANT_STOP cannot re-enter coordinator shutdown while pending save data is flushing. Assisted-by: GitHub Copilot CLI 1.0.75 (Claude Opus 5, model claude-opus-5) Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Follow-up on shutdown re-entrancy: the window was reachable. |
Treat missing legacy child lock IDs as an empty list when carrying reload state, and track completed setup entries explicitly so concurrent SETUP_IN_PROGRESS entries do not trigger early coalesced save flushes. Assisted-by: GitHub Copilot CLI 1.0.75 (Claude Opus 5, model claude-opus-5) Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Suppressed review follow-up:
else:
if not self.last_update_success:
self.last_update_success = True
self.logger.info("Fetching %s data recovered", self.name)HA does not clear 3/4.
|
|
@firstof9 my agent has made more changes based upon real-world testing. I'm going to deploy this again soon to a production system, I just have to wait for the right window. |
|
Real-world testing is great. Rock on man! |
|
Ok, just deployed to the system that caused me to pull this into draft state. (14 locks. 1 parent, 13 children) much better now! Ready for any re-review you may have |
|
@firstof9 one thing to note, my agent did flag this refactor and some of the open PRs you've got as being in partial conflict. I'm leaning towards getting this merged first given the work that it is doing but if you want me to re-review your recent changes on #700 #701 and #702 and get those merged first and then do the extra work here let me know |
|
Nah go ahead and get your big fixes in and we can go back over my 3 part fix for that user's issue after. |
Code Review: FutureTense/keymaster PR #695SummaryThis PR completes the Issue #682 refresh pipeline refactor by routing refresh completion and mutation notifications through per-lock dirty scopes. It builds on merged PRs #680 and #681, which introduced per-lock coordinators and rebound entities to them. Critical Findings1.
|
Summary
Issue #670 exposed an event-loop storm in large Keymaster deployments: one
global coordinator fanned every refresh and mutation out to roughly
12k-49k entities, tripping Home Assistant 2026.7's
_MAX_QUEUED_EVENT_DISPATCHES = 10_000guard.This PR completes the issue #682 refresh pipeline refactor by routing refresh
completion and mutation notifications through dirty per-lock scopes. It builds
on merged #680, which introduced per-lock coordinators, and #681, which rebound
entities to those coordinators.
Details
only the per-lock coordinators whose entry data changed.
_async_update_data()intoasync_refresh_all_locks()andasync_refresh_lock(), returning dirty config entry IDs from refreshes.pending notification handles into one scoped handle.
coordinator.dataas a dict mirror ofkmlocks.Two correctness paths are deliberately not scoped:
availability is global, not per-lock data.
refresh cannot overwrite a local entity-setter change.
Validation
ruff check custom_components/ tests/ruff format custom_components/ tests/mypy custom_components/keymaster/pytest tests/— 1029 tests passingCloses #682