Avoid creating tasks for dependencies already being setup (#111034)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
J. Nick Koston 2024-02-22 12:34:46 -10:00 committed by GitHub
parent 32cd3ad862
commit 2ef71289b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 71 additions and 25 deletions

View file

@ -1756,7 +1756,10 @@ class ConfigEntries:
Config entries which are created after Home Assistant is started can't be waited Config entries which are created after Home Assistant is started can't be waited
for, the function will just return if the config entry is loaded or not. for, the function will just return if the config entry is loaded or not.
""" """
if setup_future := self.hass.data.get(DATA_SETUP_DONE, {}).get(entry.domain): setup_done: dict[str, asyncio.Future[bool]] = self.hass.data.get(
DATA_SETUP_DONE, {}
)
if setup_future := setup_done.get(entry.domain):
await setup_future await setup_future
# The component was not loaded. # The component was not loaded.
if entry.domain not in self.hass.config.components: if entry.domain not in self.hass.config.components:

View file

@ -35,7 +35,7 @@ ATTR_COMPONENT: Final = "component"
BASE_PLATFORMS = {platform.value for platform in Platform} BASE_PLATFORMS = {platform.value for platform in Platform}
# DATA_SETUP is a dict[str, asyncio.Task[bool]], indicating domains which are currently # DATA_SETUP is a dict[str, asyncio.Future[bool]], indicating domains which are currently
# being setup or which failed to setup: # being setup or which failed to setup:
# - Tasks are added to DATA_SETUP by `async_setup_component`, the key is the domain # - Tasks are added to DATA_SETUP by `async_setup_component`, the key is the domain
# being setup and the Task is the `_async_setup_component` helper. # being setup and the Task is the `_async_setup_component` helper.
@ -43,7 +43,7 @@ BASE_PLATFORMS = {platform.value for platform in Platform}
# the task returned True. # the task returned True.
DATA_SETUP = "setup_tasks" DATA_SETUP = "setup_tasks"
# DATA_SETUP_DONE is a dict [str, asyncio.Future], indicating components which # DATA_SETUP_DONE is a dict [str, asyncio.Future[bool]], indicating components which
# will be setup: # will be setup:
# - Events are added to DATA_SETUP_DONE during bootstrap by # - Events are added to DATA_SETUP_DONE during bootstrap by
# async_set_domains_to_be_loaded, the key is the domain which will be loaded. # async_set_domains_to_be_loaded, the key is the domain which will be loaded.
@ -51,7 +51,7 @@ DATA_SETUP = "setup_tasks"
# is finished, regardless of if the setup was successful or not. # is finished, regardless of if the setup was successful or not.
DATA_SETUP_DONE = "setup_done" DATA_SETUP_DONE = "setup_done"
# DATA_SETUP_DONE is a dict [str, datetime], indicating when an attempt # DATA_SETUP_STARTED is a dict [str, float], indicating when an attempt
# to setup a component started. # to setup a component started.
DATA_SETUP_STARTED = "setup_started" DATA_SETUP_STARTED = "setup_started"
@ -116,10 +116,10 @@ def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str])
- Properly handle after_dependencies. - Properly handle after_dependencies.
- Keep track of domains which will load but have not yet finished loading - Keep track of domains which will load but have not yet finished loading
""" """
hass.data.setdefault(DATA_SETUP_DONE, {}) setup_done_futures: dict[str, asyncio.Future[bool]] = hass.data.setdefault(
hass.data[DATA_SETUP_DONE].update( DATA_SETUP_DONE, {}
{domain: hass.loop.create_future() for domain in domains}
) )
setup_done_futures.update({domain: hass.loop.create_future() for domain in domains})
def setup_component(hass: core.HomeAssistant, domain: str, config: ConfigType) -> bool: def setup_component(hass: core.HomeAssistant, domain: str, config: ConfigType) -> bool:
@ -142,23 +142,40 @@ async def async_setup_component(
setup_futures: dict[str, asyncio.Future[bool]] = hass.data.setdefault( setup_futures: dict[str, asyncio.Future[bool]] = hass.data.setdefault(
DATA_SETUP, {} DATA_SETUP, {}
) )
setup_done_futures: dict[str, asyncio.Future[bool]] = hass.data.setdefault(
DATA_SETUP_DONE, {}
)
if existing_future := setup_futures.get(domain): if existing_setup_future := setup_futures.get(domain):
return await existing_future return await existing_setup_future
future = hass.loop.create_future() setup_future = hass.loop.create_future()
setup_futures[domain] = future setup_futures[domain] = setup_future
try: try:
result = await _async_setup_component(hass, domain, config) result = await _async_setup_component(hass, domain, config)
future.set_result(result) setup_future.set_result(result)
if setup_done_future := setup_done_futures.pop(domain, None):
setup_done_future.set_result(result)
return result return result
except BaseException as err: # pylint: disable=broad-except except BaseException as err: # pylint: disable=broad-except
futures = [setup_future]
if setup_done_future := setup_done_futures.pop(domain, None):
futures.append(setup_done_future)
for future in futures:
# If the setup call is cancelled it likely means
# Home Assistant is shutting down so the future might
# already be done which will cause this to raise
# an InvalidStateError which is appropriate because
# the component setup was cancelled and is in an
# indeterminate state.
future.set_exception(err) future.set_exception(err)
with contextlib.suppress(BaseException):
# Clear the flag as its normal that nothing
# will wait for this future to be resolved
# if there are no concurrent setup attempts
await future
raise raise
finally:
if future := hass.data.get(DATA_SETUP_DONE, {}).pop(domain, None):
future.set_result(None)
async def _async_process_dependencies( async def _async_process_dependencies(
@ -168,14 +185,22 @@ async def _async_process_dependencies(
Returns a list of dependencies which failed to set up. Returns a list of dependencies which failed to set up.
""" """
setup_futures: dict[str, asyncio.Future[bool]] = hass.data.setdefault(
DATA_SETUP, {}
)
dependencies_tasks = { dependencies_tasks = {
dep: hass.loop.create_task(async_setup_component(hass, dep, config)) dep: setup_futures.get(dep)
or hass.loop.create_task(
async_setup_component(hass, dep, config),
name=f"setup {dep} as dependency of {integration.domain}",
)
for dep in integration.dependencies for dep in integration.dependencies
if dep not in hass.config.components if dep not in hass.config.components
} }
after_dependencies_tasks: dict[str, asyncio.Future[None]] = {} after_dependencies_tasks: dict[str, asyncio.Future[bool]] = {}
to_be_loaded: dict[str, asyncio.Future[None]] = hass.data.get(DATA_SETUP_DONE, {}) to_be_loaded: dict[str, asyncio.Future[bool]] = hass.data.get(DATA_SETUP_DONE, {})
for dep in integration.after_dependencies: for dep in integration.after_dependencies:
if ( if (
dep not in dependencies_tasks dep not in dependencies_tasks
@ -191,13 +216,13 @@ async def _async_process_dependencies(
_LOGGER.debug( _LOGGER.debug(
"Dependency %s will wait for dependencies %s", "Dependency %s will wait for dependencies %s",
integration.domain, integration.domain,
list(dependencies_tasks), dependencies_tasks.keys(),
) )
if after_dependencies_tasks: if after_dependencies_tasks:
_LOGGER.debug( _LOGGER.debug(
"Dependency %s will wait for after dependencies %s", "Dependency %s will wait for after dependencies %s",
integration.domain, integration.domain,
list(after_dependencies_tasks), after_dependencies_tasks.keys(),
) )
async with hass.timeout.async_freeze(integration.domain): async with hass.timeout.async_freeze(integration.domain):
@ -213,7 +238,7 @@ async def _async_process_dependencies(
_LOGGER.error( _LOGGER.error(
"Unable to set up dependencies of '%s'. Setup failed for dependencies: %s", "Unable to set up dependencies of '%s'. Setup failed for dependencies: %s",
integration.domain, integration.domain,
", ".join(failed), failed,
) )
return failed return failed

View file

@ -926,7 +926,7 @@ async def test_bootstrap_dependencies(
"""Assert the mqtt config entry was set up.""" """Assert the mqtt config entry was set up."""
calls.append("mqtt") calls.append("mqtt")
# assert the integration is not yet set up # assert the integration is not yet set up
assertions.append(hass.data["setup_done"][integration].is_set() is False) assertions.append(hass.data["setup_done"][integration].done() is False)
assertions.append( assertions.append(
all( all(
dependency in hass.config.components dependency in hass.config.components
@ -942,7 +942,7 @@ async def test_bootstrap_dependencies(
# assert mqtt was already set up # assert mqtt was already set up
assertions.append( assertions.append(
"mqtt" not in hass.data["setup_done"] "mqtt" not in hass.data["setup_done"]
or hass.data["setup_done"]["mqtt"].is_set() or hass.data["setup_done"]["mqtt"].done()
) )
assertions.append("mqtt" in hass.config.components) assertions.append("mqtt" in hass.config.components)
return True return True
@ -1029,5 +1029,6 @@ async def test_bootstrap_dependencies(
assert calls == ["mqtt", integration] assert calls == ["mqtt", integration]
assert ( assert (
f"Dependency {integration} will wait for dependencies ['mqtt']" in caplog.text f"Dependency {integration} will wait for dependencies dict_keys(['mqtt'])"
in caplog.text
) )

View file

@ -319,6 +319,7 @@ async def test_component_failing_setup(hass: HomeAssistant) -> None:
async def test_component_exception_setup(hass: HomeAssistant) -> None: async def test_component_exception_setup(hass: HomeAssistant) -> None:
"""Test component that raises exception during setup.""" """Test component that raises exception during setup."""
setup.async_set_domains_to_be_loaded(hass, {"comp"})
def exception_setup(hass, config): def exception_setup(hass, config):
"""Raise exception.""" """Raise exception."""
@ -330,6 +331,22 @@ async def test_component_exception_setup(hass: HomeAssistant) -> None:
assert "comp" not in hass.config.components assert "comp" not in hass.config.components
async def test_component_base_exception_setup(hass: HomeAssistant) -> None:
"""Test component that raises exception during setup."""
setup.async_set_domains_to_be_loaded(hass, {"comp"})
def exception_setup(hass, config):
"""Raise exception."""
raise BaseException("fail!")
mock_integration(hass, MockModule("comp", setup=exception_setup))
with pytest.raises(BaseException):
await setup.async_setup_component(hass, "comp", {})
assert "comp" not in hass.config.components
async def test_component_setup_with_validation_and_dependency( async def test_component_setup_with_validation_and_dependency(
hass: HomeAssistant, hass: HomeAssistant,
) -> None: ) -> None: