Skip to content

feat(pg): coalesce same-key PgBackend.hold() in-process, and offer LazyBackend-style lazy open for the other Pg stores #1297

Closed

Description

@bokelley

Summary

Two gaps on 8.0.0, both in the Postgres stores:

  1. PgBackend.hold() takes one lock_pool connection per concurrent request, even when those requests are duplicates of the same key in the same process (adcp/server/idempotency/backends.py:572-586). Each one checks out a connection, then blocks in pg_advisory_xact_lock. A burst of buyer retries for a single key can tie up every lock connection in a worker, which stalls idempotent writes for unrelated keys until the first handler finishes.
  2. Only the idempotency backend has a lazy-open path. LazyBackend (server/idempotency/lazy.py) resolves the backend once on first use. PgProposalStore (decisioning/pg/proposal_store.py:361-377) and PgTaskRegistry (decisioning/pg/task_registry.py:135-163) require a pool that is already open, from the caller, on the serving loop. Their docs say "We don't open, own, or close the pool."

Reproduction (adcp 8.0.0)

The script below uses a stub pool that emulates pg_advisory_xact_lock and counts how many lock_pool connections are checked out at once. PgBackend itself is the real 8.0.0 class.

import asyncio
from contextlib import asynccontextmanager

from adcp.server.idempotency import PgBackend


class StubPool:
    def __init__(self):
        self.checked_out = self.peak = 0
        self.advisory = {}

    @asynccontextmanager
    async def connection(self):
        self.checked_out += 1
        self.peak = max(self.peak, self.checked_out)
        conn = StubConn(self)
        try:
            yield conn
        finally:
            if conn.held is not None:
                conn.held.release()
            self.checked_out -= 1


class StubConn:
    def __init__(self, pool):
        self.pool, self.held = pool, None

    @asynccontextmanager
    async def transaction(self):
        yield

    async def execute(self, sql, params=()):
        if "pg_advisory_xact_lock" in sql:
            self.held = self.pool.advisory.setdefault(params[0], asyncio.Lock())
            await self.held.acquire()


async def main():
    lock_pool = StubPool()
    backend = PgBackend(pool=StubPool(), lock_pool=lock_pool)

    async def duplicate_request():
        async with backend.hold("tenant:buyer", "same-key"):
            await asyncio.sleep(0.05)  # handler running

    await asyncio.gather(*(duplicate_request() for _ in range(10)))
    print("10 same-key requests in one process -> peak lock_pool connections:", lock_pool.peak)

asyncio.run(main())
10 same-key requests in one process -> peak lock_pool connections: 10

MemoryBackend.hold already uses a per-key asyncio.Lock (backends.py:261-270). Putting the same lock in front of the advisory lock in PgBackend would bring the count down to one connection per distinct key per process.

Why an adopter hits this

Embedded Sales Agent overrides hold in _LazyBootstrapPgBackend (core/idempotency.py, ~70 LOC). The override keeps a reference-counted dict[(scope_key, key)] -> asyncio.Lock, so same-process duplicates wait in memory instead of each taking a scarce advisory-lock connection. The lock pool is sized by CORE_IDEMPOTENCY_LOCK_POOL_SIZE, default 10.

For lazy open, ESA subclasses PgProposalStore as _LazyOpenPgProposalStore (core/decisioning/proposal_store.py). The subclass overrides all eight public methods to call pool.open() once under a lock. The store singleton is built synchronously at platform construction and rebuilt per test database, so a one-time lifespan open() doesn't cover it. PgTaskRegistry is opened from a serve() startup hook (open_task_registry, core/decisioning/task_registry.py), which works but has to be wired by hand. Each new SDK store method has to be added to these overrides, or it hits a closed pool.

Ask

  1. In PgBackend.hold, take a process-local per-key asyncio.Lock (weak-valued, like MemoryBackend) before checking out a lock_pool connection.
  2. Make lazy construction general. Either accept a pool factory (pool: AsyncConnectionPool | Callable[[], Awaitable[AsyncConnectionPool]]) on PgProposalStore, PgTaskRegistry and PgTaskWebhookOutbox, or ship LazyProposalStore / LazyTaskRegistry wrappers with the same resolve-once semantics as LazyBackend.

Activity

  1. added
    enhancementNew feature or request
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Oct 2, 2026
  2. bokelley commented on Oct 2, 2026

    @bokelley
    ContributorAuthor

    Triage

    Classification: Feature request (two non-breaking optimizations for Postgres stores)
    Bucket(s): middleware (idempotency), client (decisioning stores) — no exact-match labels found for these buckets; flagged in run summary
    Status: ready-to-implement
    Milestone: omitted — no target version named

    What the experts said:

    • code-reviewer: Both asks are non-breaking and well-scoped. Ask 1 is safe with WeakValueDictionary + a creation-guard asyncio.Lock (same pattern as MemoryBackend); GC safety holds because every waiting task keeps a strong reference. Ask 2 should use wrapper classes (LazyProposalStore / LazyTaskRegistry / LazyTaskWebhookOutbox) rather than a factory-param design — the factory approach breaks the construction-time pool identity check at task_registry.py:147 (task_webhook_outbox._pool is pool).

    My take: Both asks have clear, correct implementations and are strictly additive. The reproduction script confirms the lock-pool exhaustion is real, and LazyBackend (lazy.py) already proves the wrapper pattern works well in this codebase.

    Implementation scope:

    • Ask 1 — PgBackend.hold() in-process coalescing (src/adcp/server/idempotency/backends.py:572):

      • Add self._hold_guard = asyncio.Lock() and self._key_locks: weakref.WeakValueDictionary[tuple[str, str], asyncio.Lock] to PgBackend.__init__. Under _hold_guard, fetch-or-create a per-key asyncio.Lock; then async with key_lock: before checking out _lock_pool.connection(). Mirrors MemoryBackend.hold() (lines 261–270) exactly.
      • Add a test asserting peak lock_pool connections == 1 for N concurrent same-key requests (adapting the repro script as a pytest fixture with a stub pool).
    • Ask 2 — LazyProposalStore, LazyTaskRegistry, LazyTaskWebhookOutbox (new module src/adcp/decisioning/pg/lazy.py):

      • Mirror LazyBackend (server/idempotency/lazy.py) with resolve-once asyncio.Lock semantics. Each wrapper delegates all public methods to the resolved store. Export from adcp.decisioning.pg.__init__.
      • LazyTaskRegistry factory must yield a (PgTaskRegistry, PgTaskWebhookOutbox) pair so the pool-identity check fires on resolution, not construction.
      • Add resolve-once concurrency tests for each wrapper.
    • Required checks: pytest on changed packages, mypy src/, ruff check . and black --check ..

    • Non-breaking rationale: Ask 1 changes no public API. Ask 2 adds new exported classes without modifying existing constructors.


    Triaged by Claude Code. Session: https://claude.ai/code/session_01WXoZKZa7tEQCUhJVjqKKut


    Generated by Claude Code

  3. added and removed
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Oct 2, 2026
  4. bokelley commented on Oct 4, 2026

    @bokelley
    ContributorAuthor

    Completed by #1317 (same-key lock connection coalescing) and #1327 (lazy proposal/task/task-outbox store resolution on the serving loop, squash 5987c40).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions