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/wire.py
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
"""The small binary framing the `kv`, `queue` and `workflow` clients speak
|
|
2
|
+
with the actors that hold the data (the Rust SDK's `wire.rs`, byte for byte):
|
|
3
|
+
unsigned integers as 8 bytes big-endian, byte strings as a 4-byte
|
|
4
|
+
big-endian length then the bytes. Also the codecs of each message, the
|
|
5
|
+
FNV-1a shard hash and the KV read cache.
|
|
6
|
+
|
|
7
|
+
Pure: it imports no host bindings (nothing from `wasmkeep` itself), so the
|
|
8
|
+
unit tests run it under plain CPython. Internal to the SDK; the shapes
|
|
9
|
+
follow the protocols in ADR 0015 and ADR 0019.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Callable, Dict, List, Optional, Sequence, Tuple, TypeVar, Union
|
|
15
|
+
|
|
16
|
+
U64_MAX = 0xFFFF_FFFF_FFFF_FFFF
|
|
17
|
+
U32_MAX = 0xFFFF_FFFF
|
|
18
|
+
|
|
19
|
+
Data = Union[str, bytes, bytearray, memoryview]
|
|
20
|
+
|
|
21
|
+
# ------------------------------------------------------------------ framing
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_u64(v: int, what: str = "value") -> int:
|
|
25
|
+
"""`v` checked as a u64: a ValueError where Rust's types rule it out."""
|
|
26
|
+
if isinstance(v, bool) or not isinstance(v, int):
|
|
27
|
+
raise ValueError(f"{what} must be an integer, got {v!r}")
|
|
28
|
+
if v < 0 or v > U64_MAX:
|
|
29
|
+
raise ValueError(f"{what} must be from 0 to {U64_MAX}, got {v}")
|
|
30
|
+
return v
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_u32(v: int, what: str = "value") -> int:
|
|
34
|
+
"""`v` checked as a u32 (a count or a limit)."""
|
|
35
|
+
if isinstance(v, bool) or not isinstance(v, int):
|
|
36
|
+
raise ValueError(f"{what} must be an integer, got {v!r}")
|
|
37
|
+
if v < 0 or v > U32_MAX:
|
|
38
|
+
raise ValueError(f"{what} must be an integer from 0 to {U32_MAX}, got {v}")
|
|
39
|
+
return v
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Writer:
|
|
43
|
+
"""Appends to a buffer."""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._buf = bytearray()
|
|
47
|
+
|
|
48
|
+
def u64(self, v: int) -> "Writer":
|
|
49
|
+
"""An unsigned 64-bit integer."""
|
|
50
|
+
self._buf += check_u64(v).to_bytes(8, "big")
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def bytes(self, v: bytes) -> "Writer":
|
|
54
|
+
"""A byte string: its length (4 bytes), then the bytes."""
|
|
55
|
+
# Bodies are capped at 16 MB by `actors.call`, so a length always fits.
|
|
56
|
+
self._buf += min(len(v), U32_MAX).to_bytes(4, "big")
|
|
57
|
+
self._buf += v
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def finish(self) -> bytes:
|
|
61
|
+
return bytes(self._buf)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Reader:
|
|
65
|
+
"""Reads what `Writer` wrote. Every read fails (None) past the end."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, buf: bytes) -> None:
|
|
68
|
+
self._buf = bytes(buf)
|
|
69
|
+
self._at = 0
|
|
70
|
+
|
|
71
|
+
def u64(self) -> Optional[int]:
|
|
72
|
+
if len(self._buf) - self._at < 8:
|
|
73
|
+
return None
|
|
74
|
+
v = int.from_bytes(self._buf[self._at : self._at + 8], "big")
|
|
75
|
+
self._at += 8
|
|
76
|
+
return v
|
|
77
|
+
|
|
78
|
+
def bytes(self) -> Optional[bytes]:
|
|
79
|
+
if len(self._buf) - self._at < 4:
|
|
80
|
+
return None
|
|
81
|
+
n = int.from_bytes(self._buf[self._at : self._at + 4], "big")
|
|
82
|
+
if len(self._buf) - self._at - 4 < n:
|
|
83
|
+
return None
|
|
84
|
+
self._at += 4
|
|
85
|
+
v = self._buf[self._at : self._at + n]
|
|
86
|
+
self._at += n
|
|
87
|
+
return v
|
|
88
|
+
|
|
89
|
+
def is_empty(self) -> bool:
|
|
90
|
+
return self._at >= len(self._buf)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _u32(v: Optional[int]) -> Optional[int]:
|
|
94
|
+
"""A u64 that must fit a u32 (an attempt), as Rust's `u32::try_from`."""
|
|
95
|
+
return v if v is not None and v <= U32_MAX else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def hex(data: bytes) -> str: # noqa: A001 - the name the other SDKs use
|
|
99
|
+
"""Lowercase hex, so any key fits in a path segment."""
|
|
100
|
+
return bytes(data).hex()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def to_bytes(v: Data) -> bytes:
|
|
104
|
+
"""A key, value or body as bytes: str is UTF-8 (as Rust's `&str`)."""
|
|
105
|
+
if isinstance(v, str):
|
|
106
|
+
return v.encode()
|
|
107
|
+
if isinstance(v, (bytes, bytearray, memoryview)):
|
|
108
|
+
return bytes(v)
|
|
109
|
+
raise TypeError(f"expected str or bytes, got {type(v).__name__}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def lossy(body: bytes) -> str:
|
|
113
|
+
"""A refusal's text: the body as lossy UTF-8, trimmed."""
|
|
114
|
+
return body.decode(errors="replace").strip()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def header(headers: Dict[str, str], name: str) -> Optional[str]:
|
|
118
|
+
"""A header's value (first occurrence, case-insensitive)."""
|
|
119
|
+
lower = name.lower()
|
|
120
|
+
for k, v in headers.items():
|
|
121
|
+
if k.lower() == lower:
|
|
122
|
+
return v
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ----------------------------------------------------------------------- kv
|
|
127
|
+
|
|
128
|
+
MAX_KEY_LEN = 1024
|
|
129
|
+
"""Keys are at most this long (bytes)."""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class Entry:
|
|
134
|
+
"""A stored value."""
|
|
135
|
+
|
|
136
|
+
value: bytes
|
|
137
|
+
"""The bytes stored."""
|
|
138
|
+
version: int
|
|
139
|
+
"""Changes on every write of the key, and is never reused within a shard
|
|
140
|
+
(not even after a delete), so a compare-and-set on an old version always
|
|
141
|
+
loses."""
|
|
142
|
+
expires_at: Optional[int] = None
|
|
143
|
+
"""When the key expires (ms since the Unix epoch), if it does."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
Expect = Union[str, int]
|
|
147
|
+
"""What a conditional write expects to find: "any", "absent" (no key, or an
|
|
148
|
+
expired one), or a version (an int)."""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check_expect(expect: Expect) -> Expect:
|
|
152
|
+
if expect in ("any", "absent") and isinstance(expect, str):
|
|
153
|
+
return expect
|
|
154
|
+
if isinstance(expect, int) and not isinstance(expect, bool):
|
|
155
|
+
return check_u64(expect, "version")
|
|
156
|
+
raise ValueError(f'expect must be "any", "absent" or a version, got {expect!r}')
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def allows(expect: Expect, current: Optional[int]) -> bool:
|
|
160
|
+
"""Whether a key currently at `current` (None: absent) meets `expect`."""
|
|
161
|
+
if expect == "any":
|
|
162
|
+
return True
|
|
163
|
+
if expect == "absent":
|
|
164
|
+
return current is None
|
|
165
|
+
return current == expect
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def encode_expect(expect: Expect) -> str:
|
|
169
|
+
return str(check_expect(expect))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
_DIGITS = re.compile(r"\+?[0-9]+")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def decode_expect(s: str) -> Optional[Expect]:
|
|
176
|
+
if s in ("any", "absent"):
|
|
177
|
+
return s
|
|
178
|
+
# Rust's `u64::from_str`: ASCII digits, an optional leading `+`.
|
|
179
|
+
if len(s) > 21 or not _DIGITS.fullmatch(s):
|
|
180
|
+
return None
|
|
181
|
+
v = int(s)
|
|
182
|
+
return v if v <= U64_MAX else None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_FNV_OFFSET = 0xCBF2_9CE4_8422_2325
|
|
186
|
+
_FNV_PRIME = 0x0000_0100_0000_01B3
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def shard_for(key: Data, shards: int) -> int:
|
|
190
|
+
"""The shard (0-based) that holds `key` when there are `shards` of
|
|
191
|
+
them: FNV-1a (64 bits) of the key, modulo the count (zero counts as
|
|
192
|
+
one). Part of the data layout: it never changes, and matches the Rust
|
|
193
|
+
SDK."""
|
|
194
|
+
h = _FNV_OFFSET
|
|
195
|
+
for b in to_bytes(key):
|
|
196
|
+
h = ((h ^ b) * _FNV_PRIME) & U64_MAX
|
|
197
|
+
return h % max(1, check_u32(shards, "shards"))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def shard_name(index: int) -> str:
|
|
201
|
+
"""The actor name of shard `index`: `shard-<index>`."""
|
|
202
|
+
return f"shard-{index}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def encode_entry(entry: Entry) -> bytes:
|
|
206
|
+
return (
|
|
207
|
+
Writer()
|
|
208
|
+
.u64(entry.version)
|
|
209
|
+
.u64(entry.expires_at if entry.expires_at is not None else 0)
|
|
210
|
+
.bytes(entry.value)
|
|
211
|
+
.finish()
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def decode_entry(buf: bytes) -> Optional[Entry]:
|
|
216
|
+
r = Reader(buf)
|
|
217
|
+
version = r.u64()
|
|
218
|
+
expires = r.u64()
|
|
219
|
+
value = r.bytes()
|
|
220
|
+
if version is None or expires is None or value is None or not r.is_empty():
|
|
221
|
+
return None
|
|
222
|
+
return Entry(value, version, expires if expires != 0 else None)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def encode_keys(keys: Sequence[bytes]) -> bytes:
|
|
226
|
+
w = Writer().u64(len(keys))
|
|
227
|
+
for k in keys:
|
|
228
|
+
w.bytes(k)
|
|
229
|
+
return w.finish()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def decode_keys(buf: bytes) -> Optional[List[bytes]]:
|
|
233
|
+
return _list(buf, lambda r: r.bytes())
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def merge_pages(pages: Sequence[Sequence[bytes]], limit: int) -> List[bytes]:
|
|
237
|
+
"""Merges sorted pages from every shard into one sorted page of `limit`
|
|
238
|
+
(byte order, as Rust sorts `Vec<u8>`)."""
|
|
239
|
+
return sorted(k for page in pages for k in page)[:limit]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class ReadCache:
|
|
243
|
+
"""A bounded map of recent reads. When full, expired entries go first,
|
|
244
|
+
then everything: simple, and a full cache is refilled by the next
|
|
245
|
+
reads."""
|
|
246
|
+
|
|
247
|
+
def __init__(self, capacity: int) -> None:
|
|
248
|
+
self.capacity = capacity
|
|
249
|
+
self.entries: Dict[bytes, Tuple[int, Optional[Entry]]] = {}
|
|
250
|
+
|
|
251
|
+
def get(self, key: bytes, now: int, max_age: int) -> Tuple[bool, Optional[Entry]]:
|
|
252
|
+
"""`(True, entry)` if read less than `max_age` ago (an entry that
|
|
253
|
+
expired since reads as absent, `(True, None)`); `(False, None)` on a
|
|
254
|
+
miss. A miss differs from a cached absence."""
|
|
255
|
+
hit = self.entries.get(key)
|
|
256
|
+
if hit is None or max(0, now - hit[0]) >= max_age:
|
|
257
|
+
return (False, None)
|
|
258
|
+
e = hit[1]
|
|
259
|
+
if e is None or (e.expires_at is not None and e.expires_at <= now):
|
|
260
|
+
return (True, None)
|
|
261
|
+
return (True, e)
|
|
262
|
+
|
|
263
|
+
def insert(self, key: bytes, now: int, entry: Optional[Entry]) -> None:
|
|
264
|
+
if len(self.entries) >= self.capacity and key not in self.entries:
|
|
265
|
+
for k in [
|
|
266
|
+
k
|
|
267
|
+
for k, (_, e) in self.entries.items()
|
|
268
|
+
if e is not None and e.expires_at is not None and e.expires_at <= now
|
|
269
|
+
]:
|
|
270
|
+
del self.entries[k]
|
|
271
|
+
if len(self.entries) >= self.capacity:
|
|
272
|
+
self.entries.clear()
|
|
273
|
+
self.entries[key] = (now, entry)
|
|
274
|
+
|
|
275
|
+
def remove(self, key: bytes) -> None:
|
|
276
|
+
self.entries.pop(key, None)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# -------------------------------------------------------------------- queue
|
|
280
|
+
|
|
281
|
+
MAX_BATCH = 1000
|
|
282
|
+
"""Messages one send may carry (the queue answers 413 past it)."""
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass(frozen=True)
|
|
286
|
+
class Message:
|
|
287
|
+
"""A leased or delivered message."""
|
|
288
|
+
|
|
289
|
+
id: int
|
|
290
|
+
"""Unique within the queue, never reused, the same on every delivery."""
|
|
291
|
+
attempt: int
|
|
292
|
+
"""Which delivery this is, from 1. Acks and retries name it, so one for
|
|
293
|
+
an earlier lease of a message leased again is ignored."""
|
|
294
|
+
enqueued_at: int
|
|
295
|
+
"""When it was sent (ms since the Unix epoch)."""
|
|
296
|
+
body: bytes
|
|
297
|
+
"""What was sent."""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@dataclass(frozen=True)
|
|
301
|
+
class RetryAfter:
|
|
302
|
+
"""An outcome: deliver the message again after `ms` milliseconds."""
|
|
303
|
+
|
|
304
|
+
ms: int
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
Outcome = Union[str, RetryAfter]
|
|
308
|
+
"""What a consumer did with a message: "ack" (handled: delete it), "retry"
|
|
309
|
+
(deliver it again after the queue's backoff), or `RetryAfter(ms)`; a retry
|
|
310
|
+
of a message that had all its attempts dead-letters it."""
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass(frozen=True)
|
|
314
|
+
class Settlement:
|
|
315
|
+
"""One settlement: a lease (`id`, `attempt`) and its outcome."""
|
|
316
|
+
|
|
317
|
+
id: int
|
|
318
|
+
attempt: int
|
|
319
|
+
outcome: Outcome
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@dataclass(frozen=True)
|
|
323
|
+
class Stats:
|
|
324
|
+
"""How many messages are where."""
|
|
325
|
+
|
|
326
|
+
ready: int
|
|
327
|
+
"""Visible now: the next lease or delivery takes them."""
|
|
328
|
+
delayed: int
|
|
329
|
+
"""Sent with a delay that has not passed."""
|
|
330
|
+
in_flight: int
|
|
331
|
+
"""Leased and not yet acked, or waiting for a retry."""
|
|
332
|
+
dead: int
|
|
333
|
+
"""In the dead-letter table."""
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@dataclass(frozen=True)
|
|
337
|
+
class DeadLetter:
|
|
338
|
+
"""A dead-lettered message."""
|
|
339
|
+
|
|
340
|
+
message: Message
|
|
341
|
+
"""The message as it was last delivered."""
|
|
342
|
+
failed_at: int
|
|
343
|
+
"""When it was dead-lettered (ms since the Unix epoch)."""
|
|
344
|
+
reason: str
|
|
345
|
+
"""Why: attempts exhausted, or the last delivery error."""
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
T = TypeVar("T")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _list(buf: bytes, item: Callable[[Reader], Optional[T]]) -> Optional[List[T]]:
|
|
352
|
+
"""Reads a count, then `n` items with `item`; None if any is malformed
|
|
353
|
+
or bytes are left over."""
|
|
354
|
+
r = Reader(buf)
|
|
355
|
+
n = r.u64()
|
|
356
|
+
if n is None:
|
|
357
|
+
return None
|
|
358
|
+
out: List[T] = []
|
|
359
|
+
for _ in range(n):
|
|
360
|
+
v = item(r)
|
|
361
|
+
if v is None:
|
|
362
|
+
return None
|
|
363
|
+
out.append(v)
|
|
364
|
+
return out if r.is_empty() else None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _read_message(r: Reader) -> Optional[Message]:
|
|
368
|
+
id_ = r.u64()
|
|
369
|
+
attempt = _u32(r.u64())
|
|
370
|
+
enqueued_at = r.u64()
|
|
371
|
+
body = r.bytes()
|
|
372
|
+
if id_ is None or attempt is None or enqueued_at is None or body is None:
|
|
373
|
+
return None
|
|
374
|
+
return Message(id_, attempt, enqueued_at, body)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _write_message(w: Writer, m: Message) -> Writer:
|
|
378
|
+
return w.u64(m.id).u64(check_u32(m.attempt, "attempt")).u64(m.enqueued_at).bytes(m.body)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def encode_messages(messages: Sequence[Message]) -> bytes:
|
|
382
|
+
w = Writer().u64(len(messages))
|
|
383
|
+
for m in messages:
|
|
384
|
+
_write_message(w, m)
|
|
385
|
+
return w.finish()
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def decode_messages(buf: bytes) -> Optional[List[Message]]:
|
|
389
|
+
return _list(buf, _read_message)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def encode_settlements(items: Sequence[Settlement]) -> bytes:
|
|
393
|
+
"""`u64::MAX` in the delay slot means "the queue's backoff"."""
|
|
394
|
+
w = Writer().u64(len(items))
|
|
395
|
+
for s in items:
|
|
396
|
+
if s.outcome == "ack":
|
|
397
|
+
kind, delay = 0, 0
|
|
398
|
+
elif s.outcome == "retry":
|
|
399
|
+
kind, delay = 1, U64_MAX
|
|
400
|
+
elif isinstance(s.outcome, RetryAfter):
|
|
401
|
+
kind, delay = 1, check_u64(s.outcome.ms, "retry delay")
|
|
402
|
+
else:
|
|
403
|
+
raise ValueError(f'an outcome is "ack", "retry" or RetryAfter(ms), got {s.outcome!r}')
|
|
404
|
+
w.u64(check_u64(s.id, "id")).u64(check_u32(s.attempt, "attempt")).u64(kind).u64(delay)
|
|
405
|
+
return w.finish()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _read_settlement(r: Reader) -> Optional[Settlement]:
|
|
409
|
+
id_ = r.u64()
|
|
410
|
+
attempt = _u32(r.u64())
|
|
411
|
+
kind = r.u64()
|
|
412
|
+
delay = r.u64()
|
|
413
|
+
if id_ is None or attempt is None or kind is None or delay is None:
|
|
414
|
+
return None
|
|
415
|
+
outcome: Outcome
|
|
416
|
+
if kind == 0:
|
|
417
|
+
outcome = "ack"
|
|
418
|
+
elif kind == 1 and delay == U64_MAX:
|
|
419
|
+
outcome = "retry"
|
|
420
|
+
elif kind == 1:
|
|
421
|
+
outcome = RetryAfter(delay)
|
|
422
|
+
else:
|
|
423
|
+
return None
|
|
424
|
+
return Settlement(id_, attempt, outcome)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def decode_settlements(buf: bytes) -> Optional[List[Settlement]]:
|
|
428
|
+
return _list(buf, _read_settlement)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def encode_counts(counts: Sequence[int]) -> bytes:
|
|
432
|
+
w = Writer().u64(len(counts))
|
|
433
|
+
for c in counts:
|
|
434
|
+
w.u64(c)
|
|
435
|
+
return w.finish()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def decode_counts(buf: bytes) -> Optional[List[int]]:
|
|
439
|
+
return _list(buf, lambda r: r.u64())
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def encode_bodies(bodies: Sequence[bytes]) -> bytes:
|
|
443
|
+
w = Writer().u64(len(bodies))
|
|
444
|
+
for b in bodies:
|
|
445
|
+
w.bytes(b)
|
|
446
|
+
return w.finish()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def decode_bodies(buf: bytes) -> Optional[List[bytes]]:
|
|
450
|
+
return _list(buf, lambda r: r.bytes())
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def encode_dead(items: Sequence[DeadLetter]) -> bytes:
|
|
454
|
+
w = Writer().u64(len(items))
|
|
455
|
+
for d in items:
|
|
456
|
+
_write_message(w, d.message).u64(d.failed_at).bytes(d.reason.encode())
|
|
457
|
+
return w.finish()
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _read_dead(r: Reader) -> Optional[DeadLetter]:
|
|
461
|
+
message = _read_message(r)
|
|
462
|
+
failed_at = r.u64()
|
|
463
|
+
reason = r.bytes()
|
|
464
|
+
if message is None or failed_at is None or reason is None:
|
|
465
|
+
return None
|
|
466
|
+
try:
|
|
467
|
+
# Rust's `String::from_utf8`: invalid UTF-8 is malformed.
|
|
468
|
+
return DeadLetter(message, failed_at, reason.decode("utf-8"))
|
|
469
|
+
except UnicodeDecodeError:
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def decode_dead(buf: bytes) -> Optional[List[DeadLetter]]:
|
|
474
|
+
return _list(buf, _read_dead)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ----------------------------------------------------------------- workflow
|
|
478
|
+
|
|
479
|
+
STATES = ("running", "waiting", "completed", "failed", "cancelled")
|
|
480
|
+
"""A workflow instance's states, as the runner names them."""
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@dataclass(frozen=True)
|
|
484
|
+
class Status:
|
|
485
|
+
"""Where a workflow instance stands (the Rust SDK's `workflow::Status`)."""
|
|
486
|
+
|
|
487
|
+
state: str
|
|
488
|
+
""""running", "waiting", "completed", "failed" or "cancelled"."""
|
|
489
|
+
wake_at: Optional[int] = None
|
|
490
|
+
"""When waiting: until when (ms since the Unix epoch), at the latest."""
|
|
491
|
+
waiting_for: List[str] = field(default_factory=list)
|
|
492
|
+
"""When waiting for events: their names."""
|
|
493
|
+
output: Optional[bytes] = None
|
|
494
|
+
"""When completed: the workflow's output."""
|
|
495
|
+
error: Optional[str] = None
|
|
496
|
+
"""When failed: why."""
|
|
497
|
+
positions: int = 0
|
|
498
|
+
"""Positions recorded so far (steps, sleeps and waits)."""
|
|
499
|
+
|
|
500
|
+
@property
|
|
501
|
+
def is_finished(self) -> bool:
|
|
502
|
+
"""Whether the instance has finished (completed, failed or
|
|
503
|
+
cancelled)."""
|
|
504
|
+
return self.state in ("completed", "failed", "cancelled")
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def encode_status(s: Status) -> bytes:
|
|
508
|
+
if s.state not in STATES:
|
|
509
|
+
raise ValueError(f"a state is one of {', '.join(STATES)}, got {s.state!r}")
|
|
510
|
+
return (
|
|
511
|
+
Writer()
|
|
512
|
+
.bytes(s.state.encode())
|
|
513
|
+
.u64(check_u64(s.wake_at if s.wake_at is not None else 0, "wake_at"))
|
|
514
|
+
.bytes("\n".join(s.waiting_for).encode())
|
|
515
|
+
.u64(1 if s.output is not None else 0)
|
|
516
|
+
.bytes(s.output if s.output is not None else b"")
|
|
517
|
+
.bytes((s.error or "").encode())
|
|
518
|
+
.u64(check_u64(s.positions, "positions"))
|
|
519
|
+
.finish()
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def decode_status(buf: bytes) -> Optional[Status]:
|
|
524
|
+
"""As Rust's `decode_status`: text must be UTF-8 and the state one of
|
|
525
|
+
`STATES`; bytes after the last field are ignored, as there."""
|
|
526
|
+
r = Reader(buf)
|
|
527
|
+
state = r.bytes()
|
|
528
|
+
wake_at = r.u64()
|
|
529
|
+
names = r.bytes()
|
|
530
|
+
has_output = r.u64()
|
|
531
|
+
output = r.bytes()
|
|
532
|
+
error = r.bytes()
|
|
533
|
+
positions = r.u64()
|
|
534
|
+
if (
|
|
535
|
+
state is None
|
|
536
|
+
or wake_at is None
|
|
537
|
+
or names is None
|
|
538
|
+
or has_output is None
|
|
539
|
+
or output is None
|
|
540
|
+
or error is None
|
|
541
|
+
or positions is None
|
|
542
|
+
):
|
|
543
|
+
return None
|
|
544
|
+
try:
|
|
545
|
+
state_s = state.decode("utf-8")
|
|
546
|
+
names_s = names.decode("utf-8")
|
|
547
|
+
error_s = error.decode("utf-8")
|
|
548
|
+
except UnicodeDecodeError:
|
|
549
|
+
return None
|
|
550
|
+
if state_s not in STATES:
|
|
551
|
+
return None
|
|
552
|
+
return Status(
|
|
553
|
+
state_s,
|
|
554
|
+
wake_at if wake_at != 0 else None,
|
|
555
|
+
[n for n in names_s.split("\n") if n],
|
|
556
|
+
output if has_output == 1 else None,
|
|
557
|
+
error_s or None,
|
|
558
|
+
positions,
|
|
559
|
+
)
|