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
idemkit/cli.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""idemkit command-line interface.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
idemkit init-pg <database-url> Create idemkit_records table + indexes.
|
|
5
|
+
idemkit pg-vacuum <database-url> Delete expired records.
|
|
6
|
+
idemkit conformance [--redis URL] [--postgres URL] [--mongo URL] [--dynamodb ENDPOINT]
|
|
7
|
+
Run the shared-core conformance vectors
|
|
8
|
+
against InMemory (always) and any backend
|
|
9
|
+
whose URL is given. Exits non-zero on any
|
|
10
|
+
failure.
|
|
11
|
+
|
|
12
|
+
``init-pg`` and ``pg-vacuum`` are idempotent and safe to re-run.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import asyncio
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _make_parser() -> argparse.ArgumentParser:
|
|
23
|
+
parser = argparse.ArgumentParser(
|
|
24
|
+
prog="idemkit",
|
|
25
|
+
description="idemkit operational tooling (schema migration, vacuum).",
|
|
26
|
+
)
|
|
27
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
28
|
+
|
|
29
|
+
init_pg = sub.add_parser(
|
|
30
|
+
"init-pg",
|
|
31
|
+
help="Create the records table and indexes (idempotent).",
|
|
32
|
+
)
|
|
33
|
+
init_pg.add_argument("database_url", help="postgres:// URL.")
|
|
34
|
+
init_pg.add_argument(
|
|
35
|
+
"--table",
|
|
36
|
+
default="idemkit_records",
|
|
37
|
+
help="Records table name (default: idemkit_records).",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
vacuum = sub.add_parser(
|
|
41
|
+
"pg-vacuum",
|
|
42
|
+
help="Delete expired records from the table.",
|
|
43
|
+
)
|
|
44
|
+
vacuum.add_argument("database_url", help="postgres:// URL.")
|
|
45
|
+
vacuum.add_argument(
|
|
46
|
+
"--table",
|
|
47
|
+
default="idemkit_records",
|
|
48
|
+
help="Records table name (default: idemkit_records).",
|
|
49
|
+
)
|
|
50
|
+
vacuum.add_argument(
|
|
51
|
+
"--expires-after-seconds",
|
|
52
|
+
type=float,
|
|
53
|
+
default=86_400.0,
|
|
54
|
+
help="Records COMPLETED before (now - this) are deleted (default: 24h).",
|
|
55
|
+
)
|
|
56
|
+
vacuum.add_argument(
|
|
57
|
+
"--lease-grace-seconds",
|
|
58
|
+
type=float,
|
|
59
|
+
default=60.0,
|
|
60
|
+
help="CLAIMED records with lease_until + grace < now are deleted (default: 60s).",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
conformance = sub.add_parser(
|
|
64
|
+
"conformance",
|
|
65
|
+
help="Run the shared-core conformance vectors against backends.",
|
|
66
|
+
)
|
|
67
|
+
conformance.add_argument("--redis", help="redis:// URL to also run the vectors against.")
|
|
68
|
+
conformance.add_argument("--postgres", help="postgres:// URL to also run the vectors against.")
|
|
69
|
+
conformance.add_argument("--mongo", help="mongodb:// URL to also run the vectors against.")
|
|
70
|
+
conformance.add_argument(
|
|
71
|
+
"--dynamodb",
|
|
72
|
+
metavar="ENDPOINT",
|
|
73
|
+
help=(
|
|
74
|
+
"DynamoDB endpoint URL to also run the vectors against (e.g. "
|
|
75
|
+
"http://localhost:8000 for DynamoDB Local; credentials and region come "
|
|
76
|
+
"from the environment)."
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return parser
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv: list[str] | None = None) -> int:
|
|
84
|
+
parser = _make_parser()
|
|
85
|
+
args = parser.parse_args(argv)
|
|
86
|
+
|
|
87
|
+
if args.command == "init-pg":
|
|
88
|
+
from idemkit.backends.postgres import init_pg
|
|
89
|
+
|
|
90
|
+
asyncio.run(init_pg(args.database_url, table=args.table))
|
|
91
|
+
print("idemkit: PostgreSQL schema initialized")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
if args.command == "pg-vacuum":
|
|
95
|
+
from idemkit.backends.postgres import pg_vacuum
|
|
96
|
+
|
|
97
|
+
n = asyncio.run(
|
|
98
|
+
pg_vacuum(
|
|
99
|
+
args.database_url,
|
|
100
|
+
table=args.table,
|
|
101
|
+
expires_after_seconds=args.expires_after_seconds,
|
|
102
|
+
lease_grace_seconds=args.lease_grace_seconds,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
print(f"idemkit: vacuumed {n} expired record(s)")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
if args.command == "conformance":
|
|
109
|
+
return asyncio.run(_run_conformance(args.redis, args.postgres, args.mongo, args.dynamodb))
|
|
110
|
+
|
|
111
|
+
parser.print_help()
|
|
112
|
+
return 2
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _run_conformance(
|
|
116
|
+
redis_url: str | None,
|
|
117
|
+
postgres_url: str | None,
|
|
118
|
+
mongo_url: str | None = None,
|
|
119
|
+
dynamodb_endpoint: str | None = None,
|
|
120
|
+
) -> int:
|
|
121
|
+
"""Run the conformance suite against InMemory plus any configured backend."""
|
|
122
|
+
from idemkit.backends.memory import InMemoryBackend
|
|
123
|
+
from idemkit.conformance import BackendConformance
|
|
124
|
+
|
|
125
|
+
all_passed = True
|
|
126
|
+
|
|
127
|
+
async def _run(backend: object, aclose: bool) -> None:
|
|
128
|
+
nonlocal all_passed
|
|
129
|
+
report = await BackendConformance(backend).run() # type: ignore[arg-type]
|
|
130
|
+
print(report.report())
|
|
131
|
+
if not report.passed:
|
|
132
|
+
all_passed = False
|
|
133
|
+
if aclose and hasattr(backend, "aclose"):
|
|
134
|
+
await backend.aclose()
|
|
135
|
+
|
|
136
|
+
await _run(InMemoryBackend(), aclose=False)
|
|
137
|
+
|
|
138
|
+
if redis_url:
|
|
139
|
+
from idemkit.backends.redis import RedisBackend
|
|
140
|
+
|
|
141
|
+
await _run(RedisBackend.from_url(redis_url), aclose=True)
|
|
142
|
+
|
|
143
|
+
if postgres_url:
|
|
144
|
+
from idemkit.backends.postgres import PostgresBackend, init_pg
|
|
145
|
+
|
|
146
|
+
await init_pg(postgres_url)
|
|
147
|
+
await _run(PostgresBackend.from_url(postgres_url), aclose=True)
|
|
148
|
+
|
|
149
|
+
if mongo_url:
|
|
150
|
+
from idemkit.backends.mongo import MongoBackend
|
|
151
|
+
|
|
152
|
+
await _run(MongoBackend.from_url(mongo_url), aclose=True)
|
|
153
|
+
|
|
154
|
+
if dynamodb_endpoint:
|
|
155
|
+
from idemkit.backends.dynamodb import DynamoBackend
|
|
156
|
+
|
|
157
|
+
await _run(DynamoBackend(endpoint_url=dynamodb_endpoint), aclose=True)
|
|
158
|
+
|
|
159
|
+
print()
|
|
160
|
+
print("idemkit: conformance PASSED" if all_passed else "idemkit: conformance FAILED")
|
|
161
|
+
return 0 if all_passed else 1
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
sys.exit(main())
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""idemkit conformance suite — a public, reusable correctness standard (spec §11.1).
|
|
2
|
+
|
|
3
|
+
The shared-core vectors (atomic claim, fencing, lease reclaim, in-flight wait,
|
|
4
|
+
TTL expiry, lease renewal) packaged so ANY library whose backend implements the
|
|
5
|
+
``IdempotencyBackend`` Protocol can validate itself against them, in or out of
|
|
6
|
+
idemkit. This is the artifact §11.1 calls for: a correctness suite the field can
|
|
7
|
+
reference, not a private test file.
|
|
8
|
+
|
|
9
|
+
from idemkit.conformance import BackendConformance
|
|
10
|
+
|
|
11
|
+
report = await BackendConformance(my_backend).run()
|
|
12
|
+
assert report.passed, report.report()
|
|
13
|
+
|
|
14
|
+
The language-neutral vector descriptions (for ports to other languages) live in
|
|
15
|
+
``spec/conformance.yaml``; this package is the runnable Python reference.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from idemkit.conformance.backend import BackendConformance
|
|
19
|
+
from idemkit.conformance.report import ConformanceReport, VectorResult
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"BackendConformance",
|
|
23
|
+
"ConformanceReport",
|
|
24
|
+
"VectorResult",
|
|
25
|
+
]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Shared-core conformance vectors for the backend Protocol (spec §9).
|
|
2
|
+
|
|
3
|
+
These are the language-neutral correctness checks every ``IdempotencyBackend``
|
|
4
|
+
must pass — atomic claim, fencing, lease reclaim, in-flight wait, TTL expiry, and
|
|
5
|
+
lease renewal — packaged so ANY implementation can point them at itself, not just
|
|
6
|
+
idemkit's own three backends. That portability is the whole point of §11.1: a
|
|
7
|
+
public correctness suite the field can reference, rather than a private test file.
|
|
8
|
+
|
|
9
|
+
Usage from a third-party library whose backend implements the Protocol::
|
|
10
|
+
|
|
11
|
+
from idemkit.conformance import BackendConformance
|
|
12
|
+
|
|
13
|
+
report = await BackendConformance(MyBackend(...)).run()
|
|
14
|
+
assert report.passed, report.report()
|
|
15
|
+
|
|
16
|
+
The vectors use a unique random key each, so they are safe to run repeatedly
|
|
17
|
+
against a shared, persistent backend (e.g. one PostgreSQL database) without
|
|
18
|
+
cross-run interference. They exercise only the public Protocol — backend-specific
|
|
19
|
+
behavior like corrupt-record recovery is tested inside idemkit itself, since it
|
|
20
|
+
needs internals a generic suite can't reach.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import uuid
|
|
27
|
+
from collections import Counter
|
|
28
|
+
from collections.abc import Awaitable, Callable
|
|
29
|
+
|
|
30
|
+
from idemkit.backends.base import IdempotencyBackend
|
|
31
|
+
from idemkit.conformance.report import ConformanceReport, VectorResult
|
|
32
|
+
from idemkit.core.state import ClaimResultType, State
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BackendConformance:
|
|
36
|
+
"""Runs the shared-core vectors against one backend instance."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, backend: IdempotencyBackend, *, supports_renew: bool = True) -> None:
|
|
39
|
+
self.backend = backend
|
|
40
|
+
self.supports_renew = supports_renew
|
|
41
|
+
|
|
42
|
+
async def run(self) -> ConformanceReport:
|
|
43
|
+
"""Run every applicable vector and collect the results."""
|
|
44
|
+
report = ConformanceReport(backend_name=type(self.backend).__name__)
|
|
45
|
+
for vector_id, description, fn in self._vectors():
|
|
46
|
+
try:
|
|
47
|
+
await fn()
|
|
48
|
+
report.results.append(VectorResult(vector_id, True, description))
|
|
49
|
+
except AssertionError as e:
|
|
50
|
+
report.results.append(
|
|
51
|
+
VectorResult(vector_id, False, description, error=f"assertion: {e}")
|
|
52
|
+
)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
# A vector that errors (not just asserts) is still a failure, not
|
|
55
|
+
# a crash of the whole run — record it and keep going.
|
|
56
|
+
report.results.append(
|
|
57
|
+
VectorResult(vector_id, False, description, error=f"{type(e).__name__}: {e}")
|
|
58
|
+
)
|
|
59
|
+
return report
|
|
60
|
+
|
|
61
|
+
def _vectors(self) -> list[tuple[str, str, Callable[[], Awaitable[None]]]]:
|
|
62
|
+
vectors: list[tuple[str, str, Callable[[], Awaitable[None]]]] = [
|
|
63
|
+
("atomic-claim", "a fresh key claims NEW with a token", self._atomic_claim),
|
|
64
|
+
(
|
|
65
|
+
"duplicate-claim",
|
|
66
|
+
"a second claim on a live key returns ALREADY_CLAIMED",
|
|
67
|
+
self._duplicate_claim,
|
|
68
|
+
),
|
|
69
|
+
(
|
|
70
|
+
"conditional-complete",
|
|
71
|
+
"complete transitions CLAIMED -> COMPLETED and replays",
|
|
72
|
+
self._conditional_complete,
|
|
73
|
+
),
|
|
74
|
+
(
|
|
75
|
+
"fencing-wrong-token",
|
|
76
|
+
"complete with a wrong token is rejected",
|
|
77
|
+
self._fencing_wrong_token,
|
|
78
|
+
),
|
|
79
|
+
(
|
|
80
|
+
"release-clears-claim",
|
|
81
|
+
"release with the right token frees the key",
|
|
82
|
+
self._release_clears,
|
|
83
|
+
),
|
|
84
|
+
(
|
|
85
|
+
"lease-reclaim",
|
|
86
|
+
"an expired lease is reclaimable with a new token",
|
|
87
|
+
self._lease_reclaim,
|
|
88
|
+
),
|
|
89
|
+
(
|
|
90
|
+
"fenced-after-reclaim",
|
|
91
|
+
"a reclaimed owner's late completion is rejected",
|
|
92
|
+
self._fenced_after_reclaim,
|
|
93
|
+
),
|
|
94
|
+
(
|
|
95
|
+
"concurrent-claim-exactly-one",
|
|
96
|
+
"N concurrent claims yield exactly one NEW",
|
|
97
|
+
self._concurrent_claim,
|
|
98
|
+
),
|
|
99
|
+
(
|
|
100
|
+
"in-flight-wait",
|
|
101
|
+
"a waiter receives the completed record",
|
|
102
|
+
self._in_flight_wait,
|
|
103
|
+
),
|
|
104
|
+
(
|
|
105
|
+
"wait-timeout",
|
|
106
|
+
"a waiter times out when the claim never completes",
|
|
107
|
+
self._wait_timeout,
|
|
108
|
+
),
|
|
109
|
+
(
|
|
110
|
+
"completed-ttl-expiry",
|
|
111
|
+
"a COMPLETED record past its TTL re-claims fresh",
|
|
112
|
+
self._completed_ttl_expiry,
|
|
113
|
+
),
|
|
114
|
+
]
|
|
115
|
+
if self.supports_renew:
|
|
116
|
+
vectors.extend(
|
|
117
|
+
[
|
|
118
|
+
(
|
|
119
|
+
"renew-extends-lease",
|
|
120
|
+
"renew pushes out a live lease so it isn't reclaimed",
|
|
121
|
+
self._renew_extends,
|
|
122
|
+
),
|
|
123
|
+
(
|
|
124
|
+
"renew-fenced",
|
|
125
|
+
"renew with a wrong token, or after reclaim, is rejected",
|
|
126
|
+
self._renew_fenced,
|
|
127
|
+
),
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
return vectors
|
|
131
|
+
|
|
132
|
+
# ----- the vectors -----
|
|
133
|
+
|
|
134
|
+
async def _atomic_claim(self) -> None:
|
|
135
|
+
key = _k("atomic")
|
|
136
|
+
result = await self.backend.claim(key, "fp", 1, 30.0)
|
|
137
|
+
assert result.result == ClaimResultType.NEW_CLAIMED
|
|
138
|
+
assert result.our_claim_token is not None
|
|
139
|
+
assert result.record.state == State.CLAIMED
|
|
140
|
+
|
|
141
|
+
async def _duplicate_claim(self) -> None:
|
|
142
|
+
key = _k("dup")
|
|
143
|
+
first = await self.backend.claim(key, "fp", 1, 30.0)
|
|
144
|
+
second = await self.backend.claim(key, "fp", 1, 30.0)
|
|
145
|
+
assert second.result == ClaimResultType.ALREADY_CLAIMED
|
|
146
|
+
assert second.record.claim_token == first.our_claim_token
|
|
147
|
+
|
|
148
|
+
async def _conditional_complete(self) -> None:
|
|
149
|
+
key = _k("complete")
|
|
150
|
+
first = await self.backend.claim(key, "fp", 1, 30.0)
|
|
151
|
+
token = _token(first)
|
|
152
|
+
ok = await self.backend.complete(key, token, 201, {"h": "v"}, b"body", 3600.0)
|
|
153
|
+
assert ok is True
|
|
154
|
+
after = await self.backend.claim(key, "fp", 1, 30.0)
|
|
155
|
+
assert after.result == ClaimResultType.ALREADY_COMPLETED
|
|
156
|
+
assert after.record.response_status == 201
|
|
157
|
+
assert after.record.response_body == b"body"
|
|
158
|
+
|
|
159
|
+
async def _fencing_wrong_token(self) -> None:
|
|
160
|
+
key = _k("fence")
|
|
161
|
+
await self.backend.claim(key, "fp", 1, 30.0)
|
|
162
|
+
ok = await self.backend.complete(key, "not-the-token", 200, {}, b"x", 3600.0)
|
|
163
|
+
assert ok is False
|
|
164
|
+
|
|
165
|
+
async def _release_clears(self) -> None:
|
|
166
|
+
key = _k("release")
|
|
167
|
+
first = await self.backend.claim(key, "fp", 1, 30.0)
|
|
168
|
+
assert await self.backend.release(key, _token(first)) is True
|
|
169
|
+
after = await self.backend.claim(key, "fp", 1, 30.0)
|
|
170
|
+
assert after.result == ClaimResultType.NEW_CLAIMED
|
|
171
|
+
|
|
172
|
+
async def _lease_reclaim(self) -> None:
|
|
173
|
+
key = _k("reclaim")
|
|
174
|
+
first = await self.backend.claim(key, "fp", 1, 0.1)
|
|
175
|
+
await asyncio.sleep(0.25)
|
|
176
|
+
second = await self.backend.claim(key, "fp", 1, 30.0)
|
|
177
|
+
assert second.result == ClaimResultType.LEASE_RECLAIMED
|
|
178
|
+
assert second.our_claim_token != first.our_claim_token
|
|
179
|
+
|
|
180
|
+
async def _fenced_after_reclaim(self) -> None:
|
|
181
|
+
key = _k("fenced-reclaim")
|
|
182
|
+
first = await self.backend.claim(key, "fp", 1, 0.1)
|
|
183
|
+
await asyncio.sleep(0.25)
|
|
184
|
+
second = await self.backend.claim(key, "fp", 1, 30.0)
|
|
185
|
+
assert second.result == ClaimResultType.LEASE_RECLAIMED
|
|
186
|
+
ok = await self.backend.complete(key, _token(first), 200, {}, b"x", 3600.0)
|
|
187
|
+
assert ok is False
|
|
188
|
+
after = await self.backend.claim(key, "fp", 1, 30.0)
|
|
189
|
+
assert after.record.claim_token == second.our_claim_token
|
|
190
|
+
|
|
191
|
+
async def _concurrent_claim(self) -> None:
|
|
192
|
+
key = _k("concurrent")
|
|
193
|
+
parallel = 25
|
|
194
|
+
results = await asyncio.gather(
|
|
195
|
+
*(self.backend.claim(key, "fp", 1, 30.0) for _ in range(parallel))
|
|
196
|
+
)
|
|
197
|
+
outcomes = Counter(r.result for r in results)
|
|
198
|
+
assert outcomes[ClaimResultType.NEW_CLAIMED] == 1
|
|
199
|
+
assert outcomes[ClaimResultType.ALREADY_CLAIMED] == parallel - 1
|
|
200
|
+
|
|
201
|
+
async def _in_flight_wait(self) -> None:
|
|
202
|
+
key = _k("wait")
|
|
203
|
+
first = await self.backend.claim(key, "fp", 1, 30.0)
|
|
204
|
+
token = _token(first)
|
|
205
|
+
|
|
206
|
+
async def completer() -> None:
|
|
207
|
+
await asyncio.sleep(0.1)
|
|
208
|
+
await self.backend.complete(key, token, 200, {}, b"hi", 3600.0)
|
|
209
|
+
|
|
210
|
+
waited, _ = await asyncio.gather(
|
|
211
|
+
self.backend.wait_for_completion(key, timeout_seconds=2.0), completer()
|
|
212
|
+
)
|
|
213
|
+
assert waited is not None
|
|
214
|
+
assert waited.state == State.COMPLETED
|
|
215
|
+
assert waited.response_body == b"hi"
|
|
216
|
+
|
|
217
|
+
async def _wait_timeout(self) -> None:
|
|
218
|
+
key = _k("wait-timeout")
|
|
219
|
+
await self.backend.claim(key, "fp", 1, 30.0)
|
|
220
|
+
result = await self.backend.wait_for_completion(key, timeout_seconds=0.3)
|
|
221
|
+
assert result is None
|
|
222
|
+
|
|
223
|
+
async def _completed_ttl_expiry(self) -> None:
|
|
224
|
+
# The TTL window must comfortably outlast a backend round-trip, or a slow
|
|
225
|
+
# store can expire the record before the "still completed" check below.
|
|
226
|
+
key = _k("ttl")
|
|
227
|
+
first = await self.backend.claim(key, "fp", 1, 30.0)
|
|
228
|
+
await self.backend.complete(key, _token(first), 200, {}, b"x", 1.0)
|
|
229
|
+
immediate = await self.backend.claim(key, "fp", 1, 30.0)
|
|
230
|
+
assert immediate.result == ClaimResultType.ALREADY_COMPLETED
|
|
231
|
+
await asyncio.sleep(1.5)
|
|
232
|
+
after = await self.backend.claim(key, "fp", 1, 30.0)
|
|
233
|
+
assert after.result == ClaimResultType.NEW_CLAIMED
|
|
234
|
+
|
|
235
|
+
async def _renew_extends(self) -> None:
|
|
236
|
+
key = _k("renew")
|
|
237
|
+
first = await self.backend.claim(key, "fp", 1, 0.2)
|
|
238
|
+
assert await self.backend.renew(key, _token(first), 30.0) is True
|
|
239
|
+
await asyncio.sleep(0.4)
|
|
240
|
+
second = await self.backend.claim(key, "fp", 1, 30.0)
|
|
241
|
+
assert second.result == ClaimResultType.ALREADY_CLAIMED
|
|
242
|
+
|
|
243
|
+
async def _renew_fenced(self) -> None:
|
|
244
|
+
key = _k("renew-fenced")
|
|
245
|
+
await self.backend.claim(key, "fp", 1, 30.0)
|
|
246
|
+
assert await self.backend.renew(key, "not-the-token", 30.0) is False
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _token(result: object) -> str:
|
|
250
|
+
"""The claim token from a NEW/RECLAIMED claim, asserted present."""
|
|
251
|
+
token = getattr(result, "our_claim_token", None)
|
|
252
|
+
assert token is not None, "a NEW/RECLAIMED claim must carry a claim token"
|
|
253
|
+
return str(token)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _k(suffix: str) -> str:
|
|
257
|
+
return f"conformance-{suffix}-{uuid.uuid4().hex}"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Result types for the conformance runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class VectorResult:
|
|
10
|
+
"""The outcome of one conformance vector."""
|
|
11
|
+
|
|
12
|
+
vector_id: str
|
|
13
|
+
passed: bool
|
|
14
|
+
description: str
|
|
15
|
+
error: str | None = None
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def status(self) -> str:
|
|
19
|
+
return "PASS" if self.passed else "FAIL"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ConformanceReport:
|
|
24
|
+
"""The result of running a set of vectors against one backend."""
|
|
25
|
+
|
|
26
|
+
backend_name: str
|
|
27
|
+
results: list[VectorResult] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def passed(self) -> bool:
|
|
31
|
+
"""True only if every vector passed."""
|
|
32
|
+
return all(r.passed for r in self.results)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def total(self) -> int:
|
|
36
|
+
return len(self.results)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def failures(self) -> list[VectorResult]:
|
|
40
|
+
return [r for r in self.results if not r.passed]
|
|
41
|
+
|
|
42
|
+
def summary(self) -> str:
|
|
43
|
+
"""One-line human summary, e.g. ``RedisBackend: 13/13 vectors passed``."""
|
|
44
|
+
passed = sum(1 for r in self.results if r.passed)
|
|
45
|
+
return f"{self.backend_name}: {passed}/{self.total} vectors passed"
|
|
46
|
+
|
|
47
|
+
def report(self) -> str:
|
|
48
|
+
"""A multi-line report listing each vector's status."""
|
|
49
|
+
lines = [self.summary()]
|
|
50
|
+
for r in self.results:
|
|
51
|
+
lines.append(f" [{r.status}] {r.vector_id} — {r.description}")
|
|
52
|
+
if r.error:
|
|
53
|
+
lines.append(f" {r.error}")
|
|
54
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Optional broker glue for idemkit's queue surface.
|
|
2
|
+
|
|
3
|
+
The core queue adapter (:class:`idemkit.IdempotentConsumer`) is broker-agnostic —
|
|
4
|
+
you supply small callables that read the dedup id, scope, and receive count from
|
|
5
|
+
whatever message object your library hands you. That is deliberately flexible but
|
|
6
|
+
means everyone rewrites the same wiring for the same two or three brokers.
|
|
7
|
+
|
|
8
|
+
``idemkit.contrib`` presets that wiring for the common cases so it's import-and-go:
|
|
9
|
+
|
|
10
|
+
* :mod:`idemkit.contrib.sqs` — Amazon SQS (boto3 message dicts).
|
|
11
|
+
* :mod:`idemkit.contrib.kafka` — Kafka (``confluent_kafka`` or ``kafka-python`` records).
|
|
12
|
+
* :mod:`idemkit.contrib.mcp` — enforce idempotency on MCP / LLM tool calls.
|
|
13
|
+
* :mod:`idemkit.contrib.fastapi` — a FastAPI route class (return a dict, read
|
|
14
|
+
``request.state`` in ``scope``).
|
|
15
|
+
* :mod:`idemkit.contrib.drf` — a Django REST Framework view mixin (read
|
|
16
|
+
``request.user`` in ``scope``).
|
|
17
|
+
|
|
18
|
+
For Celery there is no separate module: a task body is a plain synchronous
|
|
19
|
+
function, so :func:`idemkit.idempotent_sync` is the tool (stack it UNDER
|
|
20
|
+
``@app.task``). See ``examples/method/sync_function.py``.
|
|
21
|
+
|
|
22
|
+
None of these import a broker SDK; they read duck-typed message objects and (for
|
|
23
|
+
SQS) take the client you already created. Install the broker library yourself.
|
|
24
|
+
|
|
25
|
+
For observability there are two ready-made event handlers, so you don't hand-roll
|
|
26
|
+
the metric plumbing:
|
|
27
|
+
|
|
28
|
+
* :mod:`idemkit.contrib.prometheus` — record operations / latency to Prometheus.
|
|
29
|
+
* :mod:`idemkit.contrib.logging` — one structured log line per operation.
|
|
30
|
+
"""
|