replyonce 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.
- replyonce/__init__.py +39 -0
- replyonce/__main__.py +5 -0
- replyonce/_core.py +462 -0
- replyonce/_key.py +89 -0
- replyonce/_logic.py +361 -0
- replyonce/adapters.py +191 -0
- replyonce/cli.py +28 -0
- replyonce/demo.py +233 -0
- replyonce/py.typed +0 -0
- replyonce/stores/__init__.py +3 -0
- replyonce/stores/memory.py +32 -0
- replyonce/stores/postgres.py +93 -0
- replyonce/stores/redis.py +66 -0
- replyonce-0.1.0.dist-info/METADATA +108 -0
- replyonce-0.1.0.dist-info/RECORD +19 -0
- replyonce-0.1.0.dist-info/WHEEL +4 -0
- replyonce-0.1.0.dist-info/entry_points.txt +2 -0
- replyonce-0.1.0.dist-info/licenses/LICENSE +202 -0
- replyonce-0.1.0.dist-info/licenses/NOTICE +7 -0
replyonce/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""replyonce: stop your agent from replying, refunding or ticketing twice."""
|
|
2
|
+
|
|
3
|
+
from ._core import (
|
|
4
|
+
CheckResult,
|
|
5
|
+
EffectContext,
|
|
6
|
+
Key,
|
|
7
|
+
NotApplied,
|
|
8
|
+
OnceResult,
|
|
9
|
+
ReplyOnce,
|
|
10
|
+
ReplyOnceError,
|
|
11
|
+
Run,
|
|
12
|
+
Store,
|
|
13
|
+
default_classify_error,
|
|
14
|
+
unwrap,
|
|
15
|
+
)
|
|
16
|
+
from ._key import MISSING, canonical_json, effect_key, hash_payload, provider_idempotency_key
|
|
17
|
+
from .stores.memory import MemoryStore
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"CheckResult",
|
|
23
|
+
"EffectContext",
|
|
24
|
+
"Key",
|
|
25
|
+
"MISSING",
|
|
26
|
+
"MemoryStore",
|
|
27
|
+
"NotApplied",
|
|
28
|
+
"OnceResult",
|
|
29
|
+
"ReplyOnce",
|
|
30
|
+
"ReplyOnceError",
|
|
31
|
+
"Run",
|
|
32
|
+
"Store",
|
|
33
|
+
"canonical_json",
|
|
34
|
+
"default_classify_error",
|
|
35
|
+
"effect_key",
|
|
36
|
+
"hash_payload",
|
|
37
|
+
"provider_idempotency_key",
|
|
38
|
+
"unwrap",
|
|
39
|
+
]
|
replyonce/__main__.py
ADDED
replyonce/_core.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""The replyonce API (asyncio). Mirrors the TypeScript package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Awaitable, Callable, Generic, Optional, Protocol, TypeVar, Union
|
|
12
|
+
|
|
13
|
+
from . import _logic as logic
|
|
14
|
+
from ._key import MISSING, effect_key, provider_idempotency_key
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
MAX_ROUNDS = 5
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotApplied(Exception):
|
|
22
|
+
"""Raise this (or any exception with ``replyonce = "not_applied"``) when the effect definitely did not happen."""
|
|
23
|
+
|
|
24
|
+
replyonce = "not_applied"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_classify_error(err: BaseException) -> str:
|
|
28
|
+
if isinstance(err, NotApplied) or getattr(err, "replyonce", None) == "not_applied":
|
|
29
|
+
return "not_applied"
|
|
30
|
+
return "ambiguous"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Store(Protocol):
|
|
34
|
+
async def transact(self, scope: str, fn: Callable[[Optional[dict]], tuple[Optional[dict], Any]]) -> Any: ...
|
|
35
|
+
|
|
36
|
+
async def read(self, scope: str) -> Optional[dict]: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Key:
|
|
41
|
+
"""A scope (usually the conversation), an intent, and the business identity of the effect."""
|
|
42
|
+
|
|
43
|
+
scope: str
|
|
44
|
+
intent: str
|
|
45
|
+
payload: Any = MISSING
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
KeyLike = Union[str, Key, dict]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class EffectContext:
|
|
53
|
+
key: str
|
|
54
|
+
scope: str
|
|
55
|
+
intent: str
|
|
56
|
+
attempt: int
|
|
57
|
+
idempotency_key: str
|
|
58
|
+
"""Stable across attempts and workers. Pass it to providers that accept an idempotency key."""
|
|
59
|
+
fencing_token: Optional[int]
|
|
60
|
+
deadline_ms: int
|
|
61
|
+
"""Epoch ms after which the claim is treated as abandoned. Finish (or give up) before it."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class OnceResult(Generic[T]):
|
|
66
|
+
status: str
|
|
67
|
+
"""executed, replayed, in_flight, ambiguous, failed or refused."""
|
|
68
|
+
ok: bool
|
|
69
|
+
key: str
|
|
70
|
+
value: Any = None
|
|
71
|
+
attempt: Optional[int] = None
|
|
72
|
+
entry: Optional[dict] = None
|
|
73
|
+
error: Optional[BaseException] = None
|
|
74
|
+
retryable: bool = False
|
|
75
|
+
reason: Optional[str] = None
|
|
76
|
+
conflicts: Optional[list] = None
|
|
77
|
+
late_settle: bool = False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class CheckResult:
|
|
82
|
+
done: bool
|
|
83
|
+
status: str
|
|
84
|
+
entry: Optional[dict]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ReplyOnceError(Exception):
|
|
88
|
+
def __init__(self, result: OnceResult):
|
|
89
|
+
detail = f"{result.status}: {result.reason}" if result.status == "refused" else result.status
|
|
90
|
+
super().__init__(f"replyonce: effect {result.key} not executed ({detail})")
|
|
91
|
+
self.result = result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def unwrap(result: OnceResult[T]) -> T:
|
|
95
|
+
"""Return the value of a successful result, or raise ``ReplyOnceError``."""
|
|
96
|
+
if result.ok:
|
|
97
|
+
return result.value
|
|
98
|
+
raise ReplyOnceError(result)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
Effect = Callable[[EffectContext], Union[Awaitable[T], T]]
|
|
102
|
+
Reconciler = Callable[[dict], Awaitable[dict]]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _now_ms() -> int:
|
|
106
|
+
return int(time.time() * 1000)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _assert_scope(scope: Any) -> None:
|
|
110
|
+
if not isinstance(scope, str) or not scope or len(scope) > 512:
|
|
111
|
+
raise TypeError("replyonce: scope must be a non-empty string of at most 512 characters")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _describe(err: BaseException) -> str:
|
|
115
|
+
return f"{type(err).__name__}: {err}"[:500]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ReplyOnce:
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
store: Store,
|
|
122
|
+
*,
|
|
123
|
+
pending_ttl_ms: int = 15 * 60_000,
|
|
124
|
+
max_attempts: int = 3,
|
|
125
|
+
retention_ms: int = 7 * 24 * 3_600_000,
|
|
126
|
+
lease_ttl_ms: int = 60_000,
|
|
127
|
+
classify_error: Optional[Callable[[BaseException], str]] = None,
|
|
128
|
+
reconcile: Optional[Reconciler] = None,
|
|
129
|
+
on_event: Optional[Callable[[dict], None]] = None,
|
|
130
|
+
clock: Optional[Callable[[], int]] = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
if store is None:
|
|
133
|
+
raise TypeError("replyonce: a store is required")
|
|
134
|
+
if pending_ttl_ms <= 0 or max_attempts < 1 or retention_ms <= 0:
|
|
135
|
+
raise ValueError("replyonce: pending_ttl_ms, max_attempts and retention_ms must be positive")
|
|
136
|
+
self.store = store
|
|
137
|
+
self.policy = logic.Policy(pending_ttl_ms, max_attempts, retention_ms)
|
|
138
|
+
self.lease_ttl_ms = lease_ttl_ms
|
|
139
|
+
self._classify = classify_error or default_classify_error
|
|
140
|
+
self._reconcile = reconcile
|
|
141
|
+
self._on_event = on_event
|
|
142
|
+
self._now = clock or _now_ms
|
|
143
|
+
|
|
144
|
+
# -------------------------------------------------------------- helpers
|
|
145
|
+
|
|
146
|
+
def _emit(self, event: dict) -> None:
|
|
147
|
+
if self._on_event is None:
|
|
148
|
+
return
|
|
149
|
+
try:
|
|
150
|
+
self._on_event(event)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass # an observer must never change the outcome
|
|
153
|
+
|
|
154
|
+
def resolve_key(self, key: KeyLike) -> tuple[str, str, str]:
|
|
155
|
+
if isinstance(key, str):
|
|
156
|
+
_assert_scope(key)
|
|
157
|
+
return key, key, "once"
|
|
158
|
+
if isinstance(key, dict):
|
|
159
|
+
key = Key(key["scope"], key["intent"], key.get("payload", MISSING))
|
|
160
|
+
_assert_scope(key.scope)
|
|
161
|
+
return key.scope, effect_key(key.intent, key.payload), key.intent
|
|
162
|
+
|
|
163
|
+
def key(self, intent: str, payload: Any = MISSING) -> str:
|
|
164
|
+
return effect_key(intent, payload)
|
|
165
|
+
|
|
166
|
+
# -------------------------------------------------------------- public API
|
|
167
|
+
|
|
168
|
+
async def once(
|
|
169
|
+
self,
|
|
170
|
+
key: KeyLike,
|
|
171
|
+
effect: Effect,
|
|
172
|
+
*,
|
|
173
|
+
summary: Optional[str] = None,
|
|
174
|
+
classify_error: Optional[Callable[[BaseException], str]] = None,
|
|
175
|
+
reconcile: Optional[Reconciler] = None,
|
|
176
|
+
wait_ms: Optional[int] = None,
|
|
177
|
+
wait_interval_ms: int = 100,
|
|
178
|
+
) -> OnceResult:
|
|
179
|
+
"""Run ``effect`` at most once for this key."""
|
|
180
|
+
scope, ledger_key, intent = self.resolve_key(key)
|
|
181
|
+
return await self._execute(
|
|
182
|
+
scope, ledger_key, intent, None, effect,
|
|
183
|
+
summary=summary, classify_error=classify_error, reconcile=reconcile,
|
|
184
|
+
wait_ms=wait_ms, wait_interval_ms=wait_interval_ms, conflict="none", ack=False,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def check(self, key: KeyLike) -> CheckResult:
|
|
188
|
+
"""Was this already done? Read-only; safe to expose to a model."""
|
|
189
|
+
scope, ledger_key, _ = self.resolve_key(key)
|
|
190
|
+
state = await self.store.read(scope)
|
|
191
|
+
entry = (state or {}).get("effects", {}).get(ledger_key)
|
|
192
|
+
if entry is None:
|
|
193
|
+
return CheckResult(False, "none", None)
|
|
194
|
+
status = logic.effective_status(entry, self._now(), self.policy)
|
|
195
|
+
return CheckResult(status == "committed", status, entry)
|
|
196
|
+
|
|
197
|
+
async def history(self, scope: str) -> list[dict]:
|
|
198
|
+
state = await self.store.read(scope)
|
|
199
|
+
if not state:
|
|
200
|
+
return []
|
|
201
|
+
now = self._now()
|
|
202
|
+
out = [{**e, "status": logic.effective_status(e, now, self.policy)} for e in state["effects"].values()]
|
|
203
|
+
return sorted(out, key=lambda e: e["createdAt"])
|
|
204
|
+
|
|
205
|
+
async def resolve(self, key: KeyLike, outcome: str, result: Any = logic._UNSET) -> Optional[dict]:
|
|
206
|
+
"""Record what an operator or reconciler found out about an ambiguous effect."""
|
|
207
|
+
if outcome not in ("committed", "not_applied"):
|
|
208
|
+
raise ValueError("outcome must be 'committed' or 'not_applied'")
|
|
209
|
+
scope, ledger_key, _ = self.resolve_key(key)
|
|
210
|
+
now = self._now()
|
|
211
|
+
entry = await self.store.transact(
|
|
212
|
+
scope, lambda s: logic.resolve(s, key=ledger_key, outcome=outcome, now=now, policy=self.policy, result=result)
|
|
213
|
+
)
|
|
214
|
+
if entry is not None:
|
|
215
|
+
self._emit({"type": "reconciled", "scope": scope, "key": ledger_key, "outcome": outcome})
|
|
216
|
+
return entry
|
|
217
|
+
|
|
218
|
+
async def begin(
|
|
219
|
+
self,
|
|
220
|
+
scope: str,
|
|
221
|
+
*,
|
|
222
|
+
run_id: Optional[str] = None,
|
|
223
|
+
mode: str = "supersede",
|
|
224
|
+
lease_ttl_ms: Optional[int] = None,
|
|
225
|
+
context_at: Optional[int] = None,
|
|
226
|
+
) -> "Run":
|
|
227
|
+
"""Start (or, with the same ``run_id``, resume) a unit of work on a scope."""
|
|
228
|
+
_assert_scope(scope)
|
|
229
|
+
if mode not in ("supersede", "exclusive"):
|
|
230
|
+
raise ValueError("mode must be 'supersede' or 'exclusive'")
|
|
231
|
+
rid = run_id or str(uuid.uuid4())
|
|
232
|
+
ttl = lease_ttl_ms if lease_ttl_ms is not None else self.lease_ttl_ms
|
|
233
|
+
now = self._now()
|
|
234
|
+
outcome = await self.store.transact(
|
|
235
|
+
scope,
|
|
236
|
+
lambda s: logic.begin(s, run_id=rid, mode=mode, lease_ttl_ms=ttl, now=now, context_at=context_at, policy=self.policy),
|
|
237
|
+
)
|
|
238
|
+
return Run(self, scope, rid, ttl, outcome)
|
|
239
|
+
|
|
240
|
+
async def cancel(self, scope: str, run_id: str) -> bool:
|
|
241
|
+
now = self._now()
|
|
242
|
+
r = await self.store.transact(scope, lambda s: logic.end_run(s, run_id=run_id, reason="cancelled", now=now, policy=self.policy))
|
|
243
|
+
return r is not None
|
|
244
|
+
|
|
245
|
+
# -------------------------------------------------------------- internals
|
|
246
|
+
|
|
247
|
+
async def _renew(self, scope: str, run_id: str, token: int, ttl: int) -> bool:
|
|
248
|
+
now = self._now()
|
|
249
|
+
r = await self.store.transact(
|
|
250
|
+
scope, lambda s: logic.renew(s, run_id=run_id, token=token, lease_ttl_ms=ttl, now=now, policy=self.policy)
|
|
251
|
+
)
|
|
252
|
+
return r["ok"]
|
|
253
|
+
|
|
254
|
+
async def _end(self, scope: str, run_id: str, token: int, reason: str) -> None:
|
|
255
|
+
now = self._now()
|
|
256
|
+
await self.store.transact(
|
|
257
|
+
scope, lambda s: logic.end_run(s, run_id=run_id, reason=reason, now=now, policy=self.policy, token=token)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
async def _execute(
|
|
261
|
+
self,
|
|
262
|
+
scope: str,
|
|
263
|
+
key: str,
|
|
264
|
+
intent: str,
|
|
265
|
+
run: Optional[tuple[str, int, int]],
|
|
266
|
+
effect: Effect,
|
|
267
|
+
*,
|
|
268
|
+
summary: Optional[str],
|
|
269
|
+
classify_error,
|
|
270
|
+
reconcile,
|
|
271
|
+
wait_ms: Optional[int],
|
|
272
|
+
wait_interval_ms: int,
|
|
273
|
+
conflict: str,
|
|
274
|
+
ack: bool,
|
|
275
|
+
) -> OnceResult:
|
|
276
|
+
run_id, token, ttl = run if run else (None, None, self.lease_ttl_ms)
|
|
277
|
+
classify = classify_error or self._classify
|
|
278
|
+
reconciler = reconcile or self._reconcile
|
|
279
|
+
wait_until = self._now() + wait_ms if wait_ms else 0
|
|
280
|
+
reconciled = False
|
|
281
|
+
|
|
282
|
+
for _ in range(MAX_ROUNDS):
|
|
283
|
+
attempt_id = str(uuid.uuid4())
|
|
284
|
+
now = self._now()
|
|
285
|
+
claimed = await self.store.transact(
|
|
286
|
+
scope,
|
|
287
|
+
lambda s: logic.claim(
|
|
288
|
+
s, scope, key=key, intent=intent, summary=summary, run_id=run_id, token=token,
|
|
289
|
+
attempt_id=attempt_id, conflict=conflict, ack=ack, lease_ttl_ms=ttl, now=now, policy=self.policy,
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
kind = claimed["kind"]
|
|
293
|
+
if kind == "replay":
|
|
294
|
+
self._emit({"type": "replayed", "scope": scope, "key": key})
|
|
295
|
+
return OnceResult("replayed", True, key, value=claimed["entry"].get("result"), entry=claimed["entry"])
|
|
296
|
+
if kind == "refused":
|
|
297
|
+
self._emit({"type": "refused", "scope": scope, "key": key, "reason": claimed["reason"], "runId": run_id})
|
|
298
|
+
return OnceResult(
|
|
299
|
+
"refused", False, key, reason=claimed["reason"],
|
|
300
|
+
conflicts=claimed.get("conflicts"), entry=claimed.get("entry"),
|
|
301
|
+
)
|
|
302
|
+
if kind == "in_flight":
|
|
303
|
+
if wait_ms and self._now() < wait_until:
|
|
304
|
+
await self._wait_for_settle(scope, key, wait_until, wait_interval_ms)
|
|
305
|
+
continue
|
|
306
|
+
self._emit({"type": "in_flight", "scope": scope, "key": key})
|
|
307
|
+
return OnceResult("in_flight", False, key, entry=claimed["entry"])
|
|
308
|
+
if kind == "ambiguous":
|
|
309
|
+
if reconciler is not None and not reconciled:
|
|
310
|
+
reconciled = True
|
|
311
|
+
try:
|
|
312
|
+
verdict = await reconciler(claimed["entry"])
|
|
313
|
+
except Exception:
|
|
314
|
+
verdict = {"outcome": "unknown"}
|
|
315
|
+
outcome = (verdict or {}).get("outcome", "unknown")
|
|
316
|
+
if outcome in ("committed", "not_applied"):
|
|
317
|
+
at = self._now()
|
|
318
|
+
res = verdict.get("result", logic._UNSET) if outcome == "committed" else logic._UNSET
|
|
319
|
+
resolved = await self.store.transact(
|
|
320
|
+
scope,
|
|
321
|
+
lambda s: logic.resolve(
|
|
322
|
+
s, key=key, outcome=outcome, now=at, policy=self.policy, result=res,
|
|
323
|
+
attempt_id=claimed["entry"]["attemptId"],
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
if resolved is not None:
|
|
327
|
+
self._emit({"type": "reconciled", "scope": scope, "key": key, "outcome": outcome})
|
|
328
|
+
continue
|
|
329
|
+
self._emit({"type": "ambiguous", "scope": scope, "key": key})
|
|
330
|
+
return OnceResult("ambiguous", False, key, entry=claimed["entry"])
|
|
331
|
+
return await self._run_acquired(scope, key, claimed["entry"], effect, classify)
|
|
332
|
+
|
|
333
|
+
state = await self.store.read(scope)
|
|
334
|
+
entry = (state or {}).get("effects", {}).get(key)
|
|
335
|
+
if entry and entry["status"] == "committed":
|
|
336
|
+
return OnceResult("replayed", True, key, value=entry.get("result"), entry=entry)
|
|
337
|
+
if entry:
|
|
338
|
+
return OnceResult("in_flight", False, key, entry=entry)
|
|
339
|
+
raise RuntimeError("replyonce: could not claim key after several rounds")
|
|
340
|
+
|
|
341
|
+
async def _run_acquired(self, scope, key, entry, effect, classify) -> OnceResult:
|
|
342
|
+
attempt, attempt_id, run_id = entry["attempt"], entry["attemptId"], entry["runId"]
|
|
343
|
+
self._emit({"type": "claimed", "scope": scope, "key": key, "attempt": attempt, "runId": run_id})
|
|
344
|
+
ctx = EffectContext(
|
|
345
|
+
key=key, scope=scope, intent=entry["intent"], attempt=attempt,
|
|
346
|
+
idempotency_key=provider_idempotency_key(scope, key), fencing_token=entry["token"],
|
|
347
|
+
deadline_ms=entry["claimedAt"] + self.policy.pending_ttl_ms,
|
|
348
|
+
)
|
|
349
|
+
try:
|
|
350
|
+
value = effect(ctx)
|
|
351
|
+
if inspect.isawaitable(value):
|
|
352
|
+
value = await value
|
|
353
|
+
except Exception as err: # noqa: BLE001 - classified below
|
|
354
|
+
try:
|
|
355
|
+
cls = "not_applied" if classify(err) == "not_applied" else "ambiguous"
|
|
356
|
+
except Exception:
|
|
357
|
+
cls = "ambiguous"
|
|
358
|
+
outcome = "failed" if cls == "not_applied" else "ambiguous"
|
|
359
|
+
try:
|
|
360
|
+
await self._settle(scope, key=key, attempt_id=attempt_id, outcome=outcome, error=_describe(err))
|
|
361
|
+
except Exception:
|
|
362
|
+
pass
|
|
363
|
+
if outcome == "failed":
|
|
364
|
+
self._emit({"type": "failed", "scope": scope, "key": key, "attempt": attempt, "error": err})
|
|
365
|
+
return OnceResult("failed", False, key, attempt=attempt, error=err, retryable=attempt < self.policy.max_attempts)
|
|
366
|
+
self._emit({"type": "ambiguous", "scope": scope, "key": key, "error": err})
|
|
367
|
+
try:
|
|
368
|
+
current = ((await self.store.read(scope)) or {}).get("effects", {}).get(key) or entry
|
|
369
|
+
except Exception:
|
|
370
|
+
current = entry
|
|
371
|
+
return OnceResult("ambiguous", False, key, entry=current, error=err)
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
stored = None if value is None else json.loads(json.dumps(value))
|
|
375
|
+
except (TypeError, ValueError):
|
|
376
|
+
stored = None
|
|
377
|
+
self._emit({"type": "unserializable_result", "scope": scope, "key": key})
|
|
378
|
+
try:
|
|
379
|
+
settled = await self._settle(scope, key=key, attempt_id=attempt_id, outcome="committed", result=stored)
|
|
380
|
+
except Exception:
|
|
381
|
+
self._emit({"type": "late_settle", "scope": scope, "key": key, "attempt": attempt})
|
|
382
|
+
return OnceResult("executed", True, key, value=value, attempt=attempt, late_settle=True)
|
|
383
|
+
self._emit({"type": "committed", "scope": scope, "key": key, "attempt": attempt, "runId": run_id})
|
|
384
|
+
if settled["kind"] == "stale" or settled.get("wasAbandoned"):
|
|
385
|
+
self._emit({"type": "late_settle", "scope": scope, "key": key, "attempt": attempt})
|
|
386
|
+
return OnceResult("executed", True, key, value=value, attempt=attempt, late_settle=True)
|
|
387
|
+
return OnceResult("executed", True, key, value=value, attempt=attempt)
|
|
388
|
+
|
|
389
|
+
async def _settle(self, scope: str, **kw) -> dict:
|
|
390
|
+
last: Optional[BaseException] = None
|
|
391
|
+
for i in range(4):
|
|
392
|
+
try:
|
|
393
|
+
now = self._now()
|
|
394
|
+
return await self.store.transact(scope, lambda s: logic.settle(s, now=now, policy=self.policy, **kw))
|
|
395
|
+
except Exception as err: # noqa: BLE001
|
|
396
|
+
last = err
|
|
397
|
+
await asyncio.sleep(0.025 * 4**i)
|
|
398
|
+
assert last is not None
|
|
399
|
+
raise last
|
|
400
|
+
|
|
401
|
+
async def _wait_for_settle(self, scope: str, key: str, until: int, interval_ms: int) -> None:
|
|
402
|
+
while self._now() < until:
|
|
403
|
+
await asyncio.sleep(min(interval_ms, max(1, until - self._now())) / 1000)
|
|
404
|
+
e = ((await self.store.read(scope)) or {}).get("effects", {}).get(key)
|
|
405
|
+
if e is None or logic.effective_status(e, self._now(), self.policy) != "pending":
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class Run:
|
|
410
|
+
"""A unit of agent work on one scope, holding a fencing token."""
|
|
411
|
+
|
|
412
|
+
def __init__(self, ro: ReplyOnce, scope: str, run_id: str, lease_ttl_ms: int, outcome: dict) -> None:
|
|
413
|
+
self._ro = ro
|
|
414
|
+
self.scope = scope
|
|
415
|
+
self.run_id = run_id
|
|
416
|
+
self._ttl = lease_ttl_ms
|
|
417
|
+
self.state: str = outcome["state"]
|
|
418
|
+
if self.state == "busy":
|
|
419
|
+
self.fencing_token: Optional[int] = None
|
|
420
|
+
self.prior_effects: list[dict] = []
|
|
421
|
+
self.busy_holder: Optional[dict] = outcome["holder"]
|
|
422
|
+
else:
|
|
423
|
+
self.fencing_token = outcome["run"]["token"]
|
|
424
|
+
self.prior_effects = outcome["priorEffects"]
|
|
425
|
+
self.busy_holder = None
|
|
426
|
+
|
|
427
|
+
@property
|
|
428
|
+
def can_write(self) -> bool:
|
|
429
|
+
return self.state in ("new", "resumed")
|
|
430
|
+
|
|
431
|
+
@property
|
|
432
|
+
def already_wrote(self) -> bool:
|
|
433
|
+
return any(e["status"] != "failed" for e in self.prior_effects)
|
|
434
|
+
|
|
435
|
+
async def once(self, intent: str, payload: Any, effect: Effect, *, summary: Optional[str] = None,
|
|
436
|
+
conflict: str = "any", ack: bool = True, classify_error=None, reconcile=None,
|
|
437
|
+
wait_ms: Optional[int] = None, wait_interval_ms: int = 100) -> OnceResult:
|
|
438
|
+
"""Run ``effect`` at most once per (scope, intent, payload), fenced to this run."""
|
|
439
|
+
key = effect_key(intent, payload)
|
|
440
|
+
if self.fencing_token is None:
|
|
441
|
+
return OnceResult("refused", False, key, reason="busy")
|
|
442
|
+
return await self._ro._execute(
|
|
443
|
+
self.scope, key, intent, (self.run_id, self.fencing_token, self._ttl), effect,
|
|
444
|
+
summary=summary, classify_error=classify_error, reconcile=reconcile,
|
|
445
|
+
wait_ms=wait_ms, wait_interval_ms=wait_interval_ms, conflict=conflict, ack=ack,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
async def check(self, intent: str, payload: Any = MISSING) -> CheckResult:
|
|
449
|
+
return await self._ro.check(Key(self.scope, intent, payload))
|
|
450
|
+
|
|
451
|
+
async def history(self) -> list[dict]:
|
|
452
|
+
return await self._ro.history(self.scope)
|
|
453
|
+
|
|
454
|
+
async def heartbeat(self) -> bool:
|
|
455
|
+
if self.fencing_token is None:
|
|
456
|
+
return False
|
|
457
|
+
return await self._ro._renew(self.scope, self.run_id, self.fencing_token, self._ttl)
|
|
458
|
+
|
|
459
|
+
async def end(self, reason: str = "completed") -> None:
|
|
460
|
+
if self.fencing_token is None:
|
|
461
|
+
return
|
|
462
|
+
await self._ro._end(self.scope, self.run_id, self.fencing_token, reason)
|
replyonce/_key.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Keys and canonical hashing, byte-compatible with the TypeScript package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import datetime as _dt
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
MISSING: Any = object()
|
|
14
|
+
"""Sentinel for "no payload" (distinct from a ``None`` payload, which hashes as JSON null)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _sort_key(k: str) -> bytes:
|
|
18
|
+
# JavaScript sorts object keys by UTF-16 code units.
|
|
19
|
+
return k.encode("utf-16-be")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _iso(value: _dt.datetime) -> str:
|
|
23
|
+
if value.tzinfo is None:
|
|
24
|
+
value = value.replace(tzinfo=_dt.timezone.utc)
|
|
25
|
+
value = value.astimezone(_dt.timezone.utc)
|
|
26
|
+
return value.strftime("%Y-%m-%dT%H:%M:%S.") + f"{value.microsecond // 1000:03d}Z"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _normalize(value: Any, seen: set[int]) -> Any:
|
|
30
|
+
if value is None or isinstance(value, (bool, str)):
|
|
31
|
+
return value
|
|
32
|
+
if isinstance(value, int):
|
|
33
|
+
return value
|
|
34
|
+
if isinstance(value, float):
|
|
35
|
+
if math.isnan(value):
|
|
36
|
+
return "NaN"
|
|
37
|
+
if math.isinf(value):
|
|
38
|
+
return "Infinity" if value > 0 else "-Infinity"
|
|
39
|
+
return int(value) if value.is_integer() else value
|
|
40
|
+
if isinstance(value, _dt.datetime):
|
|
41
|
+
return _iso(value)
|
|
42
|
+
if isinstance(value, _dt.date):
|
|
43
|
+
return _iso(_dt.datetime(value.year, value.month, value.day, tzinfo=_dt.timezone.utc))
|
|
44
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
45
|
+
value = dataclasses.asdict(value)
|
|
46
|
+
elif hasattr(value, "model_dump") and callable(value.model_dump):
|
|
47
|
+
value = value.model_dump()
|
|
48
|
+
if id(value) in seen:
|
|
49
|
+
raise TypeError("replyonce: cannot hash a circular payload")
|
|
50
|
+
if isinstance(value, (list, tuple)):
|
|
51
|
+
seen.add(id(value))
|
|
52
|
+
out = [_normalize(v, seen) for v in value]
|
|
53
|
+
seen.discard(id(value))
|
|
54
|
+
return out
|
|
55
|
+
if isinstance(value, dict):
|
|
56
|
+
seen.add(id(value))
|
|
57
|
+
items = sorted(((str(k), v) for k, v in value.items()), key=lambda kv: _sort_key(kv[0]))
|
|
58
|
+
obj = {k: _normalize(v, seen) for k, v in items}
|
|
59
|
+
seen.discard(id(value))
|
|
60
|
+
return obj
|
|
61
|
+
raise TypeError(f"replyonce: cannot hash a payload of type {type(value).__name__}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def canonical_json(value: Any) -> str:
|
|
65
|
+
"""Deterministic JSON: keys sorted, no whitespace. Matches the TypeScript ``canonicalJson``."""
|
|
66
|
+
return json.dumps(_normalize(value, set()), separators=(",", ":"), ensure_ascii=False, sort_keys=False)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sha256_hex(text: str) -> str:
|
|
70
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def hash_payload(payload: Any) -> str:
|
|
74
|
+
return sha256_hex(canonical_json(payload))[:32]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_SAFE_INTENT = re.compile(r"^[A-Za-z0-9_.:-]{1,100}$")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def effect_key(intent: str, payload: Any = MISSING) -> str:
|
|
81
|
+
"""Ledger key for an intent plus the business identity of the payload."""
|
|
82
|
+
if not isinstance(intent, str) or not _SAFE_INTENT.match(intent):
|
|
83
|
+
raise TypeError(f"replyonce: intent must match {_SAFE_INTENT.pattern} (got {intent!r})")
|
|
84
|
+
return intent if payload is MISSING else f"{intent}:{hash_payload(payload)}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def provider_idempotency_key(scope: str, key: str) -> str:
|
|
88
|
+
"""Stable idempotency key to hand to a provider (Stripe-style ``Idempotency-Key``)."""
|
|
89
|
+
return "ro_" + sha256_hex(f"{scope}\u0000{key}")[:40]
|