Skip to content

Reshape LocalDiscovery to a single-owner model — Closes #106, #300, #433, #435, #436, #437 - #377

Merged
conradbzura merged 8 commits into
wool-labs:mainfrom
conradbzura:300-single-owner-local-discovery
Sep 17, 2026
Merged

conradbzura merged 8 commits into
wool-labs:mainfrom
conradbzura:300-single-owner-local-discovery

Conversation

@conradbzura

@conradbzura conradbzura commented Aug 25, 2026 •

Copy link
Copy Markdown
Member

Summary

Give each LocalDiscovery namespace exactly one owner. The owner is the entered instance whose entry created the namespace's shared-memory registry. Publishers and subscribers borrow that registry, and a borrower that outlives its owner is orphaned. Entry decides ownership through an exclusive create, which removes the race in which whichever process created a registry first held it and every later entrant attached without knowing its role.

The model reaches the worker runtime in two places. A durable WorkerPool borrows a namespace through a bare DiscoverySubscriberLike (#106), and WorkerProxy consumes its discovery subscriber as given, which leaves the pool as the sole owner of discovery's lifetime (#433). Three changes the model exposed ride along: discovery exceptions carry their fields in args (#437), resolved narrows its result for a single-type expect (#435), and subscriber construction is typed as the protocol it returns (#436).

Closes #106
Closes #300
Closes #433
Closes #435
Closes #436
Closes #437

Scope

Issue Relationship
#376 A durable pool over a namespace without a registry raises DiscoveryNamespaceNotFound only once its quorum wait expires. This PR pins the outcome; #376 owns the latency
#345 Gains its precondition of one teardown point per namespace; __exit__ still leaves the lock and notification files behind
#353 Unblocked by this change
#340 Unaffected: a create=True construction that fails partway still emits the stray tracker unregister
#430 Spawning pools that share one local namespace need a DiscoveryLike whose publisher and subscriber both borrow
#431 _subscriber_factories retains one closure per namespace for the life of the process, which predates this change
#432 A live shared subscription never observes a successor owner, which this change documents
#434 The matching change for loadbalancer stays out of this PR

Migration

Before After What to change
Entering LocalDiscovery(ns) on an existing registry attaches to it It raises DiscoveryNamespaceInUse Borrow with LocalDiscovery.Subscriber(ns) or LocalDiscovery.Publisher(ns)
WorkerPool(discovery=LocalDiscovery(ns)) without spawn attaches to the namespace The pool owns the namespace and raises DiscoveryNamespaceInUse where a registry exists Pass LocalDiscovery.Subscriber(ns)
Two spawning pools share one LocalDiscovery namespace The second raises DiscoveryNamespaceInUse Give each spawning pool its own namespace
A borrower on a missing registry raises FileNotFoundError It raises DiscoveryNamespaceNotFound, a WoolError Catch DiscoveryNamespaceNotFound
DiscoveryCapacityExhausted, DiscoveryBlockExhausted and DiscoveryWorkerNotFound carry their message in args They carry their fields in args, and the first and last name the discovery registry in their message Read the field attribute, or str(error)
WorkerProxy(discovery=...) accepts any Factory form It takes a DiscoverySubscriberLike and raises TypeError at construction for anything else Enter a context-manager subscriber where its lifetime is owned, e.g., a WorkerPool, and pass the subscriber

Proposed changes

Claim a namespace on entry

Participant No registry exists A registry exists On exit
Entered LocalDiscovery Creates and claims it Raises DiscoveryNamespaceInUse Unlinks the registry
LocalDiscovery.Publisher, LocalDiscovery.Subscriber Raise DiscoveryNamespaceNotFound Borrow it Release their mapping

Entry creates the registry with create=True and translates FileExistsError; the attach fallback and the owner flag go. The claim belongs to entry, the one operation with a matching __exit__, so publisher, subscriber and subscribe return borrowers whether or not their instance is entered. Borrowers bind through _registry, which translates only a missing registry, and each binds before it acquires anything else, so a failed bind leaves nothing to unwind.

No entry adopts a stranded registry. A liveness check cannot be made atomic against a slow owner, so DiscoveryNamespaceInUse names the backing segment and leaves its removal to the caller. The LocalDiscovery docstring holds the ownership, borrowing and orphaning contract.

Carry discovery exception fields in args

Each discovery exception passes its fields positionally to WoolError.__init__ and renders its message in __str__. The worker's exception serializer rebuilds an unpicklable exception as cls(*exc.args), which bound the rendered message to the first field. DiscoveryNamespaceInUse and DiscoveryNamespaceNotFound follow the same convention and join wool.__all__.

Type a subscriber construction as the protocol it returns

SubscriberMeta returns a _SharedSubscription in place of an instance of the subscriber class. LocalDiscovery.Subscriber and LanDiscovery.Subscriber declare a checker-only __new__ with their constructor's parameters and the DiscoverySubscriberLike return type, so a checker types construction as what the caller receives and still checks its arguments. LocalDiscovery.Subscriber drops its __reduce__ and namespace members, which no caller could reach through the returned object.

Consume a discovery subscriber as given in WorkerProxy

WorkerProxy iterates its discovery subscriber and never resolves or enters it. The pool had resolved discovery and the proxy resolved it again, entering a context-manager subscriber twice. The proxy validates discovery at construction, and __wool_reduce__ raises TypeError in the dispatching process for a context-manager subscriber. A pool-URI proxy borrows through afilter(tag_filter, LocalDiscovery.Subscriber(pool_uri)) and claims nothing.

A proxy constructed directly from a factory that produces an unpicklable subscriber re-ran that factory in the worker; it now raises at construction. No pool arm passed a factory down.

Accept a bare subscriber as discovery for a durable pool

A durable pool publishes nothing, so it accepts a DiscoverySubscriberLike as well as a DiscoveryLike. The durable arm classifies the resolved object once, DiscoveryLike first, since a runtime protocol check on Python 3.11 evaluates the service's properties. Rejection raises TypeError at context entry inside the resolved block, so a rejected context-manager factory is exited. #106 proposed a ValueError at construction, which Factory forms preclude because they resolve only on entry.

Narrow resolved's return type for a single-type expect

Overloads type the result of a single-type expect as that type. A tuple expect, or none, keeps the dependency's type.

Exercise single-owner discovery across processes

PoolMode.DURABLE_JOINED becomes DURABLE_BORROWED, which supplies a bare subscriber in each Factory form and asserts after resolution that the pool receives no DiscoveryLike. Pairs that allpairspy starves out of the covering array are pinned from one tuple. The suite scopes the subscriber pool and factory registry to each test, as the unit suite does, and retried bodies mint their state per attempt. New suites cover cross-process ownership, borrowing, orphaning and teardown.

The rework exposed four defects in existing tests:

  • _enter_lifecycle_forest suppressed every claim below the root, so both Hypothesis forest tests passed against attach-on-entry
  • Nothing pinned an orphaned publisher's publish, the raise pool teardown depends on
  • Six retried integration bodies reused per-attempt state across retries
  • Three legal (pool mode, discovery) pairs reached the covering array only when Hypothesis drew them

Describe single-owner local discovery in the READMEs

The READMEs name the owner, borrower and orphan roles, show how a durable pool borrows a local namespace, and defer the contract to the LocalDiscovery docstring.

Test cases

# Test Suite Given When Then Coverage Target
1 TestLocalDiscovery An unclaimed namespace A LocalDiscovery is entered and exited It creates and stamps the registry on entry and unlinks it on exit Entry claims, exit reclaims
2 TestLocalDiscovery A namespace a live instance owns A second LocalDiscovery is entered It raises DiscoveryNamespaceInUse naming the segment and leaves the owner's registry intact Rejected claim
3 TestLocalDiscovery An instance that was rejected, or that entered and exited It is entered again It raises RuntimeError Single-use instance
4 TestLocalDiscovery Generated claim, release and borrow sequences and nested entry forests on one namespace Each is replayed A claim succeeds iff no owner is live, a borrow binds iff one is, and exactly the nested claims are rejected Single-owner invariant
5 TestLocalDiscovery An owner, a bound publisher and a successor owner The first owner exits, the publisher publishes, and the successor enters The orphaned publish raises DiscoveryNamespaceNotFound and the successor's registry serves a new worker Overlapping generations
6 TestLocalDiscoveryPublisher A namespace without a registry, or whose owner has exited A Publisher is entered or publishes It raises DiscoveryNamespaceNotFound Publisher bind
7 TestLocalDiscoveryPublisher A publisher orphaned by its owner's exit A successor owner enters and the publisher publishes The worker appears in the successor's registry Successor bind
8 TestLocalDiscoveryPublisher A registry its owner stamped at a capacity A borrowing publisher registers past it It raises DiscoveryCapacityExhausted Owner's capacity governs
9 TestLocalDiscoveryPublisher, TestLocalDiscoverySubscriber Either borrower class It is constructed with a capacity It raises TypeError Borrowers declare no capacity
10 TestLocalDiscoverySubscriber A namespace without a registry A Subscriber is iterated It raises DiscoveryNamespaceNotFound and creates no registry or notification directory Subscriber bind
11 TestLocalDiscoverySubscriber A subscriber whose owner exited It is iterated before and after a new owner enters It raises, then yields the new owner's events Transient orphaning
12 TestLocalDiscoverySubscriber Several iterations started together on a namespace without a registry All run to completion Only the binding iteration raises, and the rest end without events Shared-bind failure
13 TestLocalDiscoverySubscriber A subscriber class under SubscriberMeta It is constructed and pickled It satisfies DiscoverySubscriberLike and its reduction carries its poll_interval Construction and reduction
14 The five discovery exception suites Each exception over its argument domain It is constructed, pickled and marshalled through an exception response frame It keeps its class, fields and message Exception contract
15 TestDiscoveryExceptionReconstruction Each exception with an unpicklable __cause__ The serializer's fallback rebuilds it It restores every field with its type and warns that fidelity was lost Serializer fallback
16 TestWorkerPool A durable pool given a bare subscriber, or a service satisfying both protocols The pool is entered It hands the proxy the subscriber, or the service's subscriber Durable arm acceptance
17 TestWorkerPool A durable pool given neither protocol, or a hybrid pool given no DiscoveryLike The pool is entered It raises TypeError naming the accepted protocols and exits any context it entered Protocol rejection
18 TestWorkerPool A durable pool whose discovery is a context-manager subscriber, as an instance or a factory The pool is entered and exited It enters and exits the subscriber exactly once Single lifetime owner
19 TestWorkerProxy A callable, or a value implementing no subscriber protocol, as discovery The proxy is constructed It raises TypeError Construction-time validation
20 TestWorkerProxy A proxy holding a context-manager subscriber It is reduced It raises TypeError naming discovery Context managers do not travel
21 TestWorkerProxy A non-lazy pool-URI proxy on a namespace without a registry Its sentinel binds and the context exits It raises DiscoveryNamespaceNotFound naming the namespace Pool-URI borrowing
22 TestWorkerProxy A pool-URI proxy with an extra tag over workers tagged with each and with neither It starts and drains its events It admits exactly the two whose tags intersect its own Tag filter
23 TestCrossProcessOwnership Four interpreters released at one deadline All claim one namespace Exactly one is admitted Atomic claim
24 TestCrossProcessOwnership An owner that exited cleanly, was killed with its tracker, or was killed under a live parent tracker A successor claims the namespace It is admitted in the first two cases and rejected with the segment named in the third Reclamation and stranding
25 TestPoolNamespaceOwnership A namespace a live pool owns, or one without a registry A spawning pool, or a durable borrowing pool, is entered It raises DiscoveryNamespaceInUse, or DiscoveryNamespaceNotFound, and leaves the namespace usable Pool-level ownership
26 TestCrossProcessBorrowing A namespace and its only worker owned by another interpreter A durable pool borrows it and dispatches A worker outside this interpreter's process tree serves the call Durable pool end to end
27 TestOverlappingNamespaceLifecycles An owner pool and a borrowing pool on one namespace The owner exits first and the borrower dispatches The dispatch raises NoWorkersAvailable within ten seconds and a new owner serves the namespace Orphaned pool
28 TestCrossProcessTracker An interpreter borrowing this process's namespace It exits The segment survives with no tracker complaint Borrowers never reclaim
29 TestCrossProcessLockTimeout, TestWorkerOrphanPrevention A drop announcement that cannot take the lock or hangs The pool exits The wait is bounded, the failure is logged with its TimeoutError, and the worker is reaped Bounded teardown
30 TestPoolComposition, test_integration A DURABLE_BORROWED scenario in each supply form, and any drawable scenario A routine is dispatched, or the pair is looked up It returns through the borrowed registry, and the pair appears in the covering array Scenario model

@conradbzura conradbzura self-assigned this Aug 25, 2026
@conradbzura conradbzura changed the title Reshape LocalDiscovery to a single-owner model where the owner creates/reclaims the registry and Publisher/Subscriber borrow it — Closes #300, #106 Reshape LocalDiscovery to a single-owner model where the owner creates/reclaims the registry and Publisher/Subscriber borrow it — Closes #106, #300 Sep 15, 2026
@conradbzura
conradbzura force-pushed the 300-single-owner-local-discovery branch 2 times, most recently from 06dc075 to 07de281 Compare September 15, 2026 19:07
@conradbzura
conradbzura force-pushed the 300-single-owner-local-discovery branch 3 times, most recently from b867c6a to 20291ce Compare September 16, 2026 21:30
@conradbzura conradbzura changed the title Reshape LocalDiscovery to a single-owner model where the owner creates/reclaims the registry and Publisher/Subscriber borrow it — Closes #106, #300 Reshape LocalDiscovery to a single-owner model where the owner creates/reclaims the registry and Publisher/Subscriber borrow it — Closes #106, #300, #433, #435, #436, #437 Sep 17, 2026
@conradbzura conradbzura changed the title Reshape LocalDiscovery to a single-owner model where the owner creates/reclaims the registry and Publisher/Subscriber borrow it — Closes #106, #300, #433, #435, #436, #437 Reshape LocalDiscovery to a single-owner model — Closes #106, #300, #433, #435, #436, #437 Sep 17, 2026
A single-type expect now types the yielded object as that type, so a
caller that validates a dependency against a protocol receives it typed
as that protocol. A tuple expect keeps the dependency's own type.
Entering LocalDiscovery creates a namespace's shared-memory registry
and claims it, and exiting reclaims it. Publishers and subscribers
borrow the registry, and a borrower that outlives its owner is
orphaned. An exclusive create decides ownership, so no check-then-create
race can hand one namespace to two owners.

DiscoveryNamespaceInUse and DiscoveryNamespaceNotFound report the two
failed claims and join the public API.

BREAKING CHANGE: Entering LocalDiscovery on a namespace whose registry
already exists raises DiscoveryNamespaceInUse, and a LocalDiscovery
publisher or subscriber on a namespace without a registry raises
DiscoveryNamespaceNotFound.
Each discovery exception passes its structured fields positionally to
its base and renders its message in __str__. The worker's exception
serializer rebuilds an unpicklable exception as cls(*exc.args), which
previously restored the message in place of the fields.

BREAKING CHANGE: args on DiscoveryCapacityExhausted,
DiscoveryBlockExhausted and DiscoveryWorkerNotFound holds the exception's
fields in place of its message, and the messages name the discovery
registry.
SubscriberMeta returns a shared subscription in place of an instance of
the subscriber class, a substitution no type checker observes. Each
subscriber class declares a checker-only __new__ with its constructor's
parameters and the DiscoverySubscriberLike return type.
WorkerProxy iterates its discovery subscriber and never resolves or
enters it. A pool URI proxy borrows the named LocalDiscovery namespace
through a shared subscriber, so it claims nothing. The proxy validates
discovery at construction, and reducing a proxy that holds a
context-manager subscriber raises TypeError in the dispatching process.

BREAKING CHANGE: WorkerProxy's discovery parameter takes only a
DiscoverySubscriberLike; a Factory, or any other value, raises TypeError
at construction.
A durable pool publishes nothing, so a DiscoverySubscriberLike suffices
as its discovery. Passing LocalDiscovery.Subscriber borrows a namespace
another process owns.
The integration suite borrows owned namespaces through subscribers and
covers cross-process ownership, borrowing, orphaning and teardown.
Retried bodies mint their namespaces and events per attempt, so a retry
starts from clean state.
The READMEs name the owner, borrower and orphan roles, show how a
durable pool borrows a local namespace, and defer the contract to the
LocalDiscovery docstring.
@conradbzura
conradbzura force-pushed the 300-single-owner-local-discovery branch from 20291ce to 94c07ff Compare September 17, 2026 14:07
@conradbzura
conradbzura marked this pull request as ready for review September 17, 2026 14:33
@conradbzura
conradbzura merged commit 77a88e4 into wool-labs:main Sep 17, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment