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/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"})