Skip to content

Commit 1ee6f5b

Browse files
bokelleyclaude
andauthored
feat(server): lazy idempotency backend wrapper (JS #2136 parity) (#928)
* feat(server): add lazy idempotency backend wrapper (JS #2136 parity) Add LazyBackend / create_lazy_backend, mirroring the JS SDK createLazyBackend. Defers idempotency backend construction until first use for adopters whose Pg/Redis pool resolves asynchronously from app infrastructure. - Resolve-once, memoized; concurrent first-use shares a single factory invocation via an asyncio.Lock. - A failed factory attempt is not memoized — a later call retries. - Accepts sync or async factories; validates the resolved value is an IdempotencyBackend. - clear_all is opt-in (allow_clear_all=True); method presence is the reset-safety contract, so it is genuinely absent otherwise. - Delegates get / put / delete_expired to the resolved instance. Exported from adcp.server.idempotency (PgBackend precedent: not all backends are re-exported at the top-level adcp.server namespace). Closes #927 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(server): address idempotency test static-analysis findings Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 11e5cda commit 1ee6f5b

3 files changed

Lines changed: 373 additions & 0 deletions

File tree

src/adcp/server/idempotency/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ async def get_adcp_capabilities(self, params, context=None):
6565
canonical_json_sha256,
6666
strip_excluded_fields,
6767
)
68+
from adcp.server.idempotency.lazy import (
69+
LazyBackend,
70+
LazyBackendFactory,
71+
create_lazy_backend,
72+
)
6873
from adcp.server.idempotency.store import IdempotencyStore, is_wrapped
6974
from adcp.server.idempotency.webhook_dedup import WebhookDedupStore
7075

@@ -73,10 +78,13 @@ async def get_adcp_capabilities(self, params, context=None):
7378
"EXCLUDED_FIELDS",
7479
"IdempotencyBackend",
7580
"IdempotencyStore",
81+
"LazyBackend",
82+
"LazyBackendFactory",
7683
"MemoryBackend",
7784
"PgBackend",
7885
"WebhookDedupStore",
7986
"canonical_json_sha256",
87+
"create_lazy_backend",
8088
"is_wrapped",
8189
"strip_excluded_fields",
8290
]
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Deferred-construction wrapper for :class:`IdempotencyBackend`.
2+
3+
Mirrors the JS SDK ``createLazyBackend`` (adcp-client#2136). Use this when the
4+
real backend depends on application infrastructure that resolves asynchronously
5+
*after* the SDK server is constructed — for example a Postgres pool or Redis
6+
client produced by an async bootstrap::
7+
8+
from adcp.server.idempotency import (
9+
IdempotencyStore,
10+
LazyBackend,
11+
PgBackend,
12+
)
13+
14+
async def _resolve() -> IdempotencyBackend:
15+
pool = await app.get_pg_pool()
16+
backend = PgBackend(pool=pool)
17+
await backend.create_schema()
18+
return backend
19+
20+
store = IdempotencyStore(backend=LazyBackend(_resolve), ttl_seconds=86400)
21+
22+
The underlying backend is resolved on first use and memoized (resolve-once).
23+
Concurrent first calls share a single factory invocation. If the factory
24+
raises, the wrapper forgets that failed attempt so a later call can retry.
25+
26+
``clear_all`` is **not** exposed by default: per the JS contract, the presence
27+
of a bulk-clear method is treated as the backend's explicit "safe to flush"
28+
signal, so it must be opted into via ``allow_clear_all=True``. Enable it only
29+
when every backend the factory can return safely permits bulk clearing (for
30+
example a dedicated test/dev :class:`MemoryBackend`, never a shared Redis).
31+
"""
32+
33+
from __future__ import annotations
34+
35+
import asyncio
36+
from collections.abc import Awaitable, Callable
37+
38+
from adcp.server.idempotency.backends import CachedResponse, IdempotencyBackend
39+
40+
# A factory may be sync (returns a backend) or async (returns an awaitable that
41+
# resolves to a backend) — both are normalized through ``_resolve`` below.
42+
LazyBackendFactory = Callable[[], "IdempotencyBackend | Awaitable[IdempotencyBackend]"]
43+
44+
45+
class LazyBackend(IdempotencyBackend):
46+
"""Resolve an :class:`IdempotencyBackend` lazily on first use.
47+
48+
:param factory: A zero-arg callable returning an :class:`IdempotencyBackend`
49+
or an awaitable that resolves to one. Invoked at most once across the
50+
wrapper's lifetime once it succeeds; re-invoked only if a prior attempt
51+
raised.
52+
:param allow_clear_all: When ``True``, expose :meth:`clear_all`, delegating
53+
to the resolved backend's ``clear_all`` or ``clear`` method. Defaults to
54+
``False`` because bulk clearing is dangerous on shared production stores
55+
and the SDK uses method presence as the reset-safety contract.
56+
57+
Concurrency: the first ``get``/``put``/``delete_expired`` triggers
58+
resolution. Multiple concurrent first operations share a single factory
59+
invocation via an :class:`asyncio.Lock`; later callers reuse the cached
60+
instance without locking on the hot path.
61+
"""
62+
63+
def __init__(
64+
self,
65+
factory: LazyBackendFactory,
66+
*,
67+
allow_clear_all: bool = False,
68+
) -> None:
69+
self._factory = factory
70+
self._allow_clear_all = allow_clear_all
71+
self._backend: IdempotencyBackend | None = None
72+
self._lock = asyncio.Lock()
73+
74+
async def _resolve(self) -> IdempotencyBackend:
75+
"""Return the resolved backend, invoking the factory once on first use.
76+
77+
Resolve-once + concurrency-safe: the fast path returns the memoized
78+
instance without locking. The slow path holds ``_lock`` so concurrent
79+
first callers share a single factory invocation; the double-check inside
80+
the lock means a caller that waited on the lock sees the instance the
81+
winner resolved. A factory that raises is not memoized — the next call
82+
retries.
83+
"""
84+
cached = self._backend
85+
if cached is not None:
86+
return cached
87+
async with self._lock:
88+
# Re-read under the lock: a task that lost the race to acquire it
89+
# must observe the winner's resolved instance, not re-run the
90+
# factory. (Read into a local so the narrowing is on the local,
91+
# not the instance attribute another task may have mutated.)
92+
cached = self._backend
93+
if cached is not None:
94+
return cached
95+
result = self._factory()
96+
resolved = await result if isinstance(result, Awaitable) else result
97+
if not isinstance(resolved, IdempotencyBackend):
98+
raise TypeError(
99+
"LazyBackend factory must resolve to an IdempotencyBackend, "
100+
f"got {type(resolved).__name__}"
101+
)
102+
self._backend = resolved
103+
return resolved
104+
105+
async def get(self, scope_key: str, key: str) -> CachedResponse | None:
106+
return await (await self._resolve()).get(scope_key, key)
107+
108+
async def put(self, scope_key: str, key: str, entry: CachedResponse) -> None:
109+
await (await self._resolve()).put(scope_key, key, entry)
110+
111+
async def delete_expired(self, now_epoch: float | None = None) -> int:
112+
return await (await self._resolve()).delete_expired(now_epoch)
113+
114+
async def _clear_all(self) -> None:
115+
"""Delegate a bulk clear to the resolved backend.
116+
117+
Resolves the backend (so the factory runs if it hasn't yet) and
118+
delegates to its ``clear_all`` or ``clear`` method, raising if the
119+
resolved backend supports neither. Exposed as ``clear_all`` only when
120+
the wrapper is constructed with ``allow_clear_all=True`` (see
121+
:meth:`__getattr__`).
122+
"""
123+
backend = await self._resolve()
124+
clear = getattr(backend, "clear_all", None) or getattr(backend, "clear", None)
125+
if clear is None:
126+
raise NotImplementedError(
127+
f"Resolved backend {type(backend).__name__} does not support "
128+
"clear_all() or clear()."
129+
)
130+
await clear()
131+
132+
def __getattr__(self, name: str) -> object:
133+
"""Expose ``clear_all`` only when opted in.
134+
135+
Mirrors the JS wrapper, which attaches ``clearAll`` to the returned
136+
object solely when ``{ clearAll: true }`` — reset-safety code uses
137+
``hasattr``/method presence as the "safe to flush" contract, so the
138+
attribute must genuinely be absent otherwise. ``__getattr__`` is only
139+
consulted for names not found normally, so this never shadows the
140+
delegating methods above.
141+
"""
142+
if name == "clear_all" and self.__dict__.get("_allow_clear_all"):
143+
return self._clear_all
144+
raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
145+
146+
147+
def create_lazy_backend(
148+
factory: LazyBackendFactory,
149+
*,
150+
allow_clear_all: bool = False,
151+
) -> LazyBackend:
152+
"""Construct a :class:`LazyBackend` — functional alias mirroring the JS
153+
``createLazyBackend`` factory shape.
154+
155+
See :class:`LazyBackend` for semantics and parameter documentation.
156+
"""
157+
return LazyBackend(factory, allow_clear_all=allow_clear_all)

0 commit comments

Comments
 (0)