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 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