Files
mjalmoudy94 717e53a7ce نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
2026-10-13 00:39:28 +03:30

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",
]