flo-python 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev3__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/processing.py ADDED
@@ -0,0 +1,341 @@
1
+ """Flo Processing Operations
2
+
3
+ Stream processing operations: submit, status, list, stop, cancel,
4
+ savepoint, restore, rescale, sync.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import builtins
10
+ import os
11
+ import re
12
+ import struct
13
+ from typing import TYPE_CHECKING
14
+
15
+ from .types import (
16
+ OpCode,
17
+ ProcessingCancelOptions,
18
+ ProcessingListEntry,
19
+ ProcessingListOptions,
20
+ ProcessingRescaleOptions,
21
+ ProcessingRestoreOptions,
22
+ ProcessingSavepointOptions,
23
+ ProcessingStatusOptions,
24
+ ProcessingStatusResult,
25
+ ProcessingStopOptions,
26
+ ProcessingSubmitOptions,
27
+ ProcessingSyncOptions,
28
+ ProcessingSyncResult,
29
+ )
30
+
31
+ if TYPE_CHECKING:
32
+ from .client import FloClient
33
+
34
+ _PROCESSING_STATUS_NAMES = ["running", "stopped", "cancelled", "failed", "completed"]
35
+
36
+
37
+ class ProcessingOperations:
38
+ """Processing operations for the Flo client."""
39
+
40
+ def __init__(self, client: FloClient) -> None:
41
+ self._client = client
42
+
43
+ # =========================================================================
44
+ # Core Operations
45
+ # =========================================================================
46
+
47
+ async def submit(
48
+ self, yaml: str | bytes, options: ProcessingSubmitOptions | None = None
49
+ ) -> str:
50
+ """Submit a processing job from a YAML definition. Returns the job ID."""
51
+ opts = options or ProcessingSubmitOptions()
52
+ namespace = self._client.get_namespace(opts.namespace)
53
+
54
+ yaml_bytes = yaml.encode("utf-8") if isinstance(yaml, str) else yaml
55
+
56
+ resp = await self._client._send_and_check(
57
+ OpCode.PROCESSING_SUBMIT,
58
+ namespace,
59
+ b"",
60
+ yaml_bytes,
61
+ )
62
+
63
+ return resp.data.decode("utf-8")
64
+
65
+ async def status(
66
+ self, job_id: str, options: ProcessingStatusOptions | None = None
67
+ ) -> ProcessingStatusResult | None:
68
+ """Get the status of a processing job. Returns None if not found."""
69
+ opts = options or ProcessingStatusOptions()
70
+ namespace = self._client.get_namespace(opts.namespace)
71
+
72
+ resp = await self._client._send_and_check(
73
+ OpCode.PROCESSING_STATUS,
74
+ namespace,
75
+ job_id.encode("utf-8"),
76
+ b"",
77
+ allow_not_found=True,
78
+ )
79
+
80
+ from .types import StatusCode
81
+
82
+ if resp.status == StatusCode.NOT_FOUND:
83
+ return None
84
+
85
+ return _parse_processing_status(resp.data)
86
+
87
+ async def list(self, options: ProcessingListOptions | None = None) -> list[ProcessingListEntry]:
88
+ """List processing jobs."""
89
+ opts = options or ProcessingListOptions()
90
+ namespace = self._client.get_namespace(opts.namespace)
91
+
92
+ # Wire format: [limit:u32][cursor...]
93
+ cursor = opts.cursor or b""
94
+ value = struct.pack("<I", opts.limit) + cursor
95
+
96
+ resp = await self._client._send_and_check(
97
+ OpCode.PROCESSING_LIST,
98
+ namespace,
99
+ b"",
100
+ value,
101
+ )
102
+
103
+ return _parse_processing_list(resp.data)
104
+
105
+ async def stop(self, job_id: str, options: ProcessingStopOptions | None = None) -> None:
106
+ """Gracefully stop a processing job."""
107
+ opts = options or ProcessingStopOptions()
108
+ namespace = self._client.get_namespace(opts.namespace)
109
+
110
+ await self._client._send_and_check(
111
+ OpCode.PROCESSING_STOP,
112
+ namespace,
113
+ job_id.encode("utf-8"),
114
+ b"",
115
+ )
116
+
117
+ async def cancel(self, job_id: str, options: ProcessingCancelOptions | None = None) -> None:
118
+ """Force-cancel a processing job."""
119
+ opts = options or ProcessingCancelOptions()
120
+ namespace = self._client.get_namespace(opts.namespace)
121
+
122
+ await self._client._send_and_check(
123
+ OpCode.PROCESSING_CANCEL,
124
+ namespace,
125
+ job_id.encode("utf-8"),
126
+ b"",
127
+ )
128
+
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
+ # =========================================================================
182
+ # Declarative Sync
183
+ # =========================================================================
184
+
185
+ async def sync(
186
+ self, yaml_path: str, options: ProcessingSyncOptions | None = None
187
+ ) -> ProcessingSyncResult:
188
+ """Sync a processing job from a YAML file. Returns name + job ID."""
189
+ with open(yaml_path) as f:
190
+ yaml_content = f.read()
191
+ return await self.sync_bytes(yaml_content.encode("utf-8"), options)
192
+
193
+ async def sync_bytes(
194
+ self, yaml: bytes, options: ProcessingSyncOptions | None = None
195
+ ) -> ProcessingSyncResult:
196
+ """Sync raw YAML bytes. Submits a new job and returns name + job ID."""
197
+ opts = options or ProcessingSyncOptions()
198
+ name = _extract_processing_meta(yaml)
199
+
200
+ submit_opts = ProcessingSubmitOptions(namespace=opts.namespace)
201
+ job_id = await self.submit(yaml, submit_opts)
202
+
203
+ return ProcessingSyncResult(name=name, job_id=job_id)
204
+
205
+ async def sync_dir(
206
+ self, dir_path: str, options: ProcessingSyncOptions | None = None
207
+ ) -> builtins.list[ProcessingSyncResult]:
208
+ """Sync all YAML files in a directory."""
209
+ results: builtins.list[ProcessingSyncResult] = []
210
+ for entry in sorted(os.listdir(dir_path)):
211
+ if entry.endswith((".yaml", ".yml")):
212
+ file_path = os.path.join(dir_path, entry)
213
+ result = await self.sync(file_path, options)
214
+ results.append(result)
215
+ return results
216
+
217
+
218
+ # =============================================================================
219
+ # Wire Format Parsers
220
+ # =============================================================================
221
+
222
+
223
+ def _parse_processing_status(data: bytes) -> ProcessingStatusResult:
224
+ """Parse binary wire format for processing job status.
225
+
226
+ 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]
228
+ """
229
+ pos = 0
230
+
231
+ def read_u16() -> int:
232
+ nonlocal pos
233
+ v: int = struct.unpack_from("<H", data, pos)[0]
234
+ pos += 2
235
+ return v
236
+
237
+ def read_str() -> str:
238
+ n = read_u16()
239
+ nonlocal pos
240
+ s = data[pos : pos + n].decode("utf-8")
241
+ pos += n
242
+ return s
243
+
244
+ job_id = read_str()
245
+ name = read_str()
246
+
247
+ status_byte = data[pos]
248
+ pos += 1
249
+ status_str = (
250
+ _PROCESSING_STATUS_NAMES[status_byte]
251
+ if status_byte < len(_PROCESSING_STATUS_NAMES)
252
+ else f"unknown({status_byte})"
253
+ )
254
+
255
+ (parallelism,) = struct.unpack_from("<I", data, pos)
256
+ pos += 4
257
+
258
+ (batch_size,) = struct.unpack_from("<I", data, pos)
259
+ pos += 4
260
+
261
+ (records_processed,) = struct.unpack_from("<Q", data, pos)
262
+ pos += 8
263
+
264
+ (created_at,) = struct.unpack_from("<q", data, pos)
265
+
266
+ return ProcessingStatusResult(
267
+ job_id=job_id,
268
+ name=name,
269
+ status=status_str,
270
+ parallelism=parallelism,
271
+ batch_size=batch_size,
272
+ records_processed=records_processed,
273
+ created_at=created_at,
274
+ )
275
+
276
+
277
+ def _parse_processing_list(data: bytes) -> list[ProcessingListEntry]:
278
+ """Parse binary wire format for processing job list.
279
+
280
+ Wire format: [count:u32]([name_len:u16][name][job_id_len:u16][job_id]
281
+ [status_len:u16][status][parallelism:u32][created_at:i64])*
282
+ """
283
+ if len(data) < 4:
284
+ return []
285
+
286
+ (count,) = struct.unpack_from("<I", data, 0)
287
+ pos = 4
288
+ results: list[ProcessingListEntry] = []
289
+
290
+ def read_u16() -> int:
291
+ nonlocal pos
292
+ v: int = struct.unpack_from("<H", data, pos)[0]
293
+ pos += 2
294
+ return v
295
+
296
+ def read_str() -> str:
297
+ n = read_u16()
298
+ nonlocal pos
299
+ s = data[pos : pos + n].decode("utf-8")
300
+ pos += n
301
+ return s
302
+
303
+ for _ in range(count):
304
+ name = read_str()
305
+ job_id = read_str()
306
+ status = read_str()
307
+
308
+ (parallelism,) = struct.unpack_from("<I", data, pos)
309
+ pos += 4
310
+
311
+ (created_at,) = struct.unpack_from("<q", data, pos)
312
+ pos += 8
313
+
314
+ results.append(
315
+ ProcessingListEntry(
316
+ name=name,
317
+ job_id=job_id,
318
+ status=status,
319
+ parallelism=parallelism,
320
+ created_at=created_at,
321
+ )
322
+ )
323
+
324
+ return results
325
+
326
+
327
+ # =============================================================================
328
+ # YAML Metadata Extraction
329
+ # =============================================================================
330
+
331
+ _YAML_FIELD_RE = re.compile(r"""^\s*['"]?(\w+)['"]?\s*:\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$""")
332
+
333
+
334
+ def _extract_processing_meta(data: bytes) -> str:
335
+ """Extract the job name from processing YAML."""
336
+ text = data.decode("utf-8")
337
+ for line in text.split("\n"):
338
+ m = _YAML_FIELD_RE.match(line)
339
+ if m and m.group(1) == "name":
340
+ return m.group(2).strip()
341
+ raise ValueError("flo: processing YAML missing required 'name' field")
flo/streams.py CHANGED
@@ -15,6 +15,7 @@ from .types import (
15
15
  StreamGroupJoinOptions,
16
16
  StreamGroupNackOptions,
17
17
  StreamGroupReadOptions,
18
+ StreamID,
18
19
  StreamInfo,
19
20
  StreamInfoOptions,
20
21
  StreamReadOptions,
@@ -54,11 +55,11 @@ class StreamOperations:
54
55
  options: Optional append options.
55
56
 
56
57
  Returns:
57
- StreamAppendResult with sequence and timestamp_ms.
58
+ StreamAppendResult with id (StreamID).
58
59
 
59
60
  Example:
60
61
  result = await client.stream.append("events", b'{"event": "click"}')
61
- print(f"Appended: sequence={result.sequence} timestamp_ms={result.timestamp_ms}")
62
+ print(f"Appended: id={result.id}")
62
63
  """
63
64
  opts = options or StreamAppendOptions()
64
65
  namespace = self._client.get_namespace(opts.namespace)
@@ -99,12 +100,12 @@ class StreamOperations:
99
100
  # Read from specific StreamID
100
101
  from flo.types import StreamID
101
102
  result = await client.stream.read("events", StreamReadOptions(
102
- start=StreamID.from_sequence(100), count=10
103
+ start=StreamID(timestamp_ms=0, sequence=100), count=10
103
104
  ))
104
105
 
105
106
  # Blocking read (long polling)
106
107
  result = await client.stream.read("events", StreamReadOptions(
107
- start=StreamID.from_sequence(100), block_ms=30000
108
+ start=StreamID(timestamp_ms=0, sequence=100), block_ms=30000
108
109
  ))
109
110
  """
110
111
  opts = options or StreamReadOptions()
@@ -158,7 +159,7 @@ class StreamOperations:
158
159
  options: Optional info options.
159
160
 
160
161
  Returns:
161
- StreamInfo with first_seq, last_seq, count, bytes_size.
162
+ StreamInfo with first_id, last_id, count, bytes_size.
162
163
 
163
164
  Example:
164
165
  info = await client.stream.info("events")
@@ -312,7 +313,7 @@ class StreamOperations:
312
313
  result = await client.stream.group_read("events", "processors", "worker-1")
313
314
  for record in result.records:
314
315
  process(record.payload)
315
- await client.stream.group_ack("events", "processors", [record.seq])
316
+ await client.stream.group_ack("events", "processors", [record.id])
316
317
  """
317
318
  opts = options or StreamGroupReadOptions()
318
319
  namespace = self._client.get_namespace(opts.namespace)
@@ -343,7 +344,7 @@ class StreamOperations:
343
344
  self,
344
345
  stream: str,
345
346
  group: str,
346
- seqs: list[int],
347
+ ids: list[StreamID],
347
348
  options: StreamGroupAckOptions | None = None,
348
349
  ) -> None:
349
350
  """Acknowledge records in a consumer group.
@@ -351,7 +352,7 @@ class StreamOperations:
351
352
  Args:
352
353
  stream: Stream name.
353
354
  group: Consumer group name.
354
- seqs: Sequence numbers of records to acknowledge.
355
+ ids: StreamIDs of records to acknowledge.
355
356
  options: Optional ack options (including consumer name).
356
357
 
357
358
  Example:
@@ -359,18 +360,18 @@ class StreamOperations:
359
360
  for record in result.records:
360
361
  try:
361
362
  process(record.payload)
362
- await client.stream.group_ack("events", "processors", [record.seq],
363
+ await client.stream.group_ack("events", "processors", [record.id],
363
364
  StreamGroupAckOptions(consumer="worker-1"))
364
365
  except Exception:
365
366
  pass # Record will be redelivered
366
367
  """
367
- if not seqs:
368
+ if not ids:
368
369
  return
369
370
 
370
371
  opts = options or StreamGroupAckOptions()
371
372
  namespace = self._client.get_namespace(opts.namespace)
372
373
 
373
- value = serialize_group_ack_value(group, seqs, consumer=opts.consumer)
374
+ value = serialize_group_ack_value(group, ids, consumer=opts.consumer)
374
375
 
375
376
  await self._client._send_and_check(
376
377
  OpCode.STREAM_GROUP_ACK,
@@ -384,7 +385,7 @@ class StreamOperations:
384
385
  self,
385
386
  stream: str,
386
387
  group: str,
387
- seqs: list[int],
388
+ ids: list[StreamID],
388
389
  options: StreamGroupNackOptions | None = None,
389
390
  ) -> None:
390
391
  """Negatively acknowledge records in a consumer group for redelivery.
@@ -392,7 +393,7 @@ class StreamOperations:
392
393
  Args:
393
394
  stream: Stream name.
394
395
  group: Consumer group name.
395
- seqs: Sequence numbers of records to negatively acknowledge.
396
+ ids: StreamIDs of records to negatively acknowledge.
396
397
  options: Optional nack options (consumer name, redelivery delay).
397
398
 
398
399
  Example:
@@ -401,16 +402,16 @@ class StreamOperations:
401
402
  try:
402
403
  process(record.payload)
403
404
  except Exception:
404
- await client.stream.group_nack("events", "processors", [record.seq],
405
+ await client.stream.group_nack("events", "processors", [record.id],
405
406
  StreamGroupNackOptions(consumer="worker-1", redelivery_delay_ms=5000))
406
407
  """
407
- if not seqs:
408
+ if not ids:
408
409
  return
409
410
 
410
411
  opts = options or StreamGroupNackOptions()
411
412
  namespace = self._client.get_namespace(opts.namespace)
412
413
 
413
- value = serialize_group_ack_value(group, seqs, consumer=opts.consumer)
414
+ value = serialize_group_ack_value(group, ids, consumer=opts.consumer)
414
415
 
415
416
  builder = OptionsBuilder()
416
417
  if opts.redelivery_delay_ms is not None: