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,269 @@
|
|
|
1
|
+
# broccoli/workers/hybrid_worker.py
|
|
2
|
+
# See async_worker.py: this defers evaluation of the `X | None` /
|
|
3
|
+
# lowercase-generic hints below so they don't raise on Python 3.9.
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
import redis
|
|
14
|
+
|
|
15
|
+
from broccoli.core.task.task import Task
|
|
16
|
+
from broccoli.workers.base_worker import BaseWorker
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HybridWorker(BaseWorker):
|
|
22
|
+
"""
|
|
23
|
+
Worker that combines asyncio (for concurrency) with a thread pool
|
|
24
|
+
(for CPU-bound / blocking task handlers).
|
|
25
|
+
|
|
26
|
+
Result storage and user callbacks are handled entirely inside
|
|
27
|
+
``_handle_hybrid_result``, which means ``post_process`` must NOT repeat
|
|
28
|
+
them. This class overrides ``post_process`` to run only the registered
|
|
29
|
+
post-process handlers and nothing else, preventing double-fired callbacks
|
|
30
|
+
and double-stored results.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
redis_url: str = "redis://localhost:6379",
|
|
36
|
+
worker_id: str = None,
|
|
37
|
+
thread_workers: int = 4,
|
|
38
|
+
async_tasks: int = 10,
|
|
39
|
+
result_ttl: int = 86400, # 24 hours
|
|
40
|
+
):
|
|
41
|
+
super().__init__(redis_url, worker_id)
|
|
42
|
+
self.thread_pool = ThreadPoolExecutor(max_workers=thread_workers)
|
|
43
|
+
self.thread_workers = thread_workers
|
|
44
|
+
self.async_tasks = async_tasks
|
|
45
|
+
self.active_tasks: set[str] = set()
|
|
46
|
+
self.loop: asyncio.AbstractEventLoop | None = None
|
|
47
|
+
self.result_ttl = result_ttl
|
|
48
|
+
# Semaphore created lazily inside the running loop.
|
|
49
|
+
self._semaphore: asyncio.Semaphore | None = None
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# post_process override — prevent double-firing
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def post_process(self, task: Task, success: bool) -> None:
|
|
56
|
+
"""
|
|
57
|
+
HybridWorker handles result storage, hash cleanup, and completion /
|
|
58
|
+
failure callbacks inside ``_handle_hybrid_result``. This override
|
|
59
|
+
runs only the registered post-process handlers so that none of those
|
|
60
|
+
actions fire a second time.
|
|
61
|
+
"""
|
|
62
|
+
self._run_post_process_handlers(task, success)
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
# Async / hybrid task processing
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
async def process_task_hybrid(self, task: Task) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Acquire a concurrency slot, run the task handler in the thread pool,
|
|
71
|
+
then handle the result (queue transitions, result storage, callbacks).
|
|
72
|
+
"""
|
|
73
|
+
async with self._semaphore:
|
|
74
|
+
try:
|
|
75
|
+
# pre_process is checked before entering the thread pool so a
|
|
76
|
+
# skip (returns False) can be routed to queue.fail() directly,
|
|
77
|
+
# rather than being indistinguishable from a real processing
|
|
78
|
+
# failure and consuming retry budget in _handle_hybrid_result.
|
|
79
|
+
if not self.pre_process(task):
|
|
80
|
+
logger.info(f"Task {task.task_id} skipped by pre_process")
|
|
81
|
+
self.queue.fail(task)
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Use get_running_loop() — get_event_loop() is deprecated in 3.10+.
|
|
85
|
+
loop = asyncio.get_running_loop()
|
|
86
|
+
try:
|
|
87
|
+
success = await asyncio.wait_for(
|
|
88
|
+
loop.run_in_executor(self.thread_pool, self.process, task),
|
|
89
|
+
timeout=self.task_timeout,
|
|
90
|
+
)
|
|
91
|
+
except asyncio.TimeoutError:
|
|
92
|
+
logger.error(
|
|
93
|
+
f"Task {task.task_id} timed out after {self.task_timeout}s"
|
|
94
|
+
)
|
|
95
|
+
task.error = f"Task timed out after {self.task_timeout}s"
|
|
96
|
+
success = False
|
|
97
|
+
self._handle_hybrid_result(task, success)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.error(f"Hybrid task {task.task_id} failed: {e}", exc_info=True)
|
|
100
|
+
task.error = str(e)
|
|
101
|
+
self._handle_hybrid_result(task, False)
|
|
102
|
+
finally:
|
|
103
|
+
self.active_tasks.discard(task.task_id)
|
|
104
|
+
|
|
105
|
+
def _handle_hybrid_result(self, task: Task, success: bool) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Manage queue transitions and persist results.
|
|
108
|
+
|
|
109
|
+
Queue state is handled by TaskQueue methods (complete / fail / requeue),
|
|
110
|
+
not by direct Redis calls. Result saving and hash cleanup are kept
|
|
111
|
+
separate so each concern has one place.
|
|
112
|
+
|
|
113
|
+
Completion / failure handlers are fired here (not in post_process) to
|
|
114
|
+
keep the full lifecycle in one readable sequence.
|
|
115
|
+
"""
|
|
116
|
+
if success:
|
|
117
|
+
task.status = "completed"
|
|
118
|
+
task.progress = 100.0
|
|
119
|
+
# Update Redis before releasing dependents (same ordering as BaseWorker).
|
|
120
|
+
self._update_task(task)
|
|
121
|
+
self.queue.complete(task) # releases dependents, removes from processing
|
|
122
|
+
logger.info(f"Task {task.task_id} completed")
|
|
123
|
+
else:
|
|
124
|
+
task.retries += 1
|
|
125
|
+
if task.retries >= task.max_retries:
|
|
126
|
+
task.status = "failed"
|
|
127
|
+
if not task.error:
|
|
128
|
+
task.error = "Max retries exceeded"
|
|
129
|
+
self._update_task(task)
|
|
130
|
+
self.queue.fail(task) # removes from processing set
|
|
131
|
+
else:
|
|
132
|
+
task.status = "pending"
|
|
133
|
+
self._update_task(task)
|
|
134
|
+
self.queue.requeue(task.task_id) # back to runnable queue
|
|
135
|
+
logger.info(
|
|
136
|
+
f"Task {task.task_id} requeued "
|
|
137
|
+
f"(attempt {task.retries}/{task.max_retries})"
|
|
138
|
+
)
|
|
139
|
+
# Requeued — don't store a partial result or fire handlers.
|
|
140
|
+
# post_process still runs for registered post-process handlers.
|
|
141
|
+
self.post_process(task, success)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
# On permanent failure, record in the dead-letter set before result
|
|
145
|
+
# storage/cleanup so the task stays inspectable even if _save_result
|
|
146
|
+
# raises.
|
|
147
|
+
if task.status == "failed":
|
|
148
|
+
try:
|
|
149
|
+
self._redis.zadd(
|
|
150
|
+
f"{self.task_prefix}:dead_letter", {task.task_id: time.time()}
|
|
151
|
+
)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.error(
|
|
154
|
+
f"Failed to record {task.task_id} in dead-letter set: {e}",
|
|
155
|
+
exc_info=True,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Persist extended result metadata with TTL.
|
|
159
|
+
self._save_result(task)
|
|
160
|
+
|
|
161
|
+
# Remove task metadata hash (queue sets already updated above).
|
|
162
|
+
self._cleanup_task(task)
|
|
163
|
+
|
|
164
|
+
# Fire user-facing callbacks.
|
|
165
|
+
if success:
|
|
166
|
+
self._run_completion_handlers(task, task.result)
|
|
167
|
+
else:
|
|
168
|
+
error = Exception(task.error) if task.error else Exception("Task failed")
|
|
169
|
+
self._run_failure_handlers(task, error)
|
|
170
|
+
|
|
171
|
+
# post_process runs only registered post-process handlers (overridden above).
|
|
172
|
+
self.post_process(task, success)
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
# Result storage & cleanup
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def _save_result(self, task: Task) -> None:
|
|
179
|
+
"""Persist full result metadata under ``result:<task_id>`` with a TTL."""
|
|
180
|
+
result_data = {
|
|
181
|
+
"id": task.task_id,
|
|
182
|
+
"task_type": task.task_type,
|
|
183
|
+
"status": task.status,
|
|
184
|
+
"result": task.result,
|
|
185
|
+
"error": task.error,
|
|
186
|
+
"chain": bool(task.payload.get("__chain_id")),
|
|
187
|
+
"chain_id": task.payload.get("__chain_id"),
|
|
188
|
+
"worker_id": self.worker_id,
|
|
189
|
+
"completed_at": datetime.now().isoformat(),
|
|
190
|
+
"retries": task.retries,
|
|
191
|
+
}
|
|
192
|
+
self._redis.setex(
|
|
193
|
+
f"result:{task.task_id}", self.result_ttl, json.dumps(result_data)
|
|
194
|
+
)
|
|
195
|
+
logger.debug(f"Result saved for task {task.task_id} (TTL={self.result_ttl}s)")
|
|
196
|
+
|
|
197
|
+
def _cleanup_task(self, task: Task) -> None:
|
|
198
|
+
"""
|
|
199
|
+
Remove the task metadata hash from Redis.
|
|
200
|
+
|
|
201
|
+
Queue sorted-set membership is already managed by
|
|
202
|
+
``queue.complete()`` / ``queue.fail()`` — no ``zrem`` calls here.
|
|
203
|
+
"""
|
|
204
|
+
self._redis.delete(f"{self.task_prefix}:{task.task_id}")
|
|
205
|
+
|
|
206
|
+
if task.payload.get("__chain_id"):
|
|
207
|
+
self._redis.delete(f"chain:{task.task_id}")
|
|
208
|
+
|
|
209
|
+
logger.info(f"Task {task.task_id} metadata cleaned up")
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
# Worker loop
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def start(self):
|
|
216
|
+
"""Create a fresh event loop and run until stopped."""
|
|
217
|
+
self._register_signal_handlers()
|
|
218
|
+
self.running = True
|
|
219
|
+
self.loop = asyncio.new_event_loop()
|
|
220
|
+
asyncio.set_event_loop(self.loop)
|
|
221
|
+
logger.info(
|
|
222
|
+
f"HybridWorker {self.worker_id} started "
|
|
223
|
+
f"(threads={self.thread_workers}, async_slots={self.async_tasks})"
|
|
224
|
+
)
|
|
225
|
+
try:
|
|
226
|
+
self.loop.run_until_complete(self._run_hybrid())
|
|
227
|
+
finally:
|
|
228
|
+
self.loop.close()
|
|
229
|
+
|
|
230
|
+
async def _run_hybrid(self):
|
|
231
|
+
self._semaphore = asyncio.Semaphore(self.async_tasks)
|
|
232
|
+
|
|
233
|
+
backoff = 1
|
|
234
|
+
while self.running:
|
|
235
|
+
if len(self.active_tasks) >= self.async_tasks:
|
|
236
|
+
await asyncio.sleep(0.05)
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
loop = asyncio.get_running_loop()
|
|
241
|
+
task = await loop.run_in_executor(None, self.queue.pop_with_timeout, 1)
|
|
242
|
+
backoff = 1 # reset after any successful Redis round-trip
|
|
243
|
+
except redis.exceptions.RedisError as e:
|
|
244
|
+
logger.error(
|
|
245
|
+
f"HybridWorker {self.worker_id} Redis error: {e}, "
|
|
246
|
+
f"retrying in {backoff}s",
|
|
247
|
+
exc_info=True,
|
|
248
|
+
)
|
|
249
|
+
await asyncio.sleep(backoff)
|
|
250
|
+
backoff = min(backoff * 2, 60)
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
if task is None:
|
|
254
|
+
await asyncio.sleep(0.05)
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
self.active_tasks.add(task.task_id)
|
|
258
|
+
asyncio.create_task(self.process_task_hybrid(task))
|
|
259
|
+
|
|
260
|
+
# Drain in-flight tasks before shutting down.
|
|
261
|
+
while self.active_tasks:
|
|
262
|
+
await asyncio.sleep(0.05)
|
|
263
|
+
|
|
264
|
+
self.thread_pool.shutdown(wait=True)
|
|
265
|
+
logger.info(f"HybridWorker {self.worker_id} stopped")
|
|
266
|
+
|
|
267
|
+
def stop(self):
|
|
268
|
+
self.running = False
|
|
269
|
+
logger.info(f"HybridWorker {self.worker_id} stopping...")
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# broccoli/workers/threaded_worker.py
|
|
2
|
+
# See async_worker.py: defers evaluation of the `dict[str, Task]` hint below
|
|
3
|
+
# so it doesn't raise on Python 3.9 (lowercase generics are 3.9+ for
|
|
4
|
+
# subscripting builtins, but the annotation deferral keeps this consistent
|
|
5
|
+
# across the whole worker family regardless of exact minimum version).
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
13
|
+
|
|
14
|
+
import redis
|
|
15
|
+
|
|
16
|
+
from broccoli.core.task.task import Task
|
|
17
|
+
from broccoli.workers.base_worker import BaseWorker
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ThreadedWorker(BaseWorker):
|
|
23
|
+
"""
|
|
24
|
+
Worker that processes multiple tasks concurrently using a thread pool.
|
|
25
|
+
|
|
26
|
+
``_handle_task_result`` is inherited from ``BaseWorker`` — retry logic,
|
|
27
|
+
dependency release, and queue-set transitions all live in one place.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
redis_url: str = "redis://localhost:6379",
|
|
33
|
+
worker_id: str = None,
|
|
34
|
+
max_workers: int = 4,
|
|
35
|
+
):
|
|
36
|
+
super().__init__(redis_url, worker_id)
|
|
37
|
+
self.max_workers = max_workers
|
|
38
|
+
self.executor = ThreadPoolExecutor(max_workers=max_workers)
|
|
39
|
+
self.active_tasks: dict[str, Task] = {}
|
|
40
|
+
self.task_lock = threading.Lock()
|
|
41
|
+
# Separate executor to run process() under a timeout guard. Kept
|
|
42
|
+
# distinct from self.executor (which process_task itself runs on) to
|
|
43
|
+
# avoid the deadlock risk of a task waiting on a future submitted to
|
|
44
|
+
# the same bounded pool it's already occupying a slot in.
|
|
45
|
+
self._timeout_executor = ThreadPoolExecutor(
|
|
46
|
+
max_workers=max_workers, thread_name_prefix="timeout-guard"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def process_task(self, task: Task) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Process one task inside a thread-pool thread.
|
|
52
|
+
|
|
53
|
+
Uses the inherited ``_handle_task_result`` for all state transitions,
|
|
54
|
+
then calls ``post_process`` for result storage and user callbacks.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
logger.info(
|
|
58
|
+
f"Thread {threading.current_thread().name} "
|
|
59
|
+
f"processing task {task.task_id}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if not self.pre_process(task):
|
|
63
|
+
logger.info(f"Task {task.task_id} skipped by pre_process")
|
|
64
|
+
self.queue.fail(task) # remove from processing set
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
# Only self.process() is allowed to fail the task via exception.
|
|
68
|
+
# _handle_task_result / post_process must be allowed to propagate
|
|
69
|
+
# their own errors to the outer except below, rather than being
|
|
70
|
+
# re-invoked a second time on an already-transitioned task.
|
|
71
|
+
try:
|
|
72
|
+
future = self._timeout_executor.submit(self.process, task)
|
|
73
|
+
try:
|
|
74
|
+
success = future.result(timeout=self.task_timeout)
|
|
75
|
+
except FutureTimeoutError:
|
|
76
|
+
future.cancel() # best-effort; thread keeps running if already started
|
|
77
|
+
logger.error(
|
|
78
|
+
f"Task {task.task_id} timed out after {self.task_timeout}s"
|
|
79
|
+
)
|
|
80
|
+
task.error = f"Task timed out after {self.task_timeout}s"
|
|
81
|
+
success = False
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.error(f"Task {task.task_id} handler raised: {e}", exc_info=True)
|
|
84
|
+
task.error = str(e)
|
|
85
|
+
success = False
|
|
86
|
+
|
|
87
|
+
# Central state machine (complete / requeue / fail) + _update_task
|
|
88
|
+
self._handle_task_result(task, success)
|
|
89
|
+
|
|
90
|
+
# Result storage and user-facing callbacks
|
|
91
|
+
self.post_process(task, success)
|
|
92
|
+
|
|
93
|
+
except Exception as e:
|
|
94
|
+
# Reached only if _handle_task_result/post_process themselves
|
|
95
|
+
# raised (e.g. a Redis error) — the task's queue state may be
|
|
96
|
+
# inconsistent, so we log rather than re-running the state
|
|
97
|
+
# machine on it a second time.
|
|
98
|
+
logger.error(
|
|
99
|
+
f"Task {task.task_id} failed outside handler: {e}", exc_info=True
|
|
100
|
+
)
|
|
101
|
+
finally:
|
|
102
|
+
with self.task_lock:
|
|
103
|
+
self.active_tasks.pop(task.task_id, None)
|
|
104
|
+
|
|
105
|
+
def start(self):
|
|
106
|
+
"""Main loop: pop tasks and submit them to the thread pool."""
|
|
107
|
+
self._register_signal_handlers()
|
|
108
|
+
self.running = True
|
|
109
|
+
logger.info(
|
|
110
|
+
f"ThreadedWorker {self.worker_id} started (max_workers={self.max_workers})"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
backoff = 1
|
|
114
|
+
while self.running:
|
|
115
|
+
try:
|
|
116
|
+
with self.task_lock:
|
|
117
|
+
active_count = len(self.active_tasks)
|
|
118
|
+
|
|
119
|
+
if active_count >= self.max_workers:
|
|
120
|
+
time.sleep(0.05)
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
task = self.queue.pop()
|
|
124
|
+
backoff = 1 # reset after any successful Redis round-trip
|
|
125
|
+
if task is None:
|
|
126
|
+
time.sleep(0.05)
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
with self.task_lock:
|
|
130
|
+
self.active_tasks[task.task_id] = task
|
|
131
|
+
|
|
132
|
+
self.executor.submit(self.process_task, task)
|
|
133
|
+
logger.info(
|
|
134
|
+
f"Submitted {task.task_id} to thread pool "
|
|
135
|
+
f"(active: {active_count + 1}/{self.max_workers})"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
except redis.exceptions.RedisError as e:
|
|
139
|
+
logger.error(
|
|
140
|
+
f"ThreadedWorker {self.worker_id} Redis error: {e}, "
|
|
141
|
+
f"retrying in {backoff}s",
|
|
142
|
+
exc_info=True,
|
|
143
|
+
)
|
|
144
|
+
time.sleep(backoff)
|
|
145
|
+
backoff = min(backoff * 2, 60)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.error(
|
|
148
|
+
f"ThreadedWorker {self.worker_id} loop error: {e}", exc_info=True
|
|
149
|
+
)
|
|
150
|
+
time.sleep(1)
|
|
151
|
+
|
|
152
|
+
self.executor.shutdown(wait=True)
|
|
153
|
+
self._timeout_executor.shutdown(wait=False)
|
|
154
|
+
logger.info(f"ThreadedWorker {self.worker_id} stopped")
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# broccoli/workers/worker_pool.py
|
|
2
|
+
import logging
|
|
3
|
+
import signal
|
|
4
|
+
import threading
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from broccoli.workers.base_worker import BaseWorker
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorkerPool:
|
|
13
|
+
"""
|
|
14
|
+
Manages a fixed-size pool of workers, each running in its own daemon thread.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
worker_type: type = BaseWorker,
|
|
20
|
+
num_workers: int = 4,
|
|
21
|
+
redis_url: str = "redis://localhost:6379",
|
|
22
|
+
**worker_kwargs,
|
|
23
|
+
):
|
|
24
|
+
self.worker_type = worker_type
|
|
25
|
+
self.num_workers = num_workers
|
|
26
|
+
self.redis_url = redis_url
|
|
27
|
+
self.worker_kwargs = worker_kwargs # e.g. max_workers=, max_concurrent=
|
|
28
|
+
self.workers: List[BaseWorker] = [] # initialised before _create_workers runs
|
|
29
|
+
self.threads: List[threading.Thread] = []
|
|
30
|
+
self.running = False
|
|
31
|
+
self.shutdown_flag = threading.Event()
|
|
32
|
+
self._create_workers() # populate self.workers once
|
|
33
|
+
|
|
34
|
+
def _create_workers(self) -> None:
|
|
35
|
+
"""Instantiate ``num_workers`` workers and append them to ``self.workers``."""
|
|
36
|
+
for i in range(self.num_workers):
|
|
37
|
+
worker = self.worker_type(
|
|
38
|
+
redis_url=self.redis_url,
|
|
39
|
+
worker_id=f"worker-{i + 1}",
|
|
40
|
+
**self.worker_kwargs,
|
|
41
|
+
)
|
|
42
|
+
self.workers.append(worker)
|
|
43
|
+
|
|
44
|
+
def start(self):
|
|
45
|
+
"""
|
|
46
|
+
Spawn one daemon thread per worker, then block until a shutdown signal
|
|
47
|
+
arrives (``stop()`` or a signal handler calling ``_signal_handler``).
|
|
48
|
+
"""
|
|
49
|
+
# Reset per-cycle state so repeated start()/stop() cycles (e.g. in
|
|
50
|
+
# tests) don't accumulate dead threads from a previous run or get
|
|
51
|
+
# stuck on an already-set flag from a prior shutdown.
|
|
52
|
+
self.threads = []
|
|
53
|
+
self.shutdown_flag.clear()
|
|
54
|
+
self.running = True
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
signal.signal(signal.SIGINT, self._signal_handler)
|
|
58
|
+
signal.signal(signal.SIGTERM, self._signal_handler)
|
|
59
|
+
except (ValueError, OSError) as e:
|
|
60
|
+
logger.debug(f"Could not register signal handlers: {e}")
|
|
61
|
+
|
|
62
|
+
for i, worker in enumerate(self.workers):
|
|
63
|
+
thread = threading.Thread(
|
|
64
|
+
target=worker.start,
|
|
65
|
+
name=f"Worker-{i + 1}",
|
|
66
|
+
daemon=True,
|
|
67
|
+
)
|
|
68
|
+
self.threads.append(thread)
|
|
69
|
+
thread.start()
|
|
70
|
+
logger.info(f"Started worker {i + 1}/{self.num_workers}")
|
|
71
|
+
|
|
72
|
+
# Block the calling thread until stop() or a signal fires.
|
|
73
|
+
self.shutdown_flag.wait()
|
|
74
|
+
self.stop()
|
|
75
|
+
|
|
76
|
+
def _signal_handler(self, signum, frame):
|
|
77
|
+
"""Handle SIGINT / SIGTERM by unblocking the main thread."""
|
|
78
|
+
logger.info("Received stop signal, shutting down worker pool...")
|
|
79
|
+
self.shutdown_flag.set()
|
|
80
|
+
|
|
81
|
+
def stop(self):
|
|
82
|
+
"""Gracefully stop all workers and join their threads."""
|
|
83
|
+
logger.info("Stopping all workers...")
|
|
84
|
+
|
|
85
|
+
# Set first so a direct stop() call (e.g. from a test or a completion
|
|
86
|
+
# handler) also unblocks start(), which may be parked on this wait().
|
|
87
|
+
self.shutdown_flag.set()
|
|
88
|
+
|
|
89
|
+
for worker in self.workers:
|
|
90
|
+
try:
|
|
91
|
+
worker.stop()
|
|
92
|
+
except Exception as e:
|
|
93
|
+
logger.error(f"Error stopping worker: {e}")
|
|
94
|
+
|
|
95
|
+
for thread in self.threads:
|
|
96
|
+
thread.join(timeout=3)
|
|
97
|
+
|
|
98
|
+
self.running = False
|
|
99
|
+
logger.info("All workers stopped")
|