نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
"""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",
|
||||
]
|
||||
Reference in New Issue
Block a user