celery-fastapi 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- celery_fastapi/__init__.py +27 -0
- celery_fastapi/app.py +154 -0
- celery_fastapi/cli.py +787 -0
- celery_fastapi/core.py +759 -0
- celery_fastapi/py.typed +1 -0
- celery_fastapi/server.py +98 -0
- celery_fastapi-0.1.0.dist-info/METADATA +410 -0
- celery_fastapi-0.1.0.dist-info/RECORD +11 -0
- celery_fastapi-0.1.0.dist-info/WHEEL +4 -0
- celery_fastapi-0.1.0.dist-info/entry_points.txt +3 -0
- celery_fastapi-0.1.0.dist-info/licenses/LICENSE +21 -0
celery_fastapi/core.py
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
"""Core functionality for Celery FastAPI."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Callable, get_type_hints
|
|
6
|
+
|
|
7
|
+
from celery import Celery
|
|
8
|
+
from celery.result import AsyncResult
|
|
9
|
+
from fastapi import FastAPI, HTTPException, Query
|
|
10
|
+
from pydantic import BaseModel, Field, create_model
|
|
11
|
+
|
|
12
|
+
# Celery execution options - shared fields for all task payloads
|
|
13
|
+
CELERY_OPTIONS_FIELDS: dict[str, Any] = {
|
|
14
|
+
"countdown": (
|
|
15
|
+
float | None,
|
|
16
|
+
Field(default=None, description="Seconds to wait before executing"),
|
|
17
|
+
),
|
|
18
|
+
"eta": (
|
|
19
|
+
datetime | None,
|
|
20
|
+
Field(default=None, description="Datetime when task should execute (UTC)"),
|
|
21
|
+
),
|
|
22
|
+
"expires": (
|
|
23
|
+
float | datetime | None,
|
|
24
|
+
Field(default=None, description="Task expiration time"),
|
|
25
|
+
),
|
|
26
|
+
"retry": (
|
|
27
|
+
bool | None,
|
|
28
|
+
Field(default=None, description="Retry on failure"),
|
|
29
|
+
),
|
|
30
|
+
"retry_policy": (
|
|
31
|
+
dict[str, Any] | None,
|
|
32
|
+
Field(default=None, description="Retry policy options"),
|
|
33
|
+
),
|
|
34
|
+
"queue": (
|
|
35
|
+
str | None,
|
|
36
|
+
Field(default=None, description="Override the default queue"),
|
|
37
|
+
),
|
|
38
|
+
"exchange": (
|
|
39
|
+
str | None,
|
|
40
|
+
Field(default=None, description="Override the default exchange"),
|
|
41
|
+
),
|
|
42
|
+
"routing_key": (
|
|
43
|
+
str | None,
|
|
44
|
+
Field(default=None, description="Override the default routing key"),
|
|
45
|
+
),
|
|
46
|
+
"priority": (
|
|
47
|
+
int | None,
|
|
48
|
+
Field(default=None, ge=0, le=9, description="Task priority (0-9)"),
|
|
49
|
+
),
|
|
50
|
+
"serializer": (
|
|
51
|
+
str | None,
|
|
52
|
+
Field(default=None, description="Serializer (json, pickle, yaml, msgpack)"),
|
|
53
|
+
),
|
|
54
|
+
"compression": (
|
|
55
|
+
str | None,
|
|
56
|
+
Field(default=None, description="Compression method"),
|
|
57
|
+
),
|
|
58
|
+
"headers": (
|
|
59
|
+
dict[str, Any] | None,
|
|
60
|
+
Field(default=None, description="Custom message headers"),
|
|
61
|
+
),
|
|
62
|
+
"link": (
|
|
63
|
+
list[str] | None,
|
|
64
|
+
Field(default=None, description="Tasks to call on success"),
|
|
65
|
+
),
|
|
66
|
+
"link_error": (
|
|
67
|
+
list[str] | None,
|
|
68
|
+
Field(default=None, description="Tasks to call on error"),
|
|
69
|
+
),
|
|
70
|
+
"task_id": (
|
|
71
|
+
str | None,
|
|
72
|
+
Field(default=None, description="Custom task ID"),
|
|
73
|
+
),
|
|
74
|
+
"shadow": (
|
|
75
|
+
str | None,
|
|
76
|
+
Field(default=None, description="Override task name in logs"),
|
|
77
|
+
),
|
|
78
|
+
"ignore_result": (
|
|
79
|
+
bool | None,
|
|
80
|
+
Field(default=None, description="Don't store the result"),
|
|
81
|
+
),
|
|
82
|
+
"time_limit": (
|
|
83
|
+
float | None,
|
|
84
|
+
Field(default=None, description="Hard time limit (seconds)"),
|
|
85
|
+
),
|
|
86
|
+
"soft_time_limit": (
|
|
87
|
+
float | None,
|
|
88
|
+
Field(default=None, description="Soft time limit (seconds)"),
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class GenericTaskPayload(BaseModel):
|
|
94
|
+
"""Generic payload for triggering any task by name."""
|
|
95
|
+
|
|
96
|
+
task_name: str = Field(description="Full task name (e.g., 'myapp.tasks.add')")
|
|
97
|
+
queue: str = Field(description="Queue to send the task to (required)")
|
|
98
|
+
args: list[Any] = Field(
|
|
99
|
+
default_factory=list, description="Positional arguments for the task"
|
|
100
|
+
)
|
|
101
|
+
kwargs: dict[str, Any] = Field(
|
|
102
|
+
default_factory=dict, description="Keyword arguments for the task"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Celery execution options
|
|
106
|
+
countdown: float | None = Field(default=None, description="Seconds to wait")
|
|
107
|
+
eta: datetime | None = Field(default=None, description="Execute at datetime (UTC)")
|
|
108
|
+
expires: float | datetime | None = Field(default=None, description="Expiration")
|
|
109
|
+
retry: bool | None = Field(default=None, description="Retry on failure")
|
|
110
|
+
retry_policy: dict[str, Any] | None = Field(
|
|
111
|
+
default=None, description="Retry policy"
|
|
112
|
+
)
|
|
113
|
+
exchange: str | None = Field(default=None, description="Override exchange")
|
|
114
|
+
routing_key: str | None = Field(default=None, description="Override routing key")
|
|
115
|
+
priority: int | None = Field(default=None, ge=0, le=9, description="Priority (0-9)")
|
|
116
|
+
serializer: str | None = Field(default=None, description="Serializer")
|
|
117
|
+
compression: str | None = Field(default=None, description="Compression")
|
|
118
|
+
headers: dict[str, Any] | None = Field(default=None, description="Custom headers")
|
|
119
|
+
task_id: str | None = Field(default=None, description="Custom task ID")
|
|
120
|
+
ignore_result: bool | None = Field(default=None, description="Don't store result")
|
|
121
|
+
time_limit: float | None = Field(default=None, description="Hard time limit")
|
|
122
|
+
soft_time_limit: float | None = Field(default=None, description="Soft time limit")
|
|
123
|
+
|
|
124
|
+
model_config = {
|
|
125
|
+
"json_schema_extra": {
|
|
126
|
+
"examples": [
|
|
127
|
+
{
|
|
128
|
+
"task_name": "myapp.tasks.add",
|
|
129
|
+
"queue": "celery",
|
|
130
|
+
"args": [1, 2],
|
|
131
|
+
"kwargs": {},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"task_name": "myapp.tasks.process",
|
|
135
|
+
"queue": "high_priority",
|
|
136
|
+
"args": [],
|
|
137
|
+
"kwargs": {"data": "test"},
|
|
138
|
+
"countdown": 60,
|
|
139
|
+
},
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _python_type_to_json_type(py_type: type) -> str:
|
|
146
|
+
"""Convert Python type to JSON schema type string."""
|
|
147
|
+
if py_type in (int,):
|
|
148
|
+
return "integer"
|
|
149
|
+
elif py_type in (float,):
|
|
150
|
+
return "number"
|
|
151
|
+
elif py_type in (bool,):
|
|
152
|
+
return "boolean"
|
|
153
|
+
elif py_type in (str,):
|
|
154
|
+
return "string"
|
|
155
|
+
elif py_type in (list, tuple):
|
|
156
|
+
return "array"
|
|
157
|
+
elif py_type in (dict,):
|
|
158
|
+
return "object"
|
|
159
|
+
return "string"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _create_task_payload_model(
|
|
163
|
+
task_name: str,
|
|
164
|
+
task_func: Callable[..., Any],
|
|
165
|
+
default_queue: str,
|
|
166
|
+
) -> type[BaseModel]:
|
|
167
|
+
"""
|
|
168
|
+
Create a dynamic Pydantic model for a specific task based on its signature.
|
|
169
|
+
|
|
170
|
+
This generates a model with the actual parameter names and types from the task.
|
|
171
|
+
"""
|
|
172
|
+
# Get function signature and type hints
|
|
173
|
+
sig = inspect.signature(task_func)
|
|
174
|
+
try:
|
|
175
|
+
type_hints = get_type_hints(task_func)
|
|
176
|
+
except Exception: # noqa: BLE001
|
|
177
|
+
type_hints = {}
|
|
178
|
+
|
|
179
|
+
# Build field definitions for task arguments
|
|
180
|
+
field_definitions: dict[str, Any] = {}
|
|
181
|
+
example_kwargs: dict[str, Any] = {}
|
|
182
|
+
|
|
183
|
+
for param_name, param in sig.parameters.items():
|
|
184
|
+
if param_name in ("self", "cls"):
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
# Get type annotation
|
|
188
|
+
param_type = type_hints.get(param_name, Any)
|
|
189
|
+
|
|
190
|
+
# Handle Optional types and defaults
|
|
191
|
+
has_default = param.default is not inspect.Parameter.empty
|
|
192
|
+
default_value = param.default if has_default else ...
|
|
193
|
+
|
|
194
|
+
# Create example value based on type
|
|
195
|
+
if param_type is int:
|
|
196
|
+
example_kwargs[param_name] = 1 if not has_default else default_value
|
|
197
|
+
elif param_type is float:
|
|
198
|
+
example_kwargs[param_name] = 1.0 if not has_default else default_value
|
|
199
|
+
elif param_type is str:
|
|
200
|
+
example_kwargs[param_name] = "example" if not has_default else default_value
|
|
201
|
+
elif param_type is bool:
|
|
202
|
+
example_kwargs[param_name] = True if not has_default else default_value
|
|
203
|
+
elif has_default:
|
|
204
|
+
example_kwargs[param_name] = default_value
|
|
205
|
+
|
|
206
|
+
# Make field optional if it has a default
|
|
207
|
+
if has_default:
|
|
208
|
+
field_definitions[param_name] = (
|
|
209
|
+
param_type | None,
|
|
210
|
+
Field(default=default_value, description=f"Parameter: {param_name}"),
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
field_definitions[param_name] = (
|
|
214
|
+
param_type,
|
|
215
|
+
Field(description=f"Parameter: {param_name} (required)"),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Add Celery options fields
|
|
219
|
+
field_definitions.update(CELERY_OPTIONS_FIELDS)
|
|
220
|
+
|
|
221
|
+
# Create the model class name
|
|
222
|
+
model_name = f"{task_name.replace('.', '_').title().replace('_', '')}Payload"
|
|
223
|
+
|
|
224
|
+
# Create and return the dynamic model
|
|
225
|
+
model: type[BaseModel] = create_model(model_name, **field_definitions) # type: ignore[call-overload]
|
|
226
|
+
model.__doc__ = f"Payload for {task_name} task. Default queue: {default_queue}"
|
|
227
|
+
|
|
228
|
+
# Set model config for examples
|
|
229
|
+
if example_kwargs:
|
|
230
|
+
model.model_config = {"json_schema_extra": {"examples": [example_kwargs]}}
|
|
231
|
+
|
|
232
|
+
return model
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TaskResponse(BaseModel):
|
|
236
|
+
"""Response model for task submission."""
|
|
237
|
+
|
|
238
|
+
task_id: str = Field(description="Unique identifier for the submitted task")
|
|
239
|
+
status: str = Field(default="PENDING", description="Initial task status")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class TaskStatusResponse(BaseModel):
|
|
243
|
+
"""Response model for task status."""
|
|
244
|
+
|
|
245
|
+
task_id: str
|
|
246
|
+
state: str
|
|
247
|
+
result: Any | None = None
|
|
248
|
+
traceback: str | None = None
|
|
249
|
+
date_done: datetime | None = None
|
|
250
|
+
info: dict[str, Any] | None = None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class TaskListResponse(BaseModel):
|
|
254
|
+
"""Response model for listing all tasks."""
|
|
255
|
+
|
|
256
|
+
active: dict[str, list[dict[str, Any]]]
|
|
257
|
+
scheduled: dict[str, list[dict[str, Any]]]
|
|
258
|
+
reserved: dict[str, list[dict[str, Any]]]
|
|
259
|
+
revoked: dict[str, list[str]]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class TaskRevokePayload(BaseModel):
|
|
263
|
+
"""Payload for revoking tasks."""
|
|
264
|
+
|
|
265
|
+
terminate: bool = Field(
|
|
266
|
+
default=False, description="Terminate the task if currently executing"
|
|
267
|
+
)
|
|
268
|
+
signal: str = Field(
|
|
269
|
+
default="SIGTERM", description="Signal to send (SIGTERM, SIGKILL)"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class CeleryFastAPIBridge:
|
|
274
|
+
"""
|
|
275
|
+
Bridge class that connects Celery tasks to FastAPI endpoints.
|
|
276
|
+
|
|
277
|
+
This class automatically generates REST API endpoints for all registered
|
|
278
|
+
Celery tasks, providing a seamless way to trigger and monitor tasks via HTTP.
|
|
279
|
+
|
|
280
|
+
Important: This bridge only exposes and manages tasks registered in the
|
|
281
|
+
provided Celery app instance, not tasks from the entire cluster.
|
|
282
|
+
|
|
283
|
+
Example:
|
|
284
|
+
```python
|
|
285
|
+
from celery import Celery
|
|
286
|
+
from fastapi import FastAPI
|
|
287
|
+
from celery_fastapi import CeleryFastAPIBridge
|
|
288
|
+
|
|
289
|
+
celery_app = Celery('tasks', broker='redis://localhost:6379/0')
|
|
290
|
+
fastapi_app = FastAPI()
|
|
291
|
+
|
|
292
|
+
bridge = CeleryFastAPIBridge(celery_app, fastapi_app)
|
|
293
|
+
bridge.register_routes()
|
|
294
|
+
```
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
def __init__(
|
|
298
|
+
self,
|
|
299
|
+
celery_app: Celery,
|
|
300
|
+
fastapi_app: FastAPI | None = None,
|
|
301
|
+
*,
|
|
302
|
+
prefix: str = "",
|
|
303
|
+
include_status_endpoints: bool = True,
|
|
304
|
+
task_filter: Callable[[str], bool] | None = None,
|
|
305
|
+
) -> None:
|
|
306
|
+
"""
|
|
307
|
+
Initialize the Celery FastAPI Bridge.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
celery_app: The Celery application instance.
|
|
311
|
+
fastapi_app: Optional FastAPI application instance. If not provided,
|
|
312
|
+
a new one will be created.
|
|
313
|
+
prefix: URL prefix for all generated endpoints.
|
|
314
|
+
include_status_endpoints: Whether to include task status and listing endpoints.
|
|
315
|
+
task_filter: Optional callable to filter which tasks to expose.
|
|
316
|
+
Takes task name, returns True to include, False to exclude.
|
|
317
|
+
"""
|
|
318
|
+
self.celery_app = celery_app
|
|
319
|
+
self.fastapi_app = fastapi_app or FastAPI()
|
|
320
|
+
self.prefix = prefix.rstrip("/")
|
|
321
|
+
self.include_status_endpoints = include_status_endpoints
|
|
322
|
+
self.task_filter = task_filter or (lambda name: not name.startswith("celery."))
|
|
323
|
+
self._registered = False
|
|
324
|
+
|
|
325
|
+
# Store the registered task names from THIS app only
|
|
326
|
+
self._app_task_names: set[str] = set()
|
|
327
|
+
for name in self.celery_app.tasks.keys():
|
|
328
|
+
if self.task_filter(name):
|
|
329
|
+
self._app_task_names.add(name)
|
|
330
|
+
|
|
331
|
+
def register_routes(self) -> FastAPI:
|
|
332
|
+
"""
|
|
333
|
+
Register all Celery task routes on the FastAPI application.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
The FastAPI application with registered routes.
|
|
337
|
+
"""
|
|
338
|
+
if self._registered:
|
|
339
|
+
return self.fastapi_app
|
|
340
|
+
|
|
341
|
+
self._register_task_endpoints()
|
|
342
|
+
|
|
343
|
+
if self.include_status_endpoints:
|
|
344
|
+
self._register_status_endpoints()
|
|
345
|
+
|
|
346
|
+
self._registered = True
|
|
347
|
+
return self.fastapi_app
|
|
348
|
+
|
|
349
|
+
def _register_task_endpoints(self) -> None:
|
|
350
|
+
"""Register POST endpoints for each Celery task."""
|
|
351
|
+
# Get the default queue name from Celery config (defaults to 'celery')
|
|
352
|
+
default_queue = self.celery_app.conf.task_default_queue or "celery"
|
|
353
|
+
|
|
354
|
+
for name, task in self.celery_app.tasks.items():
|
|
355
|
+
if not self.task_filter(name):
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
queue_name = getattr(task, "queue", None) or default_queue
|
|
359
|
+
endpoint = name.replace(".", "/")
|
|
360
|
+
route_path = f"{self.prefix}/{endpoint}"
|
|
361
|
+
|
|
362
|
+
# Create endpoint handler with proper closure
|
|
363
|
+
self._create_task_endpoint(name, queue_name, route_path)
|
|
364
|
+
|
|
365
|
+
def _create_task_endpoint(
|
|
366
|
+
self, task_name: str, queue_name: str, route_path: str
|
|
367
|
+
) -> None:
|
|
368
|
+
"""Create a POST endpoint for a specific task with custom payload model."""
|
|
369
|
+
# Get the task function to inspect its signature
|
|
370
|
+
task = self.celery_app.tasks.get(task_name)
|
|
371
|
+
task_func = getattr(task, "run", None) if task else None
|
|
372
|
+
|
|
373
|
+
# Create a custom payload model for this task
|
|
374
|
+
if task_func:
|
|
375
|
+
PayloadModel = _create_task_payload_model(task_name, task_func, queue_name)
|
|
376
|
+
else:
|
|
377
|
+
# Fallback to generic model if we can't inspect the task
|
|
378
|
+
PayloadModel = GenericTaskPayload
|
|
379
|
+
|
|
380
|
+
# Create the endpoint handler
|
|
381
|
+
async def run_task(
|
|
382
|
+
payload: PayloadModel, # type: ignore[valid-type]
|
|
383
|
+
task_name_override: str | None = Query(
|
|
384
|
+
default=None,
|
|
385
|
+
alias="_task_name",
|
|
386
|
+
description=f"Override task name (default: {task_name})",
|
|
387
|
+
),
|
|
388
|
+
queue_override: str | None = Query(
|
|
389
|
+
default=None,
|
|
390
|
+
alias="_queue",
|
|
391
|
+
description=f"Override queue (default: {queue_name})",
|
|
392
|
+
),
|
|
393
|
+
) -> TaskResponse:
|
|
394
|
+
"""Execute a Celery task asynchronously."""
|
|
395
|
+
# Determine actual task name and queue
|
|
396
|
+
actual_task_name = task_name_override or task_name
|
|
397
|
+
actual_queue = (
|
|
398
|
+
queue_override or getattr(payload, "queue", None) or queue_name
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# Extract task arguments from payload
|
|
402
|
+
# Get all field names that are task parameters (not Celery options)
|
|
403
|
+
celery_option_names = set(CELERY_OPTIONS_FIELDS.keys())
|
|
404
|
+
task_kwargs: dict[str, Any] = {}
|
|
405
|
+
|
|
406
|
+
for field_name in payload.model_fields:
|
|
407
|
+
if field_name not in celery_option_names:
|
|
408
|
+
value = getattr(payload, field_name, None)
|
|
409
|
+
if value is not None:
|
|
410
|
+
task_kwargs[field_name] = value
|
|
411
|
+
|
|
412
|
+
# Build send_task options
|
|
413
|
+
send_options: dict[str, Any] = {
|
|
414
|
+
"args": [],
|
|
415
|
+
"kwargs": task_kwargs,
|
|
416
|
+
"queue": actual_queue,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
# Add Celery options if set
|
|
420
|
+
for opt_name in celery_option_names:
|
|
421
|
+
value = getattr(payload, opt_name, None)
|
|
422
|
+
if value is not None and opt_name != "queue": # queue handled above
|
|
423
|
+
send_options[opt_name] = value
|
|
424
|
+
|
|
425
|
+
result = self.celery_app.send_task(actual_task_name, **send_options)
|
|
426
|
+
return TaskResponse(task_id=result.id, status="PENDING")
|
|
427
|
+
|
|
428
|
+
# Set a descriptive name for the endpoint
|
|
429
|
+
run_task.__name__ = f"run_{task_name.replace('.', '_')}"
|
|
430
|
+
run_task.__doc__ = f"Execute '{task_name}' task. Default queue: '{queue_name}'."
|
|
431
|
+
|
|
432
|
+
self.fastapi_app.post(
|
|
433
|
+
route_path,
|
|
434
|
+
response_model=TaskResponse,
|
|
435
|
+
tags=["tasks"],
|
|
436
|
+
summary=f"Run {task_name}",
|
|
437
|
+
description=f"Submit '{task_name}' task for async execution.\n\nDefault queue: `{queue_name}`\n\nUse `_task_name` and `_queue` query params to override.",
|
|
438
|
+
)(run_task)
|
|
439
|
+
|
|
440
|
+
def _register_status_endpoints(self) -> None:
|
|
441
|
+
"""Register task status, listing, and control endpoints."""
|
|
442
|
+
|
|
443
|
+
@self.fastapi_app.get(
|
|
444
|
+
f"{self.prefix}/tasks/{{task_id}}",
|
|
445
|
+
response_model=TaskStatusResponse,
|
|
446
|
+
tags=["task-status"],
|
|
447
|
+
summary="Get task status",
|
|
448
|
+
)
|
|
449
|
+
async def get_task_status(task_id: str) -> TaskStatusResponse:
|
|
450
|
+
"""
|
|
451
|
+
Get the status of a specific task by its ID.
|
|
452
|
+
|
|
453
|
+
Returns detailed information including state, result, and traceback.
|
|
454
|
+
|
|
455
|
+
Raises:
|
|
456
|
+
HTTPException: 404 if the task is not found.
|
|
457
|
+
"""
|
|
458
|
+
result = AsyncResult(task_id, app=self.celery_app)
|
|
459
|
+
|
|
460
|
+
if result.state == "PENDING":
|
|
461
|
+
raise HTTPException(
|
|
462
|
+
status_code=404, detail=f"Task '{task_id}' not found"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
# Build info dict with additional metadata
|
|
466
|
+
info: dict[str, Any] = {}
|
|
467
|
+
if hasattr(result, "info") and result.info:
|
|
468
|
+
if isinstance(result.info, dict):
|
|
469
|
+
info = result.info
|
|
470
|
+
elif isinstance(result.info, Exception):
|
|
471
|
+
info = {"error": str(result.info)}
|
|
472
|
+
|
|
473
|
+
return TaskStatusResponse(
|
|
474
|
+
task_id=task_id,
|
|
475
|
+
state=result.state,
|
|
476
|
+
result=result.result if result.ready() else None,
|
|
477
|
+
traceback=result.traceback,
|
|
478
|
+
date_done=result.date_done,
|
|
479
|
+
info=info if info else None,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
@self.fastapi_app.delete(
|
|
483
|
+
f"{self.prefix}/tasks/{{task_id}}",
|
|
484
|
+
tags=["task-status"],
|
|
485
|
+
summary="Revoke a task",
|
|
486
|
+
)
|
|
487
|
+
async def revoke_task(
|
|
488
|
+
task_id: str,
|
|
489
|
+
payload: TaskRevokePayload | None = None,
|
|
490
|
+
) -> dict[str, str]:
|
|
491
|
+
"""
|
|
492
|
+
Revoke a pending or running task.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
task_id: The task ID to revoke.
|
|
496
|
+
payload: Optional revoke options (terminate, signal).
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
Confirmation of revocation request.
|
|
500
|
+
"""
|
|
501
|
+
payload = payload or TaskRevokePayload()
|
|
502
|
+
self.celery_app.control.revoke(
|
|
503
|
+
task_id,
|
|
504
|
+
terminate=payload.terminate,
|
|
505
|
+
signal=payload.signal,
|
|
506
|
+
)
|
|
507
|
+
return {"status": "revoked", "task_id": task_id}
|
|
508
|
+
|
|
509
|
+
@self.fastapi_app.get(
|
|
510
|
+
f"{self.prefix}/tasks/{{task_id}}/result",
|
|
511
|
+
tags=["task-status"],
|
|
512
|
+
summary="Get task result",
|
|
513
|
+
)
|
|
514
|
+
async def get_task_result(task_id: str, timeout: float | None = None) -> Any:
|
|
515
|
+
"""
|
|
516
|
+
Get the result of a completed task.
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
task_id: The task ID.
|
|
520
|
+
timeout: Optional timeout in seconds to wait for result.
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
The task result.
|
|
524
|
+
|
|
525
|
+
Raises:
|
|
526
|
+
HTTPException: 404 if not found, 202 if not ready, 500 on failure.
|
|
527
|
+
"""
|
|
528
|
+
result = AsyncResult(task_id, app=self.celery_app)
|
|
529
|
+
|
|
530
|
+
if result.state == "PENDING":
|
|
531
|
+
raise HTTPException(
|
|
532
|
+
status_code=404, detail=f"Task '{task_id}' not found"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
if not result.ready():
|
|
536
|
+
if timeout:
|
|
537
|
+
try:
|
|
538
|
+
return result.get(timeout=timeout)
|
|
539
|
+
except TimeoutError as exc:
|
|
540
|
+
raise HTTPException(
|
|
541
|
+
status_code=202,
|
|
542
|
+
detail=f"Task '{task_id}' not ready within timeout",
|
|
543
|
+
) from exc
|
|
544
|
+
raise HTTPException(
|
|
545
|
+
status_code=202,
|
|
546
|
+
detail=f"Task '{task_id}' is still {result.state}",
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
if result.failed():
|
|
550
|
+
raise HTTPException(
|
|
551
|
+
status_code=500,
|
|
552
|
+
detail=f"Task '{task_id}' failed: {result.traceback}",
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
return result.result
|
|
556
|
+
|
|
557
|
+
@self.fastapi_app.get(
|
|
558
|
+
f"{self.prefix}/tasks",
|
|
559
|
+
response_model=TaskListResponse,
|
|
560
|
+
tags=["task-status"],
|
|
561
|
+
summary="List all tasks",
|
|
562
|
+
)
|
|
563
|
+
async def list_all_tasks() -> TaskListResponse:
|
|
564
|
+
"""
|
|
565
|
+
List active, scheduled, reserved, and revoked tasks for THIS app only.
|
|
566
|
+
|
|
567
|
+
Only shows tasks that are registered in this Celery application,
|
|
568
|
+
filtering out tasks from other apps in the cluster.
|
|
569
|
+
"""
|
|
570
|
+
inspector = self.celery_app.control.inspect()
|
|
571
|
+
|
|
572
|
+
# Helper to filter tasks by this app's registered task names
|
|
573
|
+
def filter_tasks(
|
|
574
|
+
tasks_by_worker: dict[str, list[dict[str, Any]]] | None,
|
|
575
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
576
|
+
if not tasks_by_worker:
|
|
577
|
+
return {}
|
|
578
|
+
filtered: dict[str, list[dict[str, Any]]] = {}
|
|
579
|
+
for worker, task_list in tasks_by_worker.items():
|
|
580
|
+
filtered_list = [
|
|
581
|
+
t for t in task_list if t.get("name") in self._app_task_names
|
|
582
|
+
]
|
|
583
|
+
if filtered_list:
|
|
584
|
+
filtered[worker] = filtered_list
|
|
585
|
+
return filtered
|
|
586
|
+
|
|
587
|
+
return TaskListResponse(
|
|
588
|
+
active=filter_tasks(inspector.active()),
|
|
589
|
+
scheduled=filter_tasks(inspector.scheduled()),
|
|
590
|
+
reserved=filter_tasks(inspector.reserved()),
|
|
591
|
+
revoked=inspector.revoked()
|
|
592
|
+
or {}, # Revoked is just task IDs, can't filter
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
@self.fastapi_app.get(
|
|
596
|
+
f"{self.prefix}/workers",
|
|
597
|
+
tags=["workers"],
|
|
598
|
+
summary="List workers",
|
|
599
|
+
)
|
|
600
|
+
async def list_workers() -> dict[str, Any]:
|
|
601
|
+
"""
|
|
602
|
+
Get information about Celery workers that can execute THIS app's tasks.
|
|
603
|
+
|
|
604
|
+
Filters registered tasks to only show tasks from this application.
|
|
605
|
+
"""
|
|
606
|
+
inspector = self.celery_app.control.inspect()
|
|
607
|
+
|
|
608
|
+
# Filter registered tasks to only show this app's tasks
|
|
609
|
+
registered = inspector.registered() or {}
|
|
610
|
+
filtered_registered: dict[str, list[str]] = {}
|
|
611
|
+
for worker, tasks in registered.items():
|
|
612
|
+
filtered_tasks = [t for t in tasks if t in self._app_task_names]
|
|
613
|
+
if filtered_tasks:
|
|
614
|
+
filtered_registered[worker] = filtered_tasks
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
"ping": inspector.ping() or {},
|
|
618
|
+
"stats": inspector.stats() or {},
|
|
619
|
+
"registered": filtered_registered,
|
|
620
|
+
"active_queues": inspector.active_queues() or {},
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
@self.fastapi_app.get(
|
|
624
|
+
f"{self.prefix}/available-tasks",
|
|
625
|
+
tags=["tasks"],
|
|
626
|
+
summary="List available tasks",
|
|
627
|
+
)
|
|
628
|
+
async def list_available_tasks() -> dict[str, Any]:
|
|
629
|
+
"""
|
|
630
|
+
List all tasks available in THIS Celery application.
|
|
631
|
+
|
|
632
|
+
Returns the task names and their configuration (queue, etc.)
|
|
633
|
+
that are registered in this specific app instance.
|
|
634
|
+
"""
|
|
635
|
+
# Get the default queue name from Celery config
|
|
636
|
+
default_queue = self.celery_app.conf.task_default_queue or "celery"
|
|
637
|
+
|
|
638
|
+
tasks_info: list[dict[str, Any]] = []
|
|
639
|
+
for name in sorted(self._app_task_names):
|
|
640
|
+
task = self.celery_app.tasks.get(name)
|
|
641
|
+
if task:
|
|
642
|
+
tasks_info.append(
|
|
643
|
+
{
|
|
644
|
+
"name": name,
|
|
645
|
+
"queue": getattr(task, "queue", None) or default_queue,
|
|
646
|
+
"rate_limit": getattr(task, "rate_limit", None),
|
|
647
|
+
"time_limit": getattr(task, "time_limit", None),
|
|
648
|
+
"soft_time_limit": getattr(task, "soft_time_limit", None),
|
|
649
|
+
"max_retries": getattr(task, "max_retries", None),
|
|
650
|
+
"default_retry_delay": getattr(
|
|
651
|
+
task, "default_retry_delay", None
|
|
652
|
+
),
|
|
653
|
+
}
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
"app_name": self.celery_app.main,
|
|
658
|
+
"task_count": len(tasks_info),
|
|
659
|
+
"tasks": tasks_info,
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
@self.fastapi_app.get(
|
|
663
|
+
f"{self.prefix}/queues",
|
|
664
|
+
tags=["workers"],
|
|
665
|
+
summary="List active queues",
|
|
666
|
+
)
|
|
667
|
+
async def list_queues() -> dict[str, Any]:
|
|
668
|
+
"""Get information about active queues."""
|
|
669
|
+
inspector = self.celery_app.control.inspect()
|
|
670
|
+
return {"queues": inspector.active_queues() or {}}
|
|
671
|
+
|
|
672
|
+
@self.fastapi_app.post(
|
|
673
|
+
f"{self.prefix}/purge",
|
|
674
|
+
tags=["workers"],
|
|
675
|
+
summary="Purge all tasks",
|
|
676
|
+
)
|
|
677
|
+
async def purge_tasks() -> dict[str, int]:
|
|
678
|
+
"""Purge all pending tasks from the queue."""
|
|
679
|
+
count = self.celery_app.control.purge()
|
|
680
|
+
return {"purged": count or 0}
|
|
681
|
+
|
|
682
|
+
@self.fastapi_app.post(
|
|
683
|
+
f"{self.prefix}/trigger",
|
|
684
|
+
response_model=TaskResponse,
|
|
685
|
+
tags=["tasks"],
|
|
686
|
+
summary="Trigger any task",
|
|
687
|
+
)
|
|
688
|
+
async def trigger_generic_task(payload: GenericTaskPayload) -> TaskResponse:
|
|
689
|
+
"""
|
|
690
|
+
Trigger any Celery task by name.
|
|
691
|
+
|
|
692
|
+
This endpoint allows triggering any task in the cluster,
|
|
693
|
+
not just tasks registered in this application.
|
|
694
|
+
|
|
695
|
+
Useful for:
|
|
696
|
+
- Triggering tasks from other applications
|
|
697
|
+
- Dynamic task invocation
|
|
698
|
+
- Testing and debugging
|
|
699
|
+
|
|
700
|
+
Note: queue is required - you must specify which queue to send the task to.
|
|
701
|
+
"""
|
|
702
|
+
# Build send_task options - queue is required in GenericTaskPayload
|
|
703
|
+
send_options: dict[str, Any] = {
|
|
704
|
+
"args": payload.args,
|
|
705
|
+
"kwargs": payload.kwargs,
|
|
706
|
+
"queue": payload.queue,
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
# Add optional Celery parameters
|
|
710
|
+
if payload.countdown is not None:
|
|
711
|
+
send_options["countdown"] = payload.countdown
|
|
712
|
+
if payload.eta is not None:
|
|
713
|
+
send_options["eta"] = payload.eta
|
|
714
|
+
if payload.expires is not None:
|
|
715
|
+
send_options["expires"] = payload.expires
|
|
716
|
+
if payload.retry is not None:
|
|
717
|
+
send_options["retry"] = payload.retry
|
|
718
|
+
if payload.retry_policy is not None:
|
|
719
|
+
send_options["retry_policy"] = payload.retry_policy
|
|
720
|
+
if payload.exchange is not None:
|
|
721
|
+
send_options["exchange"] = payload.exchange
|
|
722
|
+
if payload.routing_key is not None:
|
|
723
|
+
send_options["routing_key"] = payload.routing_key
|
|
724
|
+
if payload.priority is not None:
|
|
725
|
+
send_options["priority"] = payload.priority
|
|
726
|
+
if payload.serializer is not None:
|
|
727
|
+
send_options["serializer"] = payload.serializer
|
|
728
|
+
if payload.compression is not None:
|
|
729
|
+
send_options["compression"] = payload.compression
|
|
730
|
+
if payload.headers is not None:
|
|
731
|
+
send_options["headers"] = payload.headers
|
|
732
|
+
if payload.task_id is not None:
|
|
733
|
+
send_options["task_id"] = payload.task_id
|
|
734
|
+
if payload.ignore_result is not None:
|
|
735
|
+
send_options["ignore_result"] = payload.ignore_result
|
|
736
|
+
if payload.time_limit is not None:
|
|
737
|
+
send_options["time_limit"] = payload.time_limit
|
|
738
|
+
if payload.soft_time_limit is not None:
|
|
739
|
+
send_options["soft_time_limit"] = payload.soft_time_limit
|
|
740
|
+
|
|
741
|
+
result = self.celery_app.send_task(payload.task_name, **send_options)
|
|
742
|
+
return TaskResponse(task_id=result.id, status="PENDING")
|
|
743
|
+
|
|
744
|
+
def get_registered_routes(self) -> list[dict[str, str]]:
|
|
745
|
+
"""
|
|
746
|
+
Get a list of all registered routes.
|
|
747
|
+
|
|
748
|
+
Returns:
|
|
749
|
+
List of dictionaries containing path and method for each route.
|
|
750
|
+
"""
|
|
751
|
+
routes: list[dict[str, str]] = []
|
|
752
|
+
for route in self.fastapi_app.routes:
|
|
753
|
+
if hasattr(route, "path") and hasattr(route, "methods"):
|
|
754
|
+
path = getattr(route, "path")
|
|
755
|
+
methods = getattr(route, "methods")
|
|
756
|
+
for method in methods:
|
|
757
|
+
if method != "HEAD":
|
|
758
|
+
routes.append({"path": path, "method": method})
|
|
759
|
+
return routes
|