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