Files
heating-mpc-ha/tests/ha/test_concurrency.py
T
buzeraandClaude Opus 5.5 28ff953ddd Stop publishing and saving from the start of unload; keep deletions final
Home Assistant marks a coordinator shut down only after the platforms are
unloaded, and the model save checked for shutdown before waiting for the
storage lock. A deletion could therefore be followed by a save that had
been waiting for it, recreating the file. Also, unloading cancels a running
scheduled update, which released the lock while the file write continued
in a worker thread.

- The coordinator is marked closing before platforms unload (restored if
  unloading fails); closing updates, relay callbacks and saves do nothing.
- The save checks closing and a removed-model mark after acquiring the
  lock, and runs shielded so the lock is held until the write finishes.
- Removal marks the model removed under the lock before deleting it.
- A failed setup also shuts the coordinator down.

Tests: unload and reload during a running optimization, removal during a
save, a save waiting for a deletion, simultaneous saves, repeated
setup/unload, shutdown ordering and failed setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:27:07 +02:00

135 lines
5.3 KiB
Python

"""Unload, reload and removal while work is still in flight, in the real HA lifecycle."""
import asyncio
import threading
from datetime import timedelta
from unittest.mock import patch
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import async_fire_time_changed
from custom_components.heating_mpc import coordinator as coordinator_module
from custom_components.heating_mpc.const import DOMAIN
REAL_OPTIMIZE = coordinator_module.optimize
async def setup(hass, entry):
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return hass.data[DOMAIN][entry.entry_id]
def trained(coordinator):
model = coordinator.learners["sensor.room"]
model.count, model.on_count, model.off_count = 250, 125, 125
coordinator._update_counts()
class BlockedOptimizer:
"""Holds the optimizer in its worker thread until released."""
def __init__(self):
self.started, self.release = threading.Event(), threading.Event()
def __call__(self, *args, **kwargs):
self.started.set()
self.release.wait(5)
return REAL_OPTIMIZE(*args, **kwargs)
async def wait_started(self):
# Polled on the event loop: the test executor may run one job at a time.
for _ in range(500):
if self.started.is_set():
return True
await asyncio.sleep(0.01)
return False
def status_id(hass, entry):
return er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status")
async def test_unload_during_optimization_publishes_nothing(hass, entry, inputs):
coordinator = await setup(hass, entry)
trained(coordinator)
shown = coordinator.data
blocked = BlockedOptimizer()
with patch.object(coordinator_module, "optimize", blocked):
refresh = hass.async_create_task(coordinator.async_refresh())
assert await blocked.wait_started()
assert await hass.config_entries.async_unload(entry.entry_id)
state_after_unload = hass.states.get(status_id(hass, entry))
blocked.release.set()
await refresh
await hass.async_block_till_done()
# The plan finished after unloading began: kept out of the data and the states.
assert coordinator.data is shown
assert coordinator.data["status"] == "learning"
assert hass.states.get(status_id(hass, entry)) == state_after_unload
async def test_reload_during_optimization_shows_only_the_new_coordinator(hass, entry, inputs):
old = await setup(hass, entry)
trained(old) # only in memory: the reloaded coordinator is not ready
blocked = BlockedOptimizer()
with patch.object(coordinator_module, "optimize", blocked):
refresh = hass.async_create_task(old.async_refresh())
assert await blocked.wait_started()
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
new = hass.data[DOMAIN][entry.entry_id]
blocked.release.set()
await refresh
await hass.async_block_till_done()
assert new is not old and old.closing
assert old.data["status"] != "ready"
assert hass.states.get(status_id(hass, entry)).state == new.data["status"] == "learning"
async def test_removal_during_a_save_leaves_no_model_behind(hass, entry, inputs, freezer):
coordinator = await setup(hass, entry)
started, release = asyncio.Event(), asyncio.Event()
write = coordinator._store.async_save
async def slow_save(data):
started.set()
await release.wait()
await write(data)
coordinator._store.async_save = slow_save
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
freezer.move_to(future)
async_fire_time_changed(hass, future) # a learning poll, which saves
await asyncio.wait_for(started.wait(), 5)
removal = hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
for _ in range(5):
await asyncio.sleep(0)
assert not removal.done() # deletion waits for the save in progress
release.set()
assert await removal
await hass.async_block_till_done()
assert await Store(hass, 1, f"{DOMAIN}.{entry.entry_id}").async_load() is None
async def test_repeated_setup_and_unload_leave_nothing_running(hass, entry, inputs, freezer):
entry.add_to_hass(hass)
coordinators = []
for _ in range(3):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinators.append(hass.data[DOMAIN][entry.entry_id])
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.entry_id not in hass.data[DOMAIN]
assert all(c.closing and c._unsub_refresh is None and not c._listeners for c in coordinators)
counts = [c.learners["sensor.room"].count for c in coordinators]
hass.states.async_set("switch.boiler", "on")
future = dt_util.utcnow() + timedelta(minutes=10)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert not any(c._relay_events for c in coordinators)
assert [c.learners["sensor.room"].count for c in coordinators] == counts