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,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})
|