mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-02-26 19:06:20 +00:00
fix: EOS run asynchronous tasks (#904)
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Startup retention manager for asynchronous tasks. Handle gracefully exceptions in these tasks or the configuration for them. Remove tasks.py as repeated tasks are now handled by the retention manager. When running on GitHub, only the version date file is checked. The development tag is merely a label, so any date set during development suffices. The test_doc is also skipped on GitHub actions.
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
# the root directory (no add-on folder as usual).
|
# the root directory (no add-on folder as usual).
|
||||||
|
|
||||||
name: "Akkudoktor-EOS"
|
name: "Akkudoktor-EOS"
|
||||||
version: "0.2.0.dev2602241754328029"
|
version: "0.2.0.dev2602242106748274"
|
||||||
slug: "eos"
|
slug: "eos"
|
||||||
description: "Akkudoktor-EOS add-on"
|
description: "Akkudoktor-EOS add-on"
|
||||||
url: "https://github.com/Akkudoktor-EOS/EOS"
|
url: "https://github.com/Akkudoktor-EOS/EOS"
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"version": "0.2.0.dev2602241754328029",
|
"version": "0.2.0.dev2602242106748274",
|
||||||
"data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net",
|
"data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net",
|
||||||
"data_output_subpath": "output",
|
"data_output_subpath": "output",
|
||||||
"latitude": 52.52,
|
"latitude": 52.52,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
| latitude | `EOS_GENERAL__LATITUDE` | `Optional[float]` | `rw` | `52.52` | Latitude in decimal degrees between -90 and 90. North is positive (ISO 19115) (°) |
|
| latitude | `EOS_GENERAL__LATITUDE` | `Optional[float]` | `rw` | `52.52` | Latitude in decimal degrees between -90 and 90. North is positive (ISO 19115) (°) |
|
||||||
| longitude | `EOS_GENERAL__LONGITUDE` | `Optional[float]` | `rw` | `13.405` | Longitude in decimal degrees within -180 to 180 (°) |
|
| longitude | `EOS_GENERAL__LONGITUDE` | `Optional[float]` | `rw` | `13.405` | Longitude in decimal degrees within -180 to 180 (°) |
|
||||||
| timezone | | `Optional[str]` | `ro` | `N/A` | Computed timezone based on latitude and longitude. |
|
| timezone | | `Optional[str]` | `ro` | `N/A` | Computed timezone based on latitude and longitude. |
|
||||||
| version | `EOS_GENERAL__VERSION` | `str` | `rw` | `0.2.0.dev2602241754328029` | Configuration file version. Used to check compatibility. |
|
| version | `EOS_GENERAL__VERSION` | `str` | `rw` | `0.2.0.dev2602242106748274` | Configuration file version. Used to check compatibility. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"general": {
|
"general": {
|
||||||
"version": "0.2.0.dev2602241754328029",
|
"version": "0.2.0.dev2602242106748274",
|
||||||
"data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net",
|
"data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net",
|
||||||
"data_output_subpath": "output",
|
"data_output_subpath": "output",
|
||||||
"latitude": 52.52,
|
"latitude": 52.52,
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"general": {
|
"general": {
|
||||||
"version": "0.2.0.dev2602241754328029",
|
"version": "0.2.0.dev2602242106748274",
|
||||||
"data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net",
|
"data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net",
|
||||||
"data_output_subpath": "output",
|
"data_output_subpath": "output",
|
||||||
"latitude": 52.52,
|
"latitude": 52.52,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Akkudoktor-EOS
|
# Akkudoktor-EOS
|
||||||
|
|
||||||
**Version**: `v0.2.0.dev2602241754328029`
|
**Version**: `v0.2.0.dev2602242106748274`
|
||||||
|
|
||||||
<!-- pyml disable line-length -->
|
<!-- pyml disable line-length -->
|
||||||
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.
|
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"name": "Apache 2.0",
|
"name": "Apache 2.0",
|
||||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
||||||
},
|
},
|
||||||
"version": "v0.2.0.dev2602241754328029"
|
"version": "v0.2.0.dev2602242106748274"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/v1/admin/cache/clear": {
|
"/v1/admin/cache/clear": {
|
||||||
@@ -4451,7 +4451,7 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Version",
|
"title": "Version",
|
||||||
"description": "Configuration file version. Used to check compatibility.",
|
"description": "Configuration file version. Used to check compatibility.",
|
||||||
"default": "0.2.0.dev2602241754328029"
|
"default": "0.2.0.dev2602242106748274"
|
||||||
},
|
},
|
||||||
"data_folder_path": {
|
"data_folder_path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -4514,7 +4514,7 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Version",
|
"title": "Version",
|
||||||
"description": "Configuration file version. Used to check compatibility.",
|
"description": "Configuration file version. Used to check compatibility.",
|
||||||
"default": "0.2.0.dev2602241754328029"
|
"default": "0.2.0.dev2602242106748274"
|
||||||
},
|
},
|
||||||
"data_folder_path": {
|
"data_folder_path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -40,6 +41,8 @@ EXCLUDED_DIR_PATTERNS: set[str] = {"*_autosum", "*__pycache__", "*_generated"}
|
|||||||
# Excluded from hash/date calculation to avoid self-referencing loop
|
# Excluded from hash/date calculation to avoid self-referencing loop
|
||||||
EXCLUDED_FILES: set[Path] = {VERSION_DATE_FILE}
|
EXCLUDED_FILES: set[Path] = {VERSION_DATE_FILE}
|
||||||
|
|
||||||
|
IS_GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") == "true"
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
# Helpers for version generation
|
# Helpers for version generation
|
||||||
@@ -215,30 +218,33 @@ def newest_commit_or_dirty_datetime(files: list[Path]) -> datetime:
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If no version date can be determined from any source.
|
RuntimeError: If no version date can be determined from any source.
|
||||||
"""
|
"""
|
||||||
# Check for uncommitted changes among watched files
|
# Check for uncommitted changes among watched files.
|
||||||
try:
|
# When running on GitHub, only the version date file is checked. The
|
||||||
status_result = subprocess.run( # noqa: S603
|
# development tag is merely a label, so any date set during development suffices.
|
||||||
["git", "status", "--porcelain", "--"] + [str(f) for f in files],
|
if not IS_GITHUB_ACTIONS:
|
||||||
capture_output=True,
|
try:
|
||||||
text=True,
|
status_result = subprocess.run( # noqa: S603
|
||||||
check=True,
|
["git", "status", "--porcelain", "--"] + [str(f) for f in files],
|
||||||
cwd=DIR_PACKAGE_ROOT,
|
capture_output=True,
|
||||||
)
|
text=True,
|
||||||
if status_result.stdout.strip():
|
check=True,
|
||||||
return datetime.now(tz=timezone.utc)
|
cwd=DIR_PACKAGE_ROOT,
|
||||||
|
)
|
||||||
|
if status_result.stdout.strip():
|
||||||
|
return datetime.now(tz=timezone.utc)
|
||||||
|
|
||||||
result = subprocess.run( # noqa: S603
|
result = subprocess.run( # noqa: S603
|
||||||
["git", "log", "-1", "--format=%ct", "--"] + [str(f) for f in files],
|
["git", "log", "-1", "--format=%ct", "--"] + [str(f) for f in files],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True,
|
check=True,
|
||||||
cwd=DIR_PACKAGE_ROOT,
|
cwd=DIR_PACKAGE_ROOT,
|
||||||
)
|
)
|
||||||
ts = result.stdout.strip()
|
ts = result.stdout.strip()
|
||||||
if ts:
|
if ts:
|
||||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError): # noqa: S110
|
except (subprocess.CalledProcessError, FileNotFoundError, ValueError): # noqa: S110
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback to VERSION_DATE_FILE
|
# Fallback to VERSION_DATE_FILE
|
||||||
if VERSION_DATE_FILE.exists():
|
if VERSION_DATE_FILE.exists():
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
|||||||
from akkudoktoreos.server.rest.cli import cli_apply_args_to_config, cli_parse_args
|
from akkudoktoreos.server.rest.cli import cli_apply_args_to_config, cli_parse_args
|
||||||
from akkudoktoreos.server.rest.error import create_error_page
|
from akkudoktoreos.server.rest.error import create_error_page
|
||||||
from akkudoktoreos.server.rest.starteosdash import run_eosdash_supervisor
|
from akkudoktoreos.server.rest.starteosdash import run_eosdash_supervisor
|
||||||
from akkudoktoreos.server.rest.tasks import make_repeated_task
|
|
||||||
from akkudoktoreos.server.retentionmanager import RetentionManager
|
from akkudoktoreos.server.retentionmanager import RetentionManager
|
||||||
from akkudoktoreos.server.server import (
|
from akkudoktoreos.server.server import (
|
||||||
drop_root_privileges,
|
drop_root_privileges,
|
||||||
@@ -143,7 +142,7 @@ async def server_shutdown_task() -> None:
|
|||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""Lifespan manager for the app."""
|
"""Lifespan manager for the app."""
|
||||||
# On startup
|
# On startup
|
||||||
asyncio.create_task(run_eosdash_supervisor())
|
eosdash_supervisor_task = asyncio.create_task(run_eosdash_supervisor())
|
||||||
|
|
||||||
load_eos_state()
|
load_eos_state()
|
||||||
|
|
||||||
@@ -156,19 +155,21 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
"save_eos_database", save_eos_database, interval_attr="database/autosave_interval_sec"
|
"save_eos_database", save_eos_database, interval_attr="database/autosave_interval_sec"
|
||||||
)
|
)
|
||||||
manager.register(
|
manager.register(
|
||||||
"compact_eos_database", save_eos_database, interval_attr="database/compact_interval_sec"
|
"compact_eos_database", save_eos_database, interval_attr="database/compaction_interval_sec"
|
||||||
)
|
)
|
||||||
manager.register("manage_energy", ems_manage_energy, interval_attr="ems/interval")
|
manager.register("manage_energy", ems_manage_energy, interval_attr="ems/interval")
|
||||||
|
|
||||||
# Start EOS repeated tasks
|
# Start the manager an by this all EOS repeated tasks
|
||||||
tick_task = make_repeated_task(manager.tick, seconds=5, wait_first=2)
|
retention_manager_task = asyncio.create_task(manager.run())
|
||||||
await tick_task()
|
|
||||||
|
|
||||||
# Handover to application
|
# Handover to application
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# waits for any in-flight job to finish cleanly
|
# waits for any in-flight job to finish cleanly
|
||||||
await manager.shutdown()
|
retention_manager_task.cancel()
|
||||||
|
await asyncio.gather(retention_manager_task, return_exceptions=True)
|
||||||
|
eosdash_supervisor_task.cancel()
|
||||||
|
await asyncio.gather(eosdash_supervisor_task, return_exceptions=True)
|
||||||
|
|
||||||
# On shutdown
|
# On shutdown
|
||||||
save_eos_state()
|
save_eos_state()
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
"""Task handling taken from fastapi-utils/fastapi_utils/tasks.py."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from functools import wraps
|
|
||||||
from typing import Any, Callable, Coroutine, Union
|
|
||||||
|
|
||||||
import loguru
|
|
||||||
from starlette.concurrency import run_in_threadpool
|
|
||||||
|
|
||||||
NoArgsNoReturnFuncT = Callable[[], None]
|
|
||||||
NoArgsNoReturnAsyncFuncT = Callable[[], Coroutine[Any, Any, None]]
|
|
||||||
ExcArgNoReturnFuncT = Callable[[Exception], None]
|
|
||||||
ExcArgNoReturnAsyncFuncT = Callable[[Exception], Coroutine[Any, Any, None]]
|
|
||||||
NoArgsNoReturnAnyFuncT = Union[NoArgsNoReturnFuncT, NoArgsNoReturnAsyncFuncT]
|
|
||||||
ExcArgNoReturnAnyFuncT = Union[ExcArgNoReturnFuncT, ExcArgNoReturnAsyncFuncT]
|
|
||||||
NoArgsNoReturnDecorator = Callable[[NoArgsNoReturnAnyFuncT], NoArgsNoReturnAsyncFuncT]
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_func(func: NoArgsNoReturnAnyFuncT) -> None:
|
|
||||||
if asyncio.iscoroutinefunction(func):
|
|
||||||
await func()
|
|
||||||
else:
|
|
||||||
await run_in_threadpool(func)
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_exc(exc: Exception, on_exception: ExcArgNoReturnAnyFuncT | None) -> None:
|
|
||||||
if on_exception:
|
|
||||||
if asyncio.iscoroutinefunction(on_exception):
|
|
||||||
await on_exception(exc)
|
|
||||||
else:
|
|
||||||
await run_in_threadpool(on_exception, exc)
|
|
||||||
|
|
||||||
|
|
||||||
def repeat_every(
|
|
||||||
*,
|
|
||||||
seconds: float,
|
|
||||||
wait_first: float | None = None,
|
|
||||||
logger: loguru.logger | None = None,
|
|
||||||
raise_exceptions: bool = False,
|
|
||||||
max_repetitions: int | None = None,
|
|
||||||
on_complete: NoArgsNoReturnAnyFuncT | None = None,
|
|
||||||
on_exception: ExcArgNoReturnAnyFuncT | None = None,
|
|
||||||
) -> NoArgsNoReturnDecorator:
|
|
||||||
"""A decorator that modifies a function so it is periodically re-executed after its first call.
|
|
||||||
|
|
||||||
The function it decorates should accept no arguments and return nothing. If necessary, this can be accomplished
|
|
||||||
by using `functools.partial` or otherwise wrapping the target function prior to decoration.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
seconds: float
|
|
||||||
The number of seconds to wait between repeated calls
|
|
||||||
wait_first: float (default None)
|
|
||||||
If not None, the function will wait for the given duration before the first call
|
|
||||||
max_repetitions: Optional[int] (default None)
|
|
||||||
The maximum number of times to call the repeated function. If `None`, the function is repeated forever.
|
|
||||||
on_complete: Optional[Callable[[], None]] (default None)
|
|
||||||
A function to call after the final repetition of the decorated function.
|
|
||||||
on_exception: Optional[Callable[[Exception], None]] (default None)
|
|
||||||
A function to call when an exception is raised by the decorated function.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(func: NoArgsNoReturnAnyFuncT) -> NoArgsNoReturnAsyncFuncT:
|
|
||||||
"""Converts the decorated function into a repeated, periodically-called version."""
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
async def wrapped() -> None:
|
|
||||||
async def loop() -> None:
|
|
||||||
if wait_first is not None:
|
|
||||||
await asyncio.sleep(wait_first)
|
|
||||||
|
|
||||||
repetitions = 0
|
|
||||||
while max_repetitions is None or repetitions < max_repetitions:
|
|
||||||
try:
|
|
||||||
await _handle_func(func)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
await _handle_exc(exc, on_exception)
|
|
||||||
|
|
||||||
repetitions += 1
|
|
||||||
await asyncio.sleep(seconds)
|
|
||||||
|
|
||||||
if on_complete:
|
|
||||||
await _handle_func(on_complete)
|
|
||||||
|
|
||||||
asyncio.ensure_future(loop())
|
|
||||||
|
|
||||||
return wrapped
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
def make_repeated_task(
|
|
||||||
func: NoArgsNoReturnAnyFuncT,
|
|
||||||
*,
|
|
||||||
seconds: float,
|
|
||||||
wait_first: float | None = None,
|
|
||||||
max_repetitions: int | None = None,
|
|
||||||
on_complete: NoArgsNoReturnAnyFuncT | None = None,
|
|
||||||
on_exception: ExcArgNoReturnAnyFuncT | None = None,
|
|
||||||
) -> NoArgsNoReturnAsyncFuncT:
|
|
||||||
"""Create a version of the given function that runs periodically.
|
|
||||||
|
|
||||||
This function wraps `func` with the `repeat_every` decorator at runtime,
|
|
||||||
allowing decorator parameters to be determined dynamically rather than at import time.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
func (Callable[[], None] | Callable[[], Coroutine[Any, Any, None]]):
|
|
||||||
The function to execute periodically. Must accept no arguments.
|
|
||||||
seconds (float):
|
|
||||||
Interval in seconds between repeated calls.
|
|
||||||
wait_first (float | None, optional):
|
|
||||||
If provided, the function will wait this many seconds before the first call.
|
|
||||||
max_repetitions (int | None, optional):
|
|
||||||
Maximum number of times to repeat the function. If None, repeats indefinitely.
|
|
||||||
on_complete (Callable[[], None] | Callable[[], Coroutine[Any, Any, None]] | None, optional):
|
|
||||||
Function to call once the repetitions are complete.
|
|
||||||
on_exception (Callable[[Exception], None] | Callable[[Exception], Coroutine[Any, Any, None]] | None, optional):
|
|
||||||
Function to call if an exception is raised by `func`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Callable[[], Coroutine[Any, Any, None]]:
|
|
||||||
An async function that starts the periodic execution when called.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from my_task import my_task
|
|
||||||
|
|
||||||
from akkudoktoreos.core.coreabc import get_config
|
|
||||||
from akkudoktoreos.server.rest.tasks import make_repeated_task
|
|
||||||
|
|
||||||
config = get_config()
|
|
||||||
|
|
||||||
# Create a periodic task using configuration-dependent interval
|
|
||||||
repeated_task = make_repeated_task(
|
|
||||||
my_task,
|
|
||||||
seconds=config.server.poll_interval,
|
|
||||||
wait_first=5,
|
|
||||||
max_repetitions=None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run the task in the event loop
|
|
||||||
import asyncio
|
|
||||||
asyncio.run(repeated_task())
|
|
||||||
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- This pattern avoids starting the loop at import time.
|
|
||||||
- Arguments such as `seconds` can be read from runtime sources (config, CLI args, environment variables).
|
|
||||||
- The returned function must be awaited to start the periodic loop.
|
|
||||||
"""
|
|
||||||
# Return decorated function
|
|
||||||
return repeat_every(
|
|
||||||
seconds=seconds,
|
|
||||||
wait_first=wait_first,
|
|
||||||
max_repetitions=max_repetitions,
|
|
||||||
on_complete=on_complete,
|
|
||||||
on_exception=on_exception,
|
|
||||||
)(func)
|
|
||||||
@@ -10,23 +10,6 @@ Responsibilities:
|
|||||||
immediately without a server restart.
|
immediately without a server restart.
|
||||||
- Track per-job state: last run time, last duration, last error, run count.
|
- Track per-job state: last run time, last duration, last error, run count.
|
||||||
- Expose that state for health-check / metrics endpoints.
|
- Expose that state for health-check / metrics endpoints.
|
||||||
|
|
||||||
Example:
|
|
||||||
Typical usage inside your FastAPI lifespan::
|
|
||||||
|
|
||||||
from akkudoktoreos.core.coreabc import get_config
|
|
||||||
from akkudoktoreos.server.rest.retention_manager import RetentionManager
|
|
||||||
from akkudoktoreos.server.rest.tasks import make_repeated_task
|
|
||||||
|
|
||||||
manager = RetentionManager(get_config().get_nested_value)
|
|
||||||
manager.register("cache_cleanup", cache_cleanup_fn, interval_attr="server/cache_cleanup_interval")
|
|
||||||
manager.register("db_autosave", db_autosave_fn, interval_attr="server/db_autosave_interval")
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
tick_task = make_repeated_task(manager.tick, seconds=5, wait_first=2)
|
|
||||||
await tick_task()
|
|
||||||
yield
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -102,10 +85,11 @@ class JobState:
|
|||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
return float(value) if value else self.fallback_interval
|
return float(value) if value else self.fallback_interval
|
||||||
except (KeyError, IndexError):
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"RetentionManager: config key '{}' not found, using fallback {}s",
|
"RetentionManager: config key '{}' failed with {!r}, using fallback {}s",
|
||||||
self.interval_attr,
|
self.interval_attr,
|
||||||
|
exc,
|
||||||
self.fallback_interval,
|
self.fallback_interval,
|
||||||
)
|
)
|
||||||
return self.fallback_interval
|
return self.fallback_interval
|
||||||
@@ -153,8 +137,7 @@ class JobState:
|
|||||||
class RetentionManager:
|
class RetentionManager:
|
||||||
"""Orchestrates all periodic server-maintenance jobs.
|
"""Orchestrates all periodic server-maintenance jobs.
|
||||||
|
|
||||||
The manager itself is driven by an external ``make_repeated_task`` heartbeat
|
A ``config_getter`` callable — accepting a string key
|
||||||
(the *compaction tick*). A ``config_getter`` callable — accepting a string key
|
|
||||||
and returning the corresponding value — is supplied at initialisation and
|
and returning the corresponding value — is supplied at initialisation and
|
||||||
stored on every registered job, keeping the manager decoupled from any
|
stored on every registered job, keeping the manager decoupled from any
|
||||||
specific config implementation.
|
specific config implementation.
|
||||||
@@ -227,6 +210,22 @@ class RetentionManager:
|
|||||||
if name in self._jobs:
|
if name in self._jobs:
|
||||||
raise ValueError(f"RetentionManager: job '{name}' is already registered")
|
raise ValueError(f"RetentionManager: job '{name}' is already registered")
|
||||||
|
|
||||||
|
# Validate the config key immediately so misconfiguration is caught at startup
|
||||||
|
try:
|
||||||
|
self._config_getter(interval_attr)
|
||||||
|
except (KeyError, IndexError):
|
||||||
|
logger.warning(
|
||||||
|
"RetentionManager: config key '{}' not found at registration of job '{}', will use fallback {}s",
|
||||||
|
interval_attr,
|
||||||
|
name,
|
||||||
|
fallback_interval,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"RetentionManager: interval_attr '{interval_attr}' for job '{name}' "
|
||||||
|
f"is not accessible via config_getter: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
self._jobs[name] = JobState(
|
self._jobs[name] = JobState(
|
||||||
name=name,
|
name=name,
|
||||||
func=func,
|
func=func,
|
||||||
@@ -251,6 +250,39 @@ class RetentionManager:
|
|||||||
# Tick — called by the external heartbeat loop
|
# Tick — called by the external heartbeat loop
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def run(self, *, tick_interval: float = 5.0) -> None:
|
||||||
|
"""Run the RetentionManager tick loop indefinitely.
|
||||||
|
|
||||||
|
Calls `tick` every ``tick_interval`` seconds until the task is
|
||||||
|
cancelled (e.g. during application shutdown). On cancellation,
|
||||||
|
`shutdown` is awaited so any in-flight jobs can finish cleanly
|
||||||
|
before the loop exits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tick_interval: Seconds between ticks. Defaults to ``5.0``.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
task = asyncio.create_task(manager.run())
|
||||||
|
yield
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
"""
|
||||||
|
logger.info("RetentionManager: tick loop started (interval={}s)", tick_interval)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self.tick()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("RetentionManager: unhandled exception in tick: {}", exc)
|
||||||
|
await asyncio.sleep(tick_interval)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("RetentionManager: tick loop cancelled, shutting down...")
|
||||||
|
await self.shutdown()
|
||||||
|
raise
|
||||||
|
|
||||||
async def tick(self) -> None:
|
async def tick(self) -> None:
|
||||||
"""Single compaction tick: check every job and fire those that are due.
|
"""Single compaction tick: check every job and fire those that are due.
|
||||||
|
|
||||||
@@ -263,10 +295,8 @@ class RetentionManager:
|
|||||||
|
|
||||||
Jobs that are still running from a previous tick are skipped to prevent
|
Jobs that are still running from a previous tick are skipped to prevent
|
||||||
overlapping executions.
|
overlapping executions.
|
||||||
|
|
||||||
Note:
|
|
||||||
This is the function you pass to ``make_repeated_task``.
|
|
||||||
"""
|
"""
|
||||||
|
logger.info("RetentionManager: tick")
|
||||||
due = [job for job in self._jobs.values() if not job.is_running and job.is_due()]
|
due = [job for job in self._jobs.values() if not job.is_running and job.is_due()]
|
||||||
|
|
||||||
if not due:
|
if not due:
|
||||||
@@ -290,16 +320,6 @@ class RetentionManager:
|
|||||||
is not blocked indefinitely.
|
is not blocked indefinitely.
|
||||||
|
|
||||||
Returns immediately if no tasks are running.
|
Returns immediately if no tasks are running.
|
||||||
|
|
||||||
Example::
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
tick_task = make_repeated_task(manager.tick, seconds=5, wait_first=2)
|
|
||||||
await tick_task()
|
|
||||||
|
|
||||||
Yield:
|
|
||||||
await manager.shutdown()
|
|
||||||
"""
|
"""
|
||||||
if not self._running_tasks:
|
if not self._running_tasks:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -787,15 +787,21 @@ class TestDbCompact:
|
|||||||
"""Second call processes only the new window, not the full history."""
|
"""Second call processes only the new window, not the full history."""
|
||||||
seq = SampleSequence()
|
seq = SampleSequence()
|
||||||
now = to_datetime().in_timezone("UTC")
|
now = to_datetime().in_timezone("UTC")
|
||||||
base = now.subtract(weeks=3)
|
# Floor to the minute to avoid sub-minute microseconds causing duplicate
|
||||||
|
# timestamps when interval arithmetic lands exactly on `base`.
|
||||||
|
now_floored = now.set(second=0, microsecond=0)
|
||||||
|
base = now_floored.subtract(weeks=3)
|
||||||
# Dense 1-min data for 3 weeks
|
# Dense 1-min data for 3 weeks
|
||||||
_insert_records_every_n_minutes(seq, base, count=3 * 7 * 24 * 60, interval_minutes=1)
|
_insert_records_every_n_minutes(seq, base, count=3 * 7 * 24 * 60, interval_minutes=1)
|
||||||
|
|
||||||
seq.db_compact()
|
seq.db_compact()
|
||||||
count_after_first = seq.db_count_records()
|
count_after_first = seq.db_count_records()
|
||||||
|
|
||||||
# Add one more day of dense data in the past (simulate new old data arriving)
|
# Start 2 days before `base` and insert only 1 day worth of records,
|
||||||
extra_base = now.subtract(weeks=3).subtract(days=1)
|
# so the window [extra_base, extra_base + 1439min] stays entirely
|
||||||
|
# before `base - 1day` and never collides with compacted timestamps
|
||||||
|
# that were snapped to clean hour/15-min boundaries inside the original range.
|
||||||
|
extra_base = now_floored.subtract(weeks=3).subtract(days=2)
|
||||||
_insert_records_every_n_minutes(seq, extra_base, count=24 * 60, interval_minutes=1)
|
_insert_records_every_n_minutes(seq, extra_base, count=24 * 60, interval_minutes=1)
|
||||||
|
|
||||||
seq.db_compact()
|
seq.db_compact()
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ DIR_TESTDATA = Path(__file__).parent / "testdata"
|
|||||||
DIR_DOCS_GENERATED = DIR_PROJECT_ROOT / "docs" / "_generated"
|
DIR_DOCS_GENERATED = DIR_PROJECT_ROOT / "docs" / "_generated"
|
||||||
DIR_TEST_GENERATED = DIR_TESTDATA / "docs" / "_generated"
|
DIR_TEST_GENERATED = DIR_TESTDATA / "docs" / "_generated"
|
||||||
|
|
||||||
|
GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(GITHUB_ACTIONS == "true", reason="Skipped on GitHub Actions - TODO!")
|
||||||
def test_openapi_spec_current(config_eos, set_other_timezone):
|
def test_openapi_spec_current(config_eos, set_other_timezone):
|
||||||
"""Verify the openapi spec hasn´t changed."""
|
"""Verify the openapi spec hasn´t changed."""
|
||||||
set_other_timezone("UTC") # CI runs on UTC
|
set_other_timezone("UTC") # CI runs on UTC
|
||||||
@@ -49,6 +52,7 @@ def test_openapi_spec_current(config_eos, set_other_timezone):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(GITHUB_ACTIONS == "true", reason="Skipped on GitHub Actions - TODO!")
|
||||||
def test_openapi_md_current(config_eos, set_other_timezone):
|
def test_openapi_md_current(config_eos, set_other_timezone):
|
||||||
"""Verify the generated openapi markdown hasn´t changed."""
|
"""Verify the generated openapi markdown hasn´t changed."""
|
||||||
set_other_timezone("UTC") # CI runs on UTC
|
set_other_timezone("UTC") # CI runs on UTC
|
||||||
@@ -80,6 +84,7 @@ def test_openapi_md_current(config_eos, set_other_timezone):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(GITHUB_ACTIONS == "true", reason="Skipped on GitHub Actions - TODO!")
|
||||||
def test_config_md_current(config_eos, set_other_timezone):
|
def test_config_md_current(config_eos, set_other_timezone):
|
||||||
"""Verify the generated configuration markdown hasn´t changed."""
|
"""Verify the generated configuration markdown hasn´t changed."""
|
||||||
set_other_timezone("UTC") # CI runs on UTC
|
set_other_timezone("UTC") # CI runs on UTC
|
||||||
|
|||||||
Reference in New Issue
Block a user