flo-python 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev4__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 CHANGED
@@ -24,7 +24,7 @@ Example:
24
24
  await client.action.invoke("process", b'{}')
25
25
 
26
26
  # Worker (created from client)
27
- worker = client.new_worker(concurrency=5)
27
+ worker = client.new_action_worker(concurrency=5)
28
28
  worker.register_action("my-action", handler)
29
29
  await worker.start()
30
30
 
@@ -45,6 +45,7 @@ from .exceptions import (
45
45
  InvalidMagicError,
46
46
  KeyTooLargeError,
47
47
  NamespaceTooLargeError,
48
+ NonRetryableError,
48
49
  NotConnectedError,
49
50
  NotFoundError,
50
51
  OverloadedError,
@@ -57,11 +58,11 @@ from .exceptions import (
57
58
  UnsupportedVersionError,
58
59
  ValidationError,
59
60
  ValueTooLargeError,
61
+ is_connection_error,
60
62
  )
63
+ from .processing import ProcessingOperations
61
64
  from .types import (
62
- # KV types
63
65
  AckOptions,
64
- # Action types
65
66
  ActionDeleteOptions,
66
67
  ActionInfo,
67
68
  ActionInvokeOptions,
@@ -86,11 +87,22 @@ from .types import (
86
87
  OpCode,
87
88
  OptionTag,
88
89
  PeekOptions,
90
+ ProcessingCancelOptions,
91
+ ProcessingListEntry,
92
+ ProcessingListOptions,
93
+ ProcessingRescaleOptions,
94
+ ProcessingRestoreOptions,
95
+ ProcessingSavepointOptions,
96
+ ProcessingStatusOptions,
97
+ ProcessingStatusResult,
98
+ ProcessingStopOptions,
99
+ ProcessingSubmitOptions,
100
+ ProcessingSyncOptions,
101
+ ProcessingSyncResult,
89
102
  PutOptions,
90
103
  ScanOptions,
91
104
  ScanResult,
92
105
  StatusCode,
93
- # Stream types
94
106
  StorageTier,
95
107
  StreamAppendOptions,
96
108
  StreamAppendResult,
@@ -105,7 +117,6 @@ from .types import (
105
117
  StreamReadResult,
106
118
  StreamRecord,
107
119
  StreamTrimOptions,
108
- # Worker types
109
120
  TaskAssignment,
110
121
  TouchOptions,
111
122
  VersionEntry,
@@ -119,8 +130,31 @@ from .types import (
119
130
  WorkerRegisterOptions,
120
131
  WorkerTask,
121
132
  WorkerTouchOptions,
133
+ WorkflowCancelOptions,
134
+ WorkflowCreateOptions,
135
+ WorkflowDisableOptions,
136
+ WorkflowEnableOptions,
137
+ WorkflowGetDefinitionOptions,
138
+ WorkflowHistoryOptions,
139
+ WorkflowListDefinitionsOptions,
140
+ WorkflowListRunsOptions,
141
+ WorkflowSignalOptions,
142
+ WorkflowStartOptions,
143
+ WorkflowStatusOptions,
144
+ WorkflowSyncOptions,
145
+ WorkflowSyncResult,
146
+ )
147
+ from .worker import (
148
+ ActionContext,
149
+ ActionResult,
150
+ ActionWorker,
151
+ ActionWorkerOptions,
152
+ StreamContext,
153
+ StreamRecordHandler,
154
+ StreamWorker,
155
+ StreamWorkerOptions,
122
156
  )
123
- from .worker import ActionContext, Worker, WorkerOptions
157
+ from .workflows import WorkflowOperations
124
158
 
125
159
  __version__ = "0.1.0"
126
160
 
@@ -128,11 +162,19 @@ __all__ = [
128
162
  # Client
129
163
  "FloClient",
130
164
  # High-level Worker API
131
- "Worker",
132
- "WorkerOptions",
165
+ "ActionWorker",
166
+ "ActionWorkerOptions",
133
167
  "ActionContext",
168
+ "ActionResult",
169
+ "StreamWorker",
170
+ "StreamWorkerOptions",
171
+ "StreamContext",
172
+ "StreamRecordHandler",
173
+ "WorkflowOperations",
174
+ "ProcessingOperations",
134
175
  # Exceptions
135
176
  "FloError",
177
+ "NonRetryableError",
136
178
  "NotConnectedError",
137
179
  "ConnectionFailedError",
138
180
  "InvalidEndpointError",
@@ -156,6 +198,7 @@ __all__ = [
156
198
  "RateLimitedError",
157
199
  "InternalServerError",
158
200
  "GenericServerError",
201
+ "is_connection_error",
159
202
  # Types
160
203
  "OpCode",
161
204
  "StatusCode",
@@ -221,4 +264,33 @@ __all__ = [
221
264
  "WorkerCompleteOptions",
222
265
  "WorkerFailOptions",
223
266
  "WorkerListOptions",
267
+ # Workflow types
268
+ "WorkflowOperations",
269
+ "WorkflowCreateOptions",
270
+ "WorkflowGetDefinitionOptions",
271
+ "WorkflowStartOptions",
272
+ "WorkflowStatusOptions",
273
+ "WorkflowSignalOptions",
274
+ "WorkflowCancelOptions",
275
+ "WorkflowHistoryOptions",
276
+ "WorkflowListRunsOptions",
277
+ "WorkflowListDefinitionsOptions",
278
+ "WorkflowDisableOptions",
279
+ "WorkflowEnableOptions",
280
+ "WorkflowSyncOptions",
281
+ "WorkflowSyncResult",
282
+ # Processing types
283
+ "ProcessingOperations",
284
+ "ProcessingSubmitOptions",
285
+ "ProcessingStatusOptions",
286
+ "ProcessingListOptions",
287
+ "ProcessingStopOptions",
288
+ "ProcessingCancelOptions",
289
+ "ProcessingSavepointOptions",
290
+ "ProcessingRestoreOptions",
291
+ "ProcessingRescaleOptions",
292
+ "ProcessingSyncOptions",
293
+ "ProcessingStatusResult",
294
+ "ProcessingListEntry",
295
+ "ProcessingSyncResult",
224
296
  ]
flo/actions.py CHANGED
@@ -68,7 +68,7 @@ class ActionOperations:
68
68
 
69
69
  Args:
70
70
  name: Action name.
71
- action_type: Type of action (USER or WASM).
71
+ action_type: Type of action (USER only).
72
72
  options: Optional registration options.
73
73
  """
74
74
  opts = options or ActionRegisterOptions()
@@ -231,7 +231,12 @@ class WorkerOperations:
231
231
  opts = options or WorkerRegisterOptions()
232
232
  namespace = self._client.get_namespace(opts.namespace)
233
233
 
234
- value = serialize_worker_register_value(task_types)
234
+ value = serialize_worker_register_value(
235
+ task_types,
236
+ max_concurrency=opts.concurrency,
237
+ metadata=opts.metadata,
238
+ machine_id=opts.machine_id,
239
+ )
235
240
 
236
241
  await self._client._send_and_check(
237
242
  OpCode.WORKER_REGISTER,
@@ -240,6 +245,30 @@ class WorkerOperations:
240
245
  value,
241
246
  )
242
247
 
248
+ async def heartbeat(
249
+ self,
250
+ worker_id: str,
251
+ current_load: int = 0,
252
+ namespace: str | None = None,
253
+ ) -> None:
254
+ """Send a worker heartbeat to keep the registration alive.
255
+
256
+ Args:
257
+ worker_id: Worker identifier.
258
+ current_load: Current number of active tasks.
259
+ namespace: Optional namespace override.
260
+ """
261
+ import struct
262
+
263
+ ns = self._client.get_namespace(namespace)
264
+ value = struct.pack("<I", current_load)
265
+ await self._client._send_and_check(
266
+ OpCode.WORKER_HEARTBEAT,
267
+ ns,
268
+ worker_id.encode("utf-8"),
269
+ value,
270
+ )
271
+
243
272
  async def await_task(
244
273
  self,
245
274
  worker_id: str,
@@ -269,7 +298,7 @@ class WorkerOperations:
269
298
  options_builder.add_u32(OptionTag.TIMEOUT_MS, opts.timeout_ms)
270
299
 
271
300
  response = await self._client._send_and_check(
272
- OpCode.WORKER_AWAIT,
301
+ OpCode.ACTION_AWAIT,
273
302
  namespace,
274
303
  worker_id.encode("utf-8"),
275
304
  value,
@@ -287,6 +316,7 @@ class WorkerOperations:
287
316
  async def touch(
288
317
  self,
289
318
  worker_id: str,
319
+ action_name: str,
290
320
  task_id: str,
291
321
  options: WorkerTouchOptions | None = None,
292
322
  ) -> None:
@@ -294,16 +324,17 @@ class WorkerOperations:
294
324
 
295
325
  Args:
296
326
  worker_id: Worker identifier.
327
+ action_name: Action name for routing.
297
328
  task_id: Task identifier to extend.
298
329
  options: Optional touch options (extend_ms).
299
330
  """
300
331
  opts = options or WorkerTouchOptions()
301
332
  namespace = self._client.get_namespace(opts.namespace)
302
333
 
303
- value = serialize_worker_touch_value(task_id, opts.extend_ms)
334
+ value = serialize_worker_touch_value(action_name, task_id, opts.extend_ms)
304
335
 
305
336
  await self._client._send_and_check(
306
- OpCode.WORKER_TOUCH,
337
+ OpCode.ACTION_TOUCH,
307
338
  namespace,
308
339
  worker_id.encode("utf-8"),
309
340
  value,
@@ -312,6 +343,7 @@ class WorkerOperations:
312
343
  async def complete(
313
344
  self,
314
345
  worker_id: str,
346
+ action_name: str,
315
347
  task_id: str,
316
348
  result: bytes,
317
349
  options: WorkerCompleteOptions | None = None,
@@ -320,6 +352,7 @@ class WorkerOperations:
320
352
 
321
353
  Args:
322
354
  worker_id: Worker identifier.
355
+ action_name: Action name for routing.
323
356
  task_id: Task identifier to complete.
324
357
  result: Result data from the task.
325
358
  options: Optional complete options.
@@ -327,10 +360,10 @@ class WorkerOperations:
327
360
  opts = options or WorkerCompleteOptions()
328
361
  namespace = self._client.get_namespace(opts.namespace)
329
362
 
330
- value = serialize_worker_complete_value(task_id, result)
363
+ value = serialize_worker_complete_value(action_name, task_id, result, opts.outcome)
331
364
 
332
365
  await self._client._send_and_check(
333
- OpCode.WORKER_COMPLETE,
366
+ OpCode.ACTION_COMPLETE,
334
367
  namespace,
335
368
  worker_id.encode("utf-8"),
336
369
  value,
@@ -339,6 +372,7 @@ class WorkerOperations:
339
372
  async def fail(
340
373
  self,
341
374
  worker_id: str,
375
+ action_name: str,
342
376
  task_id: str,
343
377
  error_message: str,
344
378
  options: WorkerFailOptions | None = None,
@@ -347,6 +381,7 @@ class WorkerOperations:
347
381
 
348
382
  Args:
349
383
  worker_id: Worker identifier.
384
+ action_name: Action name for routing.
350
385
  task_id: Task identifier that failed.
351
386
  error_message: Error message describing the failure.
352
387
  options: Optional fail options (retry flag).
@@ -354,19 +389,13 @@ class WorkerOperations:
354
389
  opts = options or WorkerFailOptions()
355
390
  namespace = self._client.get_namespace(opts.namespace)
356
391
 
357
- value = serialize_worker_fail_value(task_id, error_message)
358
-
359
- # Retry flag goes in TLV options (matches Go SDK)
360
- options_builder = OptionsBuilder()
361
- if opts.retry:
362
- options_builder.add_flag(OptionTag.RETRY)
392
+ value = serialize_worker_fail_value(action_name, task_id, error_message, opts.retry)
363
393
 
364
394
  await self._client._send_and_check(
365
- OpCode.WORKER_FAIL,
395
+ OpCode.ACTION_FAIL,
366
396
  namespace,
367
397
  worker_id.encode("utf-8"),
368
398
  value,
369
- options_builder.build(),
370
399
  )
371
400
 
372
401
  async def list(
flo/client.py CHANGED
@@ -19,7 +19,7 @@ from .types import HEADER_SIZE, OpCode, StatusCode
19
19
  from .wire import RawResponse, parse_response_header, serialize_request
20
20
 
21
21
  if TYPE_CHECKING:
22
- from .worker import Worker
22
+ from .worker import ActionWorker, StreamRecordHandler, StreamWorker
23
23
 
24
24
  logger = logging.getLogger("flo")
25
25
 
@@ -65,14 +65,18 @@ class FloClient:
65
65
  # Initialize operation mixins (will be set after import to avoid circular deps)
66
66
  from .actions import ActionOperations, WorkerOperations
67
67
  from .kv import KVOperations
68
+ from .processing import ProcessingOperations
68
69
  from .queue import QueueOperations
69
70
  from .streams import StreamOperations
71
+ from .workflows import WorkflowOperations
70
72
 
71
73
  self.kv = KVOperations(self)
72
74
  self.queue = QueueOperations(self)
73
75
  self.stream = StreamOperations(self)
74
76
  self.action = ActionOperations(self)
75
77
  self.worker = WorkerOperations(self)
78
+ self.workflow = WorkflowOperations(self)
79
+ self.processing = ProcessingOperations(self)
76
80
 
77
81
  if debug:
78
82
  logging.basicConfig(level=logging.DEBUG)
@@ -133,14 +137,73 @@ class FloClient:
133
137
 
134
138
  async def close(self) -> None:
135
139
  """Close the connection."""
136
- if self._writer:
137
- self._writer.close()
138
- with contextlib.suppress(Exception):
139
- await self._writer.wait_closed()
140
- self._writer = None
141
- self._reader = None
142
- if self._debug:
143
- logger.debug("[flo] Disconnected")
140
+ async with self._lock:
141
+ if self._writer:
142
+ self._writer.close()
143
+ with contextlib.suppress(Exception):
144
+ await self._writer.wait_closed()
145
+ self._writer = None
146
+ self._reader = None
147
+ if self._debug:
148
+ logger.debug("[flo] Disconnected")
149
+
150
+ def interrupt(self) -> None:
151
+ """Forcibly close the underlying connection without acquiring the lock.
152
+
153
+ This unblocks any in-flight ``_send_request`` that is holding the lock
154
+ (e.g. a blocking Await). The interrupted call will raise an error, and
155
+ the caller should reconnect before reusing the client.
156
+ """
157
+ writer = self._writer
158
+ if writer is not None:
159
+ writer.close()
160
+
161
+ async def reconnect(self, *, timeout: float = 300.0) -> None:
162
+ """Close and re-establish the connection with exponential back-off.
163
+
164
+ Retries from 1 s up to 30 s intervals until *timeout* seconds elapse.
165
+
166
+ Args:
167
+ timeout: Maximum total time to keep retrying (default 5 min).
168
+ """
169
+ async with self._lock:
170
+ # Close existing connection
171
+ if self._writer:
172
+ self._writer.close()
173
+ with contextlib.suppress(Exception):
174
+ await self._writer.wait_closed()
175
+ self._writer = None
176
+ self._reader = None
177
+
178
+ backoff = 1.0
179
+ max_backoff = 30.0
180
+ deadline = asyncio.get_event_loop().time() + timeout
181
+ attempt = 0
182
+
183
+ while True:
184
+ attempt += 1
185
+ try:
186
+ self._reader, self._writer = await asyncio.wait_for(
187
+ asyncio.open_connection(self._host, self._port),
188
+ timeout=self._timeout,
189
+ )
190
+ if self._debug:
191
+ logger.debug(f"[flo] Reconnected to {self._endpoint} (attempt {attempt})")
192
+ return
193
+ except (asyncio.TimeoutError, OSError) as exc:
194
+ now = asyncio.get_event_loop().time()
195
+ if now >= deadline:
196
+ raise ConnectionFailedError(
197
+ f"Failed to reconnect to {self._endpoint} after {attempt} attempts"
198
+ ) from exc
199
+ logger.warning(
200
+ "[flo] Reconnect attempt %d failed: %s, retrying in %.0fs...",
201
+ attempt,
202
+ exc,
203
+ backoff,
204
+ )
205
+ await asyncio.sleep(backoff)
206
+ backoff = min(backoff * 2, max_backoff)
144
207
 
145
208
  async def __aenter__(self) -> "FloClient":
146
209
  """Async context manager entry."""
@@ -287,15 +350,15 @@ class FloClient:
287
350
  """Get effective namespace, using override if provided."""
288
351
  return override if override is not None else self._namespace
289
352
 
290
- def new_worker(
353
+ def new_action_worker(
291
354
  self,
292
355
  *,
293
356
  worker_id: str | None = None,
294
357
  concurrency: int = 10,
295
358
  action_timeout: float = 300.0,
296
359
  block_ms: int = 30000,
297
- ) -> "Worker":
298
- """Create a new Worker from this client.
360
+ ) -> "ActionWorker":
361
+ """Create a new ActionWorker from this client.
299
362
 
300
363
  The worker inherits the client's endpoint and namespace, and creates
301
364
  a dedicated connection for polling tasks.
@@ -307,20 +370,80 @@ class FloClient:
307
370
  block_ms: Timeout for blocking dequeue in milliseconds.
308
371
 
309
372
  Returns:
310
- A new Worker instance ready to register actions and start.
373
+ A new ActionWorker instance ready to register actions and start.
374
+
375
+ Example::
311
376
 
312
- Example:
313
377
  async with FloClient("localhost:3000", namespace="myapp") as client:
314
- worker = client.new_worker(concurrency=5)
378
+ worker = client.new_action_worker(concurrency=5)
315
379
  worker.register_action("process-order", process_order)
316
- await worker.start()
380
+ async with worker:
381
+ await worker.start()
317
382
  """
318
- from .worker import Worker
383
+ from .worker import ActionWorker
319
384
 
320
- return Worker(
385
+ return ActionWorker(
321
386
  self,
322
387
  worker_id=worker_id,
323
388
  concurrency=concurrency,
324
389
  action_timeout=action_timeout,
325
390
  block_ms=block_ms,
326
391
  )
392
+
393
+ def new_stream_worker(
394
+ self,
395
+ *,
396
+ stream: str,
397
+ group: str,
398
+ handler: "StreamRecordHandler",
399
+ consumer: str | None = None,
400
+ worker_id: str | None = None,
401
+ concurrency: int = 10,
402
+ batch_size: int = 10,
403
+ block_ms: int = 30000,
404
+ message_timeout: float = 300.0,
405
+ ) -> "StreamWorker":
406
+ """Create a new StreamWorker from this client.
407
+
408
+ The worker joins a consumer group on the specified stream, polls for
409
+ records, and dispatches them to the handler. Records are auto-acked
410
+ on success and auto-nacked on error.
411
+
412
+ Args:
413
+ stream: Stream name to consume from.
414
+ group: Consumer group name.
415
+ handler: Async callable that processes each StreamContext.
416
+ consumer: Consumer ID (auto-generated if not provided).
417
+ worker_id: Unique worker identifier (auto-generated if not provided).
418
+ concurrency: Maximum number of concurrent record handlers.
419
+ batch_size: Number of records to fetch per poll.
420
+ block_ms: Timeout for blocking read in milliseconds.
421
+ message_timeout: Timeout for record handlers in seconds.
422
+
423
+ Returns:
424
+ A new StreamWorker instance ready to start.
425
+
426
+ Example:
427
+ async def process_event(ctx):
428
+ event = ctx.json()
429
+ print(f"Got: {event}")
430
+
431
+ worker = client.new_stream_worker(
432
+ stream="events", group="processors", handler=process_event
433
+ )
434
+ await worker.start()
435
+ """
436
+ from .worker import StreamWorker
437
+
438
+ return StreamWorker(
439
+ self,
440
+ handler,
441
+ stream=stream,
442
+ group=group,
443
+ consumer=consumer,
444
+ worker_id=worker_id,
445
+ concurrency=concurrency,
446
+ batch_size=batch_size,
447
+ block_ms=block_ms,
448
+ message_timeout=message_timeout,
449
+ )
flo/exceptions.py CHANGED
@@ -14,6 +14,19 @@ class FloError(Exception):
14
14
  pass
15
15
 
16
16
 
17
+ class NonRetryableError(FloError):
18
+ """Marks an action error as non-retryable.
19
+
20
+ When raised from an action handler, the task will be failed with
21
+ retry=False, so the server will not re-queue it.
22
+
23
+ Example:
24
+ raise NonRetryableError("invalid input, will never succeed")
25
+ """
26
+
27
+ pass
28
+
29
+
17
30
  # =============================================================================
18
31
  # Connection Errors
19
32
  # =============================================================================
@@ -190,6 +203,14 @@ class GenericServerError(ServerError):
190
203
  super().__init__(message, StatusCode.ERROR_GENERIC)
191
204
 
192
205
 
206
+ def is_connection_error(exc: BaseException) -> bool:
207
+ """Return True if the exception indicates a broken connection.
208
+
209
+ Connection errors may be resolved by reconnecting.
210
+ """
211
+ return isinstance(exc, (UnexpectedEofError, NotConnectedError))
212
+
213
+
193
214
  def raise_for_status(status: StatusCode, data: bytes = b"") -> None:
194
215
  """Raise appropriate exception for non-OK status codes.
195
216
 
flo/kv.py CHANGED
@@ -3,6 +3,7 @@
3
3
  Key-value store operations for Flo client.
4
4
  """
5
5
 
6
+ import struct
6
7
  from typing import TYPE_CHECKING
7
8
 
8
9
  from .types import (
@@ -194,17 +195,16 @@ class KVOperations:
194
195
 
195
196
  prefix_bytes = prefix.encode("utf-8") if isinstance(prefix, str) else prefix
196
197
 
197
- # Build TLV options
198
+ # Build TLV options (keys_only only — limit is in value now)
198
199
  builder = OptionsBuilder()
199
200
 
200
- if opts.limit is not None:
201
- builder.add_u32(OptionTag.LIMIT, opts.limit)
202
-
203
201
  if opts.keys_only:
204
202
  builder.add_u8(OptionTag.KEYS_ONLY, 1)
205
203
 
206
- # Cursor goes in value field
207
- value = opts.cursor if opts.cursor is not None else b""
204
+ # Value: [limit:u32][cursor...]
205
+ limit = opts.limit if opts.limit is not None else 0 # 0 = server default
206
+ cursor = opts.cursor if opts.cursor is not None else b""
207
+ value = struct.pack("<I", limit) + cursor
208
208
 
209
209
  response = await self._client._send_and_check(
210
210
  OpCode.KV_SCAN,