first
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
"""asf-worker — stateless RabbitMQ consumer ("gli addetti alle poste").
|
||||
|
||||
Each subscribed queue maps to a handler. Handlers look up the persistent
|
||||
Mongo ``Job`` record, update its status to running → completed|failed and
|
||||
optionally publish domain events.
|
||||
|
||||
Workers are horizontally scalable. All persistent state lives in MongoDB,
|
||||
never in process memory.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from .config import WorkerSettings, get_settings
|
||||
from .worker import run_worker, build_consumer, JobHandlerFn
|
||||
|
||||
__all__ = ["WorkerSettings", "get_settings", "run_worker", "build_consumer", "JobHandlerFn"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Allow running with `python -m app.worker`."""
|
||||
from .worker import run_worker
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_worker()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class WorkerSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env", case_sensitive=False, extra="ignore"
|
||||
)
|
||||
asf_env: str = "development"
|
||||
asf_log_level: str = "INFO"
|
||||
rabbitmq_uri: str = "amqp://azurian:StrongPassword123!@172.19.40.201:5672/asf"
|
||||
mongo_uri: str = "mongodb://172.19.40.102:27017,172.19.40.103:27017/asf?replicaSet=rs0"
|
||||
mongo_db: str = "asf"
|
||||
ai_gateway_provider: str = "mock"
|
||||
|
||||
# Queue → routing key bindings consumed by this worker.
|
||||
# A single worker can subscribe to a subset; in production you'd run
|
||||
# separate worker deployments per queue pool.
|
||||
queues: list[str] = [
|
||||
"asf.ai.interactive",
|
||||
"asf.ai.background",
|
||||
"asf.indexing",
|
||||
"asf.code_analysis",
|
||||
"asf.testing",
|
||||
"asf.devops",
|
||||
"asf.notifications",
|
||||
]
|
||||
|
||||
# If true, AI jobs invoke the AI Gateway and write results back.
|
||||
enable_ai_handlers: bool = True
|
||||
|
||||
worker_id: str = "asf-worker-1"
|
||||
prefetch_count: int = 4
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> WorkerSettings:
|
||||
return WorkerSettings()
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Job handler dispatch + default handlers (AI + logging).
|
||||
|
||||
Handlers share the following signature::
|
||||
|
||||
async fn(
|
||||
envelope: JobRequestEnvelope,
|
||||
*,
|
||||
deps: WorkerDependencies,
|
||||
) -> HandlerResult
|
||||
|
||||
WorkerDependencies wraps Mongo collections, the AI Gateway, and logging.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection, AsyncIOMotorDatabase
|
||||
from asf_events.base import JobRequestEnvelope
|
||||
from asf_domain import JobStatus, JobUpdate, JobUsage
|
||||
|
||||
from app.config import WorkerSettings # type: ignore[attr-defined]
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
JobHandlerFn = Callable[["JobRequestEnvelope", "WorkerDependencies"], Awaitable["HandlerResult"]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandlerResult:
|
||||
status: JobStatus
|
||||
result: dict[str, Any] = field(default_factory=dict)
|
||||
error: str | None = None
|
||||
error_detail: dict[str, Any] | None = None
|
||||
usage: JobUsage | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerDependencies:
|
||||
db: AsyncIOMotorDatabase
|
||||
jobs: AsyncIOMotorCollection
|
||||
ai_executions: AsyncIOMotorCollection
|
||||
artifacts: AsyncIOMotorCollection
|
||||
settings: WorkerSettings
|
||||
ai_gateway: Any | None = None # will be set if enable_ai_handlers=True
|
||||
worker_id: str = "asf-worker"
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Default handlers
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
async def _mark_job_done(
|
||||
deps: WorkerDependencies,
|
||||
job_id: UUID,
|
||||
result: HandlerResult,
|
||||
) -> None:
|
||||
"""Persist the final handler outcome back to MongoDB jobs collection.
|
||||
|
||||
This is the ONLY place worker-state leaves process memory and hits the
|
||||
durable source of truth (MongoDB).
|
||||
"""
|
||||
updates: dict[str, Any] = {
|
||||
"status": result.status.value
|
||||
if isinstance(result.status, JobStatus)
|
||||
else str(result.status),
|
||||
"completed_at": __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc
|
||||
),
|
||||
"updated_at": __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc
|
||||
),
|
||||
"worker": deps.worker_id,
|
||||
}
|
||||
if result.result:
|
||||
updates["result"] = result.result
|
||||
if result.error:
|
||||
updates["error"] = result.error
|
||||
if result.error_detail:
|
||||
updates["error_detail"] = result.error_detail
|
||||
if result.usage is not None:
|
||||
updates["usage"] = (
|
||||
result.usage.model_dump()
|
||||
if hasattr(result.usage, "model_dump")
|
||||
else result.usage
|
||||
)
|
||||
await deps.jobs.update_one({"_id": job_id}, {"$set": updates})
|
||||
|
||||
|
||||
async def noop_handler(
|
||||
env: JobRequestEnvelope, deps: WorkerDependencies
|
||||
) -> HandlerResult:
|
||||
"""Catch-all handler: acknowledges the job and leaves a note.
|
||||
|
||||
Useful when a queue is not yet staffed with a real handler — ensures we
|
||||
never leave work stuck "running" with no outcome.
|
||||
"""
|
||||
log.warn(
|
||||
"no_handler_registered",
|
||||
job_id=str(env.job_id),
|
||||
queue=env.type,
|
||||
payload_keys=list(env.payload.keys()),
|
||||
)
|
||||
return HandlerResult(
|
||||
status=JobStatus.FAILED,
|
||||
error=f"No worker handler registered for job_type={env.type!r}",
|
||||
error_detail={"hint": "register a handler in app.worker.HANDLERS_BY_TYPE"},
|
||||
)
|
||||
|
||||
|
||||
async def echo_handler(
|
||||
env: JobRequestEnvelope, deps: WorkerDependencies
|
||||
) -> HandlerResult:
|
||||
"""Simple echo / dev handler: stores the payload back as result."""
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={"echo": env.payload, "handled_by": deps.worker_id},
|
||||
usage=JobUsage(cpu_seconds=0.001),
|
||||
)
|
||||
|
||||
|
||||
async def ai_handler(
|
||||
env: JobRequestEnvelope, deps: WorkerDependencies
|
||||
) -> HandlerResult:
|
||||
"""AI-interactive + AI-background handler.
|
||||
|
||||
Dispatches to the ASF AI Gateway (MockProvider in bootstrap) for:
|
||||
- "ask_asf" kind (chat completion)
|
||||
- "analyze_code"
|
||||
- "generate_tests"
|
||||
- "decompose_requirement"
|
||||
|
||||
In the bootstrap phase we safely fall back to echo if no gateway is
|
||||
attached (e.g. test runs with AI disabled).
|
||||
"""
|
||||
|
||||
kind = env.payload.get("kind", "unknown")
|
||||
log.info(
|
||||
"ai_handler_start",
|
||||
job_id=str(env.job_id),
|
||||
kind=kind,
|
||||
provider=deps.settings.ai_gateway_provider,
|
||||
)
|
||||
|
||||
if deps.ai_gateway is None:
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={
|
||||
"note": (
|
||||
"ai_gateway not attached to this worker. "
|
||||
"Install services/asf-ai and wire it in worker."
|
||||
),
|
||||
"payload": env.payload,
|
||||
},
|
||||
)
|
||||
|
||||
from asf_ai import ( # type: ignore[import-not-found]
|
||||
AIGateway,
|
||||
ChatRequest,
|
||||
ChatMessage,
|
||||
TraceContext,
|
||||
CodeAnalysisRequest,
|
||||
TestGenerationRequest,
|
||||
RequirementDecomposeRequest,
|
||||
)
|
||||
|
||||
gw: AIGateway = deps.ai_gateway
|
||||
trace = TraceContext(
|
||||
tenant_id=env.tenant_id,
|
||||
project_id=env.project_id,
|
||||
job_id=env.job_id,
|
||||
correlation_id=env.correlation_id,
|
||||
)
|
||||
|
||||
try:
|
||||
if kind == "ask_asf":
|
||||
question = env.payload.get("question", "")
|
||||
req = ChatRequest(
|
||||
trace=trace,
|
||||
messages=[ChatMessage(role="user", content=question)],
|
||||
context_summary=env.payload.get("context_summary"),
|
||||
)
|
||||
resp = await gw.chat(req)
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={
|
||||
"kind": "ask_asf",
|
||||
"question": question,
|
||||
"answer": resp.reply,
|
||||
"references": resp.references,
|
||||
},
|
||||
usage=JobUsage(
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
),
|
||||
)
|
||||
|
||||
if kind == "analyze_code":
|
||||
req = CodeAnalysisRequest(
|
||||
trace=trace,
|
||||
code_snippet=env.payload.get("code_snippet", ""),
|
||||
language=env.payload.get("language"),
|
||||
file_path=env.payload.get("file_path"),
|
||||
)
|
||||
resp = await gw.analyze_code(req)
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={
|
||||
"kind": "analyze_code",
|
||||
"summary": resp.summary,
|
||||
"findings": [f.model_dump(mode="json") for f in resp.findings],
|
||||
},
|
||||
usage=JobUsage(
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
),
|
||||
)
|
||||
|
||||
if kind == "generate_tests":
|
||||
req = TestGenerationRequest(
|
||||
trace=trace,
|
||||
code_snippet=env.payload.get("code_snippet", ""),
|
||||
language=env.payload.get("language", "python"),
|
||||
test_framework=env.payload.get("framework", "pytest"),
|
||||
goal=env.payload.get("goal", "Generate unit tests"),
|
||||
)
|
||||
resp = await gw.generate_tests(req)
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={
|
||||
"kind": "generate_tests",
|
||||
"test_cases": resp.test_cases,
|
||||
"fixtures_needed": resp.fixtures_needed,
|
||||
"coverage_notes": resp.coverage_notes,
|
||||
},
|
||||
usage=JobUsage(
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
),
|
||||
)
|
||||
|
||||
if kind == "decompose_requirement":
|
||||
req = RequirementDecomposeRequest(
|
||||
trace=trace,
|
||||
requirement_text=env.payload.get("requirement_text", ""),
|
||||
context=env.payload.get("context"),
|
||||
max_subtasks=int(env.payload.get("max_subtasks", 20)),
|
||||
)
|
||||
resp = await gw.decompose_requirement(req)
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={
|
||||
"kind": "decompose_requirement",
|
||||
"subtasks": [s.model_dump(mode="json") for s in resp.subtasks],
|
||||
"architecture_notes": resp.architecture_notes,
|
||||
"open_questions": resp.open_questions,
|
||||
},
|
||||
usage=JobUsage(
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
),
|
||||
)
|
||||
|
||||
return await echo_handler(env, deps)
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("ai_handler_failed", exc=str(exc))
|
||||
return HandlerResult(
|
||||
status=JobStatus.FAILED,
|
||||
error=f"AI handler raised: {type(exc).__name__}: {exc}",
|
||||
error_detail={"payload": env.payload},
|
||||
)
|
||||
|
||||
|
||||
async def indexing_handler(
|
||||
env: JobRequestEnvelope, deps: WorkerDependencies
|
||||
) -> HandlerResult:
|
||||
"""Stub handler for repository / doc indexing jobs."""
|
||||
log.info(
|
||||
"indexing_handler_stub",
|
||||
job_id=str(env.job_id),
|
||||
payload=env.payload,
|
||||
)
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={"stub": True, "indexed_items": 0, "source": env.payload.get("source")},
|
||||
usage=JobUsage(cpu_seconds=0.005),
|
||||
)
|
||||
|
||||
|
||||
async def notification_handler(
|
||||
env: JobRequestEnvelope, deps: WorkerDependencies
|
||||
) -> HandlerResult:
|
||||
"""Stub handler for email / webhook / in-app notifications."""
|
||||
log.info(
|
||||
"notification_stub",
|
||||
job_id=str(env.job_id),
|
||||
payload_keys=list(env.payload.keys()),
|
||||
)
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={"note": "notifications are a stub in bootstrap"},
|
||||
)
|
||||
|
||||
|
||||
# Final status persistence step, exported for reuse in the main worker loop.
|
||||
mark_job_done = _mark_job_done
|
||||
@@ -0,0 +1,22 @@
|
||||
"""ASF Worker job handlers — pluggable async handlers called from the generic consumer loop.
|
||||
|
||||
New capabilities are registered in ``REGISTRY`` below: mapping of ``job_type`` string
|
||||
to a ``JobHandlerFn``. The worker will route incoming messages by matching
|
||||
``JobRequestEnvelope.job_type`` against keys in ``REGISTRY``.
|
||||
|
||||
Currently stubbed; individual handler modules are added incrementally as features
|
||||
are plugged in (see bootstrap prompt §16).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asf_events.base import JobRequestEnvelope
|
||||
|
||||
from ..handlers import HandlerResult, WorkerDependencies
|
||||
from ..worker import JobHandlerFn
|
||||
|
||||
REGISTRY: dict[str, JobHandlerFn] = {
|
||||
# "ai.analyze_code": run_analyze_code,
|
||||
# "ai.generate_tests": run_generate_tests,
|
||||
# "ai.decompose_requirements": run_decompose_requirements,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"""AI-related job handlers stubbed out for smoke import.
|
||||
|
||||
Actual execution routes through asf-ai.AIGateway once it is wired. The handler
|
||||
below produces a stable HandlerResult.ok(...) carrying a placeholder payload so
|
||||
that consumer-loop smoke tests do not need a live LLM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asf_events.base import JobRequestEnvelope
|
||||
|
||||
from ..handlers import HandlerResult
|
||||
from ..worker import WorkerDependencies
|
||||
|
||||
|
||||
async def run_ai_job(req: JobRequestEnvelope, deps: WorkerDependencies) -> HandlerResult:
|
||||
del deps
|
||||
payload = req.payload or {}
|
||||
handler = payload.get("handler") if isinstance(payload, dict) else None
|
||||
return HandlerResult.ok(job_id=req.job_id, output={"handler": handler or "ai.stub", "status": "ok"})
|
||||
@@ -0,0 +1,237 @@
|
||||
"""RabbitMQ consumer loop — main entry point for the asf-worker process.
|
||||
|
||||
Run::
|
||||
python -m app.worker
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
import aio_pika
|
||||
import aio_pika.abc
|
||||
import structlog
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
|
||||
from .config import WorkerSettings, get_settings
|
||||
from .handlers import (
|
||||
WorkerDependencies,
|
||||
noop_handler,
|
||||
echo_handler,
|
||||
ai_handler,
|
||||
indexing_handler,
|
||||
notification_handler,
|
||||
mark_job_done,
|
||||
HandlerResult,
|
||||
)
|
||||
from asf_events.base import JobRequestEnvelope
|
||||
from asf_domain import JobStatus, JobUpdate
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
JobHandlerFn = Callable[[JobRequestEnvelope, WorkerDependencies], Awaitable[HandlerResult]]
|
||||
"""Type alias for a queue handler: (envelope, deps) → coroutine returning HandlerResult."""
|
||||
|
||||
|
||||
# Queue name → handler function
|
||||
HANDLERS_BY_QUEUE: dict[str, Callable] = {
|
||||
"asf.ai.interactive": ai_handler,
|
||||
"asf.ai.background": ai_handler,
|
||||
"asf.indexing": indexing_handler,
|
||||
"asf.code_analysis": ai_handler, # use analyze_code via AI gateway
|
||||
"asf.testing": ai_handler, # use generate_tests via AI gateway
|
||||
"asf.devops": echo_handler, # stub
|
||||
"asf.notifications": notification_handler,
|
||||
}
|
||||
|
||||
|
||||
async def _claim_running(deps: WorkerDependencies, envelope: JobRequestEnvelope) -> None:
|
||||
"""Atomically mark the job as running + claim worker ownership.
|
||||
|
||||
Guards against duplicate-delivery races from RabbitMQ (at-least-once).
|
||||
If the job is already in a terminal state we bail out early.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
res = await deps.jobs.update_one(
|
||||
{
|
||||
"_id": envelope.job_id,
|
||||
"tenant_id": envelope.tenant_id,
|
||||
"status": {"$in": [JobStatus.QUEUED.value]},
|
||||
},
|
||||
{
|
||||
"$set": {
|
||||
"status": JobStatus.RUNNING.value,
|
||||
"started_at": now,
|
||||
"updated_at": now,
|
||||
"worker": deps.worker_id,
|
||||
},
|
||||
"$inc": {"retries": 1},
|
||||
},
|
||||
)
|
||||
if res.matched_count == 0:
|
||||
|
||||
log.warn(
|
||||
"job_already_claimed_or_terminal",
|
||||
job_id=str(envelope.job_id),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Consumer:
|
||||
settings: WorkerSettings
|
||||
connection: aio_pika.abc.AbstractConnection | None = None
|
||||
channel: aio_pika.abc.AbstractChannel | None = None
|
||||
mongo_client: AsyncIOMotorClient | None = None
|
||||
deps: WorkerDependencies | None = None
|
||||
stop_event: asyncio.Event | None = None
|
||||
|
||||
async def setup(self) -> None:
|
||||
self.stop_event = asyncio.Event()
|
||||
|
||||
# ---- Mongo ----
|
||||
self.mongo_client = AsyncIOMotorClient(
|
||||
self.settings.mongo_uri,
|
||||
uuidRepresentation="standard",
|
||||
tz_aware=True,
|
||||
)
|
||||
db = self.mongo_client[self.settings.mongo_db]
|
||||
self.deps = WorkerDependencies(
|
||||
db=db,
|
||||
jobs=db["jobs"],
|
||||
ai_executions=db["ai_executions"],
|
||||
artifacts=db["artifacts"],
|
||||
settings=self.settings,
|
||||
worker_id=self.settings.worker_id,
|
||||
)
|
||||
|
||||
# ---- Optional AI gateway wiring ----
|
||||
if self.settings.enable_ai_handlers:
|
||||
try:
|
||||
from asf_ai.app.gateway import AIGateway, AIGatewayConfig # type: ignore[import-not-found]
|
||||
self.deps.ai_gateway = AIGateway(config=AIGatewayConfig(
|
||||
default_provider=self.settings.ai_gateway_provider,
|
||||
))
|
||||
log.info(
|
||||
"ai_gateway_attached", provider=self.settings.ai_gateway_provider
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warn("ai_gateway_unavailable", exc=str(exc))
|
||||
|
||||
# ---- RabbitMQ ----
|
||||
self.connection = await aio_pika.connect_robust(self.settings.rabbitmq_uri)
|
||||
self.channel = await self.connection.channel()
|
||||
await self.channel.set_qos(prefetch_count=self.settings.prefetch_count)
|
||||
|
||||
jobs_exchange = await self.channel.declare_exchange(
|
||||
"asf.jobs", type=aio_pika.ExchangeType.DIRECT, durable=True
|
||||
)
|
||||
await self.channel.declare_exchange(
|
||||
"asf.events", type=aio_pika.ExchangeType.TOPIC, durable=True
|
||||
)
|
||||
|
||||
for qname in self.settings.queues:
|
||||
q = await self.channel.declare_queue(qname, durable=True)
|
||||
await q.bind(jobs_exchange, routing_key=qname)
|
||||
await q.consume(self._make_consumer_callback(qname))
|
||||
log.info("subscribed", queue=qname)
|
||||
|
||||
def _make_consumer_callback(self, qname: str):
|
||||
handler = HANDLERS_BY_QUEUE.get(qname, noop_handler)
|
||||
|
||||
async def _cb(message: aio_pika.abc.AbstractIncomingMessage) -> None:
|
||||
async with message.process(requeue=True, reject_on_redelivered=False):
|
||||
assert self.deps is not None
|
||||
try:
|
||||
envelope = JobRequestEnvelope.model_validate_json(message.body)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("bad_message_body", queue=qname)
|
||||
return # ack & drop poison
|
||||
|
||||
# 1. claim ownership in Mongo
|
||||
await _claim_running(self.deps, envelope)
|
||||
# 2. run the handler
|
||||
try:
|
||||
result: HandlerResult = await handler(envelope, self.deps)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("handler_unhandled_exception", queue=qname)
|
||||
result = HandlerResult(
|
||||
status=JobStatus.FAILED,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
# 3. persist final state
|
||||
try:
|
||||
await mark_job_done(self.deps, envelope.job_id, result)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("job_persistence_failed", job_id=str(envelope.job_id))
|
||||
|
||||
return _cb
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
assert self.stop_event is not None
|
||||
log.info(
|
||||
"asf_worker_started",
|
||||
queues=self.settings.queues,
|
||||
worker_id=self.settings.worker_id,
|
||||
)
|
||||
await self.stop_event.wait()
|
||||
|
||||
async def close(self) -> None:
|
||||
log.info("asf_worker_shutting_down")
|
||||
if self.channel is not None:
|
||||
try:
|
||||
await self.channel.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if self.connection is not None:
|
||||
try:
|
||||
await self.connection.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if self.mongo_client is not None:
|
||||
self.mongo_client.close()
|
||||
|
||||
|
||||
def build_consumer(settings: WorkerSettings | None = None) -> Consumer:
|
||||
return Consumer(settings=settings or get_settings())
|
||||
|
||||
|
||||
async def run_worker_async() -> None:
|
||||
settings = get_settings()
|
||||
consumer = build_consumer(settings)
|
||||
await consumer.setup()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _graceful() -> None:
|
||||
if consumer.stop_event is not None:
|
||||
consumer.stop_event.set()
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, _graceful)
|
||||
except NotImplementedError:
|
||||
# Windows
|
||||
pass
|
||||
|
||||
try:
|
||||
await consumer.run_forever()
|
||||
finally:
|
||||
await consumer.close()
|
||||
|
||||
|
||||
def run_worker() -> None:
|
||||
"""Synchronous entry point (e.g. python -m app.worker)."""
|
||||
try:
|
||||
asyncio.run(run_worker_async())
|
||||
except KeyboardInterrupt:
|
||||
print("Interrupted. Goodbye.", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_worker()
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
@@ -0,0 +1,17 @@
|
||||
asf-domain==0.1.0
|
||||
asf-events==0.1.0
|
||||
asf-connectors==0.1.0
|
||||
|
||||
# Core API + context + ai are imported by worker handlers for end-to-end flow
|
||||
# (optional — install from local tree in dev)
|
||||
|
||||
pydantic>=2.7
|
||||
pydantic-settings>=2.2
|
||||
aio-pika>=9.4
|
||||
structlog>=24.4
|
||||
python-dotenv>=1.0
|
||||
motor>=3.5
|
||||
httpx>=0.27
|
||||
|
||||
pytest>=8
|
||||
pytest-asyncio>=0.23
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Shared pytest fixtures for asf-worker tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
WORKER_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(WORKER_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(WORKER_ROOT))
|
||||
|
||||
TENANT_ID = UUID("00000000-0000-0000-0000-000000000099")
|
||||
PROJECT_ID = UUID("00000000-0000-0000-0000-000000000088")
|
||||
CORRELATION_ID = "test-correlation-worker"
|
||||
|
||||
|
||||
class FakeJobsCollection:
|
||||
"""In-memory AsyncIOMotorCollection stand-in for claim/persistence tests.
|
||||
|
||||
Mirrors the tiny subset of the Motor API that `_claim_running` and
|
||||
`_mark_job_done` exercise: ``update_one(filter, ops)`` with
|
||||
``matched_count`` on the returned UpdateResult.
|
||||
"""
|
||||
|
||||
@dataclass
|
||||
class UpdateResult:
|
||||
matched_count: int = 0
|
||||
modified_count: int = 0
|
||||
upserted_id: object = None
|
||||
|
||||
def __init__(self, docs: dict[UUID, dict] | None = None) -> None:
|
||||
self._docs: dict[UUID, dict] = {k: dict(v) for k, v in (docs or {}).items()}
|
||||
self.last_update_filter: object = None
|
||||
self.last_update_ops: object = None
|
||||
|
||||
async def update_one(self, filt: dict, ops: dict) -> UpdateResult:
|
||||
self.last_update_filter = filt
|
||||
self.last_update_ops = ops
|
||||
jid = filt.get("_id")
|
||||
existing = self._docs.get(jid) if jid is not None else None
|
||||
if existing is None:
|
||||
return FakeJobsCollection.UpdateResult(matched_count=0)
|
||||
|
||||
# Apply the whole filter object like MongoDB does (simple top-level equality match).
|
||||
for k, v in filt.items():
|
||||
if k == "status" and isinstance(v, dict) and "$in" in v:
|
||||
if existing.get(k) not in v["$in"]:
|
||||
return FakeJobsCollection.UpdateResult(matched_count=0)
|
||||
elif k == "_id":
|
||||
if existing.get(k) != v:
|
||||
return FakeJobsCollection.UpdateResult(matched_count=0)
|
||||
else:
|
||||
if existing.get(k) != v:
|
||||
return FakeJobsCollection.UpdateResult(matched_count=0)
|
||||
|
||||
if "$set" in ops:
|
||||
for k, v in ops["$set"].items():
|
||||
existing[k] = v
|
||||
if "$inc" in ops:
|
||||
for k, v in ops["$inc"].items():
|
||||
existing[k] = (existing.get(k) or 0) + v
|
||||
return FakeJobsCollection.UpdateResult(matched_count=1, modified_count=1)
|
||||
|
||||
|
||||
class FakeDb:
|
||||
def __init__(self, jobs: FakeJobsCollection | None = None) -> None:
|
||||
self._jobs = jobs or FakeJobsCollection()
|
||||
|
||||
def __getitem__(self, col: str):
|
||||
if col == "jobs":
|
||||
return self._jobs
|
||||
raise KeyError(col)
|
||||
|
||||
|
||||
class FakeSettings:
|
||||
mongo_uri = "mongodb://fake"
|
||||
mongo_db = "fake_asf"
|
||||
rabbitmq_uri = "amqp://fake"
|
||||
queues: list[str] = []
|
||||
prefetch_count: int = 1
|
||||
worker_id: str = "test-worker"
|
||||
enable_ai_handlers: bool = False
|
||||
ai_gateway_provider: str = "mock"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_settings():
|
||||
return FakeSettings()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_jobs_collection():
|
||||
return FakeJobsCollection()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_db(fake_jobs_collection):
|
||||
return FakeDb(jobs=fake_jobs_collection)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker_deps(fake_settings, fake_db, fake_jobs_collection):
|
||||
from app.handlers import WorkerDependencies
|
||||
return WorkerDependencies(
|
||||
db=fake_db,
|
||||
jobs=fake_jobs_collection,
|
||||
ai_executions=fake_db["jobs"], # fine - not exercised
|
||||
artifacts=fake_db["jobs"],
|
||||
settings=fake_settings,
|
||||
worker_id=fake_settings.worker_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_envelope():
|
||||
from asf_events.base import JobRequestEnvelope
|
||||
from asf_domain import JobType
|
||||
return JobRequestEnvelope(
|
||||
job_id=uuid4(),
|
||||
tenant_id=TENANT_ID,
|
||||
project_id=PROJECT_ID,
|
||||
correlation_id=CORRELATION_ID,
|
||||
type=JobType.DEVOPS.value,
|
||||
payload={"hello": "world", "sequence": 7},
|
||||
priority="normal",
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests for worker handlers + claim/persistence logic.
|
||||
|
||||
All tests run in-memory using FakeJobsCollection — no Mongo/Rabbit required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from asf_domain import JobStatus, JobUsage
|
||||
|
||||
|
||||
class TestEchoHandler:
|
||||
@pytest.mark.asyncio
|
||||
async def test_echo_handler_returns_payload_as_result(self, worker_deps, sample_envelope):
|
||||
from app.handlers import echo_handler
|
||||
res = await echo_handler(sample_envelope, worker_deps)
|
||||
assert res.status == JobStatus.COMPLETED
|
||||
assert res.result["echo"] == sample_envelope.payload
|
||||
assert res.result["handled_by"] == worker_deps.worker_id
|
||||
assert isinstance(res.usage, JobUsage)
|
||||
assert res.usage.cpu_seconds > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_handler_marks_failed_when_queue_unregistered(self, worker_deps, sample_envelope):
|
||||
from app.handlers import noop_handler
|
||||
res = await noop_handler(sample_envelope, worker_deps)
|
||||
assert res.status == JobStatus.FAILED
|
||||
assert "No worker handler registered" in res.error
|
||||
assert res.error_detail is not None and "hint" in res.error_detail
|
||||
|
||||
|
||||
class TestMarkJobDonePersistence:
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_job_done_writes_status_result_usage(self, worker_deps, sample_envelope, fake_jobs_collection):
|
||||
from app.handlers import mark_job_done, HandlerResult
|
||||
fake_jobs_collection._docs[sample_envelope.job_id] = {
|
||||
"_id": sample_envelope.job_id,
|
||||
"status": JobStatus.RUNNING.value,
|
||||
}
|
||||
result = HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={"echo": {"ok": True}},
|
||||
usage=JobUsage(cpu_seconds=0.042, input_tokens=100, output_tokens=200),
|
||||
)
|
||||
await mark_job_done(worker_deps, sample_envelope.job_id, result)
|
||||
doc = fake_jobs_collection._docs[sample_envelope.job_id]
|
||||
assert doc["status"] == JobStatus.COMPLETED.value
|
||||
assert doc["result"] == result.result
|
||||
assert doc["worker"] == worker_deps.worker_id
|
||||
assert doc["usage"]["cpu_seconds"] == 0.042
|
||||
assert doc["usage"]["input_tokens"] == 100
|
||||
assert doc["completed_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_job_done_records_error_when_failed(self, worker_deps, sample_envelope, fake_jobs_collection):
|
||||
from app.handlers import mark_job_done, HandlerResult
|
||||
fake_jobs_collection._docs[sample_envelope.job_id] = {"_id": sample_envelope.job_id}
|
||||
await mark_job_done(
|
||||
worker_deps,
|
||||
sample_envelope.job_id,
|
||||
HandlerResult(
|
||||
status=JobStatus.FAILED,
|
||||
error="BOOM",
|
||||
error_detail={"stage": "analyze"},
|
||||
),
|
||||
)
|
||||
doc = fake_jobs_collection._docs[sample_envelope.job_id]
|
||||
assert doc["status"] == JobStatus.FAILED.value
|
||||
assert doc["error"] == "BOOM"
|
||||
assert doc["error_detail"] == {"stage": "analyze"}
|
||||
# result / usage keys are ABSENT when empty
|
||||
assert "result" not in doc
|
||||
|
||||
|
||||
class TestClaimRunningAtomic:
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_sets_running_and_inc_retries(self, worker_deps, sample_envelope, fake_jobs_collection):
|
||||
from app.worker import _claim_running
|
||||
fake_jobs_collection._docs[sample_envelope.job_id] = {
|
||||
"_id": sample_envelope.job_id,
|
||||
"tenant_id": sample_envelope.tenant_id,
|
||||
"status": JobStatus.QUEUED.value,
|
||||
"retries": 0,
|
||||
}
|
||||
await _claim_running(worker_deps, sample_envelope)
|
||||
doc = fake_jobs_collection._docs[sample_envelope.job_id]
|
||||
assert doc["status"] == JobStatus.RUNNING.value
|
||||
assert doc["worker"] == worker_deps.worker_id
|
||||
assert doc["retries"] == 1
|
||||
assert doc["started_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_bails_if_job_already_terminal(self, worker_deps, sample_envelope, fake_jobs_collection):
|
||||
from app.worker import _claim_running
|
||||
fake_jobs_collection._docs[sample_envelope.job_id] = {
|
||||
"_id": sample_envelope.job_id,
|
||||
"tenant_id": sample_envelope.tenant_id,
|
||||
"status": JobStatus.COMPLETED.value, # not QUEUED
|
||||
"worker": "some-other-worker",
|
||||
}
|
||||
await _claim_running(worker_deps, sample_envelope)
|
||||
doc = fake_jobs_collection._docs[sample_envelope.job_id]
|
||||
# still terminal + still original worker -> no overwrites
|
||||
assert doc["status"] == JobStatus.COMPLETED.value
|
||||
assert doc["worker"] == "some-other-worker"
|
||||
assert "retries" not in doc or doc["retries"] == 0
|
||||
|
||||
|
||||
class TestAIMockedGatewayHandler:
|
||||
"""Ensure ai_handler works safely with no real AI gateway attached (fallback path)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_handler_without_gateway_returns_payload_note(self, worker_deps, sample_envelope):
|
||||
from asf_domain import JobType
|
||||
sample_envelope.type = JobType.AI_INTERACTIVE.value
|
||||
sample_envelope.payload = {"kind": "ask_asf", "question": "Summarize REQ-103"}
|
||||
worker_deps.ai_gateway = None
|
||||
from app.handlers import ai_handler
|
||||
res = await ai_handler(sample_envelope, worker_deps)
|
||||
assert res.status == JobStatus.COMPLETED
|
||||
assert "ai_gateway not attached" in res.result["note"]
|
||||
assert res.result["payload"]["kind"] == "ask_asf"
|
||||
|
||||
|
||||
class TestHandlersQueueContract:
|
||||
def test_expected_queues_have_registered_handlers(self):
|
||||
from app.worker import HANDLERS_BY_QUEUE
|
||||
expected = {
|
||||
"asf.ai.interactive",
|
||||
"asf.ai.background",
|
||||
"asf.indexing",
|
||||
"asf.code_analysis",
|
||||
"asf.testing",
|
||||
"asf.devops",
|
||||
"asf.notifications",
|
||||
}
|
||||
assert set(HANDLERS_BY_QUEUE.keys()) == expected
|
||||
Reference in New Issue
Block a user