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,507 @@
|
|
|
1
|
+
"""Lifecycle of the SDK-managed embedded Go daemon (ADR 0003).
|
|
2
|
+
|
|
3
|
+
The daemon is spawned as an asyncio subprocess in ``embedded`` mode with both
|
|
4
|
+
ownership signals required by the frozen CLI contract: ``--parent-pid`` set to
|
|
5
|
+
the current process and ``--lifecycle-stdin`` with the child's stdin kept
|
|
6
|
+
open for the daemon's whole lifetime. Readiness is a single JSON line on
|
|
7
|
+
stdout carrying the loopback endpoint and protocol version; stderr is
|
|
8
|
+
collected asynchronously into a bounded ring buffer for diagnostics.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from collections import deque
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Deque, TextIO
|
|
21
|
+
|
|
22
|
+
from ._version import (
|
|
23
|
+
PROTOCOL_MAJOR,
|
|
24
|
+
PROTOCOL_MINOR,
|
|
25
|
+
SCHEMA_VERSION,
|
|
26
|
+
daemon_version_for_sdk,
|
|
27
|
+
)
|
|
28
|
+
from .binary import DAEMON_NAME, BinaryMetadata, packaged_binary
|
|
29
|
+
from .errors import ConflictError, IncompatibleVersionError, RingoError
|
|
30
|
+
|
|
31
|
+
DEFAULT_STARTUP_TIMEOUT = 15.0
|
|
32
|
+
DEFAULT_STOP_TIMEOUT = 10.0
|
|
33
|
+
_STDERR_TAIL_LINES = 200
|
|
34
|
+
_MAX_VERSION_PROBE_OUTPUT = 1024 * 1024
|
|
35
|
+
|
|
36
|
+
_EXE_NAMES = (
|
|
37
|
+
("ringo-task-queue.exe", "ringo-task-queue")
|
|
38
|
+
if sys.platform == "win32"
|
|
39
|
+
else (
|
|
40
|
+
"ringo-task-queue",
|
|
41
|
+
"ringo-task-queue.exe",
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve_daemon(
|
|
47
|
+
explicit: str | os.PathLike[str] | None = None,
|
|
48
|
+
) -> tuple[Path, BinaryMetadata | None]:
|
|
49
|
+
"""Resolve a daemon and optional packaged metadata.
|
|
50
|
+
|
|
51
|
+
Explicit and environment overrides intentionally win over package
|
|
52
|
+
resources. Overrides are development artifacts and therefore have no
|
|
53
|
+
required manifest; packaged resources are always checksum validated.
|
|
54
|
+
"""
|
|
55
|
+
if explicit:
|
|
56
|
+
path = Path(explicit)
|
|
57
|
+
if not path.is_file() or path.is_symlink():
|
|
58
|
+
raise RingoError(f"daemon binary is not a regular file: {path}")
|
|
59
|
+
return path, None
|
|
60
|
+
|
|
61
|
+
env = os.environ.get("RINGO_DAEMON_PATH")
|
|
62
|
+
if env:
|
|
63
|
+
path = Path(env)
|
|
64
|
+
if not path.is_file() or path.is_symlink():
|
|
65
|
+
raise RingoError(f"RINGO_DAEMON_PATH is not a regular file: {path}")
|
|
66
|
+
return path, None
|
|
67
|
+
|
|
68
|
+
# Packaged platform resource (only present in platform wheels). A
|
|
69
|
+
# manifest is the marker that this is a platform wheel; malformed or
|
|
70
|
+
# mismatched package data must fail loudly instead of falling through to a
|
|
71
|
+
# random development binary.
|
|
72
|
+
try:
|
|
73
|
+
from importlib import resources
|
|
74
|
+
|
|
75
|
+
package_bin = Path(str(resources.files("ringo_task_queue") / "bin"))
|
|
76
|
+
if not (package_bin / "manifest.json").is_file():
|
|
77
|
+
raise FileNotFoundError(package_bin / "manifest.json")
|
|
78
|
+
return packaged_binary()
|
|
79
|
+
except (FileNotFoundError, ModuleNotFoundError):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
# Development tree: search this file's ancestors for the binary, which
|
|
83
|
+
# covers both the repo root and ``.tmp/bin`` checkouts.
|
|
84
|
+
here = Path(__file__).resolve()
|
|
85
|
+
for parent in here.parents:
|
|
86
|
+
for name in _EXE_NAMES:
|
|
87
|
+
candidate = parent / name
|
|
88
|
+
if candidate.is_file():
|
|
89
|
+
return candidate, None
|
|
90
|
+
nested = parent / ".tmp" / "bin"
|
|
91
|
+
for name in _EXE_NAMES:
|
|
92
|
+
candidate = nested / name
|
|
93
|
+
if candidate.is_file():
|
|
94
|
+
return candidate, None
|
|
95
|
+
raise RingoError(
|
|
96
|
+
"could not locate the ringo-task-queue daemon binary; pass"
|
|
97
|
+
" daemon_path=..., set RINGO_DAEMON_PATH, or install a platform wheel"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve_daemon_path(explicit: str | os.PathLike[str] | None = None) -> Path:
|
|
102
|
+
"""Locate and (for packaged resources) verify the daemon binary."""
|
|
103
|
+
return _resolve_daemon(explicit)[0]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _read_bounded_probe_stream(
|
|
107
|
+
stream: asyncio.StreamReader | None,
|
|
108
|
+
) -> bytes:
|
|
109
|
+
if stream is None:
|
|
110
|
+
return b""
|
|
111
|
+
result = bytearray()
|
|
112
|
+
while chunk := await stream.read(64 * 1024):
|
|
113
|
+
if len(result) + len(chunk) > _MAX_VERSION_PROBE_OUTPUT:
|
|
114
|
+
raise IncompatibleVersionError(
|
|
115
|
+
f"daemon version probe output exceeds {_MAX_VERSION_PROBE_OUTPUT} bytes"
|
|
116
|
+
)
|
|
117
|
+
result.extend(chunk)
|
|
118
|
+
return bytes(result)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _probe_binary_version(
|
|
122
|
+
binary: Path, timeout: float, *, allow_legacy: bool = True
|
|
123
|
+
) -> dict[str, object]:
|
|
124
|
+
"""Run the daemon's read-only version contract before spawning it.
|
|
125
|
+
|
|
126
|
+
Source-tree development overrides may still use the historical text line.
|
|
127
|
+
Verified wheel resources must return the release ``version --json`` object.
|
|
128
|
+
"""
|
|
129
|
+
proc: asyncio.subprocess.Process | None = None
|
|
130
|
+
try:
|
|
131
|
+
proc = await asyncio.create_subprocess_exec(
|
|
132
|
+
str(binary),
|
|
133
|
+
"version",
|
|
134
|
+
"--json",
|
|
135
|
+
stdout=asyncio.subprocess.PIPE,
|
|
136
|
+
stderr=asyncio.subprocess.PIPE,
|
|
137
|
+
)
|
|
138
|
+
stdout_task = asyncio.create_task(_read_bounded_probe_stream(proc.stdout))
|
|
139
|
+
stderr_task = asyncio.create_task(_read_bounded_probe_stream(proc.stderr))
|
|
140
|
+
stdout, stderr, _ = await asyncio.wait_for(
|
|
141
|
+
asyncio.gather(stdout_task, stderr_task, proc.wait()), timeout=timeout
|
|
142
|
+
)
|
|
143
|
+
except BaseException as exc:
|
|
144
|
+
if proc is not None and proc.returncode is None:
|
|
145
|
+
try:
|
|
146
|
+
proc.kill()
|
|
147
|
+
except OSError:
|
|
148
|
+
pass
|
|
149
|
+
try:
|
|
150
|
+
await asyncio.shield(asyncio.wait_for(proc.wait(), timeout=5.0))
|
|
151
|
+
except (asyncio.CancelledError, asyncio.TimeoutError, OSError):
|
|
152
|
+
pass
|
|
153
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
154
|
+
raise
|
|
155
|
+
if isinstance(exc, (OSError, asyncio.TimeoutError)):
|
|
156
|
+
raise IncompatibleVersionError(
|
|
157
|
+
f"cannot probe daemon compatibility at {binary}: {exc}"
|
|
158
|
+
) from exc
|
|
159
|
+
raise
|
|
160
|
+
if proc.returncode != 0:
|
|
161
|
+
detail = stderr.decode("utf-8", errors="replace").strip()
|
|
162
|
+
raise IncompatibleVersionError(
|
|
163
|
+
f"daemon version probe failed (code {proc.returncode}): {detail}"
|
|
164
|
+
)
|
|
165
|
+
try:
|
|
166
|
+
result = json.loads(stdout.decode("utf-8"))
|
|
167
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
168
|
+
if not allow_legacy:
|
|
169
|
+
raise IncompatibleVersionError(
|
|
170
|
+
f"packaged daemon version probe returned invalid JSON: {stdout!r}"
|
|
171
|
+
) from exc
|
|
172
|
+
# Development binaries built before the JSON contract may only emit
|
|
173
|
+
# the documented human-readable line. Preserve source-tree override
|
|
174
|
+
# compatibility while release wheels fail closed above.
|
|
175
|
+
legacy = re.fullmatch(
|
|
176
|
+
r"(?P<name>\S+) (?P<version>\S+) protocol=(?P<major>\d+)\.(?P<minor>\d+) schema=(?P<schema>\d+)(?:.*)",
|
|
177
|
+
stdout.decode("utf-8", errors="replace").strip(),
|
|
178
|
+
)
|
|
179
|
+
if legacy is None:
|
|
180
|
+
raise IncompatibleVersionError(
|
|
181
|
+
f"daemon version probe returned invalid JSON: {stdout!r}"
|
|
182
|
+
) from exc
|
|
183
|
+
result = {
|
|
184
|
+
"name": legacy.group("name"),
|
|
185
|
+
"version": legacy.group("version"),
|
|
186
|
+
"protocolMajor": int(legacy.group("major")),
|
|
187
|
+
"protocolMinor": int(legacy.group("minor")),
|
|
188
|
+
"schemaVersion": int(legacy.group("schema")),
|
|
189
|
+
}
|
|
190
|
+
if not isinstance(result, dict):
|
|
191
|
+
raise IncompatibleVersionError("daemon version probe returned a non-object")
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class LocalDaemon:
|
|
196
|
+
"""A spawned embedded daemon subprocess owned by this process."""
|
|
197
|
+
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
data_dir: str | os.PathLike[str],
|
|
201
|
+
*,
|
|
202
|
+
storage: str = "sqlite",
|
|
203
|
+
postgres_dsn: str | None = None,
|
|
204
|
+
daemon_path: str | os.PathLike[str] | None = None,
|
|
205
|
+
startup_timeout: float = DEFAULT_STARTUP_TIMEOUT,
|
|
206
|
+
stop_timeout: float = DEFAULT_STOP_TIMEOUT,
|
|
207
|
+
) -> None:
|
|
208
|
+
self.data_dir = Path(data_dir)
|
|
209
|
+
self._storage = storage
|
|
210
|
+
self._postgres_dsn = postgres_dsn
|
|
211
|
+
self._daemon_path = daemon_path
|
|
212
|
+
self._binary_metadata: BinaryMetadata | None = None
|
|
213
|
+
self._startup_timeout = startup_timeout
|
|
214
|
+
self._stop_timeout = stop_timeout
|
|
215
|
+
self._proc: asyncio.subprocess.Process | None = None
|
|
216
|
+
self._stderr_task: asyncio.Task | None = None
|
|
217
|
+
self._stderr_tail: Deque[str] = deque(maxlen=_STDERR_TAIL_LINES)
|
|
218
|
+
self._log_file: TextIO | None = None
|
|
219
|
+
self.endpoint: str | None = None
|
|
220
|
+
self.pid: int | None = None
|
|
221
|
+
self.daemon_version: str | None = None
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def is_dead(self) -> bool:
|
|
225
|
+
proc = self._proc
|
|
226
|
+
return proc is None or proc.returncode is not None
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def stderr_tail(self) -> list[str]:
|
|
230
|
+
return list(self._stderr_tail)
|
|
231
|
+
|
|
232
|
+
async def start(self) -> str:
|
|
233
|
+
"""Spawn the daemon and return its loopback gRPC endpoint."""
|
|
234
|
+
if self._proc is not None and not self.is_dead:
|
|
235
|
+
raise RingoError("daemon already started")
|
|
236
|
+
if self._proc is not None:
|
|
237
|
+
# A previous daemon died; reap its helpers before respawning.
|
|
238
|
+
await self._finish_stderr()
|
|
239
|
+
self._proc = None
|
|
240
|
+
binary, self._binary_metadata = _resolve_daemon(self._daemon_path)
|
|
241
|
+
version_info = await _probe_binary_version(
|
|
242
|
+
binary,
|
|
243
|
+
self._startup_timeout,
|
|
244
|
+
allow_legacy=self._binary_metadata is None,
|
|
245
|
+
)
|
|
246
|
+
self._validate_version_info(version_info, self._binary_metadata, binary)
|
|
247
|
+
try:
|
|
248
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
except OSError as exc:
|
|
250
|
+
raise RingoError(
|
|
251
|
+
f"cannot create data directory {self.data_dir}: {exc}"
|
|
252
|
+
) from exc
|
|
253
|
+
|
|
254
|
+
command = [
|
|
255
|
+
str(binary),
|
|
256
|
+
"embedded",
|
|
257
|
+
"--data-dir",
|
|
258
|
+
str(self.data_dir),
|
|
259
|
+
"--listen",
|
|
260
|
+
"127.0.0.1:0",
|
|
261
|
+
"--storage",
|
|
262
|
+
self._storage,
|
|
263
|
+
"--parent-pid",
|
|
264
|
+
str(os.getpid()),
|
|
265
|
+
"--lifecycle-stdin",
|
|
266
|
+
]
|
|
267
|
+
child_env = None
|
|
268
|
+
if self._postgres_dsn is not None:
|
|
269
|
+
child_env = dict(os.environ)
|
|
270
|
+
child_env["RINGO_POSTGRES_DSN"] = self._postgres_dsn
|
|
271
|
+
try:
|
|
272
|
+
self._proc = await asyncio.create_subprocess_exec(
|
|
273
|
+
*command,
|
|
274
|
+
env=child_env,
|
|
275
|
+
stdin=asyncio.subprocess.PIPE,
|
|
276
|
+
stdout=asyncio.subprocess.PIPE,
|
|
277
|
+
stderr=asyncio.subprocess.PIPE,
|
|
278
|
+
)
|
|
279
|
+
except OSError as exc:
|
|
280
|
+
raise RingoError(f"failed to spawn daemon {binary}: {exc}") from exc
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
self._log_file = open(
|
|
284
|
+
self.data_dir / "ringo-daemon.stderr.log", "a", encoding="utf-8"
|
|
285
|
+
)
|
|
286
|
+
except OSError:
|
|
287
|
+
self._log_file = None
|
|
288
|
+
self._stderr_task = asyncio.create_task(self._collect_stderr())
|
|
289
|
+
try:
|
|
290
|
+
ready = await self._await_ready()
|
|
291
|
+
endpoint = self._validate_ready(ready)
|
|
292
|
+
self._validate_binary_compatibility(ready, self._binary_metadata, binary)
|
|
293
|
+
except BaseException:
|
|
294
|
+
await self._kill_and_reap()
|
|
295
|
+
raise
|
|
296
|
+
|
|
297
|
+
self.endpoint = endpoint
|
|
298
|
+
self.pid = ready["pid"]
|
|
299
|
+
self.daemon_version = ready["version"]
|
|
300
|
+
return endpoint
|
|
301
|
+
|
|
302
|
+
@staticmethod
|
|
303
|
+
def _validate_ready(ready: object) -> str:
|
|
304
|
+
"""Validate the readiness JSON shape before trusting any field."""
|
|
305
|
+
if not isinstance(ready, dict):
|
|
306
|
+
raise RingoError(f"malformed daemon readiness payload: {ready!r}")
|
|
307
|
+
endpoint = ready.get("endpoint")
|
|
308
|
+
if not isinstance(endpoint, str) or not endpoint:
|
|
309
|
+
raise RingoError(f"daemon readiness missing endpoint: {ready!r}")
|
|
310
|
+
match = re.fullmatch(r"(?:\[([^]]+)\]|([^:]+)):(\d+)", endpoint)
|
|
311
|
+
host = (match.group(1) or match.group(2)) if match else ""
|
|
312
|
+
port = int(match.group(3)) if match else 0
|
|
313
|
+
if host not in ("127.0.0.1", "::1", "localhost") or not 1 <= port <= 65535:
|
|
314
|
+
raise RingoError(
|
|
315
|
+
f"embedded daemon must announce a loopback endpoint with a positive port, got {endpoint!r}"
|
|
316
|
+
)
|
|
317
|
+
for key in ("pid", "protocolMajor", "protocolMinor", "schemaVersion"):
|
|
318
|
+
value = ready.get(key)
|
|
319
|
+
if type(value) is not int or value < 0 or (key == "pid" and value == 0):
|
|
320
|
+
raise RingoError(
|
|
321
|
+
f"daemon readiness field {key!r} is missing or invalid: {ready!r}"
|
|
322
|
+
)
|
|
323
|
+
if not isinstance(ready.get("version"), str):
|
|
324
|
+
raise RingoError(
|
|
325
|
+
f"daemon readiness field 'version' is missing or not a string: {ready!r}"
|
|
326
|
+
)
|
|
327
|
+
return endpoint
|
|
328
|
+
|
|
329
|
+
@staticmethod
|
|
330
|
+
def _validate_version_info(
|
|
331
|
+
info: dict[str, object], metadata: BinaryMetadata | None, binary: Path
|
|
332
|
+
) -> None:
|
|
333
|
+
if info.get("name") != DAEMON_NAME:
|
|
334
|
+
raise IncompatibleVersionError(
|
|
335
|
+
f"unexpected daemon name from {binary}: {info.get('name')!r}"
|
|
336
|
+
)
|
|
337
|
+
version = info.get("version")
|
|
338
|
+
major = info.get("protocolMajor")
|
|
339
|
+
minor = info.get("protocolMinor")
|
|
340
|
+
schema = info.get("schemaVersion")
|
|
341
|
+
if (
|
|
342
|
+
not isinstance(version, str)
|
|
343
|
+
or not version
|
|
344
|
+
or not isinstance(major, int)
|
|
345
|
+
or not isinstance(minor, int)
|
|
346
|
+
or not isinstance(schema, int)
|
|
347
|
+
):
|
|
348
|
+
raise IncompatibleVersionError(
|
|
349
|
+
f"daemon version probe is missing compatibility fields: {info!r}"
|
|
350
|
+
)
|
|
351
|
+
expected_version = daemon_version_for_sdk()
|
|
352
|
+
if version != expected_version:
|
|
353
|
+
raise IncompatibleVersionError(
|
|
354
|
+
f"daemon at {binary} reports version {version!r};"
|
|
355
|
+
f" SDK requires {expected_version!r}"
|
|
356
|
+
)
|
|
357
|
+
if major != PROTOCOL_MAJOR or minor != PROTOCOL_MINOR or schema != SCHEMA_VERSION:
|
|
358
|
+
raise IncompatibleVersionError(
|
|
359
|
+
f"daemon at {binary} reports protocol {major}.{minor}, schema {schema};"
|
|
360
|
+
f" SDK requires protocol {PROTOCOL_MAJOR}.{PROTOCOL_MINOR} and schema {SCHEMA_VERSION}"
|
|
361
|
+
)
|
|
362
|
+
if metadata is not None and (
|
|
363
|
+
version != metadata.version
|
|
364
|
+
or major != metadata.protocol_major
|
|
365
|
+
or minor != metadata.protocol_minor
|
|
366
|
+
):
|
|
367
|
+
raise IncompatibleVersionError(
|
|
368
|
+
f"daemon version probe disagrees with manifest for {binary}: {info!r}"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
@staticmethod
|
|
372
|
+
def _validate_binary_compatibility(
|
|
373
|
+
ready: dict, metadata: BinaryMetadata | None, binary: Path
|
|
374
|
+
) -> None:
|
|
375
|
+
"""Check readiness against SDK and, when packaged, manifest metadata."""
|
|
376
|
+
if (
|
|
377
|
+
ready["protocolMajor"] != PROTOCOL_MAJOR
|
|
378
|
+
or ready["protocolMinor"] != PROTOCOL_MINOR
|
|
379
|
+
or ready["schemaVersion"] != SCHEMA_VERSION
|
|
380
|
+
):
|
|
381
|
+
raise IncompatibleVersionError(
|
|
382
|
+
f"daemon at {binary} speaks protocol {ready['protocolMajor']}.{ready['protocolMinor']}, "
|
|
383
|
+
f"schema {ready['schemaVersion']}; this SDK requires protocol "
|
|
384
|
+
f"{PROTOCOL_MAJOR}.{PROTOCOL_MINOR} and schema {SCHEMA_VERSION}"
|
|
385
|
+
)
|
|
386
|
+
if metadata is not None:
|
|
387
|
+
if ready["version"] != metadata.version:
|
|
388
|
+
raise IncompatibleVersionError(
|
|
389
|
+
f"daemon at {binary} reports version {ready['version']!r};"
|
|
390
|
+
f" manifest requires {metadata.version!r}"
|
|
391
|
+
)
|
|
392
|
+
if (
|
|
393
|
+
ready["protocolMajor"] != metadata.protocol_major
|
|
394
|
+
or ready["protocolMinor"] != metadata.protocol_minor
|
|
395
|
+
):
|
|
396
|
+
raise IncompatibleVersionError(
|
|
397
|
+
f"daemon at {binary} reports protocol "
|
|
398
|
+
f"{ready['protocolMajor']}.{ready['protocolMinor']};"
|
|
399
|
+
f" manifest requires {metadata.protocol_major}.{metadata.protocol_minor}"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
async def _await_ready(self) -> dict:
|
|
403
|
+
assert self._proc is not None and self._proc.stdout is not None
|
|
404
|
+
try:
|
|
405
|
+
line = await asyncio.wait_for(
|
|
406
|
+
self._proc.stdout.readline(), timeout=self._startup_timeout
|
|
407
|
+
)
|
|
408
|
+
except asyncio.TimeoutError as exc:
|
|
409
|
+
tail = "\n".join(self.stderr_tail[-20:])
|
|
410
|
+
raise RingoError(
|
|
411
|
+
f"daemon did not become ready within {self._startup_timeout:.0f}s;"
|
|
412
|
+
f" stderr tail:\n{tail}"
|
|
413
|
+
) from exc
|
|
414
|
+
if not line:
|
|
415
|
+
# Process exited before announcing readiness.
|
|
416
|
+
returncode = await self._proc.wait()
|
|
417
|
+
if self._stderr_task is not None:
|
|
418
|
+
await self._stderr_task
|
|
419
|
+
tail = "\n".join(self.stderr_tail[-20:])
|
|
420
|
+
message = (
|
|
421
|
+
f"daemon exited during startup (code {returncode});"
|
|
422
|
+
f" stderr tail:\n{tail}"
|
|
423
|
+
)
|
|
424
|
+
if "conflict:" in tail.lower():
|
|
425
|
+
raise ConflictError(message)
|
|
426
|
+
raise RingoError(message)
|
|
427
|
+
try:
|
|
428
|
+
return json.loads(line.decode("utf-8", errors="replace"))
|
|
429
|
+
except ValueError as exc:
|
|
430
|
+
raise RingoError(f"malformed daemon readiness line: {line!r}") from exc
|
|
431
|
+
|
|
432
|
+
async def _collect_stderr(self) -> None:
|
|
433
|
+
assert self._proc is not None and self._proc.stderr is not None
|
|
434
|
+
while True:
|
|
435
|
+
try:
|
|
436
|
+
line = await self._proc.stderr.readline()
|
|
437
|
+
except Exception:
|
|
438
|
+
return
|
|
439
|
+
if not line:
|
|
440
|
+
return
|
|
441
|
+
text = line.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
442
|
+
self._stderr_tail.append(text)
|
|
443
|
+
log = self._log_file
|
|
444
|
+
if log is not None:
|
|
445
|
+
try:
|
|
446
|
+
log.write(text + "\n")
|
|
447
|
+
log.flush()
|
|
448
|
+
except OSError:
|
|
449
|
+
pass
|
|
450
|
+
|
|
451
|
+
async def stop(self) -> None:
|
|
452
|
+
"""Close the lifecycle pipe and wait for exit, escalating if needed."""
|
|
453
|
+
proc, self._proc = self._proc, None
|
|
454
|
+
if proc is None:
|
|
455
|
+
return
|
|
456
|
+
if proc.returncode is not None:
|
|
457
|
+
await self._finish_stderr()
|
|
458
|
+
return
|
|
459
|
+
# Closing stdin (the lifecycle pipe) starts the daemon's graceful
|
|
460
|
+
# shutdown; the daemon owns its own bounded grace period.
|
|
461
|
+
if proc.stdin is not None:
|
|
462
|
+
try:
|
|
463
|
+
proc.stdin.close()
|
|
464
|
+
await proc.stdin.wait_closed()
|
|
465
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
466
|
+
pass
|
|
467
|
+
try:
|
|
468
|
+
await asyncio.wait_for(proc.wait(), timeout=self._stop_timeout)
|
|
469
|
+
except asyncio.TimeoutError:
|
|
470
|
+
proc.terminate()
|
|
471
|
+
try:
|
|
472
|
+
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
473
|
+
except asyncio.TimeoutError:
|
|
474
|
+
proc.kill()
|
|
475
|
+
try:
|
|
476
|
+
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
477
|
+
except asyncio.TimeoutError as exc: # pragma: no cover - OS fault
|
|
478
|
+
raise RingoError("daemon did not exit after kill") from exc
|
|
479
|
+
finally:
|
|
480
|
+
await self._finish_stderr()
|
|
481
|
+
|
|
482
|
+
async def _finish_stderr(self) -> None:
|
|
483
|
+
task, self._stderr_task = self._stderr_task, None
|
|
484
|
+
if task is not None:
|
|
485
|
+
task.cancel()
|
|
486
|
+
try:
|
|
487
|
+
await task
|
|
488
|
+
except (asyncio.CancelledError, Exception):
|
|
489
|
+
pass
|
|
490
|
+
log, self._log_file = self._log_file, None
|
|
491
|
+
if log is not None:
|
|
492
|
+
try:
|
|
493
|
+
log.close()
|
|
494
|
+
except OSError:
|
|
495
|
+
pass
|
|
496
|
+
|
|
497
|
+
async def _kill_and_reap(self) -> None:
|
|
498
|
+
proc, self._proc = self._proc, None
|
|
499
|
+
if proc is None:
|
|
500
|
+
return
|
|
501
|
+
if proc.returncode is None:
|
|
502
|
+
proc.kill()
|
|
503
|
+
try:
|
|
504
|
+
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
505
|
+
except asyncio.TimeoutError:
|
|
506
|
+
pass
|
|
507
|
+
await self._finish_stderr()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Public exception hierarchy for the Ringo Task Queue Python SDK.
|
|
2
|
+
|
|
3
|
+
All SDK transport failures derive from :class:`RingoError` and carry one of
|
|
4
|
+
the stable public codes from ``docs/contracts/errors.md``. gRPC status
|
|
5
|
+
details (``ringo.v1.ErrorDetail``) are preferred over the coarser gRPC code,
|
|
6
|
+
exactly as the error contract requires.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Mapping
|
|
12
|
+
|
|
13
|
+
import grpc
|
|
14
|
+
from grpc_status import rpc_status
|
|
15
|
+
|
|
16
|
+
from ._proto.ringo.v1 import queue_pb2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RingoError(Exception):
|
|
20
|
+
"""Base class for every SDK-raised transport/protocol error."""
|
|
21
|
+
|
|
22
|
+
#: Stable public code (see docs/contracts/errors.md).
|
|
23
|
+
code = "INTERNAL"
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
message: str = "",
|
|
28
|
+
*,
|
|
29
|
+
request_id: str | None = None,
|
|
30
|
+
metadata: Mapping[str, str] | None = None,
|
|
31
|
+
grpc_code: grpc.StatusCode | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.message = message
|
|
35
|
+
self.request_id = request_id
|
|
36
|
+
self.metadata = dict(metadata) if metadata else {}
|
|
37
|
+
self.grpc_code = grpc_code
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
40
|
+
return f"{type(self).__name__}(code={self.code!r}, message={self.message!r})"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InvalidArgumentError(RingoError):
|
|
44
|
+
code = "INVALID_ARGUMENT"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DuplicateError(RingoError):
|
|
48
|
+
code = "DUPLICATE"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NotFoundError(RingoError):
|
|
52
|
+
code = "NOT_FOUND"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ConflictError(RingoError):
|
|
56
|
+
code = "CONFLICT"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LeaseLostError(RingoError):
|
|
60
|
+
code = "LEASE_LOST"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UnavailableError(RingoError):
|
|
64
|
+
code = "UNAVAILABLE"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DeadlineExceededError(RingoError):
|
|
68
|
+
code = "DEADLINE_EXCEEDED"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class IncompatibleVersionError(RingoError):
|
|
72
|
+
code = "INCOMPATIBLE_VERSION"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InternalError(RingoError):
|
|
76
|
+
code = "INTERNAL"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_CODE_TO_ERROR: dict[int, type[RingoError]] = {
|
|
80
|
+
queue_pb2.ERROR_CODE_INVALID_ARGUMENT: InvalidArgumentError,
|
|
81
|
+
queue_pb2.ERROR_CODE_DUPLICATE: DuplicateError,
|
|
82
|
+
queue_pb2.ERROR_CODE_NOT_FOUND: NotFoundError,
|
|
83
|
+
queue_pb2.ERROR_CODE_CONFLICT: ConflictError,
|
|
84
|
+
queue_pb2.ERROR_CODE_LEASE_LOST: LeaseLostError,
|
|
85
|
+
queue_pb2.ERROR_CODE_UNAVAILABLE: UnavailableError,
|
|
86
|
+
queue_pb2.ERROR_CODE_DEADLINE_EXCEEDED: DeadlineExceededError,
|
|
87
|
+
queue_pb2.ERROR_CODE_INCOMPATIBLE_VERSION: IncompatibleVersionError,
|
|
88
|
+
queue_pb2.ERROR_CODE_INTERNAL: InternalError,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# gRPC codes that map unambiguously onto a single public Ringo code. Other
|
|
92
|
+
# gRPC codes raise the base RingoError without inventing a more specific
|
|
93
|
+
# public code (docs/contracts/errors.md).
|
|
94
|
+
_GRPC_FALLBACK: dict[grpc.StatusCode, type[RingoError]] = {
|
|
95
|
+
grpc.StatusCode.INVALID_ARGUMENT: InvalidArgumentError,
|
|
96
|
+
grpc.StatusCode.NOT_FOUND: NotFoundError,
|
|
97
|
+
grpc.StatusCode.ALREADY_EXISTS: DuplicateError,
|
|
98
|
+
grpc.StatusCode.ABORTED: LeaseLostError,
|
|
99
|
+
grpc.StatusCode.UNAVAILABLE: UnavailableError,
|
|
100
|
+
grpc.StatusCode.DEADLINE_EXCEEDED: DeadlineExceededError,
|
|
101
|
+
grpc.StatusCode.INTERNAL: InternalError,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def error_from_detail(detail: queue_pb2.ErrorDetail) -> RingoError:
|
|
106
|
+
"""Build the mapped exception from a ``ringo.v1.ErrorDetail`` message."""
|
|
107
|
+
cls = _CODE_TO_ERROR.get(detail.code, RingoError)
|
|
108
|
+
return cls(
|
|
109
|
+
detail.message,
|
|
110
|
+
request_id=detail.request_id or None,
|
|
111
|
+
metadata=detail.metadata,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def map_rpc_error(exc: grpc.aio.AioRpcError) -> RingoError:
|
|
116
|
+
"""Map a gRPC call failure to the stable public exception.
|
|
117
|
+
|
|
118
|
+
Prefers the ``ringo.v1.ErrorDetail`` serialized in ``google.rpc.Status``
|
|
119
|
+
details; falls back to the gRPC code only when details are unavailable.
|
|
120
|
+
"""
|
|
121
|
+
rich = None
|
|
122
|
+
try:
|
|
123
|
+
rich = rpc_status.from_call(exc)
|
|
124
|
+
except Exception:
|
|
125
|
+
rich = None
|
|
126
|
+
if rich is not None:
|
|
127
|
+
for any_msg in rich.details:
|
|
128
|
+
if any_msg.Is(queue_pb2.ErrorDetail.DESCRIPTOR):
|
|
129
|
+
detail = queue_pb2.ErrorDetail()
|
|
130
|
+
any_msg.Unpack(detail)
|
|
131
|
+
err = error_from_detail(detail)
|
|
132
|
+
err.grpc_code = exc.code()
|
|
133
|
+
return err
|
|
134
|
+
cls = _GRPC_FALLBACK.get(exc.code(), RingoError)
|
|
135
|
+
message = exc.details() or str(exc)
|
|
136
|
+
return cls(message, grpc_code=exc.code())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Handler signalling errors (execution outcomes, not transport errors).
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class RetryTaskError(Exception):
|
|
144
|
+
"""Raise from a handler to retry with an optional per-attempt delay."""
|
|
145
|
+
|
|
146
|
+
def __init__(self, message: str = "", *, delay: float | None = None) -> None:
|
|
147
|
+
super().__init__(message)
|
|
148
|
+
self.delay = delay
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class PermanentTaskError(Exception):
|
|
152
|
+
"""Raise from a handler to mark the task permanently failed (DEAD)."""
|