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.
@@ -1,18 +1,17 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: flo-python
3
- Version: 0.1.0.dev4
3
+ Version: 0.1.2
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
7
7
  Project-URL: Repository, https://github.com/floruntime/flo-python
8
8
  Author: Flo Team
9
- License-Expression: MIT
9
+ License-Expression: Apache-2.0
10
10
  License-File: LICENSE
11
11
  Keywords: async,distributed-systems,flo,kv-store,queue
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Framework :: AsyncIO
14
14
  Classifier: Intended Audience :: Developers
15
- Classifier: License :: OSI Approved :: MIT License
16
15
  Classifier: Programming Language :: Python :: 3
17
16
  Classifier: Programming Language :: Python :: 3.10
18
17
  Classifier: Programming Language :: Python :: 3.11
@@ -24,6 +23,7 @@ Requires-Python: >=3.10
24
23
  Provides-Extra: dev
25
24
  Requires-Dist: mypy>=1.0; extra == 'dev'
26
25
  Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
26
+ Requires-Dist: pytest-timeout>=2.1; extra == 'dev'
27
27
  Requires-Dist: pytest>=7.0; extra == 'dev'
28
28
  Requires-Dist: ruff>=0.1; extra == 'dev'
29
29
  Description-Content-Type: text/markdown
@@ -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
@@ -62,9 +62,9 @@ asyncio.run(main())
62
62
  ## Features
63
63
 
64
64
  - **KV Store**: Versioned key-value storage with MVCC, TTL, and optimistic locking
65
- - **Queues**: Priority-based task queues with visibility timeout and dead letter queues
65
+ - **Queues**: Priority task queues (currently at-most-once: dequeue hands each message out once)
66
66
  - **Streams**: Append-only logs with consumer groups for distributed processing
67
- - **Actions**: Registered tasks with configurable timeouts, retries, and idempotency
67
+ - **Actions**: Registered tasks with configurable timeouts and retries
68
68
  - **Workers**: Distributed task execution with lease management and heartbeats
69
69
  - **Async/await**: Native asyncio support for high-performance applications
70
70
  - **Type hints**: Full type annotations for IDE support
@@ -74,18 +74,20 @@ asyncio.run(main())
74
74
  ### Get
75
75
 
76
76
  ```python
77
- from flo import GetOptions
77
+ from flo import GetOptions, WaitFor
78
78
 
79
79
  # Simple get
80
80
  value = await client.kv.get("key")
81
81
  if value is not None:
82
82
  print(value.decode())
83
83
 
84
- # Blocking get - wait up to 5 seconds for value to appear
85
- value = await client.kv.get("key", GetOptions(block_ms=5000))
84
+ # Wait up to 2 seconds for the key to exist (answers at once if it does)
85
+ # (wait_ms is at most 300000; the client waits timeout_ms on top of it;
86
+ # 0 means don't wait)
87
+ value = await client.kv.get("key", GetOptions(wait_ms=2000))
86
88
 
87
- # Blocking get - wait indefinitely (0 = infinite)
88
- value = await client.kv.get("key", GetOptions(block_ms=0))
89
+ # Wait up to 2 seconds for the key's next version
90
+ value = await client.kv.get("key", GetOptions(wait_ms=2000, wait_for=WaitFor.CHANGE))
89
91
 
90
92
  # Get with namespace override
91
93
  value = await client.kv.get("key", GetOptions(namespace="other"))
@@ -94,28 +96,34 @@ value = await client.kv.get("key", GetOptions(namespace="other"))
94
96
  ### Put
95
97
 
96
98
  ```python
97
- from flo import PutOptions
99
+ from flo import Condition, PutOptions
98
100
 
99
101
  # Simple put
100
102
  await client.kv.put("key", b"value")
101
103
 
102
104
  # Put with TTL (expires in 1 hour)
103
- await client.kv.put("session:abc", b"data", PutOptions(ttl_seconds=3600))
105
+ await client.kv.put("session:abc", b"data", PutOptions(ttl_ms=3_600_000))
104
106
 
105
107
  # Put with CAS (optimistic locking)
106
108
  await client.kv.put("counter", b"2", PutOptions(cas_version=1))
107
109
 
108
110
  # Put only if key doesn't exist
109
- await client.kv.put("key", b"value", PutOptions(if_not_exists=True))
111
+ await client.kv.put("key", b"value", PutOptions(condition=Condition.IF_ABSENT))
110
112
 
111
113
  # Put only if key exists
112
- await client.kv.put("key", b"value", PutOptions(if_exists=True))
114
+ await client.kv.put("key", b"value", PutOptions(condition=Condition.IF_PRESENT))
113
115
  ```
114
116
 
115
117
  ### Delete
116
118
 
117
119
  ```python
118
- await client.kv.delete("key")
120
+ from flo import DeleteOptions
121
+
122
+ # Returns whether the key existed; deleting a missing key is not an error
123
+ existed = await client.kv.delete("key")
124
+
125
+ # Delete only at a known version
126
+ await client.kv.delete("lock:resource", DeleteOptions(cas_version=held_version))
119
127
  ```
120
128
 
121
129
  ### Scan
@@ -123,19 +131,29 @@ await client.kv.delete("key")
123
131
  ```python
124
132
  from flo import ScanOptions
125
133
 
126
- # Scan all keys with prefix
134
+ # Names of keys with a prefix ("" for all), up to 1024 per page by default
127
135
  result = await client.kv.scan("user:")
128
- for entry in result.entries:
129
- print(f"{entry.key.decode()}: {entry.value.decode()}")
136
+ for name in result.names:
137
+ print(name)
130
138
 
131
- # Paginated scan
132
- result = await client.kv.scan("user:", ScanOptions(limit=100))
133
- while result.has_more:
134
- # Process entries...
139
+ # Next pages: the cursor is None on the last page (limit is 1..1024)
140
+ while result.cursor is not None:
135
141
  result = await client.kv.scan("user:", ScanOptions(cursor=result.cursor, limit=100))
142
+ ```
136
143
 
137
- # Keys only (more efficient when you don't need values)
138
- result = await client.kv.scan("user:", ScanOptions(keys_only=True))
144
+ ### MGet
145
+
146
+ ```python
147
+ # One entry per key, in order (at most 256 keys of at most 4096 bytes each)
148
+ for e in await client.kv.mget(["user:1", "user:2"]):
149
+ if e.found:
150
+ print(e.key, e.version, e.value)
151
+
152
+ # Keys all written with one routing key: look them all up on its shard
153
+ # (without it, each key is looked up on its own shard)
154
+ from flo import KVMGetOptions
155
+
156
+ await client.kv.mget(["user:1:name", "user:1:email"], KVMGetOptions(routing_key="user:1"))
139
157
  ```
140
158
 
141
159
  ### History
@@ -143,10 +161,51 @@ result = await client.kv.scan("user:", ScanOptions(keys_only=True))
143
161
  ```python
144
162
  from flo import HistoryOptions
145
163
 
146
- # Get version history for a key
164
+ # Versions newest first (limit 1..65, default 10), each with its write time in ms;
165
+ # empty for a missing key
147
166
  history = await client.kv.history("user:123", HistoryOptions(limit=10))
148
167
  for entry in history:
149
- print(f"v{entry.version} at {entry.timestamp}: {entry.value.decode()}")
168
+ print(f"v{entry.version} at {entry.at_ms}: {entry.value.decode()}")
169
+ ```
170
+
171
+ ### Counters, TTL and exists
172
+
173
+ ```python
174
+ from flo import KVExpireOptions, KVIncrOptions
175
+
176
+ res = await client.kv.incr("visits") # +1
177
+ res = await client.kv.incr("visits", KVIncrOptions(delta=-5))
178
+ print(res.value, res.version)
179
+
180
+ # Set a key's TTL in ms; 0 clears it. cas_version makes lease renewal race-free.
181
+ await client.kv.expire("session:abc", 60_000)
182
+ await client.kv.expire("lease", 30_000, KVExpireOptions(cas_version=lease_version))
183
+ await client.kv.expire("session:abc", 0)
184
+
185
+ if await client.kv.exists("session:abc"):
186
+ ...
187
+ ```
188
+
189
+ ### JSON documents
190
+
191
+ ```python
192
+ # Path defaults to "$" (the whole document)
193
+ await client.kv.json_set("user:1", "$", b'{"name": "Ada", "tags": []}')
194
+ doc = await client.kv.json_get("user:1", "$.name") # GetJsonResult or None
195
+ await client.kv.json_del("user:1", "$.tags") # returns the new version
196
+ await client.kv.json_del("user:1") # "$" deletes the key: returns None
197
+ ```
198
+
199
+ ### Transactions
200
+
201
+ ```python
202
+ # Every op inside is routed by the transaction's routing key
203
+ async with await client.kv.begin("user:123") as txn:
204
+ await txn.put("user:123:name", b"Jane")
205
+ await txn.incr("user:123:visits")
206
+ await txn.expire("user:123:session", 60_000)
207
+ result = await txn.commit()
208
+ print(result.commit_index, result.op_count)
150
209
  ```
151
210
 
152
211
  ## Queue Operations
@@ -159,18 +218,16 @@ from flo import EnqueueOptions
159
218
  # Simple enqueue
160
219
  seq = await client.queue.enqueue("tasks", b'{"task": "process"}')
161
220
 
162
- # With priority (higher = more urgent, 0-255)
221
+ # With priority (0-255, lower is taken first; default 0)
163
222
  seq = await client.queue.enqueue("tasks", payload, EnqueueOptions(priority=10))
164
-
165
- # With delay (message invisible for 60 seconds)
166
- seq = await client.queue.enqueue("tasks", payload, EnqueueOptions(delay_ms=60000))
167
-
168
- # With deduplication key
169
- seq = await client.queue.enqueue("tasks", payload, EnqueueOptions(dedup_key="task-123"))
170
223
  ```
171
224
 
172
225
  ### Dequeue
173
226
 
227
+ Queues are currently at-most-once: a dequeue acknowledges each message as it
228
+ hands it out. A message is not redelivered in normal operation, even if the
229
+ consumer crashes while handling it, so handle failures in your own code.
230
+
174
231
  ```python
175
232
  from flo import DequeueOptions
176
233
 
@@ -181,69 +238,25 @@ for msg in result.messages:
181
238
 
182
239
  # Long polling (wait up to 30s for messages)
183
240
  result = await client.queue.dequeue("tasks", 10, DequeueOptions(block_ms=30000))
184
-
185
- # Custom visibility timeout (message invisible for 60s)
186
- result = await client.queue.dequeue("tasks", 10, DequeueOptions(visibility_timeout_ms=60000))
187
241
  ```
188
242
 
189
- ### Ack/Nack
243
+ ### Ack/Nack and the Dead Letter Queue
190
244
 
191
- ```python
192
- from flo import NackOptions
193
-
194
- result = await client.queue.dequeue("tasks", 10)
195
- for msg in result.messages:
196
- try:
197
- process(msg.payload)
198
- # Acknowledge successful processing
199
- await client.queue.ack("tasks", [msg.seq])
200
- except Exception:
201
- # Retry the message
202
- await client.queue.nack("tasks", [msg.seq])
203
- # Or send to DLQ
204
- await client.queue.nack("tasks", [msg.seq], NackOptions(to_dlq=True))
205
- ```
206
-
207
- ### Dead Letter Queue
208
-
209
- ```python
210
- from flo import DlqListOptions
211
-
212
- # List DLQ messages
213
- result = await client.queue.dlq_list("tasks", DlqListOptions(limit=100))
214
- for msg in result.messages:
215
- print(f"Failed message {msg.seq}: {msg.payload}")
216
-
217
- # Requeue DLQ messages
218
- seqs = [msg.seq for msg in result.messages]
219
- await client.queue.dlq_requeue("tasks", seqs)
220
- ```
245
+ `queue.ack`, `queue.nack`, `queue.dlq_list` and `queue.dlq_requeue` exist, but
246
+ while queues are at-most-once they do nothing useful: a dequeued message is
247
+ already acknowledged, so ack and nack have no effect on it, nack does not
248
+ retry it, and messages don't reach the DLQ in normal use.
221
249
 
222
250
  ### Peek
223
251
 
224
252
  ```python
225
- # Peek at messages without creating leases (no visibility timeout)
253
+ # Peek at messages without removing them
226
254
  result = await client.queue.peek("tasks", 5)
227
255
  for msg in result.messages:
228
256
  print(f"Message {msg.seq}: {msg.payload}")
229
257
  # Messages remain visible to other consumers
230
258
  ```
231
259
 
232
- ### Touch (Lease Renewal)
233
-
234
- ```python
235
- # Renew lease on messages during long-running processing
236
- result = await client.queue.dequeue("tasks", 1)
237
- msg = result.messages[0]
238
-
239
- # Long running task - periodically touch to prevent visibility timeout
240
- for chunk in process_in_chunks(msg.payload):
241
- await process_chunk(chunk)
242
- await client.queue.touch("tasks", [msg.seq]) # Renew lease
243
-
244
- await client.queue.ack("tasks", [msg.seq])
245
- ```
246
-
247
260
  ## Stream Operations
248
261
 
249
262
  Streams are append-only logs ideal for event sourcing, activity feeds, and real-time data pipelines.
@@ -359,15 +372,14 @@ from flo import ActionInvokeOptions
359
372
  result = await client.action.invoke(
360
373
  "process-image",
361
374
  b'{"image_url": "https://example.com/image.jpg"}',
362
- ActionInvokeOptions(priority=10)
363
375
  )
364
376
  print(f"Run ID: {result.run_id}")
365
377
 
366
- # Invoke with idempotency key (prevents duplicate runs)
378
+ # Route the run only to workers registered with matching labels
367
379
  result = await client.action.invoke(
368
380
  "process-image",
369
- payload,
370
- ActionInvokeOptions(idempotency_key="order-123-image")
381
+ b'{"image_url": "https://example.com/image.jpg"}',
382
+ ActionInvokeOptions(labels={"gpu": True}),
371
383
  )
372
384
  ```
373
385
 
@@ -375,9 +387,11 @@ result = await client.action.invoke(
375
387
 
376
388
  ```python
377
389
  status = await client.action.status(result.run_id)
378
- print(f"Status: {status.status}")
379
- if status.result:
380
- print(f"Result: {status.result}")
390
+ print(f"Status: {status.status.name}, attempt {status.attempt}")
391
+ if status.output is not None:
392
+ print(f"Output: {status.output!r}")
393
+ if status.error_message is not None:
394
+ print(f"Error: {status.error_message}")
381
395
  ```
382
396
 
383
397
  ### List and Delete Actions
@@ -453,14 +467,14 @@ await client.worker.touch(
453
467
 
454
468
  ```python
455
469
  import asyncio
456
- from flo import FloClient
470
+ from flo import ActionRegisterOptions, FloClient
457
471
 
458
472
  async def run_worker():
459
473
  async with FloClient("localhost:9000", namespace="myapp") as client:
460
474
  # Create an action worker from the client
461
- worker = client.new_action_worker(concurrency=5)
475
+ worker = client.new_action_worker(concurrency=5, auto_touch=True)
462
476
 
463
- @worker.action("process-image")
477
+ @worker.action("process-image", ActionRegisterOptions(timeout_ms=60000))
464
478
  async def process_image(ctx):
465
479
  data = ctx.json()
466
480
  result = await do_processing(data)
@@ -471,6 +485,14 @@ async def run_worker():
471
485
  asyncio.run(run_worker())
472
486
  ```
473
487
 
488
+ The worker registers each action with the server when it starts, with exactly the options given (the server's defaults when none: a 30 s timeout, 3 attempts). Registering replaces the action's settings, so workers that register one action with different options leave it with whichever registered last.
489
+
490
+ A run whose handler goes past the action's `timeout_ms` without touching it is taken back and run again, by this worker or another. `auto_touch=True` touches each run every third of its action's timeout while the handler runs; it's off by default. Without it, call `await ctx.touch()` from long handlers. The worker also stops any handler after its own `action_timeout` (300 s by default) whatever the action's `timeout_ms`, so raise it for actions that run longer.
491
+
492
+ Delivery is at-least-once: a result lost to a dropped connection is resent, and one that never arrives lets the lease run out, so a handler may see the same task or record again. `worker.stop()` waits for running handlers, and their results are still sent.
493
+
494
+ An action worker heartbeats every 30 s, reporting the handlers it is running. One the server removed after missed heartbeats registers again, with the same options, when its next heartbeat is answered not found. One the server has drained stops taking work, and `start()` returns once its running handlers finish.
495
+
474
496
  ### StreamWorker Example
475
497
 
476
498
  ```python
@@ -506,9 +528,12 @@ client = FloClient(
506
528
  namespace="myapp", # Default namespace for all operations
507
529
  timeout_ms=5000, # Connection and operation timeout
508
530
  debug=True, # Enable debug logging
531
+ retry=RetryPolicy(), # Retries of refused, not-run requests (the default)
509
532
  )
510
533
  ```
511
534
 
535
+ A call that times out or is cancelled drops the connection; call `reconnect()` before reusing the client (workers do this themselves).
536
+
512
537
  ### Namespaces
513
538
 
514
539
  Each operation can override the default namespace:
@@ -545,6 +570,8 @@ except FloError as e:
545
570
  print(f"Flo error: {e}")
546
571
  ```
547
572
 
573
+ A call that times out (`RequestTimeoutError`, also an `asyncio.TimeoutError`) or is cancelled drops the connection; call `reconnect()` before reusing the client (workers do this themselves).
574
+
548
575
  ### Error Types
549
576
 
550
577
  | Error | Description |
@@ -553,11 +580,51 @@ except FloError as e:
553
580
  | `BadRequestError` | Invalid request parameters |
554
581
  | `ConflictError` | CAS version mismatch |
555
582
  | `UnauthorizedError` | Authentication failed |
556
- | `OverloadedError` | Server is overloaded |
557
- | `InternalServerError` | Server internal error |
583
+ | `OverloadedError` | Server is overloaded, and the client's retries ran out |
584
+ | `UnavailableError` | No leader, or the shard isn't taking writes or is offline, and the client's retries ran out (the message says why, and whether an operator must act first) |
585
+ | `InternalServerError` | Server internal error; not retryable (may mean committed but not applied) |
586
+ | `GenericServerError` | Any other error status, including one this SDK doesn't know (`status_code` carries it) |
558
587
  | `ConnectionFailedError` | Failed to connect |
588
+ | `RequestTimeoutError` | No response within the timeout |
559
589
  | `InvalidChecksumError` | Response CRC32 mismatch |
560
590
 
591
+ ### Retries
592
+
593
+ When the server refuses a request as `overloaded` or `unavailable` and says it
594
+ didn't run it (`error.ran == Ran.NO`), the client sends it again itself, as a
595
+ new request, after a jittered wait. Because the server never ran it, this is
596
+ safe for writes too. Any other refusal, or one that may have run, comes
597
+ straight back.
598
+
599
+ | `RetryPolicy` field | Default | |
600
+ |---|---|---|
601
+ | `max_attempts` | 4 | Tries in all, the first included; 1 turns retrying off |
602
+ | `initial_backoff_ms` | 50 | Ceiling of the first wait, in ms; it doubles each retry |
603
+ | `max_backoff_ms` | 1000 | Largest ceiling, in ms |
604
+
605
+ Each wait is uniform between 0 and its ceiling, so with the defaults a refused
606
+ request is given back after at most about 350 ms of waiting. A negative backoff
607
+ counts as 0, and a `max_backoff_ms` below `initial_backoff_ms` as
608
+ `initial_backoff_ms`. The request goes again on the same connection; the server
609
+ routes it afresh. Other calls on the client are not held up by the wait.
610
+
611
+ The action and stream workers use their client's policy, and wait the same way
612
+ between failed polls, from 100 ms up to 5 s, starting again from 100 ms after a
613
+ poll that succeeds; `stop()` cuts the wait short. A stream worker retries a
614
+ group join refused this way instead of stopping.
615
+
616
+ ```python
617
+ from flo import FloClient, RetryPolicy
618
+
619
+ # Up to 8 tries, waiting at most 2 s between them
620
+ client = FloClient("localhost:9000", retry=RetryPolicy(
621
+ max_attempts=8, initial_backoff_ms=100, max_backoff_ms=2000,
622
+ ))
623
+
624
+ # No retries
625
+ client = FloClient("localhost:9000", retry=RetryPolicy(max_attempts=1))
626
+ ```
627
+
561
628
  ## Requirements
562
629
 
563
630
  - Python 3.10+
@@ -565,4 +632,4 @@ except FloError as e:
565
632
 
566
633
  ## License
567
634
 
568
- MIT
635
+ Apache-2.0 - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,19 @@
1
+ flo/__init__.py,sha256=4zwv03hg6JTNTvy8n7p3k2KTS8Y2dW1UoLSR9Mrjb4c,7932
2
+ flo/actions.py,sha256=hHtfcmaDRHt0rm2-DNRPT2r3Xm6FmV8XKqSvCa7tHW4,12009
3
+ flo/client.py,sha256=0JxnT_dgiJIgpsmykQiCtnjMQLTcRlzA2sd4BZ_Y6ns,22309
4
+ flo/exceptions.py,sha256=Ab71lT63i9s_0Q55IE7IIjp-ejAcuUPD-7QcMs2dLmM,8509
5
+ flo/kv.py,sha256=4Y0PT9XK_AHouRlxFAPWZOtMYpwN6rN8Ny7HwP1Li8o,15755
6
+ flo/kv_body.py,sha256=k-Xeq_9LjqyuXCdqo7I65RfDyBJKc0SROHc887AS2YE,19073
7
+ flo/kv_txn.py,sha256=MkEoONiZz092IbFPHNScxEl_Ltr2TIwHwczche4lx9c,8399
8
+ flo/processing.py,sha256=jN8PhUahsTdYleNptMovE7kE9WLLrOqUu6UVbzULjns,8536
9
+ flo/queue.py,sha256=BoYekudiTAxB6PLpEft1j1sdW4A6rGB8A_MZZ15qjlw,8483
10
+ flo/retry.py,sha256=L8dawQa2qaOKbKm4WBGmo9ERs3krARTOZJ2u9OHUhV4,4479
11
+ flo/streams.py,sha256=RIdZIyww-1FXjJpDNfw_sBhjXjgScqt1EZEsXmE6TXc,17444
12
+ flo/types.py,sha256=LqsVbcWrhPp7FzE8LURwIy2LBQem9hIMhr6SgZ8f_gg,33469
13
+ flo/wire.py,sha256=zkBWTkFIdjuXFJ76RtecKe_69CKi5IDqvulvtg_p-JQ,33550
14
+ flo/worker.py,sha256=uboWk9bWsVrcnrRcTJgPaEQNc_EThYEeFqTUtnl_-90,56330
15
+ flo/workflows.py,sha256=8Z5mhq2UOAnjV_Ef261mcWlj4Z5yGCwrFbxjey0ICoU,15050
16
+ flo_python-0.1.2.dist-info/METADATA,sha256=4DsaVXkbzi5QweSmnYZ_rn9rJVdNPRERRfJp3MPm_1s,19611
17
+ flo_python-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
18
+ flo_python-0.1.2.dist-info/licenses/LICENSE,sha256=ti77D_daMo24gEINF1J7388qriJ5r5GjuJUo0b0k27o,11344
19
+ flo_python-0.1.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any