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/kv_txn.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Flo KV Per-Shard Transactions
|
|
2
|
+
|
|
3
|
+
Transactions are pinned to a single partition (chosen by the routing key
|
|
4
|
+
passed to ``begin``). Every key written or read inside the transaction must
|
|
5
|
+
hash to the same partition; otherwise the server returns a
|
|
6
|
+
"kv_txn_cross_shard" error.
|
|
7
|
+
|
|
8
|
+
Caps (server-enforced):
|
|
9
|
+
* 256 ops per transaction
|
|
10
|
+
* 1 MiB total payload across buffered writes
|
|
11
|
+
|
|
12
|
+
The following operations are NOT supported inside a transaction and raise
|
|
13
|
+
:class:`TxnUnsupportedOpError` without a server round-trip: ``scan``,
|
|
14
|
+
``mget``, ``json_get``, ``json_set``, ``json_del``, ``history``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import struct
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
from .types import (
|
|
24
|
+
DeleteOptions,
|
|
25
|
+
GetResult,
|
|
26
|
+
KVCommitResult,
|
|
27
|
+
OpCode,
|
|
28
|
+
OptionTag,
|
|
29
|
+
PutOptions,
|
|
30
|
+
PutResult,
|
|
31
|
+
)
|
|
32
|
+
from .wire import OptionsBuilder
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from .client import FloClient
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TxnUnsupportedOpError(Exception):
|
|
39
|
+
"""Raised when an operation is not allowed inside a KV transaction."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TxnFinishedError(Exception):
|
|
43
|
+
"""Raised when an operation is attempted on a closed transaction."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Transaction:
|
|
47
|
+
"""Per-shard KV transaction handle.
|
|
48
|
+
|
|
49
|
+
Operations are buffered on the server's pinned shard until :meth:`commit`
|
|
50
|
+
or :meth:`rollback` is called. Use as an async context manager for
|
|
51
|
+
automatic rollback on exception::
|
|
52
|
+
|
|
53
|
+
async with await client.kv.begin("user:123") as txn:
|
|
54
|
+
await txn.put("user:123:name", b"Jane")
|
|
55
|
+
await txn.incr("user:123:visits", 1)
|
|
56
|
+
result = await txn.commit()
|
|
57
|
+
print(result.commit_index)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
client: FloClient,
|
|
63
|
+
namespace: str,
|
|
64
|
+
routing_key: str,
|
|
65
|
+
txn_id: int,
|
|
66
|
+
pinned_hash: int,
|
|
67
|
+
):
|
|
68
|
+
self._client = client
|
|
69
|
+
self._namespace = namespace
|
|
70
|
+
self._routing_key = routing_key
|
|
71
|
+
self._txn_id = txn_id
|
|
72
|
+
self._pinned_hash = pinned_hash
|
|
73
|
+
self._done = False
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def id(self) -> int:
|
|
77
|
+
"""The server-assigned transaction id."""
|
|
78
|
+
return self._txn_id
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def pinned_hash(self) -> int:
|
|
82
|
+
"""The partition hash this transaction is bound to."""
|
|
83
|
+
return self._pinned_hash
|
|
84
|
+
|
|
85
|
+
def _txn_options(self) -> OptionsBuilder:
|
|
86
|
+
b = OptionsBuilder()
|
|
87
|
+
if self._routing_key:
|
|
88
|
+
b.add_bytes(OptionTag.ROUTING_KEY, self._routing_key.encode("utf-8"))
|
|
89
|
+
b.add_u64(OptionTag.TXN_ID, self._txn_id)
|
|
90
|
+
return b
|
|
91
|
+
|
|
92
|
+
def _check_alive(self) -> None:
|
|
93
|
+
if self._done:
|
|
94
|
+
raise TxnFinishedError("transaction already committed or rolled back")
|
|
95
|
+
|
|
96
|
+
async def put(
|
|
97
|
+
self,
|
|
98
|
+
key: str | bytes,
|
|
99
|
+
value: bytes,
|
|
100
|
+
options: PutOptions | None = None,
|
|
101
|
+
) -> PutResult:
|
|
102
|
+
"""Buffer a put inside the transaction."""
|
|
103
|
+
self._check_alive()
|
|
104
|
+
opts = options or PutOptions()
|
|
105
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
106
|
+
builder = self._txn_options()
|
|
107
|
+
if opts.ttl_seconds is not None:
|
|
108
|
+
builder.add_u64(OptionTag.TTL_SECONDS, opts.ttl_seconds)
|
|
109
|
+
if opts.cas_version is not None:
|
|
110
|
+
builder.add_u64(OptionTag.CAS_VERSION, opts.cas_version)
|
|
111
|
+
if opts.if_not_exists:
|
|
112
|
+
builder.add_flag(OptionTag.IF_NOT_EXISTS)
|
|
113
|
+
if opts.if_exists:
|
|
114
|
+
builder.add_flag(OptionTag.IF_EXISTS)
|
|
115
|
+
response = await self._client._send_and_check(
|
|
116
|
+
OpCode.KV_PUT,
|
|
117
|
+
self._namespace,
|
|
118
|
+
key_bytes,
|
|
119
|
+
value,
|
|
120
|
+
builder.build(),
|
|
121
|
+
)
|
|
122
|
+
if not response.data or len(response.data) < 8:
|
|
123
|
+
return PutResult(version=0)
|
|
124
|
+
return PutResult(version=struct.unpack("<Q", response.data[:8])[0])
|
|
125
|
+
|
|
126
|
+
async def get(self, key: str | bytes) -> GetResult | None:
|
|
127
|
+
"""Read a key inside the transaction (sees buffered writes)."""
|
|
128
|
+
self._check_alive()
|
|
129
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
130
|
+
builder = self._txn_options()
|
|
131
|
+
response = await self._client._send_and_check(
|
|
132
|
+
OpCode.KV_GET,
|
|
133
|
+
self._namespace,
|
|
134
|
+
key_bytes,
|
|
135
|
+
b"",
|
|
136
|
+
builder.build(),
|
|
137
|
+
allow_not_found=True,
|
|
138
|
+
)
|
|
139
|
+
if response.is_not_found():
|
|
140
|
+
return None
|
|
141
|
+
if not response.data or len(response.data) < 8:
|
|
142
|
+
return GetResult(value=b"", version=0)
|
|
143
|
+
version = struct.unpack("<Q", response.data[:8])[0]
|
|
144
|
+
return GetResult(value=bytes(response.data[8:]), version=version)
|
|
145
|
+
|
|
146
|
+
async def delete(
|
|
147
|
+
self,
|
|
148
|
+
key: str | bytes,
|
|
149
|
+
options: DeleteOptions | None = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Buffer a delete inside the transaction."""
|
|
152
|
+
self._check_alive()
|
|
153
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
154
|
+
builder = self._txn_options()
|
|
155
|
+
await self._client._send_and_check(
|
|
156
|
+
OpCode.KV_DELETE,
|
|
157
|
+
self._namespace,
|
|
158
|
+
key_bytes,
|
|
159
|
+
b"",
|
|
160
|
+
builder.build(),
|
|
161
|
+
allow_not_found=True,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def incr(self, key: str | bytes, delta: int = 1) -> int:
|
|
165
|
+
"""Buffer an atomic counter increment inside the transaction."""
|
|
166
|
+
self._check_alive()
|
|
167
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
168
|
+
builder = self._txn_options()
|
|
169
|
+
value = struct.pack("<q", delta)
|
|
170
|
+
response = await self._client._send_and_check(
|
|
171
|
+
OpCode.KV_INCR,
|
|
172
|
+
self._namespace,
|
|
173
|
+
key_bytes,
|
|
174
|
+
value,
|
|
175
|
+
builder.build(),
|
|
176
|
+
)
|
|
177
|
+
if not response.data or len(response.data) < 8:
|
|
178
|
+
return 0
|
|
179
|
+
return int(struct.unpack("<q", response.data[:8])[0])
|
|
180
|
+
|
|
181
|
+
async def touch(self, key: str | bytes, ttl_seconds: int) -> None:
|
|
182
|
+
"""Update the TTL on an existing key inside the transaction."""
|
|
183
|
+
self._check_alive()
|
|
184
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
185
|
+
builder = self._txn_options()
|
|
186
|
+
await self._client._send_and_check(
|
|
187
|
+
OpCode.KV_TOUCH,
|
|
188
|
+
self._namespace,
|
|
189
|
+
key_bytes,
|
|
190
|
+
struct.pack("<Q", ttl_seconds),
|
|
191
|
+
builder.build(),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
async def persist(self, key: str | bytes) -> None:
|
|
195
|
+
"""Remove the TTL on a key inside the transaction."""
|
|
196
|
+
self._check_alive()
|
|
197
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
198
|
+
builder = self._txn_options()
|
|
199
|
+
await self._client._send_and_check(
|
|
200
|
+
OpCode.KV_PERSIST,
|
|
201
|
+
self._namespace,
|
|
202
|
+
key_bytes,
|
|
203
|
+
b"",
|
|
204
|
+
builder.build(),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
async def exists(self, key: str | bytes) -> bool:
|
|
208
|
+
"""Check key existence inside the transaction."""
|
|
209
|
+
self._check_alive()
|
|
210
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
211
|
+
builder = self._txn_options()
|
|
212
|
+
response = await self._client._send_and_check(
|
|
213
|
+
OpCode.KV_EXISTS,
|
|
214
|
+
self._namespace,
|
|
215
|
+
key_bytes,
|
|
216
|
+
b"",
|
|
217
|
+
builder.build(),
|
|
218
|
+
)
|
|
219
|
+
# Wire body: [version:u64 LE][1 byte 0/1]
|
|
220
|
+
if not response.data or len(response.data) < 9:
|
|
221
|
+
return False
|
|
222
|
+
return response.data[8] == 1
|
|
223
|
+
|
|
224
|
+
# ── Disallowed inside a transaction ───────────────────────────────
|
|
225
|
+
|
|
226
|
+
async def scan(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
|
|
227
|
+
raise TxnUnsupportedOpError("scan is not supported inside a KV transaction")
|
|
228
|
+
|
|
229
|
+
async def mget(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
|
|
230
|
+
raise TxnUnsupportedOpError("mget is not supported inside a KV transaction")
|
|
231
|
+
|
|
232
|
+
async def json_get(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
|
|
233
|
+
raise TxnUnsupportedOpError("json_get is not supported inside a KV transaction")
|
|
234
|
+
|
|
235
|
+
async def json_set(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
|
|
236
|
+
raise TxnUnsupportedOpError("json_set is not supported inside a KV transaction")
|
|
237
|
+
|
|
238
|
+
async def json_del(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
|
|
239
|
+
raise TxnUnsupportedOpError("json_del is not supported inside a KV transaction")
|
|
240
|
+
|
|
241
|
+
async def history(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
|
|
242
|
+
raise TxnUnsupportedOpError("history is not supported inside a KV transaction")
|
|
243
|
+
|
|
244
|
+
# ── Lifecycle ──────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
async def commit(self) -> KVCommitResult:
|
|
247
|
+
"""Atomically apply all buffered operations.
|
|
248
|
+
|
|
249
|
+
After commit returns the transaction is closed and further operations
|
|
250
|
+
raise :class:`TxnFinishedError`.
|
|
251
|
+
"""
|
|
252
|
+
self._check_alive()
|
|
253
|
+
self._done = True
|
|
254
|
+
builder = OptionsBuilder()
|
|
255
|
+
if self._routing_key:
|
|
256
|
+
builder.add_bytes(OptionTag.ROUTING_KEY, self._routing_key.encode("utf-8"))
|
|
257
|
+
builder.add_u64(OptionTag.TXN_ID, self._txn_id)
|
|
258
|
+
response = await self._client._send_and_check(
|
|
259
|
+
OpCode.KV_COMMIT_TXN,
|
|
260
|
+
self._namespace,
|
|
261
|
+
self._routing_key.encode("utf-8"),
|
|
262
|
+
b"",
|
|
263
|
+
builder.build(),
|
|
264
|
+
)
|
|
265
|
+
# Wire body: [variant:u8=1][commit_index:u64 LE][op_count:u16 LE]
|
|
266
|
+
if not response.data or len(response.data) < 11:
|
|
267
|
+
raise RuntimeError(
|
|
268
|
+
f"flo: short KV commit reply ({len(response.data) if response.data else 0} bytes)"
|
|
269
|
+
)
|
|
270
|
+
return KVCommitResult(
|
|
271
|
+
commit_index=struct.unpack("<Q", response.data[1:9])[0],
|
|
272
|
+
op_count=struct.unpack("<H", response.data[9:11])[0],
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
async def rollback(self) -> None:
|
|
276
|
+
"""Discard the buffered operations without committing.
|
|
277
|
+
|
|
278
|
+
Idempotent: calling rollback after commit (or vice versa) is a no-op.
|
|
279
|
+
"""
|
|
280
|
+
if self._done:
|
|
281
|
+
return
|
|
282
|
+
self._done = True
|
|
283
|
+
builder = OptionsBuilder()
|
|
284
|
+
if self._routing_key:
|
|
285
|
+
builder.add_bytes(OptionTag.ROUTING_KEY, self._routing_key.encode("utf-8"))
|
|
286
|
+
builder.add_u64(OptionTag.TXN_ID, self._txn_id)
|
|
287
|
+
await self._client._send_and_check(
|
|
288
|
+
OpCode.KV_ROLLBACK_TXN,
|
|
289
|
+
self._namespace,
|
|
290
|
+
self._routing_key.encode("utf-8"),
|
|
291
|
+
b"",
|
|
292
|
+
builder.build(),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
async def __aenter__(self) -> Transaction:
|
|
296
|
+
return self
|
|
297
|
+
|
|
298
|
+
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
299
|
+
if not self._done:
|
|
300
|
+
# Best-effort cleanup; surface the original exception.
|
|
301
|
+
with contextlib.suppress(Exception):
|
|
302
|
+
await self.rollback()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
async def begin(
|
|
306
|
+
client: FloClient,
|
|
307
|
+
namespace: str,
|
|
308
|
+
routing_key: str,
|
|
309
|
+
) -> Transaction:
|
|
310
|
+
"""Open a new per-shard KV transaction."""
|
|
311
|
+
builder = OptionsBuilder()
|
|
312
|
+
if routing_key:
|
|
313
|
+
builder.add_bytes(OptionTag.ROUTING_KEY, routing_key.encode("utf-8"))
|
|
314
|
+
response = await client._send_and_check(
|
|
315
|
+
OpCode.KV_BEGIN_TXN,
|
|
316
|
+
namespace,
|
|
317
|
+
routing_key.encode("utf-8"),
|
|
318
|
+
b"",
|
|
319
|
+
builder.build(),
|
|
320
|
+
)
|
|
321
|
+
# Wire body: [variant:u8=0][txn_id:u64 LE][pinned_hash:u64 LE]
|
|
322
|
+
if not response.data or len(response.data) < 17:
|
|
323
|
+
raise RuntimeError(
|
|
324
|
+
f"flo: short KV begin reply ({len(response.data) if response.data else 0} bytes)"
|
|
325
|
+
)
|
|
326
|
+
return Transaction(
|
|
327
|
+
client=client,
|
|
328
|
+
namespace=namespace,
|
|
329
|
+
routing_key=routing_key,
|
|
330
|
+
txn_id=struct.unpack("<Q", response.data[1:9])[0],
|
|
331
|
+
pinned_hash=struct.unpack("<Q", response.data[9:17])[0],
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
__all__ = [
|
|
336
|
+
"Transaction",
|
|
337
|
+
"TxnUnsupportedOpError",
|
|
338
|
+
"TxnFinishedError",
|
|
339
|
+
"begin",
|
|
340
|
+
]
|
flo/streams.py
CHANGED
|
@@ -9,8 +9,10 @@ from typing import TYPE_CHECKING
|
|
|
9
9
|
from .types import (
|
|
10
10
|
OpCode,
|
|
11
11
|
OptionTag,
|
|
12
|
+
PendingEntry,
|
|
12
13
|
StreamAppendOptions,
|
|
13
14
|
StreamAppendResult,
|
|
15
|
+
StreamClaimResult,
|
|
14
16
|
StreamGroupAckOptions,
|
|
15
17
|
StreamGroupJoinOptions,
|
|
16
18
|
StreamGroupNackOptions,
|
|
@@ -24,13 +26,21 @@ from .types import (
|
|
|
24
26
|
)
|
|
25
27
|
from .wire import (
|
|
26
28
|
OptionsBuilder,
|
|
29
|
+
build_stream_batch_value,
|
|
30
|
+
parse_pending_entries,
|
|
27
31
|
parse_stream_append_response,
|
|
28
32
|
parse_stream_info_response,
|
|
29
33
|
parse_stream_read_response,
|
|
30
34
|
serialize_group_ack_value,
|
|
35
|
+
serialize_group_claim_value,
|
|
36
|
+
serialize_group_pending_value,
|
|
31
37
|
serialize_group_value,
|
|
32
38
|
)
|
|
33
39
|
|
|
40
|
+
# u64 max sentinel: group_claim returns StreamID.MAX when the PEL is fully
|
|
41
|
+
# scanned (mirrors Go's math.MaxUint64 check).
|
|
42
|
+
_U64_MAX = 0xFFFFFFFFFFFFFFFF
|
|
43
|
+
|
|
34
44
|
if TYPE_CHECKING:
|
|
35
45
|
from .client import FloClient
|
|
36
46
|
|
|
@@ -63,12 +73,13 @@ class StreamOperations:
|
|
|
63
73
|
"""
|
|
64
74
|
opts = options or StreamAppendOptions()
|
|
65
75
|
namespace = self._client.get_namespace(opts.namespace)
|
|
76
|
+
value = build_stream_batch_value(payload, opts.headers)
|
|
66
77
|
|
|
67
78
|
response = await self._client._send_and_check(
|
|
68
79
|
OpCode.STREAM_APPEND,
|
|
69
80
|
namespace,
|
|
70
81
|
stream.encode("utf-8"),
|
|
71
|
-
|
|
82
|
+
value,
|
|
72
83
|
allow_not_found=True,
|
|
73
84
|
)
|
|
74
85
|
|
|
@@ -294,11 +305,18 @@ class StreamOperations:
|
|
|
294
305
|
consumer: str,
|
|
295
306
|
options: StreamGroupReadOptions | None = None,
|
|
296
307
|
) -> StreamReadResult:
|
|
297
|
-
"""Read records from a consumer group.
|
|
308
|
+
"""Read new records from a consumer group, advancing last_delivered_id.
|
|
309
|
+
|
|
310
|
+
Records are distributed among consumers in the group; each record is
|
|
311
|
+
delivered to only one consumer and added to that consumer's Pending
|
|
312
|
+
Entry List (PEL) until acked. Unacknowledged records are redelivered.
|
|
298
313
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
314
|
+
Crash recovery: ``group_read`` alone is NOT sufficient. It only returns
|
|
315
|
+
records past ``last_delivered_id``, so a record delivered-but-unacked at
|
|
316
|
+
crash time is never re-surfaced by a later ``group_read``. To re-process
|
|
317
|
+
in-flight work after a reconnect, drain the PEL with :meth:`group_claim`
|
|
318
|
+
(StreamWorker does this automatically — see
|
|
319
|
+
``redeliver_pending_on_reconnect``).
|
|
302
320
|
|
|
303
321
|
Args:
|
|
304
322
|
stream: Stream name.
|
|
@@ -340,6 +358,99 @@ class StreamOperations:
|
|
|
340
358
|
|
|
341
359
|
return parse_stream_read_response(response.data)
|
|
342
360
|
|
|
361
|
+
async def group_pending(
|
|
362
|
+
self,
|
|
363
|
+
stream: str,
|
|
364
|
+
group: str,
|
|
365
|
+
consumer: str = "",
|
|
366
|
+
options: StreamGroupReadOptions | None = None,
|
|
367
|
+
) -> list[PendingEntry]:
|
|
368
|
+
"""List a consumer group's pending (delivered-but-unacked) entries.
|
|
369
|
+
|
|
370
|
+
If ``consumer`` is non-empty, only that consumer's entries are returned;
|
|
371
|
+
otherwise the whole group's PEL is returned. (FLO-102)
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
A list of :class:`PendingEntry`.
|
|
375
|
+
"""
|
|
376
|
+
opts = options or StreamGroupReadOptions()
|
|
377
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
378
|
+
|
|
379
|
+
value = serialize_group_pending_value(group, consumer)
|
|
380
|
+
|
|
381
|
+
response = await self._client._send_and_check(
|
|
382
|
+
OpCode.STREAM_GROUP_PENDING,
|
|
383
|
+
namespace,
|
|
384
|
+
stream.encode("utf-8"),
|
|
385
|
+
value,
|
|
386
|
+
allow_not_found=True,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
return parse_pending_entries(response.data)
|
|
390
|
+
|
|
391
|
+
async def group_claim(
|
|
392
|
+
self,
|
|
393
|
+
stream: str,
|
|
394
|
+
group: str,
|
|
395
|
+
consumer: str,
|
|
396
|
+
min_idle_ms: int,
|
|
397
|
+
start_id: StreamID,
|
|
398
|
+
count: int,
|
|
399
|
+
options: StreamGroupReadOptions | None = None,
|
|
400
|
+
) -> StreamClaimResult:
|
|
401
|
+
"""Claim a page of a consumer group's pending entries for ``consumer``.
|
|
402
|
+
|
|
403
|
+
Scans the PEL in StreamID order from ``start_id`` and takes up to
|
|
404
|
+
``count`` entries idle for at least ``min_idle_ms``. Returns the claimed
|
|
405
|
+
records (payload + headers) plus a cursor for the next page. (FLO-102)
|
|
406
|
+
|
|
407
|
+
* Drain own pending (reconnect): ``min_idle_ms=0``, ``start_id=StreamID()``.
|
|
408
|
+
* Steal from idle consumers (rebalance): ``min_idle_ms > 0``.
|
|
409
|
+
|
|
410
|
+
Loop until ``result.done`` to fully drain::
|
|
411
|
+
|
|
412
|
+
cursor = StreamID()
|
|
413
|
+
while True:
|
|
414
|
+
r = await client.stream.group_claim(
|
|
415
|
+
stream, group, consumer, 0, cursor, 100)
|
|
416
|
+
for rec in r.records:
|
|
417
|
+
process(rec)
|
|
418
|
+
if r.done or not r.records:
|
|
419
|
+
break
|
|
420
|
+
cursor = r.next_cursor
|
|
421
|
+
"""
|
|
422
|
+
opts = options or StreamGroupReadOptions()
|
|
423
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
424
|
+
|
|
425
|
+
value = serialize_group_claim_value(group, consumer, min_idle_ms, start_id, count)
|
|
426
|
+
|
|
427
|
+
response = await self._client._send_and_check(
|
|
428
|
+
OpCode.STREAM_GROUP_CLAIM,
|
|
429
|
+
namespace,
|
|
430
|
+
stream.encode("utf-8"),
|
|
431
|
+
value,
|
|
432
|
+
allow_not_found=True,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# Response = <records blob> + [next_ts:u64][next_seq:u64] trailer.
|
|
436
|
+
data = response.data or b""
|
|
437
|
+
if len(data) < 16:
|
|
438
|
+
return StreamClaimResult(records=[], next_cursor=StreamID(), done=True)
|
|
439
|
+
|
|
440
|
+
cursor_off = len(data) - 16
|
|
441
|
+
next_ts = int.from_bytes(data[cursor_off : cursor_off + 8], "little")
|
|
442
|
+
next_seq = int.from_bytes(data[cursor_off + 8 : cursor_off + 16], "little")
|
|
443
|
+
|
|
444
|
+
read = parse_stream_read_response(data[:cursor_off])
|
|
445
|
+
|
|
446
|
+
# StreamID.MAX (max, max) is the "fully scanned" sentinel.
|
|
447
|
+
done = next_ts == _U64_MAX and next_seq == _U64_MAX
|
|
448
|
+
return StreamClaimResult(
|
|
449
|
+
records=read.records,
|
|
450
|
+
next_cursor=StreamID(timestamp_ms=next_ts, sequence=next_seq),
|
|
451
|
+
done=done,
|
|
452
|
+
)
|
|
453
|
+
|
|
343
454
|
async def group_ack(
|
|
344
455
|
self,
|
|
345
456
|
stream: str,
|