flo-python 0.1.0.dev4__py3-none-any.whl → 0.1.1__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/types.py CHANGED
@@ -6,7 +6,7 @@ Core types, constants, and data classes for the Flo client SDK.
6
6
  import struct
7
7
  from dataclasses import dataclass
8
8
  from enum import IntEnum
9
- from typing import Optional
9
+ from typing import Any, Generic, Optional, TypeVar
10
10
 
11
11
  # =============================================================================
12
12
  # Protocol Constants
@@ -67,7 +67,7 @@ class OpCode(IntEnum):
67
67
  CLUSTER_MEMBERS_RESPONSE = 0x041
68
68
  CLUSTER_JOIN_RESPONSE = 0x042
69
69
 
70
- # ── KV + Transactions + Snapshots (0x100 – 0x12F) ──
70
+ # ── KV (0x100 – 0x12F) ──
71
71
  KV_PUT = 0x100
72
72
  KV_GET = 0x101
73
73
  KV_MGET = 0x102
@@ -79,13 +79,23 @@ class OpCode(IntEnum):
79
79
  KV_PUT_RESPONSE = 0x108
80
80
  KV_SCAN_RESPONSE = 0x109
81
81
  KV_HISTORY_RESPONSE = 0x10A
82
+ # KV Extended (atomic counters, JSON ops)
83
+ KV_INCR = 0x10B
84
+ KV_JSON_GET = 0x10C
85
+ KV_JSON_SET = 0x10D
86
+ KV_JSON_DEL = 0x10E
87
+ # KV Per-Shard Transactions
82
88
  KV_BEGIN_TXN = 0x110
83
89
  KV_COMMIT_TXN = 0x111
84
90
  KV_ROLLBACK_TXN = 0x112
85
- KV_SNAPSHOT_CREATE = 0x120
86
- KV_SNAPSHOT_GET = 0x121
87
- KV_SNAPSHOT_RELEASE = 0x122
88
- KV_SNAPSHOT_CREATE_RESPONSE = 0x123
91
+ # KV Extended (TTL lifecycle, exists)
92
+ KV_TOUCH = 0x113
93
+ KV_PERSIST = 0x114
94
+ KV_EXISTS = 0x115
95
+ KV_INCR_RESPONSE = 0x116
96
+ KV_JSON_RESPONSE = 0x117
97
+ KV_EXISTS_RESPONSE = 0x118
98
+ KV_TXN_RESPONSE = 0x119
89
99
 
90
100
  # ── Streams (0x130 – 0x14F) ──
91
101
  STREAM_APPEND = 0x130
@@ -285,6 +295,7 @@ class OptionTag(IntEnum):
285
295
  KEYS_ONLY = 0x06 # u8: Skip values in scan response (0/1)
286
296
  CURSOR = 0x07 # bytes: Pagination cursor (ShardWalker format)
287
297
  ROUTING_KEY = 0x08 # string: Explicit routing key for shard co-location
298
+ TXN_ID = 0x09 # u64: Transaction ID for per-shard transactions
288
299
 
289
300
  # Queue Options (0x10 - 0x1F)
290
301
  PRIORITY = 0x10 # u8: Message priority (0-255, higher = more urgent)
@@ -382,6 +393,88 @@ class VersionEntry:
382
393
  value: bytes
383
394
 
384
395
 
396
+ @dataclass
397
+ class PutResult:
398
+ """Result of a successful KV put.
399
+
400
+ The ``version`` field is the new version assigned by the server, suitable
401
+ for CAS on the next write via :class:`PutOptions.cas_version`.
402
+ """
403
+
404
+ version: int
405
+
406
+
407
+ @dataclass
408
+ class KVBeginResult:
409
+ """Result of a successful KV transaction begin.
410
+
411
+ ``txn_id`` is the server-assigned transaction handle. ``pinned_hash`` is
412
+ the partition hash this transaction is bound to — every key written or
413
+ read inside the transaction must hash to the same partition.
414
+ """
415
+
416
+ txn_id: int
417
+ pinned_hash: int
418
+
419
+
420
+ @dataclass
421
+ class KVCommitResult:
422
+ """Result of a successful KV transaction commit.
423
+
424
+ ``commit_index`` is the Raft log index of the committed batch and
425
+ ``op_count`` is the number of buffered operations applied atomically.
426
+ """
427
+
428
+ commit_index: int
429
+ op_count: int
430
+
431
+
432
+ @dataclass
433
+ class GetResult:
434
+ """Result of a KV get that found a key.
435
+
436
+ ``kv.get`` returns ``None`` when the key is missing; check for ``None``
437
+ before dereferencing.
438
+ """
439
+
440
+ value: bytes
441
+ version: int
442
+
443
+
444
+ T = TypeVar("T")
445
+
446
+
447
+ @dataclass
448
+ class GetJsonResult(Generic[T]):
449
+ """Result of a :meth:`KV.json_get` that found the key and path.
450
+
451
+ Unlike :class:`GetResult`, ``value`` is the already-parsed JSON (the
452
+ ``KV_JSON_GET`` wire body is always a JSON document, so it is decoded for
453
+ you). ``version`` is the document's current version. ``kv.json_get``
454
+ returns ``None`` when the key or path is missing.
455
+
456
+ The value type defaults to ``Any``; the SDK does not validate the shape at
457
+ runtime.
458
+ """
459
+
460
+ value: Any
461
+ version: int
462
+
463
+
464
+ @dataclass
465
+ class MGetEntry:
466
+ """One entry in a :meth:`KV.mget` response.
467
+
468
+ ``found`` is ``False`` when the key did not exist; in that case ``value``
469
+ is ``b''`` and ``version`` is ``0``.
470
+ """
471
+
472
+ key: str
473
+ value: bytes
474
+ version: int
475
+ found: bool
476
+
477
+
385
478
  @dataclass
386
479
  class Message:
387
480
  """Queue message."""
@@ -440,6 +533,7 @@ class StreamRecord:
440
533
 
441
534
  id: StreamID = None # type: ignore[assignment]
442
535
  tier: StorageTier = StorageTier.HOT
536
+ stream: str = ""
443
537
  payload: bytes = b""
444
538
  headers: dict[str, str] | None = None
445
539
 
@@ -458,6 +552,35 @@ class StreamReadResult:
458
552
  records: list[StreamRecord]
459
553
 
460
554
 
555
+ @dataclass
556
+ class PendingEntry:
557
+ """One entry in a consumer group's Pending Entry List (PEL).
558
+
559
+ A delivered-but-unacked message. ``delivery_count`` is how many times the
560
+ entry has been delivered; ``consumer`` is the consumer that currently owns
561
+ it. (FLO-102)
562
+ """
563
+
564
+ id: StreamID = None # type: ignore[assignment]
565
+ consumer: str = ""
566
+ delivery_count: int = 0
567
+
568
+
569
+ @dataclass
570
+ class StreamClaimResult:
571
+ """Result of a :meth:`StreamOperations.group_claim` cursor page (FLO-102).
572
+
573
+ ``records`` carry payload + headers (same shape as ``group_read``).
574
+ ``next_cursor`` is the ``start_id`` to pass on the next ``group_claim``
575
+ call; when ``done`` is ``True`` the PEL has been fully scanned and the loop
576
+ should stop.
577
+ """
578
+
579
+ records: list[StreamRecord]
580
+ next_cursor: StreamID = None # type: ignore[assignment]
581
+ done: bool = False
582
+
583
+
461
584
  @dataclass
462
585
  class StreamInfo:
463
586
  """Stream metadata."""
@@ -498,6 +621,7 @@ class DeleteOptions:
498
621
  """Options for KV delete operations."""
499
622
 
500
623
  namespace: str | None = None
624
+ if_match: int | None = None # CAS: only delete when current version equals if_match
501
625
 
502
626
 
503
627
  @dataclass
@@ -518,6 +642,43 @@ class HistoryOptions:
518
642
  limit: int | None = None
519
643
 
520
644
 
645
+ @dataclass
646
+ class KVIncrOptions:
647
+ """Options for KV incr operations."""
648
+
649
+ namespace: str | None = None
650
+ delta: int | None = None # default +1 when None
651
+
652
+
653
+ @dataclass
654
+ class KVTouchOptions:
655
+ """Options for KV touch / persist operations."""
656
+
657
+ namespace: str | None = None
658
+ if_match: int | None = None # CAS: only succeed when current version equals if_match
659
+
660
+
661
+ @dataclass
662
+ class KVExistsOptions:
663
+ """Options for KV exists operations."""
664
+
665
+ namespace: str | None = None
666
+
667
+
668
+ @dataclass
669
+ class KVJsonOptions:
670
+ """Options for KV JSON.* operations."""
671
+
672
+ namespace: str | None = None
673
+
674
+
675
+ @dataclass
676
+ class KVMGetOptions:
677
+ """Options for KV mget operations."""
678
+
679
+ namespace: str | None = None
680
+
681
+
521
682
  @dataclass
522
683
  class EnqueueOptions:
523
684
  """Options for queue enqueue operations."""
@@ -769,6 +930,8 @@ class TaskAssignment:
769
930
  payload: bytes
770
931
  created_at: int
771
932
  attempt: int
933
+ caller_run_id: str = ""
934
+ caller_workflow_name: str = ""
772
935
 
773
936
 
774
937
  # Alias for backwards compatibility
flo/wire.py CHANGED
@@ -30,6 +30,7 @@ from .types import (
30
30
  Message,
31
31
  OpCode,
32
32
  OptionTag,
33
+ PendingEntry,
33
34
  ScanResult,
34
35
  StatusCode,
35
36
  StorageTier,
@@ -597,7 +598,8 @@ def parse_stream_read_response(data: bytes) -> StreamReadResult:
597
598
  key_present = data[pos]
598
599
  pos += 1
599
600
 
600
- # Skip key if present
601
+ # Read key (stream name) if present
602
+ stream_name = ""
601
603
  if key_present != 0:
602
604
  if pos + 4 > len(data):
603
605
  raise IncompleteResponseError("Stream record: missing key length")
@@ -605,6 +607,7 @@ def parse_stream_read_response(data: bytes) -> StreamReadResult:
605
607
  pos += 4
606
608
  if pos + key_len > len(data):
607
609
  raise IncompleteResponseError("Stream record: missing key data")
610
+ stream_name = data[pos : pos + key_len].decode("utf-8")
608
611
  pos += key_len
609
612
 
610
613
  # Read payload
@@ -618,17 +621,41 @@ def parse_stream_read_response(data: bytes) -> StreamReadResult:
618
621
  payload = data[pos : pos + payload_len]
619
622
  pos += payload_len
620
623
 
621
- # Skip header_count (TODO: parse headers)
624
+ # Read headers: [header_count:u32]([key_len:u32][key][val_len:u32][val])*
622
625
  if pos + 4 > len(data):
623
626
  raise IncompleteResponseError("Stream record: missing header count")
627
+ header_count = struct.unpack("<I", data[pos : pos + 4])[0]
624
628
  pos += 4
625
629
 
630
+ headers = None
631
+ if header_count > 0:
632
+ headers = {}
633
+ for _ in range(header_count):
634
+ if pos + 4 > len(data):
635
+ raise IncompleteResponseError("Stream record: missing header key length")
636
+ hkey_len = struct.unpack("<I", data[pos : pos + 4])[0]
637
+ pos += 4
638
+ if pos + hkey_len > len(data):
639
+ raise IncompleteResponseError("Stream record: missing header key")
640
+ hkey = data[pos : pos + hkey_len].decode("utf-8")
641
+ pos += hkey_len
642
+ if pos + 4 > len(data):
643
+ raise IncompleteResponseError("Stream record: missing header value length")
644
+ hval_len = struct.unpack("<I", data[pos : pos + 4])[0]
645
+ pos += 4
646
+ if pos + hval_len > len(data):
647
+ raise IncompleteResponseError("Stream record: missing header value")
648
+ hval = data[pos : pos + hval_len].decode("utf-8")
649
+ pos += hval_len
650
+ headers[hkey] = hval
651
+
626
652
  records.append(
627
653
  StreamRecord(
628
654
  id=StreamID(timestamp_ms=timestamp_ms, sequence=sequence),
629
655
  tier=tier,
656
+ stream=stream_name,
630
657
  payload=payload,
631
- headers=None,
658
+ headers=headers,
632
659
  )
633
660
  )
634
661
 
@@ -661,6 +688,33 @@ def parse_stream_info_response(data: bytes) -> StreamInfo:
661
688
  )
662
689
 
663
690
 
691
+ def build_stream_batch_value(
692
+ payload: bytes,
693
+ headers: dict[str, str] | None = None,
694
+ ) -> bytes:
695
+ """Frame a single record for stream_append (batch of one).
696
+
697
+ Wire format:
698
+ [record_count:u32=1][payload_len:u32][payload]
699
+ [header_count:u16]([key_len:u16][key][val_len:u16][val])*
700
+ """
701
+ hdrs = headers or {}
702
+ parts: list[bytes] = [
703
+ struct.pack("<I", 1),
704
+ struct.pack("<I", len(payload)),
705
+ payload,
706
+ struct.pack("<H", len(hdrs)),
707
+ ]
708
+ for key, val in hdrs.items():
709
+ key_bytes = key.encode("utf-8")
710
+ val_bytes = val.encode("utf-8")
711
+ parts.append(struct.pack("<H", len(key_bytes)))
712
+ parts.append(key_bytes)
713
+ parts.append(struct.pack("<H", len(val_bytes)))
714
+ parts.append(val_bytes)
715
+ return b"".join(parts)
716
+
717
+
664
718
  def serialize_group_value(group: str, consumer: str) -> bytes:
665
719
  """Serialize group and consumer names for consumer group operations.
666
720
 
@@ -697,6 +751,92 @@ def serialize_group_ack_value(group: str, ids: list[StreamID], consumer: str = "
697
751
  return bytes(result)
698
752
 
699
753
 
754
+ def serialize_group_pending_value(group: str, consumer: str = "") -> bytes:
755
+ """Serialize group (and optional consumer filter) for STREAM_GROUP_PENDING.
756
+
757
+ Format: [group_len:u16][group]([consumer_len:u16][consumer])?
758
+
759
+ An empty ``consumer`` returns the whole group's PEL; a non-empty one filters
760
+ to that consumer's entries.
761
+ """
762
+ group_bytes = group.encode("utf-8")
763
+
764
+ result = bytearray()
765
+ result.extend(struct.pack("<H", len(group_bytes)))
766
+ result.extend(group_bytes)
767
+ if consumer:
768
+ consumer_bytes = consumer.encode("utf-8")
769
+ result.extend(struct.pack("<H", len(consumer_bytes)))
770
+ result.extend(consumer_bytes)
771
+ return bytes(result)
772
+
773
+
774
+ def serialize_group_claim_value(
775
+ group: str,
776
+ consumer: str,
777
+ min_idle_ms: int,
778
+ start_id: StreamID,
779
+ count: int,
780
+ ) -> bytes:
781
+ """Serialize the request value for STREAM_GROUP_CLAIM (FLO-102).
782
+
783
+ Format: [group_len:u16][group][consumer_len:u16][consumer]
784
+ [min_idle_ms:u32][start_ts:u64][start_seq:u64][count:u32]
785
+ """
786
+ group_bytes = group.encode("utf-8")
787
+ consumer_bytes = consumer.encode("utf-8")
788
+
789
+ result = bytearray()
790
+ result.extend(struct.pack("<H", len(group_bytes)))
791
+ result.extend(group_bytes)
792
+ result.extend(struct.pack("<H", len(consumer_bytes)))
793
+ result.extend(consumer_bytes)
794
+ result.extend(struct.pack("<I", min_idle_ms))
795
+ result.extend(struct.pack("<Q", start_id.timestamp_ms))
796
+ result.extend(struct.pack("<Q", start_id.sequence))
797
+ result.extend(struct.pack("<I", count))
798
+ return bytes(result)
799
+
800
+
801
+ def parse_pending_entries(data: bytes) -> list[PendingEntry]:
802
+ """Parse the STREAM_GROUP_PENDING response (FLO-102).
803
+
804
+ Format: [count:u32]([ts:u64][seq:u64][delivery_count:u32]
805
+ [consumer_len:u16][consumer])*
806
+ """
807
+ if len(data) < 4:
808
+ return []
809
+
810
+ pos = 0
811
+ count = struct.unpack("<I", data[pos : pos + 4])[0]
812
+ pos += 4
813
+
814
+ entries: list[PendingEntry] = []
815
+ for _ in range(count):
816
+ if pos + 8 + 8 + 4 + 2 > len(data):
817
+ raise IncompleteResponseError("Pending entry: truncated header")
818
+ ts = struct.unpack("<Q", data[pos : pos + 8])[0]
819
+ pos += 8
820
+ seq = struct.unpack("<Q", data[pos : pos + 8])[0]
821
+ pos += 8
822
+ delivery_count = struct.unpack("<I", data[pos : pos + 4])[0]
823
+ pos += 4
824
+ clen = struct.unpack("<H", data[pos : pos + 2])[0]
825
+ pos += 2
826
+ if pos + clen > len(data):
827
+ raise IncompleteResponseError("Pending entry: truncated consumer")
828
+ consumer = data[pos : pos + clen].decode("utf-8")
829
+ pos += clen
830
+ entries.append(
831
+ PendingEntry(
832
+ id=StreamID(timestamp_ms=ts, sequence=seq),
833
+ consumer=consumer,
834
+ delivery_count=delivery_count,
835
+ )
836
+ )
837
+ return entries
838
+
839
+
700
840
  # =============================================================================
701
841
  # Action/Worker Value Serialization
702
842
  # =============================================================================
@@ -760,7 +900,8 @@ def serialize_action_invoke_value(
760
900
  """Serialize action invoke value.
761
901
 
762
902
  Format: [priority:u8][delay_ms:i64][has_caller:u8]
763
- [has_idempotency_key:u8][key_len:u16]?[key]?[input...]
903
+ [has_idempotency_key:u8][key_len:u16]?[key]?
904
+ [has_labels:u8][labels_len:u16]?[labels]?[input...]
764
905
  """
765
906
  result = bytearray()
766
907
 
@@ -782,6 +923,9 @@ def serialize_action_invoke_value(
782
923
  else:
783
924
  result.append(0)
784
925
 
926
+ # labels (none)
927
+ result.append(0)
928
+
785
929
  # input
786
930
  result.extend(input_data)
787
931
 
@@ -945,7 +1089,9 @@ def parse_task_assignment(data: bytes) -> "types.TaskAssignment":
945
1089
  """Parse task assignment from server response.
946
1090
 
947
1091
  Format: [task_id_len:u16][task_id][task_type_len:u16][task_type]
948
- [created_at:i64][attempt:u32][payload...]
1092
+ [created_at:i64][attempt:u32]
1093
+ [has_caller:u8]([run_id_len:u16][run_id][wf_name_len:u16][wf_name])?
1094
+ [payload...]
949
1095
  """
950
1096
  if len(data) < 10:
951
1097
  raise ValueError("Incomplete task assignment response")
@@ -982,6 +1128,32 @@ def parse_task_assignment(data: bytes) -> "types.TaskAssignment":
982
1128
  attempt = struct.unpack_from("<I", data, pos)[0]
983
1129
  pos += 4
984
1130
 
1131
+ # caller block: [has_caller:u8][run_id_len:u16][run_id][wf_name_len:u16][wf_name]
1132
+ if pos + 1 > len(data):
1133
+ raise ValueError("Incomplete task assignment: missing has_caller")
1134
+ has_caller = data[pos]
1135
+ pos += 1
1136
+ caller_run_id = ""
1137
+ caller_workflow_name = ""
1138
+ if has_caller == 1:
1139
+ if pos + 2 > len(data):
1140
+ raise ValueError("Incomplete task assignment: missing caller_run_id length")
1141
+ crid_len = struct.unpack_from("<H", data, pos)[0]
1142
+ pos += 2
1143
+ if pos + crid_len > len(data):
1144
+ raise ValueError("Incomplete task assignment: missing caller_run_id")
1145
+ caller_run_id = data[pos : pos + crid_len].decode("utf-8")
1146
+ pos += crid_len
1147
+
1148
+ if pos + 2 > len(data):
1149
+ raise ValueError("Incomplete task assignment: missing caller_workflow_name length")
1150
+ cwf_len = struct.unpack_from("<H", data, pos)[0]
1151
+ pos += 2
1152
+ if pos + cwf_len > len(data):
1153
+ raise ValueError("Incomplete task assignment: missing caller_workflow_name")
1154
+ caller_workflow_name = data[pos : pos + cwf_len].decode("utf-8")
1155
+ pos += cwf_len
1156
+
985
1157
  # payload (rest of data)
986
1158
  payload = data[pos:]
987
1159
 
@@ -991,4 +1163,6 @@ def parse_task_assignment(data: bytes) -> "types.TaskAssignment":
991
1163
  payload=payload,
992
1164
  created_at=created_at,
993
1165
  attempt=attempt,
1166
+ caller_run_id=caller_run_id,
1167
+ caller_workflow_name=caller_workflow_name,
994
1168
  )
flo/worker.py CHANGED
@@ -666,6 +666,15 @@ class StreamWorkerOptions:
666
666
  batch_size: int = 10
667
667
  block_ms: int = 30000
668
668
  message_timeout: float = 300.0 # 5 minutes
669
+ # Drain this consumer's pending (delivered-but-unacked) entries via
670
+ # group_claim after a reconnect, before resuming normal group_read.
671
+ # Required for at-least-once across reconnects: group_read alone only
672
+ # returns records past last_delivered_id. Set False for at-most-once.
673
+ redeliver_pending_on_reconnect: bool = True
674
+ # Minimum idle time (ms) an entry must have before the reconnect drain
675
+ # claims it. 0 = drain everything this consumer already owns; > 0 also
676
+ # steals entries abandoned by other (dead) consumers idle at least this long.
677
+ claim_min_idle_ms: int = 0
669
678
 
670
679
 
671
680
  @dataclass
@@ -749,6 +758,8 @@ class StreamWorker:
749
758
  batch_size: int = 10,
750
759
  block_ms: int = 30000,
751
760
  message_timeout: float = 300.0,
761
+ redeliver_pending_on_reconnect: bool = True,
762
+ claim_min_idle_ms: int = 0,
752
763
  ):
753
764
  self._parent_client = parent_client
754
765
  self._handler = handler
@@ -761,6 +772,8 @@ class StreamWorker:
761
772
  batch_size=batch_size,
762
773
  block_ms=block_ms,
763
774
  message_timeout=message_timeout,
775
+ redeliver_pending_on_reconnect=redeliver_pending_on_reconnect,
776
+ claim_min_idle_ms=claim_min_idle_ms,
764
777
  )
765
778
 
766
779
  self._client: FloClient | None = None
@@ -805,18 +818,19 @@ class StreamWorker:
805
818
  await self._client.connect()
806
819
 
807
820
  try:
808
- # Join consumer group
809
- await self._client.stream.group_join(
810
- self.config.stream,
811
- self.config.group,
812
- self.config.consumer,
813
- )
814
- logger.info(f"Joined consumer group {self.config.group} on stream {self.config.stream}")
815
-
821
+ # Semaphore and running state must be set up before the join: a
822
+ # connection error during the join triggers _handle_reconnect ->
823
+ # _drain_pending, which acquires the semaphore.
816
824
  self._semaphore = asyncio.Semaphore(self.config.concurrency)
817
825
  self._running = True
818
826
  self._stop_event.clear()
819
827
 
828
+ # Join consumer group. A connection error here (a stalled or dropped
829
+ # connection at startup) is retried via reconnect rather than
830
+ # killing the worker — mirroring the poll loop and ack retry.
831
+ await self._join_group()
832
+ logger.info(f"Joined consumer group {self.config.group} on stream {self.config.stream}")
833
+
820
834
  await self._poll_loop()
821
835
 
822
836
  finally:
@@ -900,8 +914,41 @@ class StreamWorker:
900
914
  logger.error(f"Stream read error: {e}, retrying...")
901
915
  await asyncio.sleep(1)
902
916
 
917
+ async def _join_group(self) -> None:
918
+ """Join the consumer group, retrying on connection errors.
919
+
920
+ A connection error (a stalled or dropped connection at startup) triggers
921
+ a reconnect-and-retry instead of failing the worker. Non-connection
922
+ errors (e.g. an invalid request) remain fatal and are raised.
923
+ """
924
+ assert self._client is not None
925
+ while True:
926
+ if self._stop_event.is_set():
927
+ return
928
+ try:
929
+ await self._client.stream.group_join(
930
+ self.config.stream,
931
+ self.config.group,
932
+ self.config.consumer,
933
+ )
934
+ return
935
+ except Exception as e:
936
+ if not is_connection_error(e):
937
+ raise
938
+ logger.warning(f"Connection lost during group join: {e}, reconnecting...")
939
+ with contextlib.suppress(Exception):
940
+ await self._handle_reconnect()
941
+ await asyncio.sleep(1)
942
+
903
943
  async def _handle_reconnect(self) -> None:
904
- """Reconnect and re-join the consumer group."""
944
+ """Reconnect, re-join the consumer group, and drain pending entries.
945
+
946
+ After re-joining, this consumer's pending (delivered-but-unacked)
947
+ entries are re-processed via a group_claim cursor loop before normal
948
+ reads resume — group_read only returns records past last_delivered_id,
949
+ so without this an in-flight record at crash time is never re-surfaced.
950
+ See ``redeliver_pending_on_reconnect``.
951
+ """
905
952
  assert self._client is not None
906
953
  await self._client.reconnect()
907
954
  await self._client.stream.group_join(
@@ -909,6 +956,55 @@ class StreamWorker:
909
956
  self.config.group,
910
957
  self.config.consumer,
911
958
  )
959
+ if self.config.redeliver_pending_on_reconnect:
960
+ await self._drain_pending()
961
+
962
+ async def _drain_pending(self) -> None:
963
+ """Re-process this consumer's pending entries via a group_claim loop.
964
+
965
+ Records flow through the same _process_record path (and concurrency
966
+ gate) as live reads, so a handler that succeeds acks and clears the PEL
967
+ entry. Bounded per stream by a max page count so a pathological backlog
968
+ can't wedge the reconnect indefinitely; leftovers are reclaimed on the
969
+ next reconnect or by group_read once acked.
970
+ """
971
+ if self._semaphore is None or self._client is None:
972
+ # start() has not initialized the concurrency gate yet.
973
+ return
974
+
975
+ max_pages = 1000 # safety cap: max_pages * batch_size entries
976
+ cursor = StreamID() # MIN — scan from the start of the PEL
977
+ pages = 0
978
+ while True:
979
+ if self._stop_event.is_set():
980
+ return
981
+ try:
982
+ result = await self._client.stream.group_claim(
983
+ self.config.stream,
984
+ self.config.group,
985
+ self.config.consumer,
986
+ self.config.claim_min_idle_ms,
987
+ cursor,
988
+ self.config.batch_size,
989
+ )
990
+ except Exception as e:
991
+ # Don't fail the reconnect on a drain error; normal reads resume.
992
+ logger.warning(f"Pending drain claim failed: {e}")
993
+ return
994
+
995
+ for record in result.records:
996
+ await self._semaphore.acquire() # same gate as live reads
997
+ if self._stop_event.is_set():
998
+ self._semaphore.release()
999
+ return
1000
+ task = asyncio.create_task(self._process_record(record))
1001
+ self._tasks.add(task)
1002
+ task.add_done_callback(self._tasks.discard)
1003
+
1004
+ pages += 1
1005
+ if result.done or not result.records or pages >= max_pages:
1006
+ return
1007
+ cursor = result.next_cursor
912
1008
 
913
1009
  async def _ack_with_retry(
914
1010
  self, record_ids: list[StreamID], options: StreamGroupAckOptions
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: flo-python
3
- Version: 0.1.0.dev4
3
+ Version: 0.1.1
4
4
  Summary: Python SDK for the Flo distributed systems platform
5
5
  Project-URL: Homepage, https://github.com/floruntime/flo-python
6
6
  Project-URL: Documentation, https://github.com/floruntime/flo-python#readme
@@ -35,7 +35,7 @@ Python SDK for the [Flo](https://github.com/floruntime) distributed systems plat
35
35
  ## Installation
36
36
 
37
37
  ```bash
38
- pip install flo
38
+ pip install flo-python
39
39
  ```
40
40
 
41
41
  ## Quick Start