مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
167 lines
5.3 KiB
Python
167 lines
5.3 KiB
Python
"""Shared plumbing for the standalone workers.
|
|
|
|
The workers are deliberately thin. Every one of them is a *driver* that calls
|
|
the same application services the HTTP API calls — the wiring comes from
|
|
``app.application.container``, so a worker and the API can never disagree about
|
|
which adapter backs a port. No business rule lives in this package; if a worker
|
|
and the API ever disagreed, the API would be right.
|
|
|
|
That is also what makes the ``services/*`` split cheap later: moving a worker
|
|
into its own process changes the process boundary, not the code.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import dataclass, field
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.database import SessionFactory
|
|
|
|
logger = logging.getLogger("agy.worker")
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class WorkerOutcome:
|
|
"""What one pass of a worker did.
|
|
|
|
Returned rather than only logged, so a test can assert on it and an
|
|
operator can see a number instead of grepping a log file.
|
|
"""
|
|
|
|
name: str
|
|
processed: int = 0
|
|
skipped: int = 0
|
|
failed: int = 0
|
|
notes: list[str] = field(default_factory=list)
|
|
|
|
#: Keep the note list bounded: a worker that processes 10k rows must not
|
|
#: accumulate 10k strings in memory just to print a summary.
|
|
MAX_NOTES = 50
|
|
|
|
def note(self, message: str) -> None:
|
|
if len(self.notes) < self.MAX_NOTES:
|
|
self.notes.append(message)
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.failed == 0
|
|
|
|
@property
|
|
def touched(self) -> int:
|
|
return self.processed + self.skipped + self.failed
|
|
|
|
def summary(self) -> str:
|
|
return (
|
|
f"{self.name}: processed={self.processed} "
|
|
f"skipped={self.skipped} failed={self.failed}"
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def worker_session() -> AsyncIterator[AsyncSession]:
|
|
"""One committed transaction per pass; rolled back whole on failure.
|
|
|
|
A worker pass is all-or-nothing on purpose: a half-applied invoice rollup
|
|
is worse than no rollup, because the next pass would skip the period it
|
|
thinks it already billed.
|
|
"""
|
|
async with SessionFactory() as session:
|
|
try:
|
|
yield session
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
|
|
|
|
async def run_periodically(
|
|
pass_fn: Callable[[], Awaitable[WorkerOutcome]],
|
|
*,
|
|
interval_seconds: float,
|
|
stop: asyncio.Event,
|
|
run_once: bool = False,
|
|
worker_name: str | None = None,
|
|
) -> list[WorkerOutcome]:
|
|
"""Drive ``pass_fn`` until ``stop`` is set — or exactly once.
|
|
|
|
``run_once=True`` is the cron/scheduled shape (what the CLI uses by
|
|
default). The loop shape exists for long-running deployments.
|
|
|
|
A failing pass is persisted before the loop continues. A worker that only
|
|
logged to stderr would lose every failure the moment the container was
|
|
replaced — which is precisely when someone wants to know what went wrong.
|
|
"""
|
|
outcomes: list[WorkerOutcome] = []
|
|
name: str = worker_name or str(getattr(pass_fn, "__name__", "worker"))
|
|
while True:
|
|
try:
|
|
outcome = await pass_fn()
|
|
outcomes.append(outcome)
|
|
logger.info(outcome.summary())
|
|
for note in outcome.notes:
|
|
logger.info("%s | %s", outcome.name, note)
|
|
except Exception as exc: # noqa: BLE001 - a worker must survive a bad pass
|
|
logger.exception("%s: pass failed", name)
|
|
await _record_pass_failure(name, exc)
|
|
outcomes.append(
|
|
WorkerOutcome(name=name, failed=1, notes=[f"pass failed: {type(exc).__name__}"])
|
|
)
|
|
|
|
if run_once:
|
|
return outcomes
|
|
|
|
try:
|
|
# Returns early (cleanly) when ``stop`` is set during the wait.
|
|
await asyncio.wait_for(stop.wait(), timeout=interval_seconds)
|
|
return outcomes
|
|
except TimeoutError:
|
|
continue
|
|
|
|
|
|
async def _record_pass_failure(worker_name: str, exc: BaseException) -> None:
|
|
"""Persist a failed pass. Imported lazily to keep this module import-light."""
|
|
from app.domain.audit.models import ErrorSeverity, ErrorSource
|
|
from app.infrastructure.error_log import record_error
|
|
|
|
await record_error(
|
|
kind="worker_pass_failed",
|
|
message=str(exc) or type(exc).__name__,
|
|
exc=exc,
|
|
source=ErrorSource.WORKER,
|
|
severity=ErrorSeverity.ERROR,
|
|
metadata={"worker": worker_name},
|
|
)
|
|
|
|
|
|
def install_signal_handlers(stop: asyncio.Event) -> None:
|
|
"""Ask the event loop to set ``stop`` on SIGINT/SIGTERM.
|
|
|
|
Best-effort: on a platform without these signals (or outside the main
|
|
thread) the worker simply runs until interrupted some other way.
|
|
"""
|
|
loop = asyncio.get_running_loop()
|
|
for signal_name in ("SIGINT", "SIGTERM"):
|
|
try:
|
|
import signal as _signal
|
|
|
|
sig = getattr(_signal, signal_name)
|
|
except (AttributeError, ValueError):
|
|
continue
|
|
try:
|
|
loop.add_signal_handler(sig, stop.set)
|
|
except (NotImplementedError, RuntimeError, ValueError):
|
|
continue
|
|
|
|
|
|
__all__ = [
|
|
"WorkerOutcome",
|
|
"install_signal_handlers",
|
|
"run_periodically",
|
|
"worker_session",
|
|
]
|