Skip to content

LazyPlatformRouter: accept proposal_stores= for parity with PlatformRouter #722

@bokelley

Description

@bokelley

Summary

PlatformRouter (the eager router) accepts proposal_stores: Mapping[str, ProposalStore] and exposes proposal_store_for_tenant(tenant_id) — the accessor the framework's proposal_dispatch duck-types via hasattr(platform, "proposal_store_for_tenant"). LazyPlatformRouter doesn't accept the kwarg and doesn't expose the accessor, so adopters wiring LazyPlatformRouter (the multi-tenant lazy-build pattern the docstring recommends for 50+ tenants) can't plug a ProposalStore into the framework's proposal lifecycle.

Where it bites

adcp/decisioning/platform_router.py:

  • PlatformRouter.__init__ accepts proposal_stores= (line ~331) and proposal_store_for_tenant is defined (line ~579).
  • LazyPlatformRouter.__init__ accepts only proposal_managers= (line ~736); there's no proposal_stores= and no proposal_store_for_tenant method.

adcp/decisioning/proposal_dispatch.py:109:

if hasattr(platform, "proposal_store_for_tenant"):
    store = platform.proposal_store_for_tenant(tenant_id)

→ on LazyPlatformRouter, hasattr returns False, store=None, and maybe_persist_draft_after_get_products / maybe_hydrate_recipes_for_create_media_buy both short-circuit. Adopters get the v1 (no-proposal) path silently.

Adopter workaround (we shipped this)

Subclass LazyPlatformRouter and add the accessor:

class _LazyPlatformRouterWithStore(LazyPlatformRouter):
    def __init__(self, *args, proposal_store: ProposalStore | None = None, **kwargs):
        super().__init__(*args, **kwargs)
        self._proposal_store = proposal_store

    def proposal_store_for_tenant(self, tenant_id: str) -> ProposalStore | None:
        return self._proposal_store

Works because the framework's dispatch is duck-typed (hasattr), but every adopter wiring LazyPlatformRouter will hit the same wall and write the same subclass. Strict parity with PlatformRouter would resolve.

Proposed fix

Mirror PlatformRouter's shape on LazyPlatformRouter:

  • Add proposal_stores: Mapping[str, ProposalStore] | None = None to __init__
  • Persist as self._proposal_stores: dict[str, ProposalStore]
  • Add proposal_store_for_tenant(self, tenant_id: str) -> ProposalStore | None: return self._proposal_stores.get(tenant_id)
  • Match the eager router's cross-store consistency check (finalize_supported and tenant_id not in self._proposal_stores → ValueError)

Single-store-across-tenants adopters can still pass {tid: shared_store for tid in tenants} or — better — accept a proposal_store_factory: Callable[[str], ProposalStore] (the same shape as factory= for platforms). The factory shape composes more naturally with the lazy router's per-tenant-on-first-request philosophy.

Context

Hit while wiring a Postgres-backed ProposalStore in prebid/salesagent (PR #390) to unblock the media_buy_seller/proposal_finalize/create_media_buy storyboard step. Filed concurrent issue on the lifecycle gap that storyboard exposes (separate from this).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions