flo-python 0.1.0.dev3__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/worker.py CHANGED
@@ -3,7 +3,8 @@
3
3
  Provides ActionWorker for executing actions and StreamWorker for
4
4
  processing stream records via consumer groups.
5
5
 
6
- Example:
6
+ Example::
7
+
7
8
  from flo import FloClient, ActionContext
8
9
 
9
10
  async def process_order(ctx: ActionContext) -> bytes:
@@ -14,8 +15,9 @@ Example:
14
15
  async def main():
15
16
  async with FloClient("localhost:3000", namespace="myapp") as client:
16
17
  worker = client.new_action_worker(concurrency=5)
17
- worker.action("process-order")(process_order)
18
- await worker.start()
18
+ worker.register_action("process-order", process_order)
19
+ async with worker:
20
+ await worker.start()
19
21
  """
20
22
 
21
23
  import asyncio
@@ -626,6 +628,19 @@ class ActionWorker:
626
628
  if self._client:
627
629
  await self._client.close()
628
630
 
631
+ async def __aenter__(self) -> "ActionWorker":
632
+ """Async context manager entry."""
633
+ return self
634
+
635
+ async def __aexit__(
636
+ self,
637
+ exc_type: type[BaseException] | None,
638
+ exc_val: BaseException | None,
639
+ exc_tb: object,
640
+ ) -> None:
641
+ """Async context manager exit — closes the worker."""
642
+ await self.close()
643
+
629
644
 
630
645
  # =============================================================================
631
646
  # Stream Worker
@@ -651,6 +666,15 @@ class StreamWorkerOptions:
651
666
  batch_size: int = 10
652
667
  block_ms: int = 30000
653
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
654
678
 
655
679
 
656
680
  @dataclass
@@ -734,6 +758,8 @@ class StreamWorker:
734
758
  batch_size: int = 10,
735
759
  block_ms: int = 30000,
736
760
  message_timeout: float = 300.0,
761
+ redeliver_pending_on_reconnect: bool = True,
762
+ claim_min_idle_ms: int = 0,
737
763
  ):
738
764
  self._parent_client = parent_client
739
765
  self._handler = handler
@@ -746,6 +772,8 @@ class StreamWorker:
746
772
  batch_size=batch_size,
747
773
  block_ms=block_ms,
748
774
  message_timeout=message_timeout,
775
+ redeliver_pending_on_reconnect=redeliver_pending_on_reconnect,
776
+ claim_min_idle_ms=claim_min_idle_ms,
749
777
  )
750
778
 
751
779
  self._client: FloClient | None = None
@@ -790,18 +818,19 @@ class StreamWorker:
790
818
  await self._client.connect()
791
819
 
792
820
  try:
793
- # Join consumer group
794
- await self._client.stream.group_join(
795
- self.config.stream,
796
- self.config.group,
797
- self.config.consumer,
798
- )
799
- logger.info(f"Joined consumer group {self.config.group} on stream {self.config.stream}")
800
-
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.
801
824
  self._semaphore = asyncio.Semaphore(self.config.concurrency)
802
825
  self._running = True
803
826
  self._stop_event.clear()
804
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
+
805
834
  await self._poll_loop()
806
835
 
807
836
  finally:
@@ -885,8 +914,41 @@ class StreamWorker:
885
914
  logger.error(f"Stream read error: {e}, retrying...")
886
915
  await asyncio.sleep(1)
887
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
+
888
943
  async def _handle_reconnect(self) -> None:
889
- """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
+ """
890
952
  assert self._client is not None
891
953
  await self._client.reconnect()
892
954
  await self._client.stream.group_join(
@@ -894,6 +956,55 @@ class StreamWorker:
894
956
  self.config.group,
895
957
  self.config.consumer,
896
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
897
1008
 
898
1009
  async def _ack_with_retry(
899
1010
  self, record_ids: list[StreamID], options: StreamGroupAckOptions
@@ -977,3 +1088,16 @@ class StreamWorker:
977
1088
  self.stop()
978
1089
  if self._client:
979
1090
  await self._client.close()
1091
+
1092
+ async def __aenter__(self) -> "StreamWorker":
1093
+ """Async context manager entry."""
1094
+ return self
1095
+
1096
+ async def __aexit__(
1097
+ self,
1098
+ exc_type: type[BaseException] | None,
1099
+ exc_val: BaseException | None,
1100
+ exc_tb: object,
1101
+ ) -> None:
1102
+ """Async context manager exit — closes the stream worker."""
1103
+ await self.close()
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: flo-python
3
- Version: 0.1.0.dev3
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
@@ -0,0 +1,17 @@
1
+ flo/__init__.py,sha256=utKHOqm_iuyYkM7v8PTYynnBe2FocVd5dTdse3fdD2Q,7694
2
+ flo/actions.py,sha256=b4cd5AAe6TRAU-_UZmenbek0h--T-C_PiiDFCHyYDTs,12258
3
+ flo/client.py,sha256=-r4EDWYxJbcEKDWHXlMKtkJukUD7UNuI89Q0FiUTczA,16753
4
+ flo/exceptions.py,sha256=-3HUWvRGLe_4mSWxwZXk8CMpcfrRjFv0rRkTzu4kqP4,5868
5
+ flo/kv.py,sha256=eR9jAQrWdzvllZes4TaGvEMzvzxKJlRoe4jG89m6x8E,19429
6
+ flo/kv_txn.py,sha256=aV9LuLkyc863OkDOk4IhuI3mfpdwShzOLx_hCR7VZgs,11906
7
+ flo/processing.py,sha256=_jOQZiSgyArOPOdnoARNy9x6QgzcYvt1R0wa0vLQfoc,10530
8
+ flo/queue.py,sha256=Nn6CkgUkbXgUuH9BOCWlRkjxTZLKRs0_iMyRmwkYK6k,10888
9
+ flo/streams.py,sha256=-z5D_ZpeQbgSR1IbDFIGLqhB9TbKNISF2uQN2Sc0c-w,17168
10
+ flo/types.py,sha256=10bqL_gm5e1-UgGeIXONLeNmcrxiGCBqOm0OUhcW4n0,31804
11
+ flo/wire.py,sha256=grPcwuLUf2YYWNqBC_-kFj1yx9JinsJhuUerNDcwbEg,35803
12
+ flo/worker.py,sha256=m53FWwnhGEP_iTNgwXhEJLAYvL7qoYLAJxFHr0MFSLs,40035
13
+ flo/workflows.py,sha256=8Z5mhq2UOAnjV_Ef261mcWlj4Z5yGCwrFbxjey0ICoU,15050
14
+ flo_python-0.1.1.dist-info/METADATA,sha256=DUzsljdTbBN7h1n6XRJlzlx4ZLEvmANaM0m1lb5WEio,14640
15
+ flo_python-0.1.1.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
16
+ flo_python-0.1.1.dist-info/licenses/LICENSE,sha256=mADRSuVhPxFNpZiy7zO0gBjGrNO24OtWO9VCX6wmCVU,1065
17
+ flo_python-0.1.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.32.3
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,16 +0,0 @@
1
- flo/__init__.py,sha256=7nQrfC6ge__E4scjMx5x-VwAiAKhsiRL-JEjC7cf0ZA,7039
2
- flo/actions.py,sha256=b4cd5AAe6TRAU-_UZmenbek0h--T-C_PiiDFCHyYDTs,12258
3
- flo/client.py,sha256=a6RpbUZD1FdInmuoZJVdwocC-vkG5efcwqevIX2_NEU,15018
4
- flo/exceptions.py,sha256=-3HUWvRGLe_4mSWxwZXk8CMpcfrRjFv0rRkTzu4kqP4,5868
5
- flo/kv.py,sha256=2PgvoZylU8Clsx8yaYdTLzdCZmD8nCZgtrmJHhAAZlM,7344
6
- flo/processing.py,sha256=_jOQZiSgyArOPOdnoARNy9x6QgzcYvt1R0wa0vLQfoc,10530
7
- flo/queue.py,sha256=Nn6CkgUkbXgUuH9BOCWlRkjxTZLKRs0_iMyRmwkYK6k,10888
8
- flo/streams.py,sha256=G_27-_cq6hru712olwImd4Yxuo8x4l8XuKljUk0Y1JA,12965
9
- flo/types.py,sha256=g8yikYiiYxsjkUlSCZxKpLMdBv7jg-cWAGO-cnEnaEo,27808
10
- flo/wire.py,sha256=TLCMXeA8VnUTzyyORM7G3zrsSZhyZymSGxU9jhCxmmI,29373
11
- flo/worker.py,sha256=AUQX5Hd6-pqm5ouCoJLUiGhRbfE6hzrACvaY0OyWNhw,34604
12
- flo/workflows.py,sha256=8Z5mhq2UOAnjV_Ef261mcWlj4Z5yGCwrFbxjey0ICoU,15050
13
- flo_python-0.1.0.dev3.dist-info/METADATA,sha256=LjTltaQ-avls3GgUlmp-ysA43IrVpx6uIwZZoVZ907E,14638
14
- flo_python-0.1.0.dev3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
- flo_python-0.1.0.dev3.dist-info/licenses/LICENSE,sha256=mADRSuVhPxFNpZiy7zO0gBjGrNO24OtWO9VCX6wmCVU,1065
16
- flo_python-0.1.0.dev3.dist-info/RECORD,,