flo-python 0.1.2__py3-none-any.whl → 0.1.3__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
@@ -49,12 +49,14 @@ from .exceptions import (
49
49
  NonRetryableError,
50
50
  NotConnectedError,
51
51
  NotFoundError,
52
+ OutcomePendingError,
52
53
  OverloadedError,
53
54
  PayloadTooLargeError,
54
55
  ProtocolError,
55
56
  RateLimitedError,
56
57
  RequestTimeoutError,
57
58
  ServerError,
59
+ StaleNotFoundError,
58
60
  TableMismatchError,
59
61
  UnauthorizedError,
60
62
  UnavailableError,
@@ -85,10 +87,12 @@ from .types import (
85
87
  DlqListOptions,
86
88
  DlqRequeueOptions,
87
89
  EnqueueOptions,
90
+ ExistsResult,
88
91
  GetJsonResult,
89
92
  GetOptions,
90
93
  GetResult,
91
94
  HistoryOptions,
95
+ HistoryResult,
92
96
  IncrResult,
93
97
  KVBeginResult,
94
98
  KVCommitResult,
@@ -113,6 +117,7 @@ from .types import (
113
117
  ProcessingSubmitOptions,
114
118
  ProcessingSyncOptions,
115
119
  ProcessingSyncResult,
120
+ ProcessKind,
116
121
  PutOptions,
117
122
  PutResult,
118
123
  Ran,
@@ -143,10 +148,12 @@ from .types import (
143
148
  WorkerAwaitOptions,
144
149
  WorkerAwaitResult,
145
150
  WorkerCompleteOptions,
151
+ WorkerDetails,
146
152
  WorkerFailOptions,
147
153
  WorkerInfo,
148
154
  WorkerListOptions,
149
155
  WorkerListResult,
156
+ WorkerProcess,
150
157
  WorkerRegisterOptions,
151
158
  WorkerStatus,
152
159
  WorkerTouchOptions,
@@ -177,7 +184,7 @@ from .worker import (
177
184
  )
178
185
  from .workflows import WorkflowOperations
179
186
 
180
- __version__ = "0.1.2"
187
+ __version__ = "0.1.3"
181
188
 
182
189
  __all__ = [
183
190
  "Transaction",
@@ -191,6 +198,8 @@ __all__ = [
191
198
  "KVMGetOptions",
192
199
  "KVExpireOptions",
193
200
  "GetResult",
201
+ "HistoryResult",
202
+ "ExistsResult",
194
203
  "GetJsonResult",
195
204
  "MGetEntry",
196
205
  "PutResult",
@@ -235,6 +244,8 @@ __all__ = [
235
244
  "ConflictError",
236
245
  "UnauthorizedError",
237
246
  "OverloadedError",
247
+ "OutcomePendingError",
248
+ "StaleNotFoundError",
238
249
  "UnavailableError",
239
250
  "RateLimitedError",
240
251
  "InternalServerError",
@@ -301,7 +312,10 @@ __all__ = [
301
312
  # Worker types
302
313
  "TaskAssignment",
303
314
  "WorkerAwaitResult",
315
+ "ProcessKind",
316
+ "WorkerDetails",
304
317
  "WorkerInfo",
318
+ "WorkerProcess",
305
319
  "WorkerListResult",
306
320
  "WorkerStatus",
307
321
  "WorkerType",
flo/actions.py CHANGED
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
12
12
  from .kv_body import (
13
13
  decode_action_invoke_answer,
14
14
  decode_action_status_answer,
15
+ decode_worker_info_answer,
15
16
  decode_worker_list_answer,
16
17
  encode_worker_list_body,
17
18
  )
@@ -30,10 +31,12 @@ from .types import (
30
31
  WorkerAwaitOptions,
31
32
  WorkerAwaitResult,
32
33
  WorkerCompleteOptions,
34
+ WorkerDetails,
33
35
  WorkerFailOptions,
34
36
  WorkerListOptions,
35
37
  WorkerListResult,
36
38
  WorkerRegisterOptions,
39
+ WorkerStatus,
37
40
  WorkerTouchOptions,
38
41
  )
39
42
  from .wire import (
@@ -229,6 +232,7 @@ class WorkerOperations:
229
232
 
230
233
  value = serialize_worker_register_value(
231
234
  task_types,
235
+ worker_type=int(opts.worker_type),
232
236
  max_concurrency=opts.concurrency,
233
237
  metadata=opts.metadata,
234
238
  machine_id=opts.machine_id,
@@ -246,24 +250,57 @@ class WorkerOperations:
246
250
  worker_id: str,
247
251
  current_load: int = 0,
248
252
  namespace: str | None = None,
249
- ) -> None:
253
+ ) -> WorkerStatus:
250
254
  """Send a worker heartbeat to keep the registration alive.
251
255
 
252
256
  Args:
253
257
  worker_id: Worker identifier.
254
258
  current_load: Current number of active tasks.
255
259
  namespace: Optional namespace override.
260
+
261
+ Returns:
262
+ The worker's status on the server: DRAINING once it is drained.
263
+
264
+ Raises:
265
+ NotFoundError: The server doesn't know the worker (it never
266
+ registered, or was removed after going silent).
256
267
  """
257
268
  import struct
258
269
 
259
270
  ns = self._client.get_namespace(namespace)
260
271
  value = struct.pack("<I", current_load)
261
- await self._client._send_and_check(
272
+ response = await self._client._send_and_check(
262
273
  OpCode.WORKER_HEARTBEAT,
263
274
  ns,
264
275
  worker_id.encode("utf-8"),
265
276
  value,
266
277
  )
278
+ if len(response.data) != 1:
279
+ raise ValueError(f"heartbeat answer is {len(response.data)} bytes, want 1")
280
+ return WorkerStatus(response.data[0])
281
+
282
+ async def drain(self, worker_id: str, namespace: str | None = None) -> None:
283
+ """Mark a worker draining: the server gives it no more work, and
284
+ its heartbeats answer DRAINING until it deregisters.
285
+
286
+ Raises:
287
+ NotFoundError: The server doesn't know the worker.
288
+ """
289
+ await self._client._send_and_check(
290
+ OpCode.WORKER_DRAIN,
291
+ self._client.get_namespace(namespace),
292
+ worker_id.encode("utf-8"),
293
+ b"",
294
+ )
295
+
296
+ async def deregister(self, worker_id: str, namespace: str | None = None) -> None:
297
+ """Remove a worker from the registry."""
298
+ await self._client._send_and_check(
299
+ OpCode.WORKER_DEREGISTER,
300
+ self._client.get_namespace(namespace),
301
+ worker_id.encode("utf-8"),
302
+ b"",
303
+ )
267
304
 
268
305
  async def await_task(
269
306
  self,
@@ -412,8 +449,23 @@ class WorkerOperations:
412
449
  OpCode.WORKER_LIST,
413
450
  namespace,
414
451
  b"",
415
- encode_worker_list_body(opts.limit, opts.cursor),
452
+ encode_worker_list_body(opts.limit, opts.cursor, opts.action),
416
453
  )
417
454
 
418
455
  workers, cursor = decode_worker_list_answer(response.data)
419
456
  return WorkerListResult(workers=workers, cursor=cursor)
457
+
458
+ async def info(self, worker_id: str, namespace: str | None = None) -> WorkerDetails:
459
+ """A worker's record: its row, as :meth:`list` shows it, the metadata
460
+ it registered with, and its processes with their run and fail counts.
461
+
462
+ Raises:
463
+ NotFoundError: The server doesn't know the worker.
464
+ """
465
+ response = await self._client._send_and_check(
466
+ OpCode.WORKER_INFO,
467
+ self._client.get_namespace(namespace),
468
+ worker_id.encode("utf-8"),
469
+ b"",
470
+ )
471
+ return decode_worker_info_answer(response.data)
flo/client.py CHANGED
@@ -15,6 +15,7 @@ from .exceptions import (
15
15
  NotConnectedError,
16
16
  ProtocolError,
17
17
  RequestTimeoutError,
18
+ ServerError,
18
19
  UnexpectedEofError,
19
20
  raise_for_status,
20
21
  )
@@ -23,7 +24,7 @@ from .types import _DEFAULT_WORKER_BLOCK_MS, HEADER_SIZE, OpCode, OptionTag, Sta
23
24
  from .wire import (
24
25
  OptionsIterator,
25
26
  RawResponse,
26
- compute_crc32,
27
+ compute_crc32c,
27
28
  parse_response_header,
28
29
  serialize_request,
29
30
  )
@@ -387,7 +388,7 @@ class FloClient:
387
388
  except asyncio.TimeoutError as e:
388
389
  raise RequestTimeoutError(f"No response to {op_code.name}") from e
389
390
 
390
- status, data_len, resp_request_id, crc = parse_response_header(header_data)
391
+ status, data_len, resp_request_id, crc, stale = parse_response_header(header_data)
391
392
 
392
393
  if data_len > 0:
393
394
  try:
@@ -406,10 +407,10 @@ class FloClient:
406
407
  else:
407
408
  response_data = b""
408
409
 
409
- computed_crc = compute_crc32(header_data, response_data)
410
+ computed_crc = compute_crc32c(header_data, response_data)
410
411
  if computed_crc != crc:
411
412
  raise InvalidChecksumError(
412
- f"CRC32 mismatch: 0x{computed_crc:08X} != 0x{crc:08X}"
413
+ f"CRC32C mismatch: 0x{computed_crc:08X} != 0x{crc:08X}"
413
414
  )
414
415
  if resp_request_id == 0:
415
416
  # The server answers a request it could not parse with
@@ -435,7 +436,9 @@ class FloClient:
435
436
  if self._debug:
436
437
  logger.debug(f"[flo] <- {status.name} {len(response_data)} bytes")
437
438
 
438
- return RawResponse(status=status, data=response_data, request_id=resp_request_id)
439
+ return RawResponse(
440
+ status=status, data=response_data, request_id=resp_request_id, stale=stale
441
+ )
439
442
 
440
443
  async def _send_and_check(
441
444
  self,
@@ -471,7 +474,11 @@ class FloClient:
471
474
  if allow_not_found and response.status == StatusCode.NOT_FOUND:
472
475
  return response
473
476
 
474
- raise_for_status(response.status, response.data)
477
+ try:
478
+ raise_for_status(response.status, response.data)
479
+ except ServerError as e:
480
+ e.stale = response.stale
481
+ raise
475
482
  return response # Unreachable, but makes type checker happy
476
483
 
477
484
  def get_namespace(self, override: str | None) -> str:
flo/exceptions.py CHANGED
@@ -101,7 +101,7 @@ class TableMismatchError(ProtocolError):
101
101
 
102
102
 
103
103
  class InvalidChecksumError(ProtocolError):
104
- """CRC32 checksum validation failed."""
104
+ """A frame's CRC32C checksum didn't match."""
105
105
 
106
106
  pass
107
107
 
@@ -166,6 +166,9 @@ class ServerError(FloError):
166
166
  reason: Reason = Reason.UNCLASSIFIED
167
167
  # Whether the request took effect; unknown unless the server said.
168
168
  ran: Ran = Ran.UNKNOWN
169
+ # A halted shard answered, from its state just before the entry it
170
+ # halted at.
171
+ stale: bool = False
169
172
 
170
173
  def __init__(self, message: str, status_code: StatusCode):
171
174
  super().__init__(message)
@@ -179,6 +182,15 @@ class NotFoundError(ServerError):
179
182
  super().__init__(message, StatusCode.NOT_FOUND)
180
183
 
181
184
 
185
+ class StaleNotFoundError(NotFoundError):
186
+ """A halted shard didn't have the key, as of the entry it halted at
187
+ (``stale`` is True). It may exist since: a write after the halt hasn't
188
+ applied. kv.get and kv.json_get raise it rather than report the key
189
+ absent."""
190
+
191
+ stale = True
192
+
193
+
182
194
  class BadRequestError(ServerError):
183
195
  """Invalid request parameters."""
184
196
 
@@ -218,13 +230,25 @@ class UnavailableError(ServerError):
218
230
  """No leader, or the shard isn't taking writes or is offline. Retryable.
219
231
 
220
232
  The message says why; an offline shard stays unavailable until an
221
- operator acts, so whether and when to retry is the caller's call.
233
+ operator acts, so whether and when to retry is the caller's call. With
234
+ reason SHARD_HALTED the shard halted and takes no writes until an
235
+ operator repairs it; the client doesn't retry that one.
222
236
  """
223
237
 
224
238
  def __init__(self, message: str = StatusCode.UNAVAILABLE.message()):
225
239
  super().__init__(message, StatusCode.UNAVAILABLE)
226
240
 
227
241
 
242
+ class OutcomePendingError(ServerError):
243
+ """The write committed but hasn't applied: its shard halted at or before
244
+ it (reason SHARD_HALTED, ran UNKNOWN). The message says whether it
245
+ applies when the shard resumes. Never retried: resending it would write
246
+ it twice, or halt again."""
247
+
248
+ def __init__(self, message: str = StatusCode.OUTCOME_PENDING.message()):
249
+ super().__init__(message, StatusCode.OUTCOME_PENDING)
250
+
251
+
228
252
  class InternalServerError(ServerError):
229
253
  """Internal server error. Not retryable: the server uses it for a write
230
254
  that committed but wasn't applied, which must not be resent."""
@@ -292,6 +316,7 @@ def _error_for(status: StatusCode, message: str) -> ServerError:
292
316
  StatusCode.UNAUTHORIZED: UnauthorizedError,
293
317
  StatusCode.OVERLOADED: OverloadedError,
294
318
  StatusCode.UNAVAILABLE: UnavailableError,
319
+ StatusCode.OUTCOME_PENDING: OutcomePendingError,
295
320
  StatusCode.RATE_LIMITED: RateLimitedError,
296
321
  StatusCode.INTERNAL_ERROR: InternalServerError,
297
322
  StatusCode.ERROR_GENERIC: GenericServerError,
flo/kv.py CHANGED
@@ -6,7 +6,12 @@ Key-value store operations for Flo client.
6
6
  import json
7
7
  from typing import TYPE_CHECKING, Any
8
8
 
9
- from .exceptions import IncompleteResponseError, ServerError, raise_for_status
9
+ from .exceptions import (
10
+ IncompleteResponseError,
11
+ ServerError,
12
+ StaleNotFoundError,
13
+ raise_for_status,
14
+ )
10
15
  from .kv_body import (
11
16
  decode_delete_answer,
12
17
  decode_empty_answer,
@@ -36,10 +41,12 @@ from .kv_body import (
36
41
  )
37
42
  from .types import (
38
43
  DeleteOptions,
44
+ ExistsResult,
39
45
  GetJsonResult,
40
46
  GetOptions,
41
47
  GetResult,
42
48
  HistoryOptions,
49
+ HistoryResult,
43
50
  IncrResult,
44
51
  KVExistsOptions,
45
52
  KVExpireOptions,
@@ -53,7 +60,6 @@ from .types import (
53
60
  Reason,
54
61
  ScanOptions,
55
62
  ScanResult,
56
- VersionEntry,
57
63
  )
58
64
  from .wire import RawResponse
59
65
 
@@ -66,17 +72,24 @@ def _key(key: str | bytes) -> bytes:
66
72
  return key.encode("utf-8") if isinstance(key, str) else key
67
73
 
68
74
 
69
- def _absent(response: RawResponse) -> bool:
75
+ def _absent(response: RawResponse, *, stale_raises: bool = False) -> bool:
70
76
  """Whether a not-found answer says the thing is absent; any other not-found
71
- (no such transaction) is raised."""
77
+ (no such transaction) is raised. With stale_raises, an absent key on a
78
+ stale answer raises StaleNotFoundError: absent as of the halt is not
79
+ absent."""
72
80
  if not response.is_not_found():
73
81
  return False
74
82
  try:
75
83
  raise_for_status(response.status, response.data)
76
84
  except ServerError as e:
77
- if e.reason == Reason.NOT_FOUND:
78
- return True
79
- raise
85
+ e.stale = response.stale
86
+ if e.reason != Reason.NOT_FOUND:
87
+ raise
88
+ if stale_raises and response.stale:
89
+ stale = StaleNotFoundError(str(e))
90
+ stale.reason, stale.ran = e.reason, e.ran
91
+ raise stale from None
92
+ return True
80
93
  return False # pragma: no cover - raise_for_status always raises here
81
94
 
82
95
 
@@ -87,9 +100,11 @@ async def kv_get(
87
100
  response = await client._send_and_check(
88
101
  OpCode.KV_GET, namespace, _key(key), encode_get_body(opts, txn), allow_not_found=True
89
102
  )
90
- if _absent(response):
103
+ if _absent(response, stale_raises=True):
91
104
  return None
92
- return decode_get_answer(response.data)
105
+ result = decode_get_answer(response.data)
106
+ result.stale = response.stale
107
+ return result
93
108
 
94
109
 
95
110
  async def kv_set(
@@ -163,12 +178,14 @@ async def kv_exists(
163
178
  key: str | bytes,
164
179
  opts: KVExistsOptions,
165
180
  txn: int | None = None,
166
- ) -> bool:
181
+ ) -> ExistsResult:
167
182
  """kv.exists, inside transaction ``txn`` when given."""
168
183
  response = await client._send_and_check(
169
184
  OpCode.KV_EXISTS, namespace, _key(key), encode_exists_body(opts.routing_key, txn)
170
185
  )
171
- return decode_exists_answer(response.data)[0]
186
+ result = decode_exists_answer(response.data)
187
+ result.stale = response.stale
188
+ return result
172
189
 
173
190
 
174
191
  class KVOperations:
@@ -184,8 +201,14 @@ class KVOperations:
184
201
  ) -> GetResult | None:
185
202
  """Get value and version for a key.
186
203
 
187
- Returns ``GetResult(value, version)``, or ``None`` if the key is not
188
- found, also when a wait (``wait_ms``) ends without it.
204
+ Returns ``GetResult(value, version, expires_at_ms)``, or ``None`` if
205
+ the key is not found, also when a wait (``wait_ms``) ends without it.
206
+ ``expires_at_ms`` is when the key's TTL runs out (unix ms), ``None``
207
+ when it has none. ``stale`` says a halted shard answered.
208
+
209
+ Raises:
210
+ StaleNotFoundError: A halted shard had no such key as of the entry
211
+ it halted at; it may exist since.
189
212
 
190
213
  Example:
191
214
  result = await client.kv.get("user:123")
@@ -281,6 +304,8 @@ class KVOperations:
281
304
  )
282
305
  if next_index == 0:
283
306
  raise IncompleteResponseError("kv.mget answered none of the keys asked")
307
+ for e in entries:
308
+ e.stale = response.stale
284
309
  out.extend(entries)
285
310
  done += len(entries)
286
311
  return out
@@ -293,13 +318,14 @@ class KVOperations:
293
318
  """List the names of keys with a prefix ("" for all), a page at a time.
294
319
 
295
320
  Example:
296
- result = await client.kv.scan("user:")
297
- for name in result.names:
298
- print(name)
299
-
300
- # Next pages
301
- while result.cursor is not None:
302
- result = await client.kv.scan("user:", ScanOptions(cursor=result.cursor))
321
+ opts = ScanOptions()
322
+ while True:
323
+ result = await client.kv.scan("user:", opts)
324
+ for name in result.names:
325
+ print(name)
326
+ if result.cursor is None or not result.names:
327
+ break
328
+ opts = ScanOptions(cursor=result.cursor)
303
329
  """
304
330
  opts = options or ScanOptions()
305
331
  response = await self._client._send_and_check(
@@ -309,31 +335,41 @@ class KVOperations:
309
335
  encode_scan_body(opts.limit, opts.cursor),
310
336
  )
311
337
  names, cursor = decode_scan_answer(response.data)
312
- return ScanResult(names=names, cursor=cursor)
338
+ return ScanResult(names=names, cursor=cursor, stale=response.stale)
313
339
 
314
340
  async def history(
315
341
  self,
316
342
  key: str | bytes,
317
343
  options: HistoryOptions | None = None,
318
- ) -> list[VersionEntry]:
319
- """A key's versions, newest first, each with when it was written; empty
320
- for a missing key.
344
+ ) -> HistoryResult:
345
+ """A page of a key's versions, newest first, each with when it was
346
+ written and whether it was a delete (``deleted``, with an empty
347
+ value); empty for a missing key. ``more`` says older kept versions
348
+ follow: ask again with ``before_version`` set to the last version of
349
+ this page.
321
350
 
322
351
  Example:
323
- for entry in await client.kv.history("user:123", HistoryOptions(limit=10)):
324
- print(f"v{entry.version} at {entry.at_ms}: {entry.value}")
352
+ opts = HistoryOptions(limit=10)
353
+ while True:
354
+ page = await client.kv.history("user:123", opts)
355
+ for entry in page.versions:
356
+ print(f"v{entry.version} at {entry.at_ms}: {entry.value}")
357
+ if not page.more or not page.versions:
358
+ break
359
+ opts = HistoryOptions(limit=10, before_version=page.versions[-1].version)
325
360
  """
326
361
  opts = options or HistoryOptions()
327
362
  response = await self._client._send_and_check(
328
363
  OpCode.KV_HISTORY,
329
364
  self._client.get_namespace(opts.namespace),
330
365
  _key(key),
331
- encode_history_body(opts.limit, opts.routing_key),
366
+ encode_history_body(opts.limit, opts.before_version, opts.routing_key),
332
367
  allow_not_found=True,
333
368
  )
334
369
  if _absent(response):
335
- return []
336
- return decode_history_answer(response.data)
370
+ return HistoryResult(versions=[], more=False, stale=response.stale)
371
+ versions, more = decode_history_answer(response.data)
372
+ return HistoryResult(versions=versions, more=more, stale=response.stale)
337
373
 
338
374
  # ── Counters, TTL, exists, JSON paths ──────────────────────────────
339
375
 
@@ -373,8 +409,8 @@ class KVOperations:
373
409
  self,
374
410
  key: str | bytes,
375
411
  options: KVExistsOptions | None = None,
376
- ) -> bool:
377
- """Return ``True`` if ``key`` is present without transferring its value."""
412
+ ) -> ExistsResult:
413
+ """Whether ``key`` is present, and its version, without transferring its value."""
378
414
  opts = options or KVExistsOptions()
379
415
  return await kv_exists(self._client, self._client.get_namespace(opts.namespace), key, opts)
380
416
 
@@ -388,7 +424,8 @@ class KVOperations:
388
424
 
389
425
  Returns a :class:`GetJsonResult` whose ``value`` is the parsed JSON,
390
426
  with the document's current version, or ``None`` if the key or path
391
- is missing.
427
+ is missing. A missing key on a halted shard's answer raises
428
+ :class:`StaleNotFoundError` instead.
392
429
  """
393
430
  opts = options or KVJsonOptions()
394
431
  response = await self._client._send_and_check(
@@ -398,10 +435,12 @@ class KVOperations:
398
435
  encode_json_get_body(path, opts.routing_key),
399
436
  allow_not_found=True,
400
437
  )
401
- if _absent(response):
438
+ if _absent(response, stale_raises=True):
402
439
  return None
403
440
  version, document = decode_json_get_answer(response.data)
404
- return GetJsonResult(value=json.loads(document.decode("utf-8")), version=version)
441
+ return GetJsonResult(
442
+ value=json.loads(document.decode("utf-8")), version=version, stale=response.stale
443
+ )
405
444
 
406
445
  async def json_set(
407
446
  self,