toro-queue 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.
- toro/__init__.py +9 -0
- toro/connection.py +31 -0
- toro/errors.py +15 -0
- toro/job.py +154 -0
- toro/keys.py +108 -0
- toro/py.typed +0 -0
- toro/queue.py +545 -0
- toro/scheduler.py +37 -0
- toro/scripts.py +433 -0
- toro/worker.py +525 -0
- toro_queue-0.1.0.dist-info/METADATA +127 -0
- toro_queue-0.1.0.dist-info/RECORD +14 -0
- toro_queue-0.1.0.dist-info/WHEEL +4 -0
- toro_queue-0.1.0.dist-info/licenses/LICENSE +21 -0
toro/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""toro — an async-first, Redis-backed job queue for Python."""
|
|
2
|
+
|
|
3
|
+
from .errors import JobFailedError, ToroError
|
|
4
|
+
from .job import Job, JobOptions, JobState
|
|
5
|
+
from .queue import Queue
|
|
6
|
+
from .worker import Worker
|
|
7
|
+
|
|
8
|
+
__all__ = ["Job", "JobFailedError", "JobOptions", "JobState", "Queue", "ToroError", "Worker"]
|
|
9
|
+
__version__ = "0.0.1"
|
toro/connection.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Redis connection factory with sane defaults for toro's long-lived clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import redis.asyncio as aioredis
|
|
6
|
+
from redis.asyncio.retry import Retry
|
|
7
|
+
from redis.backoff import ExponentialBackoff
|
|
8
|
+
from redis.exceptions import ConnectionError as RedisConnectionError
|
|
9
|
+
from redis.exceptions import TimeoutError as RedisTimeoutError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def connect(url: str) -> aioredis.Redis:
|
|
13
|
+
"""Open a ``decode_responses`` client tuned for toro's long-lived connections.
|
|
14
|
+
|
|
15
|
+
Connections that sit idle for a while (a worker's blocking pop, the result()
|
|
16
|
+
pub/sub, mostly-idle producers) get:
|
|
17
|
+
|
|
18
|
+
* ``health_check_interval`` + ``socket_keepalive`` to recycle half-open
|
|
19
|
+
connections a NAT/load-balancer idle timeout silently dropped, instead of
|
|
20
|
+
failing the next real command.
|
|
21
|
+
* a ``Retry`` policy so a transient reconnect is invisible to the worker loops,
|
|
22
|
+
rather than surfacing as an exception they'd swallow into a skipped iteration.
|
|
23
|
+
"""
|
|
24
|
+
return aioredis.from_url(
|
|
25
|
+
url,
|
|
26
|
+
decode_responses=True,
|
|
27
|
+
health_check_interval=30,
|
|
28
|
+
socket_keepalive=True,
|
|
29
|
+
retry=Retry(ExponentialBackoff(), retries=3),
|
|
30
|
+
retry_on_error=[RedisConnectionError, RedisTimeoutError],
|
|
31
|
+
)
|
toro/errors.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""toro exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ToroError(Exception):
|
|
7
|
+
"""Base class for toro errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class JobFailedError(ToroError):
|
|
11
|
+
"""Raised by result() when the awaited job ended in the failed state."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, reason: str | None) -> None:
|
|
14
|
+
super().__init__(reason or "job failed")
|
|
15
|
+
self.reason = reason
|
toro/job.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The Job: a typed view over the Redis hash that stores one unit of work."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Literal, Protocol, cast
|
|
8
|
+
|
|
9
|
+
from redis.asyncio import Redis
|
|
10
|
+
|
|
11
|
+
# The lifecycle states a job can be in (also the queryable states for get_jobs).
|
|
12
|
+
JobState = Literal["wait", "active", "delayed", "completed", "failed"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SupportsResult(Protocol):
|
|
16
|
+
"""The slice of Queue that `Job.result()` needs. Typing `_queue` against this
|
|
17
|
+
(not the concrete Queue) keeps the domain object from importing the queue/redis
|
|
18
|
+
layer — dependency inversion, and no circular import to dodge.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
async def result(self, job_id: str, *, timeout: float = ...) -> Any: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class JobOptions:
|
|
26
|
+
"""Per-job options (delay, attempts, backoff, priority, auto-removal)."""
|
|
27
|
+
|
|
28
|
+
delay: int = 0 # ms to wait before the job becomes processable
|
|
29
|
+
attempts: int = 1 # total tries before the job is considered failed
|
|
30
|
+
backoff: Any = None # int ms, or {"type": "fixed"|"exponential", "delay": ms}
|
|
31
|
+
priority: int = 0 # higher = more urgent (global order); 0 = default, FIFO
|
|
32
|
+
# Auto-removal: None/False keep all · True remove on finish · int keep last N ·
|
|
33
|
+
# {"count": N, "age": seconds} keep within count and/or age.
|
|
34
|
+
remove_on_complete: Any = None
|
|
35
|
+
remove_on_fail: Any = None
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
return {
|
|
39
|
+
"delay": self.delay,
|
|
40
|
+
"attempts": self.attempts,
|
|
41
|
+
"backoff": self.backoff,
|
|
42
|
+
"priority": self.priority,
|
|
43
|
+
"removeOnComplete": self.remove_on_complete,
|
|
44
|
+
"removeOnFail": self.remove_on_fail,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dict(cls, d: dict) -> JobOptions:
|
|
49
|
+
return cls(
|
|
50
|
+
delay=d.get("delay", 0),
|
|
51
|
+
attempts=d.get("attempts", 1),
|
|
52
|
+
backoff=d.get("backoff"),
|
|
53
|
+
priority=d.get("priority", 0),
|
|
54
|
+
remove_on_complete=d.get("removeOnComplete"),
|
|
55
|
+
remove_on_fail=d.get("removeOnFail"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def keep_args(opt: Any) -> tuple[int, int]:
|
|
60
|
+
"""Map a remove option to (keepCount, keepAge_seconds) for the Lua side.
|
|
61
|
+
|
|
62
|
+
keepCount: -1 keep all · 0 remove immediately · N keep newest N.
|
|
63
|
+
keepAge: -1 no age limit · S keep only those finished within S seconds.
|
|
64
|
+
"""
|
|
65
|
+
if opt is None or opt is False:
|
|
66
|
+
return (-1, -1)
|
|
67
|
+
if opt is True:
|
|
68
|
+
return (0, -1)
|
|
69
|
+
if isinstance(opt, int):
|
|
70
|
+
return (int(opt), -1)
|
|
71
|
+
if isinstance(opt, dict):
|
|
72
|
+
return (int(opt.get("count", -1)), int(opt.get("age", -1)))
|
|
73
|
+
return (-1, -1)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class JobContext:
|
|
78
|
+
"""Worker-side handles attached to a Job while its processor runs, so the handler
|
|
79
|
+
can report progress / append logs.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
redis: Redis
|
|
83
|
+
job_key: str
|
|
84
|
+
events_key: str
|
|
85
|
+
logs_key: str
|
|
86
|
+
job_id: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Job:
|
|
91
|
+
"""A snapshot of one job: its id, data, options, state and lifecycle timestamps."""
|
|
92
|
+
|
|
93
|
+
id: str
|
|
94
|
+
name: str
|
|
95
|
+
data: Any
|
|
96
|
+
opts: JobOptions = field(default_factory=JobOptions)
|
|
97
|
+
attempts_made: int = 0
|
|
98
|
+
timestamp: int | None = None
|
|
99
|
+
returnvalue: Any = None
|
|
100
|
+
failed_reason: str | None = None
|
|
101
|
+
state: JobState | None = None
|
|
102
|
+
processed_on: int | None = None
|
|
103
|
+
finished_on: int | None = None
|
|
104
|
+
progress: Any = None
|
|
105
|
+
stacktrace: str | None = None
|
|
106
|
+
# Back-reference to the owning Queue, set on jobs returned by Queue.add() so
|
|
107
|
+
# producers can `await job.result()`. Not part of the job's data/identity.
|
|
108
|
+
_queue: SupportsResult | None = field(default=None, repr=False, compare=False)
|
|
109
|
+
# Worker-side context, set while a processor runs so the handler can report
|
|
110
|
+
# progress and append logs.
|
|
111
|
+
_ctx: JobContext | None = field(default=None, repr=False, compare=False)
|
|
112
|
+
|
|
113
|
+
async def result(self, *, timeout: float = 30.0) -> Any:
|
|
114
|
+
"""Wait for this job to finish; return its value or raise JobFailedError."""
|
|
115
|
+
if self._queue is None:
|
|
116
|
+
raise RuntimeError("job.result() requires a job returned by Queue.add()")
|
|
117
|
+
return await self._queue.result(self.id, timeout=timeout)
|
|
118
|
+
|
|
119
|
+
async def update_progress(self, value: Any) -> None:
|
|
120
|
+
"""Report progress (a number 0-100 or any JSON value) from a processor."""
|
|
121
|
+
if self._ctx is None:
|
|
122
|
+
raise RuntimeError("update_progress() is only available inside a worker processor")
|
|
123
|
+
ctx = self._ctx
|
|
124
|
+
self.progress = value
|
|
125
|
+
await ctx.redis.hset(ctx.job_key, "progress", json.dumps(value))
|
|
126
|
+
await ctx.redis.publish(
|
|
127
|
+
ctx.events_key,
|
|
128
|
+
json.dumps({"jobId": ctx.job_id, "event": "progress", "progress": value}),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def log(self, message: str) -> None:
|
|
132
|
+
"""Append a log line to this job (visible in the dashboard)."""
|
|
133
|
+
if self._ctx is None:
|
|
134
|
+
raise RuntimeError("log() is only available inside a worker processor")
|
|
135
|
+
await self._ctx.redis.rpush(self._ctx.logs_key, message)
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_hash(cls, job_id: str, h: dict) -> Job:
|
|
139
|
+
"""Build a Job from a decoded Redis hash (str keys/values)."""
|
|
140
|
+
return cls(
|
|
141
|
+
id=job_id,
|
|
142
|
+
name=h.get("name", ""),
|
|
143
|
+
data=json.loads(h["data"]) if h.get("data") else None,
|
|
144
|
+
opts=JobOptions.from_dict(json.loads(h["opts"])) if h.get("opts") else JobOptions(),
|
|
145
|
+
attempts_made=int(h.get("attemptsMade", 0)),
|
|
146
|
+
timestamp=int(h["timestamp"]) if h.get("timestamp") else None,
|
|
147
|
+
returnvalue=json.loads(h["returnvalue"]) if h.get("returnvalue") else None,
|
|
148
|
+
failed_reason=h.get("failedReason"),
|
|
149
|
+
state=cast("JobState | None", h.get("state")), # Redis stores it untyped
|
|
150
|
+
processed_on=int(h["processedOn"]) if h.get("processedOn") else None,
|
|
151
|
+
finished_on=int(h["finishedOn"]) if h.get("finishedOn") else None,
|
|
152
|
+
progress=json.loads(h["progress"]) if h.get("progress") else None,
|
|
153
|
+
stacktrace=h.get("stacktrace"),
|
|
154
|
+
)
|
toro/keys.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Central place that knows how Redis keys are laid out for a queue.
|
|
2
|
+
|
|
3
|
+
Keeping this in one spot means the Lua scripts and the Python side can never
|
|
4
|
+
disagree about where a list/zset/hash lives.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Keys:
|
|
11
|
+
"""Computes the Redis key names for one queue from its prefix + name."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, queue_name: str, prefix: str = "toro") -> None:
|
|
14
|
+
self.queue_name = queue_name
|
|
15
|
+
self.prefix = prefix
|
|
16
|
+
self.base = f"{prefix}:{queue_name}:"
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def id(self) -> str:
|
|
20
|
+
return f"{self.base}id"
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def prioritized(self) -> str:
|
|
24
|
+
# The single global-priority-ordered store of waiting jobs.
|
|
25
|
+
return f"{self.base}prioritized"
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def marker(self) -> str:
|
|
29
|
+
# Wakeup signal: idle workers BZPOPMIN here; producers ZADD an idempotent base marker.
|
|
30
|
+
return f"{self.base}marker"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def pc(self) -> str:
|
|
34
|
+
# Priority sequence counter — breaks priority ties in FIFO order.
|
|
35
|
+
return f"{self.base}pc"
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def active(self) -> str:
|
|
39
|
+
return f"{self.base}active"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def delayed(self) -> str:
|
|
43
|
+
return f"{self.base}delayed"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def completed(self) -> str:
|
|
47
|
+
return f"{self.base}completed"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def failed(self) -> str:
|
|
51
|
+
return f"{self.base}failed"
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def meta_paused(self) -> str:
|
|
55
|
+
# Existence flag: when set, workers stop claiming new jobs.
|
|
56
|
+
return f"{self.base}meta-paused"
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def events(self) -> str:
|
|
60
|
+
# Pub/sub channel for job outcomes (completed/failed) — drives result() and live UI.
|
|
61
|
+
return f"{self.base}events"
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def limiter(self) -> str:
|
|
65
|
+
# Token-bucket hash {tokens, ts} — the queue-wide rate limit, shared by all workers.
|
|
66
|
+
return f"{self.base}limiter"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def stalled(self) -> str:
|
|
70
|
+
return f"{self.base}stalled"
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def stalled_check(self) -> str:
|
|
74
|
+
return f"{self.base}stalled-check"
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def repeat(self) -> str:
|
|
78
|
+
# ZSET of scheduler ids -> next-run timestamp.
|
|
79
|
+
return f"{self.base}repeat"
|
|
80
|
+
|
|
81
|
+
def scheduler(self, scheduler_id: str) -> str:
|
|
82
|
+
# HASH holding a scheduler's template (name, every/cron, data, opts).
|
|
83
|
+
return f"{self.base}repeat:{scheduler_id}"
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def workers(self) -> str:
|
|
87
|
+
# ZSET of live worker ids -> last-heartbeat timestamp (ms). Stale entries
|
|
88
|
+
# are pruned lazily on read; powers the dashboard's "who's running" view.
|
|
89
|
+
return f"{self.base}workers"
|
|
90
|
+
|
|
91
|
+
def worker(self, worker_id: str) -> str:
|
|
92
|
+
# HASH with a worker's presence record (host, pid, concurrency, counts, ...).
|
|
93
|
+
return f"{self.base}worker:{worker_id}"
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def departed(self) -> str:
|
|
97
|
+
# Capped LIST of recent worker departures: graceful stop ("stopped") or a
|
|
98
|
+
# lost heartbeat ("lost" = crashed/killed). Gives the dashboard death history.
|
|
99
|
+
return f"{self.base}departed"
|
|
100
|
+
|
|
101
|
+
def job(self, job_id: str | int) -> str:
|
|
102
|
+
return f"{self.base}{job_id}"
|
|
103
|
+
|
|
104
|
+
def lock(self, job_id: str | int) -> str:
|
|
105
|
+
return f"{self.base}{job_id}:lock"
|
|
106
|
+
|
|
107
|
+
def logs(self, job_id: str | int) -> str:
|
|
108
|
+
return f"{self.base}{job_id}:logs"
|
toro/py.typed
ADDED
|
File without changes
|