Skip to content

Propagate the worker proxy as a wool.ContextVar #230

Description

@conradbzura

Description

__proxy__ holds the worker proxy bound for the current dispatch scope — the handle a nested routine reads to dispatch onward. It is a plain contextvars.ContextVar[WorkerProxy | None], and the proxy reaches a worker through two dedicated Task wire fields: proxy (the serialized proxy) and proxy_id (its UUID). On the worker, routine_scope deserializes that payload into a candidate proxy, uses it as a cache key into __proxy_pool__, and binds the canonical live instance to __proxy__ for the routine's lifetime.

Make __proxy__ a wool.ContextVar so the proxy propagates on the context chain like any other variable, and fold the pool lookup into reconstruction: WorkerProxy's reduce constructor, _restore_proxy, becomes pool-aware — on restore it consults __proxy_pool__ for a cached instance with the same id, reuses it when present, and constructs-and-caches it otherwise. Deserialization then yields the canonical live proxy directly, instead of a throwaway key object that routine_scope maps to the cached instance in a second step. The proxy's identity already travels inside its __wool_reduce__ payload, so the chain carries everything the dedicated fields do.

The Task.proxy and Task.proxy_id fields stay. This change is additive and wire-compatible within the major version: the dedicated fields are deprecated, not removed. Dropping them is a v1 follow-up.

Backward compatibility

A client must dispatch successfully to a worker running a newer minor version within the same major version. The chain-borne proxy is therefore additive, and both propagation paths coexist until v1:

  • Old client → new worker. The old client populates only Task.proxy / proxy_id. The new worker reads the proxy from the chain when present and falls back to the Task fields otherwise.
  • New client → old worker. The old worker reads only the Task fields. Whether the new client keeps writing them (dual-write) depends on the upgrade-ordering guarantee — resolve this as part of the design. If new-client-to-old-worker must work, the new client populates both the chain and the fields.
  • New ↔ new. Prefer the chain-borne proxy.

The worker materializes the proxy from exactly one path per dispatch — chain first, fields as fallback — so a dual-written proxy never triggers two pool acquisitions. Confirm that an old worker tolerates a chain carrying the extra __proxy__ variable.

Mark Task.proxy / proxy_id deprecated in the proto and reserve their removal for v1.

The lifecycle redesign

Transport is the mechanical half. The lifecycle is the design work, because reconstruction acquires a pooled resource from inside a synchronous decode path.

  • Sync reconstruction, async acquire. _restore_proxy runs synchronously inside context-var decode, but acquiring a proxy from __proxy_pool__ is async — async with proxy_pool.get(...) leases the entry and starts the channel. Reconstruction returns a lazy, not-yet-started proxy; WorkerProxy already defers start() to first dispatch under double-checked locking. The pool gains a synchronous get-or-insert entry point for the lazy handle, separate from its async startup path.
  • Lease lifetime. The lease is no longer bracketed by routine_scope's async with. Its release point moves onto the binding's lifetime — the pool entry is reclaimed when __proxy__ is reset on frame unmount — with no leak across dispatches.
  • Forward-only propagation. __proxy__ must travel caller-to-worker only. The chain must not carry it back on yield/return checkpoints.

Motivation

The proxy is the last piece of per-dispatch state that still reaches a worker through a bespoke wire field instead of the context chain that already carries everything else. The cost shows up twice: the Task schema hauls the proxy redundantly (proxy plus proxy_id), and the materialize-from-pool step lives in routine_scope, away from the reconstruction it completes. Routing the proxy through the chain unifies propagation onto one mechanism and lets the redundant fields retire at v1.

Expected Outcome

  • __proxy__ is a wool.ContextVar[WorkerProxy | None]; the proxy propagates on the context chain.
  • _restore_proxy consults __proxy_pool__ and returns the cached instance when one exists, constructing and caching otherwise.
  • __proxy_pool__ exposes a synchronous get-or-insert for lazy proxy handles; proxy startup stays deferred to first dispatch.
  • Task.proxy and Task.proxy_id remain on the wire, marked deprecated. The worker reads the chain-borne proxy when present and falls back to the Task fields, materializing from exactly one path per dispatch.
  • The pool lease is tied to the __proxy__ binding — reclaimed on reset or frame unmount — and does not leak across dispatches.
  • __proxy__ does not back-propagate from worker to caller.
  • A reused cached proxy resolves credentials correctly under the active credential context.
  • Compatibility holds across mixed minor versions: an old-style Task (fields only) and a new-style dispatch (chain proxy) both succeed with identical behavior.
  • A v1 follow-up to remove the deprecated fields and the fallback path is filed.
  • Existing dispatch and nested-dispatch tests pass; the suite stays green.

Metadata

Metadata

Assignees

No one assigned

    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