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,58 @@
1
+ # video_scheduler/core/task.py
2
+ import json
3
+ import uuid
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from typing import Any, Optional
7
+
8
+
9
+ @dataclass
10
+ class Task:
11
+ task_id: str = field(default_factory=lambda: str(uuid.uuid4()))
12
+ task_type: str = ""
13
+ status: str = "pending"
14
+ progress: float = 0.0
15
+ retries: int = 0
16
+ max_retries: int = 3
17
+ error: Optional[str] = None
18
+ secondary_error: Optional[str] = None
19
+ payload: dict = field(default_factory=dict)
20
+ result: Any = None
21
+ depends_on: Optional[str] = None
22
+ created_at: str = field(default_factory=lambda: datetime.now().isoformat())
23
+ updated_at: Optional[str] = None
24
+
25
+ def to_dict(self) -> dict:
26
+ return {
27
+ "task_id": self.task_id,
28
+ "task_type": self.task_type,
29
+ "status": self.status,
30
+ "progress": str(self.progress),
31
+ "retries": str(self.retries),
32
+ "max_retries": str(self.max_retries),
33
+ "error": self.error or "",
34
+ "secondary_error": self.secondary_error or "",
35
+ "payload": json.dumps(self.payload),
36
+ "result": json.dumps(self.result) if self.result is not None else "",
37
+ "depends_on": self.depends_on or "",
38
+ "created_at": self.created_at or "",
39
+ "updated_at": self.updated_at or "",
40
+ }
41
+
42
+ @classmethod
43
+ def from_dict(cls, data: dict) -> "Task":
44
+ return cls(
45
+ task_id=data.get("task_id"),
46
+ task_type=data.get("task_type", ""),
47
+ status=data.get("status", "pending"),
48
+ progress=float(data.get("progress", 0.0)),
49
+ retries=int(data.get("retries", 0)),
50
+ max_retries=int(data.get("max_retries", 3)),
51
+ error=data.get("error") or None,
52
+ secondary_error=data.get("secondary_error") or None,
53
+ payload=json.loads(data.get("payload", "{}")),
54
+ result=json.loads(data.get("result")) if data.get("result") else None,
55
+ depends_on=data.get("depends_on") or None,
56
+ created_at=data.get("created_at") or datetime.now().isoformat(),
57
+ updated_at=data.get("updated_at") or None,
58
+ )
@@ -0,0 +1,300 @@
1
+ # broccoli/core/task/task_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 TaskQueue:
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: str = "tasks:queue",
40
+ task_prefix: str = "task",
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.base = base
55
+ self.processing_key = f"{base}:processing"
56
+ self.sequence_key = f"{base}:sequence"
57
+
58
+ # ------------------------------------------------------------------
59
+ # Public API
60
+ # ------------------------------------------------------------------
61
+
62
+ def push(self, task: Task, priority: int = 0) -> str:
63
+ """
64
+ Persist task metadata then either enqueue it or register it as waiting.
65
+
66
+ If the task has no dependency, or its dependency is already completed,
67
+ it goes straight into the runnable queue. Otherwise it is stored as
68
+ ``waiting`` and added to ``dependency:<parent_id>`` so ``complete()``
69
+ can release it later.
70
+
71
+ Race condition handled with a register-then-verify pattern:
72
+ 1. Register in the dependency set first.
73
+ 2. Re-check the parent's status.
74
+ 3. If parent is already completed (raced past us), release immediately.
75
+ This eliminates the TOCTOU window present in a check-then-register approach.
76
+ """
77
+ # Always persist task metadata first so workers can read it after pop().
78
+ self._redis.hset(f"{self.task_prefix}:{task.task_id}", mapping=task.to_dict())
79
+
80
+ if task.depends_on:
81
+ # Mark as waiting immediately and register in the dependency set.
82
+ # We do this BEFORE reading the parent's status to close the
83
+ # check-then-register race window.
84
+ task.status = "waiting"
85
+ pipe = self._redis.pipeline()
86
+ pipe.hset(f"{self.task_prefix}:{task.task_id}", "status", "waiting")
87
+ # Store the requested priority so complete() can restore it.
88
+ pipe.hset(
89
+ f"{self.task_prefix}:{task.task_id}", _PRIORITY_FIELD, str(priority)
90
+ )
91
+ pipe.sadd(f"dependency:{task.depends_on}", task.task_id)
92
+ pipe.execute()
93
+
94
+ # Now verify: if the parent already completed before our sadd, release now.
95
+ dep_status = self._redis.hget(
96
+ f"{self.task_prefix}:{task.depends_on}", "status"
97
+ )
98
+ parent_done = (
99
+ dep_status
100
+ and (
101
+ dep_status.decode() if isinstance(dep_status, bytes) else dep_status
102
+ )
103
+ == "completed"
104
+ )
105
+
106
+ if parent_done:
107
+ # Parent completed before (or concurrently with) our registration.
108
+ # Release ourselves immediately and clean up.
109
+ self._redis.srem(f"dependency:{task.depends_on}", task.task_id)
110
+ self._enqueue(task.task_id, priority)
111
+ logger.debug(
112
+ f"Task {task.task_id}: dependency {task.depends_on} already "
113
+ "completed, enqueued immediately"
114
+ )
115
+ else:
116
+ logger.debug(
117
+ f"Task {task.task_id} waiting on dependency {task.depends_on}"
118
+ )
119
+ else:
120
+ self._enqueue(task.task_id, priority)
121
+
122
+ return task.task_id
123
+
124
+ def pop(self) -> Optional[Task]:
125
+ """
126
+ Pop the highest-priority runnable task and move it to the processing set.
127
+
128
+ No dependency checking happens here — that was handled at push / complete
129
+ time. Everything in the queue is ready to run.
130
+
131
+ Returns ``None`` if the queue is empty after the blocking timeout.
132
+ """
133
+ result = self._redis.bzpopmin(self.queue_key, timeout=1)
134
+ if result is None:
135
+ return None
136
+
137
+ _, task_id, _fifo_score = result
138
+ task_id = task_id.decode() if isinstance(task_id, bytes) else task_id
139
+
140
+ task_data = self._redis.hgetall(f"{self.task_prefix}:{task_id}")
141
+ if not task_data:
142
+ logger.error(
143
+ f"Task data missing for {task_id!r}; moving to dead-letter set"
144
+ )
145
+ self._redis.sadd(f"{self.task_prefix}:dead_letter", task_id)
146
+ return None
147
+
148
+ # Score in the processing set is a Unix timestamp so recover_stalled()
149
+ # can identify tasks that have been in-flight longer than expected.
150
+ now = time.time()
151
+ self._redis.zadd(self.processing_key, {task_id: now})
152
+ self._redis.hset(f"{self.task_prefix}:{task_id}", "status", "in_progress")
153
+
154
+ task = Task.from_dict(task_data)
155
+ task.status = "in_progress"
156
+ return task
157
+
158
+ def complete(self, task: Task) -> None:
159
+ """
160
+ Called after a task finishes successfully.
161
+
162
+ 1. Removes the task from the processing set.
163
+ 2. Reads any tasks that were waiting on this one and enqueues them,
164
+ restoring their original priority.
165
+ 3. Deletes the dependency set.
166
+ """
167
+ task_id = task.task_id
168
+ self._redis.zrem(self.processing_key, task_id)
169
+
170
+ dep_key = f"dependency:{task_id}"
171
+ waiting_ids = self._redis.smembers(dep_key)
172
+ if waiting_ids:
173
+ for raw_id in waiting_ids:
174
+ wid = raw_id.decode() if isinstance(raw_id, bytes) else raw_id
175
+ # Restore the priority the caller supplied when pushing the waiting task.
176
+ raw_pri = self._redis.hget(f"{self.task_prefix}:{wid}", _PRIORITY_FIELD)
177
+ priority = int(raw_pri) if raw_pri else 0
178
+ self._enqueue(wid, priority)
179
+ logger.info(
180
+ f"Released waiting task {wid} "
181
+ f"(dependency {task_id} completed, priority={priority})"
182
+ )
183
+ self._redis.delete(dep_key)
184
+
185
+ def fail(self, task: Task) -> None:
186
+ """Remove a permanently failed (or skipped) task from the processing set."""
187
+ self._redis.zrem(self.processing_key, task.task_id)
188
+
189
+ def requeue(self, task_id: str, priority: Optional[int] = None) -> None:
190
+ """
191
+ Move a task from the processing set back to the runnable queue (retry).
192
+
193
+ A fresh FIFO sequence number is issued so the retry goes to the back of
194
+ its priority tier rather than jumping the queue.
195
+
196
+ If ``priority`` isn't given explicitly, the task's originally-requested
197
+ priority (stashed under ``_PRIORITY_FIELD`` at push time) is restored,
198
+ matching the behaviour of ``complete()`` when it releases dependents.
199
+ Without this, every retry would silently reset to priority 0 and could
200
+ jump ahead of lower-priority tasks still waiting in the queue.
201
+ """
202
+ self._redis.zrem(self.processing_key, task_id)
203
+ if priority is None:
204
+ raw_pri = self._redis.hget(f"{self.task_prefix}:{task_id}", _PRIORITY_FIELD)
205
+ priority = int(raw_pri) if raw_pri else 0
206
+ self._enqueue(task_id, priority)
207
+
208
+ def recover_stalled(self, timeout_seconds: int = 3600) -> int:
209
+ """
210
+ Crash-recovery helper: re-enqueue tasks that have been in the processing
211
+ set for longer than ``timeout_seconds``.
212
+
213
+ The processing set uses Unix timestamps as scores (set by ``pop()``),
214
+ so the threshold is simply ``time.time() - timeout_seconds``.
215
+
216
+ Returns the number of tasks recovered.
217
+ """
218
+ threshold = time.time() - timeout_seconds
219
+ stalled = self._redis.zrangebyscore(self.processing_key, "-inf", threshold)
220
+ for raw_id in stalled:
221
+ tid = raw_id.decode() if isinstance(raw_id, bytes) else raw_id
222
+ self.requeue(tid)
223
+ logger.warning(f"Crash-recovery: re-enqueued stalled task {tid}")
224
+ return len(stalled)
225
+
226
+ # ------------------------------------------------------------------
227
+ # Convenience helpers
228
+ # ------------------------------------------------------------------
229
+
230
+ def get_task(self, task_id: str) -> Optional[Task]:
231
+ """Fetch task metadata by ID, or ``None`` if it doesn't exist."""
232
+ task_data = self._redis.hgetall(f"{self.task_prefix}:{task_id}")
233
+ if not task_data:
234
+ return None
235
+ return Task.from_dict(task_data)
236
+
237
+ def get_queue_name(self) -> str:
238
+ return self.queue_key
239
+
240
+ def is_empty(self) -> bool:
241
+ return self._redis.zcard(self.queue_key) == 0
242
+
243
+ def pop_with_timeout(self, timeout: int = 1) -> Optional[Task]:
244
+ """Alias for ``pop()``; timeout is already handled internally."""
245
+ return self.pop()
246
+
247
+ # ------------------------------------------------------------------
248
+ # Private helpers
249
+ # ------------------------------------------------------------------
250
+
251
+ def _enqueue(self, task_id: str, priority: int = 0) -> None:
252
+ """
253
+ Add *task_id* to the runnable sorted set.
254
+
255
+ Score = priority * _FIFO_TIER_SIZE + monotonic_seq
256
+
257
+ This gives strict priority ordering across tiers *and* FIFO within a
258
+ tier, without requiring timestamps or Lua scripts.
259
+ """
260
+ seq = self._redis.incr(self.sequence_key)
261
+ score = priority * _FIFO_TIER_SIZE + seq
262
+ self._redis.zadd(self.queue_key, {task_id: score})
263
+ self._redis.hset(
264
+ f"{self.task_prefix}:{task_id}",
265
+ mapping={"status": "pending", _PRIORITY_FIELD: str(priority)},
266
+ )
267
+ logger.debug(f"Enqueued task {task_id} (priority={priority}, seq={seq})")
268
+
269
+ def is_fully_drained(self) -> bool:
270
+ """True only when both the runnable queue and processing set are empty."""
271
+ pipe = self._redis.pipeline()
272
+ pipe.zcard(self.queue_key)
273
+ pipe.zcard(self.processing_key)
274
+ runnable, processing = pipe.execute()
275
+ return runnable == 0 and processing == 0
276
+
277
+ def get_waiting_for(self, task_id: str) -> list:
278
+ """Return the IDs of tasks currently blocked on ``task_id``."""
279
+ return [
280
+ tid.decode() if isinstance(tid, bytes) else tid
281
+ for tid in self._redis.smembers(f"dependency:{task_id}")
282
+ ]
283
+
284
+ def stats(self) -> dict:
285
+ """
286
+ Snapshot of queue depth for monitoring/diagnostics.
287
+
288
+ ``dead_letter`` reflects tasks whose hash went missing on pop() —
289
+ see ``pop()`` for how they get there.
290
+ """
291
+ pipe = self._redis.pipeline()
292
+ pipe.zcard(self.queue_key)
293
+ pipe.zcard(self.processing_key)
294
+ pipe.scard(f"{self.task_prefix}:dead_letter")
295
+ runnable, processing, dead_letter = pipe.execute()
296
+ return {
297
+ "runnable": runnable,
298
+ "processing": processing,
299
+ "dead_letter": dead_letter,
300
+ }
@@ -0,0 +1,47 @@
1
+ # video_scheduler/core/task_registry.py
2
+ import logging
3
+ from typing import Any, Callable, Dict, Optional
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class TaskRegistry:
9
+ """Registry for task handlers that can be executed by workers."""
10
+
11
+ _instance = None
12
+ _handlers: Dict[str, Callable] = {}
13
+
14
+ def __new__(cls):
15
+ if cls._instance is None:
16
+ cls._instance = super().__new__(cls)
17
+ return cls._instance
18
+
19
+ def register(self, task_type: str):
20
+ """Decorator to register a task handler."""
21
+
22
+ def decorator(func: Callable):
23
+ self._handlers[task_type] = func
24
+ logger.info(f"Registered handler for task type: {task_type}")
25
+ return func
26
+
27
+ return decorator
28
+
29
+ def register_manually(self, task_type: str, handler: Callable):
30
+ """Manually register a task handler."""
31
+ self._handlers[task_type] = handler
32
+ logger.info(f"Manually registered handler for task type: {task_type}")
33
+
34
+ def get_handler(self, task_type: str) -> Optional[Callable]:
35
+ """Get the handler for a task type."""
36
+ return self._handlers.get(task_type)
37
+
38
+ def get_all_handlers(self) -> Dict[str, Callable]:
39
+ """Get all registered handlers."""
40
+ return self._handlers
41
+
42
+ def execute(self, task_type: str, payload: Dict[str, Any], **kwargs) -> Any:
43
+ """Execute a task handler."""
44
+ handler = self.get_handler(task_type)
45
+ if not handler:
46
+ raise ValueError(f"No handler registered for task type: {task_type}")
47
+ return handler(payload, **kwargs)
@@ -0,0 +1,15 @@
1
+ from broccoli.workers.async_worker import AsyncWorker
2
+ from broccoli.workers.base_worker import BaseWorker
3
+ from broccoli.workers.chain_worker import ChainWorker
4
+ from broccoli.workers.hybrid_worker import HybridWorker
5
+ from broccoli.workers.threaded_worker import ThreadedWorker
6
+ from broccoli.workers.worker_pool import WorkerPool
7
+
8
+ __all__ = [
9
+ "BaseWorker",
10
+ "ChainWorker",
11
+ "ThreadedWorker",
12
+ "AsyncWorker",
13
+ "HybridWorker",
14
+ "WorkerPool",
15
+ ]
@@ -0,0 +1,157 @@
1
+ # broccoli/workers/async_worker.py
2
+ # PEP 563 defers annotation evaluation, so the PEP 604 `X | None` and
3
+ # lowercase-generic (`set[str]`) hints below don't raise on Python 3.9,
4
+ # which doesn't support that syntax natively (3.10+ only).
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import logging
9
+
10
+ import redis
11
+
12
+ from broccoli.core.task.task import Task
13
+ from broccoli.workers.base_worker import BaseWorker
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class AsyncWorker(BaseWorker):
19
+ """
20
+ Worker that processes tasks concurrently using asyncio.
21
+
22
+ CPU-bound / blocking task handlers are offloaded to the default thread-pool
23
+ executor so they don't stall the event loop.
24
+
25
+ State transitions (complete / fail / requeue) are fully delegated to
26
+ ``BaseWorker._handle_task_result`` — no duplicate logic here.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ redis_url: str = "redis://localhost:6379",
32
+ worker_id: str = None,
33
+ max_concurrent: int = 10,
34
+ ):
35
+ super().__init__(redis_url, worker_id)
36
+ self.max_concurrent = max_concurrent
37
+ # Semaphore is created lazily inside the running event loop to avoid
38
+ # "no running event loop" errors at construction time.
39
+ self._semaphore: asyncio.Semaphore | None = None
40
+ self.active_tasks: set[str] = set()
41
+
42
+ # ------------------------------------------------------------------
43
+ # Async task lifecycle
44
+ # ------------------------------------------------------------------
45
+
46
+ async def process_task_async(self, task: Task) -> None:
47
+ """Acquire a concurrency slot and process one task."""
48
+ async with self._semaphore:
49
+ try:
50
+ logger.info(f"AsyncWorker {self.worker_id} processing {task.task_id}")
51
+
52
+ if not self.pre_process(task):
53
+ logger.info(f"Task {task.task_id} skipped by pre_process")
54
+ self.queue.fail(task) # remove from processing set
55
+ return
56
+
57
+ # Use get_running_loop() — get_event_loop() is deprecated in 3.10+
58
+ # and raises a DeprecationWarning inside a running coroutine.
59
+ loop = asyncio.get_running_loop()
60
+ # Only self.process() is allowed to fail the task via exception.
61
+ # _handle_task_result / post_process must be allowed to propagate
62
+ # their own errors to the outer except below, rather than being
63
+ # re-invoked a second time on an already-transitioned task.
64
+ try:
65
+ success = await asyncio.wait_for(
66
+ loop.run_in_executor(None, self.process, task),
67
+ timeout=self.task_timeout,
68
+ )
69
+ except asyncio.TimeoutError:
70
+ logger.error(
71
+ f"Task {task.task_id} timed out after {self.task_timeout}s"
72
+ )
73
+ task.error = f"Task timed out after {self.task_timeout}s"
74
+ success = False
75
+ except Exception as e:
76
+ logger.error(
77
+ f"Task {task.task_id} handler raised: {e}", exc_info=True
78
+ )
79
+ task.error = str(e)
80
+ success = False
81
+
82
+ # Central state machine: complete / requeue / fail
83
+ self._handle_task_result(task, success)
84
+
85
+ # Result storage and user-facing callbacks
86
+ self.post_process(task, success)
87
+
88
+ except Exception as e:
89
+ # Reached only if _handle_task_result/post_process themselves
90
+ # raised — the task's queue state may already be inconsistent,
91
+ # so we log rather than re-running the state machine on it again.
92
+ logger.error(
93
+ f"Task {task.task_id} failed outside handler: {e}", exc_info=True
94
+ )
95
+ finally:
96
+ self.active_tasks.discard(task.task_id)
97
+
98
+ # ------------------------------------------------------------------
99
+ # Event-loop entry points
100
+ # ------------------------------------------------------------------
101
+
102
+ async def start_async(self):
103
+ """Main async loop: pop tasks and dispatch them as asyncio tasks."""
104
+ self._register_signal_handlers()
105
+ # Semaphore must be created inside the running loop.
106
+ self._semaphore = asyncio.Semaphore(self.max_concurrent)
107
+ self.running = True
108
+ logger.info(
109
+ f"AsyncWorker {self.worker_id} started "
110
+ f"(max_concurrent={self.max_concurrent})"
111
+ )
112
+
113
+ backoff = 1
114
+ while self.running:
115
+ try:
116
+ if len(self.active_tasks) >= self.max_concurrent:
117
+ await asyncio.sleep(0.05)
118
+ continue
119
+
120
+ loop = asyncio.get_running_loop()
121
+ task = await loop.run_in_executor(None, self.queue.pop)
122
+ backoff = 1 # reset after any successful Redis round-trip
123
+
124
+ if task is None:
125
+ await asyncio.sleep(0.05)
126
+ continue
127
+
128
+ self.active_tasks.add(task.task_id)
129
+ asyncio.create_task(self.process_task_async(task))
130
+ logger.info(
131
+ f"Dispatched {task.task_id} "
132
+ f"(active: {len(self.active_tasks)}/{self.max_concurrent})"
133
+ )
134
+
135
+ except redis.exceptions.RedisError as e:
136
+ logger.error(
137
+ f"AsyncWorker {self.worker_id} Redis error: {e}, "
138
+ f"retrying in {backoff}s",
139
+ exc_info=True,
140
+ )
141
+ await asyncio.sleep(backoff)
142
+ backoff = min(backoff * 2, 60)
143
+ except Exception as e:
144
+ logger.error(
145
+ f"AsyncWorker {self.worker_id} loop error: {e}", exc_info=True
146
+ )
147
+ await asyncio.sleep(1)
148
+
149
+ # Drain any still-running tasks before exiting.
150
+ while self.active_tasks:
151
+ await asyncio.sleep(0.05)
152
+
153
+ logger.info(f"AsyncWorker {self.worker_id} stopped")
154
+
155
+ def start(self):
156
+ """Synchronous entry point — runs the async loop until completion."""
157
+ asyncio.run(self.start_async())