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/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
+ )