flo-python 0.1.0.dev4__py3-none-any.whl → 0.1.2__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,237 @@
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 op inside the transaction is routed by it.
5
+
6
+ Caps (server-enforced):
7
+ * 256 ops per transaction
8
+ * 1 MiB total payload across buffered writes
9
+
10
+ The following operations are NOT supported inside a transaction and raise
11
+ :class:`TxnUnsupportedOpError` without a server round-trip: ``scan``,
12
+ ``mget``, ``json_get``, ``json_set``, ``json_del``, ``history``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import dataclasses
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from .exceptions import ValidationError
22
+ from .kv import kv_delete, kv_exists, kv_expire, kv_get, kv_incr, kv_set
23
+ from .kv_body import (
24
+ decode_empty_answer,
25
+ decode_txn_begin_answer,
26
+ decode_txn_commit_answer,
27
+ encode_txn_body,
28
+ )
29
+ from .types import (
30
+ DeleteOptions,
31
+ GetOptions,
32
+ GetResult,
33
+ KVCommitResult,
34
+ KVExistsOptions,
35
+ KVExpireOptions,
36
+ KVIncrOptions,
37
+ OpCode,
38
+ PutOptions,
39
+ PutResult,
40
+ )
41
+
42
+ if TYPE_CHECKING:
43
+ from .client import FloClient
44
+
45
+
46
+ class TxnUnsupportedOpError(Exception):
47
+ """Raised when an operation is not allowed inside a KV transaction."""
48
+
49
+
50
+ class TxnFinishedError(Exception):
51
+ """Raised when an operation is attempted on a closed transaction."""
52
+
53
+
54
+ class Transaction:
55
+ """Per-shard KV transaction handle.
56
+
57
+ Operations are buffered on the server's pinned shard until :meth:`commit`
58
+ or :meth:`rollback` is called. Use as an async context manager for
59
+ automatic rollback on exception::
60
+
61
+ async with await client.kv.begin("user:123") as txn:
62
+ await txn.put("user:123:name", b"Jane")
63
+ await txn.incr("user:123:visits", 1)
64
+ result = await txn.commit()
65
+ print(result.commit_index)
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ client: FloClient,
71
+ namespace: str,
72
+ routing_key: str,
73
+ txn_id: int,
74
+ pinned_hash: int,
75
+ ):
76
+ self._client = client
77
+ self._namespace = namespace
78
+ self._routing_key = routing_key
79
+ self._txn_id = txn_id
80
+ self._pinned_hash = pinned_hash
81
+ self._done = False
82
+
83
+ @property
84
+ def id(self) -> int:
85
+ """The server-assigned transaction id."""
86
+ return self._txn_id
87
+
88
+ @property
89
+ def pinned_hash(self) -> int:
90
+ """The partition hash this transaction is bound to."""
91
+ return self._pinned_hash
92
+
93
+ def _check_alive(self) -> None:
94
+ if self._done:
95
+ raise TxnFinishedError("transaction already committed or rolled back")
96
+
97
+ async def put(
98
+ self,
99
+ key: str | bytes,
100
+ value: bytes,
101
+ options: PutOptions | None = None,
102
+ ) -> PutResult:
103
+ """Buffer a put inside the transaction; it has no version until commit (0)."""
104
+ self._check_alive()
105
+ opts = dataclasses.replace(options or PutOptions(), routing_key=self._routing_key)
106
+ return await kv_set(self._client, self._namespace, key, value, opts, self._txn_id)
107
+
108
+ async def get(self, key: str | bytes) -> GetResult | None:
109
+ """Read a key inside the transaction (sees buffered writes)."""
110
+ self._check_alive()
111
+ return await kv_get(
112
+ self._client,
113
+ self._namespace,
114
+ key,
115
+ GetOptions(routing_key=self._routing_key),
116
+ self._txn_id,
117
+ )
118
+
119
+ async def delete(
120
+ self,
121
+ key: str | bytes,
122
+ options: DeleteOptions | None = None,
123
+ ) -> None:
124
+ """Buffer a delete inside the transaction; whether the key existed is unknown."""
125
+ self._check_alive()
126
+ opts = dataclasses.replace(options or DeleteOptions(), routing_key=self._routing_key)
127
+ await kv_delete(self._client, self._namespace, key, opts, self._txn_id)
128
+
129
+ async def incr(self, key: str | bytes, delta: int = 1) -> None:
130
+ """Buffer an atomic counter increment; its value is unknown until commit."""
131
+ self._check_alive()
132
+ opts = KVIncrOptions(delta=delta, routing_key=self._routing_key)
133
+ await kv_incr(self._client, self._namespace, key, opts, self._txn_id)
134
+
135
+ async def expire(
136
+ self,
137
+ key: str | bytes,
138
+ ttl_ms: int,
139
+ options: KVExpireOptions | None = None,
140
+ ) -> None:
141
+ """Buffer setting the TTL on a key, in milliseconds; ``ttl_ms=0`` clears it."""
142
+ self._check_alive()
143
+ opts = dataclasses.replace(options or KVExpireOptions(), routing_key=self._routing_key)
144
+ await kv_expire(self._client, self._namespace, key, ttl_ms, opts, self._txn_id)
145
+
146
+ async def exists(self, key: str | bytes) -> bool:
147
+ """Check key existence inside the transaction (sees buffered writes)."""
148
+ self._check_alive()
149
+ opts = KVExistsOptions(routing_key=self._routing_key)
150
+ return await kv_exists(self._client, self._namespace, key, opts, self._txn_id)
151
+
152
+ # ── Disallowed inside a transaction ───────────────────────────────
153
+
154
+ async def scan(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
155
+ raise TxnUnsupportedOpError("scan is not supported inside a KV transaction")
156
+
157
+ async def mget(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
158
+ raise TxnUnsupportedOpError("mget is not supported inside a KV transaction")
159
+
160
+ async def json_get(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
161
+ raise TxnUnsupportedOpError("json_get is not supported inside a KV transaction")
162
+
163
+ async def json_set(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
164
+ raise TxnUnsupportedOpError("json_set is not supported inside a KV transaction")
165
+
166
+ async def json_del(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
167
+ raise TxnUnsupportedOpError("json_del is not supported inside a KV transaction")
168
+
169
+ async def history(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
170
+ raise TxnUnsupportedOpError("history is not supported inside a KV transaction")
171
+
172
+ # ── Lifecycle ──────────────────────────────────────────────────────
173
+
174
+ async def commit(self) -> KVCommitResult:
175
+ """Atomically apply all buffered operations.
176
+
177
+ After commit returns the transaction is closed and further operations
178
+ raise :class:`TxnFinishedError`.
179
+ """
180
+ self._check_alive()
181
+ self._done = True
182
+ response = await self._client._send_and_check(
183
+ OpCode.KV_COMMIT_TXN, self._namespace, b"", encode_txn_body(self._txn_id)
184
+ )
185
+ commit_index, op_count = decode_txn_commit_answer(response.data)
186
+ return KVCommitResult(commit_index=commit_index, op_count=op_count)
187
+
188
+ async def rollback(self) -> None:
189
+ """Discard the buffered operations without committing.
190
+
191
+ Idempotent: calling rollback after commit (or vice versa) is a no-op.
192
+ """
193
+ if self._done:
194
+ return
195
+ self._done = True
196
+ response = await self._client._send_and_check(
197
+ OpCode.KV_ROLLBACK_TXN, self._namespace, b"", encode_txn_body(self._txn_id)
198
+ )
199
+ decode_empty_answer("kv.txn_rollback", response.data)
200
+
201
+ async def __aenter__(self) -> Transaction:
202
+ return self
203
+
204
+ async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
205
+ if not self._done:
206
+ # Best-effort cleanup; surface the original exception.
207
+ with contextlib.suppress(Exception):
208
+ await self.rollback()
209
+
210
+
211
+ async def begin(
212
+ client: FloClient,
213
+ namespace: str,
214
+ routing_key: str,
215
+ ) -> Transaction:
216
+ """Open a new per-shard KV transaction: its routing key is the request's key."""
217
+ if not routing_key:
218
+ raise ValidationError("a transaction needs a routing key")
219
+ response = await client._send_and_check(
220
+ OpCode.KV_BEGIN_TXN, namespace, routing_key.encode("utf-8"), b""
221
+ )
222
+ txn_id, pinned_hash = decode_txn_begin_answer(response.data)
223
+ return Transaction(
224
+ client=client,
225
+ namespace=namespace,
226
+ routing_key=routing_key,
227
+ txn_id=txn_id,
228
+ pinned_hash=pinned_hash,
229
+ )
230
+
231
+
232
+ __all__ = [
233
+ "Transaction",
234
+ "TxnUnsupportedOpError",
235
+ "TxnFinishedError",
236
+ "begin",
237
+ ]
flo/processing.py CHANGED
@@ -1,7 +1,6 @@
1
1
  """Flo Processing Operations
2
2
 
3
- Stream processing operations: submit, status, list, stop, cancel,
4
- savepoint, restore, rescale, sync.
3
+ Stream processing operations: submit, status, list, stop, cancel, sync.
5
4
  """
6
5
 
7
6
  from __future__ import annotations
@@ -17,9 +16,6 @@ from .types import (
17
16
  ProcessingCancelOptions,
18
17
  ProcessingListEntry,
19
18
  ProcessingListOptions,
20
- ProcessingRescaleOptions,
21
- ProcessingRestoreOptions,
22
- ProcessingSavepointOptions,
23
19
  ProcessingStatusOptions,
24
20
  ProcessingStatusResult,
25
21
  ProcessingStopOptions,
@@ -126,58 +122,6 @@ class ProcessingOperations:
126
122
  b"",
127
123
  )
128
124
 
129
- async def savepoint(
130
- self, job_id: str, options: ProcessingSavepointOptions | None = None
131
- ) -> str:
132
- """Trigger a savepoint for a processing job. Returns the savepoint ID."""
133
- opts = options or ProcessingSavepointOptions()
134
- namespace = self._client.get_namespace(opts.namespace)
135
-
136
- resp = await self._client._send_and_check(
137
- OpCode.PROCESSING_SAVEPOINT,
138
- namespace,
139
- job_id.encode("utf-8"),
140
- b"",
141
- )
142
-
143
- return resp.data.decode("utf-8")
144
-
145
- async def restore(
146
- self,
147
- job_id: str,
148
- savepoint_id: str,
149
- options: ProcessingRestoreOptions | None = None,
150
- ) -> None:
151
- """Restore a processing job from a savepoint."""
152
- opts = options or ProcessingRestoreOptions()
153
- namespace = self._client.get_namespace(opts.namespace)
154
-
155
- await self._client._send_and_check(
156
- OpCode.PROCESSING_RESTORE,
157
- namespace,
158
- job_id.encode("utf-8"),
159
- savepoint_id.encode("utf-8"),
160
- )
161
-
162
- async def rescale(
163
- self,
164
- job_id: str,
165
- parallelism: int,
166
- options: ProcessingRescaleOptions | None = None,
167
- ) -> None:
168
- """Change the parallelism of a processing job."""
169
- opts = options or ProcessingRescaleOptions()
170
- namespace = self._client.get_namespace(opts.namespace)
171
-
172
- value = struct.pack("<I", parallelism)
173
-
174
- await self._client._send_and_check(
175
- OpCode.PROCESSING_RESCALE,
176
- namespace,
177
- job_id.encode("utf-8"),
178
- value,
179
- )
180
-
181
125
  # =========================================================================
182
126
  # Declarative Sync
183
127
  # =========================================================================
@@ -224,7 +168,7 @@ def _parse_processing_status(data: bytes) -> ProcessingStatusResult:
224
168
  """Parse binary wire format for processing job status.
225
169
 
226
170
  Wire format: [job_id_len:u16][job_id][name_len:u16][name][status:u8]
227
- [parallelism:u32][batch_size:u32][records_processed:u64][created_at:i64]
171
+ [batch_size:u32][records_processed:u64][created_at:i64]
228
172
  """
229
173
  pos = 0
230
174
 
@@ -252,9 +196,6 @@ def _parse_processing_status(data: bytes) -> ProcessingStatusResult:
252
196
  else f"unknown({status_byte})"
253
197
  )
254
198
 
255
- (parallelism,) = struct.unpack_from("<I", data, pos)
256
- pos += 4
257
-
258
199
  (batch_size,) = struct.unpack_from("<I", data, pos)
259
200
  pos += 4
260
201
 
@@ -267,7 +208,6 @@ def _parse_processing_status(data: bytes) -> ProcessingStatusResult:
267
208
  job_id=job_id,
268
209
  name=name,
269
210
  status=status_str,
270
- parallelism=parallelism,
271
211
  batch_size=batch_size,
272
212
  records_processed=records_processed,
273
213
  created_at=created_at,
@@ -278,7 +218,7 @@ def _parse_processing_list(data: bytes) -> list[ProcessingListEntry]:
278
218
  """Parse binary wire format for processing job list.
279
219
 
280
220
  Wire format: [count:u32]([name_len:u16][name][job_id_len:u16][job_id]
281
- [status_len:u16][status][parallelism:u32][created_at:i64])*
221
+ [status_len:u16][status][created_at:i64])*
282
222
  """
283
223
  if len(data) < 4:
284
224
  return []
@@ -305,9 +245,6 @@ def _parse_processing_list(data: bytes) -> list[ProcessingListEntry]:
305
245
  job_id = read_str()
306
246
  status = read_str()
307
247
 
308
- (parallelism,) = struct.unpack_from("<I", data, pos)
309
- pos += 4
310
-
311
248
  (created_at,) = struct.unpack_from("<q", data, pos)
312
249
  pos += 8
313
250
 
@@ -316,7 +253,6 @@ def _parse_processing_list(data: bytes) -> list[ProcessingListEntry]:
316
253
  name=name,
317
254
  job_id=job_id,
318
255
  status=status,
319
- parallelism=parallelism,
320
256
  created_at=created_at,
321
257
  )
322
258
  )
flo/queue.py CHANGED
@@ -16,7 +16,6 @@ from .types import (
16
16
  OpCode,
17
17
  OptionTag,
18
18
  PeekOptions,
19
- TouchOptions,
20
19
  )
21
20
  from .wire import OptionsBuilder, parse_dequeue_response, parse_enqueue_response, serialize_seqs
22
21
 
@@ -41,7 +40,7 @@ class QueueOperations:
41
40
  Args:
42
41
  queue: Queue name.
43
42
  payload: Message payload.
44
- options: Optional enqueue options (priority, delay, dedup_key).
43
+ options: Optional enqueue options (priority).
45
44
 
46
45
  Returns:
47
46
  Sequence number of the enqueued message.
@@ -50,14 +49,8 @@ class QueueOperations:
50
49
  # Simple enqueue
51
50
  seq = await client.queue.enqueue("tasks", b'{"task": "process"}')
52
51
 
53
- # With priority (higher = more urgent)
52
+ # With priority (0-255, lower is taken first; default 0)
54
53
  seq = await client.queue.enqueue("tasks", payload, EnqueueOptions(priority=10))
55
-
56
- # With delay
57
- seq = await client.queue.enqueue("tasks", payload, EnqueueOptions(delay_ms=60000))
58
-
59
- # With deduplication
60
- seq = await client.queue.enqueue("tasks", payload, EnqueueOptions(dedup_key="task-123"))
61
54
  """
62
55
  opts = options or EnqueueOptions()
63
56
  namespace = self._client.get_namespace(opts.namespace)
@@ -68,12 +61,6 @@ class QueueOperations:
68
61
  if opts.priority != 0:
69
62
  builder.add_u8(OptionTag.PRIORITY, opts.priority)
70
63
 
71
- if opts.delay_ms is not None:
72
- builder.add_u64(OptionTag.DELAY_MS, opts.delay_ms)
73
-
74
- if opts.dedup_key is not None:
75
- builder.add_bytes(OptionTag.DEDUP_KEY, opts.dedup_key.encode("utf-8"))
76
-
77
64
  response = await self._client._send_and_check(
78
65
  OpCode.QUEUE_ENQUEUE,
79
66
  namespace,
@@ -92,10 +79,14 @@ class QueueOperations:
92
79
  ) -> DequeueResult:
93
80
  """Dequeue messages from a queue.
94
81
 
82
+ Queues are currently at-most-once: the server acknowledges each
83
+ message as it hands it out, so it is not redelivered in normal
84
+ operation, even if the consumer crashes.
85
+
95
86
  Args:
96
87
  queue: Queue name.
97
88
  count: Maximum number of messages to dequeue.
98
- options: Optional dequeue options (visibility_timeout, block_ms).
89
+ options: Optional dequeue options (block_ms).
99
90
 
100
91
  Returns:
101
92
  DequeueResult containing list of messages.
@@ -105,19 +96,12 @@ class QueueOperations:
105
96
  result = await client.queue.dequeue("tasks", 10)
106
97
  for msg in result.messages:
107
98
  process(msg.payload)
108
- await client.queue.ack("tasks", [msg.seq])
109
99
 
110
100
  # With long polling (wait up to 30s for messages)
111
101
  result = await client.queue.dequeue(
112
102
  "tasks", 10,
113
103
  DequeueOptions(block_ms=30000)
114
104
  )
115
-
116
- # With custom visibility timeout
117
- result = await client.queue.dequeue(
118
- "tasks", 10,
119
- DequeueOptions(visibility_timeout_ms=60000)
120
- )
121
105
  """
122
106
  opts = options or DequeueOptions()
123
107
  namespace = self._client.get_namespace(opts.namespace)
@@ -126,9 +110,6 @@ class QueueOperations:
126
110
  builder = OptionsBuilder()
127
111
  builder.add_u32(OptionTag.COUNT, count)
128
112
 
129
- if opts.visibility_timeout_ms is not None:
130
- builder.add_u32(OptionTag.VISIBILITY_TIMEOUT_MS, opts.visibility_timeout_ms)
131
-
132
113
  if opts.block_ms is not None:
133
114
  builder.add_u32(OptionTag.BLOCK_MS, opts.block_ms)
134
115
 
@@ -148,21 +129,15 @@ class QueueOperations:
148
129
  seqs: list[int],
149
130
  options: AckOptions | None = None,
150
131
  ) -> None:
151
- """Acknowledge messages as successfully processed.
132
+ """Acknowledge messages.
133
+
134
+ Queues are currently at-most-once: dequeue already acknowledges each
135
+ message it hands out, so this has no effect on a dequeued message.
152
136
 
153
137
  Args:
154
138
  queue: Queue name.
155
139
  seqs: Sequence numbers of messages to acknowledge.
156
140
  options: Optional ack options.
157
-
158
- Example:
159
- result = await client.queue.dequeue("tasks", 10)
160
- for msg in result.messages:
161
- try:
162
- process(msg.payload)
163
- await client.queue.ack("tasks", [msg.seq])
164
- except Exception:
165
- await client.queue.nack("tasks", [msg.seq])
166
141
  """
167
142
  if not seqs:
168
143
  return
@@ -185,19 +160,16 @@ class QueueOperations:
185
160
  seqs: list[int],
186
161
  options: NackOptions | None = None,
187
162
  ) -> None:
188
- """Negative acknowledge messages (retry or send to DLQ).
163
+ """Negatively acknowledge messages.
164
+
165
+ Queues are currently at-most-once: dequeue already acknowledges each
166
+ message it hands out, so this has no effect on a dequeued message and
167
+ does not retry it.
189
168
 
190
169
  Args:
191
170
  queue: Queue name.
192
171
  seqs: Sequence numbers of messages to nack.
193
- options: Optional nack options (to_dlq).
194
-
195
- Example:
196
- # Retry the message
197
- await client.queue.nack("tasks", [msg.seq])
198
-
199
- # Send to DLQ (don't retry)
200
- await client.queue.nack("tasks", [msg.seq], NackOptions(to_dlq=True))
172
+ options: Optional nack options.
201
173
  """
202
174
  if not seqs:
203
175
  return
@@ -205,12 +177,6 @@ class QueueOperations:
205
177
  opts = options or NackOptions()
206
178
  namespace = self._client.get_namespace(opts.namespace)
207
179
 
208
- # Build TLV options
209
- builder = OptionsBuilder()
210
-
211
- if opts.to_dlq:
212
- builder.add_u8(OptionTag.SEND_TO_DLQ, 1)
213
-
214
180
  value = serialize_seqs(seqs)
215
181
 
216
182
  await self._client._send_and_check(
@@ -218,7 +184,6 @@ class QueueOperations:
218
184
  namespace,
219
185
  queue.encode("utf-8"),
220
186
  value,
221
- builder.build(),
222
187
  )
223
188
 
224
189
  async def dlq_list(
@@ -228,31 +193,29 @@ class QueueOperations:
228
193
  ) -> DequeueResult:
229
194
  """List messages in the Dead Letter Queue.
230
195
 
196
+ Queues are currently at-most-once, so messages don't reach the DLQ in
197
+ normal use.
198
+
231
199
  Args:
232
200
  queue: Queue name.
233
- options: Optional DLQ list options (limit).
201
+ options: Optional DLQ list options (namespace).
234
202
 
235
203
  Returns:
236
204
  DequeueResult containing list of DLQ messages.
237
205
 
238
206
  Example:
239
- result = await client.queue.dlq_list("tasks", DlqListOptions(limit=100))
207
+ result = await client.queue.dlq_list("tasks")
240
208
  for msg in result.messages:
241
209
  print(f"Failed message {msg.seq}: {msg.payload}")
242
210
  """
243
211
  opts = options or DlqListOptions()
244
212
  namespace = self._client.get_namespace(opts.namespace)
245
213
 
246
- # Build TLV options
247
- builder = OptionsBuilder()
248
- builder.add_u32(OptionTag.LIMIT, opts.limit)
249
-
250
214
  response = await self._client._send_and_check(
251
215
  OpCode.QUEUE_DLQ_LIST,
252
216
  namespace,
253
217
  queue.encode("utf-8"),
254
218
  b"",
255
- builder.build(),
256
219
  )
257
220
 
258
221
  return parse_dequeue_response(response.data)
@@ -265,6 +228,9 @@ class QueueOperations:
265
228
  ) -> None:
266
229
  """Move messages from DLQ back to the main queue.
267
230
 
231
+ Queues are currently at-most-once, so messages don't reach the DLQ in
232
+ normal use.
233
+
268
234
  Args:
269
235
  queue: Queue name.
270
236
  seqs: Sequence numbers of DLQ messages to requeue.
@@ -332,45 +298,3 @@ class QueueOperations:
332
298
  )
333
299
 
334
300
  return parse_dequeue_response(response.data)
335
-
336
- async def touch(
337
- self,
338
- queue: str,
339
- seqs: list[int],
340
- options: TouchOptions | None = None,
341
- ) -> None:
342
- """Renew lease on messages to prevent visibility timeout.
343
-
344
- Call this periodically during long-running processing to prevent
345
- the message from becoming visible again before you're done.
346
-
347
- Args:
348
- queue: Queue name.
349
- seqs: Sequence numbers of messages to touch.
350
- options: Optional touch options.
351
-
352
- Example:
353
- result = await client.queue.dequeue("tasks", 1)
354
- msg = result.messages[0]
355
-
356
- # Long running task - renew lease periodically
357
- for chunk in process_in_chunks(msg.payload):
358
- await process_chunk(chunk)
359
- await client.queue.touch("tasks", [msg.seq])
360
-
361
- await client.queue.ack("tasks", [msg.seq])
362
- """
363
- if not seqs:
364
- return
365
-
366
- opts = options or TouchOptions()
367
- namespace = self._client.get_namespace(opts.namespace)
368
-
369
- value = serialize_seqs(seqs)
370
-
371
- await self._client._send_and_check(
372
- OpCode.QUEUE_TOUCH,
373
- namespace,
374
- queue.encode("utf-8"),
375
- value,
376
- )