idemkit 0.1.0__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.
- idemkit/__init__.py +101 -0
- idemkit/_version.py +1 -0
- idemkit/adapters/__init__.py +10 -0
- idemkit/adapters/ai.py +723 -0
- idemkit/adapters/asgi.py +533 -0
- idemkit/adapters/queue.py +413 -0
- idemkit/adapters/route.py +273 -0
- idemkit/adapters/wsgi.py +393 -0
- idemkit/backends/__init__.py +30 -0
- idemkit/backends/base.py +116 -0
- idemkit/backends/dynamodb.py +489 -0
- idemkit/backends/memory.py +325 -0
- idemkit/backends/mongo.py +395 -0
- idemkit/backends/postgres.py +682 -0
- idemkit/backends/redis.py +547 -0
- idemkit/cli.py +165 -0
- idemkit/conformance/__init__.py +25 -0
- idemkit/conformance/backend.py +257 -0
- idemkit/conformance/report.py +54 -0
- idemkit/contrib/__init__.py +30 -0
- idemkit/contrib/drf.py +181 -0
- idemkit/contrib/fastapi.py +141 -0
- idemkit/contrib/kafka.py +96 -0
- idemkit/contrib/logging.py +61 -0
- idemkit/contrib/mcp.py +113 -0
- idemkit/contrib/prometheus.py +79 -0
- idemkit/contrib/pubsub.py +98 -0
- idemkit/contrib/rabbitmq.py +138 -0
- idemkit/contrib/reconciliation.py +86 -0
- idemkit/contrib/sqs.py +117 -0
- idemkit/core/__init__.py +36 -0
- idemkit/core/codecs.py +229 -0
- idemkit/core/config.py +255 -0
- idemkit/core/engine.py +331 -0
- idemkit/core/events.py +63 -0
- idemkit/core/exception_cache.py +88 -0
- idemkit/core/exceptions.py +79 -0
- idemkit/core/fingerprint.py +153 -0
- idemkit/core/policy.py +143 -0
- idemkit/core/runner.py +664 -0
- idemkit/core/state.py +95 -0
- idemkit/core/sync_bridge.py +115 -0
- idemkit/problem_details.py +119 -0
- idemkit/py.typed +0 -0
- idemkit-0.1.0.dist-info/METADATA +446 -0
- idemkit-0.1.0.dist-info/RECORD +48 -0
- idemkit-0.1.0.dist-info/WHEEL +4 -0
- idemkit-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""Amazon DynamoDB backend for idemkit (spec §4.1).
|
|
2
|
+
|
|
3
|
+
One item per ``effective_key`` (the partition key), the same single-record layout as
|
|
4
|
+
the Postgres and Mongo backends. Correctness rests on DynamoDB's conditional writes:
|
|
5
|
+
``claim`` is an ``UpdateItem`` with ``ConditionExpression`` (create if absent, or
|
|
6
|
+
reclaim if the lease expired) and ``ReturnValues=ALL_OLD`` so it can tell a fresh
|
|
7
|
+
claim from a lease reclaim; ``complete`` / ``release`` / ``renew`` are conditional on
|
|
8
|
+
``claim_token`` so a reclaimed executor is fenced. ``lease_until`` doubles as the
|
|
9
|
+
COMPLETED record's expiry, so an expired record is treated as ABSENT on read.
|
|
10
|
+
|
|
11
|
+
**One difference from the other backends:** DynamoDB has no server clock in a
|
|
12
|
+
condition expression, so lease decisions use the CLIENT clock.
|
|
13
|
+
With NTP-synced hosts this is fine, but unlike Redis/Postgres/Mongo it does not give
|
|
14
|
+
the storage-clock guarantee against a badly skewed app clock (spec §5.7). A DynamoDB
|
|
15
|
+
TTL attribute reaps expired items (best-effort, up to ~48h), so correctness relies on
|
|
16
|
+
expiry-on-read, not on TTL timeliness.
|
|
17
|
+
|
|
18
|
+
Requires the ``dynamodb`` extra: ``pip install 'idemkit[dynamodb]'``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import secrets
|
|
24
|
+
import time
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from idemkit.core.exceptions import StorageError
|
|
28
|
+
from idemkit.core.state import ClaimResult, ClaimResultType, State, StoredRecord
|
|
29
|
+
|
|
30
|
+
DEFAULT_TABLE = "idemkit_records"
|
|
31
|
+
PK = "effective_key"
|
|
32
|
+
|
|
33
|
+
_POLL_INITIAL_SECONDS = 0.05
|
|
34
|
+
_POLL_CAP_SECONDS = 1.0
|
|
35
|
+
_CLAIM_RETRIES = 3
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DynamoBackend:
|
|
39
|
+
"""Amazon DynamoDB idempotency backend."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
table: str = DEFAULT_TABLE,
|
|
45
|
+
endpoint_url: str | None = None,
|
|
46
|
+
region_name: str = "us-east-1",
|
|
47
|
+
create_table: bool = True,
|
|
48
|
+
lease_grace_seconds: float = 60.0,
|
|
49
|
+
connect_timeout: float | None = None,
|
|
50
|
+
read_timeout: float | None = None,
|
|
51
|
+
max_retries: int | None = None,
|
|
52
|
+
**client_kwargs: Any,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Configure the backend (lazy: no connection until first use).
|
|
55
|
+
|
|
56
|
+
``endpoint_url`` targets DynamoDB Local / LocalStack for tests. ``create_table``
|
|
57
|
+
makes the table (on-demand billing, TTL on ``ttl``) on first use if it does not
|
|
58
|
+
exist; set it to ``False`` in production and provision the table out of band so
|
|
59
|
+
the runtime role needs no ``CreateTable`` permission.
|
|
60
|
+
|
|
61
|
+
``lease_grace_seconds`` pads the DynamoDB ``ttl`` attribute past the logical
|
|
62
|
+
expiry (which is enforced on read via ``lease_until``). The ``ttl`` attribute
|
|
63
|
+
has one-second granularity, so without a pad a just-lapsed claim could be
|
|
64
|
+
TTL-reaped before the next attempt reclaims it, turning a ``lease_reclaimed``
|
|
65
|
+
into a ``new`` — the same grace Redis and Mongo apply.
|
|
66
|
+
|
|
67
|
+
``connect_timeout`` (default 5s), ``read_timeout`` (default 10s), and
|
|
68
|
+
``max_retries`` (default 3) fail fast like the other backends instead of
|
|
69
|
+
hanging on botocore's 60s default; each default is applied only if you do not
|
|
70
|
+
pass it. Extra keyword args go to the aioboto3 resource (e.g. credentials);
|
|
71
|
+
advanced callers can pass a full ``config=Config(...)`` (botocore) that
|
|
72
|
+
overrides the three timeout/retry knobs.
|
|
73
|
+
"""
|
|
74
|
+
self._table_name = table
|
|
75
|
+
self._endpoint_url = endpoint_url
|
|
76
|
+
self._region_name = region_name
|
|
77
|
+
self._create_table = create_table
|
|
78
|
+
self._grace_seconds = lease_grace_seconds
|
|
79
|
+
self._connect_timeout = connect_timeout
|
|
80
|
+
self._read_timeout = read_timeout
|
|
81
|
+
self._max_retries = max_retries
|
|
82
|
+
self._client_kwargs = client_kwargs
|
|
83
|
+
self._cm: Any = None
|
|
84
|
+
self._resource: Any = None
|
|
85
|
+
self._table: Any = None
|
|
86
|
+
import asyncio
|
|
87
|
+
|
|
88
|
+
self._init_lock = asyncio.Lock()
|
|
89
|
+
|
|
90
|
+
async def _ensure_table(self) -> Any:
|
|
91
|
+
if self._table is not None:
|
|
92
|
+
return self._table
|
|
93
|
+
async with self._init_lock:
|
|
94
|
+
if self._table is not None:
|
|
95
|
+
return self._table
|
|
96
|
+
try:
|
|
97
|
+
import aioboto3
|
|
98
|
+
except ImportError as e:
|
|
99
|
+
raise ImportError(
|
|
100
|
+
"idemkit: install the `dynamodb` extra: pip install 'idemkit[dynamodb]'"
|
|
101
|
+
) from e
|
|
102
|
+
from botocore.config import Config
|
|
103
|
+
|
|
104
|
+
session = aioboto3.Session()
|
|
105
|
+
# Fail fast (like Redis/Postgres) instead of hanging on the botocore
|
|
106
|
+
# default 60s timeouts; retry transient throttling with backoff. Each
|
|
107
|
+
# flat knob applies its default only if the caller did not set it; a full
|
|
108
|
+
# ``config=`` (advanced) overrides all three.
|
|
109
|
+
client_kwargs = dict(self._client_kwargs)
|
|
110
|
+
config = client_kwargs.pop("config", None)
|
|
111
|
+
if config is None:
|
|
112
|
+
config = Config(
|
|
113
|
+
connect_timeout=self._connect_timeout
|
|
114
|
+
if self._connect_timeout is not None
|
|
115
|
+
else 5,
|
|
116
|
+
read_timeout=self._read_timeout if self._read_timeout is not None else 10,
|
|
117
|
+
retries={
|
|
118
|
+
"max_attempts": self._max_retries if self._max_retries is not None else 3,
|
|
119
|
+
"mode": "standard",
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
self._cm = session.resource(
|
|
123
|
+
"dynamodb",
|
|
124
|
+
endpoint_url=self._endpoint_url,
|
|
125
|
+
region_name=self._region_name,
|
|
126
|
+
config=config,
|
|
127
|
+
**client_kwargs,
|
|
128
|
+
)
|
|
129
|
+
self._resource = await self._cm.__aenter__()
|
|
130
|
+
try:
|
|
131
|
+
if self._create_table:
|
|
132
|
+
await self._ensure_table_exists()
|
|
133
|
+
self._table = await self._resource.Table(self._table_name)
|
|
134
|
+
except Exception:
|
|
135
|
+
# Don't leak the aiohttp session if setup fails; a later call retries.
|
|
136
|
+
await self._cm.__aexit__(None, None, None)
|
|
137
|
+
self._cm = self._resource = self._table = None
|
|
138
|
+
raise
|
|
139
|
+
return self._table
|
|
140
|
+
|
|
141
|
+
async def _ensure_table_exists(self) -> None:
|
|
142
|
+
try:
|
|
143
|
+
table = await self._resource.create_table(
|
|
144
|
+
TableName=self._table_name,
|
|
145
|
+
KeySchema=[{"AttributeName": PK, "KeyType": "HASH"}],
|
|
146
|
+
AttributeDefinitions=[{"AttributeName": PK, "AttributeType": "S"}],
|
|
147
|
+
BillingMode="PAY_PER_REQUEST",
|
|
148
|
+
)
|
|
149
|
+
await table.wait_until_exists()
|
|
150
|
+
client = self._resource.meta.client
|
|
151
|
+
try:
|
|
152
|
+
await client.update_time_to_live(
|
|
153
|
+
TableName=self._table_name,
|
|
154
|
+
TimeToLiveSpecification={"Enabled": True, "AttributeName": "ttl"},
|
|
155
|
+
)
|
|
156
|
+
except Exception:
|
|
157
|
+
pass # TTL best-effort; expiry-on-read is what enforces correctness
|
|
158
|
+
except Exception as e:
|
|
159
|
+
# Already exists (ResourceInUseException) is fine; anything else is fatal.
|
|
160
|
+
if "ResourceInUse" not in type(e).__name__ and "ResourceInUse" not in str(e):
|
|
161
|
+
raise
|
|
162
|
+
|
|
163
|
+
async def aclose(self) -> None:
|
|
164
|
+
"""Close the DynamoDB resource."""
|
|
165
|
+
if self._cm is not None:
|
|
166
|
+
await self._cm.__aexit__(None, None, None)
|
|
167
|
+
self._cm = self._resource = self._table = None
|
|
168
|
+
|
|
169
|
+
async def __aenter__(self) -> DynamoBackend:
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
173
|
+
await self.aclose()
|
|
174
|
+
|
|
175
|
+
# ----- spec §4.1 backend Protocol -----
|
|
176
|
+
|
|
177
|
+
async def claim(
|
|
178
|
+
self,
|
|
179
|
+
effective_key: str,
|
|
180
|
+
fingerprint: str,
|
|
181
|
+
fingerprint_version: int,
|
|
182
|
+
lease_ttl_seconds: float,
|
|
183
|
+
) -> ClaimResult:
|
|
184
|
+
table = await self._ensure_table()
|
|
185
|
+
from botocore.exceptions import ClientError
|
|
186
|
+
|
|
187
|
+
# The condition can fail because a live record is held (return ALREADY_*) or
|
|
188
|
+
# because a concurrent release deleted it in the same instant. In the second
|
|
189
|
+
# case the record is gone, so retry the claim a bounded number of times.
|
|
190
|
+
for _attempt in range(_CLAIM_RETRIES):
|
|
191
|
+
token = secrets.token_hex(16)
|
|
192
|
+
now = time.time()
|
|
193
|
+
lease_until = now + lease_ttl_seconds
|
|
194
|
+
try:
|
|
195
|
+
resp = await table.update_item(
|
|
196
|
+
Key={PK: effective_key},
|
|
197
|
+
UpdateExpression=(
|
|
198
|
+
"SET #s = :claimed, fingerprint = :fp, fingerprint_version = :fpv, "
|
|
199
|
+
"claim_token = :tok, claimed_at = :now, lease_until = :lu, #ttl = :ttl "
|
|
200
|
+
"REMOVE completed_at, response_status, response_headers, response_body"
|
|
201
|
+
),
|
|
202
|
+
ConditionExpression="attribute_not_exists(#pk) OR lease_until < :now",
|
|
203
|
+
ExpressionAttributeNames={"#s": "state", "#pk": PK, "#ttl": "ttl"},
|
|
204
|
+
ExpressionAttributeValues={
|
|
205
|
+
":claimed": "CLAIMED",
|
|
206
|
+
":fp": fingerprint,
|
|
207
|
+
":fpv": fingerprint_version,
|
|
208
|
+
":tok": token,
|
|
209
|
+
":now": _num(now),
|
|
210
|
+
":lu": _num(lease_until),
|
|
211
|
+
":ttl": int(lease_until + self._grace_seconds),
|
|
212
|
+
},
|
|
213
|
+
ReturnValues="ALL_OLD",
|
|
214
|
+
)
|
|
215
|
+
except ClientError as e:
|
|
216
|
+
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
|
|
217
|
+
already = await self._already(
|
|
218
|
+
table, effective_key, fingerprint, fingerprint_version, lease_ttl_seconds
|
|
219
|
+
)
|
|
220
|
+
if already is not None:
|
|
221
|
+
return already
|
|
222
|
+
continue # record vanished under us; retry the claim
|
|
223
|
+
raise StorageError(f"dynamodb claim failed: {e}") from e
|
|
224
|
+
except Exception as e:
|
|
225
|
+
raise StorageError(f"dynamodb claim failed: {e}") from e
|
|
226
|
+
|
|
227
|
+
record = self._record(
|
|
228
|
+
effective_key,
|
|
229
|
+
State.CLAIMED,
|
|
230
|
+
fingerprint,
|
|
231
|
+
fingerprint_version,
|
|
232
|
+
token,
|
|
233
|
+
now,
|
|
234
|
+
lease_until,
|
|
235
|
+
)
|
|
236
|
+
old = resp.get("Attributes")
|
|
237
|
+
if old and old.get("state") == "CLAIMED":
|
|
238
|
+
return ClaimResult(ClaimResultType.LEASE_RECLAIMED, record, token)
|
|
239
|
+
return ClaimResult(ClaimResultType.NEW_CLAIMED, record, token)
|
|
240
|
+
|
|
241
|
+
raise StorageError("dynamodb: claim contended repeatedly; retry")
|
|
242
|
+
|
|
243
|
+
async def _already(
|
|
244
|
+
self,
|
|
245
|
+
table: Any,
|
|
246
|
+
effective_key: str,
|
|
247
|
+
fingerprint: str,
|
|
248
|
+
fingerprint_version: int,
|
|
249
|
+
lease_ttl_seconds: float,
|
|
250
|
+
) -> ClaimResult | None:
|
|
251
|
+
"""Read the record that made the conditional claim fail. Returns ``None`` if
|
|
252
|
+
it vanished (caller retries the claim), or force-reclaims a corrupt one."""
|
|
253
|
+
try:
|
|
254
|
+
resp = await table.get_item(Key={PK: effective_key}, ConsistentRead=True)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
raise StorageError(f"dynamodb read failed: {e}") from e
|
|
257
|
+
item = resp.get("Item")
|
|
258
|
+
if item is None:
|
|
259
|
+
return None # released between our failed claim and this read; retry
|
|
260
|
+
try:
|
|
261
|
+
record = self._item_to_record(item)
|
|
262
|
+
except Exception:
|
|
263
|
+
# §4.12: an unreadable record is treated as ABSENT and re-claimed fresh.
|
|
264
|
+
return await self._force_claim(
|
|
265
|
+
table, effective_key, fingerprint, fingerprint_version, lease_ttl_seconds
|
|
266
|
+
)
|
|
267
|
+
if record.state == State.COMPLETED:
|
|
268
|
+
return ClaimResult(ClaimResultType.ALREADY_COMPLETED, record)
|
|
269
|
+
return ClaimResult(ClaimResultType.ALREADY_CLAIMED, record)
|
|
270
|
+
|
|
271
|
+
async def _force_claim(
|
|
272
|
+
self,
|
|
273
|
+
table: Any,
|
|
274
|
+
effective_key: str,
|
|
275
|
+
fingerprint: str,
|
|
276
|
+
fingerprint_version: int,
|
|
277
|
+
lease_ttl_seconds: float,
|
|
278
|
+
) -> ClaimResult:
|
|
279
|
+
"""Overwrite a corrupt record with a fresh claim, unconditionally (§4.12)."""
|
|
280
|
+
token = secrets.token_hex(16)
|
|
281
|
+
now = time.time()
|
|
282
|
+
lease_until = now + lease_ttl_seconds
|
|
283
|
+
try:
|
|
284
|
+
await table.update_item(
|
|
285
|
+
Key={PK: effective_key},
|
|
286
|
+
UpdateExpression=(
|
|
287
|
+
"SET #s = :claimed, fingerprint = :fp, fingerprint_version = :fpv, "
|
|
288
|
+
"claim_token = :tok, claimed_at = :now, lease_until = :lu, #ttl = :ttl "
|
|
289
|
+
"REMOVE completed_at, response_status, response_headers, response_body"
|
|
290
|
+
),
|
|
291
|
+
ExpressionAttributeNames={"#s": "state", "#ttl": "ttl"},
|
|
292
|
+
ExpressionAttributeValues={
|
|
293
|
+
":claimed": "CLAIMED",
|
|
294
|
+
":fp": fingerprint,
|
|
295
|
+
":fpv": fingerprint_version,
|
|
296
|
+
":tok": token,
|
|
297
|
+
":now": _num(now),
|
|
298
|
+
":lu": _num(lease_until),
|
|
299
|
+
":ttl": int(lease_until + self._grace_seconds),
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
except Exception as e:
|
|
303
|
+
raise StorageError(f"dynamodb corrupt-record reclaim failed: {e}") from e
|
|
304
|
+
record = self._record(
|
|
305
|
+
effective_key, State.CLAIMED, fingerprint, fingerprint_version, token, now, lease_until
|
|
306
|
+
)
|
|
307
|
+
return ClaimResult(ClaimResultType.NEW_CLAIMED, record, token, recovered_from_corrupt=True)
|
|
308
|
+
|
|
309
|
+
async def complete(
|
|
310
|
+
self,
|
|
311
|
+
effective_key: str,
|
|
312
|
+
claim_token: str,
|
|
313
|
+
response_status: int,
|
|
314
|
+
response_headers: dict[str, str],
|
|
315
|
+
response_body: bytes,
|
|
316
|
+
expires_after_seconds: float,
|
|
317
|
+
) -> bool:
|
|
318
|
+
table = await self._ensure_table()
|
|
319
|
+
now = time.time()
|
|
320
|
+
expiry = now + expires_after_seconds
|
|
321
|
+
from botocore.exceptions import ClientError
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
await table.update_item(
|
|
325
|
+
Key={PK: effective_key},
|
|
326
|
+
UpdateExpression=(
|
|
327
|
+
"SET #s = :completed, completed_at = :now, lease_until = :exp, #ttl = :ttl, "
|
|
328
|
+
"response_status = :st, response_headers = :hd, response_body = :bd"
|
|
329
|
+
),
|
|
330
|
+
ConditionExpression="#s = :claimed AND claim_token = :tok",
|
|
331
|
+
ExpressionAttributeNames={"#s": "state", "#ttl": "ttl"},
|
|
332
|
+
ExpressionAttributeValues={
|
|
333
|
+
":completed": "COMPLETED",
|
|
334
|
+
":claimed": "CLAIMED",
|
|
335
|
+
":tok": claim_token,
|
|
336
|
+
":now": _num(now),
|
|
337
|
+
":exp": _num(expiry),
|
|
338
|
+
":ttl": int(expiry + self._grace_seconds),
|
|
339
|
+
":st": response_status,
|
|
340
|
+
":hd": response_headers,
|
|
341
|
+
":bd": response_body,
|
|
342
|
+
},
|
|
343
|
+
)
|
|
344
|
+
return True
|
|
345
|
+
except ClientError as e:
|
|
346
|
+
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
|
|
347
|
+
return False
|
|
348
|
+
raise StorageError(f"dynamodb complete failed: {e}") from e
|
|
349
|
+
except Exception as e:
|
|
350
|
+
raise StorageError(f"dynamodb complete failed: {e}") from e
|
|
351
|
+
|
|
352
|
+
async def release(self, effective_key: str, claim_token: str) -> bool:
|
|
353
|
+
table = await self._ensure_table()
|
|
354
|
+
from botocore.exceptions import ClientError
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
await table.delete_item(
|
|
358
|
+
Key={PK: effective_key},
|
|
359
|
+
ConditionExpression="#s = :claimed AND claim_token = :tok",
|
|
360
|
+
ExpressionAttributeNames={"#s": "state"},
|
|
361
|
+
ExpressionAttributeValues={":claimed": "CLAIMED", ":tok": claim_token},
|
|
362
|
+
)
|
|
363
|
+
return True
|
|
364
|
+
except ClientError as e:
|
|
365
|
+
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
|
|
366
|
+
return False
|
|
367
|
+
raise StorageError(f"dynamodb release failed: {e}") from e
|
|
368
|
+
except Exception as e:
|
|
369
|
+
raise StorageError(f"dynamodb release failed: {e}") from e
|
|
370
|
+
|
|
371
|
+
async def renew(
|
|
372
|
+
self,
|
|
373
|
+
effective_key: str,
|
|
374
|
+
claim_token: str,
|
|
375
|
+
lease_ttl_seconds: float,
|
|
376
|
+
) -> bool:
|
|
377
|
+
table = await self._ensure_table()
|
|
378
|
+
now = time.time()
|
|
379
|
+
lease_until = now + lease_ttl_seconds
|
|
380
|
+
from botocore.exceptions import ClientError
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
await table.update_item(
|
|
384
|
+
Key={PK: effective_key},
|
|
385
|
+
UpdateExpression="SET lease_until = :lu, #ttl = :ttl",
|
|
386
|
+
ConditionExpression="#s = :claimed AND claim_token = :tok",
|
|
387
|
+
ExpressionAttributeNames={"#s": "state", "#ttl": "ttl"},
|
|
388
|
+
ExpressionAttributeValues={
|
|
389
|
+
":claimed": "CLAIMED",
|
|
390
|
+
":tok": claim_token,
|
|
391
|
+
":lu": _num(lease_until),
|
|
392
|
+
":ttl": int(lease_until + self._grace_seconds),
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
return True
|
|
396
|
+
except ClientError as e:
|
|
397
|
+
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
|
|
398
|
+
return False
|
|
399
|
+
raise StorageError(f"dynamodb renew failed: {e}") from e
|
|
400
|
+
except Exception as e:
|
|
401
|
+
raise StorageError(f"dynamodb renew failed: {e}") from e
|
|
402
|
+
|
|
403
|
+
async def wait_for_completion(
|
|
404
|
+
self,
|
|
405
|
+
effective_key: str,
|
|
406
|
+
timeout_seconds: float,
|
|
407
|
+
) -> StoredRecord | None:
|
|
408
|
+
import asyncio
|
|
409
|
+
|
|
410
|
+
deadline = time.monotonic() + timeout_seconds
|
|
411
|
+
poll = _POLL_INITIAL_SECONDS
|
|
412
|
+
while True:
|
|
413
|
+
record = await self._get_record(effective_key)
|
|
414
|
+
if record is None:
|
|
415
|
+
return None
|
|
416
|
+
if record.state == State.COMPLETED:
|
|
417
|
+
return record
|
|
418
|
+
remaining = deadline - time.monotonic()
|
|
419
|
+
if remaining <= 0:
|
|
420
|
+
return None
|
|
421
|
+
await asyncio.sleep(min(poll, remaining))
|
|
422
|
+
poll = min(poll * 2, _POLL_CAP_SECONDS)
|
|
423
|
+
|
|
424
|
+
# ----- internals -----
|
|
425
|
+
|
|
426
|
+
async def _get_record(self, effective_key: str) -> StoredRecord | None:
|
|
427
|
+
table = await self._ensure_table()
|
|
428
|
+
try:
|
|
429
|
+
resp = await table.get_item(Key={PK: effective_key}, ConsistentRead=True)
|
|
430
|
+
except Exception as e:
|
|
431
|
+
raise StorageError(f"dynamodb read failed: {e}") from e
|
|
432
|
+
item = resp.get("Item")
|
|
433
|
+
if item is None:
|
|
434
|
+
return None
|
|
435
|
+
try:
|
|
436
|
+
return self._item_to_record(item)
|
|
437
|
+
except Exception:
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
def _record(
|
|
441
|
+
self,
|
|
442
|
+
effective_key: str,
|
|
443
|
+
state: State,
|
|
444
|
+
fingerprint: str,
|
|
445
|
+
fingerprint_version: int,
|
|
446
|
+
token: str,
|
|
447
|
+
claimed_at: float,
|
|
448
|
+
lease_until: float,
|
|
449
|
+
) -> StoredRecord:
|
|
450
|
+
return StoredRecord(
|
|
451
|
+
effective_key=effective_key,
|
|
452
|
+
state=state,
|
|
453
|
+
fingerprint=fingerprint,
|
|
454
|
+
fingerprint_version=fingerprint_version,
|
|
455
|
+
claim_token=token,
|
|
456
|
+
claimed_at=claimed_at,
|
|
457
|
+
lease_until=lease_until,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def _item_to_record(self, item: dict[str, Any]) -> StoredRecord:
|
|
461
|
+
body = item.get("response_body") or b""
|
|
462
|
+
if hasattr(body, "value"): # botocore Binary
|
|
463
|
+
body = body.value
|
|
464
|
+
return StoredRecord(
|
|
465
|
+
effective_key=item[PK],
|
|
466
|
+
state=State(item["state"]),
|
|
467
|
+
fingerprint=item["fingerprint"],
|
|
468
|
+
fingerprint_version=int(item["fingerprint_version"]),
|
|
469
|
+
claim_token=item["claim_token"],
|
|
470
|
+
claimed_at=float(item["claimed_at"]),
|
|
471
|
+
lease_until=float(item["lease_until"]),
|
|
472
|
+
completed_at=float(item["completed_at"])
|
|
473
|
+
if item.get("completed_at") is not None
|
|
474
|
+
else None,
|
|
475
|
+
response_status=int(item["response_status"])
|
|
476
|
+
if item.get("response_status") is not None
|
|
477
|
+
else None,
|
|
478
|
+
response_headers={
|
|
479
|
+
str(k): str(v) for k, v in (item.get("response_headers") or {}).items()
|
|
480
|
+
},
|
|
481
|
+
response_body=bytes(body),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _num(value: float) -> Any:
|
|
486
|
+
"""DynamoDB numbers must be Decimal (the resource layer rejects float)."""
|
|
487
|
+
from decimal import Decimal
|
|
488
|
+
|
|
489
|
+
return Decimal(str(value))
|