From 1fe96b6b45f5bb0633f718f86dea6630e4f26733 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 11:14:36 +0000 Subject: [PATCH 1/4] Cache HACSStore instances per (hass, key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_store_for_key() built a new HACSStore on every call. Store.async_delay_save debounces via instance state — it cancels the previous _unsub_delayed_write and keeps a single EVENT_HOMEASSISTANT_FINAL_WRITE listener per Store. Returning a fresh instance each call defeats the debounce and leaks a final-write listener on every scheduled save. Cache the HACSStore on hass.data so callers share one instance per key. --- custom_components/hacs/utils/store.py | 9 +++++++-- tests/utils/test_store.py | 10 ++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/custom_components/hacs/utils/store.py b/custom_components/hacs/utils/store.py index f0afa07b6bf..8aab92eed1c 100644 --- a/custom_components/hacs/utils/store.py +++ b/custom_components/hacs/utils/store.py @@ -10,6 +10,8 @@ _LOGGER = LOGGER +STORE_CACHE_KEY = "hacs_store_cache" + class HACSStore(Store): """A subclass of Store that allows multiple loads in the executor.""" @@ -43,8 +45,11 @@ def _get_store_for_key(hass, key, encoder): def get_store_for_key(hass, key): - """Create a Store object for the key.""" - return _get_store_for_key(hass, key, JSONEncoder) + """Get (or create and cache) the Store object for the key.""" + cache = hass.data.setdefault(STORE_CACHE_KEY, {}) + if key not in cache: + cache[key] = _get_store_for_key(hass, key, JSONEncoder) + return cache[key] async def async_load_from_store(hass, key): diff --git a/tests/utils/test_store.py b/tests/utils/test_store.py index a0a3b9adb4b..cd279c50cdd 100644 --- a/tests/utils/test_store.py +++ b/tests/utils/test_store.py @@ -64,3 +64,13 @@ async def test_store_store(hass: HomeAssistant, caplog: pytest.LogCaptureFixture await async_save_to_store(hass, "test", {"test": "test"}) assert async_save_mock.call_count == 1 + + +async def test_store_instance_cached_per_key(hass: HomeAssistant) -> None: + """Repeated calls for the same key must return the same Store instance. + + Store.async_delay_save() debounces via instance state, so callers that + schedule delayed writes need the same Store object each time. + """ + assert get_store_for_key(hass, "test") is get_store_for_key(hass, "test") + assert get_store_for_key(hass, "test") is not get_store_for_key(hass, "other") From 60cd84ada6fc32127d5b9c6e19eeace28e9794fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 12:21:33 +0000 Subject: [PATCH 2/4] Move HACSStore cache onto config entry runtime_data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caching on hass.data leaked the cache across unload/reload — stale HACSStore objects (with their final-write listeners) survived until HA restarted. runtime_data is reset by async_setup_entry on each setup, so moving the cache there ties its lifecycle to the integration. Adds a typed HacsRuntimeData dataclass and HacsConfigEntry alias per the HA quality scale runtime-data rule, and replaces the hass.data sentinel in get_store_for_key with a lookup of the (single-instance) HACS config entry's runtime_data.store_cache. Falls back to an uncached Store when no entry is loaded (defensive, used by existing tests that don't set up an entry). --- custom_components/hacs/__init__.py | 12 +++++++----- custom_components/hacs/runtime_data.py | 21 +++++++++++++++++++++ custom_components/hacs/utils/store.py | 23 ++++++++++++++++++----- tests/utils/test_store.py | 24 +++++++++++++++++++++++- 4 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 custom_components/hacs/runtime_data.py diff --git a/custom_components/hacs/__init__.py b/custom_components/hacs/__init__.py index b93426ea380..71106932d59 100644 --- a/custom_components/hacs/__init__.py +++ b/custom_components/hacs/__init__.py @@ -11,7 +11,7 @@ from awesomeversion import AwesomeVersion from homeassistant.components.frontend import async_remove_panel from homeassistant.components.lovelace.system_health import system_health_info -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import Platform, __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -25,6 +25,7 @@ from .data_client import HacsDataClient from .enums import HacsDisabledReason, HacsStage, LovelaceMode from .frontend import async_register_frontend +from .runtime_data import HacsConfigEntry, HacsRuntimeData from .utils.data import HacsData from .utils.queue_manager import QueueManager from .utils.version import version_left_higher_or_equal_then_right @@ -35,9 +36,10 @@ async def _async_initialize_integration( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HacsConfigEntry, ) -> bool: """Initialize the integration""" + config_entry.runtime_data = HacsRuntimeData() hass.data[DOMAIN] = hacs = HacsBase() hacs.enable_hacs() @@ -179,7 +181,7 @@ async def async_try_startup(_=None): return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: HacsConfigEntry) -> bool: """Set up this integration using UI.""" config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry)) setup_result = await _async_initialize_integration(hass=hass, config_entry=config_entry) @@ -187,7 +189,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return setup_result and not hacs.system.disabled -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, config_entry: HacsConfigEntry) -> bool: """Handle removal of an entry.""" hacs: HacsBase = hass.data[DOMAIN] @@ -222,7 +224,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, config_entry: HacsConfigEntry) -> None: """Reload the HACS config entry.""" if not await async_unload_entry(hass, config_entry): return diff --git a/custom_components/hacs/runtime_data.py b/custom_components/hacs/runtime_data.py new file mode 100644 index 00000000000..750d4d29d33 --- /dev/null +++ b/custom_components/hacs/runtime_data.py @@ -0,0 +1,21 @@ +"""Runtime data attached to the HACS config entry.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, TypeAlias + +from homeassistant.config_entries import ConfigEntry + +if TYPE_CHECKING: + from .utils.store import HACSStore + + +@dataclass +class HacsRuntimeData: + """Runtime data for the HACS config entry.""" + + store_cache: dict[str, HACSStore] = field(default_factory=dict) + + +HacsConfigEntry: TypeAlias = ConfigEntry[HacsRuntimeData] diff --git a/custom_components/hacs/utils/store.py b/custom_components/hacs/utils/store.py index 8aab92eed1c..61da0933d67 100644 --- a/custom_components/hacs/utils/store.py +++ b/custom_components/hacs/utils/store.py @@ -1,16 +1,21 @@ """Storage handers.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.storage import Store from homeassistant.util import json as json_util -from ..const import VERSION_STORAGE +from ..const import DOMAIN, VERSION_STORAGE from ..exceptions import HacsException from .logger import LOGGER -_LOGGER = LOGGER +if TYPE_CHECKING: + from ..runtime_data import HacsConfigEntry -STORE_CACHE_KEY = "hacs_store_cache" +_LOGGER = LOGGER class HACSStore(Store): @@ -45,8 +50,16 @@ def _get_store_for_key(hass, key, encoder): def get_store_for_key(hass, key): - """Get (or create and cache) the Store object for the key.""" - cache = hass.data.setdefault(STORE_CACHE_KEY, {}) + """Get (or create and cache) the Store object for the key. + + The cache lives on the HACS config entry's runtime_data so it is + discarded when the entry is unloaded / reloaded. + """ + entries = hass.config_entries.async_entries(DOMAIN) + if not entries: + return _get_store_for_key(hass, key, JSONEncoder) + entry: HacsConfigEntry = entries[0] + cache = entry.runtime_data.store_cache if key not in cache: cache[key] = _get_store_for_key(hass, key, JSONEncoder) return cache[key] diff --git a/tests/utils/test_store.py b/tests/utils/test_store.py index cd279c50cdd..45499cfa639 100644 --- a/tests/utils/test_store.py +++ b/tests/utils/test_store.py @@ -6,6 +6,7 @@ from custom_components.hacs.const import VERSION_STORAGE from custom_components.hacs.exceptions import HacsException +from custom_components.hacs.runtime_data import HacsRuntimeData from custom_components.hacs.utils.store import ( async_load_from_store, async_remove_store, @@ -13,6 +14,8 @@ get_store_for_key, ) +from tests.common import create_config_entry + async def test_store_load(hass: HomeAssistant) -> None: """Test the store load.""" @@ -70,7 +73,26 @@ async def test_store_instance_cached_per_key(hass: HomeAssistant) -> None: """Repeated calls for the same key must return the same Store instance. Store.async_delay_save() debounces via instance state, so callers that - schedule delayed writes need the same Store object each time. + schedule delayed writes need the same Store object each time. The cache + is backed by the HACS config entry's runtime_data. """ + entry = create_config_entry() + entry.runtime_data = HacsRuntimeData() + entry.add_to_hass(hass) + assert get_store_for_key(hass, "test") is get_store_for_key(hass, "test") assert get_store_for_key(hass, "test") is not get_store_for_key(hass, "other") + + +async def test_store_cache_tied_to_entry_lifecycle(hass: HomeAssistant) -> None: + """A fresh runtime_data (simulating an entry reload) yields a new Store.""" + entry = create_config_entry() + entry.runtime_data = HacsRuntimeData() + entry.add_to_hass(hass) + + first = get_store_for_key(hass, "test") + + # Simulate an entry reload — async_setup_entry replaces runtime_data. + entry.runtime_data = HacsRuntimeData() + + assert get_store_for_key(hass, "test") is not first From 75ac0722db6d7f3dc01fff7534873d8dc588b325 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 12:25:52 +0000 Subject: [PATCH 3/4] Clear store cache on unload instead of using runtime_data Revert the runtime_data scaffolding and keep the simpler hass.data cache, but pop STORE_CACHE_KEY in async_unload_entry so cached HACSStore objects (and their EVENT_HOMEASSISTANT_FINAL_WRITE listeners) don't survive an unload/reload cycle. --- custom_components/hacs/__init__.py | 14 +++++++------- custom_components/hacs/runtime_data.py | 21 --------------------- custom_components/hacs/utils/store.py | 21 ++++++--------------- tests/utils/test_store.py | 24 +++++------------------- 4 files changed, 18 insertions(+), 62 deletions(-) delete mode 100644 custom_components/hacs/runtime_data.py diff --git a/custom_components/hacs/__init__.py b/custom_components/hacs/__init__.py index 71106932d59..1a494516afe 100644 --- a/custom_components/hacs/__init__.py +++ b/custom_components/hacs/__init__.py @@ -11,7 +11,7 @@ from awesomeversion import AwesomeVersion from homeassistant.components.frontend import async_remove_panel from homeassistant.components.lovelace.system_health import system_health_info -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import Platform, __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -25,8 +25,8 @@ from .data_client import HacsDataClient from .enums import HacsDisabledReason, HacsStage, LovelaceMode from .frontend import async_register_frontend -from .runtime_data import HacsConfigEntry, HacsRuntimeData from .utils.data import HacsData +from .utils.store import STORE_CACHE_KEY from .utils.queue_manager import QueueManager from .utils.version import version_left_higher_or_equal_then_right from .websocket import async_register_websocket_commands @@ -36,10 +36,9 @@ async def _async_initialize_integration( hass: HomeAssistant, - config_entry: HacsConfigEntry, + config_entry: ConfigEntry, ) -> bool: """Initialize the integration""" - config_entry.runtime_data = HacsRuntimeData() hass.data[DOMAIN] = hacs = HacsBase() hacs.enable_hacs() @@ -181,7 +180,7 @@ async def async_try_startup(_=None): return True -async def async_setup_entry(hass: HomeAssistant, config_entry: HacsConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up this integration using UI.""" config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry)) setup_result = await _async_initialize_integration(hass=hass, config_entry=config_entry) @@ -189,7 +188,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: HacsConfigEntry) return setup_result and not hacs.system.disabled -async def async_unload_entry(hass: HomeAssistant, config_entry: HacsConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Handle removal of an entry.""" hacs: HacsBase = hass.data[DOMAIN] @@ -220,11 +219,12 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: HacsConfigEntry) hacs.disable_hacs(HacsDisabledReason.REMOVED) hass.data.pop(DOMAIN, None) + hass.data.pop(STORE_CACHE_KEY, None) return unload_ok -async def async_reload_entry(hass: HomeAssistant, config_entry: HacsConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Reload the HACS config entry.""" if not await async_unload_entry(hass, config_entry): return diff --git a/custom_components/hacs/runtime_data.py b/custom_components/hacs/runtime_data.py deleted file mode 100644 index 750d4d29d33..00000000000 --- a/custom_components/hacs/runtime_data.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Runtime data attached to the HACS config entry.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, TypeAlias - -from homeassistant.config_entries import ConfigEntry - -if TYPE_CHECKING: - from .utils.store import HACSStore - - -@dataclass -class HacsRuntimeData: - """Runtime data for the HACS config entry.""" - - store_cache: dict[str, HACSStore] = field(default_factory=dict) - - -HacsConfigEntry: TypeAlias = ConfigEntry[HacsRuntimeData] diff --git a/custom_components/hacs/utils/store.py b/custom_components/hacs/utils/store.py index 61da0933d67..18ddef60514 100644 --- a/custom_components/hacs/utils/store.py +++ b/custom_components/hacs/utils/store.py @@ -1,22 +1,17 @@ """Storage handers.""" -from __future__ import annotations - -from typing import TYPE_CHECKING - from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.storage import Store from homeassistant.util import json as json_util -from ..const import DOMAIN, VERSION_STORAGE +from ..const import VERSION_STORAGE from ..exceptions import HacsException from .logger import LOGGER -if TYPE_CHECKING: - from ..runtime_data import HacsConfigEntry - _LOGGER = LOGGER +STORE_CACHE_KEY = "hacs_store_cache" + class HACSStore(Store): """A subclass of Store that allows multiple loads in the executor.""" @@ -52,14 +47,10 @@ def _get_store_for_key(hass, key, encoder): def get_store_for_key(hass, key): """Get (or create and cache) the Store object for the key. - The cache lives on the HACS config entry's runtime_data so it is - discarded when the entry is unloaded / reloaded. + The cache is cleared in async_unload_entry so Store instances do not + survive an integration unload / reload. """ - entries = hass.config_entries.async_entries(DOMAIN) - if not entries: - return _get_store_for_key(hass, key, JSONEncoder) - entry: HacsConfigEntry = entries[0] - cache = entry.runtime_data.store_cache + cache = hass.data.setdefault(STORE_CACHE_KEY, {}) if key not in cache: cache[key] = _get_store_for_key(hass, key, JSONEncoder) return cache[key] diff --git a/tests/utils/test_store.py b/tests/utils/test_store.py index 45499cfa639..f753699369c 100644 --- a/tests/utils/test_store.py +++ b/tests/utils/test_store.py @@ -6,16 +6,14 @@ from custom_components.hacs.const import VERSION_STORAGE from custom_components.hacs.exceptions import HacsException -from custom_components.hacs.runtime_data import HacsRuntimeData from custom_components.hacs.utils.store import ( + STORE_CACHE_KEY, async_load_from_store, async_remove_store, async_save_to_store, get_store_for_key, ) -from tests.common import create_config_entry - async def test_store_load(hass: HomeAssistant) -> None: """Test the store load.""" @@ -73,26 +71,14 @@ async def test_store_instance_cached_per_key(hass: HomeAssistant) -> None: """Repeated calls for the same key must return the same Store instance. Store.async_delay_save() debounces via instance state, so callers that - schedule delayed writes need the same Store object each time. The cache - is backed by the HACS config entry's runtime_data. + schedule delayed writes need the same Store object each time. """ - entry = create_config_entry() - entry.runtime_data = HacsRuntimeData() - entry.add_to_hass(hass) - assert get_store_for_key(hass, "test") is get_store_for_key(hass, "test") assert get_store_for_key(hass, "test") is not get_store_for_key(hass, "other") -async def test_store_cache_tied_to_entry_lifecycle(hass: HomeAssistant) -> None: - """A fresh runtime_data (simulating an entry reload) yields a new Store.""" - entry = create_config_entry() - entry.runtime_data = HacsRuntimeData() - entry.add_to_hass(hass) - +async def test_store_cache_cleared_on_unload(hass: HomeAssistant) -> None: + """Clearing STORE_CACHE_KEY (as async_unload_entry does) drops cached Stores.""" first = get_store_for_key(hass, "test") - - # Simulate an entry reload — async_setup_entry replaces runtime_data. - entry.runtime_data = HacsRuntimeData() - + hass.data.pop(STORE_CACHE_KEY, None) assert get_store_for_key(hass, "test") is not first From a13cd398c01edfd4e9d5f7a25a6445b4aeab2d63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 12:28:00 +0000 Subject: [PATCH 4/4] Sort imports --- custom_components/hacs/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/hacs/__init__.py b/custom_components/hacs/__init__.py index 1a494516afe..00a44dc78d1 100644 --- a/custom_components/hacs/__init__.py +++ b/custom_components/hacs/__init__.py @@ -26,8 +26,8 @@ from .enums import HacsDisabledReason, HacsStage, LovelaceMode from .frontend import async_register_frontend from .utils.data import HacsData -from .utils.store import STORE_CACHE_KEY from .utils.queue_manager import QueueManager +from .utils.store import STORE_CACHE_KEY from .utils.version import version_left_higher_or_equal_then_right from .websocket import async_register_websocket_commands