Padmanav commited on
Commit
ba4292e
·
1 Parent(s): ae4efe3

feat(worker): add named Celery queues (high/low priority) with task routing by repo size

Browse files
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
- "ai_code_review_agent",
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
- task_track_started=True,
18
- task_time_limit=600,
19
- task_soft_time_limit=540,
20
- result_expires=3600,
 
 
21
  task_always_eager=settings.celery_task_always_eager,
22
- task_store_eager_result=settings.celery_task_always_eager,
 
 
 
 
 
 
 
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() directly when no event loop is running (the normal
18
- case in a Celery prefork worker). If a loop is already running (e.g.
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, github_url: str, base_sha: str | None = None
 
 
42
  ) -> dict[str, Any]:
43
  """
44
  Celery task wrapper around the async analysis pipeline.
45
- Runs the full multi-agent pipeline and returns a JSON-serializable
46
- EngineeringReport.
 
 
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", extra={"url": github_url, "error": str(exc)}
 
57
  )
58
- raise
 
 
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.