flo-python 0.1.0.dev3__py3-none-any.whl → 0.1.1__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 +31 -1
- flo/client.py +37 -3
- flo/kv.py +360 -35
- flo/kv_txn.py +340 -0
- flo/streams.py +116 -5
- flo/types.py +169 -6
- flo/wire.py +179 -5
- flo/worker.py +136 -12
- {flo_python-0.1.0.dev3.dist-info → flo_python-0.1.1.dist-info}/METADATA +3 -3
- flo_python-0.1.1.dist-info/RECORD +17 -0
- {flo_python-0.1.0.dev3.dist-info → flo_python-0.1.1.dist-info}/WHEEL +1 -1
- flo_python-0.1.0.dev3.dist-info/RECORD +0 -16
- {flo_python-0.1.0.dev3.dist-info → flo_python-0.1.1.dist-info}/licenses/LICENSE +0 -0
flo/__init__.py
CHANGED
|
@@ -60,6 +60,7 @@ from .exceptions import (
|
|
|
60
60
|
ValueTooLargeError,
|
|
61
61
|
is_connection_error,
|
|
62
62
|
)
|
|
63
|
+
from .kv_txn import Transaction, TxnFinishedError, TxnUnsupportedOpError
|
|
63
64
|
from .processing import ProcessingOperations
|
|
64
65
|
from .types import (
|
|
65
66
|
AckOptions,
|
|
@@ -79,14 +80,25 @@ from .types import (
|
|
|
79
80
|
DlqListOptions,
|
|
80
81
|
DlqRequeueOptions,
|
|
81
82
|
EnqueueOptions,
|
|
83
|
+
GetJsonResult,
|
|
82
84
|
GetOptions,
|
|
85
|
+
GetResult,
|
|
83
86
|
HistoryOptions,
|
|
87
|
+
KVBeginResult,
|
|
88
|
+
KVCommitResult,
|
|
84
89
|
KVEntry,
|
|
90
|
+
KVExistsOptions,
|
|
91
|
+
KVIncrOptions,
|
|
92
|
+
KVJsonOptions,
|
|
93
|
+
KVMGetOptions,
|
|
94
|
+
KVTouchOptions,
|
|
85
95
|
Message,
|
|
96
|
+
MGetEntry,
|
|
86
97
|
NackOptions,
|
|
87
98
|
OpCode,
|
|
88
99
|
OptionTag,
|
|
89
100
|
PeekOptions,
|
|
101
|
+
PendingEntry,
|
|
90
102
|
ProcessingCancelOptions,
|
|
91
103
|
ProcessingListEntry,
|
|
92
104
|
ProcessingListOptions,
|
|
@@ -100,12 +112,14 @@ from .types import (
|
|
|
100
112
|
ProcessingSyncOptions,
|
|
101
113
|
ProcessingSyncResult,
|
|
102
114
|
PutOptions,
|
|
115
|
+
PutResult,
|
|
103
116
|
ScanOptions,
|
|
104
117
|
ScanResult,
|
|
105
118
|
StatusCode,
|
|
106
119
|
StorageTier,
|
|
107
120
|
StreamAppendOptions,
|
|
108
121
|
StreamAppendResult,
|
|
122
|
+
StreamClaimResult,
|
|
109
123
|
StreamGroupAckOptions,
|
|
110
124
|
StreamGroupJoinOptions,
|
|
111
125
|
StreamGroupNackOptions,
|
|
@@ -156,9 +170,23 @@ from .worker import (
|
|
|
156
170
|
)
|
|
157
171
|
from .workflows import WorkflowOperations
|
|
158
172
|
|
|
159
|
-
__version__ = "0.1.
|
|
173
|
+
__version__ = "0.1.1"
|
|
160
174
|
|
|
161
175
|
__all__ = [
|
|
176
|
+
"Transaction",
|
|
177
|
+
"TxnFinishedError",
|
|
178
|
+
"TxnUnsupportedOpError",
|
|
179
|
+
"KVBeginResult",
|
|
180
|
+
"KVCommitResult",
|
|
181
|
+
"KVExistsOptions",
|
|
182
|
+
"KVIncrOptions",
|
|
183
|
+
"KVJsonOptions",
|
|
184
|
+
"KVMGetOptions",
|
|
185
|
+
"KVTouchOptions",
|
|
186
|
+
"GetResult",
|
|
187
|
+
"GetJsonResult",
|
|
188
|
+
"MGetEntry",
|
|
189
|
+
"PutResult",
|
|
162
190
|
# Client
|
|
163
191
|
"FloClient",
|
|
164
192
|
# High-level Worker API
|
|
@@ -229,6 +257,8 @@ __all__ = [
|
|
|
229
257
|
"StreamRecord",
|
|
230
258
|
"StreamAppendResult",
|
|
231
259
|
"StreamReadResult",
|
|
260
|
+
"StreamClaimResult",
|
|
261
|
+
"PendingEntry",
|
|
232
262
|
"StreamInfo",
|
|
233
263
|
# Stream options
|
|
234
264
|
"StreamAppendOptions",
|
flo/client.py
CHANGED
|
@@ -83,7 +83,27 @@ class FloClient:
|
|
|
83
83
|
|
|
84
84
|
@staticmethod
|
|
85
85
|
def _parse_endpoint(endpoint: str) -> tuple[str, int]:
|
|
86
|
-
"""Parse endpoint string into host and port.
|
|
86
|
+
"""Parse endpoint string into host and port.
|
|
87
|
+
|
|
88
|
+
Accepts an optional URI scheme: ``flo://`` (plaintext) is stripped and
|
|
89
|
+
bare ``host:port`` remains valid. ``flos://`` (TLS) is reserved but not
|
|
90
|
+
yet implemented, and any other scheme is rejected so it fails here
|
|
91
|
+
rather than as a confusing DNS error at connect time.
|
|
92
|
+
"""
|
|
93
|
+
if "://" in endpoint:
|
|
94
|
+
scheme, _, rest = endpoint.partition("://")
|
|
95
|
+
scheme = scheme.lower()
|
|
96
|
+
if scheme == "flo":
|
|
97
|
+
# Drop scheme and any trailing path/query (reserved for future use).
|
|
98
|
+
endpoint = rest.split("/", 1)[0]
|
|
99
|
+
elif scheme == "flos":
|
|
100
|
+
raise InvalidEndpointError(f"flos:// (TLS) is not yet supported: {endpoint}")
|
|
101
|
+
else:
|
|
102
|
+
raise InvalidEndpointError(
|
|
103
|
+
f"Unsupported scheme {scheme}:// (expected flo:// or bare "
|
|
104
|
+
f"host:port): {endpoint}"
|
|
105
|
+
)
|
|
106
|
+
|
|
87
107
|
parts = endpoint.rsplit(":", 1)
|
|
88
108
|
if len(parts) != 2:
|
|
89
109
|
raise InvalidEndpointError(f"Invalid endpoint format: {endpoint} (expected host:port)")
|
|
@@ -372,11 +392,13 @@ class FloClient:
|
|
|
372
392
|
Returns:
|
|
373
393
|
A new ActionWorker instance ready to register actions and start.
|
|
374
394
|
|
|
375
|
-
Example
|
|
395
|
+
Example::
|
|
396
|
+
|
|
376
397
|
async with FloClient("localhost:3000", namespace="myapp") as client:
|
|
377
398
|
worker = client.new_action_worker(concurrency=5)
|
|
378
399
|
worker.register_action("process-order", process_order)
|
|
379
|
-
|
|
400
|
+
async with worker:
|
|
401
|
+
await worker.start()
|
|
380
402
|
"""
|
|
381
403
|
from .worker import ActionWorker
|
|
382
404
|
|
|
@@ -400,6 +422,8 @@ class FloClient:
|
|
|
400
422
|
batch_size: int = 10,
|
|
401
423
|
block_ms: int = 30000,
|
|
402
424
|
message_timeout: float = 300.0,
|
|
425
|
+
redeliver_pending_on_reconnect: bool = True,
|
|
426
|
+
claim_min_idle_ms: int = 0,
|
|
403
427
|
) -> "StreamWorker":
|
|
404
428
|
"""Create a new StreamWorker from this client.
|
|
405
429
|
|
|
@@ -417,6 +441,14 @@ class FloClient:
|
|
|
417
441
|
batch_size: Number of records to fetch per poll.
|
|
418
442
|
block_ms: Timeout for blocking read in milliseconds.
|
|
419
443
|
message_timeout: Timeout for record handlers in seconds.
|
|
444
|
+
redeliver_pending_on_reconnect: Drain this consumer's pending
|
|
445
|
+
(delivered-but-unacked) entries via group_claim after a
|
|
446
|
+
reconnect, before resuming normal reads. Defaults to True
|
|
447
|
+
(required for at-least-once across reconnects). Set False for
|
|
448
|
+
at-most-once.
|
|
449
|
+
claim_min_idle_ms: Minimum idle time (ms) before the reconnect drain
|
|
450
|
+
claims an entry. 0 drains everything this consumer owns; > 0
|
|
451
|
+
also steals entries abandoned by dead consumers.
|
|
420
452
|
|
|
421
453
|
Returns:
|
|
422
454
|
A new StreamWorker instance ready to start.
|
|
@@ -444,4 +476,6 @@ class FloClient:
|
|
|
444
476
|
batch_size=batch_size,
|
|
445
477
|
block_ms=block_ms,
|
|
446
478
|
message_timeout=message_timeout,
|
|
479
|
+
redeliver_pending_on_reconnect=redeliver_pending_on_reconnect,
|
|
480
|
+
claim_min_idle_ms=claim_min_idle_ms,
|
|
447
481
|
)
|
flo/kv.py
CHANGED
|
@@ -3,16 +3,26 @@
|
|
|
3
3
|
Key-value store operations for Flo client.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import json
|
|
6
7
|
import struct
|
|
7
|
-
from typing import TYPE_CHECKING
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
8
9
|
|
|
9
10
|
from .types import (
|
|
10
11
|
DeleteOptions,
|
|
12
|
+
GetJsonResult,
|
|
11
13
|
GetOptions,
|
|
14
|
+
GetResult,
|
|
12
15
|
HistoryOptions,
|
|
16
|
+
KVExistsOptions,
|
|
17
|
+
KVIncrOptions,
|
|
18
|
+
KVJsonOptions,
|
|
19
|
+
KVMGetOptions,
|
|
20
|
+
KVTouchOptions,
|
|
21
|
+
MGetEntry,
|
|
13
22
|
OpCode,
|
|
14
23
|
OptionTag,
|
|
15
24
|
PutOptions,
|
|
25
|
+
PutResult,
|
|
16
26
|
ScanOptions,
|
|
17
27
|
ScanResult,
|
|
18
28
|
VersionEntry,
|
|
@@ -21,6 +31,7 @@ from .wire import OptionsBuilder, parse_history_response, parse_scan_response
|
|
|
21
31
|
|
|
22
32
|
if TYPE_CHECKING:
|
|
23
33
|
from .client import FloClient
|
|
34
|
+
from .kv_txn import Transaction
|
|
24
35
|
|
|
25
36
|
|
|
26
37
|
class KVOperations:
|
|
@@ -33,36 +44,30 @@ class KVOperations:
|
|
|
33
44
|
self,
|
|
34
45
|
key: str | bytes,
|
|
35
46
|
options: GetOptions | None = None,
|
|
36
|
-
) ->
|
|
37
|
-
"""Get value for a key.
|
|
47
|
+
) -> GetResult | None:
|
|
48
|
+
"""Get value and version for a key.
|
|
38
49
|
|
|
39
50
|
Args:
|
|
40
51
|
key: The key to retrieve.
|
|
41
52
|
options: Optional operation options (block_ms for blocking get).
|
|
42
53
|
|
|
43
54
|
Returns:
|
|
44
|
-
|
|
55
|
+
``GetResult(value, version)`` if found, ``None`` if not found.
|
|
45
56
|
|
|
46
57
|
Example:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
print(f"Found: {value.decode()}")
|
|
58
|
+
result = await client.kv.get("user:123")
|
|
59
|
+
if result is not None:
|
|
60
|
+
print(f"Found {result.value!r} at version {result.version}")
|
|
51
61
|
|
|
52
|
-
# Blocking get
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
# Blocking get - wait indefinitely (0 = infinite)
|
|
56
|
-
value = await client.kv.get("key", GetOptions(block_ms=0))
|
|
62
|
+
# Blocking get — wait up to 5 seconds
|
|
63
|
+
result = await client.kv.get("key", GetOptions(block_ms=5000))
|
|
57
64
|
"""
|
|
58
65
|
opts = options or GetOptions()
|
|
59
66
|
namespace = self._client.get_namespace(opts.namespace)
|
|
60
67
|
|
|
61
68
|
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
62
69
|
|
|
63
|
-
# Build TLV options
|
|
64
70
|
builder = OptionsBuilder()
|
|
65
|
-
|
|
66
71
|
if opts.block_ms is not None:
|
|
67
72
|
builder.add_u32(OptionTag.BLOCK_MS, opts.block_ms)
|
|
68
73
|
|
|
@@ -78,52 +83,51 @@ class KVOperations:
|
|
|
78
83
|
if response.is_not_found():
|
|
79
84
|
return None
|
|
80
85
|
|
|
81
|
-
|
|
86
|
+
# Wire body: [version:u64 LE][value bytes]
|
|
87
|
+
if not response.data or len(response.data) < 8:
|
|
88
|
+
return GetResult(value=b"", version=0)
|
|
89
|
+
version = struct.unpack("<Q", response.data[:8])[0]
|
|
90
|
+
return GetResult(value=bytes(response.data[8:]), version=version)
|
|
82
91
|
|
|
83
92
|
async def put(
|
|
84
93
|
self,
|
|
85
94
|
key: str | bytes,
|
|
86
95
|
value: bytes,
|
|
87
96
|
options: PutOptions | None = None,
|
|
88
|
-
) ->
|
|
89
|
-
"""Set a key-value pair.
|
|
97
|
+
) -> PutResult:
|
|
98
|
+
"""Set a key-value pair and return the new version.
|
|
90
99
|
|
|
91
100
|
Args:
|
|
92
101
|
key: The key to set.
|
|
93
102
|
value: The value bytes.
|
|
94
103
|
options: Optional operation options (TTL, CAS, etc.).
|
|
95
104
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
# Put with TTL
|
|
101
|
-
await client.kv.put("session:abc", b"data", PutOptions(ttl_seconds=3600))
|
|
105
|
+
Returns:
|
|
106
|
+
``PutResult(version)`` — the version assigned by the server,
|
|
107
|
+
usable for CAS on the next write.
|
|
102
108
|
|
|
103
|
-
|
|
104
|
-
await client.kv.put("
|
|
109
|
+
Example:
|
|
110
|
+
res = await client.kv.put("user:123", b"John Doe")
|
|
111
|
+
# CAS the next write against this version:
|
|
112
|
+
await client.kv.put("user:123", b"Jane",
|
|
113
|
+
PutOptions(cas_version=res.version))
|
|
105
114
|
"""
|
|
106
115
|
opts = options or PutOptions()
|
|
107
116
|
namespace = self._client.get_namespace(opts.namespace)
|
|
108
117
|
|
|
109
118
|
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
110
119
|
|
|
111
|
-
# Build TLV options
|
|
112
120
|
builder = OptionsBuilder()
|
|
113
|
-
|
|
114
121
|
if opts.ttl_seconds is not None:
|
|
115
122
|
builder.add_u64(OptionTag.TTL_SECONDS, opts.ttl_seconds)
|
|
116
|
-
|
|
117
123
|
if opts.cas_version is not None:
|
|
118
124
|
builder.add_u64(OptionTag.CAS_VERSION, opts.cas_version)
|
|
119
|
-
|
|
120
125
|
if opts.if_not_exists:
|
|
121
126
|
builder.add_flag(OptionTag.IF_NOT_EXISTS)
|
|
122
|
-
|
|
123
127
|
if opts.if_exists:
|
|
124
128
|
builder.add_flag(OptionTag.IF_EXISTS)
|
|
125
129
|
|
|
126
|
-
await self._client._send_and_check(
|
|
130
|
+
response = await self._client._send_and_check(
|
|
127
131
|
OpCode.KV_PUT,
|
|
128
132
|
namespace,
|
|
129
133
|
key_bytes,
|
|
@@ -131,6 +135,11 @@ class KVOperations:
|
|
|
131
135
|
builder.build(),
|
|
132
136
|
)
|
|
133
137
|
|
|
138
|
+
# Wire body: [version:u64 LE]
|
|
139
|
+
if not response.data or len(response.data) < 8:
|
|
140
|
+
return PutResult(version=0)
|
|
141
|
+
return PutResult(version=struct.unpack("<Q", response.data[:8])[0])
|
|
142
|
+
|
|
134
143
|
async def delete(
|
|
135
144
|
self,
|
|
136
145
|
key: str | bytes,
|
|
@@ -143,25 +152,117 @@ class KVOperations:
|
|
|
143
152
|
options: Optional operation options.
|
|
144
153
|
|
|
145
154
|
Note:
|
|
146
|
-
This operation succeeds even if the key doesn't exist
|
|
155
|
+
This operation succeeds even if the key doesn't exist (unless
|
|
156
|
+
``if_match`` is set, in which case a missing key is treated as a
|
|
157
|
+
CAS mismatch).
|
|
147
158
|
|
|
148
159
|
Example:
|
|
149
160
|
await client.kv.delete("user:123")
|
|
161
|
+
# CAS-protected release — only the owner deletes:
|
|
162
|
+
await client.kv.delete("lock:resource",
|
|
163
|
+
DeleteOptions(if_match=tag_version))
|
|
150
164
|
"""
|
|
151
165
|
opts = options or DeleteOptions()
|
|
152
166
|
namespace = self._client.get_namespace(opts.namespace)
|
|
153
167
|
|
|
154
168
|
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
155
169
|
|
|
156
|
-
|
|
170
|
+
builder = OptionsBuilder()
|
|
171
|
+
if opts.if_match is not None:
|
|
172
|
+
builder.add_u64(OptionTag.CAS_VERSION, opts.if_match)
|
|
173
|
+
|
|
174
|
+
# Delete succeeds for both OK and NOT_FOUND (when if_match is unset).
|
|
157
175
|
await self._client._send_and_check(
|
|
158
176
|
OpCode.KV_DELETE,
|
|
159
177
|
namespace,
|
|
160
178
|
key_bytes,
|
|
161
179
|
b"",
|
|
162
|
-
|
|
180
|
+
builder.build(),
|
|
181
|
+
allow_not_found=opts.if_match is None,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def mget(
|
|
185
|
+
self,
|
|
186
|
+
keys: list[str | bytes],
|
|
187
|
+
options: KVMGetOptions | None = None,
|
|
188
|
+
) -> list[MGetEntry]:
|
|
189
|
+
"""Look up many keys in a single round trip.
|
|
190
|
+
|
|
191
|
+
Keys may live on different shards — the server gathers results in
|
|
192
|
+
parallel and returns one entry per requested key in the same order.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
keys: Keys to fetch (max 256). Empty list returns ``[]``.
|
|
196
|
+
options: Optional namespace override.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
List of :class:`MGetEntry`. ``found=False`` indicates a missing key.
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
for e in await client.kv.mget(["user:1", "user:2", "user:3"]):
|
|
203
|
+
if e.found:
|
|
204
|
+
print(e.key, "@v", e.version, ":", e.value)
|
|
205
|
+
"""
|
|
206
|
+
if not keys:
|
|
207
|
+
return []
|
|
208
|
+
if len(keys) > 256:
|
|
209
|
+
raise ValueError("mget: too many keys (max 256)")
|
|
210
|
+
opts = options or KVMGetOptions()
|
|
211
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
212
|
+
|
|
213
|
+
# Pack request: [count:u16 LE]([key_len:u16 LE][key])*
|
|
214
|
+
encoded: list[bytes] = [(k.encode("utf-8") if isinstance(k, str) else k) for k in keys]
|
|
215
|
+
for k in encoded:
|
|
216
|
+
if len(k) > 0xFFFF:
|
|
217
|
+
raise ValueError("mget: key too long")
|
|
218
|
+
parts: list[bytes] = [struct.pack("<H", len(encoded))]
|
|
219
|
+
for k in encoded:
|
|
220
|
+
parts.append(struct.pack("<H", len(k)))
|
|
221
|
+
parts.append(k)
|
|
222
|
+
value = b"".join(parts)
|
|
223
|
+
|
|
224
|
+
response = await self._client._send_and_check(
|
|
225
|
+
OpCode.KV_MGET,
|
|
226
|
+
namespace,
|
|
227
|
+
b"",
|
|
228
|
+
value,
|
|
163
229
|
)
|
|
164
230
|
|
|
231
|
+
data = bytes(response.data or b"")
|
|
232
|
+
if len(data) < 4:
|
|
233
|
+
return []
|
|
234
|
+
(count,) = struct.unpack("<I", data[:4])
|
|
235
|
+
out: list[MGetEntry] = []
|
|
236
|
+
off = 4
|
|
237
|
+
for _ in range(count):
|
|
238
|
+
if off + 1 + 2 > len(data):
|
|
239
|
+
break
|
|
240
|
+
status = data[off]
|
|
241
|
+
off += 1
|
|
242
|
+
(klen,) = struct.unpack("<H", data[off : off + 2])
|
|
243
|
+
off += 2
|
|
244
|
+
if off + klen + 8 + 4 > len(data):
|
|
245
|
+
break
|
|
246
|
+
key_bytes = data[off : off + klen]
|
|
247
|
+
off += klen
|
|
248
|
+
(version,) = struct.unpack("<Q", data[off : off + 8])
|
|
249
|
+
off += 8
|
|
250
|
+
(vlen,) = struct.unpack("<I", data[off : off + 4])
|
|
251
|
+
off += 4
|
|
252
|
+
if off + vlen > len(data):
|
|
253
|
+
break
|
|
254
|
+
val = data[off : off + vlen]
|
|
255
|
+
off += vlen
|
|
256
|
+
out.append(
|
|
257
|
+
MGetEntry(
|
|
258
|
+
key=key_bytes.decode("utf-8", errors="replace"),
|
|
259
|
+
value=bytes(val),
|
|
260
|
+
version=version,
|
|
261
|
+
found=status == 0,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
return out
|
|
265
|
+
|
|
165
266
|
async def scan(
|
|
166
267
|
self,
|
|
167
268
|
prefix: str | bytes,
|
|
@@ -255,3 +356,227 @@ class KVOperations:
|
|
|
255
356
|
)
|
|
256
357
|
|
|
257
358
|
return parse_history_response(response.data)
|
|
359
|
+
|
|
360
|
+
# ── Extended ops: counters, TTL lifecycle, exists, JSON paths ──────
|
|
361
|
+
|
|
362
|
+
async def incr(
|
|
363
|
+
self,
|
|
364
|
+
key: str | bytes,
|
|
365
|
+
options: KVIncrOptions | None = None,
|
|
366
|
+
) -> int:
|
|
367
|
+
"""Atomically add ``delta`` (default +1) to the i64 counter at ``key``.
|
|
368
|
+
|
|
369
|
+
The first ``incr`` on a missing key creates it at the delta value.
|
|
370
|
+
Raises a server error if the key already holds a non-counter value.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
The new counter value as a Python int.
|
|
374
|
+
"""
|
|
375
|
+
opts = options or KVIncrOptions()
|
|
376
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
377
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
378
|
+
|
|
379
|
+
value = b""
|
|
380
|
+
if opts.delta is not None:
|
|
381
|
+
value = struct.pack("<q", opts.delta)
|
|
382
|
+
|
|
383
|
+
response = await self._client._send_and_check(
|
|
384
|
+
OpCode.KV_INCR,
|
|
385
|
+
namespace,
|
|
386
|
+
key_bytes,
|
|
387
|
+
value,
|
|
388
|
+
b"",
|
|
389
|
+
)
|
|
390
|
+
# Wire body: [version:u64][counter:i64 LE]
|
|
391
|
+
if not response.data or len(response.data) < 16:
|
|
392
|
+
raise ValueError("incr: short response")
|
|
393
|
+
return int(struct.unpack("<q", response.data[8:16])[0])
|
|
394
|
+
|
|
395
|
+
async def touch(
|
|
396
|
+
self,
|
|
397
|
+
key: str | bytes,
|
|
398
|
+
ttl_seconds: int,
|
|
399
|
+
options: KVTouchOptions | None = None,
|
|
400
|
+
) -> None:
|
|
401
|
+
"""Update the TTL on an existing key. ``ttl_seconds=0`` clears the TTL.
|
|
402
|
+
|
|
403
|
+
When ``options.if_match`` is set, the touch only succeeds if the
|
|
404
|
+
current key version equals it — enabling race-free lease renewal.
|
|
405
|
+
"""
|
|
406
|
+
opts = options or KVTouchOptions()
|
|
407
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
408
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
409
|
+
builder = OptionsBuilder()
|
|
410
|
+
if opts.if_match is not None:
|
|
411
|
+
builder.add_u64(OptionTag.CAS_VERSION, opts.if_match)
|
|
412
|
+
await self._client._send_and_check(
|
|
413
|
+
OpCode.KV_TOUCH,
|
|
414
|
+
namespace,
|
|
415
|
+
key_bytes,
|
|
416
|
+
struct.pack("<Q", ttl_seconds),
|
|
417
|
+
builder.build(),
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
async def persist(
|
|
421
|
+
self,
|
|
422
|
+
key: str | bytes,
|
|
423
|
+
options: KVTouchOptions | None = None,
|
|
424
|
+
) -> None:
|
|
425
|
+
"""Clear the TTL on an existing key, making it permanent.
|
|
426
|
+
|
|
427
|
+
When ``options.if_match`` is set, the persist only succeeds if the
|
|
428
|
+
current key version equals it.
|
|
429
|
+
"""
|
|
430
|
+
opts = options or KVTouchOptions()
|
|
431
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
432
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
433
|
+
builder = OptionsBuilder()
|
|
434
|
+
if opts.if_match is not None:
|
|
435
|
+
builder.add_u64(OptionTag.CAS_VERSION, opts.if_match)
|
|
436
|
+
await self._client._send_and_check(
|
|
437
|
+
OpCode.KV_PERSIST,
|
|
438
|
+
namespace,
|
|
439
|
+
key_bytes,
|
|
440
|
+
b"",
|
|
441
|
+
builder.build(),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
async def exists(
|
|
445
|
+
self,
|
|
446
|
+
key: str | bytes,
|
|
447
|
+
options: KVExistsOptions | None = None,
|
|
448
|
+
) -> bool:
|
|
449
|
+
"""Return ``True`` if ``key`` is present without transferring its value."""
|
|
450
|
+
opts = options or KVExistsOptions()
|
|
451
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
452
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
453
|
+
response = await self._client._send_and_check(
|
|
454
|
+
OpCode.KV_EXISTS,
|
|
455
|
+
namespace,
|
|
456
|
+
key_bytes,
|
|
457
|
+
b"",
|
|
458
|
+
b"",
|
|
459
|
+
)
|
|
460
|
+
# Wire body: [version:u64][1 byte 0/1]
|
|
461
|
+
if not response.data or len(response.data) < 9:
|
|
462
|
+
return False
|
|
463
|
+
return response.data[8] == 1
|
|
464
|
+
|
|
465
|
+
async def json_get(
|
|
466
|
+
self,
|
|
467
|
+
key: str | bytes,
|
|
468
|
+
path: str = "$",
|
|
469
|
+
options: KVJsonOptions | None = None,
|
|
470
|
+
) -> GetJsonResult[Any] | None:
|
|
471
|
+
"""Extract the value at ``path`` from the JSON document at ``key``.
|
|
472
|
+
|
|
473
|
+
Returns a :class:`GetJsonResult` whose ``value`` is the already-parsed
|
|
474
|
+
JSON (the ``KV_JSON_GET`` wire body is always a JSON document, so it is
|
|
475
|
+
decoded for you) together with the document's current version, or
|
|
476
|
+
``None`` if the key or path is missing.
|
|
477
|
+
"""
|
|
478
|
+
opts = options or KVJsonOptions()
|
|
479
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
480
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
481
|
+
path_bytes = path.encode("utf-8") if isinstance(path, str) else path
|
|
482
|
+
|
|
483
|
+
response = await self._client._send_and_check(
|
|
484
|
+
OpCode.KV_JSON_GET,
|
|
485
|
+
namespace,
|
|
486
|
+
key_bytes,
|
|
487
|
+
path_bytes,
|
|
488
|
+
b"",
|
|
489
|
+
allow_not_found=True,
|
|
490
|
+
)
|
|
491
|
+
if response.is_not_found():
|
|
492
|
+
return None
|
|
493
|
+
# Wire body: [version:u64 LE][json bytes]
|
|
494
|
+
if not response.data or len(response.data) < 8:
|
|
495
|
+
return None
|
|
496
|
+
version = int.from_bytes(response.data[:8], "little")
|
|
497
|
+
value = json.loads(bytes(response.data[8:]).decode("utf-8"))
|
|
498
|
+
return GetJsonResult(value=value, version=version)
|
|
499
|
+
|
|
500
|
+
async def json_set(
|
|
501
|
+
self,
|
|
502
|
+
key: str | bytes,
|
|
503
|
+
path: str,
|
|
504
|
+
json_value: bytes,
|
|
505
|
+
options: KVJsonOptions | None = None,
|
|
506
|
+
) -> PutResult:
|
|
507
|
+
"""Set the JSON value at ``path`` inside the document at ``key``.
|
|
508
|
+
|
|
509
|
+
Path ``"$"`` replaces the whole document (and creates the key if missing).
|
|
510
|
+
Sub-paths require the key to already exist. Returns a :class:`PutResult`
|
|
511
|
+
with the new document version.
|
|
512
|
+
"""
|
|
513
|
+
opts = options or KVJsonOptions()
|
|
514
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
515
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
516
|
+
path_str = path or "$"
|
|
517
|
+
path_bytes = path_str.encode("utf-8")
|
|
518
|
+
if len(path_bytes) > 0xFFFF:
|
|
519
|
+
raise ValueError("json_set: path too long")
|
|
520
|
+
value = struct.pack("<H", len(path_bytes)) + path_bytes + json_value
|
|
521
|
+
response = await self._client._send_and_check(
|
|
522
|
+
OpCode.KV_JSON_SET,
|
|
523
|
+
namespace,
|
|
524
|
+
key_bytes,
|
|
525
|
+
value,
|
|
526
|
+
b"",
|
|
527
|
+
)
|
|
528
|
+
version = (
|
|
529
|
+
int.from_bytes(response.data[:8], "little")
|
|
530
|
+
if response.data and len(response.data) >= 8
|
|
531
|
+
else 0
|
|
532
|
+
)
|
|
533
|
+
return PutResult(version=version)
|
|
534
|
+
|
|
535
|
+
async def json_del(
|
|
536
|
+
self,
|
|
537
|
+
key: str | bytes,
|
|
538
|
+
path: str = "$",
|
|
539
|
+
options: KVJsonOptions | None = None,
|
|
540
|
+
) -> PutResult:
|
|
541
|
+
"""Remove the value at ``path`` from the JSON document at ``key``.
|
|
542
|
+
|
|
543
|
+
For sub-paths the returned :class:`PutResult` carries the new document
|
|
544
|
+
version. For ``"$"`` (whole document delete) the version is ``0`` since
|
|
545
|
+
the key is gone.
|
|
546
|
+
"""
|
|
547
|
+
opts = options or KVJsonOptions()
|
|
548
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
549
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
550
|
+
path_bytes = (path or "$").encode("utf-8")
|
|
551
|
+
response = await self._client._send_and_check(
|
|
552
|
+
OpCode.KV_JSON_DEL,
|
|
553
|
+
namespace,
|
|
554
|
+
key_bytes,
|
|
555
|
+
path_bytes,
|
|
556
|
+
b"",
|
|
557
|
+
)
|
|
558
|
+
version = (
|
|
559
|
+
int.from_bytes(response.data[:8], "little")
|
|
560
|
+
if response.data and len(response.data) >= 8
|
|
561
|
+
else 0
|
|
562
|
+
)
|
|
563
|
+
return PutResult(version=version)
|
|
564
|
+
|
|
565
|
+
async def begin(self, routing_key: str) -> "Transaction":
|
|
566
|
+
"""Open a per-shard KV transaction pinned to ``routing_key``'s partition.
|
|
567
|
+
|
|
568
|
+
Every key written or read inside the transaction must hash to the same
|
|
569
|
+
partition; otherwise the server returns a "kv_txn_cross_shard" error.
|
|
570
|
+
|
|
571
|
+
Returns a :class:`Transaction` handle. Use as an async context manager
|
|
572
|
+
for automatic rollback on exception::
|
|
573
|
+
|
|
574
|
+
async with await client.kv.begin("user:123") as txn:
|
|
575
|
+
await txn.put("user:123:name", b"Jane")
|
|
576
|
+
await txn.incr("user:123:visits", 1)
|
|
577
|
+
await txn.commit()
|
|
578
|
+
"""
|
|
579
|
+
from .kv_txn import begin as _begin
|
|
580
|
+
|
|
581
|
+
namespace = self._client.get_namespace(None)
|
|
582
|
+
return await _begin(self._client, namespace, routing_key)
|