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.
@@ -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)
@@ -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