wasmkeep 0.2.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.
- wasmkeep/__init__.py +496 -0
- wasmkeep/__main__.py +98 -0
- wasmkeep/_call.py +33 -0
- wasmkeep/kv.py +277 -0
- wasmkeep/queue.py +214 -0
- wasmkeep/wire.py +559 -0
- wasmkeep/wit/actor.wit +273 -0
- wasmkeep/wit/deps/cli.wit +233 -0
- wasmkeep/wit/deps/clocks.wit +162 -0
- wasmkeep/wit/deps/filesystem.wit +589 -0
- wasmkeep/wit/deps/http.wit +733 -0
- wasmkeep/wit/deps/io.wit +299 -0
- wasmkeep/wit/deps/random.wit +92 -0
- wasmkeep/wit/deps/sockets.wit +949 -0
- wasmkeep/workflow.py +110 -0
- wasmkeep-0.2.0.dist-info/METADATA +254 -0
- wasmkeep-0.2.0.dist-info/RECORD +20 -0
- wasmkeep-0.2.0.dist-info/WHEEL +4 -0
- wasmkeep-0.2.0.dist-info/licenses/LICENSE +202 -0
- wasmkeep-0.2.0.dist-info/licenses/NOTICE +14 -0
wasmkeep/kv.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Key-value storage built from actors (ADR 0015): the client side.
|
|
2
|
+
|
|
3
|
+
Keys are spread over a fixed number of shard actors of one class
|
|
4
|
+
(`<class>/shard-0` .. `shard-<n-1>`), chosen by FNV-1a 64 of the key modulo
|
|
5
|
+
`n`. Each shard keeps its keys in its own SQLite database, so every
|
|
6
|
+
operation on a key is strongly consistent. The shard class is the Rust SDK's
|
|
7
|
+
ready-made shard, deployed from `examples/jobs/kv`; this client speaks its
|
|
8
|
+
protocol (`/wasmkeep-kv/v1`) over `actors.call`.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from wasmkeep import kv
|
|
12
|
+
|
|
13
|
+
store = kv.Client("kv", 8)
|
|
14
|
+
version = store.put("user:42", "Ada")
|
|
15
|
+
entry = store.get("user:42") # Entry(value, version, expires_at) or None
|
|
16
|
+
store.compare_and_set("user:42", version, "Ada L.") # KvError kind "conflict" if it moved
|
|
17
|
+
```
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
from typing import Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from ._call import CallFailed, call
|
|
24
|
+
from .wire import (
|
|
25
|
+
MAX_KEY_LEN,
|
|
26
|
+
U64_MAX,
|
|
27
|
+
Data,
|
|
28
|
+
Entry,
|
|
29
|
+
Expect,
|
|
30
|
+
ReadCache,
|
|
31
|
+
Reader,
|
|
32
|
+
allows,
|
|
33
|
+
check_u32,
|
|
34
|
+
check_u64,
|
|
35
|
+
decode_entry,
|
|
36
|
+
decode_keys,
|
|
37
|
+
encode_expect,
|
|
38
|
+
hex,
|
|
39
|
+
lossy,
|
|
40
|
+
merge_pages,
|
|
41
|
+
shard_for,
|
|
42
|
+
shard_name,
|
|
43
|
+
to_bytes,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"MAX_KEY_LEN",
|
|
48
|
+
"Client",
|
|
49
|
+
"Entry",
|
|
50
|
+
"Expect",
|
|
51
|
+
"KvError",
|
|
52
|
+
"allows",
|
|
53
|
+
"shard_for",
|
|
54
|
+
"shard_name",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
_PREFIX = "/wasmkeep-kv/v1"
|
|
58
|
+
_EXPECT = "x-wasmkeep-kv-expect"
|
|
59
|
+
_TTL = "x-wasmkeep-kv-ttl-ms"
|
|
60
|
+
_DURABLE = "x-wasmkeep-kv-durable"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class KvError(Exception):
|
|
64
|
+
"""Why a KV operation failed, as `kind`:
|
|
65
|
+
|
|
66
|
+
- "conflict": a conditional write found another version (or a key where
|
|
67
|
+
it expected none);
|
|
68
|
+
- "key-too-long": the key is longer than `MAX_KEY_LEN` bytes;
|
|
69
|
+
- "storage": the shard's database refused the operation;
|
|
70
|
+
- "call": the call to the shard failed. The outcome of a write is then
|
|
71
|
+
not known (it may have applied): re-read before retrying a conditional
|
|
72
|
+
write;
|
|
73
|
+
- "refused": the shard answered with an unexpected `status`.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, kind: str, detail: str = "", status: Optional[int] = None):
|
|
77
|
+
if kind == "conflict":
|
|
78
|
+
message = "conflict: the key changed"
|
|
79
|
+
elif kind == "key-too-long":
|
|
80
|
+
message = f"key longer than {MAX_KEY_LEN} bytes"
|
|
81
|
+
elif kind == "storage":
|
|
82
|
+
message = f"storage: {detail}"
|
|
83
|
+
elif kind == "call":
|
|
84
|
+
message = detail
|
|
85
|
+
else:
|
|
86
|
+
message = f"shard answered {status}: {detail}"
|
|
87
|
+
super().__init__(message)
|
|
88
|
+
self.kind = kind
|
|
89
|
+
self.detail = detail
|
|
90
|
+
"""The detail: the call's error, or the shard's answer."""
|
|
91
|
+
self.status = status
|
|
92
|
+
"""The shard's status, for "refused"."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _bad_reply() -> KvError:
|
|
96
|
+
return KvError("refused", "malformed reply from shard", 200)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _refused(status: int, body: bytes) -> KvError:
|
|
100
|
+
return KvError("refused", lossy(body), status)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_key(key: bytes) -> None:
|
|
104
|
+
if len(key) > MAX_KEY_LEN:
|
|
105
|
+
raise KvError("key-too-long")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _now_ms() -> int:
|
|
109
|
+
return int(time.time() * 1000)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# One per instance: lost with it (hibernation, a failed event, a deploy).
|
|
113
|
+
_CACHE = ReadCache(4096)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Client:
|
|
117
|
+
"""Reaches the shards of one class. Cheap to create; keep one per
|
|
118
|
+
class."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, class_name: str, shards: int):
|
|
121
|
+
"""Keys spread over `shards` actors of `class_name` (at least one).
|
|
122
|
+
Every caller must use the same count."""
|
|
123
|
+
self.class_name = class_name
|
|
124
|
+
self.shards = max(1, check_u32(shards, "shards"))
|
|
125
|
+
self._cache_ms = 0
|
|
126
|
+
|
|
127
|
+
def with_read_cache(self, max_age_ms: int) -> "Client":
|
|
128
|
+
"""Serve `get` from an in-memory cache in this actor's instance when
|
|
129
|
+
the entry was read less than `max_age_ms` ago. This actor's own
|
|
130
|
+
writes through the client update the cache, so it reads its own
|
|
131
|
+
writes; other actors' writes show up within `max_age_ms`. The cache
|
|
132
|
+
is lost with the instance (hibernation, a failed event, a deploy).
|
|
133
|
+
Returns this client."""
|
|
134
|
+
self._cache_ms = check_u64(max_age_ms, "max_age_ms")
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def _address(self, key: bytes) -> str:
|
|
138
|
+
return f"{self.class_name}/{shard_name(shard_for(key, self.shards))}"
|
|
139
|
+
|
|
140
|
+
def _cache_key(self, key: bytes) -> bytes:
|
|
141
|
+
return self.class_name.encode() + b"\0" + key
|
|
142
|
+
|
|
143
|
+
def _remember(self, key: bytes, entry: Optional[Entry]) -> None:
|
|
144
|
+
if self._cache_ms > 0:
|
|
145
|
+
_CACHE.insert(self._cache_key(key), _now_ms(), entry)
|
|
146
|
+
|
|
147
|
+
def _forget(self, key: bytes) -> None:
|
|
148
|
+
if self._cache_ms > 0:
|
|
149
|
+
_CACHE.remove(self._cache_key(key))
|
|
150
|
+
|
|
151
|
+
def _call(self, key: bytes, method: str, headers: Dict[str, str], body: bytes):
|
|
152
|
+
try:
|
|
153
|
+
return call(self._address(key), method, f"{_PREFIX}/k/{hex(key)}", headers, body)
|
|
154
|
+
except CallFailed as e:
|
|
155
|
+
if method != "GET":
|
|
156
|
+
self._forget(key)
|
|
157
|
+
raise KvError("call", str(e)) from None
|
|
158
|
+
|
|
159
|
+
def get(self, key: Data) -> Optional[Entry]:
|
|
160
|
+
"""The entry at `key` (None if absent or expired), from the read
|
|
161
|
+
cache if one is set and fresh."""
|
|
162
|
+
k = to_bytes(key)
|
|
163
|
+
if self._cache_ms > 0:
|
|
164
|
+
hit, entry = _CACHE.get(self._cache_key(k), _now_ms(), self._cache_ms)
|
|
165
|
+
if hit:
|
|
166
|
+
return entry
|
|
167
|
+
return self.get_fresh(k)
|
|
168
|
+
|
|
169
|
+
def get_fresh(self, key: Data) -> Optional[Entry]:
|
|
170
|
+
"""The entry at `key`, always asking the shard."""
|
|
171
|
+
k = to_bytes(key)
|
|
172
|
+
_check_key(k)
|
|
173
|
+
status, body = self._call(k, "GET", {}, b"")
|
|
174
|
+
entry: Optional[Entry] = None
|
|
175
|
+
if status == 200:
|
|
176
|
+
entry = decode_entry(body)
|
|
177
|
+
if entry is None:
|
|
178
|
+
raise _bad_reply()
|
|
179
|
+
elif status != 404:
|
|
180
|
+
raise _refused(status, body)
|
|
181
|
+
self._remember(k, entry)
|
|
182
|
+
return entry
|
|
183
|
+
|
|
184
|
+
def put(self, key: Data, value: Data) -> int:
|
|
185
|
+
"""Stores `value` at `key` (durably); returns the new version."""
|
|
186
|
+
return self.put_with(key, value)
|
|
187
|
+
|
|
188
|
+
def compare_and_set(self, key: Data, expected: Optional[int], value: Data) -> int:
|
|
189
|
+
"""Stores `value` only if `key` is at version `expected` (None:
|
|
190
|
+
absent); `KvError` "conflict" otherwise."""
|
|
191
|
+
return self.put_with(key, value, expect="absent" if expected is None else expected)
|
|
192
|
+
|
|
193
|
+
def put_with(
|
|
194
|
+
self,
|
|
195
|
+
key: Data,
|
|
196
|
+
value: Data,
|
|
197
|
+
expect: Expect = "any",
|
|
198
|
+
ttl_ms: Optional[int] = None,
|
|
199
|
+
durable: bool = True,
|
|
200
|
+
) -> int:
|
|
201
|
+
"""Stores `value` at `key`; returns the new version.
|
|
202
|
+
|
|
203
|
+
- `expect`: the condition the write needs: "any" (none, the
|
|
204
|
+
default), "absent", or a version;
|
|
205
|
+
- `ttl_ms`: expire the key this many milliseconds after the write;
|
|
206
|
+
- `durable`: return only once the write is durable (in the bucket in
|
|
207
|
+
a cluster, fsynced on one node). False acknowledges once committed
|
|
208
|
+
on the shard's node: faster, but a node lost in that window loses
|
|
209
|
+
it.
|
|
210
|
+
"""
|
|
211
|
+
k = to_bytes(key)
|
|
212
|
+
v = to_bytes(value)
|
|
213
|
+
_check_key(k)
|
|
214
|
+
headers = {_EXPECT: encode_expect(expect), _DURABLE: "1" if durable else "0"}
|
|
215
|
+
if ttl_ms is not None:
|
|
216
|
+
headers[_TTL] = str(check_u64(ttl_ms, "ttl_ms"))
|
|
217
|
+
sent = _now_ms()
|
|
218
|
+
status, body = self._call(k, "PUT", headers, v)
|
|
219
|
+
if status == 200:
|
|
220
|
+
version = Reader(body).u64()
|
|
221
|
+
if version is None:
|
|
222
|
+
raise _bad_reply()
|
|
223
|
+
expires_at = None if ttl_ms is None else min(sent + ttl_ms, U64_MAX)
|
|
224
|
+
self._remember(k, Entry(v, version, expires_at))
|
|
225
|
+
return version
|
|
226
|
+
self._forget(k)
|
|
227
|
+
if status == 409:
|
|
228
|
+
raise KvError("conflict")
|
|
229
|
+
raise _refused(status, body)
|
|
230
|
+
|
|
231
|
+
def delete(self, key: Data) -> bool:
|
|
232
|
+
"""Removes `key`; True if it was there."""
|
|
233
|
+
return self._delete_with(key, "any")
|
|
234
|
+
|
|
235
|
+
def delete_if(self, key: Data, version: int) -> bool:
|
|
236
|
+
"""Removes `key` only if it holds `version`; `KvError` "conflict"
|
|
237
|
+
otherwise."""
|
|
238
|
+
return self._delete_with(key, check_u64(version, "version"))
|
|
239
|
+
|
|
240
|
+
def _delete_with(self, key: Data, expect: Expect) -> bool:
|
|
241
|
+
k = to_bytes(key)
|
|
242
|
+
_check_key(k)
|
|
243
|
+
status, body = self._call(k, "DELETE", {_EXPECT: encode_expect(expect)}, b"")
|
|
244
|
+
if status == 200:
|
|
245
|
+
removed = Reader(body).u64()
|
|
246
|
+
if removed is None:
|
|
247
|
+
raise _bad_reply()
|
|
248
|
+
self._remember(k, None)
|
|
249
|
+
return removed == 1
|
|
250
|
+
self._forget(k)
|
|
251
|
+
if status == 409:
|
|
252
|
+
raise KvError("conflict")
|
|
253
|
+
raise _refused(status, body)
|
|
254
|
+
|
|
255
|
+
def list(self, prefix: Data = b"", after: Optional[Data] = None, limit: int = 100) -> List[bytes]:
|
|
256
|
+
"""Up to `limit` keys starting with `prefix`, in byte order, after
|
|
257
|
+
`after` (exclusive) if given: pass the last key of a page to get the
|
|
258
|
+
next one. Asks every shard (one call each), so it costs more than a
|
|
259
|
+
read; keep it for listings, not hot paths."""
|
|
260
|
+
check_u32(limit, "limit")
|
|
261
|
+
path = f"{_PREFIX}/list?prefix={hex(to_bytes(prefix))}&limit={limit}"
|
|
262
|
+
if after is not None:
|
|
263
|
+
path += f"&after={hex(to_bytes(after))}"
|
|
264
|
+
pages = []
|
|
265
|
+
for index in range(self.shards):
|
|
266
|
+
address = f"{self.class_name}/{shard_name(index)}"
|
|
267
|
+
try:
|
|
268
|
+
status, body = call(address, "GET", path, {}, b"")
|
|
269
|
+
except CallFailed as e:
|
|
270
|
+
raise KvError("call", str(e)) from None
|
|
271
|
+
if status != 200:
|
|
272
|
+
raise _refused(status, body)
|
|
273
|
+
keys = decode_keys(body)
|
|
274
|
+
if keys is None:
|
|
275
|
+
raise _bad_reply()
|
|
276
|
+
pages.append(keys)
|
|
277
|
+
return merge_pages(pages, limit)
|
wasmkeep/queue.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Queues with at-least-once delivery, built from one actor (ADR 0015): the
|
|
2
|
+
producer and consumer side.
|
|
3
|
+
|
|
4
|
+
A queue is an actor whose SQLite table holds its messages: the Rust SDK's
|
|
5
|
+
queue, deployed from `examples/jobs/queue` (its settings, such as the push
|
|
6
|
+
consumer, are code in that class). This module speaks its protocol
|
|
7
|
+
(`/wasmkeep-queue/v1`) over `actors.call`:
|
|
8
|
+
|
|
9
|
+
- producers `send` (returns once the message is durable);
|
|
10
|
+
- pull consumers `lease` a batch, then `ack` or `retry` each message;
|
|
11
|
+
- push consumers answer deliveries with `handle_delivery`.
|
|
12
|
+
|
|
13
|
+
A message may be delivered more than once (a lost ack, a lease that ran
|
|
14
|
+
out, a failover), with the same `id`: record the ids you handled when a
|
|
15
|
+
duplicate would matter.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from wasmkeep import Actor, queue
|
|
19
|
+
|
|
20
|
+
jobs = queue.Client("queue/jobs")
|
|
21
|
+
|
|
22
|
+
class App(Actor):
|
|
23
|
+
def fetch(self, req):
|
|
24
|
+
if queue.is_delivery(req):
|
|
25
|
+
return queue.handle_delivery(req, lambda _queue, m: "ack")
|
|
26
|
+
jobs.send("resize 42")
|
|
27
|
+
return "ok\\n"
|
|
28
|
+
```
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from typing import Callable, Dict, List, Optional, Sequence
|
|
32
|
+
|
|
33
|
+
from . import Response
|
|
34
|
+
from ._call import CallFailed, call
|
|
35
|
+
from .wire import (
|
|
36
|
+
MAX_BATCH,
|
|
37
|
+
Data,
|
|
38
|
+
DeadLetter,
|
|
39
|
+
Message,
|
|
40
|
+
Outcome,
|
|
41
|
+
RetryAfter,
|
|
42
|
+
Settlement,
|
|
43
|
+
Stats,
|
|
44
|
+
check_u32,
|
|
45
|
+
check_u64,
|
|
46
|
+
decode_counts,
|
|
47
|
+
decode_dead,
|
|
48
|
+
decode_messages,
|
|
49
|
+
encode_bodies,
|
|
50
|
+
encode_settlements,
|
|
51
|
+
header,
|
|
52
|
+
lossy,
|
|
53
|
+
to_bytes,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"DELIVER_PATH",
|
|
58
|
+
"MAX_BATCH",
|
|
59
|
+
"Client",
|
|
60
|
+
"DeadLetter",
|
|
61
|
+
"Message",
|
|
62
|
+
"Outcome",
|
|
63
|
+
"QueueError",
|
|
64
|
+
"RetryAfter",
|
|
65
|
+
"Settlement",
|
|
66
|
+
"Stats",
|
|
67
|
+
"handle_delivery",
|
|
68
|
+
"is_delivery",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
_PREFIX = "/wasmkeep-queue/v1"
|
|
72
|
+
DELIVER_PATH = "/wasmkeep-queue/v1/deliver"
|
|
73
|
+
"""The path a push delivery arrives on, at the consumer."""
|
|
74
|
+
_DELAY = "x-wasmkeep-queue-delay-ms"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class QueueError(Exception):
|
|
78
|
+
"""Why a queue operation failed, as `kind`:
|
|
79
|
+
|
|
80
|
+
- "storage": the queue's database refused the operation;
|
|
81
|
+
- "call": the call to the queue failed. For a send, the message may or
|
|
82
|
+
may not be queued;
|
|
83
|
+
- "refused": the queue answered with an unexpected `status`.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, kind: str, detail: str = "", status: Optional[int] = None):
|
|
87
|
+
if kind == "storage":
|
|
88
|
+
message = f"storage: {detail}"
|
|
89
|
+
elif kind == "call":
|
|
90
|
+
message = detail
|
|
91
|
+
else:
|
|
92
|
+
message = f"queue answered {status}: {detail}"
|
|
93
|
+
super().__init__(message)
|
|
94
|
+
self.kind = kind
|
|
95
|
+
self.detail = detail
|
|
96
|
+
"""The detail: the call's error, or the queue's answer."""
|
|
97
|
+
self.status = status
|
|
98
|
+
"""The queue's status, for "refused"."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _bad_reply() -> QueueError:
|
|
102
|
+
return QueueError("refused", "malformed reply from queue", 200)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Client:
|
|
106
|
+
"""Reaches one queue actor."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, address: str):
|
|
109
|
+
"""The queue at `address` (`class/name`)."""
|
|
110
|
+
self.address = address
|
|
111
|
+
|
|
112
|
+
def _call(self, method: str, path: str, headers: Dict[str, str], body: bytes) -> bytes:
|
|
113
|
+
try:
|
|
114
|
+
status, reply = call(self.address, method, path, headers, body)
|
|
115
|
+
except CallFailed as e:
|
|
116
|
+
raise QueueError("call", str(e)) from None
|
|
117
|
+
if status != 200:
|
|
118
|
+
raise QueueError("refused", lossy(reply), status)
|
|
119
|
+
return reply
|
|
120
|
+
|
|
121
|
+
def send(self, body: Data) -> int:
|
|
122
|
+
"""Queues `body`; returns its id once it is durable."""
|
|
123
|
+
return self.send_delayed(body, 0)
|
|
124
|
+
|
|
125
|
+
def send_delayed(self, body: Data, delay_ms: int) -> int:
|
|
126
|
+
"""Queues `body`, visible after `delay_ms`."""
|
|
127
|
+
ids = self._send([body], delay_ms)
|
|
128
|
+
if not ids:
|
|
129
|
+
raise _bad_reply()
|
|
130
|
+
return ids[0]
|
|
131
|
+
|
|
132
|
+
def send_batch(self, bodies: Sequence[Data]) -> List[int]:
|
|
133
|
+
"""Queues several messages (at most `MAX_BATCH`) in one durable
|
|
134
|
+
write; returns their ids in order. All or none are queued."""
|
|
135
|
+
return self._send(bodies, 0)
|
|
136
|
+
|
|
137
|
+
def _send(self, bodies: Sequence[Data], delay_ms: int) -> List[int]:
|
|
138
|
+
headers = {_DELAY: str(check_u64(delay_ms, "delay_ms"))}
|
|
139
|
+
payload = encode_bodies([to_bytes(b) for b in bodies])
|
|
140
|
+
ids = decode_counts(self._call("POST", f"{_PREFIX}/send", headers, payload))
|
|
141
|
+
if ids is None or len(ids) != len(bodies):
|
|
142
|
+
raise _bad_reply()
|
|
143
|
+
return ids
|
|
144
|
+
|
|
145
|
+
def lease(self, max: int, visibility_ms: int) -> List[Message]: # noqa: A002 - Rust's name
|
|
146
|
+
"""Leases up to `max` visible messages for `visibility_ms`."""
|
|
147
|
+
path = (
|
|
148
|
+
f"{_PREFIX}/lease?max={check_u32(max, 'max')}"
|
|
149
|
+
f"&visibility_ms={check_u64(visibility_ms, 'visibility_ms')}"
|
|
150
|
+
)
|
|
151
|
+
messages = decode_messages(self._call("POST", path, {}, b""))
|
|
152
|
+
if messages is None:
|
|
153
|
+
raise _bad_reply()
|
|
154
|
+
return messages
|
|
155
|
+
|
|
156
|
+
def ack(self, message: Message) -> bool:
|
|
157
|
+
"""Deletes a leased message. False if this lease is no longer
|
|
158
|
+
current (it ran out and the message was leased again, or was
|
|
159
|
+
settled). Any object with `id` and `attempt` will do."""
|
|
160
|
+
return self.settle([Settlement(message.id, message.attempt, "ack")])[:1] == [True]
|
|
161
|
+
|
|
162
|
+
def retry(self, message: Message, delay_ms: Optional[int] = None) -> bool:
|
|
163
|
+
"""Makes a leased message visible again after `delay_ms` (None: the
|
|
164
|
+
queue's backoff). False if this lease is no longer current."""
|
|
165
|
+
outcome: Outcome = "retry" if delay_ms is None else RetryAfter(delay_ms)
|
|
166
|
+
return self.settle([Settlement(message.id, message.attempt, outcome)])[:1] == [True]
|
|
167
|
+
|
|
168
|
+
def settle(self, items: Sequence[Settlement]) -> List[bool]:
|
|
169
|
+
"""Settles several leased messages in one call; one flag per item,
|
|
170
|
+
True where the lease was current."""
|
|
171
|
+
flags = decode_counts(self._call("POST", f"{_PREFIX}/settle", {}, encode_settlements(items)))
|
|
172
|
+
if flags is None:
|
|
173
|
+
raise _bad_reply()
|
|
174
|
+
return [f == 1 for f in flags]
|
|
175
|
+
|
|
176
|
+
def stats(self) -> Stats:
|
|
177
|
+
"""How many messages are ready, delayed, in flight and dead."""
|
|
178
|
+
counts = decode_counts(self._call("GET", f"{_PREFIX}/stats", {}, b""))
|
|
179
|
+
if counts is None or len(counts) != 4:
|
|
180
|
+
raise _bad_reply()
|
|
181
|
+
return Stats(*counts)
|
|
182
|
+
|
|
183
|
+
def dead_letters(self, limit: int) -> List[DeadLetter]:
|
|
184
|
+
"""The oldest `limit` dead-lettered messages."""
|
|
185
|
+
path = f"{_PREFIX}/dead?limit={check_u32(limit, 'limit')}"
|
|
186
|
+
items = decode_dead(self._call("GET", path, {}, b""))
|
|
187
|
+
if items is None:
|
|
188
|
+
raise _bad_reply()
|
|
189
|
+
return items
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def is_delivery(req) -> bool:
|
|
193
|
+
"""Whether `req` (a `wasmkeep.Request`) is a push delivery from a queue
|
|
194
|
+
(answer it with `handle_delivery`)."""
|
|
195
|
+
return req.method == "POST" and req.path == DELIVER_PATH
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def handle_delivery(req, handle: Callable[[str, Message], Outcome]) -> Response:
|
|
199
|
+
"""Answers a push delivery: `handle` gets the sending queue's address
|
|
200
|
+
(`tenant/class/name`) and each message, and returns its outcome ("ack",
|
|
201
|
+
"retry" or `RetryAfter(ms)`). Return the result from `fetch`.
|
|
202
|
+
Deliveries from outside clients (without `x-wasmkeep-caller`) get 403. If
|
|
203
|
+
`handle` raises, the exception propagates (`fetch` then answers 500) and
|
|
204
|
+
the queue retries the whole batch with its backoff. Record handled ids
|
|
205
|
+
in this actor's database, in the same event, when a duplicate delivery
|
|
206
|
+
would matter."""
|
|
207
|
+
queue = header(req.headers, "x-wasmkeep-caller")
|
|
208
|
+
if queue is None:
|
|
209
|
+
return Response("deliveries come from queue actors\n", 403, {"content-type": "text/plain"})
|
|
210
|
+
messages = decode_messages(req.bytes())
|
|
211
|
+
if messages is None:
|
|
212
|
+
return Response("malformed delivery\n", 400, {"content-type": "text/plain"})
|
|
213
|
+
outcomes = [Settlement(m.id, m.attempt, handle(queue, m)) for m in messages]
|
|
214
|
+
return Response(encode_settlements(outcomes), 200, {"content-type": "application/octet-stream"})
|