ringo-task-queue 0.1.0.dev0__py3-none-win_amd64.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.
- ringo_task_queue/__init__.py +68 -0
- ringo_task_queue/_pb.py +154 -0
- ringo_task_queue/_proto/ringo/v1/queue_pb2.py +133 -0
- ringo_task_queue/_proto/ringo/v1/queue_pb2_grpc.py +574 -0
- ringo_task_queue/_version.py +13 -0
- ringo_task_queue/bin/manifest.json +20 -0
- ringo_task_queue/bin/ringo-task-queue-windows-amd64.exe +0 -0
- ringo_task_queue/binary.py +253 -0
- ringo_task_queue/client.py +863 -0
- ringo_task_queue/daemon.py +507 -0
- ringo_task_queue/errors.py +152 -0
- ringo_task_queue/models.py +364 -0
- ringo_task_queue/py.typed +0 -0
- ringo_task_queue/worker.py +785 -0
- ringo_task_queue-0.1.0.dev0.dist-info/METADATA +445 -0
- ringo_task_queue-0.1.0.dev0.dist-info/RECORD +18 -0
- ringo_task_queue-0.1.0.dev0.dist-info/WHEEL +4 -0
- ringo_task_queue-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Public data model for the Ringo Task Queue Python SDK.
|
|
2
|
+
|
|
3
|
+
Dataclasses here mirror the frozen ``ringo.v1`` protobuf contract but expose
|
|
4
|
+
plain Python values (timezone-aware datetimes, timedeltas, arbitrary JSON
|
|
5
|
+
payloads). Conversion to/from wire messages lives in :mod:`._pb`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
from enum import Enum
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from . import _pb
|
|
18
|
+
from ._proto.ringo.v1 import queue_pb2
|
|
19
|
+
from .errors import InternalError, InvalidArgumentError
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Enums
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TaskStatus(str, Enum):
|
|
26
|
+
SCHEDULED = "SCHEDULED"
|
|
27
|
+
READY = "READY"
|
|
28
|
+
LEASED = "LEASED"
|
|
29
|
+
SUCCEEDED = "SUCCEEDED"
|
|
30
|
+
DEAD = "DEAD"
|
|
31
|
+
CANCELLED = "CANCELLED"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RetryStrategy(str, Enum):
|
|
35
|
+
FIXED = "FIXED"
|
|
36
|
+
EXPONENTIAL = "EXPONENTIAL"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class SQLiteStorage:
|
|
41
|
+
"""Use the embedded daemon's SQLite database in ``data_dir``."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class PostgresStorage:
|
|
46
|
+
"""Connect the embedded daemon to user-managed PostgreSQL."""
|
|
47
|
+
|
|
48
|
+
dsn: str = field(repr=False)
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
if not self.dsn.strip():
|
|
52
|
+
raise InvalidArgumentError("PostgreSQL DSN is required")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_STATUS_FROM_PROTO = {
|
|
56
|
+
queue_pb2.TASK_STATUS_SCHEDULED: TaskStatus.SCHEDULED,
|
|
57
|
+
queue_pb2.TASK_STATUS_READY: TaskStatus.READY,
|
|
58
|
+
queue_pb2.TASK_STATUS_LEASED: TaskStatus.LEASED,
|
|
59
|
+
queue_pb2.TASK_STATUS_SUCCEEDED: TaskStatus.SUCCEEDED,
|
|
60
|
+
queue_pb2.TASK_STATUS_DEAD: TaskStatus.DEAD,
|
|
61
|
+
queue_pb2.TASK_STATUS_CANCELLED: TaskStatus.CANCELLED,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_STRATEGY_TO_PROTO = {
|
|
65
|
+
RetryStrategy.FIXED: queue_pb2.RETRY_STRATEGY_FIXED,
|
|
66
|
+
RetryStrategy.EXPONENTIAL: queue_pb2.RETRY_STRATEGY_EXPONENTIAL,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_STRATEGY_FROM_PROTO = {value: key for key, value in _STRATEGY_TO_PROTO.items()}
|
|
70
|
+
|
|
71
|
+
#: Maximum encoded JSON payload size (docs/contracts/defaults.md), measured
|
|
72
|
+
#: in UTF-8 bytes exactly like the server enforces it.
|
|
73
|
+
_MAX_PAYLOAD_BYTES = 1_048_576
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _status_from_proto(value: int) -> TaskStatus:
|
|
77
|
+
status = _STATUS_FROM_PROTO.get(value)
|
|
78
|
+
if status is None:
|
|
79
|
+
raise InternalError(f"server returned unknown task status enum {value}")
|
|
80
|
+
return status
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _strategy_from_proto(value: int) -> RetryStrategy:
|
|
84
|
+
strategy = _STRATEGY_FROM_PROTO.get(value)
|
|
85
|
+
if strategy is None:
|
|
86
|
+
raise InternalError(f"server returned unknown retry strategy enum {value}")
|
|
87
|
+
return strategy
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Producer-facing spec types
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class RetryPolicy:
|
|
96
|
+
"""Retry configuration; ``None`` fields fall back to server defaults."""
|
|
97
|
+
|
|
98
|
+
max_attempts: int = 3
|
|
99
|
+
strategy: RetryStrategy = RetryStrategy.EXPONENTIAL
|
|
100
|
+
initial_delay: timedelta = timedelta(seconds=1)
|
|
101
|
+
max_delay: timedelta = timedelta(seconds=60)
|
|
102
|
+
multiplier: float = 2.0
|
|
103
|
+
jitter: float = 0.2
|
|
104
|
+
decrease_priority: bool = False
|
|
105
|
+
|
|
106
|
+
def to_proto(self) -> queue_pb2.RetryPolicy:
|
|
107
|
+
# Mirrors internal/domain/retry.go Validate exactly.
|
|
108
|
+
if self.max_attempts < 1 or self.max_attempts > 32:
|
|
109
|
+
raise InvalidArgumentError("max_attempts must be between 1 and 32")
|
|
110
|
+
if not isinstance(self.strategy, RetryStrategy):
|
|
111
|
+
raise InvalidArgumentError(f"unknown retry strategy {self.strategy!r}")
|
|
112
|
+
if self.initial_delay < timedelta(0) or self.max_delay < self.initial_delay:
|
|
113
|
+
raise InvalidArgumentError(
|
|
114
|
+
"retry delays are invalid: initial_delay must be >= 0 and"
|
|
115
|
+
" max_delay must be >= initial_delay"
|
|
116
|
+
)
|
|
117
|
+
if (
|
|
118
|
+
math.isnan(self.multiplier)
|
|
119
|
+
or math.isinf(self.multiplier)
|
|
120
|
+
or (self.strategy is RetryStrategy.EXPONENTIAL and self.multiplier < 1.0)
|
|
121
|
+
):
|
|
122
|
+
raise InvalidArgumentError(
|
|
123
|
+
"exponential retry multiplier must be finite and at least one"
|
|
124
|
+
)
|
|
125
|
+
if (
|
|
126
|
+
math.isnan(self.jitter)
|
|
127
|
+
or math.isinf(self.jitter)
|
|
128
|
+
or not 0.0 <= self.jitter <= 1.0
|
|
129
|
+
):
|
|
130
|
+
raise InvalidArgumentError("jitter must be finite and between zero and one")
|
|
131
|
+
return queue_pb2.RetryPolicy(
|
|
132
|
+
max_attempts=self.max_attempts,
|
|
133
|
+
strategy=_STRATEGY_TO_PROTO[self.strategy],
|
|
134
|
+
initial_delay=_pb.td_to_dur(self.initial_delay),
|
|
135
|
+
max_delay=_pb.td_to_dur(self.max_delay),
|
|
136
|
+
multiplier=self.multiplier,
|
|
137
|
+
jitter=self.jitter,
|
|
138
|
+
decrease_priority=self.decrease_priority,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_proto(cls, msg: queue_pb2.RetryPolicy) -> "RetryPolicy":
|
|
143
|
+
return cls(
|
|
144
|
+
max_attempts=msg.max_attempts,
|
|
145
|
+
strategy=_strategy_from_proto(msg.strategy),
|
|
146
|
+
initial_delay=_pb.dur_to_td(msg.initial_delay) or timedelta(0),
|
|
147
|
+
max_delay=_pb.dur_to_td(msg.max_delay) or timedelta(0),
|
|
148
|
+
multiplier=msg.multiplier,
|
|
149
|
+
jitter=msg.jitter,
|
|
150
|
+
decrease_priority=msg.decrease_priority,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _check_payload_size(payload: Any) -> None:
|
|
155
|
+
"""Client-side precheck of the 1 MiB encoded-JSON payload ceiling.
|
|
156
|
+
|
|
157
|
+
Mirrors the server contract: the limit is measured in UTF-8 encoded
|
|
158
|
+
bytes. The server remains the authoritative enforcer; this catches the
|
|
159
|
+
common case without a round trip and without diverging semantics.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
encoded = json.dumps(
|
|
163
|
+
payload, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
|
164
|
+
).encode("utf-8")
|
|
165
|
+
except (TypeError, ValueError) as exc:
|
|
166
|
+
raise InvalidArgumentError(f"payload is not JSON-serializable: {exc}") from exc
|
|
167
|
+
if len(encoded) > _MAX_PAYLOAD_BYTES:
|
|
168
|
+
raise InvalidArgumentError(
|
|
169
|
+
f"encoded payload is {len(encoded)} bytes, exceeding the"
|
|
170
|
+
f" {_MAX_PAYLOAD_BYTES}-byte limit"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class _UnsetType:
|
|
175
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
176
|
+
return "UNSET"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
#: Sentinel distinguishing "payload not supplied" (INVALID_ARGUMENT) from an
|
|
180
|
+
#: explicit JSON ``None`` payload.
|
|
181
|
+
UNSET = _UnsetType()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class TaskSpec:
|
|
186
|
+
"""Producer-supplied task fields only.
|
|
187
|
+
|
|
188
|
+
Internal lifecycle fields (status, attempt, lease, ...) are server-owned
|
|
189
|
+
and intentionally absent here; the wire contract rejects enqueueing them.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
unique_key: str
|
|
193
|
+
task_type: str
|
|
194
|
+
payload: Any = UNSET
|
|
195
|
+
priority: int = 0
|
|
196
|
+
retry_policy: RetryPolicy | None = None
|
|
197
|
+
available_at: datetime | None = None
|
|
198
|
+
|
|
199
|
+
def to_proto(self) -> queue_pb2.TaskSpec:
|
|
200
|
+
if not self.unique_key:
|
|
201
|
+
raise InvalidArgumentError("unique_key is required")
|
|
202
|
+
if not self.task_type:
|
|
203
|
+
raise InvalidArgumentError("task_type is required")
|
|
204
|
+
if self.payload is UNSET:
|
|
205
|
+
raise InvalidArgumentError(
|
|
206
|
+
"payload is required; pass None explicitly for a JSON null payload"
|
|
207
|
+
)
|
|
208
|
+
if not -(2**31) <= self.priority < 2**31:
|
|
209
|
+
raise InvalidArgumentError("priority must fit in a signed 32-bit integer")
|
|
210
|
+
_check_payload_size(self.payload)
|
|
211
|
+
msg = queue_pb2.TaskSpec(
|
|
212
|
+
unique_key=self.unique_key,
|
|
213
|
+
type=self.task_type,
|
|
214
|
+
payload=_pb.py_to_value(self.payload),
|
|
215
|
+
priority=self.priority,
|
|
216
|
+
)
|
|
217
|
+
if self.retry_policy is not None:
|
|
218
|
+
msg.retry_policy.CopyFrom(self.retry_policy.to_proto())
|
|
219
|
+
if self.available_at is not None:
|
|
220
|
+
msg.available_at.CopyFrom(_pb.dt_to_ts(self.available_at))
|
|
221
|
+
return msg
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# Server-facing read models
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass
|
|
229
|
+
class Attempt:
|
|
230
|
+
number: int
|
|
231
|
+
worker_id: str
|
|
232
|
+
started_at: datetime | None
|
|
233
|
+
finished_at: datetime | None
|
|
234
|
+
outcome: str
|
|
235
|
+
error: str
|
|
236
|
+
error_truncated: bool
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class Progress:
|
|
241
|
+
current: float
|
|
242
|
+
total: float
|
|
243
|
+
message: str
|
|
244
|
+
updated_at: datetime | None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass
|
|
248
|
+
class LeaseInfo:
|
|
249
|
+
attempt: int
|
|
250
|
+
worker_id: str
|
|
251
|
+
until: datetime | None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass
|
|
255
|
+
class Task:
|
|
256
|
+
id: str
|
|
257
|
+
queue: str
|
|
258
|
+
unique_key: str
|
|
259
|
+
task_type: str
|
|
260
|
+
payload: Any
|
|
261
|
+
priority: int
|
|
262
|
+
status: TaskStatus
|
|
263
|
+
attempt: int
|
|
264
|
+
max_attempts: int
|
|
265
|
+
available_at: datetime | None
|
|
266
|
+
created_at: datetime | None
|
|
267
|
+
updated_at: datetime | None
|
|
268
|
+
completed_at: datetime | None
|
|
269
|
+
last_error: str
|
|
270
|
+
last_error_truncated: bool
|
|
271
|
+
attempts: list[Attempt] = field(default_factory=list)
|
|
272
|
+
progress: Progress | None = None
|
|
273
|
+
current_lease: LeaseInfo | None = None
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
def from_proto(cls, msg: queue_pb2.Task) -> "Task":
|
|
277
|
+
return cls(
|
|
278
|
+
id=msg.id,
|
|
279
|
+
queue=msg.queue,
|
|
280
|
+
unique_key=msg.unique_key,
|
|
281
|
+
task_type=msg.type,
|
|
282
|
+
payload=_pb.value_to_py(msg.payload),
|
|
283
|
+
priority=msg.priority,
|
|
284
|
+
status=_status_from_proto(msg.status),
|
|
285
|
+
attempt=msg.attempt,
|
|
286
|
+
max_attempts=msg.max_attempts,
|
|
287
|
+
available_at=_pb.ts_to_dt(msg.available_at)
|
|
288
|
+
if msg.HasField("available_at")
|
|
289
|
+
else None,
|
|
290
|
+
created_at=_pb.ts_to_dt(msg.created_at)
|
|
291
|
+
if msg.HasField("created_at")
|
|
292
|
+
else None,
|
|
293
|
+
updated_at=_pb.ts_to_dt(msg.updated_at)
|
|
294
|
+
if msg.HasField("updated_at")
|
|
295
|
+
else None,
|
|
296
|
+
completed_at=_pb.ts_to_dt(msg.completed_at)
|
|
297
|
+
if msg.HasField("completed_at")
|
|
298
|
+
else None,
|
|
299
|
+
last_error=msg.last_error,
|
|
300
|
+
last_error_truncated=msg.last_error_truncated,
|
|
301
|
+
attempts=[
|
|
302
|
+
Attempt(
|
|
303
|
+
number=a.number,
|
|
304
|
+
worker_id=a.worker_id,
|
|
305
|
+
started_at=_pb.ts_to_dt(a.started_at)
|
|
306
|
+
if a.HasField("started_at")
|
|
307
|
+
else None,
|
|
308
|
+
finished_at=_pb.ts_to_dt(a.finished_at)
|
|
309
|
+
if a.HasField("finished_at")
|
|
310
|
+
else None,
|
|
311
|
+
outcome=a.outcome,
|
|
312
|
+
error=a.error,
|
|
313
|
+
error_truncated=a.error_truncated,
|
|
314
|
+
)
|
|
315
|
+
for a in msg.attempts
|
|
316
|
+
],
|
|
317
|
+
progress=Progress(
|
|
318
|
+
current=msg.progress.current,
|
|
319
|
+
total=msg.progress.total,
|
|
320
|
+
message=msg.progress.message,
|
|
321
|
+
updated_at=_pb.ts_to_dt(msg.progress.updated_at)
|
|
322
|
+
if msg.progress.HasField("updated_at")
|
|
323
|
+
else None,
|
|
324
|
+
)
|
|
325
|
+
if msg.HasField("progress")
|
|
326
|
+
else None,
|
|
327
|
+
current_lease=LeaseInfo(
|
|
328
|
+
attempt=msg.current_lease.attempt,
|
|
329
|
+
worker_id=msg.current_lease.worker_id,
|
|
330
|
+
until=_pb.ts_to_dt(msg.current_lease.until)
|
|
331
|
+
if msg.current_lease.HasField("until")
|
|
332
|
+
else None,
|
|
333
|
+
)
|
|
334
|
+
if msg.HasField("current_lease")
|
|
335
|
+
else None,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@dataclass
|
|
340
|
+
class EnqueueResult:
|
|
341
|
+
task_id: str
|
|
342
|
+
created: bool
|
|
343
|
+
task: Task | None = None
|
|
344
|
+
|
|
345
|
+
@classmethod
|
|
346
|
+
def from_proto(cls, msg: queue_pb2.EnqueueResult) -> "EnqueueResult":
|
|
347
|
+
return cls(
|
|
348
|
+
task_id=msg.task_id,
|
|
349
|
+
created=msg.created,
|
|
350
|
+
task=Task.from_proto(msg.task) if msg.HasField("task") else None,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass
|
|
355
|
+
class BatchResult:
|
|
356
|
+
"""Per-item results for a batch enqueue; never loses per-task meaning."""
|
|
357
|
+
|
|
358
|
+
results: list[EnqueueResult]
|
|
359
|
+
created_count: int
|
|
360
|
+
duplicate_count: int
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def task_ids(self) -> list[str]:
|
|
364
|
+
return [r.task_id for r in self.results]
|
|
File without changes
|