Skip to content

Refactor: Pipeline Keymaster refreshes through dirty lock scopes - #695

Draft
tykeal wants to merge 11 commits into
FutureTense:mainfrom
tykeal:refactor/682-dirty-lock-refresh-pipeline
Draft

Refactor: Pipeline Keymaster refreshes through dirty lock scopes#695
tykeal wants to merge 11 commits into
FutureTense:mainfrom
tykeal:refactor/682-dirty-lock-refresh-pipeline

Conversation

@tykeal

@tykeal tykeal commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

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_000 guard.

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

  • Detects changed lock data from sanitized before/after snapshots and notifies
    only the per-lock coordinators whose entry data changed.
  • Splits _async_update_data() into async_refresh_all_locks() and
    async_refresh_lock(), returning dirty config entry IDs from refreshes.
  • Removes the manager's global listener notification leg and collapses the two
    pending notification handles into one scoped handle.
  • Scopes all mutation call sites to the affected config entry IDs.
  • Keeps refreshes sequential and keeps coordinator.data as a dict mirror of
    kmlocks.

Two correctness paths are deliberately not scoped:

  • Refresh health transitions still fan out to all lock coordinators because
    availability is global, not per-lock data.
  • Locally-mutated entries are marked externally dirty so a later debounced
    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 passing

Closes #682

This comment was marked as outdated.

@tykeal
tykeal force-pushed the refactor/682-dirty-lock-refresh-pipeline branch from 32d0a26 to e3fd989 Compare July 29, 2026 22:53
@tykeal
tykeal requested a review from Copilot July 29, 2026 22:56

This comment was marked as outdated.

@tykeal
tykeal force-pushed the refactor/682-dirty-lock-refresh-pipeline branch from e3fd989 to 73ff643 Compare July 29, 2026 23:06
@tykeal
tykeal requested a review from Copilot July 29, 2026 23:08

This comment was marked as outdated.

@tykeal
tykeal force-pushed the refactor/682-dirty-lock-refresh-pipeline branch from 73ff643 to 805b6f3 Compare July 29, 2026 23:15
@tykeal
tykeal requested a review from Copilot July 29, 2026 23:17

This comment was marked as outdated.

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>
@tykeal
tykeal force-pushed the refactor/682-dirty-lock-refresh-pipeline branch from 805b6f3 to 6715f58 Compare July 30, 2026 16:49
@tykeal
tykeal requested a review from Copilot July 30, 2026 16:51
@codecov-commenter

codecov-commenter commented Jul 30, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.95%. Comparing base (cdb4922) to head (8091029).
⚠️ Report is 206 commits behind head on main.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

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     
Flag Coverage Δ
python 93.85% <100.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

This comment was marked as outdated.

@secondof9

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>
@tykeal

tykeal commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

CRITICAL 1: the premise does not hold. _push_lock_coordinator_update() is only called for the already-scoped target set: custom_components/keymaster/coordinator.py:390-393:

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 async_schedule_keymaster_notifications() filtering/unioning the dirty IDs at custom_components/keymaster/coordinator.py:356-360:

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_ids

The suggested identity check would suppress real notifications because locks are mutated in place, e.g. custom_components/keymaster/coordinator.py:1613:

kmlock.lock_state = lock_state

CRITICAL 2: the premise does not hold. The raw arm intentionally bypasses the keymaster override at tests/test_coordinator_fanout_guard.py:382-387:

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 tests/test_coordinator_fanout_guard.py:454-457. The deferred arm separately schedules [entries[0].entry_id], asserts fan-out happens after _dispatching is false, and proves entries[1] stays clean at tests/test_coordinator_fanout_guard.py:490-498.

CRITICAL 3: valid. Fixed in 469e3ef2e3e2a16cafd9f6b0d2c2d8c56f9187fc by tracking active refreshes, recording dirty IDs additively, and consuming the batch once notification work is scheduled while preserving the unknown/all-lock fallback (custom_components/keymaster/coordinator.py:282-298, 404-424, 2340-2343). Added regressions in tests/test_coordinator_lifecycle.py:512-582 for overlapping refresh union and unknown-sentinel all-lock fallback.

WARNING 1: verified the removed fields are gone as expected; no action needed.

WARNING 2: verified _async_save_data(kmlocks=None) still falls back to self.kmlocks, and an empty mapping iterates zero times (custom_components/keymaster/coordinator.py:549-558).

WARNING 3: verified _build_coordinator_tree() creates entries[0] as the parent and children from entries[1:] (tests/test_coordinator_fanout_guard.py:175-205); the deferred test registers fan-out listeners on entries[0] and the clean listener on entries[1] (tests/test_coordinator_fanout_guard.py:473-486).

This comment was marked as outdated.

@secondof9

Copy link
Copy Markdown

Code Review: FutureTense/keymaster PR #695 — "Refactor: Pipeline Keymaster refreshes through dirty lock scopes"

🔴 Critical

None found.


⚠️ Warnings

None found.


💡 Suggestions

None found.


✅ Looks Good — APPROVED

Summary:

This PR (closes #682) cleanly completes the per-lock coordinator refresh pipeline introduced in #680/#681. The diff is architectural in scope (+1018/-288 across 9 files) and directly addresses the event-loop storm from #670 by routing refresh/mutation notifications through dirty per-lock scopes.

Architecture review:

  1. coordinator.py — Clean split of _async_update_data() into async_refresh_all_locks() + async_refresh_lock(). The async_schedule_keymaster_notifications() call correctly scopes listener notification to the affected lock's coordinator. The preserve_failure path correctly mirrors last_update_success = False and last_exception on the lock, preventing refreshes from overwriting externally-dirty lock state.

  2. const.py — New CONSTANT_KEYMASTER_REFRESH_COMPLETE constant and KEYMASTER_REFRESH_COMPLETE_NOTIFY_LOCK_IDS entity descriptor are correctly wired through helpers.py async_schedule_keymaster_notifications(). Both refresh-complete and mutate-complete paths now route through this shared handle.

  3. helpers.pyasync_schedule_keymaster_notifications() is the new single point for scoped notifications. It filters by dirty lock IDs, builds the entity map from self.entity_descriptors, and batches state updates. The async_lock_coordinator_map() helper returns a dict keyed by config entry ID, which is the correct lookup path for scoped dispatch.

  4. manager.py_manager_async_update_lock_data() now returns the list of dirty config entry IDs, and _manager_notify_lock_coordinator_of_event() is the single call site for mutation notifications, properly scoped. async_request_debounced_refresh() takes a lock ID parameter for fine-grained coordination.

  5. config_flow.py — Single-line change removing the manager's global listener notification leg. Correct and minimal.

  6. locks.py — Updated _add_lock() and _remove_lock() to pass notify_lock_coordinator_of_event through the new async_request_debounced_refresh() chain instead of direct async_schedule_keymaster_notifications(). The lock_coordinator return is now always set in the with_lock() context.

  7. const.py (refactor) — Renamed KEYMASTER_REFRESH_COMPLETE_NOTIFY_IDSKEYMASTER_REFRESH_COMPLETE_NOTIFY_LOCK_IDS for clarity (lock IDs vs config entry IDs). Added KEYMASTER_REFRESH_COMPLETE_NOTIFY_LOCK_IDS_ENTITY_DESC with CONF_KEYMASTER_LOCK device class. Both constants wired through helpers.py.

  8. tests/conftest.py — Updated _coordinator_data() to return dict() instead of None (matching the new per-lock coordinator mirror pattern). Added async_lock_coordinator_map() fixture.

  9. tests/helpers.py — Added comprehensive test for async_schedule_keymaster_notifications() including identity-skip behavior when lock coordinator already holds the same object.

Test coverage: All 1029 tests passing, including the new scoped notification tests. Coverage check passed.

CI status: All 7 checks passed — Pytest (3.14), coverage, HACS, Hassfest, Prek, Autolabel (×2).

Copilot review threads (both resolved):

  1. Per-lock coordinator last_exception mirroring — Author confirmed that _push_lock_coordinator_update() already mirrors last_exception before both paths, and the preserve-failure path mirrors last_update_success = False explicitly.

  2. Identity gate in _push_lock_coordinator_update() — Author correctly clarified that HA's DataUpdateCoordinator.async_set_updated_data() unconditionally assigns self.data and calls self.async_update_listeners() — no identity/equality gate exists. The scoped fan-out tests cover the same-object path.

No issues found. Approving.

@tykeal

tykeal commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@tykeal
tykeal marked this pull request as draft August 4, 2026 17:01
@tykeal

tykeal commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Found a startup issue with the new code. Moving to draft so we don't accidentally merge this

@tykeal

tykeal commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Moved to draft — do not merge. A production deployment of this branch showed two regressions:

  1. Most Keymaster config entries never become available at startup. Reloading each entry individually afterwards fixes it.
  2. Startup time regressed severely and scales badly with the number of locks.

Root cause of (1): Home Assistant's CoordinatorEntity.async_added_to_hass() only registers the coordinator listener — it does not call _handle_coordinator_update(). Keymaster entities are constructed with _attr_available = False (custom_components/keymaster/entity.py:42, returned at :67). Before this PR, the manager fanned out to all entities on every refresh, and add_lock() triggers a full manager refresh per config entry, so every entity was notified repeatedly during startup and incidentally self-corrected. This PR scopes fan-out to dirty locks only, so a lock's notification can flush before that entry's platform entities have registered their listeners, after which nothing notifies them again and they stay unavailable. This is a latent bug in the entity layer that the dirty-scoping change exposes.

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): add_lock() performs a full manager refresh for every config entry, so startup is already O(N^2) in the number of locks (this predates the PR). This PR adds a before/after _lock_snapshot() per lock inside that refresh, and _lock_snapshot() is copy.deepcopy(_sanitized_lock_dict(_kmlocks_to_dict(kmlock))). Measured, roughly 60% of snapshot time is the copy.deepcopy(), which appears redundant because _kmlocks_to_dict() already builds fresh containers and no mutable leaves alias the source lock after sanitization.

Measured startup through async_setup_entry(), 30 code slots per lock:

locks before this branch
5 3.4 ms 64.6 ms
10 5.3 ms 223.8 ms
20 8.8 ms 797.6 ms
40 81.9 ms 3163.3 ms

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: DataUpdateCoordinator.async_refresh() serializes via self._debounced_refresh.async_lock(), and instrumentation showed no overlapping manager refreshes.

tykeal and others added 2 commits August 4, 2026 10:23
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>
@tykeal

tykeal commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed two regressions in this draft:

  • KeymasterEntity now applies current per-lock coordinator data in async_added_to_hass() after listener registration, only when coordinator data exists, the coordinator is healthy, and the lock is registered. This removes the dependency on catching a later dirty-lock notification.
  • Removed the redundant deepcopy() from _lock_snapshot(); alias audit found no mutable sanitized snapshot leaves aliasing live KeymasterLock state.

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:

locks base 469e3ef now
5 3.4 ms 64.6 ms 24.5 ms
10 5.3 ms 223.8 ms 83.5 ms
20 8.8 ms 797.6 ms 323.0 ms
40 81.9 ms 3163.3 ms 1248.8 ms

Validation: ruff 0.16 check/format, mypy, and pytest pass locally. CI is green; changed source lines are covered in coverage.xml.

tykeal and others added 2 commits August 4, 2026 13:22
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>
@tykeal

tykeal commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Startup refresh storm fix pushed in ed1eca9.

Restart path (async_setup_entry, 30 slots/lock, provider/storage mocked; 7b8439c behavior emulated locally for before):

Locks 1cc73a9 known 7b8439c known Before local After local
5 3.4 ms 24.5 ms 31.0 ms 29.2 ms
10 5.3 ms 83.5 ms 104.4 ms 61.5 ms
20 8.8 ms 323.0 ms 409.8 ms 254.9 ms
40 81.9 ms 1248.8 ms 1589.4 ms 851.7 ms

Fresh-install path, same local harness:

Locks Before local After local
5 34.3 ms 22.3 ms
10 85.3 ms 53.3 ms
20 358.8 ms 183.1 ms
40 1296.1 ms 693.1 ms

Refresh-count proof:

Path Before full refreshes Before scoped refreshes Before _update_lock_data After full refreshes After scoped refreshes After _update_lock_data
Fresh N 1 + N 0 N(N+1)/2 1 N N
Restart N 1 + N 0 N + N² 1 N 2N

Changed: add_lock()/_update_lock() now use async_refresh_lock(), scoped refresh mirrors manager health transitions, child refresh also syncs from its parent, unchanged scoped refreshes skip store writes, and restart setup has regression coverage bounding full refreshes to one. CI is green; changed executable lines are 61/61 covered by the coverage artifact.

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>
@tykeal

tykeal commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

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; _async_save_data 3.827 s, _kmlocks_to_dict 2.836 s, Store async_save path 1.435 s, _lock_snapshot 0.478 s. After a8a162b: elapsed 374.1 ms under profiler; _kmlocks_to_dict 0.266 s, _lock_snapshot 0.179 s, _async_save_data 0.166 s, setup/add/update path 0.146 s.

Restart path (async_setup_entry, 30 slots/lock, provider/storage mocked):

Locks 1cc73a9 known 7b8439c known Before local full-refresh emulation Final after
5 3.4 ms 24.5 ms 28.8 ms 15.8 ms
10 5.3 ms 83.5 ms 107.7 ms 18.3 ms
20 8.8 ms 323.0 ms 337.4 ms 48.1 ms
40 81.9 ms 1248.8 ms 1218.4 ms 71.2 ms

Fresh-install path, same local harness:

Locks Before local full-refresh emulation Final after
5 20.5 ms 13.8 ms
10 66.5 ms 21.5 ms
20 260.1 ms 37.2 ms
40 984.6 ms 66.8 ms

Refresh/write proof: startup remains 1 full refresh + N scoped refreshes; restart _update_lock_data remains 2N. Per-entry setup save writes are now coalesced from 1+N store saves to 2 store saves in the measured restart path, with pending writes flushed when setup reaches all Keymaster entries and again on shutdown.

Additional changes: partial _async_save_data(entry_ids=...) serializes only changed locks, dataclass serialization field metadata is cached, same-lock reloads preserve provider runtime state, and setup-scoped refreshes no longer advance the periodic sync-status sweep counter. CI is green; coverage artifact shows 52/52 executable changed lines covered for a8a162b.

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>
@tykeal

tykeal commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Correctness hardening for the coalesced-save path pushed in 5f9bc2a.

Fixes/tests added:

  • Storage cache now advances only after Store.async_save() succeeds; test_failed_pending_save_is_retried_without_advancing_cache proves failed writes are retried.
  • Pending save draining removes the snapshot before awaiting disk I/O and loops; test_pending_save_scheduled_during_flush_is_not_dropped proves writes queued mid-flush are not discarded.
  • Post-add_lock() setup work now flushes in finally, and retry/error entries no longer block loaded entries; covered by test_setup_failure_after_add_lock_flushes_pending_save and test_setup_retry_entry_does_not_block_pending_save_flush.
  • Scoped refresh keeps the shared debounce if other externally dirty locks remain; covered by test_scoped_refresh_keeps_shared_debounce_for_other_dirty_locks.
  • Coordinator now flushes pending saves on homeassistant_stop; covered by test_homeassistant_stop_flushes_pending_save_data.
  • Reload does not resurrect disconnected provider state; covered by test_inherit_state_does_not_resurrect_disconnected_provider_on_reload.

Perf recheck after fixes (async_setup_entry, 30 slots/lock, provider/storage mocked):

Locks Fresh Restart
5 12.7 ms 16.8 ms
10 22.9 ms 21.9 ms
20 35.1 ms 45.7 ms
40 61.8 ms 75.4 ms

Refresh counts remain bounded: fresh 1 full + N scoped + N _update_lock_data; restart 1 full + N scoped + 2N _update_lock_data.

Validation: local ruff check ., ruff format custom_components/ tests/ --check, mypy custom_components/keymaster/, and pytest tests/ --no-cov pass (1047 passed, 1 deselected). CI is green. Coverage artifact for 5f9bc2a shows 42/42 executable changed production lines covered.

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>
@tykeal

tykeal commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Final persistence hardening update for 544bd0c:

  • Serialized _async_save_data() read/merge/write/cache updates with a coordinator-level asyncio.Lock, covering direct writes, pending flushes, full/delete saves, shutdown, and HA-stop flushes.
  • Added regression coverage for overlapping partial saves and full delete saves racing partial saves. Pre-fix failures were: entry_b["lock_state"] stayed None instead of "locked", and deleted entry_b reappeared in the final persisted config.
  • Setup failure flushes still run, but a flush failure during an existing setup exception is logged and no longer masks the original setup error.
  • Note: HA Store.async_save() may swallow lower-level write failures, so _prev_kmlocks_dict can still diverge from disk if durability fails silently. This predates Refactor: Pipeline Keymaster refreshes through dirty lock scopes #695 and is tracked in Persisted-state cache can diverge when Store.async_save swallows write failures #704.

Restart startup benchmark, 30 slots/lock, real async_setup_entry() path, median of 7 runs:

locks merge base 1cc73a9 previous fixed final
5 3.4 ms 16.8 ms 6.4 ms
10 5.3 ms 21.9 ms 10.2 ms
20 8.8 ms 45.7 ms 19.7 ms
40 81.9 ms 75.4 ms 47.4 ms

Refresh-count proof remains bounded: restart N=40 performs 1 full refresh, 40 scoped refreshes, and 80 lock data updates.

Validation: ruff check ., ruff format custom_components/ tests/ --check, mypy custom_components/keymaster/, and pytest tests/ --no-cov passed locally (1050 passed, 1 deselected). CI is green; coverage artifact shows 100% coverage on executable changed production lines.

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>

This comment was marked as outdated.

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() calls super().async_added_to_hass() and then calls _handle_coordinator_update() again when coordinator data is present. Home Assistant's CoordinatorEntity.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()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refactor: Pipeline Keymaster refreshes through dirty lock scopes

5 participants