Cache existence of addon paths (#4944)

* Cache existence of addon paths

* Always update submodules

* Switch to an always cached model

* Cache on store addon only

* Fix tests

* refresh_cache to refresh_path_cache

* Fix name change in test

* Move logic into StoreManager
This commit is contained in:
Mike Degatano 2024-03-15 11:43:26 -04:00 committed by GitHub
parent 2148de45a0
commit a8af04ff82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 157 additions and 35 deletions

View File

@ -180,6 +180,9 @@ class Addon(AddonModel):
async def load(self) -> None: async def load(self) -> None:
"""Async initialize of object.""" """Async initialize of object."""
if self.is_detached:
await super().refresh_path_cache()
self._listeners.append( self._listeners.append(
self.sys_bus.register_event( self.sys_bus.register_event(
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, self.container_state_changed BusEvent.DOCKER_CONTAINER_STATE_CHANGE, self.container_state_changed
@ -230,6 +233,34 @@ class Addon(AddonModel):
"""Return True if add-on is detached.""" """Return True if add-on is detached."""
return self.slug not in self.sys_store.data.addons return self.slug not in self.sys_store.data.addons
@property
def with_icon(self) -> bool:
"""Return True if an icon exists."""
if self.is_detached:
return super().with_icon
return self.addon_store.with_icon
@property
def with_logo(self) -> bool:
"""Return True if a logo exists."""
if self.is_detached:
return super().with_logo
return self.addon_store.with_logo
@property
def with_changelog(self) -> bool:
"""Return True if a changelog exists."""
if self.is_detached:
return super().with_changelog
return self.addon_store.with_changelog
@property
def with_documentation(self) -> bool:
"""Return True if a documentation exists."""
if self.is_detached:
return super().with_documentation
return self.addon_store.with_documentation
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if this add-on is available on this platform.""" """Return True if this add-on is available on this platform."""
@ -1399,3 +1430,9 @@ class Addon(AddonModel):
ContainerState.UNHEALTHY, ContainerState.UNHEALTHY,
]: ]:
await self._restart_after_problem(event.state) await self._restart_after_problem(event.state)
def refresh_path_cache(self) -> Awaitable[None]:
"""Refresh cache of existing paths."""
if self.is_detached:
return super().refresh_path_cache()
return self.addon_store.refresh_path_cache()

View File

@ -77,15 +77,20 @@ class AddonManager(CoreSysAttributes):
async def load(self) -> None: async def load(self) -> None:
"""Start up add-on management.""" """Start up add-on management."""
tasks = [] # Refresh cache for all store addons
tasks: list[Awaitable[None]] = [
store.refresh_path_cache() for store in self.store.values()
]
# Load all installed addons
for slug in self.data.system: for slug in self.data.system:
addon = self.local[slug] = Addon(self.coresys, slug) addon = self.local[slug] = Addon(self.coresys, slug)
tasks.append(self.sys_create_task(addon.load())) tasks.append(addon.load())
# Run initial tasks # Run initial tasks
_LOGGER.info("Found %d installed add-ons", len(tasks)) _LOGGER.info("Found %d installed add-ons", len(self.data.system))
if tasks: if tasks:
await asyncio.wait(tasks) await asyncio.gather(*tasks)
# Sync DNS # Sync DNS
await self.sync_dns() await self.sync_dns()

View File

@ -1,7 +1,7 @@
"""Init file for Supervisor add-ons.""" """Init file for Supervisor add-ons."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable from collections.abc import Awaitable, Callable
from contextlib import suppress from contextlib import suppress
from datetime import datetime from datetime import datetime
import logging import logging
@ -118,6 +118,10 @@ class AddonModel(JobGroup, ABC):
coresys, JOB_GROUP_ADDON.format_map(defaultdict(str, slug=slug)), slug coresys, JOB_GROUP_ADDON.format_map(defaultdict(str, slug=slug)), slug
) )
self.slug: str = slug self.slug: str = slug
self._path_icon_exists: bool = False
self._path_logo_exists: bool = False
self._path_changelog_exists: bool = False
self._path_documentation_exists: bool = False
@property @property
@abstractmethod @abstractmethod
@ -511,22 +515,22 @@ class AddonModel(JobGroup, ABC):
@property @property
def with_icon(self) -> bool: def with_icon(self) -> bool:
"""Return True if an icon exists.""" """Return True if an icon exists."""
return self.path_icon.exists() return self._path_icon_exists
@property @property
def with_logo(self) -> bool: def with_logo(self) -> bool:
"""Return True if a logo exists.""" """Return True if a logo exists."""
return self.path_logo.exists() return self._path_logo_exists
@property @property
def with_changelog(self) -> bool: def with_changelog(self) -> bool:
"""Return True if a changelog exists.""" """Return True if a changelog exists."""
return self.path_changelog.exists() return self._path_changelog_exists
@property @property
def with_documentation(self) -> bool: def with_documentation(self) -> bool:
"""Return True if a documentation exists.""" """Return True if a documentation exists."""
return self.path_documentation.exists() return self._path_documentation_exists
@property @property
def supported_arch(self) -> list[str]: def supported_arch(self) -> list[str]:
@ -635,6 +639,17 @@ class AddonModel(JobGroup, ABC):
"""Return breaking versions of addon.""" """Return breaking versions of addon."""
return self.data[ATTR_BREAKING_VERSIONS] return self.data[ATTR_BREAKING_VERSIONS]
def refresh_path_cache(self) -> Awaitable[None]:
"""Refresh cache of existing paths."""
def check_paths():
self._path_icon_exists = self.path_icon.exists()
self._path_logo_exists = self.path_logo.exists()
self._path_changelog_exists = self.path_changelog.exists()
self._path_documentation_exists = self.path_documentation.exists()
return self.sys_run_in_executor(check_paths)
def validate_availability(self) -> None: def validate_availability(self) -> None:
"""Validate if addon is available for current system.""" """Validate if addon is available for current system."""
return self._validate_availability(self.data, logger=_LOGGER.error) return self._validate_availability(self.data, logger=_LOGGER.error)

View File

@ -41,7 +41,7 @@ class FixupStoreExecuteReload(FixupBase):
# Load data again # Load data again
try: try:
await repository.load() await repository.load()
await repository.update() await self.sys_store.reload(repository)
except StoreError: except StoreError:
raise ResolutionFixupError() from None raise ResolutionFixupError() from None

View File

@ -1,5 +1,6 @@
"""Add-on Store handler.""" """Add-on Store handler."""
import asyncio import asyncio
from collections.abc import Awaitable
import logging import logging
from ..const import ATTR_REPOSITORIES, URL_HASSIO_ADDONS from ..const import ATTR_REPOSITORIES, URL_HASSIO_ADDONS
@ -85,15 +86,39 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
conditions=[JobCondition.SUPERVISOR_UPDATED], conditions=[JobCondition.SUPERVISOR_UPDATED],
on_condition=StoreJobError, on_condition=StoreJobError,
) )
async def reload(self) -> None: async def reload(self, repository: Repository | None = None) -> None:
"""Update add-ons from repository and reload list.""" """Update add-ons from repository and reload list."""
tasks = [self.sys_create_task(repository.update()) for repository in self.all] # Make a copy to prevent race with other tasks
if tasks: repositories = [repository] if repository else self.all.copy()
await asyncio.wait(tasks) results: list[bool | Exception] = await asyncio.gather(
*[repo.update() for repo in repositories], return_exceptions=True
)
# read data from repositories # Determine which repositories were updated
await self.load() updated_repos: set[str] = set()
self._read_addons() for i, result in enumerate(results):
if result is True:
updated_repos.add(repositories[i].slug)
elif result:
_LOGGER.error(
"Could not reload repository %s due to %r",
repositories[i].slug,
result,
)
# Update path cache for all addons in updated repos
if updated_repos:
await asyncio.gather(
*[
addon.refresh_path_cache()
for addon in self.sys_addons.store.values()
if addon.repository in updated_repos
]
)
# read data from repositories
await self.load()
await self._read_addons()
@Job( @Job(
name="store_manager_add_repository", name="store_manager_add_repository",
@ -185,7 +210,7 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
# Persist changes # Persist changes
if persist: if persist:
await self.data.update() await self.data.update()
self._read_addons() await self._read_addons()
async def remove_repository(self, repository: Repository, *, persist: bool = True): async def remove_repository(self, repository: Repository, *, persist: bool = True):
"""Remove a repository.""" """Remove a repository."""
@ -205,7 +230,7 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
if persist: if persist:
await self.data.update() await self.data.update()
self._read_addons() await self._read_addons()
@Job(name="store_manager_update_repositories") @Job(name="store_manager_update_repositories")
async def update_repositories( async def update_repositories(
@ -245,14 +270,14 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
# Always update data, even there are errors, some changes may have succeeded # Always update data, even there are errors, some changes may have succeeded
await self.data.update() await self.data.update()
self._read_addons() await self._read_addons()
# Raise the first error we found (if any) # Raise the first error we found (if any)
for error in add_errors + remove_errors: for error in add_errors + remove_errors:
if error: if error:
raise error raise error
def _read_addons(self) -> None: async def _read_addons(self) -> None:
"""Reload add-ons inside store.""" """Reload add-ons inside store."""
all_addons = set(self.data.addons) all_addons = set(self.data.addons)
@ -268,8 +293,13 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
) )
# new addons # new addons
for slug in add_addons: if add_addons:
self.sys_addons.store[slug] = AddonStore(self.coresys, slug) cache_updates: list[Awaitable[None]] = []
for slug in add_addons:
self.sys_addons.store[slug] = AddonStore(self.coresys, slug)
cache_updates.append(self.sys_addons.store[slug].refresh_path_cache())
await asyncio.gather(*cache_updates)
# remove # remove
for slug in del_addons: for slug in del_addons:

View File

@ -117,7 +117,7 @@ class GitRepo(CoreSysAttributes):
conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_SYSTEM], conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_SYSTEM],
on_condition=StoreJobError, on_condition=StoreJobError,
) )
async def pull(self): async def pull(self) -> bool:
"""Pull Git add-on repo.""" """Pull Git add-on repo."""
if self.lock.locked(): if self.lock.locked():
_LOGGER.warning("There is already a task in progress") _LOGGER.warning("There is already a task in progress")
@ -140,10 +140,13 @@ class GitRepo(CoreSysAttributes):
) )
) )
# Jump on top of that if changed := self.repo.commit(branch) != self.repo.commit(
await self.sys_run_in_executor( f"origin/{branch}"
ft.partial(self.repo.git.reset, f"origin/{branch}", hard=True) ):
) # Jump on top of that
await self.sys_run_in_executor(
ft.partial(self.repo.git.reset, f"origin/{branch}", hard=True)
)
# Update submodules # Update submodules
await self.sys_run_in_executor( await self.sys_run_in_executor(
@ -160,6 +163,8 @@ class GitRepo(CoreSysAttributes):
# Cleanup old data # Cleanup old data
await self.sys_run_in_executor(ft.partial(self.repo.git.clean, "-xdf")) await self.sys_run_in_executor(ft.partial(self.repo.git.clean, "-xdf"))
return changed
except ( except (
git.InvalidGitRepositoryError, git.InvalidGitRepositoryError,
git.NoSuchPathError, git.NoSuchPathError,

View File

@ -1,4 +1,5 @@
"""Represent a Supervisor repository.""" """Represent a Supervisor repository."""
import logging import logging
from pathlib import Path from pathlib import Path
@ -101,11 +102,11 @@ class Repository(CoreSysAttributes):
return return
await self.git.load() await self.git.load()
async def update(self) -> None: async def update(self) -> bool:
"""Update add-on repository.""" """Update add-on repository."""
if self.type == StoreType.LOCAL or not self.validate(): if not self.validate():
return return False
await self.git.pull() return self.type == StoreType.LOCAL or await self.git.pull()
async def remove(self) -> None: async def remove(self) -> None:
"""Remove add-on repository.""" """Remove add-on repository."""

View File

@ -748,3 +748,18 @@ def test_auto_update_available(coresys: CoreSys, install_addon_example: Addon):
Addon, "version", new=PropertyMock(return_value=AwesomeVersion("test")) Addon, "version", new=PropertyMock(return_value=AwesomeVersion("test"))
): ):
assert install_addon_example.auto_update_available is False assert install_addon_example.auto_update_available is False
async def test_paths_cache(coresys: CoreSys, install_addon_ssh: Addon):
"""Test cache for key paths that may or may not exist."""
with patch("supervisor.addons.addon.Path.exists", return_value=True):
assert not install_addon_ssh.with_logo
assert not install_addon_ssh.with_icon
assert not install_addon_ssh.with_changelog
assert not install_addon_ssh.with_documentation
await coresys.store.reload(coresys.store.get("local"))
assert install_addon_ssh.with_logo
assert install_addon_ssh.with_icon
assert install_addon_ssh.with_changelog
assert install_addon_ssh.with_documentation

View File

@ -393,7 +393,7 @@ async def test_store_data_changes_during_update(
update_task = coresys.create_task(simulate_update()) update_task = coresys.create_task(simulate_update())
await asyncio.sleep(0) await asyncio.sleep(0)
with patch.object(Repository, "update"): with patch.object(Repository, "update", return_value=True):
await coresys.store.reload() await coresys.store.reload()
assert "image" not in coresys.store.data.addons["local_ssh"] assert "image" not in coresys.store.data.addons["local_ssh"]

View File

@ -24,12 +24,19 @@ from tests.common import load_yaml_fixture
async def test_default_load(coresys: CoreSys): async def test_default_load(coresys: CoreSys):
"""Test default load from config.""" """Test default load from config."""
store_manager = StoreManager(coresys) store_manager = StoreManager(coresys)
refresh_cache_calls: set[str] = set()
async def mock_refresh_cache(obj: AddonStore):
nonlocal refresh_cache_calls
refresh_cache_calls.add(obj.slug)
with patch( with patch(
"supervisor.store.repository.Repository.load", return_value=None "supervisor.store.repository.Repository.load", return_value=None
), patch.object( ), patch.object(
type(coresys.config), "addons_repositories", return_value=[] type(coresys.config), "addons_repositories", return_value=[]
), patch("pathlib.Path.exists", return_value=True): ), patch("pathlib.Path.exists", return_value=True), patch.object(
AddonStore, "refresh_path_cache", new=mock_refresh_cache
):
await store_manager.load() await store_manager.load()
assert len(store_manager.all) == 4 assert len(store_manager.all) == 4
@ -44,10 +51,15 @@ async def test_default_load(coresys: CoreSys):
"https://github.com/esphome/home-assistant-addon" "https://github.com/esphome/home-assistant-addon"
in store_manager.repository_urls in store_manager.repository_urls
) )
assert refresh_cache_calls == {"local_ssh", "local_example", "core_samba"}
async def test_load_with_custom_repository(coresys: CoreSys): async def test_load_with_custom_repository(coresys: CoreSys):
"""Test load from config with custom repository.""" """Test load from config with custom repository."""
async def mock_refresh_cache(_):
pass
with patch( with patch(
"supervisor.utils.common.read_json_or_yaml_file", "supervisor.utils.common.read_json_or_yaml_file",
return_value={"repositories": ["http://example.com"]}, return_value={"repositories": ["http://example.com"]},
@ -60,7 +72,9 @@ async def test_load_with_custom_repository(coresys: CoreSys):
type(coresys.config), "addons_repositories", return_value=[] type(coresys.config), "addons_repositories", return_value=[]
), patch( ), patch(
"supervisor.store.repository.Repository.validate", return_value=True "supervisor.store.repository.Repository.validate", return_value=True
), patch("pathlib.Path.exists", return_value=True): ), patch("pathlib.Path.exists", return_value=True), patch.object(
AddonStore, "refresh_path_cache", new=mock_refresh_cache
):
await store_manager.load() await store_manager.load()
assert len(store_manager.all) == 5 assert len(store_manager.all) == 5