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/core/task.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import redis.asyncio as redis
|
|
8
|
+
|
|
9
|
+
from taskmanager.config import settings
|
|
10
|
+
from taskmanager.core.broker import RedisBroker
|
|
11
|
+
from taskmanager.core.job import Job
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TaskRegistry:
|
|
15
|
+
"""Registry holding all user-defined background task functions."""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self._tasks: dict[str, Task] = {}
|
|
19
|
+
self._default_broker: RedisBroker | None = None
|
|
20
|
+
|
|
21
|
+
def register(self, task: Task) -> None:
|
|
22
|
+
self._tasks[task.name] = task
|
|
23
|
+
|
|
24
|
+
def get(self, name: str) -> Task | None:
|
|
25
|
+
return self._tasks.get(name)
|
|
26
|
+
|
|
27
|
+
def list_tasks(self) -> list[str]:
|
|
28
|
+
return list(self._tasks.keys())
|
|
29
|
+
|
|
30
|
+
def get_broker(self) -> RedisBroker:
|
|
31
|
+
if self._default_broker is None:
|
|
32
|
+
client = redis.from_url(settings.redis_url, decode_responses=True)
|
|
33
|
+
self._default_broker = RedisBroker(client, prefix=settings.redis_prefix)
|
|
34
|
+
return self._default_broker
|
|
35
|
+
|
|
36
|
+
def set_broker(self, broker: RedisBroker) -> None:
|
|
37
|
+
self._default_broker = broker
|
|
38
|
+
|
|
39
|
+
def task(
|
|
40
|
+
self,
|
|
41
|
+
name: str | None = None,
|
|
42
|
+
queue: str = "default",
|
|
43
|
+
max_retries: int = 3,
|
|
44
|
+
retry_backoff: float = 2.0,
|
|
45
|
+
timeout: float | None = None,
|
|
46
|
+
) -> Callable[..., Task]:
|
|
47
|
+
"""Decorator to register a function directly to this registry."""
|
|
48
|
+
def decorator(func: Callable[..., Any]) -> Task:
|
|
49
|
+
t = Task(
|
|
50
|
+
func=func,
|
|
51
|
+
name=name,
|
|
52
|
+
queue=queue,
|
|
53
|
+
max_retries=max_retries,
|
|
54
|
+
retry_backoff=retry_backoff,
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
broker=self._default_broker,
|
|
57
|
+
)
|
|
58
|
+
self.register(t)
|
|
59
|
+
return t
|
|
60
|
+
|
|
61
|
+
return decorator
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
registry = TaskRegistry()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Task:
|
|
68
|
+
"""Represents a background task wrapper around a Python callable or coroutine."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
func: Callable[..., Any],
|
|
73
|
+
name: str | None = None,
|
|
74
|
+
queue: str = "default",
|
|
75
|
+
max_retries: int = 3,
|
|
76
|
+
retry_backoff: float = 2.0,
|
|
77
|
+
timeout: float | None = None,
|
|
78
|
+
broker: RedisBroker | None = None,
|
|
79
|
+
):
|
|
80
|
+
self.func = func
|
|
81
|
+
self.name = name or f"{func.__module__}.{func.__name__}"
|
|
82
|
+
self.queue = queue
|
|
83
|
+
self.max_retries = max_retries
|
|
84
|
+
self.retry_backoff = retry_backoff
|
|
85
|
+
self.timeout = timeout
|
|
86
|
+
self._broker = broker
|
|
87
|
+
self.is_async = inspect.iscoroutinefunction(func)
|
|
88
|
+
|
|
89
|
+
def get_signature_info(self) -> dict[str, Any]:
|
|
90
|
+
"""Extracts parameters, types, defaults, and generates a sample payload."""
|
|
91
|
+
try:
|
|
92
|
+
sig = inspect.signature(self.func)
|
|
93
|
+
params = []
|
|
94
|
+
sample_kwargs: dict[str, Any] = {}
|
|
95
|
+
for param in sig.parameters.values():
|
|
96
|
+
has_default = param.default is not inspect.Parameter.empty
|
|
97
|
+
default_val = param.default if has_default else None
|
|
98
|
+
ann_str = "Any" if param.annotation is inspect.Parameter.empty else str(param.annotation)
|
|
99
|
+
|
|
100
|
+
params.append(
|
|
101
|
+
{
|
|
102
|
+
"name": param.name,
|
|
103
|
+
"has_default": has_default,
|
|
104
|
+
"default": default_val,
|
|
105
|
+
"annotation": ann_str,
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Heuristics for realistic smart sample values
|
|
110
|
+
if has_default:
|
|
111
|
+
if default_val is not None:
|
|
112
|
+
sample_kwargs[param.name] = default_val
|
|
113
|
+
else:
|
|
114
|
+
ann_lower = ann_str.lower()
|
|
115
|
+
p_name = param.name.lower()
|
|
116
|
+
if "cwd" in p_name:
|
|
117
|
+
pass # Keep optional cwd omitted from sample
|
|
118
|
+
elif "int" in ann_lower or "year" in p_name or "month" in p_name or "count" in p_name:
|
|
119
|
+
sample_kwargs[param.name] = 2026 if "year" in p_name else (8 if "month" in p_name else 1)
|
|
120
|
+
elif "float" in ann_lower:
|
|
121
|
+
sample_kwargs[param.name] = 10.0
|
|
122
|
+
elif "bool" in ann_lower or "dry_run" in p_name or "force" in p_name:
|
|
123
|
+
sample_kwargs[param.name] = False
|
|
124
|
+
elif "list" in ann_lower:
|
|
125
|
+
sample_kwargs[param.name] = []
|
|
126
|
+
elif "dict" in ann_lower:
|
|
127
|
+
sample_kwargs[param.name] = {}
|
|
128
|
+
elif "email" in p_name:
|
|
129
|
+
sample_kwargs[param.name] = "cliente@exemplo.com"
|
|
130
|
+
elif "name" in p_name or "nome" in p_name:
|
|
131
|
+
sample_kwargs[param.name] = "Carlos Silva"
|
|
132
|
+
elif "order" in p_name or "id" in p_name:
|
|
133
|
+
sample_kwargs[param.name] = "PED-12345"
|
|
134
|
+
elif "command" in p_name:
|
|
135
|
+
sample_kwargs[param.name] = "python scripts/backup_database.py --compress"
|
|
136
|
+
elif "path" in p_name:
|
|
137
|
+
sample_kwargs[param.name] = "scripts/backup_database.py"
|
|
138
|
+
else:
|
|
139
|
+
sample_kwargs[param.name] = f"valor_{param.name}"
|
|
140
|
+
|
|
141
|
+
doc = inspect.getdoc(self.func) or ""
|
|
142
|
+
return {
|
|
143
|
+
"parameters": params,
|
|
144
|
+
"sample_kwargs": sample_kwargs,
|
|
145
|
+
"docstring": doc,
|
|
146
|
+
}
|
|
147
|
+
except Exception:
|
|
148
|
+
return {
|
|
149
|
+
"parameters": [],
|
|
150
|
+
"sample_kwargs": {},
|
|
151
|
+
"docstring": "",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def broker(self) -> RedisBroker:
|
|
156
|
+
return self._broker or registry.get_broker()
|
|
157
|
+
|
|
158
|
+
def set_broker(self, broker: RedisBroker) -> None:
|
|
159
|
+
self._broker = broker
|
|
160
|
+
|
|
161
|
+
async def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
162
|
+
"""Direct call execution for testing or inline execution."""
|
|
163
|
+
if self.is_async:
|
|
164
|
+
return await self.func(*args, **kwargs)
|
|
165
|
+
return self.func(*args, **kwargs)
|
|
166
|
+
|
|
167
|
+
async def delay(self, *args: Any, **kwargs: Any) -> Job:
|
|
168
|
+
"""Enqueues the task immediately into the default queue."""
|
|
169
|
+
return await self.apply_async(args=list(args), kwargs=kwargs)
|
|
170
|
+
|
|
171
|
+
async def apply_async(
|
|
172
|
+
self,
|
|
173
|
+
args: list[Any] | None = None,
|
|
174
|
+
kwargs: dict[str, Any] | None = None,
|
|
175
|
+
queue: str | None = None,
|
|
176
|
+
delay: float | None = None,
|
|
177
|
+
priority: int = 0,
|
|
178
|
+
max_retries: int | None = None,
|
|
179
|
+
retry_backoff: float | None = None,
|
|
180
|
+
timeout: float | None = None,
|
|
181
|
+
idempotency_key: str | None = None,
|
|
182
|
+
) -> Job:
|
|
183
|
+
"""Enqueues the task with custom execution parameters."""
|
|
184
|
+
job = Job(
|
|
185
|
+
task_name=self.name,
|
|
186
|
+
queue=queue or self.queue,
|
|
187
|
+
args=args or [],
|
|
188
|
+
kwargs=kwargs or {},
|
|
189
|
+
priority=priority,
|
|
190
|
+
max_retries=self.max_retries if max_retries is None else max_retries,
|
|
191
|
+
retry_backoff=self.retry_backoff if retry_backoff is None else retry_backoff,
|
|
192
|
+
timeout=self.timeout if timeout is None else timeout,
|
|
193
|
+
idempotency_key=idempotency_key,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if delay and delay > 0:
|
|
197
|
+
return await self.broker.schedule_delayed(job, delay_seconds=delay)
|
|
198
|
+
return await self.broker.enqueue(job)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def task(
|
|
202
|
+
name: str | None = None,
|
|
203
|
+
queue: str = "default",
|
|
204
|
+
max_retries: int = 3,
|
|
205
|
+
retry_backoff: float = 2.0,
|
|
206
|
+
timeout: float | None = None,
|
|
207
|
+
broker: RedisBroker | None = None,
|
|
208
|
+
) -> Callable[[Callable[..., Any]], Task]:
|
|
209
|
+
"""Decorator to register a function as a TaskManager background task."""
|
|
210
|
+
|
|
211
|
+
def decorator(fn: Callable[..., Any]) -> Task:
|
|
212
|
+
t = Task(
|
|
213
|
+
func=fn,
|
|
214
|
+
name=name or f"{fn.__module__}.{fn.__name__}",
|
|
215
|
+
queue=queue,
|
|
216
|
+
max_retries=max_retries,
|
|
217
|
+
retry_backoff=retry_backoff,
|
|
218
|
+
timeout=timeout,
|
|
219
|
+
broker=broker,
|
|
220
|
+
)
|
|
221
|
+
registry.register(t)
|
|
222
|
+
return t
|
|
223
|
+
|
|
224
|
+
return decorator
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from croniter import croniter
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ScheduleType(str, Enum):
|
|
14
|
+
CRON = "cron"
|
|
15
|
+
INTERVAL = "interval"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Schedule(BaseModel):
|
|
19
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
20
|
+
name: str
|
|
21
|
+
task_name: str
|
|
22
|
+
queue: str = "default"
|
|
23
|
+
schedule_type: ScheduleType = ScheduleType.CRON
|
|
24
|
+
cron_expression: str | None = None # e.g. "*/5 * * * *"
|
|
25
|
+
interval_seconds: float | None = None
|
|
26
|
+
args: list[Any] = Field(default_factory=list)
|
|
27
|
+
kwargs: dict[str, Any] = Field(default_factory=dict)
|
|
28
|
+
enabled: bool = True
|
|
29
|
+
last_run: float | None = None
|
|
30
|
+
next_run: float | None = None
|
|
31
|
+
total_runs: int = 0
|
|
32
|
+
created_at: float = Field(default_factory=time.time)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def calculate_next_run(schedule: Schedule, from_timestamp: float | None = None) -> float:
|
|
36
|
+
"""Calculates the next unix timestamp for a given Schedule."""
|
|
37
|
+
base_time = from_timestamp if from_timestamp is not None else time.time()
|
|
38
|
+
|
|
39
|
+
if schedule.schedule_type == ScheduleType.CRON:
|
|
40
|
+
if not schedule.cron_expression or not croniter.is_valid(schedule.cron_expression):
|
|
41
|
+
raise ValueError(f"Invalid cron expression: {schedule.cron_expression}")
|
|
42
|
+
base_dt = datetime.fromtimestamp(base_time, tz=UTC).astimezone()
|
|
43
|
+
iter_cron = croniter(schedule.cron_expression, base_dt)
|
|
44
|
+
next_dt = iter_cron.get_next(datetime)
|
|
45
|
+
return next_dt.timestamp()
|
|
46
|
+
|
|
47
|
+
elif schedule.schedule_type == ScheduleType.INTERVAL:
|
|
48
|
+
if not schedule.interval_seconds or schedule.interval_seconds <= 0:
|
|
49
|
+
raise ValueError(f"Invalid interval seconds: {schedule.interval_seconds}")
|
|
50
|
+
return base_time + schedule.interval_seconds
|
|
51
|
+
|
|
52
|
+
raise ValueError(f"Unknown schedule type: {schedule.schedule_type}")
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from taskmanager.config import settings
|
|
8
|
+
from taskmanager.core.broker import RedisBroker
|
|
9
|
+
from taskmanager.core.job import Job
|
|
10
|
+
from taskmanager.scheduler.cron import Schedule, calculate_next_run
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Scheduler:
|
|
16
|
+
"""Dynamic Cron and Interval scheduler utilizing Redis for persistence and distributed locking."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, broker: RedisBroker):
|
|
19
|
+
self.broker = broker
|
|
20
|
+
self._running = False
|
|
21
|
+
self._task: asyncio.Task[None] | None = None
|
|
22
|
+
self._lock_id = f"scheduler-{time.time()}"
|
|
23
|
+
|
|
24
|
+
# --- Schedule CRUD Operations ---
|
|
25
|
+
async def add_schedule(self, schedule: Schedule) -> Schedule:
|
|
26
|
+
"""Registers a new schedule and computes initial next_run."""
|
|
27
|
+
if schedule.next_run is None:
|
|
28
|
+
schedule.next_run = calculate_next_run(schedule)
|
|
29
|
+
key = self.broker._key_schedules()
|
|
30
|
+
await self.broker.redis.hset(key, schedule.id, schedule.model_dump_json())
|
|
31
|
+
await self.broker.publish_event("schedule:created", schedule.model_dump())
|
|
32
|
+
return schedule
|
|
33
|
+
|
|
34
|
+
async def get_schedule(self, schedule_id: str) -> Schedule | None:
|
|
35
|
+
"""Fetches a schedule by ID."""
|
|
36
|
+
key = self.broker._key_schedules()
|
|
37
|
+
raw = await self.broker.redis.hget(key, schedule_id)
|
|
38
|
+
if not raw:
|
|
39
|
+
return None
|
|
40
|
+
return Schedule.model_validate_json(raw)
|
|
41
|
+
|
|
42
|
+
async def list_schedules(self) -> list[Schedule]:
|
|
43
|
+
"""Lists all registered schedules."""
|
|
44
|
+
key = self.broker._key_schedules()
|
|
45
|
+
all_raw = await self.broker.redis.hgetall(key)
|
|
46
|
+
schedules: list[Schedule] = []
|
|
47
|
+
for raw in all_raw.values():
|
|
48
|
+
try:
|
|
49
|
+
schedules.append(Schedule.model_validate_json(raw))
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
return sorted(schedules, key=lambda s: s.created_at)
|
|
53
|
+
|
|
54
|
+
async def update_schedule(self, schedule: Schedule) -> Schedule:
|
|
55
|
+
"""Updates an existing schedule."""
|
|
56
|
+
if schedule.enabled:
|
|
57
|
+
schedule.next_run = calculate_next_run(schedule)
|
|
58
|
+
key = self.broker._key_schedules()
|
|
59
|
+
await self.broker.redis.hset(key, schedule.id, schedule.model_dump_json())
|
|
60
|
+
await self.broker.publish_event("schedule:updated", schedule.model_dump())
|
|
61
|
+
return schedule
|
|
62
|
+
|
|
63
|
+
async def delete_schedule(self, schedule_id: str) -> bool:
|
|
64
|
+
"""Deletes a schedule."""
|
|
65
|
+
key = self.broker._key_schedules()
|
|
66
|
+
res = await self.broker.redis.hdel(key, schedule_id)
|
|
67
|
+
if res > 0:
|
|
68
|
+
await self.broker.publish_event("schedule:deleted", {"schedule_id": schedule_id})
|
|
69
|
+
return True
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
async def toggle_schedule(self, schedule_id: str, enabled: bool) -> Schedule | None:
|
|
73
|
+
"""Enables or disables a schedule."""
|
|
74
|
+
schedule = await self.get_schedule(schedule_id)
|
|
75
|
+
if not schedule:
|
|
76
|
+
return None
|
|
77
|
+
schedule.enabled = enabled
|
|
78
|
+
if enabled:
|
|
79
|
+
schedule.next_run = calculate_next_run(schedule)
|
|
80
|
+
else:
|
|
81
|
+
schedule.next_run = None
|
|
82
|
+
return await self.update_schedule(schedule)
|
|
83
|
+
|
|
84
|
+
async def trigger_now(self, schedule_id: str) -> Job | None:
|
|
85
|
+
"""Manually triggers a schedule immediately."""
|
|
86
|
+
schedule = await self.get_schedule(schedule_id)
|
|
87
|
+
if not schedule:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
job = Job(
|
|
91
|
+
task_name=schedule.task_name,
|
|
92
|
+
queue=schedule.queue,
|
|
93
|
+
args=schedule.args,
|
|
94
|
+
kwargs=schedule.kwargs,
|
|
95
|
+
)
|
|
96
|
+
enqueued_job = await self.broker.enqueue(job)
|
|
97
|
+
|
|
98
|
+
schedule.last_run = time.time()
|
|
99
|
+
schedule.total_runs += 1
|
|
100
|
+
key = self.broker._key_schedules()
|
|
101
|
+
await self.broker.redis.hset(key, schedule.id, schedule.model_dump_json())
|
|
102
|
+
await self.broker.publish_event(
|
|
103
|
+
"schedule:triggered",
|
|
104
|
+
{
|
|
105
|
+
"schedule_id": schedule.id,
|
|
106
|
+
"job_id": enqueued_job.id,
|
|
107
|
+
"manual": True,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
return enqueued_job
|
|
111
|
+
|
|
112
|
+
# --- Scheduler Daemon Loop ---
|
|
113
|
+
async def start(self) -> None:
|
|
114
|
+
"""Starts the scheduler polling loop."""
|
|
115
|
+
self._running = True
|
|
116
|
+
logger.info("Scheduler daemon started.")
|
|
117
|
+
try:
|
|
118
|
+
while self._running:
|
|
119
|
+
await self._tick()
|
|
120
|
+
await asyncio.sleep(settings.scheduler_poll_interval)
|
|
121
|
+
except asyncio.CancelledError:
|
|
122
|
+
pass
|
|
123
|
+
finally:
|
|
124
|
+
self._running = False
|
|
125
|
+
logger.info("Scheduler daemon stopped.")
|
|
126
|
+
|
|
127
|
+
async def stop(self) -> None:
|
|
128
|
+
self._running = False
|
|
129
|
+
|
|
130
|
+
async def _tick(self) -> None:
|
|
131
|
+
"""Evaluates schedules and enqueues due jobs using distributed locking."""
|
|
132
|
+
lock_key = self.broker._key_lock("scheduler")
|
|
133
|
+
# Attempt to acquire leader lock with 3-second TTL
|
|
134
|
+
acquired = await self.broker.redis.set(lock_key, self._lock_id, nx=True, ex=3)
|
|
135
|
+
if not acquired:
|
|
136
|
+
# Check if this instance already holds the lock
|
|
137
|
+
current_holder = await self.broker.redis.get(lock_key)
|
|
138
|
+
if current_holder != self._lock_id:
|
|
139
|
+
return # Another scheduler instance is the active leader
|
|
140
|
+
# Refresh lock
|
|
141
|
+
await self.broker.redis.expire(lock_key, 3)
|
|
142
|
+
|
|
143
|
+
now = time.time()
|
|
144
|
+
schedules = await self.list_schedules()
|
|
145
|
+
|
|
146
|
+
for schedule in schedules:
|
|
147
|
+
if not schedule.enabled or schedule.next_run is None:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
if now >= schedule.next_run:
|
|
151
|
+
# Enqueue the job
|
|
152
|
+
job = Job(
|
|
153
|
+
task_name=schedule.task_name,
|
|
154
|
+
queue=schedule.queue,
|
|
155
|
+
args=schedule.args,
|
|
156
|
+
kwargs=schedule.kwargs,
|
|
157
|
+
)
|
|
158
|
+
await self.broker.enqueue(job)
|
|
159
|
+
|
|
160
|
+
# Advance next run
|
|
161
|
+
schedule.last_run = now
|
|
162
|
+
schedule.total_runs += 1
|
|
163
|
+
try:
|
|
164
|
+
schedule.next_run = calculate_next_run(schedule, from_timestamp=now)
|
|
165
|
+
except Exception as err:
|
|
166
|
+
logger.error(f"Error calculating next run for schedule {schedule.id}: {err}")
|
|
167
|
+
schedule.next_run = now + 60
|
|
168
|
+
|
|
169
|
+
key = self.broker._key_schedules()
|
|
170
|
+
await self.broker.redis.hset(key, schedule.id, schedule.model_dump_json())
|
|
171
|
+
await self.broker.publish_event(
|
|
172
|
+
"schedule:triggered",
|
|
173
|
+
{
|
|
174
|
+
"schedule_id": schedule.id,
|
|
175
|
+
"job_id": job.id,
|
|
176
|
+
"manual": False,
|
|
177
|
+
},
|
|
178
|
+
)
|