flo-python 0.1.0.dev4__py3-none-any.whl → 0.1.2__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.
flo/kv_body.py ADDED
@@ -0,0 +1,554 @@
1
+ """KV ops, action.status and worker.list, as table ops.
2
+
3
+ The request is ``[namespace][key][body]``, the body coded by the op's
4
+ schema, field by field in order. An optional field is a presence byte then
5
+ its value; a field with a default is sent present. A byte string or list has
6
+ a length prefix as wide as its bound needs. An ok answer's data is the op's
7
+ response fields in order, and is decoded whole.
8
+ """
9
+
10
+ import struct
11
+ from collections.abc import Sequence
12
+ from typing import TypeVar
13
+
14
+ from .exceptions import BlockTooLongError, IncompleteResponseError, ValidationError
15
+ from .types import (
16
+ MAX_KV_VALUE_SIZE,
17
+ MAX_WAIT_MS,
18
+ ActionRunStatus,
19
+ Condition,
20
+ GetOptions,
21
+ GetResult,
22
+ MGetEntry,
23
+ OpCode,
24
+ PutOptions,
25
+ PutResult,
26
+ RunStatus,
27
+ VersionEntry,
28
+ WaitFor,
29
+ WorkerInfo,
30
+ WorkerStatus,
31
+ WorkerType,
32
+ )
33
+
34
+ # The longest routing key; its length prefix is a u16.
35
+ _MAX_ROUTING_KEY = 4096
36
+ _MAX_JSON_PATH = 1024
37
+ _MAX_JSON_DOCUMENT = 252_819
38
+ _MAX_SCAN_CURSOR = 64
39
+ MAX_SCAN = 1024
40
+ MAX_MGET = 256
41
+ # The most bytes of mget key elements (each [len:u16][key]) in one request:
42
+ # the frame less the count and the longest routing key.
43
+ MAX_MGET_KEYS_BYTES = 253_848
44
+ MAX_HISTORY = 65
45
+ MAX_WORKER_LIST = 1024
46
+ _MAX_WORKER_ID = 4096
47
+ # The longest TTL whose nanoseconds fit a u64.
48
+ MAX_TTL_MS = 18_446_744_073_709
49
+ _U64_MAX = (1 << 64) - 1
50
+ _I64_MIN = -(1 << 63)
51
+ _I64_MAX = (1 << 63) - 1
52
+
53
+ _TABLE_OPS = frozenset(
54
+ {
55
+ OpCode.KV_PUT,
56
+ OpCode.KV_GET,
57
+ OpCode.KV_MGET,
58
+ OpCode.KV_DELETE,
59
+ OpCode.KV_SCAN,
60
+ OpCode.KV_HISTORY,
61
+ OpCode.KV_INCR,
62
+ OpCode.KV_JSON_GET,
63
+ OpCode.KV_JSON_SET,
64
+ OpCode.KV_JSON_DEL,
65
+ OpCode.KV_BEGIN_TXN,
66
+ OpCode.KV_COMMIT_TXN,
67
+ OpCode.KV_ROLLBACK_TXN,
68
+ OpCode.KV_EXPIRE,
69
+ OpCode.KV_EXISTS,
70
+ OpCode.ACTION_STATUS,
71
+ OpCode.WORKER_LIST,
72
+ }
73
+ )
74
+
75
+
76
+ def is_table_op(op_code: int) -> bool:
77
+ """Whether the op's request carries a schema-coded body, not a value and options."""
78
+ return op_code in _TABLE_OPS
79
+
80
+
81
+ # ── Request fields ────────────────────────────────────────────────────────
82
+
83
+
84
+ def _u64(name: str, v: int) -> bytes:
85
+ if not isinstance(v, int) or isinstance(v, bool) or not 0 <= v <= _U64_MAX:
86
+ raise ValidationError(f"{name} must be a u64, got {v!r}")
87
+ return struct.pack("<Q", v)
88
+
89
+
90
+ def _opt_u64(name: str, v: int | None) -> bytes:
91
+ if v is None:
92
+ return b"\x00"
93
+ return b"\x01" + _u64(name, v)
94
+
95
+
96
+ def _limit(name: str, v: int, most: int) -> bytes:
97
+ """A defaulted u32 limit in 1..most, sent present."""
98
+ if not isinstance(v, int) or isinstance(v, bool) or not 1 <= v <= most:
99
+ raise ValidationError(f"{name} must be 1..{most}, got {v!r}")
100
+ return b"\x01" + struct.pack("<I", v)
101
+
102
+
103
+ def _bytes16(name: str, b: bytes, most: int) -> bytes:
104
+ if len(b) > most:
105
+ raise ValidationError(f"{name} is {len(b)} bytes, at most {most}")
106
+ return struct.pack("<H", len(b)) + b
107
+
108
+
109
+ def _bytes32(name: str, b: bytes, most: int) -> bytes:
110
+ if len(b) > most:
111
+ raise ValidationError(f"{name} is {len(b)} bytes, at most {most}")
112
+ return struct.pack("<I", len(b)) + bytes(b)
113
+
114
+
115
+ def _routing_key(k: str | None) -> bytes:
116
+ if not k:
117
+ return b"\x00"
118
+ return b"\x01" + _bytes16("routing key", k.encode("utf-8"), _MAX_ROUTING_KEY)
119
+
120
+
121
+ def _path(path: str | None) -> bytes:
122
+ return b"\x01" + _bytes16("JSON path", (path or "$").encode("utf-8"), _MAX_JSON_PATH)
123
+
124
+
125
+ def encode_get_body(opts: GetOptions, txn: int | None = None) -> bytes:
126
+ """kv.get's request: wait_ms, wait_for, routing_key, txn."""
127
+ wait_ms = opts.wait_ms
128
+ if not isinstance(wait_ms, int) or isinstance(wait_ms, bool) or wait_ms < 0:
129
+ raise ValidationError(f"wait_ms must be a whole number of ms, got {wait_ms!r}")
130
+ if wait_ms > MAX_WAIT_MS:
131
+ raise BlockTooLongError(
132
+ f"a wait (wait_ms) is at most {MAX_WAIT_MS} ms (5 minutes), got {wait_ms}"
133
+ )
134
+ wait_for = WaitFor(opts.wait_for)
135
+ return (
136
+ b"\x01"
137
+ + struct.pack("<Q", wait_ms)
138
+ + b"\x01"
139
+ + bytes([wait_for])
140
+ + _routing_key(opts.routing_key)
141
+ + _opt_u64("txn", txn)
142
+ )
143
+
144
+
145
+ def encode_set_body(value: bytes, opts: PutOptions, txn: int | None = None) -> bytes:
146
+ """kv.set's request: value, ttl_ms, condition, cas_version, routing_key, txn."""
147
+ if len(value) > MAX_KV_VALUE_SIZE:
148
+ raise ValidationError(f"value is {len(value)} bytes, at most {MAX_KV_VALUE_SIZE}")
149
+ condition = Condition(opts.condition)
150
+ ttl = opts.ttl_ms or None
151
+ return (
152
+ struct.pack("<I", len(value))
153
+ + bytes(value)
154
+ + _opt_u64("ttl_ms", ttl)
155
+ + b"\x01"
156
+ + bytes([condition])
157
+ + _opt_u64("cas_version", opts.cas_version)
158
+ + _routing_key(opts.routing_key)
159
+ + _opt_u64("txn", txn)
160
+ )
161
+
162
+
163
+ def encode_delete_body(
164
+ cas_version: int | None, routing_key: str | None, txn: int | None = None
165
+ ) -> bytes:
166
+ """kv.delete's request: cas_version, routing_key, txn."""
167
+ return _opt_u64("cas_version", cas_version) + _routing_key(routing_key) + _opt_u64("txn", txn)
168
+
169
+
170
+ def encode_scan_body(limit: int, cursor: bytes | None) -> bytes:
171
+ """kv.scan's request: limit, cursor (empty for the first page)."""
172
+ cursor = cursor or b""
173
+ if len(cursor) > _MAX_SCAN_CURSOR:
174
+ raise ValidationError(f"scan cursor is {len(cursor)} bytes, at most {_MAX_SCAN_CURSOR}")
175
+ return _limit("scan limit", limit, MAX_SCAN) + bytes([len(cursor)]) + cursor
176
+
177
+
178
+ def encode_history_body(limit: int, routing_key: str | None) -> bytes:
179
+ """kv.history's request: limit, routing_key."""
180
+ return _limit("history limit", limit, MAX_HISTORY) + _routing_key(routing_key)
181
+
182
+
183
+ def encode_mget_body(keys: Sequence[bytes], routing_key: str | None = None) -> bytes:
184
+ """kv.mget's request: keys, routing_key."""
185
+ if not 1 <= len(keys) <= MAX_MGET:
186
+ raise ValidationError(f"mget takes 1..{MAX_MGET} keys, got {len(keys)}")
187
+ elements = b"".join(_bytes16("mget key", k, _MAX_ROUTING_KEY) for k in keys)
188
+ if len(elements) > MAX_MGET_KEYS_BYTES:
189
+ raise ValidationError(f"mget keys are {len(elements)} bytes, at most {MAX_MGET_KEYS_BYTES}")
190
+ return struct.pack("<H", len(keys)) + elements + _routing_key(routing_key)
191
+
192
+
193
+ def split_mget_keys(keys: Sequence[bytes]) -> list[Sequence[bytes]]:
194
+ """The keys in runs that each fit one request frame; refuses a bad key list
195
+ before any round trip."""
196
+ if not 1 <= len(keys) <= MAX_MGET:
197
+ raise ValidationError(f"mget takes 1..{MAX_MGET} keys, got {len(keys)}")
198
+ runs: list[Sequence[bytes]] = []
199
+ start, size = 0, 0
200
+ for i, k in enumerate(keys):
201
+ if len(k) > _MAX_ROUTING_KEY:
202
+ raise ValidationError(f"mget key is {len(k)} bytes, at most {_MAX_ROUTING_KEY}")
203
+ if size + 2 + len(k) > MAX_MGET_KEYS_BYTES:
204
+ runs.append(keys[start:i])
205
+ start, size = i, 0
206
+ size += 2 + len(k)
207
+ runs.append(keys[start:])
208
+ return runs
209
+
210
+
211
+ def encode_incr_body(delta: int, routing_key: str | None, txn: int | None = None) -> bytes:
212
+ """kv.incr's request: delta, routing_key, txn."""
213
+ if not isinstance(delta, int) or isinstance(delta, bool) or not _I64_MIN <= delta <= _I64_MAX:
214
+ raise ValidationError(f"delta must be an i64, got {delta!r}")
215
+ return b"\x01" + struct.pack("<q", delta) + _routing_key(routing_key) + _opt_u64("txn", txn)
216
+
217
+
218
+ def encode_expire_body(
219
+ ttl_ms: int, cas_version: int | None, routing_key: str | None, txn: int | None = None
220
+ ) -> bytes:
221
+ """kv.expire's request: ttl_ms (0 clears the TTL), cas_version, routing_key, txn."""
222
+ if not isinstance(ttl_ms, int) or isinstance(ttl_ms, bool) or not 0 <= ttl_ms <= MAX_TTL_MS:
223
+ raise ValidationError(f"ttl_ms must be 0..{MAX_TTL_MS}, got {ttl_ms!r}")
224
+ return (
225
+ struct.pack("<Q", ttl_ms)
226
+ + _opt_u64("cas_version", cas_version)
227
+ + _routing_key(routing_key)
228
+ + _opt_u64("txn", txn)
229
+ )
230
+
231
+
232
+ def encode_exists_body(routing_key: str | None, txn: int | None = None) -> bytes:
233
+ """kv.exists's request: routing_key, txn."""
234
+ return _routing_key(routing_key) + _opt_u64("txn", txn)
235
+
236
+
237
+ def encode_json_get_body(path: str | None, routing_key: str | None) -> bytes:
238
+ """kv.json_get's request: path, routing_key."""
239
+ return _path(path) + _routing_key(routing_key)
240
+
241
+
242
+ def encode_json_set_body(path: str | None, document: bytes, routing_key: str | None) -> bytes:
243
+ """kv.json_set's request: path, document, routing_key."""
244
+ return (
245
+ _path(path)
246
+ + _bytes32("JSON document", document, _MAX_JSON_DOCUMENT)
247
+ + _routing_key(routing_key)
248
+ )
249
+
250
+
251
+ def encode_json_del_body(path: str | None, routing_key: str | None) -> bytes:
252
+ """kv.json_del's request: path, routing_key."""
253
+ return _path(path) + _routing_key(routing_key)
254
+
255
+
256
+ def encode_worker_list_body(limit: int, cursor: bytes | None) -> bytes:
257
+ """worker.list's request: limit, cursor (empty starts)."""
258
+ return _limit("limit", limit, MAX_WORKER_LIST) + _bytes16(
259
+ "cursor", cursor or b"", _MAX_WORKER_ID
260
+ )
261
+
262
+
263
+ def encode_txn_body(txn: int) -> bytes:
264
+ """kv.txn_commit's and kv.txn_rollback's request: txn."""
265
+ return _u64("txn", txn)
266
+
267
+
268
+ # ── Answers ───────────────────────────────────────────────────────────────
269
+
270
+
271
+ class _Answer:
272
+ """Reads an answer's fields in order; ``done`` refuses bytes left over."""
273
+
274
+ def __init__(self, op: str, data: bytes):
275
+ self._op = op
276
+ self._data = bytes(data)
277
+ self._at = 0
278
+
279
+ def _take(self, n: int) -> bytes:
280
+ if self._at + n > len(self._data):
281
+ raise IncompleteResponseError(f"{self._op} answer is cut short")
282
+ b = self._data[self._at : self._at + n]
283
+ self._at += n
284
+ return b
285
+
286
+ def u8(self) -> int:
287
+ return self._take(1)[0]
288
+
289
+ def u16(self) -> int:
290
+ return int(struct.unpack("<H", self._take(2))[0])
291
+
292
+ def u32(self) -> int:
293
+ return int(struct.unpack("<I", self._take(4))[0])
294
+
295
+ def u64(self) -> int:
296
+ return int(struct.unpack("<Q", self._take(8))[0])
297
+
298
+ def i64(self) -> int:
299
+ return int(struct.unpack("<q", self._take(8))[0])
300
+
301
+ def flag(self) -> bool:
302
+ b = self.u8()
303
+ if b > 1:
304
+ raise IncompleteResponseError(f"{self._op} answer has a bool of {b}")
305
+ return b == 1
306
+
307
+ def present(self) -> bool:
308
+ """An optional field's presence byte."""
309
+ return self.flag()
310
+
311
+ def bytes8(self) -> bytes:
312
+ return self._take(self.u8())
313
+
314
+ def bytes16(self) -> bytes:
315
+ return self._take(self.u16())
316
+
317
+ def bytes32(self) -> bytes:
318
+ return self._take(self.u32())
319
+
320
+ def done(self) -> None:
321
+ if self._at != len(self._data):
322
+ raise IncompleteResponseError(
323
+ f"{self._op} answer has {len(self._data) - self._at} bytes left over"
324
+ )
325
+
326
+
327
+ def decode_get_answer(data: bytes) -> GetResult:
328
+ """kv.get's answer: version, value."""
329
+ a = _Answer("kv.get", data)
330
+ version = a.u64()
331
+ value = a.bytes32()
332
+ a.done()
333
+ return GetResult(value=value, version=version)
334
+
335
+
336
+ def decode_set_answer(data: bytes) -> PutResult:
337
+ """kv.set's answer: its version, absent (0) inside a transaction."""
338
+ a = _Answer("kv.set", data)
339
+ version = a.u64() if a.present() else 0
340
+ a.done()
341
+ return PutResult(version=version)
342
+
343
+
344
+ def decode_delete_answer(data: bytes) -> bool | None:
345
+ """kv.delete's answer: whether the key existed, None inside a transaction."""
346
+ a = _Answer("kv.delete", data)
347
+ existed = a.flag() if a.present() else None
348
+ a.done()
349
+ return existed
350
+
351
+
352
+ def decode_scan_answer(data: bytes) -> tuple[list[str], bytes | None]:
353
+ """kv.scan's answer: names, then the next page's cursor (None on the last page)."""
354
+ a = _Answer("kv.scan", data)
355
+ try:
356
+ names = [a.bytes16().decode("utf-8") for _ in range(a.u16())]
357
+ except UnicodeDecodeError as e:
358
+ raise IncompleteResponseError("kv.scan answer has a name that isn't UTF-8") from e
359
+ cursor = a.bytes8() if a.present() else None
360
+ a.done()
361
+ return names, cursor
362
+
363
+
364
+ def decode_history_answer(data: bytes) -> list[VersionEntry]:
365
+ """kv.history's answer: versions, newest first."""
366
+ a = _Answer("kv.history", data)
367
+ versions = [
368
+ VersionEntry(version=a.u64(), at_ms=a.i64(), value=a.bytes32()) for _ in range(a.u8())
369
+ ]
370
+ a.done()
371
+ return versions
372
+
373
+
374
+ def decode_mget_answer(data: bytes, keys: Sequence[str]) -> tuple[list[MGetEntry], int | None]:
375
+ """kv.mget's answer: one entry per answered key, then the index of the first
376
+ key not answered (None when all were)."""
377
+ a = _Answer("kv.mget", data)
378
+ count = a.u16()
379
+ if count > len(keys):
380
+ raise IncompleteResponseError(f"kv.mget answered {count} entries for {len(keys)} keys")
381
+ entries = []
382
+ for key in keys[:count]:
383
+ found = a.flag()
384
+ version = a.u64()
385
+ entries.append(MGetEntry(key=key, found=found, version=version, value=a.bytes32()))
386
+ next_index = a.u16() if a.present() else None
387
+ a.done()
388
+ if next_index == len(keys):
389
+ next_index = None # resumes past the last key: all were answered
390
+ if next_index is None and count != len(keys):
391
+ raise IncompleteResponseError(f"kv.mget answered {count} of {len(keys)} keys")
392
+ if next_index is not None and next_index != count:
393
+ raise IncompleteResponseError(
394
+ f"kv.mget answered {count} entries but resumes at key {next_index}"
395
+ )
396
+ return entries, next_index
397
+
398
+
399
+ def decode_incr_answer(data: bytes) -> tuple[int | None, int | None]:
400
+ """kv.incr's answer: version, value (both None inside a transaction)."""
401
+ a = _Answer("kv.incr", data)
402
+ version = a.u64() if a.present() else None
403
+ value = a.i64() if a.present() else None
404
+ a.done()
405
+ return version, value
406
+
407
+
408
+ def decode_empty_answer(op: str, data: bytes) -> None:
409
+ """An answer with no fields."""
410
+ _Answer(op, data).done()
411
+
412
+
413
+ def decode_exists_answer(data: bytes) -> tuple[bool, int]:
414
+ """kv.exists's answer: exists, version (0 when absent or buffered in a transaction)."""
415
+ a = _Answer("kv.exists", data)
416
+ exists = a.flag()
417
+ version = a.u64()
418
+ a.done()
419
+ return exists, version
420
+
421
+
422
+ def decode_json_get_answer(data: bytes) -> tuple[int, bytes]:
423
+ """kv.json_get's answer: version, document."""
424
+ a = _Answer("kv.json_get", data)
425
+ version = a.u64()
426
+ document = a.bytes32()
427
+ a.done()
428
+ return version, document
429
+
430
+
431
+ def decode_json_set_answer(data: bytes) -> int:
432
+ """kv.json_set's answer: version."""
433
+ a = _Answer("kv.json_set", data)
434
+ version = a.u64()
435
+ a.done()
436
+ return version
437
+
438
+
439
+ def decode_json_del_answer(data: bytes) -> int | None:
440
+ """kv.json_del's answer: version, None when the path was "$" and the key is gone."""
441
+ a = _Answer("kv.json_del", data)
442
+ version = a.u64() if a.present() else None
443
+ a.done()
444
+ return version
445
+
446
+
447
+ def decode_txn_begin_answer(data: bytes) -> tuple[int, int]:
448
+ """kv.txn_begin's answer: txn, pinned."""
449
+ a = _Answer("kv.txn_begin", data)
450
+ txn, pinned = a.u64(), a.u64()
451
+ a.done()
452
+ return txn, pinned
453
+
454
+
455
+ def decode_txn_commit_answer(data: bytes) -> tuple[int, int]:
456
+ """kv.txn_commit's answer: commit_index, ops."""
457
+ a = _Answer("kv.txn_commit", data)
458
+ commit_index, ops = a.u64(), a.u16()
459
+ a.done()
460
+ return commit_index, ops
461
+
462
+
463
+ def decode_action_status_answer(run_id: str, data: bytes) -> ActionRunStatus:
464
+ """action.status's answer: status, created_at, started_at?, completed_at?,
465
+ attempt, output?, error_message?, worker_id?, with u16 lengths."""
466
+ a = _Answer("action.status", data)
467
+ code = a.u8()
468
+ try:
469
+ status = RunStatus(code)
470
+ except ValueError:
471
+ raise IncompleteResponseError(
472
+ f"action.status answer has an unknown status {code}"
473
+ ) from None
474
+ created_at_ms = a.i64()
475
+ started_at_ms = a.i64() if a.present() else None
476
+ completed_at_ms = a.i64() if a.present() else None
477
+ attempt = a.u32()
478
+ output = a.bytes16() if a.present() else None
479
+ error = a.bytes16() if a.present() else None
480
+ worker_id = a.bytes16() if a.present() else None
481
+ a.done()
482
+ return ActionRunStatus(
483
+ run_id=run_id,
484
+ status=status,
485
+ created_at_ms=created_at_ms,
486
+ attempt=attempt,
487
+ started_at_ms=started_at_ms,
488
+ completed_at_ms=completed_at_ms,
489
+ output=output,
490
+ error_message=error.decode("utf-8", errors="replace") if error is not None else None,
491
+ worker_id=worker_id.decode("utf-8", errors="replace") if worker_id is not None else None,
492
+ )
493
+
494
+
495
+ def decode_action_invoke_answer(data: bytes) -> str:
496
+ """action.invoke's answer: [run_id_len:u16][run_id][has_output:u8]; the
497
+ server never sets output."""
498
+ a = _Answer("action.invoke", data)
499
+ run_id = a.bytes16()
500
+ if not run_id:
501
+ raise IncompleteResponseError("action.invoke answer has no run id")
502
+ if a.present():
503
+ raise IncompleteResponseError("action.invoke answer carries an output")
504
+ a.done()
505
+ return run_id.decode("utf-8")
506
+
507
+
508
+ _E = TypeVar("_E", WorkerType, WorkerStatus)
509
+
510
+
511
+ def _enum8(a: _Answer, op: str, kind: type[_E]) -> _E:
512
+ code = a.u8()
513
+ try:
514
+ return kind(code)
515
+ except ValueError:
516
+ raise IncompleteResponseError(
517
+ f"{op} answer has an unknown {kind.__name__} {code}"
518
+ ) from None
519
+
520
+
521
+ def decode_worker_list_answer(data: bytes) -> tuple[list[WorkerInfo], bytes | None]:
522
+ """worker.list's answer: workers in id order, then the next page's cursor
523
+ (None on the last page)."""
524
+ op = "worker.list"
525
+ a = _Answer(op, data)
526
+ count = a.u16()
527
+ if count > MAX_WORKER_LIST:
528
+ raise IncompleteResponseError(f"{op} answer lists {count} workers")
529
+ workers = []
530
+ for _ in range(count):
531
+ worker_id = a.bytes16().decode("utf-8", errors="replace")
532
+ worker_type = _enum8(a, op, WorkerType)
533
+ status = _enum8(a, op, WorkerStatus)
534
+ current_load, max_concurrency = a.u32(), a.u32()
535
+ tasks_completed, tasks_failed = a.u64(), a.u64()
536
+ registered_at_ms, last_heartbeat_ms = a.i64(), a.i64()
537
+ machine_id = a.bytes8().decode("utf-8", errors="replace") if a.present() else None
538
+ workers.append(
539
+ WorkerInfo(
540
+ worker_id=worker_id,
541
+ worker_type=worker_type,
542
+ status=status,
543
+ current_load=current_load,
544
+ max_concurrency=max_concurrency,
545
+ tasks_completed=tasks_completed,
546
+ tasks_failed=tasks_failed,
547
+ registered_at_ms=registered_at_ms,
548
+ last_heartbeat_ms=last_heartbeat_ms,
549
+ machine_id=machine_id,
550
+ )
551
+ )
552
+ cursor = a.bytes16() if a.present() else None
553
+ a.done()
554
+ return workers, cursor