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
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import redis.asyncio as redis
|
|
10
|
+
|
|
11
|
+
from taskmanager.core.job import Job, JobStatus
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RedisBroker:
|
|
17
|
+
"""Redis-backed broker managing queues, delayed jobs, state persistence, DLQ, and events."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, redis_client: redis.Redis, prefix: str = "tm"):
|
|
20
|
+
self.redis = redis_client
|
|
21
|
+
self.prefix = prefix
|
|
22
|
+
|
|
23
|
+
# --- Key Helper Functions ---
|
|
24
|
+
def _key_queue(self, queue: str) -> str:
|
|
25
|
+
return f"{self.prefix}:queue:{queue}"
|
|
26
|
+
|
|
27
|
+
def _key_delayed(self, queue: str) -> str:
|
|
28
|
+
return f"{self.prefix}:delayed:{queue}"
|
|
29
|
+
|
|
30
|
+
def _key_job(self, job_id: str) -> str:
|
|
31
|
+
return f"{self.prefix}:job:{job_id}"
|
|
32
|
+
|
|
33
|
+
def _key_active(self, worker_id: str) -> str:
|
|
34
|
+
return f"{self.prefix}:active:{worker_id}"
|
|
35
|
+
|
|
36
|
+
def _key_worker(self, worker_id: str) -> str:
|
|
37
|
+
return f"{self.prefix}:worker:{worker_id}"
|
|
38
|
+
|
|
39
|
+
def _key_workers(self) -> str:
|
|
40
|
+
return f"{self.prefix}:workers"
|
|
41
|
+
|
|
42
|
+
def _key_queues(self) -> str:
|
|
43
|
+
return f"{self.prefix}:queues"
|
|
44
|
+
|
|
45
|
+
def _key_dlq(self, queue: str) -> str:
|
|
46
|
+
return f"{self.prefix}:dlq:{queue}"
|
|
47
|
+
|
|
48
|
+
def _key_events(self) -> str:
|
|
49
|
+
return f"{self.prefix}:events"
|
|
50
|
+
|
|
51
|
+
def _key_schedules(self) -> str:
|
|
52
|
+
return f"{self.prefix}:schedules"
|
|
53
|
+
|
|
54
|
+
def _key_lock(self, name: str) -> str:
|
|
55
|
+
return f"{self.prefix}:lock:{name}"
|
|
56
|
+
|
|
57
|
+
def _key_control(self) -> str:
|
|
58
|
+
return f"{self.prefix}:control"
|
|
59
|
+
|
|
60
|
+
def _key_history(self) -> str:
|
|
61
|
+
return f"{self.prefix}:jobs:history"
|
|
62
|
+
|
|
63
|
+
# --- Job Operations ---
|
|
64
|
+
async def save_job(self, job: Job) -> None:
|
|
65
|
+
"""Persists or updates full Job state in Redis."""
|
|
66
|
+
key = self._key_job(job.id)
|
|
67
|
+
data = job.model_dump_json()
|
|
68
|
+
await self.redis.set(key, data, ex=86400 * 7) # 7-day TTL
|
|
69
|
+
|
|
70
|
+
async def get_job(self, job_id: str) -> Job | None:
|
|
71
|
+
"""Retrieves a Job by ID from Redis."""
|
|
72
|
+
key = self._key_job(job_id)
|
|
73
|
+
data = await self.redis.get(key)
|
|
74
|
+
if not data:
|
|
75
|
+
return None
|
|
76
|
+
return Job.model_validate_json(data)
|
|
77
|
+
|
|
78
|
+
async def enqueue(self, job: Job) -> Job:
|
|
79
|
+
"""Pushes a job into the specified FIFO queue."""
|
|
80
|
+
job.status = JobStatus.PENDING
|
|
81
|
+
if not job.logs:
|
|
82
|
+
job.logs.append(f"[{time.strftime('%H:%M:%S')}] Job enfileirado na fila '{job.queue}'.")
|
|
83
|
+
await self.save_job(job)
|
|
84
|
+
await self.redis.sadd(self._key_queues(), job.queue)
|
|
85
|
+
await self.redis.rpush(self._key_queue(job.queue), job.id)
|
|
86
|
+
await self.publish_event(
|
|
87
|
+
"job:enqueued", {"job_id": job.id, "queue": job.queue, "task": job.task_name}
|
|
88
|
+
)
|
|
89
|
+
return job
|
|
90
|
+
|
|
91
|
+
async def schedule_delayed(self, job: Job, delay_seconds: float) -> Job:
|
|
92
|
+
"""Places a job into the delayed sorted set with a target execution timestamp."""
|
|
93
|
+
job.status = JobStatus.DELAYED
|
|
94
|
+
target_timestamp = time.time() + delay_seconds
|
|
95
|
+
await self.save_job(job)
|
|
96
|
+
await self.redis.sadd(self._key_queues(), job.queue)
|
|
97
|
+
await self.redis.zadd(self._key_delayed(job.queue), {job.id: target_timestamp})
|
|
98
|
+
await self.publish_event(
|
|
99
|
+
"job:delayed",
|
|
100
|
+
{
|
|
101
|
+
"job_id": job.id,
|
|
102
|
+
"queue": job.queue,
|
|
103
|
+
"task": job.task_name,
|
|
104
|
+
"scheduled_at": target_timestamp,
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
return job
|
|
108
|
+
|
|
109
|
+
async def process_delayed_jobs(self, queue: str) -> int:
|
|
110
|
+
"""Moves ready jobs from the delayed sorted set to the active FIFO queue."""
|
|
111
|
+
delayed_key = self._key_delayed(queue)
|
|
112
|
+
queue_key = self._key_queue(queue)
|
|
113
|
+
now = time.time()
|
|
114
|
+
|
|
115
|
+
# Find jobs where score <= now
|
|
116
|
+
job_ids = await self.redis.zrangebyscore(delayed_key, min=0, max=now)
|
|
117
|
+
count = 0
|
|
118
|
+
for job_id in job_ids:
|
|
119
|
+
# Remove from delayed set
|
|
120
|
+
removed = await self.redis.zrem(delayed_key, job_id)
|
|
121
|
+
if removed:
|
|
122
|
+
job = await self.get_job(job_id)
|
|
123
|
+
if job and job.status == JobStatus.DELAYED:
|
|
124
|
+
job.status = JobStatus.PENDING
|
|
125
|
+
await self.save_job(job)
|
|
126
|
+
await self.redis.rpush(queue_key, job_id)
|
|
127
|
+
await self.publish_event(
|
|
128
|
+
"job:enqueued",
|
|
129
|
+
{"job_id": job.id, "queue": job.queue, "task": job.task_name},
|
|
130
|
+
)
|
|
131
|
+
count += 1
|
|
132
|
+
return count
|
|
133
|
+
|
|
134
|
+
async def fetch_next_job(
|
|
135
|
+
self, queues: list[str], worker_id: str, timeout: int = 1
|
|
136
|
+
) -> Job | None:
|
|
137
|
+
"""Fetches the next available job across the provided queues."""
|
|
138
|
+
# First, process delayed jobs on these queues
|
|
139
|
+
for q in queues:
|
|
140
|
+
await self.process_delayed_jobs(q)
|
|
141
|
+
|
|
142
|
+
# Polling queues with LPOP / BLPOP
|
|
143
|
+
for q in queues:
|
|
144
|
+
queue_key = self._key_queue(q)
|
|
145
|
+
job_id = await self.redis.lpop(queue_key)
|
|
146
|
+
if job_id:
|
|
147
|
+
job = await self.get_job(job_id)
|
|
148
|
+
if job:
|
|
149
|
+
job.status = JobStatus.ACTIVE
|
|
150
|
+
job.started_at = time.time()
|
|
151
|
+
job.worker_id = worker_id
|
|
152
|
+
job.logs.append(
|
|
153
|
+
f"[{time.strftime('%H:%M:%S')}] Job atribuído ao worker '{worker_id}'."
|
|
154
|
+
)
|
|
155
|
+
await self.save_job(job)
|
|
156
|
+
# Add to worker's active set
|
|
157
|
+
await self.redis.sadd(self._key_active(worker_id), job.id)
|
|
158
|
+
await self.publish_event(
|
|
159
|
+
"job:active",
|
|
160
|
+
{
|
|
161
|
+
"job_id": job.id,
|
|
162
|
+
"worker_id": worker_id,
|
|
163
|
+
"queue": job.queue,
|
|
164
|
+
"task": job.task_name,
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
return job
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
async def mark_completed(self, job: Job, result: Any) -> None:
|
|
171
|
+
"""Marks a job as completed and stores the execution result."""
|
|
172
|
+
job.status = JobStatus.COMPLETED
|
|
173
|
+
job.completed_at = time.time()
|
|
174
|
+
job.duration = round((job.completed_at - (job.started_at or job.created_at)), 4)
|
|
175
|
+
job.result = result
|
|
176
|
+
job.logs.append(
|
|
177
|
+
f"[{time.strftime('%H:%M:%S')}] Job concluído com sucesso em {job.duration:.3f}s."
|
|
178
|
+
)
|
|
179
|
+
await self.save_job(job)
|
|
180
|
+
if job.worker_id:
|
|
181
|
+
await self.redis.srem(self._key_active(job.worker_id), job.id)
|
|
182
|
+
|
|
183
|
+
await self.redis.zadd(self._key_history(), {job.id: job.completed_at})
|
|
184
|
+
await self.redis.zremrangebyrank(self._key_history(), 0, -1001)
|
|
185
|
+
|
|
186
|
+
await self.publish_event(
|
|
187
|
+
"job:completed",
|
|
188
|
+
{
|
|
189
|
+
"job_id": job.id,
|
|
190
|
+
"worker_id": job.worker_id,
|
|
191
|
+
"queue": job.queue,
|
|
192
|
+
"duration": job.duration,
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
async def mark_failed(self, job: Job, error: str, traceback_str: str) -> None:
|
|
197
|
+
"""Handles job failure: retries with exponential backoff or routes to DLQ."""
|
|
198
|
+
if job.worker_id:
|
|
199
|
+
await self.redis.srem(self._key_active(job.worker_id), job.id)
|
|
200
|
+
|
|
201
|
+
job.error = error
|
|
202
|
+
job.traceback = traceback_str
|
|
203
|
+
|
|
204
|
+
if job.can_retry():
|
|
205
|
+
job.retry_count += 1
|
|
206
|
+
backoff_delay = job.calculate_next_backoff()
|
|
207
|
+
job.status = JobStatus.RETRYING
|
|
208
|
+
job.logs.append(
|
|
209
|
+
f"[{time.strftime('%H:%M:%S')}] Tentativa {job.retry_count}/{job.max_retries} falhou: {error}. Agendando retry em {backoff_delay}s."
|
|
210
|
+
)
|
|
211
|
+
await self.save_job(job)
|
|
212
|
+
await self.publish_event(
|
|
213
|
+
"job:retrying",
|
|
214
|
+
{
|
|
215
|
+
"job_id": job.id,
|
|
216
|
+
"retry_count": job.retry_count,
|
|
217
|
+
"max_retries": job.max_retries,
|
|
218
|
+
"backoff_delay": backoff_delay,
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
await self.schedule_delayed(job, backoff_delay)
|
|
222
|
+
else:
|
|
223
|
+
# Exhausted retries -> Route to Dead Letter Queue (DLQ)
|
|
224
|
+
job.status = JobStatus.FAILED
|
|
225
|
+
job.completed_at = time.time()
|
|
226
|
+
job.duration = round(((job.completed_at) - (job.started_at or job.created_at)), 4)
|
|
227
|
+
job.logs.append(
|
|
228
|
+
f"[{time.strftime('%H:%M:%S')}] Retentativas esgotadas ({job.retry_count}/{job.max_retries}). Movido para Dead Letter Queue (DLQ): {error}"
|
|
229
|
+
)
|
|
230
|
+
await self.save_job(job)
|
|
231
|
+
await self.redis.rpush(self._key_dlq(job.queue), job.id)
|
|
232
|
+
await self.redis.zadd(self._key_history(), {job.id: job.completed_at})
|
|
233
|
+
await self.redis.zremrangebyrank(self._key_history(), 0, -1001)
|
|
234
|
+
|
|
235
|
+
await self.publish_event(
|
|
236
|
+
"job:failed", {"job_id": job.id, "queue": job.queue, "error": error, "dlq": True}
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
async def cancel_job(self, job_id: str) -> bool:
|
|
240
|
+
"""Cancels a pending or delayed job."""
|
|
241
|
+
job = await self.get_job(job_id)
|
|
242
|
+
if not job:
|
|
243
|
+
return False
|
|
244
|
+
if job.status in [JobStatus.PENDING, JobStatus.DELAYED]:
|
|
245
|
+
job.status = JobStatus.CANCELLED
|
|
246
|
+
job.completed_at = time.time()
|
|
247
|
+
await self.save_job(job)
|
|
248
|
+
await self.redis.lrem(self._key_queue(job.queue), 0, job.id)
|
|
249
|
+
await self.redis.zrem(self._key_delayed(job.queue), job.id)
|
|
250
|
+
await self.publish_event("job:cancelled", {"job_id": job.id, "queue": job.queue})
|
|
251
|
+
return True
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
# --- DLQ Operations ---
|
|
255
|
+
async def get_dlq_jobs(self, queue: str | None = None, limit: int = 50) -> list[Job]:
|
|
256
|
+
"""Returns jobs in the Dead Letter Queue for a specific queue or all queues."""
|
|
257
|
+
if queue and queue != "all":
|
|
258
|
+
queues = [queue]
|
|
259
|
+
else:
|
|
260
|
+
queues = await self.get_all_queues()
|
|
261
|
+
|
|
262
|
+
jobs: list[Job] = []
|
|
263
|
+
for q in queues:
|
|
264
|
+
job_ids = await self.redis.lrange(self._key_dlq(q), 0, limit - 1)
|
|
265
|
+
for jid in job_ids:
|
|
266
|
+
job = await self.get_job(jid)
|
|
267
|
+
if job:
|
|
268
|
+
jobs.append(job)
|
|
269
|
+
if len(jobs) >= limit:
|
|
270
|
+
break
|
|
271
|
+
if len(jobs) >= limit:
|
|
272
|
+
break
|
|
273
|
+
return jobs
|
|
274
|
+
|
|
275
|
+
async def replay_dlq_job(self, job_id: str) -> Job | None:
|
|
276
|
+
"""Re-enqueues a job from the DLQ for re-execution."""
|
|
277
|
+
job = await self.get_job(job_id)
|
|
278
|
+
if not job or job.status != JobStatus.FAILED:
|
|
279
|
+
return None
|
|
280
|
+
# Remove from DLQ list
|
|
281
|
+
await self.redis.lrem(self._key_dlq(job.queue), 0, job.id)
|
|
282
|
+
# Reset retry and error state
|
|
283
|
+
job.retry_count = 0
|
|
284
|
+
job.error = None
|
|
285
|
+
job.traceback = None
|
|
286
|
+
job.started_at = None
|
|
287
|
+
job.completed_at = None
|
|
288
|
+
job.status = JobStatus.PENDING
|
|
289
|
+
await self.enqueue(job)
|
|
290
|
+
await self.publish_event("job:replayed", {"job_id": job.id, "queue": job.queue})
|
|
291
|
+
return job
|
|
292
|
+
|
|
293
|
+
async def purge_dlq(self, queue: str = "all") -> int:
|
|
294
|
+
"""Removes all jobs from the DLQ of a specific queue or all queues."""
|
|
295
|
+
if queue and queue != "all":
|
|
296
|
+
queues = [queue]
|
|
297
|
+
else:
|
|
298
|
+
queues = await self.get_all_queues()
|
|
299
|
+
|
|
300
|
+
total_count = 0
|
|
301
|
+
for q in queues:
|
|
302
|
+
dlq_key = self._key_dlq(q)
|
|
303
|
+
job_ids = await self.redis.lrange(dlq_key, 0, -1)
|
|
304
|
+
total_count += len(job_ids)
|
|
305
|
+
await self.redis.delete(dlq_key)
|
|
306
|
+
return total_count
|
|
307
|
+
|
|
308
|
+
# --- Maintenance & Flush Operations ---
|
|
309
|
+
async def flush_queues(self) -> dict[str, int]:
|
|
310
|
+
"""Cleans all pending queues, delayed sets, and DLQ lists across all queues."""
|
|
311
|
+
queues = await self.get_all_queues()
|
|
312
|
+
total_deleted = 0
|
|
313
|
+
for q in queues:
|
|
314
|
+
# Delete queue, delayed, and dlq keys
|
|
315
|
+
deleted = await self.redis.delete(
|
|
316
|
+
self._key_queue(q),
|
|
317
|
+
self._key_delayed(q),
|
|
318
|
+
self._key_dlq(q),
|
|
319
|
+
)
|
|
320
|
+
total_deleted += deleted
|
|
321
|
+
await self.publish_event("maintenance:flushed", {"target": "queues"})
|
|
322
|
+
return {"cleared_queues": len(queues), "keys_deleted": total_deleted}
|
|
323
|
+
|
|
324
|
+
async def flush_history(self) -> int:
|
|
325
|
+
"""Clears job execution history logs and metrics."""
|
|
326
|
+
history_key = self._key_history()
|
|
327
|
+
deleted = await self.redis.delete(history_key)
|
|
328
|
+
await self.publish_event("maintenance:flushed", {"target": "history"})
|
|
329
|
+
return deleted
|
|
330
|
+
|
|
331
|
+
async def flush_all(self) -> int:
|
|
332
|
+
"""Clears all TaskManager keys in Redis (queues, jobs, history, dlq, schedules)."""
|
|
333
|
+
keys = []
|
|
334
|
+
async for key in self.redis.scan_iter(f"{self.prefix}:*"):
|
|
335
|
+
keys.append(key)
|
|
336
|
+
deleted_count = 0
|
|
337
|
+
if keys:
|
|
338
|
+
deleted_count = await self.redis.delete(*keys)
|
|
339
|
+
await self.publish_event("maintenance:flushed", {"target": "all"})
|
|
340
|
+
return deleted_count
|
|
341
|
+
|
|
342
|
+
# --- Telemetry & Metrics ---
|
|
343
|
+
async def create_queue(self, queue: str) -> bool:
|
|
344
|
+
"""Explicitly registers a queue in Redis."""
|
|
345
|
+
cleaned = queue.strip()
|
|
346
|
+
if not cleaned:
|
|
347
|
+
return False
|
|
348
|
+
await self.redis.sadd(self._key_queues(), cleaned)
|
|
349
|
+
await self.publish_event("queue:created", {"queue": cleaned})
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
async def delete_queue(self, queue: str) -> bool:
|
|
353
|
+
"""Deletes a queue from registered queues and deletes remaining queue data."""
|
|
354
|
+
cleaned = queue.strip()
|
|
355
|
+
if not cleaned or cleaned == "default":
|
|
356
|
+
return False
|
|
357
|
+
res = await self.redis.srem(self._key_queues(), cleaned)
|
|
358
|
+
await self.redis.delete(
|
|
359
|
+
self._key_queue(cleaned),
|
|
360
|
+
self._key_delayed(cleaned),
|
|
361
|
+
self._key_dlq(cleaned),
|
|
362
|
+
)
|
|
363
|
+
if res > 0:
|
|
364
|
+
await self.publish_event("queue:deleted", {"queue": cleaned})
|
|
365
|
+
return True
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
async def get_all_queues(self) -> list[str]:
|
|
369
|
+
"""Returns all registered queue names."""
|
|
370
|
+
queues = await self.redis.smembers(self._key_queues())
|
|
371
|
+
if not queues:
|
|
372
|
+
return ["default"]
|
|
373
|
+
return sorted(list(queues))
|
|
374
|
+
|
|
375
|
+
async def get_queue_metrics(self, queue: str) -> dict[str, int]:
|
|
376
|
+
"""Returns counts for pending, delayed, and DLQ jobs in a queue."""
|
|
377
|
+
pending_count = await self.redis.llen(self._key_queue(queue))
|
|
378
|
+
delayed_count = await self.redis.zcard(self._key_delayed(queue))
|
|
379
|
+
dlq_count = await self.redis.llen(self._key_dlq(queue))
|
|
380
|
+
return {
|
|
381
|
+
"queue": queue,
|
|
382
|
+
"pending": pending_count,
|
|
383
|
+
"delayed": delayed_count,
|
|
384
|
+
"dlq": dlq_count,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async def check_persistence_health(self) -> dict[str, Any]:
|
|
388
|
+
"""Inspects Redis server configuration for AOF durability and eviction safety."""
|
|
389
|
+
health: dict[str, Any] = {
|
|
390
|
+
"aof_enabled": False,
|
|
391
|
+
"maxmemory_policy": "unknown",
|
|
392
|
+
"is_durable": False,
|
|
393
|
+
"warnings": [],
|
|
394
|
+
}
|
|
395
|
+
try:
|
|
396
|
+
config = await self.redis.config_get("appendonly", "maxmemory-policy")
|
|
397
|
+
aof = config.get("appendonly", "no").lower() == "yes"
|
|
398
|
+
policy = config.get("maxmemory-policy", "noeviction")
|
|
399
|
+
health["aof_enabled"] = aof
|
|
400
|
+
health["maxmemory_policy"] = policy
|
|
401
|
+
health["is_durable"] = aof and policy == "noeviction"
|
|
402
|
+
|
|
403
|
+
if not aof:
|
|
404
|
+
health["warnings"].append(
|
|
405
|
+
"AOF persistence is disabled. Crons/DLQ might not survive unexpected reboots."
|
|
406
|
+
)
|
|
407
|
+
if policy != "noeviction":
|
|
408
|
+
health["warnings"].append(
|
|
409
|
+
f"Redis eviction policy is '{policy}'. Use 'noeviction' to prevent silent job deletion."
|
|
410
|
+
)
|
|
411
|
+
except Exception:
|
|
412
|
+
# FakeRedis or managed Redis without CONFIG GET permission
|
|
413
|
+
pass
|
|
414
|
+
return health
|
|
415
|
+
|
|
416
|
+
async def get_history(
|
|
417
|
+
self,
|
|
418
|
+
limit: int = 50,
|
|
419
|
+
status: str | None = None,
|
|
420
|
+
task_name: str | None = None,
|
|
421
|
+
) -> list[Job]:
|
|
422
|
+
"""Retrieves recent job executions ordered from newest to oldest."""
|
|
423
|
+
history_key = self._key_history()
|
|
424
|
+
# Get recent job IDs by score descending
|
|
425
|
+
job_ids = await self.redis.zrevrange(history_key, 0, limit * 3)
|
|
426
|
+
jobs: list[Job] = []
|
|
427
|
+
|
|
428
|
+
for j_id in job_ids:
|
|
429
|
+
job = await self.get_job(j_id)
|
|
430
|
+
if not job:
|
|
431
|
+
continue
|
|
432
|
+
if status and job.status != status:
|
|
433
|
+
continue
|
|
434
|
+
if task_name and task_name.lower() not in job.task_name.lower():
|
|
435
|
+
continue
|
|
436
|
+
jobs.append(job)
|
|
437
|
+
if len(jobs) >= limit:
|
|
438
|
+
break
|
|
439
|
+
return jobs
|
|
440
|
+
|
|
441
|
+
async def get_observability_metrics(self) -> dict[str, Any]:
|
|
442
|
+
"""Calculates LGTM-style aggregated performance metrics over recent executions."""
|
|
443
|
+
history_key = self._key_history()
|
|
444
|
+
job_ids = await self.redis.zrevrange(history_key, 0, 200)
|
|
445
|
+
|
|
446
|
+
completed_count = 0
|
|
447
|
+
failed_count = 0
|
|
448
|
+
durations: list[float] = []
|
|
449
|
+
now = time.time()
|
|
450
|
+
last_minute_runs = 0
|
|
451
|
+
|
|
452
|
+
for j_id in job_ids:
|
|
453
|
+
job = await self.get_job(j_id)
|
|
454
|
+
if not job:
|
|
455
|
+
continue
|
|
456
|
+
if job.status == JobStatus.COMPLETED:
|
|
457
|
+
completed_count += 1
|
|
458
|
+
if job.duration is not None:
|
|
459
|
+
durations.append(job.duration)
|
|
460
|
+
if job.completed_at and (now - job.completed_at) <= 60:
|
|
461
|
+
last_minute_runs += 1
|
|
462
|
+
elif job.status == JobStatus.FAILED:
|
|
463
|
+
failed_count += 1
|
|
464
|
+
if job.duration is not None:
|
|
465
|
+
durations.append(job.duration)
|
|
466
|
+
|
|
467
|
+
total = completed_count + failed_count
|
|
468
|
+
success_rate = round((completed_count / total * 100), 1) if total > 0 else 100.0
|
|
469
|
+
|
|
470
|
+
if durations:
|
|
471
|
+
sorted_durations = sorted(durations)
|
|
472
|
+
avg_duration_ms = round((sum(durations) / len(durations)) * 1000, 1)
|
|
473
|
+
p95_idx = int(len(sorted_durations) * 0.95)
|
|
474
|
+
p95_duration_ms = round(sorted_durations[min(p95_idx, len(sorted_durations) - 1)] * 1000, 1)
|
|
475
|
+
else:
|
|
476
|
+
avg_duration_ms = 0.0
|
|
477
|
+
p95_duration_ms = 0.0
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
"total_executions": total,
|
|
481
|
+
"completed_count": completed_count,
|
|
482
|
+
"failed_count": failed_count,
|
|
483
|
+
"success_rate_percent": success_rate,
|
|
484
|
+
"avg_duration_ms": avg_duration_ms,
|
|
485
|
+
"p95_duration_ms": p95_duration_ms,
|
|
486
|
+
"throughput_per_minute": last_minute_runs,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
# --- Real-Time Pub/Sub Events ---
|
|
490
|
+
async def publish_event(self, event_type: str, data: dict[str, Any]) -> None:
|
|
491
|
+
"""Publishes an event to the Redis event channel."""
|
|
492
|
+
payload = json.dumps(
|
|
493
|
+
{
|
|
494
|
+
"type": event_type,
|
|
495
|
+
"timestamp": time.time(),
|
|
496
|
+
"data": data,
|
|
497
|
+
}
|
|
498
|
+
)
|
|
499
|
+
try:
|
|
500
|
+
await self.redis.publish(self._key_events(), payload)
|
|
501
|
+
except Exception as err:
|
|
502
|
+
logger.debug(f"Failed to publish event {event_type}: {err}")
|
|
503
|
+
|
|
504
|
+
async def subscribe_events(self) -> AsyncIterator[dict[str, Any]]:
|
|
505
|
+
"""Subscribes to live events channel, yielding parsed events."""
|
|
506
|
+
pubsub = self.redis.pubsub()
|
|
507
|
+
await pubsub.subscribe(self._key_events())
|
|
508
|
+
try:
|
|
509
|
+
async for message in pubsub.listen():
|
|
510
|
+
if message["type"] == "message":
|
|
511
|
+
try:
|
|
512
|
+
yield json.loads(message["data"])
|
|
513
|
+
except Exception:
|
|
514
|
+
pass
|
|
515
|
+
finally:
|
|
516
|
+
await pubsub.unsubscribe(self._key_events())
|
|
517
|
+
await pubsub.aclose()
|
|
518
|
+
|
|
519
|
+
async def publish_control(self, action: str, worker_id: str | None = None) -> None:
|
|
520
|
+
"""Publishes a control action (pause, resume, stop) targeted to workers."""
|
|
521
|
+
payload = json.dumps({"action": action, "worker_id": worker_id, "timestamp": time.time()})
|
|
522
|
+
try:
|
|
523
|
+
await self.redis.publish(self._key_control(), payload)
|
|
524
|
+
except Exception as err:
|
|
525
|
+
logger.debug(f"Failed to publish control command {action}: {err}")
|
|
526
|
+
|
|
527
|
+
async def subscribe_control(self) -> AsyncIterator[dict[str, Any]]:
|
|
528
|
+
"""Subscribes to the worker control channel."""
|
|
529
|
+
pubsub = self.redis.pubsub()
|
|
530
|
+
await pubsub.subscribe(self._key_control())
|
|
531
|
+
try:
|
|
532
|
+
async for message in pubsub.listen():
|
|
533
|
+
if message["type"] == "message":
|
|
534
|
+
try:
|
|
535
|
+
yield json.loads(message["data"])
|
|
536
|
+
except Exception:
|
|
537
|
+
pass
|
|
538
|
+
finally:
|
|
539
|
+
await pubsub.unsubscribe(self._key_control())
|
|
540
|
+
await pubsub.aclose()
|
|
541
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from taskmanager.core.task import task
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@task(name="system.run_command", queue="default", max_retries=1, timeout=300.0)
|
|
15
|
+
async def run_command(command: str, cwd: str | None = None) -> dict[str, Any]:
|
|
16
|
+
"""Executes a shell command or script and captures stdout, stderr, and exit code."""
|
|
17
|
+
logger.info(f"Executing system command: {command}")
|
|
18
|
+
env = os.environ.copy()
|
|
19
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
20
|
+
env["PYTHONUTF8"] = "1"
|
|
21
|
+
|
|
22
|
+
# Normalize cwd: if invalid directory or placeholder, fallback to None (current workspace)
|
|
23
|
+
if cwd and (cwd.startswith("valor_") or not os.path.exists(cwd) or not os.path.isdir(cwd)):
|
|
24
|
+
logger.warning(f"Directory '{cwd}' is invalid or does not exist. Running in current working directory.")
|
|
25
|
+
cwd = None
|
|
26
|
+
|
|
27
|
+
process = await asyncio.create_subprocess_shell(
|
|
28
|
+
command,
|
|
29
|
+
cwd=cwd,
|
|
30
|
+
env=env,
|
|
31
|
+
stdout=asyncio.subprocess.PIPE,
|
|
32
|
+
stderr=asyncio.subprocess.PIPE,
|
|
33
|
+
)
|
|
34
|
+
stdout, stderr = await process.communicate()
|
|
35
|
+
exit_code = process.returncode
|
|
36
|
+
|
|
37
|
+
stdout_str = stdout.decode("utf-8", errors="replace").strip()
|
|
38
|
+
stderr_str = stderr.decode("utf-8", errors="replace").strip()
|
|
39
|
+
|
|
40
|
+
if exit_code != 0:
|
|
41
|
+
err_msg = stderr_str or f"Command failed with exit code {exit_code}"
|
|
42
|
+
raise RuntimeError(f"Command '{command}' failed (exit {exit_code}): {err_msg}")
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
"command": command,
|
|
46
|
+
"exit_code": exit_code,
|
|
47
|
+
"stdout": stdout_str,
|
|
48
|
+
"stderr": stderr_str,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@task(name="system.run_script", queue="default", max_retries=1, timeout=300.0)
|
|
53
|
+
async def run_script(script_path: str, args: list[str] | None = None) -> dict[str, Any]:
|
|
54
|
+
"""Executes a Python script file using the active python interpreter."""
|
|
55
|
+
py_exec = sys.executable or "python"
|
|
56
|
+
cmd = f'"{py_exec}" {script_path}'
|
|
57
|
+
if args:
|
|
58
|
+
cmd += " " + " ".join(args)
|
|
59
|
+
return await run_command(command=cmd)
|
taskmanager/core/job.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JobStatus(str, Enum):
|
|
12
|
+
PENDING = "pending"
|
|
13
|
+
ACTIVE = "active"
|
|
14
|
+
COMPLETED = "completed"
|
|
15
|
+
FAILED = "failed"
|
|
16
|
+
DELAYED = "delayed"
|
|
17
|
+
RETRYING = "retrying"
|
|
18
|
+
CANCELLED = "cancelled"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Job(BaseModel):
|
|
22
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
23
|
+
task_name: str
|
|
24
|
+
queue: str = "default"
|
|
25
|
+
args: list[Any] = Field(default_factory=list)
|
|
26
|
+
kwargs: dict[str, Any] = Field(default_factory=dict)
|
|
27
|
+
status: JobStatus = JobStatus.PENDING
|
|
28
|
+
priority: int = 0
|
|
29
|
+
max_retries: int = 3
|
|
30
|
+
retry_count: int = 0
|
|
31
|
+
retry_backoff: float = 2.0 # Multiplier in seconds for exponential backoff (e.g. 2s, 4s, 8s)
|
|
32
|
+
timeout: float | None = None
|
|
33
|
+
created_at: float = Field(default_factory=time.time)
|
|
34
|
+
started_at: float | None = None
|
|
35
|
+
completed_at: float | None = None
|
|
36
|
+
duration: float | None = None
|
|
37
|
+
logs: list[str] = Field(default_factory=list)
|
|
38
|
+
result: Any = None
|
|
39
|
+
error: str | None = None
|
|
40
|
+
traceback: str | None = None
|
|
41
|
+
worker_id: str | None = None
|
|
42
|
+
idempotency_key: str | None = None
|
|
43
|
+
|
|
44
|
+
def calculate_next_backoff(self) -> float:
|
|
45
|
+
"""Calculate exponential backoff in seconds for the next retry attempt."""
|
|
46
|
+
return self.retry_backoff * (2**self.retry_count)
|
|
47
|
+
|
|
48
|
+
def can_retry(self) -> bool:
|
|
49
|
+
"""Check if job has remaining retry attempts."""
|
|
50
|
+
return self.retry_count < self.max_retries
|