Add execution limit for jobs (#2612)

* Add execution limit for jobs

* Add test for execution police

* Use better test

* Apply suggestions from code review

Co-authored-by: Joakim Sørensen <joasoe@gmail.com>

* Rename JobExecutionLimit

* fix typing

Co-authored-by: Joakim Sørensen <joasoe@gmail.com>
This commit is contained in:
Pascal Vizeli 2021-02-24 17:15:13 +01:00 committed by GitHub
parent 90d8832cd2
commit 8630adc54a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 90 additions and 36 deletions

View File

@ -17,3 +17,9 @@ class JobCondition(str, Enum):
INTERNET_SYSTEM = "internet_system" INTERNET_SYSTEM = "internet_system"
INTERNET_HOST = "internet_host" INTERNET_HOST = "internet_host"
RUNNING = "running" RUNNING = "running"
class JobExecutionLimit(str, Enum):
"""Job Execution limits."""
SINGLE_WAIT = "single_wait"

View File

@ -1,19 +1,20 @@
"""Job decorator.""" """Job decorator."""
import asyncio
import logging import logging
from typing import Any, List, Optional from typing import Any, List, Optional, Tuple
import sentry_sdk import sentry_sdk
from ..const import CoreState from ..const import CoreState
from ..coresys import CoreSys from ..coresys import CoreSysAttributes
from ..exceptions import HassioError, JobException from ..exceptions import HassioError, JobException
from ..resolution.const import MINIMUM_FREE_SPACE_THRESHOLD, ContextType, IssueType from ..resolution.const import MINIMUM_FREE_SPACE_THRESHOLD, ContextType, IssueType
from .const import JobCondition from .const import JobCondition, JobExecutionLimit
_LOGGER: logging.Logger = logging.getLogger(__package__) _LOGGER: logging.Logger = logging.getLogger(__package__)
class Job: class Job(CoreSysAttributes):
"""Supervisor job decorator.""" """Supervisor job decorator."""
def __init__( def __init__(
@ -22,33 +23,42 @@ class Job:
conditions: Optional[List[JobCondition]] = None, conditions: Optional[List[JobCondition]] = None,
cleanup: bool = True, cleanup: bool = True,
on_condition: Optional[JobException] = None, on_condition: Optional[JobException] = None,
limit: Optional[JobExecutionLimit] = None,
): ):
"""Initialize the Job class.""" """Initialize the Job class."""
self.name = name self.name = name
self.conditions = conditions self.conditions = conditions
self.cleanup = cleanup self.cleanup = cleanup
self.on_condition = on_condition self.on_condition = on_condition
self._coresys: Optional[CoreSys] = None self.limit = limit
self._lock: Optional[asyncio.Semaphore] = None
self._method = None self._method = None
def _post_init(self, args: Tuple[Any]) -> None:
"""Runtime init."""
if self.name is None:
self.name = str(self._method.__qualname__).lower().replace(".", "_")
# Coresys
try:
self.coresys = args[0].coresys
except AttributeError:
pass
if not self.coresys:
raise JobException(f"coresys is missing on {self.name}")
if self._lock is None:
self._lock = asyncio.Semaphore()
def __call__(self, method): def __call__(self, method):
"""Call the wrapper logic.""" """Call the wrapper logic."""
self._method = method self._method = method
async def wrapper(*args, **kwargs) -> Any: async def wrapper(*args, **kwargs) -> Any:
"""Wrap the method.""" """Wrap the method."""
if self.name is None: self._post_init(args)
self.name = str(self._method.__qualname__).lower().replace(".", "_")
# Evaluate coresys job = self.sys_jobs.get_job(self.name)
try:
self._coresys = args[0].coresys
except AttributeError:
pass
if not self._coresys:
raise JobException(f"coresys is missing on {self.name}")
job = self._coresys.jobs.get_job(self.name)
# Handle condition # Handle condition
if self.conditions and not self._check_conditions(): if self.conditions and not self._check_conditions():
@ -56,6 +66,10 @@ class Job:
return return
raise self.on_condition() raise self.on_condition()
# Handle exection limits
if self.limit:
await self._acquire_exection_limit()
# Execute Job # Execute Job
try: try:
return await self._method(*args, **kwargs) return await self._method(*args, **kwargs)
@ -67,18 +81,15 @@ class Job:
raise JobException() from err raise JobException() from err
finally: finally:
if self.cleanup: if self.cleanup:
self._coresys.jobs.remove_job(job) self.sys_jobs.remove_job(job)
self._release_exception_limits()
return wrapper return wrapper
def _check_conditions(self): def _check_conditions(self):
"""Check conditions.""" """Check conditions."""
used_conditions = set(self.conditions) - set( used_conditions = set(self.conditions) - set(self.sys_jobs.ignore_conditions)
self._coresys.jobs.ignore_conditions ignored_conditions = set(self.conditions) & set(self.sys_jobs.ignore_conditions)
)
ignored_conditions = set(self.conditions) & set(
self._coresys.jobs.ignore_conditions
)
# Check if somethings is ignored # Check if somethings is ignored
if ignored_conditions: if ignored_conditions:
@ -87,7 +98,7 @@ class Job:
ignored_conditions, ignored_conditions,
) )
if JobCondition.HEALTHY in used_conditions and not self._coresys.core.healthy: if JobCondition.HEALTHY in used_conditions and not self.sys_core.healthy:
_LOGGER.warning( _LOGGER.warning(
"'%s' blocked from execution, system is not healthy", "'%s' blocked from execution, system is not healthy",
self._method.__qualname__, self._method.__qualname__,
@ -96,33 +107,31 @@ class Job:
if ( if (
JobCondition.RUNNING in used_conditions JobCondition.RUNNING in used_conditions
and self._coresys.core.state != CoreState.RUNNING and self.sys_core.state != CoreState.RUNNING
): ):
_LOGGER.warning( _LOGGER.warning(
"'%s' blocked from execution, system is not running - %s", "'%s' blocked from execution, system is not running - %s",
self._method.__qualname__, self._method.__qualname__,
self._coresys.core.state, self.sys_core.state,
) )
return False return False
if ( if (
JobCondition.FREE_SPACE in used_conditions JobCondition.FREE_SPACE in used_conditions
and self._coresys.host.info.free_space < MINIMUM_FREE_SPACE_THRESHOLD and self.sys_host.info.free_space < MINIMUM_FREE_SPACE_THRESHOLD
): ):
_LOGGER.warning( _LOGGER.warning(
"'%s' blocked from execution, not enough free space (%sGB) left on the device", "'%s' blocked from execution, not enough free space (%sGB) left on the device",
self._method.__qualname__, self._method.__qualname__,
self._coresys.host.info.free_space, self.sys_host.info.free_space,
)
self._coresys.resolution.create_issue(
IssueType.FREE_SPACE, ContextType.SYSTEM
) )
self.sys_resolution.create_issue(IssueType.FREE_SPACE, ContextType.SYSTEM)
return False return False
if ( if (
JobCondition.INTERNET_SYSTEM in self.conditions JobCondition.INTERNET_SYSTEM in self.conditions
and not self._coresys.supervisor.connectivity and not self.sys_supervisor.connectivity
and self._coresys.core.state in (CoreState.SETUP, CoreState.RUNNING) and self.sys_core.state in (CoreState.SETUP, CoreState.RUNNING)
): ):
_LOGGER.warning( _LOGGER.warning(
"'%s' blocked from execution, no supervisor internet connection", "'%s' blocked from execution, no supervisor internet connection",
@ -132,9 +141,9 @@ class Job:
if ( if (
JobCondition.INTERNET_HOST in self.conditions JobCondition.INTERNET_HOST in self.conditions
and self._coresys.host.network.connectivity is not None and self.sys_host.network.connectivity is not None
and not self._coresys.host.network.connectivity and not self.sys_host.network.connectivity
and self._coresys.core.state in (CoreState.SETUP, CoreState.RUNNING) and self.sys_core.state in (CoreState.SETUP, CoreState.RUNNING)
): ):
_LOGGER.warning( _LOGGER.warning(
"'%s' blocked from execution, no host internet connection", "'%s' blocked from execution, no host internet connection",
@ -143,3 +152,15 @@ class Job:
return False return False
return True return True
async def _acquire_exection_limit(self) -> None:
"""Process exection limits."""
if self.limit == JobExecutionLimit.SINGLE_WAIT:
await self._lock.acquire()
def _release_exception_limits(self) -> None:
"""Release possible exception limits."""
if self.limit == JobExecutionLimit.SINGLE_WAIT:
self._lock.release()

View File

@ -1,5 +1,6 @@
"""Test the condition decorators.""" """Test the condition decorators."""
# pylint: disable=protected-access,import-error # pylint: disable=protected-access,import-error
import asyncio
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@ -7,6 +8,7 @@ import pytest
from supervisor.const import CoreState from supervisor.const import CoreState
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.exceptions import HassioError, JobException from supervisor.exceptions import HassioError, JobException
from supervisor.jobs.const import JobExecutionLimit
from supervisor.jobs.decorator import Job, JobCondition from supervisor.jobs.decorator import Job, JobCondition
from supervisor.resolution.const import UnhealthyReason from supervisor.resolution.const import UnhealthyReason
@ -257,3 +259,28 @@ async def test_exception_conditions(coresys: CoreSys):
coresys.core.state = CoreState.FREEZE coresys.core.state = CoreState.FREEZE
with pytest.raises(HassioError): with pytest.raises(HassioError):
await test.execute() await test.execute()
async def test_exectution_limit_single_wait(
coresys: CoreSys, loop: asyncio.BaseEventLoop
):
"""Test the ignore conditions decorator."""
class TestClass:
"""Test class."""
def __init__(self, coresys: CoreSys):
"""Initialize the test class."""
self.coresys = coresys
self.run = asyncio.Lock()
@Job(limit=JobExecutionLimit.SINGLE_WAIT)
async def execute(self, sleep: float):
"""Execute the class method."""
assert not self.run.locked()
async with self.run:
await asyncio.sleep(sleep)
test = TestClass(coresys)
await asyncio.gather(*[test.execute(0.1), test.execute(0.1), test.execute(0.1)])