Dispatch nested routines to the pool and implement a Wool-picklable contextvars.Token wrapper — Closes #278, #231#282
Draft
conradbzura wants to merge 8 commits into
Conversation
Routine dispatch put the raw undecorated function in the Task callable, so a worker deserialized and ran the function body directly under do_dispatch(False). Nested routine calls then took the wrapper's local-execution branch and ran in-process on the same worker instead of dispatching across the pool. The raw function also could not pickle by reference, since its qualname resolves to the wrapper, forcing its code to serialize by value on every dispatch. Dispatch the routine wrapper instead of the raw function. On the worker the wrapper runs under do_dispatch(False), takes its else branch, and _execute re-enables do_dispatch(True) around the body so nested calls dispatch as intended. The wrapper also pickles by reference for importable routines, shrinking the serialized callable. Claude-Session: https://claude.ai/code/session_01YV7ftABBRGQFCitMqLydP1
Add two fields to the ChainManifest wire schema and its decoded counterpart. Each ContextVar entry gains external_used, the hex ids of wool.Token instances for that var consumed by a reset somewhere in the chain; the manifest gains live_token_ids, the ids of unconsumed tokens minted in the context the manifest snapshots. Both ride every request and response frame so consumption propagates in both directions and the receiver can decide which wire tokens belong to the context it continues. A process-wide external_used registry keyed by var identity backs the consumed ledger, guarded by the existing registry lock. It lives in the registry module rather than the token module so chain and manifest can consult it without an import cycle. Encoding stays byte-deterministic: both id sets emit sorted, and consumed ids piggyback on the var entry that already exists for the value or reset the consuming reset produced. Claude-Session: https://claude.ai/code/session_01YV7ftABBRGQFCitMqLydP1
wool.Token was an alias for the stdlib contextvars.Token, which cannot pickle, so a token passed to a routine as an argument or nested in a propagated ContextVar value was silently dropped from the dispatch. With nested routines now genuinely dispatching, the downstream-reset pattern — set on the caller, reset on a worker — was broken end to end. Introduce a first-class wool.Token returned by wool.ContextVar.set. It wraps the native stdlib token locally and serializes only through the dispatch pickler via a wool-reduce hook; vanilla pickle, cloudpickle, and copy are rejected with a TypeError pointing at the dispatch path. Reset reproduces the stdlib contract: single use enforced used-first across processes through the consumed-token ledger, per-variable token binding, and per-context ownership with the canonical error types and messages. Context ownership follows the exact minting context, not the chain. A wire token earns a reset anchor on the receiver only when its id is in the mounted manifest's live_token_ids — the snapshot the sender's to_manifest captured inside the context it ships. The decision cannot be made at serialize time: a worker pickles its result in the gRPC handler, outside the routine's context, so the ambient chain there is not a reliable witness. Reconstituted tokens queue by chain id and Chain.mount drains the queue inside the target context, minting an anchor stdlib token that delegates same-context validation to stdlib machinery. A token that fails the gate arrives as an orphan: freely holdable, returnable, and re-dispatchable, failing only at an actual reset attempt with the stdlib different-Context error. Frame.mount no longer short-circuits a manifest whose only state is token bookkeeping, since ledger ingest and anchor drain live inside the mount. Claude-Session: https://claude.ai/code/session_01YV7ftABBRGQFCitMqLydP1
Rewrite the token unit tests for the first-class wool.Token: repr names the wool variable, var restores the stdlib var identity parity the stdlib alias never had, old_value reports Undefined for a previously unset variable, vanilla pickle, cloudpickle, copy, and deepcopy are rejected, and the wool serializer roundtrips a token preserving its id and variable identity. Update the var tests for the new reset contract: set returns a wool.Token whose var is the wool variable, and the reset error surface matches stdlib byte for byte — expected an instance of Token for a non-token argument and created by a different ContextVar for a foreign token. Claude-Session: https://claude.ai/code/session_01YV7ftABBRGQFCitMqLydP1
Add integration coverage for the token wire semantics over a real worker pool: a token reset on a worker is consumed for the caller too, so the caller's second reset raises the already-used RuntimeError; an orphaned token minted in a sibling context transports losslessly to the worker and back, failing only on an explicit reset; a worker-side reset of that orphan raises the different-Context ValueError even though the sibling shares the dispatching chain id; and a token minted on the worker returns to the awaiting caller resettable, restoring the pre-call value with stdlib awaited-coroutine parity. Four routines back the tests: reset a passed token, describe a token without resetting, attempt an orphan reset and report the error, and set-then-return a fresh token. Claude-Session: https://claude.ai/code/session_01YV7ftABBRGQFCitMqLydP1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fix nested routine dispatch and make wool.Token survive it. Routine dispatch shipped the raw undecorated function to workers, so nested @wool.routine calls ran in-process on the worker instead of dispatching across the pool. Fixing that exposed the missing half of the story: the stdlib contextvars.Token returned by wool.ContextVar.set cannot pickle, so the downstream-reset pattern — set on the caller, reset on a worker — silently dropped the token at the serialization boundary.
Introduce a first-class wool.Token that crosses the wire through the dispatch pickler while reproducing stdlib reset semantics end to end: single use enforced across processes, per-variable token binding, and per-context ownership with stdlib's canonical error types and messages. Vanilla pickle, cloudpickle, and copy remain rejected — the token serializes only through a dispatch. Guiding principle throughout: stdlib parity, with an awaited routine behaving like an awaited local coroutine.
Trade-offs worth noting: the wire manifest carries two new bookkeeping sets (consumed token ids per var, live token ids per chain) shipped in full on every frame; diff-encoding is deferred. Context ownership is decided receiver-side at mount time rather than at serialize time, because a worker pickles its result in the gRPC handler outside the routine's context — the manifest, captured inside the right context, is the reliable witness.
Closes #278
Closes #231
Proposed changes
Dispatch the routine wrapper so nested routines reach the pool
Put the decorated wrapper in the Task callable instead of the raw function. On the worker the wrapper runs under do_dispatch(False), takes its local-execution branch, and _execute/_stream re-enable do_dispatch(True) around the body so nested calls dispatch as intended. The wrapper also pickles by reference for importable routines, shrinking the serialized callable.
Carry token bookkeeping on the wire chain manifest
Add external_used (consumed token ids, per ContextVar entry) and live_token_ids (unconsumed token ids minted in the snapshotted context, per manifest) to the ChainManifest wire schema and its decoded counterpart. Both ride every request and response frame so consumption propagates in both directions and the receiver can decide which wire tokens belong to the context it continues. A process-wide registry backs the consumed ledger; encoding stays byte-deterministic via sorted emission.
Make wool.Token survive dispatch with stdlib reset semantics
wool.Token wraps the native stdlib token locally and serializes only through the dispatch pickler via a wool-reduce hook, with a reduce guard rejecting vanilla pickle, cloudpickle, and copy. Reset reproduces the stdlib contract and check order: an already-used token raises RuntimeError (used-first, consulting the cross-process ledger so a token consumed on a worker is dead everywhere, including for the caller's original object), a foreign variable's token raises ValueError, and a token minted in a context the receiver does not continue raises the stdlib different-Context ValueError.
A wire token earns a reset anchor on the receiver only when its id appears in the mounted manifest's live_token_ids. Reconstituted tokens queue by chain id and Chain.mount drains the queue inside the target context, minting an anchor stdlib token that delegates same-context validation to stdlib machinery. A token that fails the gate arrives as an orphan: freely holdable, returnable, and re-dispatchable, failing only at an actual reset attempt. Because live ids ride the manifest, the ownership decision composes across hops — a token passed through one worker to another anchors wherever the continuing context reaches, matching awaited-call semantics.
Frame.mount no longer short-circuits a manifest whose only state is token bookkeeping, since ledger ingest and anchor drain live inside the mount.
Test cases
TestTokenTestTokentoken.varis the wool ContextVar itselfTestTokenTestTokenTestTokenTestTokentoken.varstill resolves with the original identityTestContextVarTestContextVarTestContextVarTestContextVarPropagationTestContextVarPropagationTestContextVarPropagationTestContextVarPropagation