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/__init__.py +31 -1
- flo/client.py +33 -1
- 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 +105 -9
- {flo_python-0.1.0.dev4.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.dev4.dist-info → flo_python-0.1.1.dist-info}/WHEEL +1 -1
- flo_python-0.1.0.dev4.dist-info/RECORD +0 -16
- {flo_python-0.1.0.dev4.dist-info → flo_python-0.1.1.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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=
|
|
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]?
|
|
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]
|
|
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
|
-
#
|
|
809
|
-
|
|
810
|
-
|
|
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
|
|
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.
|
|
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
|