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,49 @@
1
+ # video_scheduler/core/chain_mixin.py
2
+ from typing import Any
3
+
4
+ from broccoli.core.task.task import Task
5
+
6
+
7
+ class ChainWorkerMixin:
8
+ """Mixin to add chain support to a worker."""
9
+
10
+ def post_process(self, task: Task, success: bool) -> None:
11
+ """Override this in your worker and call super().post_process()."""
12
+ chain_id = task.payload.get("__chain_id")
13
+
14
+ if chain_id:
15
+ # Delete the chain-prefixed task key now that it's done
16
+ self._redis.delete(f"chain:{task.task_id}")
17
+
18
+ # Completion tasks have __chain_id set (so they get cleaned up above)
19
+ # but must not call continue_chain — they are not sequential chain steps
20
+ is_completion_task = task.payload.get("__chain_position") is None
21
+ if is_completion_task:
22
+ # Run completion handlers for the chain
23
+ if success:
24
+ self._run_completion_handlers(task, task.result)
25
+ else:
26
+ self._run_failure_handlers(
27
+ task,
28
+ Exception(task.error)
29
+ if task.error
30
+ else Exception("Chain task failed"),
31
+ )
32
+ return
33
+
34
+ if success:
35
+ from broccoli.core.chain.task_chain import TaskChain
36
+
37
+ chain = TaskChain()
38
+ finished = chain.continue_chain(
39
+ task, task.result, push_completion_task=True
40
+ )
41
+ if finished:
42
+ self.on_finish(chain_id, task.result)
43
+
44
+ # Run completion handlers for chain steps
45
+ self._run_completion_handlers(task, task.result)
46
+
47
+ def on_finish(self, chain_id: str, final_result: Any) -> None:
48
+ """Hook called when the entire chain is finished. Override in your worker."""
49
+ pass # Your existing on_finish logic here
@@ -0,0 +1,299 @@
1
+ # broccoli/core/chain/chain_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 ChainQueue:
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="chain_tasks:queue",
40
+ task_prefix="chain",
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.processing_key = f"{base}:processing"
55
+ self.sequence_key = f"{base}:sequence"
56
+
57
+ # ------------------------------------------------------------------
58
+ # Public API
59
+ # ------------------------------------------------------------------
60
+
61
+ def push(self, task: Task, priority: int = 0) -> str:
62
+ """
63
+ Persist task metadata then either enqueue it or register it as waiting.
64
+
65
+ If the task has no dependency, or its dependency is already completed,
66
+ it goes straight into the runnable queue. Otherwise it is stored as
67
+ ``waiting`` and added to ``dependency:<parent_id>`` so ``complete()``
68
+ can release it later.
69
+
70
+ Race condition handled with a register-then-verify pattern:
71
+ 1. Register in the dependency set first.
72
+ 2. Re-check the parent's status.
73
+ 3. If parent is already completed (raced past us), release immediately.
74
+ This eliminates the TOCTOU window present in a check-then-register approach.
75
+ """
76
+ # Always persist task metadata first so workers can read it after pop().
77
+ self._redis.hset(f"{self.task_prefix}:{task.task_id}", mapping=task.to_dict())
78
+
79
+ if task.depends_on:
80
+ # Mark as waiting immediately and register in the dependency set.
81
+ # We do this BEFORE reading the parent's status to close the
82
+ # check-then-register race window.
83
+ task.status = "waiting"
84
+ pipe = self._redis.pipeline()
85
+ pipe.hset(f"{self.task_prefix}:{task.task_id}", "status", "waiting")
86
+ # Store the requested priority so complete() can restore it.
87
+ pipe.hset(
88
+ f"{self.task_prefix}:{task.task_id}", _PRIORITY_FIELD, str(priority)
89
+ )
90
+ pipe.sadd(f"dependency:{task.depends_on}", task.task_id)
91
+ pipe.execute()
92
+
93
+ # Now verify: if the parent already completed before our sadd, release now.
94
+ dep_status = self._redis.hget(
95
+ f"{self.task_prefix}:{task.depends_on}", "status"
96
+ )
97
+ parent_done = (
98
+ dep_status
99
+ and (
100
+ dep_status.decode() if isinstance(dep_status, bytes) else dep_status
101
+ )
102
+ == "completed"
103
+ )
104
+
105
+ if parent_done:
106
+ # Parent completed before (or concurrently with) our registration.
107
+ # Release ourselves immediately and clean up.
108
+ self._redis.srem(f"dependency:{task.depends_on}", task.task_id)
109
+ self._enqueue(task.task_id, priority)
110
+ logger.debug(
111
+ f"Task {task.task_id}: dependency {task.depends_on} already "
112
+ "completed, enqueued immediately"
113
+ )
114
+ else:
115
+ logger.debug(
116
+ f"Task {task.task_id} waiting on dependency {task.depends_on}"
117
+ )
118
+ else:
119
+ self._enqueue(task.task_id, priority)
120
+
121
+ return task.task_id
122
+
123
+ def pop(self) -> Optional[Task]:
124
+ """
125
+ Pop the highest-priority runnable task and move it to the processing set.
126
+
127
+ No dependency checking happens here — that was handled at push / complete
128
+ time. Everything in the queue is ready to run.
129
+
130
+ Returns ``None`` if the queue is empty after the blocking timeout.
131
+ """
132
+ result = self._redis.bzpopmin(self.queue_key, timeout=1)
133
+ if result is None:
134
+ return None
135
+
136
+ _, task_id, _fifo_score = result
137
+ task_id = task_id.decode() if isinstance(task_id, bytes) else task_id
138
+
139
+ task_data = self._redis.hgetall(f"{self.task_prefix}:{task_id}")
140
+ if not task_data:
141
+ logger.error(
142
+ f"Task data missing for {task_id!r}; moving to dead-letter set"
143
+ )
144
+ self._redis.sadd(f"{self.task_prefix}:dead_letter", task_id)
145
+ return None
146
+
147
+ # Score in the processing set is a Unix timestamp so recover_stalled()
148
+ # can identify tasks that have been in-flight longer than expected.
149
+ now = time.time()
150
+ self._redis.zadd(self.processing_key, {task_id: now})
151
+ self._redis.hset(f"{self.task_prefix}:{task_id}", "status", "in_progress")
152
+
153
+ task = Task.from_dict(task_data)
154
+ task.status = "in_progress"
155
+ return task
156
+
157
+ def complete(self, task: Task) -> None:
158
+ """
159
+ Called after a task finishes successfully.
160
+
161
+ 1. Removes the task from the processing set.
162
+ 2. Reads any tasks that were waiting on this one and enqueues them,
163
+ restoring their original priority.
164
+ 3. Deletes the dependency set.
165
+ """
166
+ task_id = task.task_id
167
+ self._redis.zrem(self.processing_key, task_id)
168
+
169
+ dep_key = f"dependency:{task_id}"
170
+ waiting_ids = self._redis.smembers(dep_key)
171
+ if waiting_ids:
172
+ for raw_id in waiting_ids:
173
+ wid = raw_id.decode() if isinstance(raw_id, bytes) else raw_id
174
+ # Restore the priority the caller supplied when pushing the waiting task.
175
+ raw_pri = self._redis.hget(f"{self.task_prefix}:{wid}", _PRIORITY_FIELD)
176
+ priority = int(raw_pri) if raw_pri else 0
177
+ self._enqueue(wid, priority)
178
+ logger.info(
179
+ f"Released waiting task {wid} "
180
+ f"(dependency {task_id} completed, priority={priority})"
181
+ )
182
+ self._redis.delete(dep_key)
183
+
184
+ def fail(self, task: Task) -> None:
185
+ """Remove a permanently failed (or skipped) task from the processing set."""
186
+ self._redis.zrem(self.processing_key, task.task_id)
187
+
188
+ def requeue(self, task_id: str, priority: Optional[int] = None) -> None:
189
+ """
190
+ Move a task from the processing set back to the runnable queue (retry).
191
+
192
+ A fresh FIFO sequence number is issued so the retry goes to the back of
193
+ its priority tier rather than jumping the queue.
194
+
195
+ If ``priority`` isn't given explicitly, the task's originally-requested
196
+ priority (stashed under ``_PRIORITY_FIELD`` at push time) is restored,
197
+ matching the behaviour of ``complete()`` when it releases dependents.
198
+ Without this, every retry would silently reset to priority 0 and could
199
+ jump ahead of lower-priority tasks still waiting in the queue.
200
+ """
201
+ self._redis.zrem(self.processing_key, task_id)
202
+ if priority is None:
203
+ raw_pri = self._redis.hget(f"{self.task_prefix}:{task_id}", _PRIORITY_FIELD)
204
+ priority = int(raw_pri) if raw_pri else 0
205
+ self._enqueue(task_id, priority)
206
+
207
+ def recover_stalled(self, timeout_seconds: int = 3600) -> int:
208
+ """
209
+ Crash-recovery helper: re-enqueue tasks that have been in the processing
210
+ set for longer than ``timeout_seconds``.
211
+
212
+ The processing set uses Unix timestamps as scores (set by ``pop()``),
213
+ so the threshold is simply ``time.time() - timeout_seconds``.
214
+
215
+ Returns the number of tasks recovered.
216
+ """
217
+ threshold = time.time() - timeout_seconds
218
+ stalled = self._redis.zrangebyscore(self.processing_key, "-inf", threshold)
219
+ for raw_id in stalled:
220
+ tid = raw_id.decode() if isinstance(raw_id, bytes) else raw_id
221
+ self.requeue(tid)
222
+ logger.warning(f"Crash-recovery: re-enqueued stalled task {tid}")
223
+ return len(stalled)
224
+
225
+ # ------------------------------------------------------------------
226
+ # Convenience helpers
227
+ # ------------------------------------------------------------------
228
+
229
+ def get_task(self, task_id: str) -> Optional[Task]:
230
+ """Fetch task metadata by ID, or ``None`` if it doesn't exist."""
231
+ task_data = self._redis.hgetall(f"{self.task_prefix}:{task_id}")
232
+ if not task_data:
233
+ return None
234
+ return Task.from_dict(task_data)
235
+
236
+ def get_queue_name(self) -> str:
237
+ return self.queue_key
238
+
239
+ def is_empty(self) -> bool:
240
+ return self._redis.zcard(self.queue_key) == 0
241
+
242
+ def pop_with_timeout(self, timeout: int = 1) -> Optional[Task]:
243
+ """Alias for ``pop()``; timeout is already handled internally."""
244
+ return self.pop()
245
+
246
+ # ------------------------------------------------------------------
247
+ # Private helpers
248
+ # ------------------------------------------------------------------
249
+
250
+ def _enqueue(self, task_id: str, priority: int = 0) -> None:
251
+ """
252
+ Add *task_id* to the runnable sorted set.
253
+
254
+ Score = priority * _FIFO_TIER_SIZE + monotonic_seq
255
+
256
+ This gives strict priority ordering across tiers *and* FIFO within a
257
+ tier, without requiring timestamps or Lua scripts.
258
+ """
259
+ seq = self._redis.incr(self.sequence_key)
260
+ score = priority * _FIFO_TIER_SIZE + seq
261
+ self._redis.zadd(self.queue_key, {task_id: score})
262
+ self._redis.hset(
263
+ f"{self.task_prefix}:{task_id}",
264
+ mapping={"status": "pending", _PRIORITY_FIELD: str(priority)},
265
+ )
266
+ logger.debug(f"Enqueued task {task_id} (priority={priority}, seq={seq})")
267
+
268
+ def is_fully_drained(self) -> bool:
269
+ """True only when both the runnable queue and processing set are empty."""
270
+ pipe = self._redis.pipeline()
271
+ pipe.zcard(self.queue_key)
272
+ pipe.zcard(self.processing_key)
273
+ runnable, processing = pipe.execute()
274
+ return runnable == 0 and processing == 0
275
+
276
+ def get_waiting_for(self, task_id: str) -> list:
277
+ """Return the IDs of tasks currently blocked on ``task_id``."""
278
+ return [
279
+ tid.decode() if isinstance(tid, bytes) else tid
280
+ for tid in self._redis.smembers(f"dependency:{task_id}")
281
+ ]
282
+
283
+ def stats(self) -> dict:
284
+ """
285
+ Snapshot of queue depth for monitoring/diagnostics.
286
+
287
+ ``dead_letter`` reflects tasks whose hash went missing on pop() —
288
+ see ``pop()`` for how they get there.
289
+ """
290
+ pipe = self._redis.pipeline()
291
+ pipe.zcard(self.queue_key)
292
+ pipe.zcard(self.processing_key)
293
+ pipe.scard(f"{self.task_prefix}:dead_letter")
294
+ runnable, processing, dead_letter = pipe.execute()
295
+ return {
296
+ "runnable": runnable,
297
+ "processing": processing,
298
+ "dead_letter": dead_letter,
299
+ }
@@ -0,0 +1,201 @@
1
+ # video_scheduler/core/task_chain.py
2
+ import json
3
+ import logging
4
+ import uuid
5
+ from typing import Any, Dict, List
6
+ from xmlrpc.client import Boolean
7
+
8
+ from broccoli.core.chain.chain import Chain
9
+ from broccoli.core.redis_controller import RedisController
10
+ from broccoli.core.task.task import Task
11
+ from broccoli.core.task.task_queue import TaskQueue
12
+ from broccoli.core.task.task_registry import TaskRegistry
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class TaskChain:
18
+ """Chain multiple tasks together where each task passes its result to the next."""
19
+
20
+ def __init__(self, redis_url: str = "redis://localhost:6379", chain_id: str = None):
21
+ self.queue = TaskQueue(
22
+ queue_name="chain_tasks:queue", redis_url=redis_url, task_prefix="chain"
23
+ )
24
+ self._redis = RedisController(redis_url).get_client()
25
+ self.registry = TaskRegistry()
26
+ self.chain_id = chain_id or str(uuid.uuid4())
27
+
28
+ def chain(
29
+ self,
30
+ tasks: List[Dict[str, Any]],
31
+ shared_payload: Dict[str, Any] = None,
32
+ completion_task: str = None,
33
+ ) -> str:
34
+ """
35
+ Chain tasks together.
36
+
37
+ Args:
38
+ tasks: List of task configurations, each with 'task_type' and 'payload'
39
+ shared_payload: Data passed to all tasks in the chain
40
+ completion_task: Optional task to run after the chain completes
41
+
42
+ Returns:
43
+ chain_id: ID for tracking the entire chain
44
+
45
+ Example:
46
+ chain = TaskChain()
47
+ chain.chain([
48
+ {"task_type": "download_file", "payload": {"url": "https://..."}},
49
+ {"task_type": "process_video", "payload": {"quality": "high"}},
50
+ {"task_type": "upload_file", "payload": {"bucket": "my-bucket"}}
51
+ ], completion_task="on_chain_finished")
52
+ """
53
+ if not tasks:
54
+ raise ValueError("Cannot chain empty task list")
55
+
56
+ # Store chain metadata
57
+ chain = Chain(
58
+ chain_id=self.chain_id,
59
+ total_tasks=len(tasks),
60
+ completed_tasks=0,
61
+ failed=False,
62
+ status="pending",
63
+ )
64
+
65
+ if completion_task:
66
+ chain.completion_task = completion_task
67
+
68
+ # Save chain metadata to Redis
69
+ self._redis.hset(
70
+ f"chain:{self.chain_id}",
71
+ mapping={k: str(v) for k, v in chain.to_dict().items()},
72
+ )
73
+
74
+ # Assign task_ids to ALL tasks first, before creating any Task objects.
75
+ # This ensures the IDs stored in Redis match the Task objects pushed to the queue.
76
+ for t in tasks:
77
+ if "task_id" not in t:
78
+ t["task_id"] = str(uuid.uuid4())
79
+
80
+ # Store all task configs for reference
81
+ self._redis.set(
82
+ ex=86400, # 24 hours TTL
83
+ name=f"chain:{self.chain_id}:tasks",
84
+ value=json.dumps(tasks),
85
+ )
86
+
87
+ # Create and push first task using the now-stable task_id
88
+ first_task = tasks[0]
89
+ task = Task(
90
+ task_type=first_task["task_type"],
91
+ task_id=first_task["task_id"],
92
+ payload={
93
+ **first_task.get("payload", {}),
94
+ **(shared_payload or {}),
95
+ "__chain_id": self.chain_id,
96
+ "__chain_position": 0,
97
+ "__total_tasks": len(tasks),
98
+ "__is_first": True,
99
+ },
100
+ max_retries=first_task.get("max_retries", 3),
101
+ )
102
+
103
+ # Push first task
104
+ self.queue.push(task)
105
+ logger.info(f"Chain {self.chain_id} started with task {task.task_id}")
106
+
107
+ return self.chain_id
108
+
109
+ def continue_chain(
110
+ self,
111
+ previous_task: Task,
112
+ previous_result: Any,
113
+ push_completion_task: bool = True,
114
+ ) -> Boolean | None:
115
+ """
116
+ Continue the chain after a task completes.
117
+ Called by the worker's post_process hook.
118
+
119
+ Args:
120
+ push_completion_task: If True, enqueue the completion task when the chain
121
+ finishes. Set to False when the caller (e.g. ChainWorkerMixin.on_finish)
122
+ handles chain completion directly, to avoid running it twice.
123
+ """
124
+ chain_id = previous_task.payload.get("__chain_id")
125
+ if not chain_id:
126
+ return None
127
+
128
+ position = previous_task.payload.get("__chain_position", 0)
129
+ total_tasks = previous_task.payload.get("__total_tasks", 0)
130
+
131
+ # Update chain progress
132
+ self._redis.hincrby(f"chain:{chain_id}", "completed_tasks", 1)
133
+ self._redis.hset(f"chain:{chain_id}", "current_task", position + 1)
134
+
135
+ # Check if chain is complete
136
+ if position + 1 >= total_tasks:
137
+ self._redis.hset(f"chain:{chain_id}", "status", "completed")
138
+ logger.info(f"Chain {chain_id} completed successfully")
139
+
140
+ if push_completion_task:
141
+ metadata = self._redis.hgetall(f"chain:{chain_id}")
142
+ completion_task = metadata.get("completion_task")
143
+ if completion_task:
144
+ task = Task(
145
+ task_type=completion_task,
146
+ payload={
147
+ "chain_id": chain_id,
148
+ "result": previous_result,
149
+ # Mark as chain task so ChainWorkerMixin.post_process
150
+ # deletes chain:{task_id} after it runs
151
+ "__chain_id": chain_id,
152
+ },
153
+ )
154
+ self.queue.push(task)
155
+
156
+ return True
157
+
158
+ # Get next task configuration
159
+ tasks_json = self._redis.get(f"chain:{chain_id}:tasks")
160
+ if not tasks_json:
161
+ logger.error(f"Chain {chain_id} tasks not found")
162
+ return None
163
+
164
+ tasks = json.loads(tasks_json)
165
+ next_task_config = tasks[position + 1]
166
+
167
+ # Create next task using the pre-assigned task_id from the stored config
168
+ next_task = Task(
169
+ task_type=next_task_config["task_type"],
170
+ task_id=next_task_config["task_id"],
171
+ payload={
172
+ **next_task_config.get("payload", {}),
173
+ "__chain_id": chain_id,
174
+ "__chain_position": position + 1,
175
+ "__total_tasks": total_tasks,
176
+ "__previous_result": previous_result,
177
+ "__is_first": False,
178
+ },
179
+ max_retries=next_task_config.get("max_retries", 3),
180
+ )
181
+
182
+ # Push next task
183
+ self.queue.push(next_task)
184
+ logger.info(
185
+ f"Chain {chain_id} continuing with task {next_task.task_id} (position {position + 1})"
186
+ )
187
+
188
+ def get_chain_status(self, chain_id: str) -> Dict[str, Any]:
189
+ """Get the status of a chain."""
190
+ data = self._redis.hgetall(f"chain:{chain_id}")
191
+ if not data:
192
+ return {"status": "not_found"}
193
+
194
+ return {
195
+ "chain_id": chain_id,
196
+ "total_tasks": int(data.get("total_tasks", 0)),
197
+ "completed_tasks": int(data.get("completed_tasks", 0)),
198
+ "current_task": int(data.get("current_task", 0)),
199
+ "status": data.get("status", "unknown"),
200
+ "failed": data.get("failed", "False") == "True",
201
+ }
@@ -0,0 +1,20 @@
1
+ # video_scheduler/core/health.py
2
+ class HealthCheck:
3
+ def __init__(self, worker):
4
+ self.worker = worker
5
+
6
+ def check(self) -> dict:
7
+ return {
8
+ "status": "healthy" if self.worker.running else "unhealthy",
9
+ "worker_id": self.worker.worker_id,
10
+ "redis": self._check_redis(),
11
+ "tasks_processed": self.worker.tasks_processed,
12
+ "active_task": self.worker.current_task_id,
13
+ }
14
+
15
+ def _check_redis(self):
16
+ try:
17
+ self.worker.queue.redis.ping()
18
+ return {"status": "connected"}
19
+ except:
20
+ return {"status": "disconnected"}
@@ -0,0 +1,35 @@
1
+ import redis
2
+
3
+
4
+ class RedisController:
5
+ def __init__(self, redis_url: str = "redis://localhost:6379"):
6
+ self._redis = redis.from_url(redis_url, decode_responses=True)
7
+
8
+ def delete_all_keys(self):
9
+ """Delete all keys in Redis (use with caution)."""
10
+ return self._redis.flushdb()
11
+
12
+ def get_client(self):
13
+ """Get the Redis client instance."""
14
+ return self._redis
15
+
16
+ def view_all_keys(self):
17
+ """View all keys in Redis."""
18
+ return self._redis.keys("*")
19
+
20
+ def view_key(self, key: str):
21
+ """View value of a specific key."""
22
+ return self._redis.get(key)
23
+
24
+ def delete_key(self, key: str):
25
+ """Delete a specific key."""
26
+ return self._redis.delete(key)
27
+
28
+ def get_count(self, pattern="*"):
29
+ """Get count of keys matching a pattern."""
30
+ return len(self._redis.keys(pattern))
31
+
32
+ def view_all(self, pattern="*"):
33
+ """View all keys and their values matching a pattern."""
34
+ keys = self._redis.keys(pattern)
35
+ return {key.decode(): self._redis.get(key).decode() for key in keys}
@@ -0,0 +1,78 @@
1
+ # video_scheduler/core/result.py
2
+ import json
3
+ from dataclasses import dataclass
4
+
5
+ import redis
6
+
7
+ from broccoli.core.chain.chain import Chain
8
+ from broccoli.core.task.task import Task
9
+
10
+
11
+ class ResultBackend:
12
+ def __init__(self, redis_url: str):
13
+ self._redis = redis.from_url(redis_url)
14
+ self.ttl = 3600 # Default TTL for results in seconds
15
+
16
+ def store_task(self, task: Task) -> None:
17
+ """Store task result with TTL."""
18
+ key = f"result:{task.task_id}"
19
+ result_mapping = ResultMapping(
20
+ id=task.task_id,
21
+ result=task.result,
22
+ status=task.status,
23
+ chain=False,
24
+ error=task.error or "",
25
+ )
26
+
27
+ json_string = json.dumps(result_mapping.to_dict())
28
+ self._redis.set(name=key, ex=self.ttl, value=json_string)
29
+
30
+ def store_chain(self, chain: Chain) -> None:
31
+ """Store chain result with TTL."""
32
+ key = f"result:{chain.chain_id}"
33
+ result_mapping = ResultMapping(
34
+ id=chain.chain_id,
35
+ result=chain.result,
36
+ status=chain.status,
37
+ chain=True,
38
+ error="",
39
+ )
40
+
41
+ json_string = json.dumps(result_mapping.to_dict())
42
+ self._redis.set(name=key, ex=self.ttl, value=json_string)
43
+
44
+ def get(self, id: str) -> any:
45
+ """Retrieve task result."""
46
+ key = f"result:{id}"
47
+ data = self._redis.get(key)
48
+ if data:
49
+ return ResultMapping.from_dict(json.loads(data))
50
+ return None
51
+
52
+
53
+ @dataclass
54
+ class ResultMapping:
55
+ id: str
56
+ result: any
57
+ status: str
58
+ chain: bool
59
+ error: str = "" # Optional error field for tasks
60
+
61
+ def to_dict(self) -> dict:
62
+ return {
63
+ "id": self.id,
64
+ "result": json.dumps(self.result),
65
+ "status": self.status,
66
+ "chain": self.chain,
67
+ "error": self.error,
68
+ }
69
+
70
+ @classmethod
71
+ def from_dict(cls, data: dict) -> "ResultMapping":
72
+ return cls(
73
+ id=data.get("id"),
74
+ result=json.loads(data.get("result", "{}")),
75
+ status=data.get("status", "unknown"),
76
+ chain=data.get("chain", False),
77
+ error=data.get("error", ""),
78
+ )