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/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
- payload,
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
- ) -> None:
186
- """Trim a stream based on retention policy.
198
+ ) -> StreamTrimResult:
199
+ """Remove a stream's oldest records by exactly one bound.
187
200
 
188
- Args:
189
- stream: Stream name.
190
- options: Trim options (max_len, max_age_seconds, max_bytes, dry_run).
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 last 1000 records
207
+ # Keep only the newest 1000 records
194
208
  await client.stream.trim("events", StreamTrimOptions(max_len=1000))
195
209
 
196
- # Delete records older than 1 day
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.RETENTION_COUNT, opts.max_len)
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.RETENTION_AGE, opts.max_age_seconds)
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
- Records are distributed among consumers in the group. Each record
300
- is delivered to only one consumer. Unacknowledged records will be
301
- redelivered.
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, redelivery delay).
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", redelivery_delay_ms=5000))
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
  )