taskmanager-engine 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.
- taskmanager/__init__.py +150 -0
- taskmanager/api/app.py +488 -0
- taskmanager/api/events.py +68 -0
- taskmanager/cli.py +291 -0
- taskmanager/config.py +19 -0
- taskmanager/contrib/__init__.py +3 -0
- taskmanager/contrib/django/__init__.py +6 -0
- taskmanager/contrib/django/apps.py +46 -0
- taskmanager/contrib/django/management/__init__.py +1 -0
- taskmanager/contrib/django/management/commands/__init__.py +1 -0
- taskmanager/contrib/django/management/commands/run_scheduler.py +44 -0
- taskmanager/contrib/django/management/commands/run_worker.py +72 -0
- taskmanager/contrib/django/urls.py +30 -0
- taskmanager/contrib/fastapi.py +31 -0
- taskmanager/core/broker.py +541 -0
- taskmanager/core/builtin_tasks.py +59 -0
- taskmanager/core/job.py +50 -0
- taskmanager/core/task.py +224 -0
- taskmanager/scheduler/cron.py +52 -0
- taskmanager/scheduler/scheduler.py +178 -0
- taskmanager/ui/app.js +1390 -0
- taskmanager/ui/index.html +681 -0
- taskmanager/ui/styles.css +1048 -0
- taskmanager/worker/heartbeat.py +150 -0
- taskmanager/worker/worker.py +207 -0
- taskmanager_engine-0.1.0.dist-info/METADATA +284 -0
- taskmanager_engine-0.1.0.dist-info/RECORD +30 -0
- taskmanager_engine-0.1.0.dist-info/WHEEL +5 -0
- taskmanager_engine-0.1.0.dist-info/entry_points.txt +2 -0
- taskmanager_engine-0.1.0.dist-info/top_level.txt +1 -0
taskmanager/__init__.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TaskManager: Modern background task execution engine with dynamic cron scheduling
|
|
3
|
+
and real-time management dashboard.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import fakeredis.aioredis
|
|
12
|
+
import redis.asyncio as redis
|
|
13
|
+
|
|
14
|
+
import taskmanager.core.builtin_tasks as builtin_tasks # Auto-registers system tasks
|
|
15
|
+
from taskmanager.api.app import create_app
|
|
16
|
+
from taskmanager.config import settings
|
|
17
|
+
from taskmanager.core.broker import RedisBroker
|
|
18
|
+
from taskmanager.core.job import Job
|
|
19
|
+
from taskmanager.core.task import Task, TaskRegistry, registry, task
|
|
20
|
+
from taskmanager.scheduler.scheduler import Scheduler
|
|
21
|
+
from taskmanager.worker.worker import Worker
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TaskManager:
|
|
27
|
+
"""
|
|
28
|
+
High-level manager for embedding TaskManager into existing Python applications
|
|
29
|
+
(FastAPI, Starlette, Django, Flask, or standalone scripts).
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
```python
|
|
33
|
+
from taskmanager import TaskManager, task
|
|
34
|
+
|
|
35
|
+
tm = TaskManager(redis_url="redis://localhost:6379/0", prefix="myapp")
|
|
36
|
+
|
|
37
|
+
@task(name="send_welcome_email")
|
|
38
|
+
async def send_welcome_email(email: str):
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
# Mount into FastAPI
|
|
42
|
+
app.mount("/tasks", tm.get_app())
|
|
43
|
+
```
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
redis_url: str | None = None,
|
|
49
|
+
prefix: str | None = None,
|
|
50
|
+
task_registry: TaskRegistry | None = None,
|
|
51
|
+
broker: RedisBroker | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.redis_url = redis_url or settings.redis_url
|
|
54
|
+
self.prefix = prefix or settings.redis_prefix
|
|
55
|
+
self.registry = task_registry or registry
|
|
56
|
+
self._broker = broker
|
|
57
|
+
self._scheduler: Scheduler | None = None
|
|
58
|
+
self._app: Any = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def broker(self) -> RedisBroker:
|
|
62
|
+
if self._broker is None:
|
|
63
|
+
if self.redis_url and self.redis_url.startswith("memory://"):
|
|
64
|
+
client = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
|
65
|
+
else:
|
|
66
|
+
client = redis.from_url(self.redis_url, decode_responses=True)
|
|
67
|
+
self._broker = RedisBroker(client, prefix=self.prefix)
|
|
68
|
+
self.registry.set_broker(self._broker)
|
|
69
|
+
return self._broker
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def scheduler(self) -> Scheduler:
|
|
73
|
+
if self._scheduler is None:
|
|
74
|
+
self._scheduler = Scheduler(self.broker)
|
|
75
|
+
return self._scheduler
|
|
76
|
+
|
|
77
|
+
def task(
|
|
78
|
+
self,
|
|
79
|
+
name: str | None = None,
|
|
80
|
+
queue: str = "default",
|
|
81
|
+
max_retries: int = 3,
|
|
82
|
+
retry_backoff: float = 2.0,
|
|
83
|
+
timeout: float | None = None,
|
|
84
|
+
) -> Callable[..., Task]:
|
|
85
|
+
"""Decorator to register a function as a Task within this manager's registry."""
|
|
86
|
+
|
|
87
|
+
def decorator(func: Callable[..., Any]) -> Task:
|
|
88
|
+
t = Task(
|
|
89
|
+
func=func,
|
|
90
|
+
name=name,
|
|
91
|
+
queue=queue,
|
|
92
|
+
max_retries=max_retries,
|
|
93
|
+
retry_backoff=retry_backoff,
|
|
94
|
+
timeout=timeout,
|
|
95
|
+
broker=self.broker,
|
|
96
|
+
)
|
|
97
|
+
self.registry.register(t)
|
|
98
|
+
return t
|
|
99
|
+
|
|
100
|
+
return decorator
|
|
101
|
+
|
|
102
|
+
def create_worker(
|
|
103
|
+
self,
|
|
104
|
+
queues: list[str] | None = None,
|
|
105
|
+
concurrency: int = 5,
|
|
106
|
+
name: str | None = None,
|
|
107
|
+
max_memory_mb: float | None = None,
|
|
108
|
+
max_cpu_percent: float | None = None,
|
|
109
|
+
) -> Worker:
|
|
110
|
+
"""Creates a Worker instance bound to this broker and registry."""
|
|
111
|
+
return Worker(
|
|
112
|
+
broker=self.broker,
|
|
113
|
+
task_registry=self.registry,
|
|
114
|
+
queues=queues or ["default"],
|
|
115
|
+
concurrency=concurrency,
|
|
116
|
+
name=name,
|
|
117
|
+
max_memory_mb=max_memory_mb,
|
|
118
|
+
max_cpu_percent=max_cpu_percent,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def get_app(self, root_path: str = "") -> Any:
|
|
122
|
+
"""Returns the configured FastAPI application for the dashboard and REST API."""
|
|
123
|
+
if self._app is None or root_path:
|
|
124
|
+
self._app = create_app(
|
|
125
|
+
broker=self.broker,
|
|
126
|
+
task_reg=self.registry,
|
|
127
|
+
root_path=root_path,
|
|
128
|
+
)
|
|
129
|
+
return self._app
|
|
130
|
+
|
|
131
|
+
def mount_to(self, parent_app: Any, path: str = "/tasks") -> None:
|
|
132
|
+
"""Mounts the TaskManager dashboard and API sub-application onto a parent FastAPI/Starlette app."""
|
|
133
|
+
sub_app = self.get_app(root_path=path)
|
|
134
|
+
parent_app.mount(path, sub_app)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
__all__ = [
|
|
138
|
+
"TaskManager",
|
|
139
|
+
"task",
|
|
140
|
+
"create_app",
|
|
141
|
+
"Job",
|
|
142
|
+
"Task",
|
|
143
|
+
"TaskRegistry",
|
|
144
|
+
"registry",
|
|
145
|
+
"Worker",
|
|
146
|
+
"Scheduler",
|
|
147
|
+
"RedisBroker",
|
|
148
|
+
"builtin_tasks",
|
|
149
|
+
"__version__",
|
|
150
|
+
]
|
taskmanager/api/app.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import psutil
|
|
9
|
+
import redis.asyncio as redis
|
|
10
|
+
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
|
11
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
12
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from taskmanager.api.events import WebSocketEventManager
|
|
17
|
+
from taskmanager.config import settings
|
|
18
|
+
from taskmanager.core.broker import RedisBroker
|
|
19
|
+
from taskmanager.core.job import Job
|
|
20
|
+
from taskmanager.core.task import TaskRegistry, registry
|
|
21
|
+
from taskmanager.scheduler.cron import Schedule
|
|
22
|
+
from taskmanager.scheduler.scheduler import Scheduler
|
|
23
|
+
from taskmanager.worker.heartbeat import HeartbeatManager
|
|
24
|
+
from taskmanager.worker.worker import Worker
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SpawnWorkerRequest(BaseModel):
|
|
28
|
+
name: str | None = None
|
|
29
|
+
queues: list[str] = Field(default_factory=lambda: ["default"])
|
|
30
|
+
concurrency: int = 5
|
|
31
|
+
max_memory_mb: float | None = None
|
|
32
|
+
max_cpu_percent: float | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CreateScheduleRequest(BaseModel):
|
|
36
|
+
name: str
|
|
37
|
+
task_name: str
|
|
38
|
+
queue: str = "default"
|
|
39
|
+
schedule_type: str = "cron" # "cron" or "interval"
|
|
40
|
+
cron_expression: str | None = None
|
|
41
|
+
interval_seconds: float | None = None
|
|
42
|
+
args: list[Any] = Field(default_factory=list)
|
|
43
|
+
kwargs: dict[str, Any] = Field(default_factory=dict)
|
|
44
|
+
enabled: bool = True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EnqueueTaskRequest(BaseModel):
|
|
48
|
+
args: list[Any] = Field(default_factory=list)
|
|
49
|
+
kwargs: dict[str, Any] = Field(default_factory=dict)
|
|
50
|
+
queue: str | None = None
|
|
51
|
+
delay: float | None = None
|
|
52
|
+
priority: int = 0
|
|
53
|
+
max_retries: int | None = None
|
|
54
|
+
timeout: float | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def create_app(
|
|
58
|
+
broker: RedisBroker | None = None,
|
|
59
|
+
task_reg: TaskRegistry | None = None,
|
|
60
|
+
root_path: str = "",
|
|
61
|
+
) -> FastAPI:
|
|
62
|
+
"""Factory creating configured FastAPI application."""
|
|
63
|
+
if broker is None:
|
|
64
|
+
client = redis.from_url(settings.redis_url, decode_responses=True)
|
|
65
|
+
broker = RedisBroker(client, prefix=settings.redis_prefix)
|
|
66
|
+
if task_reg is None:
|
|
67
|
+
task_reg = registry
|
|
68
|
+
|
|
69
|
+
scheduler = Scheduler(broker)
|
|
70
|
+
event_manager = WebSocketEventManager(broker)
|
|
71
|
+
|
|
72
|
+
@asynccontextmanager
|
|
73
|
+
async def lifespan(app: FastAPI):
|
|
74
|
+
await event_manager.start_listener()
|
|
75
|
+
sched_task = asyncio.create_task(scheduler.start())
|
|
76
|
+
try:
|
|
77
|
+
yield
|
|
78
|
+
finally:
|
|
79
|
+
await scheduler.stop()
|
|
80
|
+
sched_task.cancel()
|
|
81
|
+
await event_manager.stop_listener()
|
|
82
|
+
|
|
83
|
+
app = FastAPI(
|
|
84
|
+
title="TaskManager API",
|
|
85
|
+
description="Background task execution engine & real-time dashboard API",
|
|
86
|
+
version="0.1.0",
|
|
87
|
+
lifespan=lifespan,
|
|
88
|
+
root_path=root_path,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
app.add_middleware(
|
|
92
|
+
CORSMiddleware,
|
|
93
|
+
allow_origins=["*"],
|
|
94
|
+
allow_credentials=True,
|
|
95
|
+
allow_methods=["*"],
|
|
96
|
+
allow_headers=["*"],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
app.state.broker = broker
|
|
100
|
+
app.state.registry = task_reg
|
|
101
|
+
app.state.scheduler = scheduler
|
|
102
|
+
app.state.event_manager = event_manager
|
|
103
|
+
|
|
104
|
+
@app.exception_handler(ValueError)
|
|
105
|
+
async def value_error_handler(request, exc: ValueError):
|
|
106
|
+
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
|
107
|
+
|
|
108
|
+
spawned_workers: dict[str, tuple[Worker, asyncio.Task[Any]]] = {}
|
|
109
|
+
|
|
110
|
+
# --- REST Endpoints ---
|
|
111
|
+
|
|
112
|
+
@app.get("/api/overview")
|
|
113
|
+
async def get_overview():
|
|
114
|
+
"""Returns consolidated metrics for dashboard counters."""
|
|
115
|
+
for t_name in task_reg.list_tasks():
|
|
116
|
+
t = task_reg.get(t_name)
|
|
117
|
+
if t and t.queue:
|
|
118
|
+
await broker.redis.sadd(broker._key_queues(), t.queue)
|
|
119
|
+
|
|
120
|
+
queues = await broker.get_all_queues()
|
|
121
|
+
queue_summaries = []
|
|
122
|
+
total_pending = 0
|
|
123
|
+
total_delayed = 0
|
|
124
|
+
total_dlq = 0
|
|
125
|
+
|
|
126
|
+
for q in queues:
|
|
127
|
+
m = await broker.get_queue_metrics(q)
|
|
128
|
+
queue_summaries.append(m)
|
|
129
|
+
total_pending += m["pending"]
|
|
130
|
+
total_delayed += m["delayed"]
|
|
131
|
+
total_dlq += m["dlq"]
|
|
132
|
+
|
|
133
|
+
workers = await HeartbeatManager.get_all_workers(broker)
|
|
134
|
+
active_workers = [w for w in workers if w.status in ["idle", "busy", "paused", "throttled"]]
|
|
135
|
+
total_active_jobs = sum(w.active_jobs_count for w in active_workers)
|
|
136
|
+
|
|
137
|
+
schedules = await scheduler.list_schedules()
|
|
138
|
+
|
|
139
|
+
# Worker Resource Telemetry (from heartbeats / worker process RSS)
|
|
140
|
+
if active_workers:
|
|
141
|
+
worker_cpu_percent = round(sum(w.cpu_percent for w in active_workers) / len(active_workers), 1)
|
|
142
|
+
worker_memory_mb = round(sum(w.memory_mb for w in active_workers), 2)
|
|
143
|
+
worker_memory_detail = f"{len(active_workers)} worker(s) ativo(s)"
|
|
144
|
+
else:
|
|
145
|
+
try:
|
|
146
|
+
proc = psutil.Process()
|
|
147
|
+
worker_cpu_percent = psutil.cpu_percent(interval=None)
|
|
148
|
+
worker_memory_mb = round(proc.memory_info().rss / (1024 * 1024), 2)
|
|
149
|
+
worker_memory_detail = "Processo local"
|
|
150
|
+
except Exception:
|
|
151
|
+
worker_cpu_percent = 0.0
|
|
152
|
+
worker_memory_mb = 0.0
|
|
153
|
+
worker_memory_detail = "--"
|
|
154
|
+
|
|
155
|
+
persistence = await broker.check_persistence_health()
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"workers_count": len(active_workers),
|
|
159
|
+
"total_workers": len(workers),
|
|
160
|
+
"active_jobs": total_active_jobs,
|
|
161
|
+
"total_pending": total_pending,
|
|
162
|
+
"total_delayed": total_delayed,
|
|
163
|
+
"total_dlq": total_dlq,
|
|
164
|
+
"schedules_count": len(schedules),
|
|
165
|
+
"worker_cpu_percent": worker_cpu_percent,
|
|
166
|
+
"worker_memory_mb": worker_memory_mb,
|
|
167
|
+
"worker_memory_detail": worker_memory_detail,
|
|
168
|
+
"queues": queue_summaries,
|
|
169
|
+
"persistence": persistence,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@app.get("/api/queues")
|
|
173
|
+
async def get_queues():
|
|
174
|
+
queues = await broker.get_all_queues()
|
|
175
|
+
results = []
|
|
176
|
+
for q in queues:
|
|
177
|
+
m = await broker.get_queue_metrics(q)
|
|
178
|
+
results.append(m)
|
|
179
|
+
return results
|
|
180
|
+
|
|
181
|
+
@app.post("/api/queues")
|
|
182
|
+
async def create_queue_endpoint(payload: dict[str, str]):
|
|
183
|
+
name = payload.get("name", "").strip()
|
|
184
|
+
if not name:
|
|
185
|
+
raise HTTPException(status_code=400, detail="Nome da fila é obrigatório.")
|
|
186
|
+
created = await broker.create_queue(name)
|
|
187
|
+
if not created:
|
|
188
|
+
raise HTTPException(status_code=400, detail="Nome de fila inválido.")
|
|
189
|
+
metrics = await broker.get_queue_metrics(name)
|
|
190
|
+
return {"status": "created", "queue": name, "metrics": metrics}
|
|
191
|
+
|
|
192
|
+
@app.delete("/api/queues/{queue}")
|
|
193
|
+
async def delete_queue_endpoint(queue: str):
|
|
194
|
+
if queue == "default":
|
|
195
|
+
raise HTTPException(status_code=400, detail="A fila 'default' não pode ser excluída.")
|
|
196
|
+
deleted = await broker.delete_queue(queue)
|
|
197
|
+
if not deleted:
|
|
198
|
+
raise HTTPException(status_code=404, detail="Fila não encontrada.")
|
|
199
|
+
return {"status": "deleted", "queue": queue}
|
|
200
|
+
|
|
201
|
+
@app.get("/api/workers")
|
|
202
|
+
async def get_workers():
|
|
203
|
+
return await HeartbeatManager.get_all_workers(broker)
|
|
204
|
+
|
|
205
|
+
@app.post("/api/workers/spawn")
|
|
206
|
+
async def spawn_worker_endpoint(req: SpawnWorkerRequest):
|
|
207
|
+
"""Dynamically creates and starts a new worker directly from the Dashboard/API."""
|
|
208
|
+
worker = Worker(
|
|
209
|
+
name=req.name,
|
|
210
|
+
queues=req.queues,
|
|
211
|
+
concurrency=req.concurrency,
|
|
212
|
+
max_memory_mb=req.max_memory_mb,
|
|
213
|
+
max_cpu_percent=req.max_cpu_percent,
|
|
214
|
+
broker=broker,
|
|
215
|
+
task_registry=task_reg,
|
|
216
|
+
)
|
|
217
|
+
task = asyncio.create_task(worker.start())
|
|
218
|
+
spawned_workers[worker.id] = (worker, task)
|
|
219
|
+
# Publish event so frontend updates instantly
|
|
220
|
+
await broker.publish_event(
|
|
221
|
+
"worker:spawned",
|
|
222
|
+
{
|
|
223
|
+
"id": worker.id,
|
|
224
|
+
"name": worker.name,
|
|
225
|
+
"queues": worker.queues,
|
|
226
|
+
"concurrency": worker.concurrency,
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
return {
|
|
230
|
+
"status": "started",
|
|
231
|
+
"id": worker.id,
|
|
232
|
+
"name": worker.name,
|
|
233
|
+
"queues": worker.queues,
|
|
234
|
+
"concurrency": worker.concurrency,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
@app.post("/api/workers/{worker_id}/pause")
|
|
238
|
+
async def pause_worker_endpoint(worker_id: str):
|
|
239
|
+
"""Pauses job consumption on a worker."""
|
|
240
|
+
if worker_id in spawned_workers:
|
|
241
|
+
spawned_workers[worker_id][0].pause()
|
|
242
|
+
await broker.publish_control("pause", worker_id=worker_id)
|
|
243
|
+
return {"status": "paused", "worker_id": worker_id}
|
|
244
|
+
|
|
245
|
+
@app.post("/api/workers/{worker_id}/resume")
|
|
246
|
+
async def resume_worker_endpoint(worker_id: str):
|
|
247
|
+
"""Resumes job consumption on a worker."""
|
|
248
|
+
if worker_id in spawned_workers:
|
|
249
|
+
spawned_workers[worker_id][0].resume()
|
|
250
|
+
await broker.publish_control("resume", worker_id=worker_id)
|
|
251
|
+
return {"status": "resumed", "worker_id": worker_id}
|
|
252
|
+
|
|
253
|
+
@app.post("/api/workers/{worker_id}/stop")
|
|
254
|
+
async def stop_worker_endpoint(worker_id: str):
|
|
255
|
+
"""Gracefully terminates a worker."""
|
|
256
|
+
if worker_id in spawned_workers:
|
|
257
|
+
w, _ = spawned_workers[worker_id]
|
|
258
|
+
await w.stop()
|
|
259
|
+
del spawned_workers[worker_id]
|
|
260
|
+
await broker.publish_control("stop", worker_id=worker_id)
|
|
261
|
+
return {"status": "stopped", "worker_id": worker_id}
|
|
262
|
+
|
|
263
|
+
@app.get("/api/tasks")
|
|
264
|
+
async def list_registered_tasks():
|
|
265
|
+
tasks = []
|
|
266
|
+
for name in task_reg.list_tasks():
|
|
267
|
+
t = task_reg.get(name)
|
|
268
|
+
if t:
|
|
269
|
+
sig_info = t.get_signature_info()
|
|
270
|
+
tasks.append(
|
|
271
|
+
{
|
|
272
|
+
"name": t.name,
|
|
273
|
+
"queue": t.queue,
|
|
274
|
+
"max_retries": t.max_retries,
|
|
275
|
+
"retry_backoff": t.retry_backoff,
|
|
276
|
+
"timeout": t.timeout,
|
|
277
|
+
"is_async": t.is_async,
|
|
278
|
+
"parameters": sig_info["parameters"],
|
|
279
|
+
"sample_kwargs": sig_info["sample_kwargs"],
|
|
280
|
+
"docstring": sig_info["docstring"],
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
return tasks
|
|
284
|
+
|
|
285
|
+
@app.post("/api/tasks/{task_name}/enqueue")
|
|
286
|
+
async def enqueue_task(task_name: str, req: EnqueueTaskRequest):
|
|
287
|
+
t = task_reg.get(task_name)
|
|
288
|
+
if not t:
|
|
289
|
+
# Allow dynamic enqueue even if not registered locally
|
|
290
|
+
job = Job(
|
|
291
|
+
task_name=task_name,
|
|
292
|
+
queue=req.queue or "default",
|
|
293
|
+
args=req.args,
|
|
294
|
+
kwargs=req.kwargs,
|
|
295
|
+
priority=req.priority,
|
|
296
|
+
max_retries=req.max_retries or 3,
|
|
297
|
+
timeout=req.timeout,
|
|
298
|
+
)
|
|
299
|
+
if req.delay and req.delay > 0:
|
|
300
|
+
enqueued = await broker.schedule_delayed(job, req.delay)
|
|
301
|
+
else:
|
|
302
|
+
enqueued = await broker.enqueue(job)
|
|
303
|
+
return enqueued
|
|
304
|
+
|
|
305
|
+
enqueued = await t.apply_async(
|
|
306
|
+
args=req.args,
|
|
307
|
+
kwargs=req.kwargs,
|
|
308
|
+
queue=req.queue,
|
|
309
|
+
delay=req.delay,
|
|
310
|
+
priority=req.priority,
|
|
311
|
+
max_retries=req.max_retries,
|
|
312
|
+
timeout=req.timeout,
|
|
313
|
+
)
|
|
314
|
+
return enqueued
|
|
315
|
+
|
|
316
|
+
@app.get("/api/jobs/history")
|
|
317
|
+
async def get_job_history(
|
|
318
|
+
limit: int = 50,
|
|
319
|
+
status: str | None = None,
|
|
320
|
+
task_name: str | None = None,
|
|
321
|
+
):
|
|
322
|
+
"""Returns recent job execution history with optional filtering."""
|
|
323
|
+
return await broker.get_history(limit=limit, status=status, task_name=task_name)
|
|
324
|
+
|
|
325
|
+
@app.get("/api/metrics/observability")
|
|
326
|
+
async def get_observability_metrics():
|
|
327
|
+
"""Returns LGTM-style aggregated performance metrics (p95 latency, success rate, throughput)."""
|
|
328
|
+
return await broker.get_observability_metrics()
|
|
329
|
+
|
|
330
|
+
@app.get("/api/jobs/{job_id}")
|
|
331
|
+
async def get_job_by_id(job_id: str):
|
|
332
|
+
job = await broker.get_job(job_id)
|
|
333
|
+
if not job:
|
|
334
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
335
|
+
return job
|
|
336
|
+
|
|
337
|
+
@app.post("/api/jobs/{job_id}/cancel")
|
|
338
|
+
async def cancel_job_endpoint(job_id: str):
|
|
339
|
+
success = await broker.cancel_job(job_id)
|
|
340
|
+
if not success:
|
|
341
|
+
raise HTTPException(
|
|
342
|
+
status_code=400, detail="Job cannot be cancelled (not pending/delayed or not found)"
|
|
343
|
+
)
|
|
344
|
+
return {"status": "cancelled", "job_id": job_id}
|
|
345
|
+
|
|
346
|
+
# --- Schedule Endpoints ---
|
|
347
|
+
@app.get("/api/schedules")
|
|
348
|
+
async def list_schedules():
|
|
349
|
+
return await scheduler.list_schedules()
|
|
350
|
+
|
|
351
|
+
@app.post("/api/schedules")
|
|
352
|
+
async def create_schedule(req: CreateScheduleRequest):
|
|
353
|
+
cron_expr = req.cron_expression.strip() if req.cron_expression else None
|
|
354
|
+
if req.schedule_type == "cron" and not cron_expr:
|
|
355
|
+
cron_expr = "0 * * * *"
|
|
356
|
+
interval_sec = req.interval_seconds if req.interval_seconds and req.interval_seconds > 0 else (60.0 if req.schedule_type == "interval" else None)
|
|
357
|
+
|
|
358
|
+
sched = Schedule(
|
|
359
|
+
name=req.name,
|
|
360
|
+
task_name=req.task_name,
|
|
361
|
+
queue=req.queue,
|
|
362
|
+
schedule_type=req.schedule_type,
|
|
363
|
+
cron_expression=cron_expr,
|
|
364
|
+
interval_seconds=interval_sec,
|
|
365
|
+
args=req.args,
|
|
366
|
+
kwargs=req.kwargs,
|
|
367
|
+
enabled=req.enabled,
|
|
368
|
+
)
|
|
369
|
+
created = await scheduler.add_schedule(sched)
|
|
370
|
+
return created
|
|
371
|
+
|
|
372
|
+
@app.get("/api/schedules/{schedule_id}")
|
|
373
|
+
async def get_schedule(schedule_id: str):
|
|
374
|
+
sched = await scheduler.get_schedule(schedule_id)
|
|
375
|
+
if not sched:
|
|
376
|
+
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
377
|
+
return sched
|
|
378
|
+
|
|
379
|
+
@app.put("/api/schedules/{schedule_id}")
|
|
380
|
+
async def update_schedule(schedule_id: str, req: CreateScheduleRequest):
|
|
381
|
+
existing = await scheduler.get_schedule(schedule_id)
|
|
382
|
+
if not existing:
|
|
383
|
+
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
384
|
+
|
|
385
|
+
cron_expr = req.cron_expression.strip() if req.cron_expression else None
|
|
386
|
+
if req.schedule_type == "cron" and not cron_expr:
|
|
387
|
+
cron_expr = "0 * * * *"
|
|
388
|
+
interval_sec = req.interval_seconds if req.interval_seconds and req.interval_seconds > 0 else (60.0 if req.schedule_type == "interval" else None)
|
|
389
|
+
|
|
390
|
+
existing.name = req.name
|
|
391
|
+
existing.task_name = req.task_name
|
|
392
|
+
existing.queue = req.queue
|
|
393
|
+
existing.schedule_type = req.schedule_type
|
|
394
|
+
existing.cron_expression = cron_expr
|
|
395
|
+
existing.interval_seconds = interval_sec
|
|
396
|
+
existing.args = req.args
|
|
397
|
+
existing.kwargs = req.kwargs
|
|
398
|
+
existing.enabled = req.enabled
|
|
399
|
+
|
|
400
|
+
updated = await scheduler.update_schedule(existing)
|
|
401
|
+
return updated
|
|
402
|
+
|
|
403
|
+
@app.delete("/api/schedules/{schedule_id}")
|
|
404
|
+
async def delete_schedule(schedule_id: str):
|
|
405
|
+
deleted = await scheduler.delete_schedule(schedule_id)
|
|
406
|
+
if not deleted:
|
|
407
|
+
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
408
|
+
return {"status": "deleted", "schedule_id": schedule_id}
|
|
409
|
+
|
|
410
|
+
@app.post("/api/schedules/{schedule_id}/toggle")
|
|
411
|
+
async def toggle_schedule(schedule_id: str, payload: dict[str, bool]):
|
|
412
|
+
enabled = payload.get("enabled", True)
|
|
413
|
+
sched = await scheduler.toggle_schedule(schedule_id, enabled)
|
|
414
|
+
if not sched:
|
|
415
|
+
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
416
|
+
return sched
|
|
417
|
+
|
|
418
|
+
@app.post("/api/schedules/{schedule_id}/trigger")
|
|
419
|
+
async def trigger_schedule(schedule_id: str):
|
|
420
|
+
job = await scheduler.trigger_now(schedule_id)
|
|
421
|
+
if not job:
|
|
422
|
+
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
423
|
+
return {"status": "triggered", "job": job}
|
|
424
|
+
|
|
425
|
+
# --- DLQ Endpoints ---
|
|
426
|
+
@app.get("/api/dlq")
|
|
427
|
+
@app.get("/api/dlq/{queue}")
|
|
428
|
+
async def get_dlq(queue: str = "all"):
|
|
429
|
+
return await broker.get_dlq_jobs(queue)
|
|
430
|
+
|
|
431
|
+
@app.post("/api/dlq/{job_id}/replay")
|
|
432
|
+
async def replay_dlq(job_id: str):
|
|
433
|
+
replayed = await broker.replay_dlq_job(job_id)
|
|
434
|
+
if not replayed:
|
|
435
|
+
raise HTTPException(status_code=404, detail="Job not found in DLQ")
|
|
436
|
+
return {"status": "replayed", "job": replayed}
|
|
437
|
+
|
|
438
|
+
@app.post("/api/dlq/purge")
|
|
439
|
+
@app.post("/api/dlq/{queue}/purge")
|
|
440
|
+
async def purge_dlq(queue: str = "all"):
|
|
441
|
+
count = await broker.purge_dlq(queue)
|
|
442
|
+
return {"status": "purged", "count": count, "queue": queue}
|
|
443
|
+
|
|
444
|
+
# --- Maintenance & Flush Endpoints ---
|
|
445
|
+
@app.post("/api/maintenance/flush")
|
|
446
|
+
async def flush_maintenance(payload: dict[str, str]):
|
|
447
|
+
target = payload.get("target", "queues")
|
|
448
|
+
if target == "queues":
|
|
449
|
+
res = await broker.flush_queues()
|
|
450
|
+
return {"status": "ok", "target": "queues", "result": res}
|
|
451
|
+
elif target == "history":
|
|
452
|
+
res = await broker.flush_history()
|
|
453
|
+
return {"status": "ok", "target": "history", "deleted_keys": res}
|
|
454
|
+
elif target == "all":
|
|
455
|
+
res = await broker.flush_all()
|
|
456
|
+
return {"status": "ok", "target": "all", "deleted_keys": res}
|
|
457
|
+
else:
|
|
458
|
+
raise HTTPException(
|
|
459
|
+
status_code=400,
|
|
460
|
+
detail="Invalid target. Must be 'queues', 'history', or 'all'.",
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# --- WebSocket Real-Time Stream ---
|
|
464
|
+
@app.websocket("/ws/events")
|
|
465
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
466
|
+
await event_manager.connect(websocket)
|
|
467
|
+
try:
|
|
468
|
+
while True:
|
|
469
|
+
# Keepalive loop
|
|
470
|
+
await websocket.receive_text()
|
|
471
|
+
except WebSocketDisconnect:
|
|
472
|
+
event_manager.disconnect(websocket)
|
|
473
|
+
except Exception:
|
|
474
|
+
event_manager.disconnect(websocket)
|
|
475
|
+
|
|
476
|
+
# --- Static UI Mount ---
|
|
477
|
+
ui_dir = Path(__file__).resolve().parent.parent / "ui"
|
|
478
|
+
if ui_dir.exists():
|
|
479
|
+
app.mount("/static", StaticFiles(directory=str(ui_dir)), name="static")
|
|
480
|
+
|
|
481
|
+
@app.get("/")
|
|
482
|
+
async def index():
|
|
483
|
+
index_file = ui_dir / "index.html"
|
|
484
|
+
if index_file.exists():
|
|
485
|
+
return FileResponse(str(index_file))
|
|
486
|
+
return JSONResponse({"status": "ok", "message": "TaskManager API is running"})
|
|
487
|
+
|
|
488
|
+
return app
|