entroq 0.11.0__tar.gz
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.
- entroq-0.11.0/PKG-INFO +82 -0
- entroq-0.11.0/README.md +59 -0
- entroq-0.11.0/pyproject.toml +50 -0
- entroq-0.11.0/setup.cfg +4 -0
- entroq-0.11.0/src/entroq/__init__.py +8 -0
- entroq-0.11.0/src/entroq/base.py +53 -0
- entroq-0.11.0/src/entroq/json.py +254 -0
- entroq-0.11.0/src/entroq/pg/__init__.py +414 -0
- entroq-0.11.0/src/entroq/pg/schema.sql +1218 -0
- entroq-0.11.0/src/entroq/types.py +289 -0
- entroq-0.11.0/src/entroq/worker.py +506 -0
- entroq-0.11.0/src/entroq.egg-info/PKG-INFO +82 -0
- entroq-0.11.0/src/entroq.egg-info/SOURCES.txt +17 -0
- entroq-0.11.0/src/entroq.egg-info/dependency_links.txt +1 -0
- entroq-0.11.0/src/entroq.egg-info/requires.txt +11 -0
- entroq-0.11.0/src/entroq.egg-info/top_level.txt +1 -0
- entroq-0.11.0/tests/test_entroq_pg.py +492 -0
- entroq-0.11.0/tests/test_example_worker.py +95 -0
- entroq-0.11.0/tests/test_worker.py +745 -0
entroq-0.11.0/PKG-INFO
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: entroq
|
|
3
|
+
Version: 0.11.0
|
|
4
|
+
Summary: Python client for the EntroQ task queue.
|
|
5
|
+
Author-email: Chris Monson <shiblon@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/shiblon/entroq
|
|
7
|
+
Project-URL: Repository, https://github.com/shiblon/entroq
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/shiblon/entroq/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: httpx
|
|
15
|
+
Provides-Extra: pg
|
|
16
|
+
Requires-Dist: psycopg>=3.1.8; extra == "pg"
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: entroq[pg]; extra == "dev"
|
|
19
|
+
Requires-Dist: psycopg[binary]; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
22
|
+
Requires-Dist: testcontainers[postgres]; extra == "dev"
|
|
23
|
+
|
|
24
|
+
# EntroQ (Python client)
|
|
25
|
+
|
|
26
|
+
Python client for [EntroQ](https://github.com/shiblon/entroq), a fault-tolerant,
|
|
27
|
+
competing-consumer task queue with exactly-once semantics and an atomic
|
|
28
|
+
`Modify` operation over tasks and a companion key/value doc store.
|
|
29
|
+
|
|
30
|
+
This package provides an async client and a small worker framework. It speaks to
|
|
31
|
+
EntroQ two ways:
|
|
32
|
+
|
|
33
|
+
- **`EntroQJSON`** — talks to a Go EntroQ server over its HTTP/Connect API. Use
|
|
34
|
+
this when a server is already running (`eqpg`/`eqmem`/`eqredis serve`).
|
|
35
|
+
- **`EntroQ`** (in `entroq.pg`) — talks **directly to PostgreSQL**, no Go server
|
|
36
|
+
in the path. It uses the same stored procedures the Go `eqpg` backend does, and
|
|
37
|
+
bundles the canonical schema so a pip-only environment can initialize a database
|
|
38
|
+
without a Go toolchain. Requires the `pg` extra.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pip install entroq # client + worker (JSON/HTTP)
|
|
44
|
+
pip install "entroq[pg]" # adds the direct-PostgreSQL backend (psycopg 3)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
A worker claims tasks from one or more queues and dispatches them to a handler:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import asyncio
|
|
53
|
+
from entroq import EntroQWorker, Modification
|
|
54
|
+
from entroq.json import EntroQJSON
|
|
55
|
+
|
|
56
|
+
eq = EntroQJSON("http://localhost:8080")
|
|
57
|
+
|
|
58
|
+
@EntroQWorker.handler
|
|
59
|
+
async def process(task, docs):
|
|
60
|
+
# ... do the work ...
|
|
61
|
+
return Modification(Modification.deleting(task)) # ack by deleting
|
|
62
|
+
|
|
63
|
+
asyncio.run(EntroQWorker(eq, "my-queue").run(process))
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Talking straight to PostgreSQL instead of a server:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from entroq.pg import EntroQ
|
|
70
|
+
|
|
71
|
+
eq = EntroQ("host=localhost dbname=entroq user=entroq password=secret")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A worker backed by the direct-PostgreSQL client also carries EntroQ's built-in,
|
|
75
|
+
always-on garbage collection for `/gc=`-marked queues on the backend's behalf; a
|
|
76
|
+
worker talking to a Go server does not, since the server collects for itself.
|
|
77
|
+
|
|
78
|
+
## Documentation
|
|
79
|
+
|
|
80
|
+
See the [main repository](https://github.com/shiblon/entroq) for the concepts
|
|
81
|
+
(tasks, claims, the atomic `Modify`, the doc store, and the `/gc=` naming
|
|
82
|
+
convention) and the server/backends the Go side provides.
|
entroq-0.11.0/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# EntroQ (Python client)
|
|
2
|
+
|
|
3
|
+
Python client for [EntroQ](https://github.com/shiblon/entroq), a fault-tolerant,
|
|
4
|
+
competing-consumer task queue with exactly-once semantics and an atomic
|
|
5
|
+
`Modify` operation over tasks and a companion key/value doc store.
|
|
6
|
+
|
|
7
|
+
This package provides an async client and a small worker framework. It speaks to
|
|
8
|
+
EntroQ two ways:
|
|
9
|
+
|
|
10
|
+
- **`EntroQJSON`** — talks to a Go EntroQ server over its HTTP/Connect API. Use
|
|
11
|
+
this when a server is already running (`eqpg`/`eqmem`/`eqredis serve`).
|
|
12
|
+
- **`EntroQ`** (in `entroq.pg`) — talks **directly to PostgreSQL**, no Go server
|
|
13
|
+
in the path. It uses the same stored procedures the Go `eqpg` backend does, and
|
|
14
|
+
bundles the canonical schema so a pip-only environment can initialize a database
|
|
15
|
+
without a Go toolchain. Requires the `pg` extra.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pip install entroq # client + worker (JSON/HTTP)
|
|
21
|
+
pip install "entroq[pg]" # adds the direct-PostgreSQL backend (psycopg 3)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
A worker claims tasks from one or more queues and dispatches them to a handler:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import asyncio
|
|
30
|
+
from entroq import EntroQWorker, Modification
|
|
31
|
+
from entroq.json import EntroQJSON
|
|
32
|
+
|
|
33
|
+
eq = EntroQJSON("http://localhost:8080")
|
|
34
|
+
|
|
35
|
+
@EntroQWorker.handler
|
|
36
|
+
async def process(task, docs):
|
|
37
|
+
# ... do the work ...
|
|
38
|
+
return Modification(Modification.deleting(task)) # ack by deleting
|
|
39
|
+
|
|
40
|
+
asyncio.run(EntroQWorker(eq, "my-queue").run(process))
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Talking straight to PostgreSQL instead of a server:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from entroq.pg import EntroQ
|
|
47
|
+
|
|
48
|
+
eq = EntroQ("host=localhost dbname=entroq user=entroq password=secret")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A worker backed by the direct-PostgreSQL client also carries EntroQ's built-in,
|
|
52
|
+
always-on garbage collection for `/gc=`-marked queues on the backend's behalf; a
|
|
53
|
+
worker talking to a Go server does not, since the server collects for itself.
|
|
54
|
+
|
|
55
|
+
## Documentation
|
|
56
|
+
|
|
57
|
+
See the [main repository](https://github.com/shiblon/entroq) for the concepts
|
|
58
|
+
(tasks, claims, the atomic `Modify`, the doc store, and the `/gc=` naming
|
|
59
|
+
convention) and the server/backends the Go side provides.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "entroq"
|
|
7
|
+
version = "0.11.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Chris Monson", email="shiblon@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "Python client for the EntroQ task queue."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"httpx",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
"Homepage" = "https://github.com/shiblon/entroq"
|
|
25
|
+
"Repository" = "https://github.com/shiblon/entroq"
|
|
26
|
+
"Bug Tracker" = "https://github.com/shiblon/entroq/issues"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
pg = [
|
|
30
|
+
"psycopg>=3.1.8",
|
|
31
|
+
]
|
|
32
|
+
dev = [
|
|
33
|
+
"entroq[pg]",
|
|
34
|
+
"psycopg[binary]",
|
|
35
|
+
"pytest",
|
|
36
|
+
"pytest-asyncio",
|
|
37
|
+
"testcontainers[postgres]",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
asyncio_mode = "auto"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools]
|
|
44
|
+
include-package-data = true
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
where = ["src"]
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.package-data]
|
|
50
|
+
"*" = ["*.sql"]
|
entroq-0.11.0/setup.cfg
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import List, Optional, Sequence, Union
|
|
4
|
+
|
|
5
|
+
from .types import Task, Doc, Modification, ModifyResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EntroQBase(ABC):
|
|
9
|
+
"""Abstract base class for EntroQ clients. All methods are async."""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def time(self) -> datetime:
|
|
13
|
+
"""Return the current time according to the backend."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
async def queues(self, prefix: str = '', exact: Sequence[str] = (), limit: int = 0) -> List[dict]:
|
|
17
|
+
"""Return queue statistics."""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
async def tasks(self, queue: str = '', limit: int = 0, omit_values: bool = False) -> List[Task]:
|
|
21
|
+
"""Return a list of tasks in a queue."""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
async def try_claim(self, queue: Union[str, List[str]], duration_ms: int = 30000) -> Optional[Task]:
|
|
25
|
+
"""Attempt to claim a task; returns None immediately if none available."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def claim(self, queue: Union[str, List[str]], duration_ms: int = 30000, poll_ms: int = 5000, timeout_s: Optional[float] = None) -> Task:
|
|
29
|
+
"""Block until a task is available, then claim it."""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def modify(self, modification: Modification, *, unsafe_claimant_id: str | None = None) -> ModifyResult:
|
|
33
|
+
"""Atomically apply task and doc modifications in a single operation."""
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
async def docs(
|
|
37
|
+
self,
|
|
38
|
+
namespace: str = '',
|
|
39
|
+
key_start: str = '',
|
|
40
|
+
key_end: str = '',
|
|
41
|
+
limit: int = 0,
|
|
42
|
+
omit_values: bool = False,
|
|
43
|
+
) -> List[Doc]:
|
|
44
|
+
"""Return docs in a namespace, optionally filtered by key range [key_start, key_end)."""
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
async def claim_docs(
|
|
48
|
+
self,
|
|
49
|
+
namespace: str,
|
|
50
|
+
key: str,
|
|
51
|
+
duration_ms: int = 30000,
|
|
52
|
+
) -> List[Doc]:
|
|
53
|
+
"""Atomically claim all docs sharing key in namespace."""
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .types import (
|
|
9
|
+
Task, TaskData, TaskChange, TaskID,
|
|
10
|
+
Doc, DocData, DocChange, DocID,
|
|
11
|
+
DependencyError, Modification, ModifyResult,
|
|
12
|
+
)
|
|
13
|
+
from .base import EntroQBase
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse_ms(ms: int | str) -> datetime:
|
|
17
|
+
return datetime.fromtimestamp(int(ms) / 1000.0, tz=timezone.utc)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _to_ms(dt: datetime | None) -> int:
|
|
21
|
+
return 0 if dt is None else int(dt.timestamp() * 1000)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _task_from_json(obj: dict) -> Task:
|
|
25
|
+
return Task(
|
|
26
|
+
id=obj.get("id", ""),
|
|
27
|
+
version=int(obj.get("version", 0)),
|
|
28
|
+
queue=obj.get("queue", ""),
|
|
29
|
+
at=_parse_ms(obj.get("atMs", 0)),
|
|
30
|
+
claimant=obj.get("claimantId", ""),
|
|
31
|
+
value=obj.get("value"),
|
|
32
|
+
created=_parse_ms(obj.get("createdMs", 0)),
|
|
33
|
+
modified=_parse_ms(obj.get("modifiedMs", 0)),
|
|
34
|
+
claims=int(obj.get("claims", 0)),
|
|
35
|
+
attempt=int(obj.get("attempt", 0)),
|
|
36
|
+
err=obj.get("err", ""),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _doc_from_json(obj: dict) -> Doc:
|
|
41
|
+
return Doc(
|
|
42
|
+
namespace=obj.get("namespace", ""),
|
|
43
|
+
id=obj.get("id", ""),
|
|
44
|
+
version=int(obj.get("version", 0)),
|
|
45
|
+
key=obj.get("key", ""),
|
|
46
|
+
secondary_key=obj.get("secondaryKey", ""),
|
|
47
|
+
content=obj.get("content"),
|
|
48
|
+
claimant=obj.get("claimant", ""),
|
|
49
|
+
at=_parse_ms(obj["atMs"]) if obj.get("atMs") else None,
|
|
50
|
+
created=_parse_ms(obj["createdMs"]) if obj.get("createdMs") else None,
|
|
51
|
+
modified=_parse_ms(obj["modifiedMs"]) if obj.get("modifiedMs") else None,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _task_id_json(t: Task | TaskID) -> dict:
|
|
56
|
+
return {"id": t.id, "version": t.version, "queue": getattr(t, "queue", "")}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _doc_id_json(d: Doc | DocID) -> dict:
|
|
60
|
+
return {"namespace": d.namespace, "id": d.id, "version": d.version}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _task_insert_json(i: TaskData) -> dict:
|
|
64
|
+
return {k: v for k, v in {
|
|
65
|
+
"queue": i.queue,
|
|
66
|
+
"atMs": _to_ms(i.at) or None,
|
|
67
|
+
"value": i.value,
|
|
68
|
+
"id": i.id or None,
|
|
69
|
+
"attempt": i.attempt or None,
|
|
70
|
+
"err": i.err or None,
|
|
71
|
+
}.items() if v is not None}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _task_change_json(c: TaskChange) -> dict:
|
|
75
|
+
return {
|
|
76
|
+
"oldId": {"id": c.id, "version": c.version, "queue": c.queue},
|
|
77
|
+
"newData": {
|
|
78
|
+
"queue": c.queue,
|
|
79
|
+
"atMs": _to_ms(c.at),
|
|
80
|
+
"value": c.value,
|
|
81
|
+
"attempt": c.attempt,
|
|
82
|
+
"err": c.err,
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _doc_insert_json(d: DocData) -> dict:
|
|
88
|
+
return {k: v for k, v in {
|
|
89
|
+
"namespace": d.namespace,
|
|
90
|
+
"id": d.id or None,
|
|
91
|
+
"key": d.key,
|
|
92
|
+
"secondaryKey": d.secondary_key or None,
|
|
93
|
+
"content": d.content,
|
|
94
|
+
}.items() if v is not None}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _doc_change_json(c: DocChange) -> dict:
|
|
98
|
+
return {
|
|
99
|
+
"oldId": {"namespace": c.namespace, "id": c.id, "version": c.version},
|
|
100
|
+
"newData": {
|
|
101
|
+
"namespace": c.namespace,
|
|
102
|
+
"key": c.key,
|
|
103
|
+
"secondaryKey": c.secondary_key,
|
|
104
|
+
"content": c.content,
|
|
105
|
+
"atMs": _to_ms(c.at),
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class EntroQJSON(EntroQBase):
|
|
111
|
+
"""EntroQ client talking to the REST/gRPC-gateway API at /api/v0."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, base_url: str, claimant_id: str | None = None) -> None:
|
|
114
|
+
self._base_url = base_url.rstrip("/")
|
|
115
|
+
self.claimant_id = claimant_id or secrets.token_hex(8)
|
|
116
|
+
self._http = httpx.AsyncClient()
|
|
117
|
+
|
|
118
|
+
async def _request(self, method: str, path: str, *, json=None, params=None) -> dict:
|
|
119
|
+
resp = await self._http.request(method, f"{self._base_url}{path}", json=json, params=params)
|
|
120
|
+
if not resp.is_success:
|
|
121
|
+
self._raise_for_error(resp)
|
|
122
|
+
if resp.status_code == 204:
|
|
123
|
+
return {}
|
|
124
|
+
return resp.json()
|
|
125
|
+
|
|
126
|
+
def _raise_for_error(self, resp: httpx.Response) -> None:
|
|
127
|
+
# Dependency errors arrive as 409 Conflict (Aborted). 404 is also
|
|
128
|
+
# accepted for tolerance; the dependency-detail check below keeps an
|
|
129
|
+
# ordinary 404 from being misread as a dependency error.
|
|
130
|
+
if resp.status_code in (409, 404):
|
|
131
|
+
try:
|
|
132
|
+
body = resp.json()
|
|
133
|
+
details = body.get("details", [])
|
|
134
|
+
_DEP_TYPES = {"INSERT", "CHANGE", "DELETE", "DEPEND", "CLAIM", "DETAIL"}
|
|
135
|
+
if any(d.get("type") in _DEP_TYPES for d in details):
|
|
136
|
+
kwargs: dict = {"message": body.get("message", "")}
|
|
137
|
+
for d in details:
|
|
138
|
+
dtype = d.get("type")
|
|
139
|
+
tid_raw = d.get("id")
|
|
140
|
+
tid = (
|
|
141
|
+
TaskID(id=tid_raw["id"], version=int(tid_raw.get("version", 0)), queue=tid_raw.get("queue", ""))
|
|
142
|
+
if tid_raw else None
|
|
143
|
+
)
|
|
144
|
+
if dtype == "INSERT": kwargs.setdefault("inserts", []).append(tid)
|
|
145
|
+
elif dtype == "CHANGE": kwargs.setdefault("changes", []).append(tid)
|
|
146
|
+
elif dtype == "DELETE": kwargs.setdefault("deletes", []).append(tid)
|
|
147
|
+
elif dtype == "DEPEND": kwargs.setdefault("depends", []).append(tid)
|
|
148
|
+
elif dtype == "CLAIM": kwargs.setdefault("claims", []).append(tid)
|
|
149
|
+
elif dtype == "DETAIL": kwargs["message"] = d.get("msg", kwargs["message"])
|
|
150
|
+
kwargs["missing"] = kwargs.get("depends", []) + kwargs.get("deletes", [])
|
|
151
|
+
kwargs["collisions"] = kwargs.get("inserts", [])
|
|
152
|
+
raise DependencyError(**kwargs)
|
|
153
|
+
except (ValueError, KeyError):
|
|
154
|
+
pass
|
|
155
|
+
resp.raise_for_status()
|
|
156
|
+
|
|
157
|
+
async def time(self) -> datetime:
|
|
158
|
+
data = await self._request("GET", "/api/v0/time")
|
|
159
|
+
return _parse_ms(data.get("timeMs", 0))
|
|
160
|
+
|
|
161
|
+
async def queues(self, prefix: str = "", exact=(), limit: int = 0) -> list[dict]:
|
|
162
|
+
params: dict = {}
|
|
163
|
+
if prefix: params["matchPrefix"] = prefix
|
|
164
|
+
if exact: params["matchExact"] = list(exact)
|
|
165
|
+
if limit: params["limit"] = limit
|
|
166
|
+
data = await self._request("GET", "/api/v0/queues", params=params)
|
|
167
|
+
return [
|
|
168
|
+
{
|
|
169
|
+
"name": q.get("name", ""),
|
|
170
|
+
"num_tasks": q.get("numTasks", 0),
|
|
171
|
+
"num_claimed": q.get("numClaimed", 0),
|
|
172
|
+
"num_available": q.get("numAvailable", 0),
|
|
173
|
+
"num_future": q.get("numFuture", 0),
|
|
174
|
+
}
|
|
175
|
+
for q in data.get("queues", [])
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
async def tasks(self, queue: str = "", limit: int = 0, omit_values: bool = False) -> list[Task]:
|
|
179
|
+
params: dict = {}
|
|
180
|
+
if queue: params["queue"] = queue
|
|
181
|
+
if limit: params["limit"] = limit
|
|
182
|
+
if omit_values: params["omitValues"] = "true"
|
|
183
|
+
data = await self._request("GET", "/api/v0/tasks", params=params)
|
|
184
|
+
return [_task_from_json(t) for t in data.get("tasks", [])]
|
|
185
|
+
|
|
186
|
+
async def try_claim(self, queue: str | list[str], duration_ms: int = 30000) -> Task | None:
|
|
187
|
+
queues = [queue] if isinstance(queue, str) else list(queue)
|
|
188
|
+
data = await self._request("POST", "/api/v0/claim", json={
|
|
189
|
+
"claimantId": self.claimant_id,
|
|
190
|
+
"queues": queues,
|
|
191
|
+
"durationMs": str(duration_ms),
|
|
192
|
+
"pollMs": "0",
|
|
193
|
+
})
|
|
194
|
+
# The server may emit an explicit "task": null when nothing is available
|
|
195
|
+
# (zero-valued fields are not omitted), so check the value, not the key.
|
|
196
|
+
return _task_from_json(data["task"]) if data.get("task") is not None else None
|
|
197
|
+
|
|
198
|
+
async def claim(self, queue: str | list[str], duration_ms: int = 30000, poll_ms: int = 5000, timeout_s: float | None = None) -> Task:
|
|
199
|
+
queues = [queue] if isinstance(queue, str) else list(queue)
|
|
200
|
+
data = await self._request("POST", "/api/v0/claim/wait", json={
|
|
201
|
+
"claimantId": self.claimant_id,
|
|
202
|
+
"queues": queues,
|
|
203
|
+
"durationMs": str(duration_ms),
|
|
204
|
+
"pollMs": str(poll_ms),
|
|
205
|
+
})
|
|
206
|
+
if data.get("task") is not None:
|
|
207
|
+
return _task_from_json(data["task"])
|
|
208
|
+
raise TimeoutError("claim timed out")
|
|
209
|
+
|
|
210
|
+
async def modify(self, modification: Modification, *, unsafe_claimant_id: str | None = None) -> ModifyResult:
|
|
211
|
+
data = await self._request("POST", "/api/v0/modify", json={
|
|
212
|
+
"claimantId": unsafe_claimant_id or self.claimant_id,
|
|
213
|
+
"inserts": [_task_insert_json(i) for i in modification.task_inserts],
|
|
214
|
+
"changes": [_task_change_json(c) for c in modification.task_changes],
|
|
215
|
+
"deletes": [_task_id_json(d) for d in modification.task_deletes],
|
|
216
|
+
"depends": [_task_id_json(d) for d in modification.task_depends],
|
|
217
|
+
"docInserts": [_doc_insert_json(i) for i in modification.doc_inserts],
|
|
218
|
+
"docChanges": [_doc_change_json(c) for c in modification.doc_changes],
|
|
219
|
+
"docDeletes": [_doc_id_json(d) for d in modification.doc_deletes],
|
|
220
|
+
"docDepends": [_doc_id_json(d) for d in modification.doc_depends],
|
|
221
|
+
})
|
|
222
|
+
return ModifyResult(
|
|
223
|
+
tasks_inserted=[_task_from_json(t) for t in data.get("inserted", [])],
|
|
224
|
+
tasks_changed=[_task_from_json(t) for t in data.get("changed", [])],
|
|
225
|
+
docs_inserted=[_doc_from_json(d) for d in data.get("insertedDocs", [])],
|
|
226
|
+
docs_changed=[_doc_from_json(d) for d in data.get("changedDocs", [])],
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def docs(
|
|
230
|
+
self,
|
|
231
|
+
namespace: str = "",
|
|
232
|
+
key_start: str = "",
|
|
233
|
+
key_end: str = "",
|
|
234
|
+
limit: int = 0,
|
|
235
|
+
omit_values: bool = False,
|
|
236
|
+
) -> list[Doc]:
|
|
237
|
+
params: dict = {"namespace": namespace}
|
|
238
|
+
if key_start: params["keyStart"] = key_start
|
|
239
|
+
if key_end: params["keyEnd"] = key_end
|
|
240
|
+
if limit: params["limit"] = limit
|
|
241
|
+
if omit_values: params["omitValues"] = "true"
|
|
242
|
+
data = await self._request("GET", "/api/v0/docs", params=params)
|
|
243
|
+
return [_doc_from_json(d) for d in data.get("docs", [])]
|
|
244
|
+
|
|
245
|
+
async def claim_docs(self, namespace: str, key: str, duration_ms: int = 30000) -> list[Doc]:
|
|
246
|
+
data = await self._request("POST", "/api/v0/docs/claim", json={
|
|
247
|
+
"claimQuery": {
|
|
248
|
+
"namespace": namespace,
|
|
249
|
+
"claimant": self.claimant_id,
|
|
250
|
+
"key": key,
|
|
251
|
+
"durationMs": duration_ms,
|
|
252
|
+
},
|
|
253
|
+
})
|
|
254
|
+
return [_doc_from_json(d) for d in data.get("docs", [])]
|