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 +15 -1
- flo/actions.py +55 -3
- flo/client.py +13 -6
- flo/exceptions.py +27 -2
- flo/kv.py +73 -34
- flo/kv_body.py +82 -38
- flo/kv_txn.py +2 -1
- flo/processing.py +27 -24
- flo/retry.py +25 -3
- flo/types.py +89 -2
- flo/wire.py +42 -30
- flo/worker.py +606 -226
- {flo_python-0.1.2.dist-info → flo_python-0.1.3.dist-info}/METADATA +95 -18
- flo_python-0.1.3.dist-info/RECORD +19 -0
- {flo_python-0.1.2.dist-info → flo_python-0.1.3.dist-info}/WHEEL +1 -1
- flo_python-0.1.2.dist-info/RECORD +0 -19
- {flo_python-0.1.2.dist-info → flo_python-0.1.3.dist-info}/licenses/LICENSE +0 -0
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.
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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 =
|
|
410
|
+
computed_crc = compute_crc32c(header_data, response_data)
|
|
410
411
|
if computed_crc != crc:
|
|
411
412
|
raise InvalidChecksumError(
|
|
412
|
-
f"
|
|
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(
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
) ->
|
|
319
|
-
"""A key's versions, newest first, each with when it was
|
|
320
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
) ->
|
|
377
|
-
"""
|
|
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(
|
|
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,
|