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,339 @@
1
+ # broccoli/workers/base_worker.py
2
+ import logging
3
+ import signal
4
+ import time
5
+ from abc import ABC
6
+ from datetime import datetime
7
+ from typing import Any, Callable, List
8
+
9
+ import redis
10
+
11
+ from broccoli.core.redis_controller import RedisController
12
+ from broccoli.core.result import ResultBackend
13
+ from broccoli.core.task.task import Task
14
+ from broccoli.core.task.task_queue import TaskQueue
15
+ from broccoli.core.task.task_registry import TaskRegistry
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class BaseWorker(ABC):
21
+ def __init__(
22
+ self,
23
+ redis_url: str = "redis://localhost:6379",
24
+ worker_id: str = None,
25
+ queue_name: str = "tasks:queue",
26
+ task_prefix: str = "task",
27
+ ):
28
+ self.redis_url = redis_url
29
+ self._redis = RedisController(redis_url).get_client()
30
+ self.task_prefix = task_prefix
31
+ self.queue = TaskQueue(
32
+ queue_name=queue_name, redis_url=redis_url, task_prefix=task_prefix
33
+ )
34
+ self.registry = TaskRegistry()
35
+ self.running = False
36
+ self.worker_id = worker_id or f"worker-{id(self)}"
37
+ self.task_timeout = 3600
38
+ self.result = ResultBackend(redis_url)
39
+
40
+ # Handler lists
41
+ self._completion_handlers: List[Callable[[Task, Any], None]] = []
42
+ self._failure_handlers: List[Callable[[Task, Exception], None]] = []
43
+ self._pre_process_handlers: List[Callable[[Task], bool]] = []
44
+ self._post_process_handlers: List[Callable[[Task, bool], None]] = []
45
+
46
+ # ============ Handler Registration Methods ============
47
+
48
+ def add_completion_handler(self, handler: Callable[[Task, Any], None]):
49
+ """Add a handler to run when tasks complete successfully."""
50
+ self._completion_handlers.append(handler)
51
+ return self
52
+
53
+ def add_failure_handler(self, handler: Callable[[Task, Exception], None]):
54
+ """Add a handler to run when tasks fail."""
55
+ self._failure_handlers.append(handler)
56
+ return self
57
+
58
+ def add_pre_process_handler(self, handler: Callable[[Task], bool]):
59
+ """Add a handler to run before task processing. Return False to skip."""
60
+ self._pre_process_handlers.append(handler)
61
+ return self
62
+
63
+ def add_post_process_handler(self, handler: Callable[[Task, bool], None]):
64
+ """Add a handler to run after task processing (regardless of success)."""
65
+ self._post_process_handlers.append(handler)
66
+ return self
67
+
68
+ # ============ Decorator Methods ============
69
+
70
+ def on_complete(self, func):
71
+ """Decorator to register completion handler."""
72
+ self._completion_handlers.append(func)
73
+ return func
74
+
75
+ def on_failure(self, func):
76
+ """Decorator to register failure handler."""
77
+ self._failure_handlers.append(func)
78
+ return func
79
+
80
+ def on_pre_process(self, func):
81
+ """Decorator to register pre-process handler."""
82
+ self._pre_process_handlers.append(func)
83
+ return func
84
+
85
+ def on_post_process(self, func):
86
+ """Decorator to register post-process handler."""
87
+ self._post_process_handlers.append(func)
88
+ return func
89
+
90
+ # ============ Handler Execution Methods ============
91
+
92
+ def _run_completion_handlers(self, task: Task, result: Any):
93
+ for handler in self._completion_handlers:
94
+ try:
95
+ handler(task, result)
96
+ except Exception as e:
97
+ logger.error(f"Completion handler failed: {e}", exc_info=True)
98
+
99
+ def _run_failure_handlers(self, task: Task, error: Exception):
100
+ for handler in self._failure_handlers:
101
+ try:
102
+ handler(task, error)
103
+ except Exception as e:
104
+ logger.error(f"Failure handler failed: {e}", exc_info=True)
105
+
106
+ def _run_pre_process_handlers(self, task: Task) -> bool:
107
+ """Return False if any handler returns False or raises."""
108
+ for handler in self._pre_process_handlers:
109
+ try:
110
+ if not handler(task):
111
+ return False
112
+ except Exception as e:
113
+ logger.error(f"Pre-process handler failed: {e}", exc_info=True)
114
+ return False
115
+ return True
116
+
117
+ def _run_post_process_handlers(self, task: Task, success: bool):
118
+ for handler in self._post_process_handlers:
119
+ try:
120
+ handler(task, success)
121
+ except Exception as e:
122
+ logger.error(f"Post-process handler failed: {e}", exc_info=True)
123
+
124
+ # ============ Override Hooks ============
125
+
126
+ def pre_process(self, task: Task) -> bool:
127
+ """
128
+ Hook called before processing. Override in subclasses for custom logic.
129
+ Registered handlers run first; return False from any handler to skip the task.
130
+ """
131
+ return self._run_pre_process_handlers(task)
132
+
133
+ def post_process(self, task: Task, success: bool) -> None:
134
+ """
135
+ Hook called after processing (and after queue-state transitions).
136
+
137
+ Only runs result storage and fires completion/failure handlers for
138
+ terminal states (completed or failed). Requeued tasks (status='pending')
139
+ are skipped entirely — their hash must remain in Redis for the next
140
+ worker that picks them up.
141
+
142
+ Queue management (complete / fail / requeue) has already happened in
143
+ ``_handle_task_result`` before this is called.
144
+ """
145
+ self._run_post_process_handlers(task, success)
146
+
147
+ # Chain tasks are cleaned up in bulk by ChainWorkerMixin.
148
+ if task.payload.get("__chain_id"):
149
+ logger.info(
150
+ f"Task {task.task_id} {task.status} (chain task, skipping result store)"
151
+ )
152
+ return
153
+
154
+ # Do not store or delete a requeued task — it will be retried and still
155
+ # needs its hash in Redis. The worker that eventually completes or
156
+ # permanently fails it will handle cleanup.
157
+ if task.status == "pending":
158
+ return
159
+
160
+ # On permanent failure, record the task in a dead-letter set *before*
161
+ # touching result storage or deleting its hash, so it stays
162
+ # inspectable/re-queueable even if result.store_task() below raises
163
+ # (e.g. a Redis error) and the hash still ends up deleted.
164
+ if task.status == "failed":
165
+ try:
166
+ self._redis.zadd(
167
+ f"{self.task_prefix}:dead_letter", {task.task_id: time.time()}
168
+ )
169
+ except Exception as e:
170
+ logger.error(
171
+ f"Failed to record {task.task_id} in dead-letter set: {e}",
172
+ exc_info=True,
173
+ )
174
+
175
+ # Persist result and remove the task metadata hash.
176
+ self.result.store_task(task)
177
+ self._redis.delete(f"{self.task_prefix}:{task.task_id}")
178
+ logger.info(f"Task {task.task_id} {task.status} — result stored")
179
+
180
+ if success:
181
+ self._run_completion_handlers(task, task.result)
182
+ else:
183
+ self._run_failure_handlers(
184
+ task,
185
+ Exception(task.error) if task.error else Exception("Unknown error"),
186
+ )
187
+
188
+ # ============ Core Task Lifecycle ============
189
+
190
+ def process(self, task: Task) -> bool:
191
+ """
192
+ Execute the task using its registered handler.
193
+ Returns True on success, False on failure.
194
+ """
195
+ try:
196
+ handler = self.registry.get_handler(task.task_type)
197
+ if not handler:
198
+ task.error = f"No handler registered for task type: {task.task_type}"
199
+ logger.error(task.error)
200
+ return False
201
+
202
+ task.result = handler(task.payload)
203
+ return True
204
+
205
+ except Exception as e:
206
+ task.error = str(e)
207
+ logger.error(f"Task {task.task_id} failed: {e}", exc_info=True)
208
+ return False
209
+
210
+ def _handle_task_result(self, task: Task, success: bool) -> None:
211
+ """
212
+ Central state machine for task outcomes.
213
+
214
+ Transitions:
215
+ success → completed — queue.complete() releases dependents
216
+ failure, retries → pending — queue.requeue() moves back to runnable queue
217
+ failure, no retry → failed — queue.fail() removes from processing set
218
+
219
+ _update_task() is called BEFORE queue.complete() so that any concurrent
220
+ push() checking the parent's status will see 'completed' rather than
221
+ 'in_progress' when deciding whether to wait or enqueue immediately.
222
+
223
+ Subclasses (ThreadedWorker, AsyncWorker, HybridWorker) inherit this so
224
+ the retry / dependency-release logic lives in exactly one place.
225
+ """
226
+ if success:
227
+ task.status = "completed"
228
+ task.progress = 100.0
229
+ # Persist 'completed' status BEFORE releasing dependents so that
230
+ # any concurrent push() checking this task's status sees the
231
+ # terminal state and enqueues immediately rather than waiting.
232
+ self._update_task(task)
233
+ self.queue.complete(task) # releases any waiting dependents
234
+ else:
235
+ task.retries += 1
236
+ if task.retries >= task.max_retries:
237
+ task.status = "failed"
238
+ if not task.error:
239
+ task.error = "Max retries exceeded"
240
+ self._update_task(task)
241
+ self.queue.fail(task)
242
+ else:
243
+ task.status = "pending"
244
+ self._update_task(task)
245
+ self.queue.requeue(task.task_id)
246
+ logger.info(
247
+ f"Task {task.task_id} requeued "
248
+ f"(attempt {task.retries}/{task.max_retries})"
249
+ )
250
+
251
+ def _update_task(self, task: Task) -> None:
252
+ """Persist current task state to Redis (skipped for chain tasks)."""
253
+ if task.payload.get("__chain_id"):
254
+ return
255
+ task.updated_at = datetime.now().isoformat()
256
+ self._redis.hset(f"{self.task_prefix}:{task.task_id}", mapping=task.to_dict())
257
+
258
+ # ============ Worker Loop ============
259
+
260
+ def start(self):
261
+ """
262
+ Start the blocking worker loop.
263
+
264
+ NOTE — ``task_timeout`` is NOT enforced here. This loop runs
265
+ ``self.process(task)`` synchronously on the only thread the worker
266
+ has; there is no clean, non-hacky way to abort a running Python
267
+ function from another thread (killing threads is unsafe/unsupported
268
+ in CPython). ThreadedWorker, AsyncWorker, and HybridWorker all
269
+ enforce ``task_timeout`` because they have a spare thread/event loop
270
+ to wait on. If you need timeout enforcement, use one of those
271
+ instead of the plain BaseWorker loop.
272
+ """
273
+ self._register_signal_handlers()
274
+ self.running = True
275
+ logger.info(f"Worker {self.worker_id} started")
276
+
277
+ backoff = 1
278
+ while self.running:
279
+ try:
280
+ task = self.queue.pop()
281
+ backoff = 1 # reset after any successful Redis round-trip
282
+ if task is None:
283
+ continue
284
+
285
+ logger.info(
286
+ f"Worker {self.worker_id} processing task "
287
+ f"{task.task_id} ({task.task_type})"
288
+ )
289
+
290
+ if not self.pre_process(task):
291
+ logger.info(f"Task {task.task_id} skipped by pre_process")
292
+ # Remove from processing set without touching result storage.
293
+ self.queue.fail(task)
294
+ continue
295
+
296
+ success = self.process(task)
297
+ self._handle_task_result(task, success)
298
+ self.post_process(task, success)
299
+
300
+ except redis.exceptions.RedisError as e:
301
+ logger.error(
302
+ f"Worker {self.worker_id} Redis error: {e}, retrying in {backoff}s",
303
+ exc_info=True,
304
+ )
305
+ time.sleep(backoff)
306
+ backoff = min(backoff * 2, 60)
307
+ except Exception as e:
308
+ logger.error(
309
+ f"Worker {self.worker_id} encountered error: {e}", exc_info=True
310
+ )
311
+ time.sleep(1)
312
+
313
+ logger.info(f"Worker {self.worker_id} stopped")
314
+
315
+ def _stop_handler(self, signum, frame):
316
+ logger.info(f"Worker {self.worker_id} received stop signal")
317
+ self.running = False
318
+
319
+ def _register_signal_handlers(self):
320
+ """
321
+ Register SIGINT/SIGTERM to call ``_stop_handler``.
322
+
323
+ ``signal.signal`` only works from the main thread — when a worker is
324
+ run inside ``WorkerPool`` it lives on a background daemon thread, so
325
+ we skip registration there and rely on ``WorkerPool`` catching the
326
+ signal itself and calling ``stop()`` on each worker instead.
327
+ """
328
+ import threading
329
+
330
+ if threading.current_thread() is not threading.main_thread():
331
+ return
332
+ try:
333
+ signal.signal(signal.SIGINT, self._stop_handler)
334
+ signal.signal(signal.SIGTERM, self._stop_handler)
335
+ except (ValueError, OSError) as e:
336
+ logger.debug(f"Could not register signal handlers: {e}")
337
+
338
+ def stop(self):
339
+ self.running = False
@@ -0,0 +1,94 @@
1
+ # broccoli/workers/chain_worker.py
2
+ import json
3
+ import logging
4
+
5
+ from broccoli.core.chain.chain import Chain
6
+ from broccoli.core.chain.chain_mixin import ChainWorkerMixin
7
+ from broccoli.core.redis_controller import RedisController
8
+ from broccoli.core.result import ResultBackend
9
+ from broccoli.workers.base_worker import BaseWorker
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ChainWorker(ChainWorkerMixin, BaseWorker):
15
+ """
16
+ Worker that processes chained tasks.
17
+
18
+ Each step in a chain carries ``__chain_id`` in its payload so that
19
+ ``post_process`` in BaseWorker skips individual result storage —
20
+ the chain result is written atomically by ``on_chain_finished``.
21
+
22
+ With the new TaskQueue design, dependency release between chain steps
23
+ happens automatically inside ``queue.complete()`` when a step finishes,
24
+ provided each step's ``depends_on`` is set to the previous step's task_id
25
+ at push time. ``ChainWorkerMixin`` is responsible for setting that up.
26
+ """
27
+
28
+ def __init__(self, redis_url: str = "redis://localhost:6379"):
29
+ super().__init__(
30
+ redis_url=redis_url,
31
+ queue_name="chain_tasks:queue",
32
+ task_prefix="chain",
33
+ )
34
+
35
+ # The final step of every chain pushes an "on_chain_finished" task
36
+ # whose handler ties everything together.
37
+ self.registry.register_manually(
38
+ "on_chain_finished",
39
+ self.on_chain_finished,
40
+ )
41
+
42
+ self.result_backend = ResultBackend(redis_url)
43
+ self._redis = RedisController(redis_url).get_client()
44
+
45
+ # ------------------------------------------------------------------
46
+ # Chain completion
47
+ # ------------------------------------------------------------------
48
+
49
+ def on_chain_finished(self, payload: dict):
50
+ """
51
+ Registered task handler called when the last step in a chain completes.
52
+
53
+ Reads the chain record, attaches the final result, persists it via the
54
+ result backend, and runs full cleanup.
55
+ """
56
+ chain_id = payload.get("chain_id")
57
+ final_result = payload.get("result")
58
+
59
+ chain = Chain.from_dict(self._redis.hgetall(f"{self.task_prefix}:{chain_id}"))
60
+ chain.result = final_result
61
+ self.result_backend.store_chain(chain)
62
+ self.cleanup(chain_id)
63
+
64
+ def cleanup(self, chain_id: str) -> None:
65
+ """
66
+ Remove all per-step task hashes and chain metadata from Redis.
67
+
68
+ Queue sorted-set entries have already been removed by the TaskQueue
69
+ methods (``complete`` / ``fail``) as each step finished — this method
70
+ only deletes leftover hash keys.
71
+ """
72
+ tasks_raw = self._redis.get(f"{self.task_prefix}:{chain_id}:tasks")
73
+ if not tasks_raw:
74
+ logger.warning(f"No task list found for chain {chain_id} during cleanup")
75
+ return
76
+
77
+ tasks = json.loads(tasks_raw)
78
+ for task in tasks:
79
+ task_id = task.get("task_id")
80
+ if task_id:
81
+ self._redis.delete(f"{self.task_prefix}:{task_id}")
82
+ logger.debug(f"Deleted task hash for {task_id}")
83
+
84
+ self._redis.delete(f"{self.task_prefix}:{chain_id}")
85
+ self._redis.delete(f"{self.task_prefix}:{chain_id}:tasks")
86
+ # self._redis.delete(f"{self.queue.base}:sequence")
87
+ logger.info(f"Chain {chain_id} cleaned up")
88
+
89
+ def on_finish(self, chain_id: str, final_result) -> None:
90
+ """
91
+ Hook called when the entire chain is finished.
92
+ Override in subclasses for custom post-chain logic.
93
+ """
94
+ self.on_chain_finished({"chain_id": chain_id, "result": final_result})