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,58 @@
|
|
|
1
|
+
# video_scheduler/core/task.py
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Task:
|
|
11
|
+
task_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
12
|
+
task_type: str = ""
|
|
13
|
+
status: str = "pending"
|
|
14
|
+
progress: float = 0.0
|
|
15
|
+
retries: int = 0
|
|
16
|
+
max_retries: int = 3
|
|
17
|
+
error: Optional[str] = None
|
|
18
|
+
secondary_error: Optional[str] = None
|
|
19
|
+
payload: dict = field(default_factory=dict)
|
|
20
|
+
result: Any = None
|
|
21
|
+
depends_on: Optional[str] = None
|
|
22
|
+
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
23
|
+
updated_at: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
return {
|
|
27
|
+
"task_id": self.task_id,
|
|
28
|
+
"task_type": self.task_type,
|
|
29
|
+
"status": self.status,
|
|
30
|
+
"progress": str(self.progress),
|
|
31
|
+
"retries": str(self.retries),
|
|
32
|
+
"max_retries": str(self.max_retries),
|
|
33
|
+
"error": self.error or "",
|
|
34
|
+
"secondary_error": self.secondary_error or "",
|
|
35
|
+
"payload": json.dumps(self.payload),
|
|
36
|
+
"result": json.dumps(self.result) if self.result is not None else "",
|
|
37
|
+
"depends_on": self.depends_on or "",
|
|
38
|
+
"created_at": self.created_at or "",
|
|
39
|
+
"updated_at": self.updated_at or "",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_dict(cls, data: dict) -> "Task":
|
|
44
|
+
return cls(
|
|
45
|
+
task_id=data.get("task_id"),
|
|
46
|
+
task_type=data.get("task_type", ""),
|
|
47
|
+
status=data.get("status", "pending"),
|
|
48
|
+
progress=float(data.get("progress", 0.0)),
|
|
49
|
+
retries=int(data.get("retries", 0)),
|
|
50
|
+
max_retries=int(data.get("max_retries", 3)),
|
|
51
|
+
error=data.get("error") or None,
|
|
52
|
+
secondary_error=data.get("secondary_error") or None,
|
|
53
|
+
payload=json.loads(data.get("payload", "{}")),
|
|
54
|
+
result=json.loads(data.get("result")) if data.get("result") else None,
|
|
55
|
+
depends_on=data.get("depends_on") or None,
|
|
56
|
+
created_at=data.get("created_at") or datetime.now().isoformat(),
|
|
57
|
+
updated_at=data.get("updated_at") or None,
|
|
58
|
+
)
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# broccoli/core/task/task_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 TaskQueue:
|
|
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: str = "tasks:queue",
|
|
40
|
+
task_prefix: str = "task",
|
|
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.base = base
|
|
55
|
+
self.processing_key = f"{base}:processing"
|
|
56
|
+
self.sequence_key = f"{base}:sequence"
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# Public API
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def push(self, task: Task, priority: int = 0) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Persist task metadata then either enqueue it or register it as waiting.
|
|
65
|
+
|
|
66
|
+
If the task has no dependency, or its dependency is already completed,
|
|
67
|
+
it goes straight into the runnable queue. Otherwise it is stored as
|
|
68
|
+
``waiting`` and added to ``dependency:<parent_id>`` so ``complete()``
|
|
69
|
+
can release it later.
|
|
70
|
+
|
|
71
|
+
Race condition handled with a register-then-verify pattern:
|
|
72
|
+
1. Register in the dependency set first.
|
|
73
|
+
2. Re-check the parent's status.
|
|
74
|
+
3. If parent is already completed (raced past us), release immediately.
|
|
75
|
+
This eliminates the TOCTOU window present in a check-then-register approach.
|
|
76
|
+
"""
|
|
77
|
+
# Always persist task metadata first so workers can read it after pop().
|
|
78
|
+
self._redis.hset(f"{self.task_prefix}:{task.task_id}", mapping=task.to_dict())
|
|
79
|
+
|
|
80
|
+
if task.depends_on:
|
|
81
|
+
# Mark as waiting immediately and register in the dependency set.
|
|
82
|
+
# We do this BEFORE reading the parent's status to close the
|
|
83
|
+
# check-then-register race window.
|
|
84
|
+
task.status = "waiting"
|
|
85
|
+
pipe = self._redis.pipeline()
|
|
86
|
+
pipe.hset(f"{self.task_prefix}:{task.task_id}", "status", "waiting")
|
|
87
|
+
# Store the requested priority so complete() can restore it.
|
|
88
|
+
pipe.hset(
|
|
89
|
+
f"{self.task_prefix}:{task.task_id}", _PRIORITY_FIELD, str(priority)
|
|
90
|
+
)
|
|
91
|
+
pipe.sadd(f"dependency:{task.depends_on}", task.task_id)
|
|
92
|
+
pipe.execute()
|
|
93
|
+
|
|
94
|
+
# Now verify: if the parent already completed before our sadd, release now.
|
|
95
|
+
dep_status = self._redis.hget(
|
|
96
|
+
f"{self.task_prefix}:{task.depends_on}", "status"
|
|
97
|
+
)
|
|
98
|
+
parent_done = (
|
|
99
|
+
dep_status
|
|
100
|
+
and (
|
|
101
|
+
dep_status.decode() if isinstance(dep_status, bytes) else dep_status
|
|
102
|
+
)
|
|
103
|
+
== "completed"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if parent_done:
|
|
107
|
+
# Parent completed before (or concurrently with) our registration.
|
|
108
|
+
# Release ourselves immediately and clean up.
|
|
109
|
+
self._redis.srem(f"dependency:{task.depends_on}", task.task_id)
|
|
110
|
+
self._enqueue(task.task_id, priority)
|
|
111
|
+
logger.debug(
|
|
112
|
+
f"Task {task.task_id}: dependency {task.depends_on} already "
|
|
113
|
+
"completed, enqueued immediately"
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
logger.debug(
|
|
117
|
+
f"Task {task.task_id} waiting on dependency {task.depends_on}"
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
self._enqueue(task.task_id, priority)
|
|
121
|
+
|
|
122
|
+
return task.task_id
|
|
123
|
+
|
|
124
|
+
def pop(self) -> Optional[Task]:
|
|
125
|
+
"""
|
|
126
|
+
Pop the highest-priority runnable task and move it to the processing set.
|
|
127
|
+
|
|
128
|
+
No dependency checking happens here — that was handled at push / complete
|
|
129
|
+
time. Everything in the queue is ready to run.
|
|
130
|
+
|
|
131
|
+
Returns ``None`` if the queue is empty after the blocking timeout.
|
|
132
|
+
"""
|
|
133
|
+
result = self._redis.bzpopmin(self.queue_key, timeout=1)
|
|
134
|
+
if result is None:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
_, task_id, _fifo_score = result
|
|
138
|
+
task_id = task_id.decode() if isinstance(task_id, bytes) else task_id
|
|
139
|
+
|
|
140
|
+
task_data = self._redis.hgetall(f"{self.task_prefix}:{task_id}")
|
|
141
|
+
if not task_data:
|
|
142
|
+
logger.error(
|
|
143
|
+
f"Task data missing for {task_id!r}; moving to dead-letter set"
|
|
144
|
+
)
|
|
145
|
+
self._redis.sadd(f"{self.task_prefix}:dead_letter", task_id)
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
# Score in the processing set is a Unix timestamp so recover_stalled()
|
|
149
|
+
# can identify tasks that have been in-flight longer than expected.
|
|
150
|
+
now = time.time()
|
|
151
|
+
self._redis.zadd(self.processing_key, {task_id: now})
|
|
152
|
+
self._redis.hset(f"{self.task_prefix}:{task_id}", "status", "in_progress")
|
|
153
|
+
|
|
154
|
+
task = Task.from_dict(task_data)
|
|
155
|
+
task.status = "in_progress"
|
|
156
|
+
return task
|
|
157
|
+
|
|
158
|
+
def complete(self, task: Task) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Called after a task finishes successfully.
|
|
161
|
+
|
|
162
|
+
1. Removes the task from the processing set.
|
|
163
|
+
2. Reads any tasks that were waiting on this one and enqueues them,
|
|
164
|
+
restoring their original priority.
|
|
165
|
+
3. Deletes the dependency set.
|
|
166
|
+
"""
|
|
167
|
+
task_id = task.task_id
|
|
168
|
+
self._redis.zrem(self.processing_key, task_id)
|
|
169
|
+
|
|
170
|
+
dep_key = f"dependency:{task_id}"
|
|
171
|
+
waiting_ids = self._redis.smembers(dep_key)
|
|
172
|
+
if waiting_ids:
|
|
173
|
+
for raw_id in waiting_ids:
|
|
174
|
+
wid = raw_id.decode() if isinstance(raw_id, bytes) else raw_id
|
|
175
|
+
# Restore the priority the caller supplied when pushing the waiting task.
|
|
176
|
+
raw_pri = self._redis.hget(f"{self.task_prefix}:{wid}", _PRIORITY_FIELD)
|
|
177
|
+
priority = int(raw_pri) if raw_pri else 0
|
|
178
|
+
self._enqueue(wid, priority)
|
|
179
|
+
logger.info(
|
|
180
|
+
f"Released waiting task {wid} "
|
|
181
|
+
f"(dependency {task_id} completed, priority={priority})"
|
|
182
|
+
)
|
|
183
|
+
self._redis.delete(dep_key)
|
|
184
|
+
|
|
185
|
+
def fail(self, task: Task) -> None:
|
|
186
|
+
"""Remove a permanently failed (or skipped) task from the processing set."""
|
|
187
|
+
self._redis.zrem(self.processing_key, task.task_id)
|
|
188
|
+
|
|
189
|
+
def requeue(self, task_id: str, priority: Optional[int] = None) -> None:
|
|
190
|
+
"""
|
|
191
|
+
Move a task from the processing set back to the runnable queue (retry).
|
|
192
|
+
|
|
193
|
+
A fresh FIFO sequence number is issued so the retry goes to the back of
|
|
194
|
+
its priority tier rather than jumping the queue.
|
|
195
|
+
|
|
196
|
+
If ``priority`` isn't given explicitly, the task's originally-requested
|
|
197
|
+
priority (stashed under ``_PRIORITY_FIELD`` at push time) is restored,
|
|
198
|
+
matching the behaviour of ``complete()`` when it releases dependents.
|
|
199
|
+
Without this, every retry would silently reset to priority 0 and could
|
|
200
|
+
jump ahead of lower-priority tasks still waiting in the queue.
|
|
201
|
+
"""
|
|
202
|
+
self._redis.zrem(self.processing_key, task_id)
|
|
203
|
+
if priority is None:
|
|
204
|
+
raw_pri = self._redis.hget(f"{self.task_prefix}:{task_id}", _PRIORITY_FIELD)
|
|
205
|
+
priority = int(raw_pri) if raw_pri else 0
|
|
206
|
+
self._enqueue(task_id, priority)
|
|
207
|
+
|
|
208
|
+
def recover_stalled(self, timeout_seconds: int = 3600) -> int:
|
|
209
|
+
"""
|
|
210
|
+
Crash-recovery helper: re-enqueue tasks that have been in the processing
|
|
211
|
+
set for longer than ``timeout_seconds``.
|
|
212
|
+
|
|
213
|
+
The processing set uses Unix timestamps as scores (set by ``pop()``),
|
|
214
|
+
so the threshold is simply ``time.time() - timeout_seconds``.
|
|
215
|
+
|
|
216
|
+
Returns the number of tasks recovered.
|
|
217
|
+
"""
|
|
218
|
+
threshold = time.time() - timeout_seconds
|
|
219
|
+
stalled = self._redis.zrangebyscore(self.processing_key, "-inf", threshold)
|
|
220
|
+
for raw_id in stalled:
|
|
221
|
+
tid = raw_id.decode() if isinstance(raw_id, bytes) else raw_id
|
|
222
|
+
self.requeue(tid)
|
|
223
|
+
logger.warning(f"Crash-recovery: re-enqueued stalled task {tid}")
|
|
224
|
+
return len(stalled)
|
|
225
|
+
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
# Convenience helpers
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def get_task(self, task_id: str) -> Optional[Task]:
|
|
231
|
+
"""Fetch task metadata by ID, or ``None`` if it doesn't exist."""
|
|
232
|
+
task_data = self._redis.hgetall(f"{self.task_prefix}:{task_id}")
|
|
233
|
+
if not task_data:
|
|
234
|
+
return None
|
|
235
|
+
return Task.from_dict(task_data)
|
|
236
|
+
|
|
237
|
+
def get_queue_name(self) -> str:
|
|
238
|
+
return self.queue_key
|
|
239
|
+
|
|
240
|
+
def is_empty(self) -> bool:
|
|
241
|
+
return self._redis.zcard(self.queue_key) == 0
|
|
242
|
+
|
|
243
|
+
def pop_with_timeout(self, timeout: int = 1) -> Optional[Task]:
|
|
244
|
+
"""Alias for ``pop()``; timeout is already handled internally."""
|
|
245
|
+
return self.pop()
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
# Private helpers
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def _enqueue(self, task_id: str, priority: int = 0) -> None:
|
|
252
|
+
"""
|
|
253
|
+
Add *task_id* to the runnable sorted set.
|
|
254
|
+
|
|
255
|
+
Score = priority * _FIFO_TIER_SIZE + monotonic_seq
|
|
256
|
+
|
|
257
|
+
This gives strict priority ordering across tiers *and* FIFO within a
|
|
258
|
+
tier, without requiring timestamps or Lua scripts.
|
|
259
|
+
"""
|
|
260
|
+
seq = self._redis.incr(self.sequence_key)
|
|
261
|
+
score = priority * _FIFO_TIER_SIZE + seq
|
|
262
|
+
self._redis.zadd(self.queue_key, {task_id: score})
|
|
263
|
+
self._redis.hset(
|
|
264
|
+
f"{self.task_prefix}:{task_id}",
|
|
265
|
+
mapping={"status": "pending", _PRIORITY_FIELD: str(priority)},
|
|
266
|
+
)
|
|
267
|
+
logger.debug(f"Enqueued task {task_id} (priority={priority}, seq={seq})")
|
|
268
|
+
|
|
269
|
+
def is_fully_drained(self) -> bool:
|
|
270
|
+
"""True only when both the runnable queue and processing set are empty."""
|
|
271
|
+
pipe = self._redis.pipeline()
|
|
272
|
+
pipe.zcard(self.queue_key)
|
|
273
|
+
pipe.zcard(self.processing_key)
|
|
274
|
+
runnable, processing = pipe.execute()
|
|
275
|
+
return runnable == 0 and processing == 0
|
|
276
|
+
|
|
277
|
+
def get_waiting_for(self, task_id: str) -> list:
|
|
278
|
+
"""Return the IDs of tasks currently blocked on ``task_id``."""
|
|
279
|
+
return [
|
|
280
|
+
tid.decode() if isinstance(tid, bytes) else tid
|
|
281
|
+
for tid in self._redis.smembers(f"dependency:{task_id}")
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
def stats(self) -> dict:
|
|
285
|
+
"""
|
|
286
|
+
Snapshot of queue depth for monitoring/diagnostics.
|
|
287
|
+
|
|
288
|
+
``dead_letter`` reflects tasks whose hash went missing on pop() —
|
|
289
|
+
see ``pop()`` for how they get there.
|
|
290
|
+
"""
|
|
291
|
+
pipe = self._redis.pipeline()
|
|
292
|
+
pipe.zcard(self.queue_key)
|
|
293
|
+
pipe.zcard(self.processing_key)
|
|
294
|
+
pipe.scard(f"{self.task_prefix}:dead_letter")
|
|
295
|
+
runnable, processing, dead_letter = pipe.execute()
|
|
296
|
+
return {
|
|
297
|
+
"runnable": runnable,
|
|
298
|
+
"processing": processing,
|
|
299
|
+
"dead_letter": dead_letter,
|
|
300
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# video_scheduler/core/task_registry.py
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Callable, Dict, Optional
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TaskRegistry:
|
|
9
|
+
"""Registry for task handlers that can be executed by workers."""
|
|
10
|
+
|
|
11
|
+
_instance = None
|
|
12
|
+
_handlers: Dict[str, Callable] = {}
|
|
13
|
+
|
|
14
|
+
def __new__(cls):
|
|
15
|
+
if cls._instance is None:
|
|
16
|
+
cls._instance = super().__new__(cls)
|
|
17
|
+
return cls._instance
|
|
18
|
+
|
|
19
|
+
def register(self, task_type: str):
|
|
20
|
+
"""Decorator to register a task handler."""
|
|
21
|
+
|
|
22
|
+
def decorator(func: Callable):
|
|
23
|
+
self._handlers[task_type] = func
|
|
24
|
+
logger.info(f"Registered handler for task type: {task_type}")
|
|
25
|
+
return func
|
|
26
|
+
|
|
27
|
+
return decorator
|
|
28
|
+
|
|
29
|
+
def register_manually(self, task_type: str, handler: Callable):
|
|
30
|
+
"""Manually register a task handler."""
|
|
31
|
+
self._handlers[task_type] = handler
|
|
32
|
+
logger.info(f"Manually registered handler for task type: {task_type}")
|
|
33
|
+
|
|
34
|
+
def get_handler(self, task_type: str) -> Optional[Callable]:
|
|
35
|
+
"""Get the handler for a task type."""
|
|
36
|
+
return self._handlers.get(task_type)
|
|
37
|
+
|
|
38
|
+
def get_all_handlers(self) -> Dict[str, Callable]:
|
|
39
|
+
"""Get all registered handlers."""
|
|
40
|
+
return self._handlers
|
|
41
|
+
|
|
42
|
+
def execute(self, task_type: str, payload: Dict[str, Any], **kwargs) -> Any:
|
|
43
|
+
"""Execute a task handler."""
|
|
44
|
+
handler = self.get_handler(task_type)
|
|
45
|
+
if not handler:
|
|
46
|
+
raise ValueError(f"No handler registered for task type: {task_type}")
|
|
47
|
+
return handler(payload, **kwargs)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from broccoli.workers.async_worker import AsyncWorker
|
|
2
|
+
from broccoli.workers.base_worker import BaseWorker
|
|
3
|
+
from broccoli.workers.chain_worker import ChainWorker
|
|
4
|
+
from broccoli.workers.hybrid_worker import HybridWorker
|
|
5
|
+
from broccoli.workers.threaded_worker import ThreadedWorker
|
|
6
|
+
from broccoli.workers.worker_pool import WorkerPool
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BaseWorker",
|
|
10
|
+
"ChainWorker",
|
|
11
|
+
"ThreadedWorker",
|
|
12
|
+
"AsyncWorker",
|
|
13
|
+
"HybridWorker",
|
|
14
|
+
"WorkerPool",
|
|
15
|
+
]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# broccoli/workers/async_worker.py
|
|
2
|
+
# PEP 563 defers annotation evaluation, so the PEP 604 `X | None` and
|
|
3
|
+
# lowercase-generic (`set[str]`) hints below don't raise on Python 3.9,
|
|
4
|
+
# which doesn't support that syntax natively (3.10+ only).
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
import redis
|
|
11
|
+
|
|
12
|
+
from broccoli.core.task.task import Task
|
|
13
|
+
from broccoli.workers.base_worker import BaseWorker
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AsyncWorker(BaseWorker):
|
|
19
|
+
"""
|
|
20
|
+
Worker that processes tasks concurrently using asyncio.
|
|
21
|
+
|
|
22
|
+
CPU-bound / blocking task handlers are offloaded to the default thread-pool
|
|
23
|
+
executor so they don't stall the event loop.
|
|
24
|
+
|
|
25
|
+
State transitions (complete / fail / requeue) are fully delegated to
|
|
26
|
+
``BaseWorker._handle_task_result`` — no duplicate logic here.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
redis_url: str = "redis://localhost:6379",
|
|
32
|
+
worker_id: str = None,
|
|
33
|
+
max_concurrent: int = 10,
|
|
34
|
+
):
|
|
35
|
+
super().__init__(redis_url, worker_id)
|
|
36
|
+
self.max_concurrent = max_concurrent
|
|
37
|
+
# Semaphore is created lazily inside the running event loop to avoid
|
|
38
|
+
# "no running event loop" errors at construction time.
|
|
39
|
+
self._semaphore: asyncio.Semaphore | None = None
|
|
40
|
+
self.active_tasks: set[str] = set()
|
|
41
|
+
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
# Async task lifecycle
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
async def process_task_async(self, task: Task) -> None:
|
|
47
|
+
"""Acquire a concurrency slot and process one task."""
|
|
48
|
+
async with self._semaphore:
|
|
49
|
+
try:
|
|
50
|
+
logger.info(f"AsyncWorker {self.worker_id} processing {task.task_id}")
|
|
51
|
+
|
|
52
|
+
if not self.pre_process(task):
|
|
53
|
+
logger.info(f"Task {task.task_id} skipped by pre_process")
|
|
54
|
+
self.queue.fail(task) # remove from processing set
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Use get_running_loop() — get_event_loop() is deprecated in 3.10+
|
|
58
|
+
# and raises a DeprecationWarning inside a running coroutine.
|
|
59
|
+
loop = asyncio.get_running_loop()
|
|
60
|
+
# Only self.process() is allowed to fail the task via exception.
|
|
61
|
+
# _handle_task_result / post_process must be allowed to propagate
|
|
62
|
+
# their own errors to the outer except below, rather than being
|
|
63
|
+
# re-invoked a second time on an already-transitioned task.
|
|
64
|
+
try:
|
|
65
|
+
success = await asyncio.wait_for(
|
|
66
|
+
loop.run_in_executor(None, self.process, task),
|
|
67
|
+
timeout=self.task_timeout,
|
|
68
|
+
)
|
|
69
|
+
except asyncio.TimeoutError:
|
|
70
|
+
logger.error(
|
|
71
|
+
f"Task {task.task_id} timed out after {self.task_timeout}s"
|
|
72
|
+
)
|
|
73
|
+
task.error = f"Task timed out after {self.task_timeout}s"
|
|
74
|
+
success = False
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.error(
|
|
77
|
+
f"Task {task.task_id} handler raised: {e}", exc_info=True
|
|
78
|
+
)
|
|
79
|
+
task.error = str(e)
|
|
80
|
+
success = False
|
|
81
|
+
|
|
82
|
+
# Central state machine: complete / requeue / fail
|
|
83
|
+
self._handle_task_result(task, success)
|
|
84
|
+
|
|
85
|
+
# Result storage and user-facing callbacks
|
|
86
|
+
self.post_process(task, success)
|
|
87
|
+
|
|
88
|
+
except Exception as e:
|
|
89
|
+
# Reached only if _handle_task_result/post_process themselves
|
|
90
|
+
# raised — the task's queue state may already be inconsistent,
|
|
91
|
+
# so we log rather than re-running the state machine on it again.
|
|
92
|
+
logger.error(
|
|
93
|
+
f"Task {task.task_id} failed outside handler: {e}", exc_info=True
|
|
94
|
+
)
|
|
95
|
+
finally:
|
|
96
|
+
self.active_tasks.discard(task.task_id)
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Event-loop entry points
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
async def start_async(self):
|
|
103
|
+
"""Main async loop: pop tasks and dispatch them as asyncio tasks."""
|
|
104
|
+
self._register_signal_handlers()
|
|
105
|
+
# Semaphore must be created inside the running loop.
|
|
106
|
+
self._semaphore = asyncio.Semaphore(self.max_concurrent)
|
|
107
|
+
self.running = True
|
|
108
|
+
logger.info(
|
|
109
|
+
f"AsyncWorker {self.worker_id} started "
|
|
110
|
+
f"(max_concurrent={self.max_concurrent})"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
backoff = 1
|
|
114
|
+
while self.running:
|
|
115
|
+
try:
|
|
116
|
+
if len(self.active_tasks) >= self.max_concurrent:
|
|
117
|
+
await asyncio.sleep(0.05)
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
loop = asyncio.get_running_loop()
|
|
121
|
+
task = await loop.run_in_executor(None, self.queue.pop)
|
|
122
|
+
backoff = 1 # reset after any successful Redis round-trip
|
|
123
|
+
|
|
124
|
+
if task is None:
|
|
125
|
+
await asyncio.sleep(0.05)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
self.active_tasks.add(task.task_id)
|
|
129
|
+
asyncio.create_task(self.process_task_async(task))
|
|
130
|
+
logger.info(
|
|
131
|
+
f"Dispatched {task.task_id} "
|
|
132
|
+
f"(active: {len(self.active_tasks)}/{self.max_concurrent})"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
except redis.exceptions.RedisError as e:
|
|
136
|
+
logger.error(
|
|
137
|
+
f"AsyncWorker {self.worker_id} Redis error: {e}, "
|
|
138
|
+
f"retrying in {backoff}s",
|
|
139
|
+
exc_info=True,
|
|
140
|
+
)
|
|
141
|
+
await asyncio.sleep(backoff)
|
|
142
|
+
backoff = min(backoff * 2, 60)
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.error(
|
|
145
|
+
f"AsyncWorker {self.worker_id} loop error: {e}", exc_info=True
|
|
146
|
+
)
|
|
147
|
+
await asyncio.sleep(1)
|
|
148
|
+
|
|
149
|
+
# Drain any still-running tasks before exiting.
|
|
150
|
+
while self.active_tasks:
|
|
151
|
+
await asyncio.sleep(0.05)
|
|
152
|
+
|
|
153
|
+
logger.info(f"AsyncWorker {self.worker_id} stopped")
|
|
154
|
+
|
|
155
|
+
def start(self):
|
|
156
|
+
"""Synchronous entry point — runs the async loop until completion."""
|
|
157
|
+
asyncio.run(self.start_async())
|