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/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
- payload,
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
- Records are distributed among consumers in the group. Each record
300
- is delivered to only one consumer. Unacknowledged records will be
301
- redelivered.
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,