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/__init__.py +31 -1
- flo/client.py +37 -3
- flo/kv.py +360 -35
- flo/kv_txn.py +340 -0
- flo/streams.py +116 -5
- flo/types.py +169 -6
- flo/wire.py +179 -5
- flo/worker.py +136 -12
- {flo_python-0.1.0.dev3.dist-info → flo_python-0.1.1.dist-info}/METADATA +3 -3
- flo_python-0.1.1.dist-info/RECORD +17 -0
- {flo_python-0.1.0.dev3.dist-info → flo_python-0.1.1.dist-info}/WHEEL +1 -1
- flo_python-0.1.0.dev3.dist-info/RECORD +0 -16
- {flo_python-0.1.0.dev3.dist-info → flo_python-0.1.1.dist-info}/licenses/LICENSE +0 -0
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.
|
|
18
|
-
|
|
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
|
-
#
|
|
794
|
-
|
|
795
|
-
|
|
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
|
|
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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: flo-python
|
|
3
|
-
Version: 0.1.
|
|
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,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,,
|
|
File without changes
|