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/__init__.py +60 -15
- flo/actions.py +20 -27
- flo/client.py +193 -46
- flo/exceptions.py +83 -23
- flo/kv.py +373 -165
- flo/kv_body.py +554 -0
- flo/kv_txn.py +237 -0
- flo/processing.py +3 -67
- flo/queue.py +25 -101
- flo/retry.py +124 -0
- flo/streams.py +146 -31
- flo/types.py +431 -221
- flo/wire.py +263 -208
- flo/worker.py +684 -250
- {flo_python-0.1.0.dev4.dist-info → flo_python-0.1.2.dist-info}/METADATA +165 -98
- flo_python-0.1.2.dist-info/RECORD +19 -0
- {flo_python-0.1.0.dev4.dist-info → flo_python-0.1.2.dist-info}/WHEEL +1 -1
- flo_python-0.1.2.dist-info/licenses/LICENSE +202 -0
- flo_python-0.1.0.dev4.dist-info/RECORD +0 -16
- flo_python-0.1.0.dev4.dist-info/licenses/LICENSE +0 -21
flo/retry.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Retrying requests the server refused without running them."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from .exceptions import ServerError, raise_for_status
|
|
10
|
+
from .types import Ran, StatusCode
|
|
11
|
+
|
|
12
|
+
_RETRYABLE = (StatusCode.OVERLOADED, StatusCode.UNAVAILABLE)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class RetryPolicy:
|
|
17
|
+
"""How often a request the server refused without running it is sent again.
|
|
18
|
+
|
|
19
|
+
Only a refusal whose status is overloaded or unavailable and whose ``ran``
|
|
20
|
+
is ``Ran.NO`` is retried: the server says it never ran, so resending is
|
|
21
|
+
safe for any operation, writes included. Each retry is a new request with
|
|
22
|
+
a new id; the server routes it afresh.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
max_attempts: Tries in all, the first included; 1 (or less) turns
|
|
26
|
+
retrying off.
|
|
27
|
+
initial_backoff_ms: Ceiling of the first wait, in milliseconds; each
|
|
28
|
+
later ceiling doubles, up to ``max_backoff_ms``. Each wait is
|
|
29
|
+
uniform in [0, ceiling].
|
|
30
|
+
max_backoff_ms: Largest ceiling, in milliseconds.
|
|
31
|
+
|
|
32
|
+
A negative or non-finite ``initial_backoff_ms`` counts as 0; a
|
|
33
|
+
``max_backoff_ms`` that is non-finite or below ``initial_backoff_ms``
|
|
34
|
+
counts as ``initial_backoff_ms``; a non-finite ``max_attempts`` counts
|
|
35
|
+
as 1, and a fractional one is truncated.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
max_attempts: int = 4
|
|
39
|
+
initial_backoff_ms: float = 50
|
|
40
|
+
max_backoff_ms: float = 1000
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
# A negative or non-finite bound would reach random.uniform or
|
|
44
|
+
# asyncio.sleep (where nan and inf never return), a max below the
|
|
45
|
+
# initial would make the ceiling jump down after the first wait, and
|
|
46
|
+
# a nan attempt count would never be reached.
|
|
47
|
+
initial = _finite_or(self.initial_backoff_ms, 0.0)
|
|
48
|
+
initial = max(initial, 0.0)
|
|
49
|
+
top = max(_finite_or(self.max_backoff_ms, initial), initial)
|
|
50
|
+
attempts = self.max_attempts
|
|
51
|
+
attempts = int(attempts) if math.isfinite(attempts) else 1
|
|
52
|
+
object.__setattr__(self, "initial_backoff_ms", initial)
|
|
53
|
+
object.__setattr__(self, "max_backoff_ms", top)
|
|
54
|
+
object.__setattr__(self, "max_attempts", attempts)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _finite_or(v: float, default: float) -> float:
|
|
58
|
+
return v if math.isfinite(v) else default
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# The default waits at most about 350 ms in all before giving back the refusal.
|
|
62
|
+
DEFAULT_RETRY_POLICY = RetryPolicy()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _refused_unrun(status: StatusCode, data: bytes) -> bool:
|
|
66
|
+
"""Whether a response is a refusal the server says it didn't run, of a
|
|
67
|
+
kind worth sending again. A refusal whose body doesn't say whether it ran
|
|
68
|
+
is not retried."""
|
|
69
|
+
if status not in _RETRYABLE:
|
|
70
|
+
return False
|
|
71
|
+
try:
|
|
72
|
+
raise_for_status(status, data)
|
|
73
|
+
except ServerError as e:
|
|
74
|
+
return e.ran == Ran.NO
|
|
75
|
+
except Exception:
|
|
76
|
+
return False
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_refused_unrun(exc: BaseException) -> bool:
|
|
81
|
+
"""Whether exc is a refusal the server says it didn't run, of a kind worth
|
|
82
|
+
trying again later."""
|
|
83
|
+
return isinstance(exc, ServerError) and exc.status_code in _RETRYABLE and exc.ran == Ran.NO
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _Backoff:
|
|
87
|
+
"""A capped, doubling, fully jittered wait, in seconds."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, initial_s: float, max_s: float) -> None:
|
|
90
|
+
self._initial = max(initial_s, 0.0)
|
|
91
|
+
self._max = max(max_s, self._initial)
|
|
92
|
+
self._ceiling = 0.0
|
|
93
|
+
|
|
94
|
+
def next(self) -> float:
|
|
95
|
+
"""The next wait; doubles the ceiling for the one after."""
|
|
96
|
+
if self._ceiling == 0:
|
|
97
|
+
self._ceiling = self._initial
|
|
98
|
+
d = random.uniform(0, self._ceiling)
|
|
99
|
+
self._ceiling = min(self._ceiling * 2, self._max)
|
|
100
|
+
return d
|
|
101
|
+
|
|
102
|
+
def reset(self) -> None:
|
|
103
|
+
"""Start the waits again from the initial ceiling."""
|
|
104
|
+
self._ceiling = 0.0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _policy_backoff(policy: RetryPolicy) -> _Backoff:
|
|
108
|
+
return _Backoff(policy.initial_backoff_ms / 1000.0, policy.max_backoff_ms / 1000.0)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Worker poll loops wait this way after a failed poll, and start again from
|
|
112
|
+
# the bottom after a poll that succeeds.
|
|
113
|
+
_WORKER_INITIAL_BACKOFF_S = 0.1
|
|
114
|
+
_WORKER_MAX_BACKOFF_S = 5.0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _worker_backoff() -> _Backoff:
|
|
118
|
+
return _Backoff(_WORKER_INITIAL_BACKOFF_S, _WORKER_MAX_BACKOFF_S)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _sleep_unless_stopped(stop: asyncio.Event, seconds: float) -> None:
|
|
122
|
+
"""Wait seconds, or until stop is set."""
|
|
123
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
124
|
+
await asyncio.wait_for(stop.wait(), timeout=seconds)
|
flo/streams.py
CHANGED
|
@@ -4,13 +4,16 @@ Stream (append-only log) operations for Flo client.
|
|
|
4
4
|
Uses StreamID-native positioning (timestamp_ms + sequence).
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import struct
|
|
7
8
|
from typing import TYPE_CHECKING
|
|
8
9
|
|
|
9
10
|
from .types import (
|
|
10
11
|
OpCode,
|
|
11
12
|
OptionTag,
|
|
13
|
+
PendingEntry,
|
|
12
14
|
StreamAppendOptions,
|
|
13
15
|
StreamAppendResult,
|
|
16
|
+
StreamClaimResult,
|
|
14
17
|
StreamGroupAckOptions,
|
|
15
18
|
StreamGroupJoinOptions,
|
|
16
19
|
StreamGroupNackOptions,
|
|
@@ -21,16 +24,25 @@ from .types import (
|
|
|
21
24
|
StreamReadOptions,
|
|
22
25
|
StreamReadResult,
|
|
23
26
|
StreamTrimOptions,
|
|
27
|
+
StreamTrimResult,
|
|
24
28
|
)
|
|
25
29
|
from .wire import (
|
|
26
30
|
OptionsBuilder,
|
|
31
|
+
build_stream_batch_value,
|
|
32
|
+
parse_pending_entries,
|
|
27
33
|
parse_stream_append_response,
|
|
28
34
|
parse_stream_info_response,
|
|
29
35
|
parse_stream_read_response,
|
|
30
36
|
serialize_group_ack_value,
|
|
37
|
+
serialize_group_claim_value,
|
|
38
|
+
serialize_group_pending_value,
|
|
31
39
|
serialize_group_value,
|
|
32
40
|
)
|
|
33
41
|
|
|
42
|
+
# u64 max sentinel: group_claim returns StreamID.MAX when the PEL is fully
|
|
43
|
+
# scanned (mirrors Go's math.MaxUint64 check).
|
|
44
|
+
_U64_MAX = 0xFFFFFFFFFFFFFFFF
|
|
45
|
+
|
|
34
46
|
if TYPE_CHECKING:
|
|
35
47
|
from .client import FloClient
|
|
36
48
|
|
|
@@ -63,12 +75,13 @@ class StreamOperations:
|
|
|
63
75
|
"""
|
|
64
76
|
opts = options or StreamAppendOptions()
|
|
65
77
|
namespace = self._client.get_namespace(opts.namespace)
|
|
78
|
+
value = build_stream_batch_value(payload, opts.headers)
|
|
66
79
|
|
|
67
80
|
response = await self._client._send_and_check(
|
|
68
81
|
OpCode.STREAM_APPEND,
|
|
69
82
|
namespace,
|
|
70
83
|
stream.encode("utf-8"),
|
|
71
|
-
|
|
84
|
+
value,
|
|
72
85
|
allow_not_found=True,
|
|
73
86
|
)
|
|
74
87
|
|
|
@@ -182,21 +195,19 @@ class StreamOperations:
|
|
|
182
195
|
self,
|
|
183
196
|
stream: str,
|
|
184
197
|
options: StreamTrimOptions | None = None,
|
|
185
|
-
) ->
|
|
186
|
-
"""
|
|
198
|
+
) -> StreamTrimResult:
|
|
199
|
+
"""Remove a stream's oldest records by exactly one bound.
|
|
187
200
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
201
|
+
Set one of ``before``, ``max_len`` or ``max_age_seconds``; the server
|
|
202
|
+
refuses none or several. Trim cuts whole append batches, so ``max_len``
|
|
203
|
+
may keep a few more records than asked. With ``dry_run`` nothing is
|
|
204
|
+
removed and the result says what would be.
|
|
191
205
|
|
|
192
206
|
Example:
|
|
193
|
-
# Keep only
|
|
207
|
+
# Keep only the newest 1000 records
|
|
194
208
|
await client.stream.trim("events", StreamTrimOptions(max_len=1000))
|
|
195
209
|
|
|
196
|
-
#
|
|
197
|
-
await client.stream.trim("events", StreamTrimOptions(max_age_seconds=86400))
|
|
198
|
-
|
|
199
|
-
# Preview what would be deleted
|
|
210
|
+
# Count what that would remove, removing nothing
|
|
200
211
|
await client.stream.trim("events", StreamTrimOptions(max_len=1000, dry_run=True))
|
|
201
212
|
"""
|
|
202
213
|
opts = options or StreamTrimOptions()
|
|
@@ -204,26 +215,29 @@ class StreamOperations:
|
|
|
204
215
|
|
|
205
216
|
builder = OptionsBuilder()
|
|
206
217
|
|
|
218
|
+
if opts.before is not None:
|
|
219
|
+
builder.add_bytes(OptionTag.STREAM_START, opts.before.to_bytes())
|
|
220
|
+
|
|
207
221
|
if opts.max_len is not None:
|
|
208
|
-
builder.add_u64(OptionTag.
|
|
222
|
+
builder.add_u64(OptionTag.LIMIT, opts.max_len)
|
|
209
223
|
|
|
210
224
|
if opts.max_age_seconds is not None:
|
|
211
|
-
builder.add_u64(OptionTag.
|
|
212
|
-
|
|
213
|
-
if opts.max_bytes is not None:
|
|
214
|
-
builder.add_u64(OptionTag.RETENTION_BYTES, opts.max_bytes)
|
|
225
|
+
builder.add_u64(OptionTag.MAX_AGE_SECONDS, opts.max_age_seconds)
|
|
215
226
|
|
|
216
227
|
if opts.dry_run:
|
|
217
228
|
builder.add_flag(OptionTag.DRY_RUN)
|
|
218
229
|
|
|
219
|
-
await self._client._send_and_check(
|
|
230
|
+
response = await self._client._send_and_check(
|
|
220
231
|
OpCode.STREAM_TRIM,
|
|
221
232
|
namespace,
|
|
222
233
|
stream.encode("utf-8"),
|
|
223
234
|
b"",
|
|
224
235
|
builder.build(),
|
|
225
|
-
allow_not_found=True,
|
|
226
236
|
)
|
|
237
|
+
if len(response.data) != 16:
|
|
238
|
+
raise ValueError(f"stream trim: response is {len(response.data)} bytes, want 16")
|
|
239
|
+
removed, first_seq = struct.unpack("<QQ", response.data)
|
|
240
|
+
return StreamTrimResult(removed=removed, first_seq=first_seq)
|
|
227
241
|
|
|
228
242
|
async def group_join(
|
|
229
243
|
self,
|
|
@@ -294,11 +308,18 @@ class StreamOperations:
|
|
|
294
308
|
consumer: str,
|
|
295
309
|
options: StreamGroupReadOptions | None = None,
|
|
296
310
|
) -> StreamReadResult:
|
|
297
|
-
"""Read records from a consumer group.
|
|
311
|
+
"""Read new records from a consumer group, advancing last_delivered_id.
|
|
312
|
+
|
|
313
|
+
Records are distributed among consumers in the group; each record is
|
|
314
|
+
delivered to only one consumer and added to that consumer's Pending
|
|
315
|
+
Entry List (PEL) until acked. Unacknowledged records are redelivered.
|
|
298
316
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
317
|
+
Crash recovery: ``group_read`` alone is NOT sufficient. It only returns
|
|
318
|
+
records past ``last_delivered_id``, so a record delivered-but-unacked at
|
|
319
|
+
crash time is never re-surfaced by a later ``group_read``. To re-process
|
|
320
|
+
in-flight work after a reconnect, drain the PEL with :meth:`group_claim`
|
|
321
|
+
(StreamWorker does this automatically — see
|
|
322
|
+
``redeliver_pending_on_reconnect``).
|
|
302
323
|
|
|
303
324
|
Args:
|
|
304
325
|
stream: Stream name.
|
|
@@ -340,6 +361,99 @@ class StreamOperations:
|
|
|
340
361
|
|
|
341
362
|
return parse_stream_read_response(response.data)
|
|
342
363
|
|
|
364
|
+
async def group_pending(
|
|
365
|
+
self,
|
|
366
|
+
stream: str,
|
|
367
|
+
group: str,
|
|
368
|
+
consumer: str = "",
|
|
369
|
+
options: StreamGroupReadOptions | None = None,
|
|
370
|
+
) -> list[PendingEntry]:
|
|
371
|
+
"""List a consumer group's pending (delivered-but-unacked) entries.
|
|
372
|
+
|
|
373
|
+
If ``consumer`` is non-empty, only that consumer's entries are returned;
|
|
374
|
+
otherwise the whole group's PEL is returned.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
A list of :class:`PendingEntry`.
|
|
378
|
+
"""
|
|
379
|
+
opts = options or StreamGroupReadOptions()
|
|
380
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
381
|
+
|
|
382
|
+
value = serialize_group_pending_value(group, consumer)
|
|
383
|
+
|
|
384
|
+
response = await self._client._send_and_check(
|
|
385
|
+
OpCode.STREAM_GROUP_PENDING,
|
|
386
|
+
namespace,
|
|
387
|
+
stream.encode("utf-8"),
|
|
388
|
+
value,
|
|
389
|
+
allow_not_found=True,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return parse_pending_entries(response.data)
|
|
393
|
+
|
|
394
|
+
async def group_claim(
|
|
395
|
+
self,
|
|
396
|
+
stream: str,
|
|
397
|
+
group: str,
|
|
398
|
+
consumer: str,
|
|
399
|
+
min_idle_ms: int,
|
|
400
|
+
start_id: StreamID,
|
|
401
|
+
count: int,
|
|
402
|
+
options: StreamGroupReadOptions | None = None,
|
|
403
|
+
) -> StreamClaimResult:
|
|
404
|
+
"""Claim a page of a consumer group's pending entries for ``consumer``.
|
|
405
|
+
|
|
406
|
+
Scans the PEL in StreamID order from ``start_id`` and takes up to
|
|
407
|
+
``count`` entries idle for at least ``min_idle_ms``. Returns the claimed
|
|
408
|
+
records (payload + headers) plus a cursor for the next page.
|
|
409
|
+
|
|
410
|
+
* Drain own pending (reconnect): ``min_idle_ms=0``, ``start_id=StreamID()``.
|
|
411
|
+
* Steal from idle consumers (rebalance): ``min_idle_ms > 0``.
|
|
412
|
+
|
|
413
|
+
Loop until ``result.done`` to fully drain::
|
|
414
|
+
|
|
415
|
+
cursor = StreamID()
|
|
416
|
+
while True:
|
|
417
|
+
r = await client.stream.group_claim(
|
|
418
|
+
stream, group, consumer, 0, cursor, 100)
|
|
419
|
+
for rec in r.records:
|
|
420
|
+
process(rec)
|
|
421
|
+
if r.done or not r.records:
|
|
422
|
+
break
|
|
423
|
+
cursor = r.next_cursor
|
|
424
|
+
"""
|
|
425
|
+
opts = options or StreamGroupReadOptions()
|
|
426
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
427
|
+
|
|
428
|
+
value = serialize_group_claim_value(group, consumer, min_idle_ms, start_id, count)
|
|
429
|
+
|
|
430
|
+
response = await self._client._send_and_check(
|
|
431
|
+
OpCode.STREAM_GROUP_CLAIM,
|
|
432
|
+
namespace,
|
|
433
|
+
stream.encode("utf-8"),
|
|
434
|
+
value,
|
|
435
|
+
allow_not_found=True,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# Response = <records blob> + [next_ts:u64][next_seq:u64] trailer.
|
|
439
|
+
data = response.data or b""
|
|
440
|
+
if len(data) < 16:
|
|
441
|
+
return StreamClaimResult(records=[], next_cursor=StreamID(), done=True)
|
|
442
|
+
|
|
443
|
+
cursor_off = len(data) - 16
|
|
444
|
+
next_ts = int.from_bytes(data[cursor_off : cursor_off + 8], "little")
|
|
445
|
+
next_seq = int.from_bytes(data[cursor_off + 8 : cursor_off + 16], "little")
|
|
446
|
+
|
|
447
|
+
read = parse_stream_read_response(data[:cursor_off])
|
|
448
|
+
|
|
449
|
+
# StreamID.MAX (max, max) is the "fully scanned" sentinel.
|
|
450
|
+
done = next_ts == _U64_MAX and next_seq == _U64_MAX
|
|
451
|
+
return StreamClaimResult(
|
|
452
|
+
records=read.records,
|
|
453
|
+
next_cursor=StreamID(timestamp_ms=next_ts, sequence=next_seq),
|
|
454
|
+
done=done,
|
|
455
|
+
)
|
|
456
|
+
|
|
343
457
|
async def group_ack(
|
|
344
458
|
self,
|
|
345
459
|
stream: str,
|
|
@@ -355,6 +469,10 @@ class StreamOperations:
|
|
|
355
469
|
ids: StreamIDs of records to acknowledge.
|
|
356
470
|
options: Optional ack options (including consumer name).
|
|
357
471
|
|
|
472
|
+
Raises:
|
|
473
|
+
NotFoundError: If the stream or group does not exist, so the ack
|
|
474
|
+
did not take effect.
|
|
475
|
+
|
|
358
476
|
Example:
|
|
359
477
|
result = await client.stream.group_read("events", "processors", "worker-1")
|
|
360
478
|
for record in result.records:
|
|
@@ -378,7 +496,6 @@ class StreamOperations:
|
|
|
378
496
|
namespace,
|
|
379
497
|
stream.encode("utf-8"),
|
|
380
498
|
value,
|
|
381
|
-
allow_not_found=True,
|
|
382
499
|
)
|
|
383
500
|
|
|
384
501
|
async def group_nack(
|
|
@@ -394,7 +511,11 @@ class StreamOperations:
|
|
|
394
511
|
stream: Stream name.
|
|
395
512
|
group: Consumer group name.
|
|
396
513
|
ids: StreamIDs of records to negatively acknowledge.
|
|
397
|
-
options: Optional nack options (consumer name
|
|
514
|
+
options: Optional nack options (consumer name).
|
|
515
|
+
|
|
516
|
+
Raises:
|
|
517
|
+
NotFoundError: If the stream or group does not exist, so the nack
|
|
518
|
+
did not take effect.
|
|
398
519
|
|
|
399
520
|
Example:
|
|
400
521
|
result = await client.stream.group_read("events", "processors", "worker-1")
|
|
@@ -403,7 +524,7 @@ class StreamOperations:
|
|
|
403
524
|
process(record.payload)
|
|
404
525
|
except Exception:
|
|
405
526
|
await client.stream.group_nack("events", "processors", [record.id],
|
|
406
|
-
StreamGroupNackOptions(consumer="worker-1"
|
|
527
|
+
StreamGroupNackOptions(consumer="worker-1"))
|
|
407
528
|
"""
|
|
408
529
|
if not ids:
|
|
409
530
|
return
|
|
@@ -413,15 +534,9 @@ class StreamOperations:
|
|
|
413
534
|
|
|
414
535
|
value = serialize_group_ack_value(group, ids, consumer=opts.consumer)
|
|
415
536
|
|
|
416
|
-
builder = OptionsBuilder()
|
|
417
|
-
if opts.redelivery_delay_ms is not None:
|
|
418
|
-
builder.add_u32(OptionTag.REDELIVERY_DELAY_MS, opts.redelivery_delay_ms)
|
|
419
|
-
|
|
420
537
|
await self._client._send_and_check(
|
|
421
538
|
OpCode.STREAM_GROUP_NACK,
|
|
422
539
|
namespace,
|
|
423
540
|
stream.encode("utf-8"),
|
|
424
541
|
value,
|
|
425
|
-
builder.build(),
|
|
426
|
-
allow_not_found=True,
|
|
427
542
|
)
|