From 3ef9d5b62bd7418aa5c7ac30ba8db0fa2f733dcc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 10:43:57 +0000 Subject: [PATCH 1/4] Persist post-install state via debounced delayed save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HA Update entity flow (`update.async_install` → `repository.async_download_repository` → `_async_post_install`) never called `async_write`, so updates only landed on disk when the next unrelated websocket action fired or when `EVENT_HOMEASSISTANT_FINAL_WRITE` ran at shutdown. On Docker installs the container is often killed without a graceful stop and the event never fires, so HACS forgets it had updated the repository. Schedule a debounced write at the end of `_async_post_install` via `Store.async_delay_save` so every install path persists, while bursts (bulk updates, queued installs) coalesce into a single disk write per store. HA's Store also flushes pending delayed saves on shutdown for free, so the existing `EVENT_HOMEASSISTANT_FINAL_WRITE` safety net stays as belt-and-braces. Refactor `HacsData` to use pure sync builders for the three store payloads (`hacs`, `data`, `repositories`) so both the eager `async_write` path and the new `async_schedule_write` path share the same assembly logic without relying on a mutable `self.content` side-effect. --- custom_components/hacs/repositories/base.py | 5 + custom_components/hacs/utils/data.py | 108 +++++++++++++------- custom_components/hacs/utils/store.py | 21 ++++ 3 files changed, 98 insertions(+), 36 deletions(-) diff --git a/custom_components/hacs/repositories/base.py b/custom_components/hacs/repositories/base.py index 950f52facd3..735eb6dfd72 100644 --- a/custom_components/hacs/repositories/base.py +++ b/custom_components/hacs/repositories/base.py @@ -916,6 +916,11 @@ async def _async_post_install(self) -> None: "repository_id": self.data.id, }, ) + # Some install paths (e.g. the HA Update entity) do not otherwise + # call async_write, so without this the new state is only flushed + # at EVENT_HOMEASSISTANT_FINAL_WRITE, which never fires when the + # process is killed (common on Docker). + self.hacs.data.async_schedule_write() self.logger.info("%s Post installation steps completed", self.string) async def async_install_repository(self, *, version: str | None = None, **_) -> None: diff --git a/custom_components/hacs/utils/data.py b/custom_components/hacs/utils/data.py index f540272e62d..7e8cd3b89ad 100644 --- a/custom_components/hacs/utils/data.py +++ b/custom_components/hacs/utils/data.py @@ -15,7 +15,9 @@ from ..repositories.base import TOPIC_FILTER, HacsManifest, HacsRepository from .logger import LOGGER from .path import is_safe -from .store import async_load_from_store, async_save_to_store +from .store import async_delay_save_to_store, async_load_from_store, async_save_to_store + +DELAYED_WRITE_DELAY = 30 EXPORTED_BASE_DATA = ( ("new", False), @@ -62,7 +64,9 @@ def __init__(self, hacs: HacsBase): """Initialize.""" self.logger = LOGGER self.hacs = hacs - self.content = {} + # Used by the offline category-data generator subclass in + # scripts/data/generate_category_data.py — not by runtime writes. + self.content: dict = {} async def async_force_write(self, _=None): """Force write.""" @@ -75,45 +79,78 @@ async def async_write(self, force: bool = False) -> None: self.logger.debug(" Saving data") - # Hacs + await async_save_to_store(self.hacs.hass, "hacs", self._build_hacs_data()) + await async_save_to_store( + self.hacs.hass, "data", self._build_experimental_data() + ) await async_save_to_store( + self.hacs.hass, "repositories", self._build_repositories_data() + ) + for event in (HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.CONFIG): + self.hacs.async_dispatch(event, {}) + + @callback + def async_schedule_write(self) -> None: + """Schedule a debounced write of all HACS stores. + + Multiple calls within ``DELAYED_WRITE_DELAY`` seconds collapse into + a single disk write per store. Pending writes are flushed by HA on + shutdown via ``Store.async_delay_save``'s internal listener. + """ + if self.hacs.system.disabled: + return + + self.logger.debug(" Scheduling delayed save") + + async_delay_save_to_store( + self.hacs.hass, "hacs", self._build_hacs_data, DELAYED_WRITE_DELAY + ) + async_delay_save_to_store( + self.hacs.hass, "data", self._build_experimental_data, DELAYED_WRITE_DELAY + ) + async_delay_save_to_store( self.hacs.hass, - "hacs", - { - "archived_repositories": self.hacs.common.archived_repositories, - "renamed_repositories": self.hacs.common.renamed_repositories, - "ignored_repositories": self.hacs.common.ignored_repositories, - }, + "repositories", + self._build_repositories_data, + DELAYED_WRITE_DELAY, ) - await self._async_store_experimental_content_and_repos() - await self._async_store_content_and_repos() - async def _async_store_content_and_repos(self, _=None): # bb: ignore - """Store the main repos file and each repo that is out of date.""" - # Repositories - self.content = {} + @callback + def _build_hacs_data(self) -> dict: + """Build the payload for the ``hacs`` store.""" + return { + "archived_repositories": self.hacs.common.archived_repositories, + "renamed_repositories": self.hacs.common.renamed_repositories, + "ignored_repositories": self.hacs.common.ignored_repositories, + } + + @callback + def _build_repositories_data(self) -> dict: + """Build the payload for the legacy ``repositories`` store.""" + content: dict[str, dict] = {} for repository in self.hacs.repositories.list_all: if repository.data.category in self.hacs.common.categories: - self.async_store_repository_data(repository) + content[str(repository.data.id)] = self._repository_export(repository) + return content - await async_save_to_store(self.hacs.hass, "repositories", self.content) - for event in (HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.CONFIG): - self.hacs.async_dispatch(event, {}) - - async def _async_store_experimental_content_and_repos(self, _=None): - """Store the main repos file and each repo that is out of date.""" - # Repositories - self.content = {} + @callback + def _build_experimental_data(self) -> dict: + """Build the payload for the experimental ``data`` store.""" + content: dict[str, list] = {} for repository in self.hacs.repositories.list_all: if repository.data.category in self.hacs.common.categories: - self.async_store_experimental_repository_data(repository) - - await async_save_to_store(self.hacs.hass, "data", {"repositories": self.content}) + content.setdefault(repository.data.category, []).append( + { + "id": str(repository.data.id), + **self._experimental_repository_export(repository), + } + ) + return {"repositories": content} @callback - def async_store_repository_data(self, repository: HacsRepository) -> dict: - """Store the repository data.""" - data = {"repository_manifest": repository.repository_manifest.manifest} + def _repository_export(self, repository: HacsRepository) -> dict: + """Return the per-repository dict for the legacy repositories store.""" + data: dict[str, Any] = {"repository_manifest": repository.repository_manifest.manifest} for key, default in ( EXPORTED_DOWNLOADED_REPOSITORY_DATA @@ -128,13 +165,12 @@ def async_store_repository_data(self, repository: HacsRepository) -> dict: if repository.data.last_fetched: data["last_fetched"] = repository.data.last_fetched.timestamp() - self.content[str(repository.data.id)] = data + return data @callback - def async_store_experimental_repository_data(self, repository: HacsRepository) -> None: - """Store the experimental repository data for non downloaded repositories.""" - data = {} - self.content.setdefault(repository.data.category, []) + def _experimental_repository_export(self, repository: HacsRepository) -> dict: + """Return the per-repository dict for the experimental data store.""" + data: dict[str, Any] = {} if repository.data.installed: data["repository_manifest"] = repository.repository_manifest.manifest @@ -151,7 +187,7 @@ def async_store_experimental_repository_data(self, repository: HacsRepository) - if (value := getattr(repository.data, key, default)) != default: data[key] = value - self.content[repository.data.category].append({"id": str(repository.data.id), **data}) + return data async def restore(self): """Restore saved data.""" diff --git a/custom_components/hacs/utils/store.py b/custom_components/hacs/utils/store.py index f0afa07b6bf..0eea8bd082a 100644 --- a/custom_components/hacs/utils/store.py +++ b/custom_components/hacs/utils/store.py @@ -1,5 +1,9 @@ """Storage handers.""" +from collections.abc import Callable +from typing import Any + +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.storage import Store from homeassistant.util import json as json_util @@ -72,6 +76,23 @@ async def async_save_to_store(hass, key, data): ) +@callback +def async_delay_save_to_store( + hass: HomeAssistant, + key: str, + data_func: Callable[[], Any], + delay: float, +) -> None: + """Schedule a debounced save to the store. + + The data callback is invoked at flush time so it captures the latest + in-memory state. Subsequent calls within the delay window reset the + timer on the underlying Store, coalescing bursts into a single write. + Home Assistant's Store also flushes pending delayed saves on shutdown. + """ + get_store_for_key(hass, key).async_delay_save(data_func, delay) + + async def async_remove_store(hass, key): """Remove a store element that should no longer be used.""" if "/" not in key: From 4d9757f740ed537d40f9018e0c5df9db3c704fd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 10:47:06 +0000 Subject: [PATCH 2/4] Trim docstrings and comments --- custom_components/hacs/repositories/base.py | 4 ---- custom_components/hacs/utils/data.py | 14 +------------- custom_components/hacs/utils/store.py | 8 +------- 3 files changed, 2 insertions(+), 24 deletions(-) diff --git a/custom_components/hacs/repositories/base.py b/custom_components/hacs/repositories/base.py index 735eb6dfd72..a0e50a4cb9f 100644 --- a/custom_components/hacs/repositories/base.py +++ b/custom_components/hacs/repositories/base.py @@ -916,10 +916,6 @@ async def _async_post_install(self) -> None: "repository_id": self.data.id, }, ) - # Some install paths (e.g. the HA Update entity) do not otherwise - # call async_write, so without this the new state is only flushed - # at EVENT_HOMEASSISTANT_FINAL_WRITE, which never fires when the - # process is killed (common on Docker). self.hacs.data.async_schedule_write() self.logger.info("%s Post installation steps completed", self.string) diff --git a/custom_components/hacs/utils/data.py b/custom_components/hacs/utils/data.py index 7e8cd3b89ad..756034a6771 100644 --- a/custom_components/hacs/utils/data.py +++ b/custom_components/hacs/utils/data.py @@ -64,8 +64,6 @@ def __init__(self, hacs: HacsBase): """Initialize.""" self.logger = LOGGER self.hacs = hacs - # Used by the offline category-data generator subclass in - # scripts/data/generate_category_data.py — not by runtime writes. self.content: dict = {} async def async_force_write(self, _=None): @@ -91,12 +89,7 @@ async def async_write(self, force: bool = False) -> None: @callback def async_schedule_write(self) -> None: - """Schedule a debounced write of all HACS stores. - - Multiple calls within ``DELAYED_WRITE_DELAY`` seconds collapse into - a single disk write per store. Pending writes are flushed by HA on - shutdown via ``Store.async_delay_save``'s internal listener. - """ + """Schedule a debounced write of all HACS stores.""" if self.hacs.system.disabled: return @@ -117,7 +110,6 @@ def async_schedule_write(self) -> None: @callback def _build_hacs_data(self) -> dict: - """Build the payload for the ``hacs`` store.""" return { "archived_repositories": self.hacs.common.archived_repositories, "renamed_repositories": self.hacs.common.renamed_repositories, @@ -126,7 +118,6 @@ def _build_hacs_data(self) -> dict: @callback def _build_repositories_data(self) -> dict: - """Build the payload for the legacy ``repositories`` store.""" content: dict[str, dict] = {} for repository in self.hacs.repositories.list_all: if repository.data.category in self.hacs.common.categories: @@ -135,7 +126,6 @@ def _build_repositories_data(self) -> dict: @callback def _build_experimental_data(self) -> dict: - """Build the payload for the experimental ``data`` store.""" content: dict[str, list] = {} for repository in self.hacs.repositories.list_all: if repository.data.category in self.hacs.common.categories: @@ -149,7 +139,6 @@ def _build_experimental_data(self) -> dict: @callback def _repository_export(self, repository: HacsRepository) -> dict: - """Return the per-repository dict for the legacy repositories store.""" data: dict[str, Any] = {"repository_manifest": repository.repository_manifest.manifest} for key, default in ( @@ -169,7 +158,6 @@ def _repository_export(self, repository: HacsRepository) -> dict: @callback def _experimental_repository_export(self, repository: HacsRepository) -> dict: - """Return the per-repository dict for the experimental data store.""" data: dict[str, Any] = {} if repository.data.installed: diff --git a/custom_components/hacs/utils/store.py b/custom_components/hacs/utils/store.py index 0eea8bd082a..3570a76e189 100644 --- a/custom_components/hacs/utils/store.py +++ b/custom_components/hacs/utils/store.py @@ -83,13 +83,7 @@ def async_delay_save_to_store( data_func: Callable[[], Any], delay: float, ) -> None: - """Schedule a debounced save to the store. - - The data callback is invoked at flush time so it captures the latest - in-memory state. Subsequent calls within the delay window reset the - timer on the underlying Store, coalescing bursts into a single write. - Home Assistant's Store also flushes pending delayed saves on shutdown. - """ + """Schedule a debounced save to the store.""" get_store_for_key(hass, key).async_delay_save(data_func, delay) From a2a9f630d4c86fb70b2cc4a04a8505f3f63aefe9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 11:03:34 +0000 Subject: [PATCH 3/4] Apply ruff-format --- custom_components/hacs/utils/data.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/custom_components/hacs/utils/data.py b/custom_components/hacs/utils/data.py index 756034a6771..7b34bd4b716 100644 --- a/custom_components/hacs/utils/data.py +++ b/custom_components/hacs/utils/data.py @@ -78,12 +78,8 @@ async def async_write(self, force: bool = False) -> None: self.logger.debug(" Saving data") await async_save_to_store(self.hacs.hass, "hacs", self._build_hacs_data()) - await async_save_to_store( - self.hacs.hass, "data", self._build_experimental_data() - ) - await async_save_to_store( - self.hacs.hass, "repositories", self._build_repositories_data() - ) + await async_save_to_store(self.hacs.hass, "data", self._build_experimental_data()) + await async_save_to_store(self.hacs.hass, "repositories", self._build_repositories_data()) for event in (HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.CONFIG): self.hacs.async_dispatch(event, {}) From 230c25f148f3e43861c694ccb0692e9aa7c28ffd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 11:16:10 +0000 Subject: [PATCH 4/4] Dispatch REPOSITORY and CONFIG from async_schedule_write --- custom_components/hacs/utils/data.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/hacs/utils/data.py b/custom_components/hacs/utils/data.py index 7b34bd4b716..4d33135af73 100644 --- a/custom_components/hacs/utils/data.py +++ b/custom_components/hacs/utils/data.py @@ -103,6 +103,8 @@ def async_schedule_write(self) -> None: self._build_repositories_data, DELAYED_WRITE_DELAY, ) + for event in (HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.CONFIG): + self.hacs.async_dispatch(event, {}) @callback def _build_hacs_data(self) -> dict: