Skip to content

Commit 28c4f64

Browse files
bokelleysangilish
andauthored
feat(decisioning): add full stack registry factory (#862)
Co-authored-by: sangilish <56685007+sangilish@users.noreply.github.com>
1 parent 2657a8b commit 28c4f64

2 files changed

Lines changed: 148 additions & 0 deletions

File tree

src/adcp/decisioning/pg/buyer_agent_registry.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
import logging
8383
import re
8484
import threading
85+
import time
8586
from collections.abc import Callable
8687
from typing import TYPE_CHECKING, Any
8788

@@ -96,6 +97,7 @@
9697
if TYPE_CHECKING:
9798
from psycopg_pool import ConnectionPool
9899

100+
from adcp.audit_sink import AuditSink
99101
from adcp.decisioning.registry_cache import CachingBuyerAgentRegistry
100102

101103
logger = logging.getLogger(__name__)
@@ -417,6 +419,73 @@ def with_caching(
417419
self.add_mutation_observer(lambda _op, _agent_url: cache.clear_sync())
418420
return cache
419421

422+
def with_full_stack(
423+
self,
424+
*,
425+
ttl_seconds: float = 60.0,
426+
max_entries: int = 4096,
427+
hit_callback: Callable[[str], None] | None = None,
428+
rps_per_tenant: float = 100.0,
429+
burst: float | None = None,
430+
audit_sink: AuditSink | None = None,
431+
sink_timeout_seconds: float = 5.0,
432+
time_source: Callable[[], float] = time.monotonic,
433+
) -> CachingBuyerAgentRegistry:
434+
"""Return the canonical production registry wrapper stack.
435+
436+
Builds and returns ``Caching(RateLimited(Auditing(self)))``:
437+
438+
* cache is outermost so cached hits skip rate-limit accounting
439+
and DB work;
440+
* rate limiting applies only to cache misses that need inner
441+
resolution;
442+
* auditing wraps the SQL-backed store so DB ``resolved`` /
443+
``miss`` outcomes are recorded.
444+
445+
``audit_sink`` and ``sink_timeout_seconds`` are threaded through
446+
all three layers, so cache hits/misses, rate-limit rejects, and
447+
terminal DB outcomes can all land in the same audit trail.
448+
``time_source`` is shared by the cache and rate limiter for
449+
deterministic tests.
450+
451+
Mutations through this :class:`PgBuyerAgentRegistry` instance
452+
clear the returned cache via the same observer wiring as
453+
:meth:`with_caching`. Adopters needing a different layer order
454+
should compose :class:`CachingBuyerAgentRegistry`,
455+
:class:`RateLimitedBuyerAgentRegistry`, and
456+
:class:`AuditingBuyerAgentRegistry` manually.
457+
"""
458+
from adcp.decisioning.registry_cache import (
459+
AuditingBuyerAgentRegistry,
460+
CachingBuyerAgentRegistry,
461+
RateLimitedBuyerAgentRegistry,
462+
)
463+
464+
audited = AuditingBuyerAgentRegistry(
465+
self,
466+
audit_sink=audit_sink,
467+
sink_timeout_seconds=sink_timeout_seconds,
468+
)
469+
rate_limited = RateLimitedBuyerAgentRegistry(
470+
audited,
471+
rps_per_tenant=rps_per_tenant,
472+
burst=burst,
473+
audit_sink=audit_sink,
474+
sink_timeout_seconds=sink_timeout_seconds,
475+
time_source=time_source,
476+
)
477+
cache = CachingBuyerAgentRegistry(
478+
rate_limited,
479+
ttl_seconds=ttl_seconds,
480+
max_entries=max_entries,
481+
hit_callback=hit_callback,
482+
audit_sink=audit_sink,
483+
sink_timeout_seconds=sink_timeout_seconds,
484+
time_source=time_source,
485+
)
486+
self.add_mutation_observer(lambda _op, _agent_url: cache.clear_sync())
487+
return cache
488+
420489
def _notify_mutation(self, op: str, agent_url: str) -> None:
421490
"""Fire registered observers; log and swallow exceptions."""
422491
with self._mutation_observers_lock:

tests/conformance/decisioning/test_pg_buyer_agent_registry.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,34 @@
3333
allow_module_level=True,
3434
)
3535

36+
from adcp.audit_sink import AuditEvent # noqa: E402
3637
from adcp.decisioning import ( # noqa: E402
3738
ApiKeyCredential,
3839
BuyerAgent,
3940
BuyerAgentDefaultTerms,
4041
OAuthCredential,
4142
)
4243
from adcp.decisioning.pg import PgBuyerAgentRegistry # noqa: E402
44+
from adcp.decisioning.types import AdcpError # noqa: E402
45+
46+
47+
class CapturingAuditSink:
48+
def __init__(self) -> None:
49+
self.events: list[AuditEvent] = []
50+
51+
async def record(self, event: AuditEvent) -> None:
52+
self.events.append(event)
53+
54+
55+
class FakeClock:
56+
def __init__(self, start: float = 1000.0) -> None:
57+
self.now = start
58+
59+
def __call__(self) -> float:
60+
return self.now
61+
62+
def advance(self, seconds: float) -> None:
63+
self.now += seconds
4364

4465

4566
@pytest.fixture()
@@ -436,3 +457,61 @@ def test_with_caching_returns_wired_cache(isolated_pool) -> None:
436457
"Cache served stale 'active' after pg.set_status — with_caching "
437458
"observer did not fire or did not invalidate"
438459
)
460+
461+
462+
def test_with_full_stack_wires_cache_invalidation_and_audit(isolated_pool) -> None:
463+
registry = _registry(isolated_pool)
464+
sink = CapturingAuditSink()
465+
stack = registry.with_full_stack(
466+
ttl_seconds=60.0,
467+
rps_per_tenant=1000.0,
468+
audit_sink=sink,
469+
)
470+
471+
registry.upsert(
472+
BuyerAgent(
473+
agent_url="https://full-stack/",
474+
display_name="Full Stack",
475+
status="active",
476+
)
477+
)
478+
479+
first = asyncio.run(stack.resolve_by_agent_url("https://full-stack/"))
480+
second = asyncio.run(stack.resolve_by_agent_url("https://full-stack/"))
481+
assert first is not None
482+
assert second is not None
483+
assert first.status == "active"
484+
assert second.status == "active"
485+
486+
registry.set_status("https://full-stack/", "suspended")
487+
after_mutation = asyncio.run(stack.resolve_by_agent_url("https://full-stack/"))
488+
assert after_mutation is not None
489+
assert after_mutation.status == "suspended"
490+
491+
outcomes = [event.details["outcome"] for event in sink.events]
492+
assert outcomes.count("resolved") == 2
493+
assert outcomes.count("cached_hit") == 1
494+
495+
496+
def test_with_full_stack_rate_limit_fires_and_audits(isolated_pool) -> None:
497+
registry = _registry(isolated_pool)
498+
sink = CapturingAuditSink()
499+
clock = FakeClock()
500+
stack = registry.with_full_stack(
501+
ttl_seconds=0.1,
502+
rps_per_tenant=1.0,
503+
burst=1.0,
504+
audit_sink=sink,
505+
time_source=clock,
506+
)
507+
508+
assert asyncio.run(stack.resolve_by_agent_url("https://rate-limited/")) is None
509+
clock.advance(0.2)
510+
511+
with pytest.raises(AdcpError) as exc_info:
512+
asyncio.run(stack.resolve_by_agent_url("https://rate-limited/"))
513+
514+
assert exc_info.value.code == "PERMISSION_DENIED"
515+
outcomes = [event.details["outcome"] for event in sink.events]
516+
assert outcomes.count("miss") == 1
517+
assert outcomes.count("rate_limited") == 1

0 commit comments

Comments
 (0)