Refactor: Pipeline Keymaster refreshes through dirty lock scopes - #695
Refactor: Pipeline Keymaster refreshes through dirty lock scopes#695tykeal wants to merge 11 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.95% +9.81%
==========================================
Files 10 42 +32
Lines 801 5394 +4593
Branches 0 30 +30
==========================================
+ Hits 674 5068 +4394
- 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 |
Code Review: FutureTense/keymaster PR #695 — "Refactor: Pipeline Keymaster refreshes through dirty lock scopes"🔴 CriticalNone found.
|
|
I'll push it to the system that I have that caused all of this before I do the final merge. I did that with the last refactor as well to make sure it was good to go. I waited until all reviews were done so that all the review gotchas were cleared up. |
|
Found a startup issue with the new code. Moving to draft so we don't accidentally merge this |
|
Moved to draft — do not merge. A production deployment of this branch showed two regressions:
Root cause of (1): Home Assistant's Reproduction: with 5 entries set up through the normal setup path, only the first entry's entities became available; entries 1-4 remained unavailable. Root cause of (2): Measured startup through
Planned remediation: initialize entity state when the entity is added rather than relying solely on a subsequent notification; drop the redundant deep copy; and avoid full-lock snapshotting on the startup refresh path. Will un-draft once fixed and re-verified. Also note that a concurrency concern raised earlier was checked and refuted: |
Apply the current per-lock coordinator data after Keymaster entities are added so startup does not depend on catching a later dirty-lock notification. 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>
Remove the extra deepcopy from sanitized lock snapshots; the snapshot conversion already builds independent mutable containers. 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>
|
Fixed two regressions in this draft:
Regression test: tests/test_coordinator_lifecycle.py::test_multi_entry_startup_entities_initialize_available fails on 469e3ef with unavailable entities across the startup entries, and passes now. Startup timing through async_setup_entry(), 30 slots/lock, I/O mocked:
Validation: ruff 0.16 check/format, mypy, and pytest pass locally. CI is green; changed source lines are covered in coverage.xml. |
Use the existing scoped lock refresh during per-entry setup instead of running a full all-lock manager refresh for every entry. Preserve manager health transitions for scoped refreshes, propagate parent settings when a child is refreshed, avoid unchanged scoped refresh store writes, and keep restart setup coverage bounded to one full refresh plus per-lock scoped refreshes. 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>
Exercise scoped refresh cancellation, deferred notification flushing, and relationship rebuilds when a lock changes parents so the structural startup fix remains fully covered. 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>
|
Startup refresh storm fix pushed in ed1eca9. Restart path (
Fresh-install path, same local harness:
Refresh-count proof:
Changed: |
Batch per-entry setup save work until setup reaches all Keymaster entries, flush pending saves on shutdown, and allow scoped saves to serialize only changed entries. Preserve provider runtime state across same-lock reloads, avoid setup refreshes advancing the periodic sync-status sweep, and cache persisted dataclass field lists used by lock serialization. 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>
|
Final startup profiling/fix pushed in a8a162b. 40-lock restart cProfile top cumulative functions before this follow-up (ed1eca9): elapsed 4343.4 ms under profiler; Restart path (
Fresh-install path, same local harness:
Refresh/write proof: startup remains 1 full refresh + N scoped refreshes; restart Additional changes: partial |
Move the persisted-save cache update after successful storage writes, drain pending save work without dropping mutations queued during disk I/O, and flush deferred setup saves on post-add setup failures. Treat retry/error entries as setup-terminal for pending save flushes, keep other locks' shared debounced refreshes scheduled after scoped refreshes, flush on Home Assistant stop, and avoid resurrecting disconnected provider state on reload. 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>
|
Correctness hardening for the coalesced-save path pushed in 5f9bc2a. Fixes/tests added:
Perf recheck after fixes (
Refresh counts remain bounded: fresh Validation: local |
Serialize coordinator save read/merge/write sections so overlapping partial and full saves cannot build from stale persisted-state caches. Also keep setup flush failures from masking the original setup exception and document the Store.async_save ordering test limitation. 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>
|
Final persistence hardening update for 544bd0c:
Restart startup benchmark, 30 slots/lock, real
Refresh-count proof remains bounded: restart N=40 performs 1 full refresh, 40 scoped refreshes, and 80 lock data updates. Validation: |
Remove the unreachable fallback initialization for the coordinator save lock and update the storage test fixture that bypasses coordinator construction to provide the required lock explicitly. 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>
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>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (1)
custom_components/keymaster/entity.py:76
KeymasterEntity.async_added_to_hass()callssuper().async_added_to_hass()and then calls_handle_coordinator_update()again when coordinator data is present. Home Assistant'sCoordinatorEntity.async_added_to_hass()already calls_handle_coordinator_update()once during entity add, so this results in a duplicate initial state write (which can be significant in large installations).
async def async_added_to_hass(self) -> None:
"""Apply current coordinator data after the entity is added."""
await super().async_added_to_hass()
if (
self.entity_id is None
or self.coordinator.data is None
or not self.coordinator.last_update_success
or self._kmlock is None
):
return
self._handle_coordinator_update()
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