broccoli-workers 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.
- broccoli/__init__.py +0 -0
- broccoli/cli.py +637 -0
- broccoli/core/__init__.py +15 -0
- broccoli/core/chain/chain.py +45 -0
- broccoli/core/chain/chain_mixin.py +49 -0
- broccoli/core/chain/chain_queue.py +299 -0
- broccoli/core/chain/task_chain.py +201 -0
- broccoli/core/health.py +20 -0
- broccoli/core/redis_controller.py +35 -0
- broccoli/core/result.py +78 -0
- broccoli/core/task/task.py +58 -0
- broccoli/core/task/task_queue.py +300 -0
- broccoli/core/task/task_registry.py +47 -0
- broccoli/workers/__init__.py +15 -0
- broccoli/workers/async_worker.py +157 -0
- broccoli/workers/base_worker.py +339 -0
- broccoli/workers/chain_worker.py +94 -0
- broccoli/workers/hybrid_worker.py +269 -0
- broccoli/workers/threaded_worker.py +154 -0
- broccoli/workers/worker_pool.py +99 -0
- broccoli_workers-0.1.0.dist-info/METADATA +594 -0
- broccoli_workers-0.1.0.dist-info/RECORD +26 -0
- broccoli_workers-0.1.0.dist-info/WHEEL +5 -0
- broccoli_workers-0.1.0.dist-info/entry_points.txt +2 -0
- broccoli_workers-0.1.0.dist-info/licenses/LICENSE +21 -0
- broccoli_workers-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# video_scheduler/core/chain_mixin.py
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from broccoli.core.task.task import Task
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ChainWorkerMixin:
|
|
8
|
+
"""Mixin to add chain support to a worker."""
|
|
9
|
+
|
|
10
|
+
def post_process(self, task: Task, success: bool) -> None:
|
|
11
|
+
"""Override this in your worker and call super().post_process()."""
|
|
12
|
+
chain_id = task.payload.get("__chain_id")
|
|
13
|
+
|
|
14
|
+
if chain_id:
|
|
15
|
+
# Delete the chain-prefixed task key now that it's done
|
|
16
|
+
self._redis.delete(f"chain:{task.task_id}")
|
|
17
|
+
|
|
18
|
+
# Completion tasks have __chain_id set (so they get cleaned up above)
|
|
19
|
+
# but must not call continue_chain — they are not sequential chain steps
|
|
20
|
+
is_completion_task = task.payload.get("__chain_position") is None
|
|
21
|
+
if is_completion_task:
|
|
22
|
+
# Run completion handlers for the chain
|
|
23
|
+
if success:
|
|
24
|
+
self._run_completion_handlers(task, task.result)
|
|
25
|
+
else:
|
|
26
|
+
self._run_failure_handlers(
|
|
27
|
+
task,
|
|
28
|
+
Exception(task.error)
|
|
29
|
+
if task.error
|
|
30
|
+
else Exception("Chain task failed"),
|
|
31
|
+
)
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
if success:
|
|
35
|
+
from broccoli.core.chain.task_chain import TaskChain
|
|
36
|
+
|
|
37
|
+
chain = TaskChain()
|
|
38
|
+
finished = chain.continue_chain(
|
|
39
|
+
task, task.result, push_completion_task=True
|
|
40
|
+
)
|
|
41
|
+
if finished:
|
|
42
|
+
self.on_finish(chain_id, task.result)
|
|
43
|
+
|
|
44
|
+
# Run completion handlers for chain steps
|
|
45
|
+
self._run_completion_handlers(task, task.result)
|
|
46
|
+
|
|
47
|
+
def on_finish(self, chain_id: str, final_result: Any) -> None:
|
|
48
|
+
"""Hook called when the entire chain is finished. Override in your worker."""
|
|
49
|
+
pass # Your existing on_finish logic here
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# broccoli/core/chain/chain_queue.py
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from broccoli.core.redis_controller import RedisController
|
|
7
|
+
from broccoli.core.task.task import Task
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# Max tasks per priority tier before FIFO scores would collide.
|
|
12
|
+
# 10^12 tasks per tier is effectively unlimited in practice.
|
|
13
|
+
_FIFO_TIER_SIZE = 1_000_000_000_000
|
|
14
|
+
|
|
15
|
+
# Redis field used to carry a task's original priority through the waiting state
|
|
16
|
+
# so it can be restored when the dependency is released.
|
|
17
|
+
_PRIORITY_FIELD = "_broccoli_priority"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ChainQueue:
|
|
21
|
+
"""
|
|
22
|
+
Priority queue backed by three Redis structures:
|
|
23
|
+
|
|
24
|
+
``<base>:queue`` — sorted set of runnable task IDs
|
|
25
|
+
score = priority_tier * _FIFO_TIER_SIZE + monotonic_seq
|
|
26
|
+
``<base>:processing`` — sorted set of in-flight task IDs
|
|
27
|
+
score = Unix timestamp of when the task was popped
|
|
28
|
+
(used for crash recovery, not for ordering)
|
|
29
|
+
``<base>:sequence`` — monotonic counter for FIFO ordering within a priority tier
|
|
30
|
+
``dependency:<id>`` — set of task IDs waiting for task <id> to complete
|
|
31
|
+
|
|
32
|
+
Dependency resolution happens at *push time* and *completion time*, never
|
|
33
|
+
inside ``pop()``. Workers only ever see runnable tasks.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
redis_url: str = "redis://localhost:6379",
|
|
39
|
+
queue_name="chain_tasks:queue",
|
|
40
|
+
task_prefix="chain",
|
|
41
|
+
):
|
|
42
|
+
self.redis_url = redis_url
|
|
43
|
+
self._redis = RedisController(redis_url).get_client()
|
|
44
|
+
self.queue_key = queue_name
|
|
45
|
+
self.task_prefix = task_prefix
|
|
46
|
+
|
|
47
|
+
# Derive sibling keys from queue_name so all keys share a namespace.
|
|
48
|
+
# e.g. "tasks:queue" → "tasks:processing", "tasks:sequence"
|
|
49
|
+
base = (
|
|
50
|
+
queue_name[: -len(":queue")]
|
|
51
|
+
if queue_name.endswith(":queue")
|
|
52
|
+
else queue_name
|
|
53
|
+
)
|
|
54
|
+
self.processing_key = f"{base}:processing"
|
|
55
|
+
self.sequence_key = f"{base}:sequence"
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
# Public API
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def push(self, task: Task, priority: int = 0) -> str:
|
|
62
|
+
"""
|
|
63
|
+
Persist task metadata then either enqueue it or register it as waiting.
|
|
64
|
+
|
|
65
|
+
If the task has no dependency, or its dependency is already completed,
|
|
66
|
+
it goes straight into the runnable queue. Otherwise it is stored as
|
|
67
|
+
``waiting`` and added to ``dependency:<parent_id>`` so ``complete()``
|
|
68
|
+
can release it later.
|
|
69
|
+
|
|
70
|
+
Race condition handled with a register-then-verify pattern:
|
|
71
|
+
1. Register in the dependency set first.
|
|
72
|
+
2. Re-check the parent's status.
|
|
73
|
+
3. If parent is already completed (raced past us), release immediately.
|
|
74
|
+
This eliminates the TOCTOU window present in a check-then-register approach.
|
|
75
|
+
"""
|
|
76
|
+
# Always persist task metadata first so workers can read it after pop().
|
|
77
|
+
self._redis.hset(f"{self.task_prefix}:{task.task_id}", mapping=task.to_dict())
|
|
78
|
+
|
|
79
|
+
if task.depends_on:
|
|
80
|
+
# Mark as waiting immediately and register in the dependency set.
|
|
81
|
+
# We do this BEFORE reading the parent's status to close the
|
|
82
|
+
# check-then-register race window.
|
|
83
|
+
task.status = "waiting"
|
|
84
|
+
pipe = self._redis.pipeline()
|
|
85
|
+
pipe.hset(f"{self.task_prefix}:{task.task_id}", "status", "waiting")
|
|
86
|
+
# Store the requested priority so complete() can restore it.
|
|
87
|
+
pipe.hset(
|
|
88
|
+
f"{self.task_prefix}:{task.task_id}", _PRIORITY_FIELD, str(priority)
|
|
89
|
+
)
|
|
90
|
+
pipe.sadd(f"dependency:{task.depends_on}", task.task_id)
|
|
91
|
+
pipe.execute()
|
|
92
|
+
|
|
93
|
+
# Now verify: if the parent already completed before our sadd, release now.
|
|
94
|
+
dep_status = self._redis.hget(
|
|
95
|
+
f"{self.task_prefix}:{task.depends_on}", "status"
|
|
96
|
+
)
|
|
97
|
+
parent_done = (
|
|
98
|
+
dep_status
|
|
99
|
+
and (
|
|
100
|
+
dep_status.decode() if isinstance(dep_status, bytes) else dep_status
|
|
101
|
+
)
|
|
102
|
+
== "completed"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if parent_done:
|
|
106
|
+
# Parent completed before (or concurrently with) our registration.
|
|
107
|
+
# Release ourselves immediately and clean up.
|
|
108
|
+
self._redis.srem(f"dependency:{task.depends_on}", task.task_id)
|
|
109
|
+
self._enqueue(task.task_id, priority)
|
|
110
|
+
logger.debug(
|
|
111
|
+
f"Task {task.task_id}: dependency {task.depends_on} already "
|
|
112
|
+
"completed, enqueued immediately"
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
logger.debug(
|
|
116
|
+
f"Task {task.task_id} waiting on dependency {task.depends_on}"
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
self._enqueue(task.task_id, priority)
|
|
120
|
+
|
|
121
|
+
return task.task_id
|
|
122
|
+
|
|
123
|
+
def pop(self) -> Optional[Task]:
|
|
124
|
+
"""
|
|
125
|
+
Pop the highest-priority runnable task and move it to the processing set.
|
|
126
|
+
|
|
127
|
+
No dependency checking happens here — that was handled at push / complete
|
|
128
|
+
time. Everything in the queue is ready to run.
|
|
129
|
+
|
|
130
|
+
Returns ``None`` if the queue is empty after the blocking timeout.
|
|
131
|
+
"""
|
|
132
|
+
result = self._redis.bzpopmin(self.queue_key, timeout=1)
|
|
133
|
+
if result is None:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
_, task_id, _fifo_score = result
|
|
137
|
+
task_id = task_id.decode() if isinstance(task_id, bytes) else task_id
|
|
138
|
+
|
|
139
|
+
task_data = self._redis.hgetall(f"{self.task_prefix}:{task_id}")
|
|
140
|
+
if not task_data:
|
|
141
|
+
logger.error(
|
|
142
|
+
f"Task data missing for {task_id!r}; moving to dead-letter set"
|
|
143
|
+
)
|
|
144
|
+
self._redis.sadd(f"{self.task_prefix}:dead_letter", task_id)
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
# Score in the processing set is a Unix timestamp so recover_stalled()
|
|
148
|
+
# can identify tasks that have been in-flight longer than expected.
|
|
149
|
+
now = time.time()
|
|
150
|
+
self._redis.zadd(self.processing_key, {task_id: now})
|
|
151
|
+
self._redis.hset(f"{self.task_prefix}:{task_id}", "status", "in_progress")
|
|
152
|
+
|
|
153
|
+
task = Task.from_dict(task_data)
|
|
154
|
+
task.status = "in_progress"
|
|
155
|
+
return task
|
|
156
|
+
|
|
157
|
+
def complete(self, task: Task) -> None:
|
|
158
|
+
"""
|
|
159
|
+
Called after a task finishes successfully.
|
|
160
|
+
|
|
161
|
+
1. Removes the task from the processing set.
|
|
162
|
+
2. Reads any tasks that were waiting on this one and enqueues them,
|
|
163
|
+
restoring their original priority.
|
|
164
|
+
3. Deletes the dependency set.
|
|
165
|
+
"""
|
|
166
|
+
task_id = task.task_id
|
|
167
|
+
self._redis.zrem(self.processing_key, task_id)
|
|
168
|
+
|
|
169
|
+
dep_key = f"dependency:{task_id}"
|
|
170
|
+
waiting_ids = self._redis.smembers(dep_key)
|
|
171
|
+
if waiting_ids:
|
|
172
|
+
for raw_id in waiting_ids:
|
|
173
|
+
wid = raw_id.decode() if isinstance(raw_id, bytes) else raw_id
|
|
174
|
+
# Restore the priority the caller supplied when pushing the waiting task.
|
|
175
|
+
raw_pri = self._redis.hget(f"{self.task_prefix}:{wid}", _PRIORITY_FIELD)
|
|
176
|
+
priority = int(raw_pri) if raw_pri else 0
|
|
177
|
+
self._enqueue(wid, priority)
|
|
178
|
+
logger.info(
|
|
179
|
+
f"Released waiting task {wid} "
|
|
180
|
+
f"(dependency {task_id} completed, priority={priority})"
|
|
181
|
+
)
|
|
182
|
+
self._redis.delete(dep_key)
|
|
183
|
+
|
|
184
|
+
def fail(self, task: Task) -> None:
|
|
185
|
+
"""Remove a permanently failed (or skipped) task from the processing set."""
|
|
186
|
+
self._redis.zrem(self.processing_key, task.task_id)
|
|
187
|
+
|
|
188
|
+
def requeue(self, task_id: str, priority: Optional[int] = None) -> None:
|
|
189
|
+
"""
|
|
190
|
+
Move a task from the processing set back to the runnable queue (retry).
|
|
191
|
+
|
|
192
|
+
A fresh FIFO sequence number is issued so the retry goes to the back of
|
|
193
|
+
its priority tier rather than jumping the queue.
|
|
194
|
+
|
|
195
|
+
If ``priority`` isn't given explicitly, the task's originally-requested
|
|
196
|
+
priority (stashed under ``_PRIORITY_FIELD`` at push time) is restored,
|
|
197
|
+
matching the behaviour of ``complete()`` when it releases dependents.
|
|
198
|
+
Without this, every retry would silently reset to priority 0 and could
|
|
199
|
+
jump ahead of lower-priority tasks still waiting in the queue.
|
|
200
|
+
"""
|
|
201
|
+
self._redis.zrem(self.processing_key, task_id)
|
|
202
|
+
if priority is None:
|
|
203
|
+
raw_pri = self._redis.hget(f"{self.task_prefix}:{task_id}", _PRIORITY_FIELD)
|
|
204
|
+
priority = int(raw_pri) if raw_pri else 0
|
|
205
|
+
self._enqueue(task_id, priority)
|
|
206
|
+
|
|
207
|
+
def recover_stalled(self, timeout_seconds: int = 3600) -> int:
|
|
208
|
+
"""
|
|
209
|
+
Crash-recovery helper: re-enqueue tasks that have been in the processing
|
|
210
|
+
set for longer than ``timeout_seconds``.
|
|
211
|
+
|
|
212
|
+
The processing set uses Unix timestamps as scores (set by ``pop()``),
|
|
213
|
+
so the threshold is simply ``time.time() - timeout_seconds``.
|
|
214
|
+
|
|
215
|
+
Returns the number of tasks recovered.
|
|
216
|
+
"""
|
|
217
|
+
threshold = time.time() - timeout_seconds
|
|
218
|
+
stalled = self._redis.zrangebyscore(self.processing_key, "-inf", threshold)
|
|
219
|
+
for raw_id in stalled:
|
|
220
|
+
tid = raw_id.decode() if isinstance(raw_id, bytes) else raw_id
|
|
221
|
+
self.requeue(tid)
|
|
222
|
+
logger.warning(f"Crash-recovery: re-enqueued stalled task {tid}")
|
|
223
|
+
return len(stalled)
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
# Convenience helpers
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def get_task(self, task_id: str) -> Optional[Task]:
|
|
230
|
+
"""Fetch task metadata by ID, or ``None`` if it doesn't exist."""
|
|
231
|
+
task_data = self._redis.hgetall(f"{self.task_prefix}:{task_id}")
|
|
232
|
+
if not task_data:
|
|
233
|
+
return None
|
|
234
|
+
return Task.from_dict(task_data)
|
|
235
|
+
|
|
236
|
+
def get_queue_name(self) -> str:
|
|
237
|
+
return self.queue_key
|
|
238
|
+
|
|
239
|
+
def is_empty(self) -> bool:
|
|
240
|
+
return self._redis.zcard(self.queue_key) == 0
|
|
241
|
+
|
|
242
|
+
def pop_with_timeout(self, timeout: int = 1) -> Optional[Task]:
|
|
243
|
+
"""Alias for ``pop()``; timeout is already handled internally."""
|
|
244
|
+
return self.pop()
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
# Private helpers
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def _enqueue(self, task_id: str, priority: int = 0) -> None:
|
|
251
|
+
"""
|
|
252
|
+
Add *task_id* to the runnable sorted set.
|
|
253
|
+
|
|
254
|
+
Score = priority * _FIFO_TIER_SIZE + monotonic_seq
|
|
255
|
+
|
|
256
|
+
This gives strict priority ordering across tiers *and* FIFO within a
|
|
257
|
+
tier, without requiring timestamps or Lua scripts.
|
|
258
|
+
"""
|
|
259
|
+
seq = self._redis.incr(self.sequence_key)
|
|
260
|
+
score = priority * _FIFO_TIER_SIZE + seq
|
|
261
|
+
self._redis.zadd(self.queue_key, {task_id: score})
|
|
262
|
+
self._redis.hset(
|
|
263
|
+
f"{self.task_prefix}:{task_id}",
|
|
264
|
+
mapping={"status": "pending", _PRIORITY_FIELD: str(priority)},
|
|
265
|
+
)
|
|
266
|
+
logger.debug(f"Enqueued task {task_id} (priority={priority}, seq={seq})")
|
|
267
|
+
|
|
268
|
+
def is_fully_drained(self) -> bool:
|
|
269
|
+
"""True only when both the runnable queue and processing set are empty."""
|
|
270
|
+
pipe = self._redis.pipeline()
|
|
271
|
+
pipe.zcard(self.queue_key)
|
|
272
|
+
pipe.zcard(self.processing_key)
|
|
273
|
+
runnable, processing = pipe.execute()
|
|
274
|
+
return runnable == 0 and processing == 0
|
|
275
|
+
|
|
276
|
+
def get_waiting_for(self, task_id: str) -> list:
|
|
277
|
+
"""Return the IDs of tasks currently blocked on ``task_id``."""
|
|
278
|
+
return [
|
|
279
|
+
tid.decode() if isinstance(tid, bytes) else tid
|
|
280
|
+
for tid in self._redis.smembers(f"dependency:{task_id}")
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
def stats(self) -> dict:
|
|
284
|
+
"""
|
|
285
|
+
Snapshot of queue depth for monitoring/diagnostics.
|
|
286
|
+
|
|
287
|
+
``dead_letter`` reflects tasks whose hash went missing on pop() —
|
|
288
|
+
see ``pop()`` for how they get there.
|
|
289
|
+
"""
|
|
290
|
+
pipe = self._redis.pipeline()
|
|
291
|
+
pipe.zcard(self.queue_key)
|
|
292
|
+
pipe.zcard(self.processing_key)
|
|
293
|
+
pipe.scard(f"{self.task_prefix}:dead_letter")
|
|
294
|
+
runnable, processing, dead_letter = pipe.execute()
|
|
295
|
+
return {
|
|
296
|
+
"runnable": runnable,
|
|
297
|
+
"processing": processing,
|
|
298
|
+
"dead_letter": dead_letter,
|
|
299
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# video_scheduler/core/task_chain.py
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
from xmlrpc.client import Boolean
|
|
7
|
+
|
|
8
|
+
from broccoli.core.chain.chain import Chain
|
|
9
|
+
from broccoli.core.redis_controller import RedisController
|
|
10
|
+
from broccoli.core.task.task import Task
|
|
11
|
+
from broccoli.core.task.task_queue import TaskQueue
|
|
12
|
+
from broccoli.core.task.task_registry import TaskRegistry
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TaskChain:
|
|
18
|
+
"""Chain multiple tasks together where each task passes its result to the next."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, redis_url: str = "redis://localhost:6379", chain_id: str = None):
|
|
21
|
+
self.queue = TaskQueue(
|
|
22
|
+
queue_name="chain_tasks:queue", redis_url=redis_url, task_prefix="chain"
|
|
23
|
+
)
|
|
24
|
+
self._redis = RedisController(redis_url).get_client()
|
|
25
|
+
self.registry = TaskRegistry()
|
|
26
|
+
self.chain_id = chain_id or str(uuid.uuid4())
|
|
27
|
+
|
|
28
|
+
def chain(
|
|
29
|
+
self,
|
|
30
|
+
tasks: List[Dict[str, Any]],
|
|
31
|
+
shared_payload: Dict[str, Any] = None,
|
|
32
|
+
completion_task: str = None,
|
|
33
|
+
) -> str:
|
|
34
|
+
"""
|
|
35
|
+
Chain tasks together.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
tasks: List of task configurations, each with 'task_type' and 'payload'
|
|
39
|
+
shared_payload: Data passed to all tasks in the chain
|
|
40
|
+
completion_task: Optional task to run after the chain completes
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
chain_id: ID for tracking the entire chain
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
chain = TaskChain()
|
|
47
|
+
chain.chain([
|
|
48
|
+
{"task_type": "download_file", "payload": {"url": "https://..."}},
|
|
49
|
+
{"task_type": "process_video", "payload": {"quality": "high"}},
|
|
50
|
+
{"task_type": "upload_file", "payload": {"bucket": "my-bucket"}}
|
|
51
|
+
], completion_task="on_chain_finished")
|
|
52
|
+
"""
|
|
53
|
+
if not tasks:
|
|
54
|
+
raise ValueError("Cannot chain empty task list")
|
|
55
|
+
|
|
56
|
+
# Store chain metadata
|
|
57
|
+
chain = Chain(
|
|
58
|
+
chain_id=self.chain_id,
|
|
59
|
+
total_tasks=len(tasks),
|
|
60
|
+
completed_tasks=0,
|
|
61
|
+
failed=False,
|
|
62
|
+
status="pending",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if completion_task:
|
|
66
|
+
chain.completion_task = completion_task
|
|
67
|
+
|
|
68
|
+
# Save chain metadata to Redis
|
|
69
|
+
self._redis.hset(
|
|
70
|
+
f"chain:{self.chain_id}",
|
|
71
|
+
mapping={k: str(v) for k, v in chain.to_dict().items()},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Assign task_ids to ALL tasks first, before creating any Task objects.
|
|
75
|
+
# This ensures the IDs stored in Redis match the Task objects pushed to the queue.
|
|
76
|
+
for t in tasks:
|
|
77
|
+
if "task_id" not in t:
|
|
78
|
+
t["task_id"] = str(uuid.uuid4())
|
|
79
|
+
|
|
80
|
+
# Store all task configs for reference
|
|
81
|
+
self._redis.set(
|
|
82
|
+
ex=86400, # 24 hours TTL
|
|
83
|
+
name=f"chain:{self.chain_id}:tasks",
|
|
84
|
+
value=json.dumps(tasks),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Create and push first task using the now-stable task_id
|
|
88
|
+
first_task = tasks[0]
|
|
89
|
+
task = Task(
|
|
90
|
+
task_type=first_task["task_type"],
|
|
91
|
+
task_id=first_task["task_id"],
|
|
92
|
+
payload={
|
|
93
|
+
**first_task.get("payload", {}),
|
|
94
|
+
**(shared_payload or {}),
|
|
95
|
+
"__chain_id": self.chain_id,
|
|
96
|
+
"__chain_position": 0,
|
|
97
|
+
"__total_tasks": len(tasks),
|
|
98
|
+
"__is_first": True,
|
|
99
|
+
},
|
|
100
|
+
max_retries=first_task.get("max_retries", 3),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Push first task
|
|
104
|
+
self.queue.push(task)
|
|
105
|
+
logger.info(f"Chain {self.chain_id} started with task {task.task_id}")
|
|
106
|
+
|
|
107
|
+
return self.chain_id
|
|
108
|
+
|
|
109
|
+
def continue_chain(
|
|
110
|
+
self,
|
|
111
|
+
previous_task: Task,
|
|
112
|
+
previous_result: Any,
|
|
113
|
+
push_completion_task: bool = True,
|
|
114
|
+
) -> Boolean | None:
|
|
115
|
+
"""
|
|
116
|
+
Continue the chain after a task completes.
|
|
117
|
+
Called by the worker's post_process hook.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
push_completion_task: If True, enqueue the completion task when the chain
|
|
121
|
+
finishes. Set to False when the caller (e.g. ChainWorkerMixin.on_finish)
|
|
122
|
+
handles chain completion directly, to avoid running it twice.
|
|
123
|
+
"""
|
|
124
|
+
chain_id = previous_task.payload.get("__chain_id")
|
|
125
|
+
if not chain_id:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
position = previous_task.payload.get("__chain_position", 0)
|
|
129
|
+
total_tasks = previous_task.payload.get("__total_tasks", 0)
|
|
130
|
+
|
|
131
|
+
# Update chain progress
|
|
132
|
+
self._redis.hincrby(f"chain:{chain_id}", "completed_tasks", 1)
|
|
133
|
+
self._redis.hset(f"chain:{chain_id}", "current_task", position + 1)
|
|
134
|
+
|
|
135
|
+
# Check if chain is complete
|
|
136
|
+
if position + 1 >= total_tasks:
|
|
137
|
+
self._redis.hset(f"chain:{chain_id}", "status", "completed")
|
|
138
|
+
logger.info(f"Chain {chain_id} completed successfully")
|
|
139
|
+
|
|
140
|
+
if push_completion_task:
|
|
141
|
+
metadata = self._redis.hgetall(f"chain:{chain_id}")
|
|
142
|
+
completion_task = metadata.get("completion_task")
|
|
143
|
+
if completion_task:
|
|
144
|
+
task = Task(
|
|
145
|
+
task_type=completion_task,
|
|
146
|
+
payload={
|
|
147
|
+
"chain_id": chain_id,
|
|
148
|
+
"result": previous_result,
|
|
149
|
+
# Mark as chain task so ChainWorkerMixin.post_process
|
|
150
|
+
# deletes chain:{task_id} after it runs
|
|
151
|
+
"__chain_id": chain_id,
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
self.queue.push(task)
|
|
155
|
+
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
# Get next task configuration
|
|
159
|
+
tasks_json = self._redis.get(f"chain:{chain_id}:tasks")
|
|
160
|
+
if not tasks_json:
|
|
161
|
+
logger.error(f"Chain {chain_id} tasks not found")
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
tasks = json.loads(tasks_json)
|
|
165
|
+
next_task_config = tasks[position + 1]
|
|
166
|
+
|
|
167
|
+
# Create next task using the pre-assigned task_id from the stored config
|
|
168
|
+
next_task = Task(
|
|
169
|
+
task_type=next_task_config["task_type"],
|
|
170
|
+
task_id=next_task_config["task_id"],
|
|
171
|
+
payload={
|
|
172
|
+
**next_task_config.get("payload", {}),
|
|
173
|
+
"__chain_id": chain_id,
|
|
174
|
+
"__chain_position": position + 1,
|
|
175
|
+
"__total_tasks": total_tasks,
|
|
176
|
+
"__previous_result": previous_result,
|
|
177
|
+
"__is_first": False,
|
|
178
|
+
},
|
|
179
|
+
max_retries=next_task_config.get("max_retries", 3),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Push next task
|
|
183
|
+
self.queue.push(next_task)
|
|
184
|
+
logger.info(
|
|
185
|
+
f"Chain {chain_id} continuing with task {next_task.task_id} (position {position + 1})"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def get_chain_status(self, chain_id: str) -> Dict[str, Any]:
|
|
189
|
+
"""Get the status of a chain."""
|
|
190
|
+
data = self._redis.hgetall(f"chain:{chain_id}")
|
|
191
|
+
if not data:
|
|
192
|
+
return {"status": "not_found"}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
"chain_id": chain_id,
|
|
196
|
+
"total_tasks": int(data.get("total_tasks", 0)),
|
|
197
|
+
"completed_tasks": int(data.get("completed_tasks", 0)),
|
|
198
|
+
"current_task": int(data.get("current_task", 0)),
|
|
199
|
+
"status": data.get("status", "unknown"),
|
|
200
|
+
"failed": data.get("failed", "False") == "True",
|
|
201
|
+
}
|
broccoli/core/health.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# video_scheduler/core/health.py
|
|
2
|
+
class HealthCheck:
|
|
3
|
+
def __init__(self, worker):
|
|
4
|
+
self.worker = worker
|
|
5
|
+
|
|
6
|
+
def check(self) -> dict:
|
|
7
|
+
return {
|
|
8
|
+
"status": "healthy" if self.worker.running else "unhealthy",
|
|
9
|
+
"worker_id": self.worker.worker_id,
|
|
10
|
+
"redis": self._check_redis(),
|
|
11
|
+
"tasks_processed": self.worker.tasks_processed,
|
|
12
|
+
"active_task": self.worker.current_task_id,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
def _check_redis(self):
|
|
16
|
+
try:
|
|
17
|
+
self.worker.queue.redis.ping()
|
|
18
|
+
return {"status": "connected"}
|
|
19
|
+
except:
|
|
20
|
+
return {"status": "disconnected"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import redis
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RedisController:
|
|
5
|
+
def __init__(self, redis_url: str = "redis://localhost:6379"):
|
|
6
|
+
self._redis = redis.from_url(redis_url, decode_responses=True)
|
|
7
|
+
|
|
8
|
+
def delete_all_keys(self):
|
|
9
|
+
"""Delete all keys in Redis (use with caution)."""
|
|
10
|
+
return self._redis.flushdb()
|
|
11
|
+
|
|
12
|
+
def get_client(self):
|
|
13
|
+
"""Get the Redis client instance."""
|
|
14
|
+
return self._redis
|
|
15
|
+
|
|
16
|
+
def view_all_keys(self):
|
|
17
|
+
"""View all keys in Redis."""
|
|
18
|
+
return self._redis.keys("*")
|
|
19
|
+
|
|
20
|
+
def view_key(self, key: str):
|
|
21
|
+
"""View value of a specific key."""
|
|
22
|
+
return self._redis.get(key)
|
|
23
|
+
|
|
24
|
+
def delete_key(self, key: str):
|
|
25
|
+
"""Delete a specific key."""
|
|
26
|
+
return self._redis.delete(key)
|
|
27
|
+
|
|
28
|
+
def get_count(self, pattern="*"):
|
|
29
|
+
"""Get count of keys matching a pattern."""
|
|
30
|
+
return len(self._redis.keys(pattern))
|
|
31
|
+
|
|
32
|
+
def view_all(self, pattern="*"):
|
|
33
|
+
"""View all keys and their values matching a pattern."""
|
|
34
|
+
keys = self._redis.keys(pattern)
|
|
35
|
+
return {key.decode(): self._redis.get(key).decode() for key in keys}
|
broccoli/core/result.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# video_scheduler/core/result.py
|
|
2
|
+
import json
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import redis
|
|
6
|
+
|
|
7
|
+
from broccoli.core.chain.chain import Chain
|
|
8
|
+
from broccoli.core.task.task import Task
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ResultBackend:
|
|
12
|
+
def __init__(self, redis_url: str):
|
|
13
|
+
self._redis = redis.from_url(redis_url)
|
|
14
|
+
self.ttl = 3600 # Default TTL for results in seconds
|
|
15
|
+
|
|
16
|
+
def store_task(self, task: Task) -> None:
|
|
17
|
+
"""Store task result with TTL."""
|
|
18
|
+
key = f"result:{task.task_id}"
|
|
19
|
+
result_mapping = ResultMapping(
|
|
20
|
+
id=task.task_id,
|
|
21
|
+
result=task.result,
|
|
22
|
+
status=task.status,
|
|
23
|
+
chain=False,
|
|
24
|
+
error=task.error or "",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
json_string = json.dumps(result_mapping.to_dict())
|
|
28
|
+
self._redis.set(name=key, ex=self.ttl, value=json_string)
|
|
29
|
+
|
|
30
|
+
def store_chain(self, chain: Chain) -> None:
|
|
31
|
+
"""Store chain result with TTL."""
|
|
32
|
+
key = f"result:{chain.chain_id}"
|
|
33
|
+
result_mapping = ResultMapping(
|
|
34
|
+
id=chain.chain_id,
|
|
35
|
+
result=chain.result,
|
|
36
|
+
status=chain.status,
|
|
37
|
+
chain=True,
|
|
38
|
+
error="",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
json_string = json.dumps(result_mapping.to_dict())
|
|
42
|
+
self._redis.set(name=key, ex=self.ttl, value=json_string)
|
|
43
|
+
|
|
44
|
+
def get(self, id: str) -> any:
|
|
45
|
+
"""Retrieve task result."""
|
|
46
|
+
key = f"result:{id}"
|
|
47
|
+
data = self._redis.get(key)
|
|
48
|
+
if data:
|
|
49
|
+
return ResultMapping.from_dict(json.loads(data))
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ResultMapping:
|
|
55
|
+
id: str
|
|
56
|
+
result: any
|
|
57
|
+
status: str
|
|
58
|
+
chain: bool
|
|
59
|
+
error: str = "" # Optional error field for tasks
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict:
|
|
62
|
+
return {
|
|
63
|
+
"id": self.id,
|
|
64
|
+
"result": json.dumps(self.result),
|
|
65
|
+
"status": self.status,
|
|
66
|
+
"chain": self.chain,
|
|
67
|
+
"error": self.error,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_dict(cls, data: dict) -> "ResultMapping":
|
|
72
|
+
return cls(
|
|
73
|
+
id=data.get("id"),
|
|
74
|
+
result=json.loads(data.get("result", "{}")),
|
|
75
|
+
status=data.get("status", "unknown"),
|
|
76
|
+
chain=data.get("chain", False),
|
|
77
|
+
error=data.get("error", ""),
|
|
78
|
+
)
|