Skip to content

Commit cf6a5f4

Browse files
vvatelotcursoragent
andcommitted
feat(api): migrate from Celery to RQ and expose queue position
Replace Celery with RQ to allow reporting pending task position and in-progress count in task status responses, resolving #107. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 582068e commit cf6a5f4

18 files changed

Lines changed: 740 additions & 374 deletions

File tree

bases/ecoindex/backend/routers/tasks.py

Lines changed: 73 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
from json import loads
21
from typing import Annotated
32
from urllib.parse import urlparse, urlunparse
43

54
import idna
65
import requests
7-
from celery.result import AsyncResult
86
from ecoindex.backend.dependencies.validation import validate_api_key_batch
97
from ecoindex.backend.models.dependencies_parameters.id import IdParameter
108
from ecoindex.backend.utils import check_quota
@@ -17,10 +15,22 @@
1715
example_daily_limit_response,
1816
example_host_unreachable,
1917
)
20-
from ecoindex.models.tasks import QueueTaskApi, QueueTaskApiBatch, QueueTaskResult
18+
from ecoindex.models.tasks import QueueTaskApi, QueueTaskApiBatch
2119
from ecoindex.scraper.scrap import EcoindexScraper
2220
from ecoindex.worker.tasks import ecoindex_batch_import_task, ecoindex_task
23-
from ecoindex.worker_component import app as task_app
21+
from ecoindex.worker_component import (
22+
NoSuchJobError,
23+
cancel_job,
24+
ecoindex_batch_queue,
25+
ecoindex_queue,
26+
fetch_job,
27+
get_queue_for_job,
28+
get_queue_position,
29+
get_retry_policy,
30+
get_tasks_in_progress,
31+
map_job_status_to_task_status,
32+
parse_job_result,
33+
)
2434
from fastapi import APIRouter, Depends, HTTPException, Response, status
2535
from fastapi.params import Body
2636
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -70,6 +80,18 @@ def convert_url_to_punycode(url: str) -> str:
7080
return url
7181

7282

83+
def _enqueue_settings(*, with_retry: bool = True) -> dict:
84+
settings = Settings()
85+
enqueue_settings = {
86+
"result_ttl": settings.RQ_RESULT_TTL,
87+
"failure_ttl": settings.RQ_FAILURE_TTL,
88+
"job_timeout": settings.RQ_JOB_TIMEOUT,
89+
}
90+
if with_retry:
91+
enqueue_settings["retry"] = get_retry_policy()
92+
return enqueue_settings
93+
94+
7395
@router.post(
7496
name="Add new ecoindex analysis task to the waiting queue",
7597
path="/",
@@ -142,14 +164,16 @@ async def add_ecoindex_analysis_task(
142164
detail=f"The URL {web_page.url} is unreachable. Are you really sure of this url? 🤔 ({e.response.status_code if e.response else ''})",
143165
)
144166

145-
task_result = ecoindex_task.delay( # type: ignore
167+
job = ecoindex_queue.enqueue(
168+
ecoindex_task,
146169
url=str(web_page.url),
147170
width=web_page.width,
148171
height=web_page.height,
149172
custom_headers=headers,
173+
**_enqueue_settings(),
150174
)
151175

152-
return task_result.id
176+
return job.id
153177

154178

155179
@router.get(
@@ -166,23 +190,35 @@ async def get_ecoindex_analysis_task_by_id(
166190
response: Response,
167191
id: IdParameter,
168192
) -> QueueTaskApi:
169-
t = AsyncResult(id=str(id), app=task_app)
193+
try:
194+
job = fetch_job(str(id))
195+
except NoSuchJobError as exc:
196+
raise HTTPException(
197+
status_code=status.HTTP_404_NOT_FOUND,
198+
detail="Task not found",
199+
) from exc
200+
201+
queue = get_queue_for_job(job)
202+
task_status = map_job_status_to_task_status(job)
203+
result = parse_job_result(job)
170204

171205
task_response = QueueTaskApi(
172-
id=str(t.id),
173-
status=t.state,
206+
id=job.id,
207+
status=task_status,
208+
queue_position=get_queue_position(job, queue),
209+
tasks_in_progress=get_tasks_in_progress(queue),
174210
)
175211

176-
if t.state == TaskStatus.PENDING:
212+
if task_status == TaskStatus.PENDING:
177213
response.status_code = status.HTTP_425_TOO_EARLY
178214

179215
return task_response
180216

181-
if t.state == TaskStatus.SUCCESS:
182-
task_response.ecoindex_result = QueueTaskResult(**loads(t.result))
217+
if task_status == TaskStatus.SUCCESS:
218+
task_response.ecoindex_result = result
183219

184-
if t.state == TaskStatus.FAILURE:
185-
task_response.task_error = t.info
220+
if task_status == TaskStatus.FAILURE:
221+
task_response.task_error = result
186222

187223
response.status_code = status.HTTP_200_OK
188224

@@ -198,9 +234,13 @@ async def get_ecoindex_analysis_task_by_id(
198234
async def delete_ecoindex_analysis_task_by_id(
199235
id: IdParameter,
200236
) -> None:
201-
res = task_app.control.revoke(id, terminate=True, signal="SIGKILL")
202-
203-
return res
237+
try:
238+
cancel_job(str(id))
239+
except NoSuchJobError as exc:
240+
raise HTTPException(
241+
status_code=status.HTTP_404_NOT_FOUND,
242+
detail="Task not found",
243+
) from exc
204244

205245

206246
@router.post(
@@ -227,12 +267,14 @@ async def add_ecoindex_analysis_task_batch(
227267
],
228268
batch_key: str = Depends(validate_api_key_batch),
229269
):
230-
task_result = ecoindex_batch_import_task.delay( # type: ignore
270+
job = ecoindex_batch_queue.enqueue(
271+
ecoindex_batch_import_task,
231272
results=[result.model_dump() for result in results],
232273
source=batch_key["source"], # type: ignore
274+
**_enqueue_settings(with_retry=False),
233275
)
234276

235-
return task_result.id
277+
return job.id
236278

237279

238280
@router.get(
@@ -250,14 +292,22 @@ async def get_ecoindex_analysis_batch_task_by_id(
250292
id: IdParameter,
251293
_: str = Depends(validate_api_key_batch),
252294
) -> QueueTaskApiBatch:
253-
t = AsyncResult(id=str(id), app=task_app)
295+
try:
296+
job = fetch_job(str(id))
297+
except NoSuchJobError as exc:
298+
raise HTTPException(
299+
status_code=status.HTTP_404_NOT_FOUND,
300+
detail="Task not found",
301+
) from exc
302+
303+
task_status = map_job_status_to_task_status(job)
254304

255305
task_response = QueueTaskApiBatch(
256-
id=str(t.id),
257-
status=t.state,
306+
id=job.id,
307+
status=task_status,
258308
)
259309

260-
if t.state == TaskStatus.PENDING:
310+
if task_status == TaskStatus.PENDING:
261311
response.status_code = status.HTTP_425_TOO_EARLY
262312

263313
return task_response

bases/ecoindex/worker/health.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
from ecoindex.models.api import HealthWorker, HealthWorkers
2-
from ecoindex.worker.tasks import app
2+
from ecoindex.worker_component import redis_connection
3+
from redis.exceptions import ConnectionError as RedisConnectionError
4+
from rq.worker import Worker
35

46

57
def is_worker_healthy() -> HealthWorkers:
6-
workers = []
7-
workers_ping = app.control.ping()
8-
9-
for worker in workers_ping:
10-
for name in worker:
8+
try:
9+
workers = []
10+
for worker in Worker.all(connection=redis_connection):
1111
workers.append(
12-
HealthWorker(name=name, healthy=True if "ok" in worker[name] else False)
12+
HealthWorker(
13+
name=worker.name,
14+
healthy=worker.state in ("busy", "idle"),
15+
)
1316
)
17+
except RedisConnectionError:
18+
return HealthWorkers(healthy=False, workers=[])
1419

1520
return HealthWorkers(
16-
healthy=False if False in [w.healthy for w in workers] or not workers else True,
21+
healthy=bool(workers) and all(w.healthy for w in workers),
1722
workers=workers,
1823
)

bases/ecoindex/worker/tasks.py

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from asyncio import run
22
from os import getcwd
33
from urllib.parse import urlparse
4+
from uuid import UUID
45

56
from ecoindex.backend.utils import check_quota, format_exception_response
67
from ecoindex.config.settings import Settings
@@ -19,38 +20,39 @@
1920
from ecoindex.models.enums import TaskStatus
2021
from ecoindex.models.tasks import QueueTaskError, QueueTaskResult
2122
from ecoindex.scraper.scrap import EcoindexScraper
22-
from ecoindex.worker_component import app
2323
from playwright._impl._errors import Error as WebDriverException
24+
from rq import get_current_job
2425
from sentry_sdk import init as sentry_init
2526

2627
if Settings().GLITCHTIP_DSN:
2728
sentry_init(Settings().GLITCHTIP_DSN)
2829

2930

30-
@app.task(
31-
name="ecoindex.analysis",
32-
bind=True,
33-
autoretry_for=(Exception,),
34-
retry_backoff=5,
35-
retry_kwargs={"max_retries": 5},
36-
timezone=Settings().TZ,
37-
queue="ecoindex",
38-
dont_autoretry_for=[EcoindexScraperStatusException, TypeError],
39-
)
31+
def _get_task_id() -> UUID:
32+
job = get_current_job()
33+
if job is None:
34+
raise RuntimeError("No RQ job context available")
35+
return UUID(job.id)
36+
37+
4038
def ecoindex_task(
41-
self, url: str, width: int, height: int, custom_headers: dict[str, str]
39+
url: str, width: int, height: int, custom_headers: dict[str, str]
4240
) -> str:
4341
queue_task_result = run(
4442
async_ecoindex_task(
45-
self, url=url, width=width, height=height, custom_headers=custom_headers
43+
task_id=_get_task_id(),
44+
url=url,
45+
width=width,
46+
height=height,
47+
custom_headers=custom_headers,
4648
)
4749
)
4850

4951
return queue_task_result.model_dump_json()
5052

5153

5254
async def async_ecoindex_task(
53-
self,
55+
task_id: UUID,
5456
url: str,
5557
width: int,
5658
height: int,
@@ -68,7 +70,7 @@ async def async_ecoindex_task(
6870
wait_after_scroll=Settings().WAIT_AFTER_SCROLL,
6971
wait_before_scroll=Settings().WAIT_BEFORE_SCROLL,
7072
screenshot=ScreenShot(
71-
id=str(self.request.id), folder=f"{getcwd()}/screenshots/v1"
73+
id=str(task_id), folder=f"{getcwd()}/screenshots/v1"
7274
)
7375
if Settings().ENABLE_SCREENSHOT
7476
else None,
@@ -79,7 +81,7 @@ async def async_ecoindex_task(
7981

8082
db_result = await save_ecoindex_result_db(
8183
session=session,
82-
id=self.request.id,
84+
id=task_id,
8385
ecoindex_result=ecoindex,
8486
)
8587

@@ -164,13 +166,7 @@ async def async_ecoindex_task(
164166
)
165167

166168

167-
@app.task(
168-
name="ecoindex.batch_import",
169-
timezone=Settings().TZ,
170-
queue="ecoindex_batch",
171-
bind=True,
172-
)
173-
def ecoindex_batch_import_task(self, results: list[dict], source: str):
169+
def ecoindex_batch_import_task(results: list[dict], source: str) -> str:
174170
queue_task_result = run(
175171
async_ecoindex_batch_import_task(
176172
results=[ApiEcoindex.model_validate(result) for result in results],

components/ecoindex/config/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ class Settings(BaseSettings):
2020
FRONTEND_BASE_URL: str = "https://www.ecoindex.fr"
2121
GLITCHTIP_DSN: str = ""
2222
REDIS_CACHE_HOST: str = "localhost"
23+
RQ_FAILURE_TTL: int = 86400
24+
RQ_JOB_TIMEOUT: int = 600
25+
RQ_RESULT_TTL: int = 86400
26+
RQ_WORKERS: int = 3
2327
SCREENSHOTS_GID: int | None = None
2428
SCREENSHOTS_UID: int | None = None
2529
TZ: str = "Europe/Paris"

components/ecoindex/models/tasks.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ class QueueTaskApi(BaseModel):
4646
default=...,
4747
title="Status of the current task. Can be PENDING, FAILURE, SUCCESS",
4848
)
49+
queue_position: int | None = Field(
50+
default=None,
51+
title="Position of the task in the waiting queue (0 = next to run)",
52+
description="Null when the task is running or already completed.",
53+
)
54+
tasks_in_progress: int = Field(
55+
default=0,
56+
title="Number of tasks currently being processed on this queue",
57+
)
4958
ecoindex_result: QueueTaskResult | None = Field(
5059
default=None, title="Result of the Ecoindex analysis"
5160
)

0 commit comments

Comments
 (0)