Spaces:
Running
Running
feat(worker): add named Celery queues (high/low priority) with task routing by repo size
Browse files- app/worker/celery_app.py +44 -7
- app/worker/tasks.py +49 -12
- docker-compose.yml +1 -1
- docs/architecture.md +17 -1
app/worker/celery_app.py
CHANGED
|
@@ -1,23 +1,60 @@
|
|
| 1 |
from celery import Celery
|
|
|
|
| 2 |
|
| 3 |
from app.core.config import get_settings
|
| 4 |
|
| 5 |
settings = get_settings()
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
celery_app = Celery(
|
| 8 |
-
"
|
| 9 |
broker=settings.redis_url,
|
| 10 |
backend=settings.celery_result_backend or settings.redis_url,
|
| 11 |
)
|
| 12 |
|
| 13 |
celery_app.conf.update(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
task_serializer="json",
|
| 15 |
-
accept_content=["json"],
|
| 16 |
result_serializer="json",
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
result_expires=
|
|
|
|
|
|
|
| 21 |
task_always_eager=settings.celery_task_always_eager,
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
)
|
|
|
|
| 1 |
from celery import Celery
|
| 2 |
+
from kombu import Exchange, Queue
|
| 3 |
|
| 4 |
from app.core.config import get_settings
|
| 5 |
|
| 6 |
settings = get_settings()
|
| 7 |
|
| 8 |
+
# Two named queues:
|
| 9 |
+
# high — small repos (< 10 MB), expected to complete in < 30s
|
| 10 |
+
# low — large repos (>= 10 MB), may take several minutes
|
| 11 |
+
#
|
| 12 |
+
# Both are durable (survives Redis restart). The worker consumes high
|
| 13 |
+
# first due to queue ordering in the -Q argument (see docker-compose.yml).
|
| 14 |
+
|
| 15 |
+
default_exchange = Exchange("default", type="direct")
|
| 16 |
+
|
| 17 |
+
HIGH_QUEUE = "high"
|
| 18 |
+
LOW_QUEUE = "low"
|
| 19 |
+
|
| 20 |
celery_app = Celery(
|
| 21 |
+
"ai_code_review",
|
| 22 |
broker=settings.redis_url,
|
| 23 |
backend=settings.celery_result_backend or settings.redis_url,
|
| 24 |
)
|
| 25 |
|
| 26 |
celery_app.conf.update(
|
| 27 |
+
# Queue definitions
|
| 28 |
+
task_queues=(
|
| 29 |
+
Queue(HIGH_QUEUE, default_exchange, routing_key="high", durable=True),
|
| 30 |
+
Queue(LOW_QUEUE, default_exchange, routing_key="low", durable=True),
|
| 31 |
+
),
|
| 32 |
+
task_default_queue=HIGH_QUEUE,
|
| 33 |
+
task_default_exchange="default",
|
| 34 |
+
task_default_routing_key="high",
|
| 35 |
+
|
| 36 |
+
# Route analyze_repository_task to high or low based on a size hint
|
| 37 |
+
# passed as a task header (set in tasks.py before .apply_async())
|
| 38 |
+
task_routes={
|
| 39 |
+
"analyze_repository_task": {"queue": HIGH_QUEUE}, # default; overridden at call site
|
| 40 |
+
},
|
| 41 |
+
|
| 42 |
+
# Serialisation
|
| 43 |
task_serializer="json",
|
|
|
|
| 44 |
result_serializer="json",
|
| 45 |
+
accept_content=["json"],
|
| 46 |
+
|
| 47 |
+
# Results expire after 24 h — matches the cache TTL
|
| 48 |
+
result_expires=86400,
|
| 49 |
+
|
| 50 |
+
# Eagerness (test mode)
|
| 51 |
task_always_eager=settings.celery_task_always_eager,
|
| 52 |
+
|
| 53 |
+
# Acknowledge only after the task completes, not on receipt.
|
| 54 |
+
# Prevents a crashed worker from silently dropping a job.
|
| 55 |
+
task_acks_late=True,
|
| 56 |
+
task_reject_on_worker_lost=True,
|
| 57 |
+
|
| 58 |
+
# Worker settings
|
| 59 |
+
worker_prefetch_multiplier=1, # one task at a time per worker slot
|
| 60 |
)
|
app/worker/tasks.py
CHANGED
|
@@ -2,23 +2,54 @@ import asyncio
|
|
| 2 |
import threading
|
| 3 |
from typing import Any, cast
|
| 4 |
|
|
|
|
| 5 |
from app.core.logger import get_logger
|
| 6 |
from app.models.report import EngineeringReport
|
| 7 |
from app.services.analysis_service import run_full_analysis
|
| 8 |
-
from app.worker.celery_app import celery_app
|
| 9 |
|
| 10 |
logger = get_logger(__name__)
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
def _run_coro_sync(coro: Any) -> Any:
|
| 14 |
"""
|
| 15 |
-
Run an awaitable to completion from synchronous code.
|
| 16 |
|
| 17 |
-
Uses asyncio.run()
|
| 18 |
-
|
| 19 |
-
Celery's eager-execution mode invoked from within a pytest-asyncio
|
| 20 |
-
test), runs the coroutine in a separate thread with its own loop to
|
| 21 |
-
avoid "asyncio.run() cannot be called from a running event loop".
|
| 22 |
"""
|
| 23 |
try:
|
| 24 |
asyncio.get_running_loop()
|
|
@@ -38,12 +69,16 @@ def _run_coro_sync(coro: Any) -> Any:
|
|
| 38 |
|
| 39 |
@celery_app.task(name="analyze_repository_task", bind=True, max_retries=2)
|
| 40 |
def analyze_repository_task(
|
| 41 |
-
self: Any,
|
|
|
|
|
|
|
| 42 |
) -> dict[str, Any]:
|
| 43 |
"""
|
| 44 |
Celery task wrapper around the async analysis pipeline.
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
| 47 |
"""
|
| 48 |
try:
|
| 49 |
report = cast(
|
|
@@ -53,6 +88,8 @@ def analyze_repository_task(
|
|
| 53 |
return cast(dict[str, Any], report.model_dump())
|
| 54 |
except Exception as exc:
|
| 55 |
logger.exception(
|
| 56 |
-
"Analysis task failed",
|
|
|
|
| 57 |
)
|
| 58 |
-
|
|
|
|
|
|
| 2 |
import threading
|
| 3 |
from typing import Any, cast
|
| 4 |
|
| 5 |
+
from app.core.config import get_settings
|
| 6 |
from app.core.logger import get_logger
|
| 7 |
from app.models.report import EngineeringReport
|
| 8 |
from app.services.analysis_service import run_full_analysis
|
| 9 |
+
from app.worker.celery_app import celery_app, HIGH_QUEUE, LOW_QUEUE
|
| 10 |
|
| 11 |
logger = get_logger(__name__)
|
| 12 |
|
| 13 |
+
# Repos larger than this threshold are routed to the low-priority queue
|
| 14 |
+
# so they do not starve small, fast jobs.
|
| 15 |
+
LARGE_REPO_MB_THRESHOLD = 50
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def route_queue_for_repo(estimated_size_mb: float | None) -> str:
|
| 19 |
+
"""Return the appropriate queue name based on estimated repo size."""
|
| 20 |
+
if estimated_size_mb is not None and estimated_size_mb >= LARGE_REPO_MB_THRESHOLD:
|
| 21 |
+
return LOW_QUEUE
|
| 22 |
+
return HIGH_QUEUE
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def submit_analysis_task(
|
| 26 |
+
github_url: str,
|
| 27 |
+
base_sha: str | None = None,
|
| 28 |
+
estimated_size_mb: float | None = None,
|
| 29 |
+
) -> Any:
|
| 30 |
+
"""
|
| 31 |
+
Submit an analysis task to the correct priority queue.
|
| 32 |
+
|
| 33 |
+
Call this instead of calling .delay() or .apply_async() directly so
|
| 34 |
+
that queue routing logic stays in one place.
|
| 35 |
+
"""
|
| 36 |
+
queue = route_queue_for_repo(estimated_size_mb)
|
| 37 |
+
logger.info(
|
| 38 |
+
"Submitting analysis task",
|
| 39 |
+
extra={"url": github_url, "queue": queue, "size_mb": estimated_size_mb},
|
| 40 |
+
)
|
| 41 |
+
return analyze_repository_task.apply_async(
|
| 42 |
+
args=[github_url, base_sha],
|
| 43 |
+
queue=queue,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
|
| 47 |
def _run_coro_sync(coro: Any) -> Any:
|
| 48 |
"""
|
| 49 |
+
Run an awaitable to completion from synchronous Celery worker code.
|
| 50 |
|
| 51 |
+
Uses asyncio.run() when no event loop is running (normal prefork worker).
|
| 52 |
+
Falls back to a thread when a loop is already running (eager test mode).
|
|
|
|
|
|
|
|
|
|
| 53 |
"""
|
| 54 |
try:
|
| 55 |
asyncio.get_running_loop()
|
|
|
|
| 69 |
|
| 70 |
@celery_app.task(name="analyze_repository_task", bind=True, max_retries=2)
|
| 71 |
def analyze_repository_task(
|
| 72 |
+
self: Any,
|
| 73 |
+
github_url: str,
|
| 74 |
+
base_sha: str | None = None,
|
| 75 |
) -> dict[str, Any]:
|
| 76 |
"""
|
| 77 |
Celery task wrapper around the async analysis pipeline.
|
| 78 |
+
|
| 79 |
+
Runs the full multi-agent pipeline and returns a JSON-serialisable
|
| 80 |
+
EngineeringReport dict. Retries up to 2 times on unexpected failure
|
| 81 |
+
with exponential backoff.
|
| 82 |
"""
|
| 83 |
try:
|
| 84 |
report = cast(
|
|
|
|
| 88 |
return cast(dict[str, Any], report.model_dump())
|
| 89 |
except Exception as exc:
|
| 90 |
logger.exception(
|
| 91 |
+
"Analysis task failed",
|
| 92 |
+
extra={"url": github_url, "queue": self.request.delivery_info.get("routing_key"), "error": str(exc)},
|
| 93 |
)
|
| 94 |
+
# Exponential backoff: 60s, 120s
|
| 95 |
+
raise self.retry(exc=exc, countdown=60 * (self.request.retries + 1))
|
docker-compose.yml
CHANGED
|
@@ -41,7 +41,7 @@ services:
|
|
| 41 |
worker:
|
| 42 |
build: .
|
| 43 |
entrypoint: ["celery"]
|
| 44 |
-
command: ["-A", "app.worker.celery_app", "worker", "--loglevel=info", "--concurrency=2"]
|
| 45 |
environment:
|
| 46 |
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
| 47 |
- API_KEY=${API_KEY}
|
|
|
|
| 41 |
worker:
|
| 42 |
build: .
|
| 43 |
entrypoint: ["celery"]
|
| 44 |
+
command: ["-A", "app.worker.celery_app", "worker", "--loglevel=info", "--concurrency=2", "-Q", "high,low"]
|
| 45 |
environment:
|
| 46 |
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
| 47 |
- API_KEY=${API_KEY}
|
docs/architecture.md
CHANGED
|
@@ -56,4 +56,20 @@ Default model: `meta-llama/llama-3.3-70b-instruct`
|
|
| 56 |
Alternative models:
|
| 57 |
- `mistralai/mistral-7b-instruct`
|
| 58 |
- `deepseek/deepseek-chat`
|
| 59 |
-
- `google/gemma-3-27b-it`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
Alternative models:
|
| 57 |
- `mistralai/mistral-7b-instruct`
|
| 58 |
- `deepseek/deepseek-chat`
|
| 59 |
+
- `google/gemma-3-27b-it`
|
| 60 |
+
|
| 61 |
+
## Task queue design
|
| 62 |
+
|
| 63 |
+
Jobs are routed to one of two Celery queues backed by Redis:
|
| 64 |
+
|
| 65 |
+
| Queue | Threshold | Expected duration |
|
| 66 |
+
|---|---|---|
|
| 67 |
+
| `high` | repos < 50 MB | < 60 s |
|
| 68 |
+
| `low` | repos ≥ 50 MB | 1–5 min |
|
| 69 |
+
|
| 70 |
+
Workers consume `high` before `low`, so a flood of large-repo jobs cannot
|
| 71 |
+
starve small fast ones. Both queues are durable — a Redis restart does not
|
| 72 |
+
lose in-flight tasks because `task_acks_late=True` means a task is only
|
| 73 |
+
acknowledged after it completes, not on receipt.
|
| 74 |
+
|
| 75 |
+
Retry policy: up to 2 retries with 60 s / 120 s exponential backoff.
|