You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Follow-up to #477 (see the final checklist bullet in #477 (comment))
and its removal PR #484: with the legacy non-blocking ActorNursery.run_in_actor() gone and to_actor.run() covering the
blocking one-shot case, the deferred/non-blocking remote-task shape
deserves a properly scoped successor — an explicit supervision scope
for dynamically spawned remote tasks over a (flat) subactor cluster.
Design
Core principle: the scope is the API (no Portal.run_soon())
The distilled lesson of the #477 arc: every non-blocking remote-task
invocation must have an explicitly-owned enclosing scope. run_in_actor()'s original sin wasn't non-blockingness — it was
hanging task-lifetime off a connection handle (Portal) so that
result-ownership fell to invisible machinery (._expect_result_ctx +
the teardown reap-cluster we just deleted). A Portal.run_soon()
would recreate exactly that shape: a method on a connection object
that needs somebody's scope, forcing either implicit re-coupling to
the parent ActorNursery or a mandatory nursery param — at which
point the nursery-owning object should just be the API. PR #484
also just shrank Portal's surface by its whole result-cluster; task
supervision gets its own noun instead:
asyncwithto_actor.open_taskman(
an=an, # or `portal=` for single-actor mode
) astm:
ctx: Context=awaittm.run_soon(some_async_fn, **kwargs)
...
res=awaitctx.wait_for_result() # optional; see exit policy
run_soon() -> Context via parked-holder tasks
Each .run_soon() spawns a holder task into the taskman's private trio task-nursery which enters the (implicit) Portal.open_context() acm, hands the Context out to the caller,
then parks on a release-event — never on the result. This is
exactly the (production proven) pattern of piker.service.Services.start_service_task() and solves the
"auto-close the implicit open_context().__aexit__() at taskman
scope" problem structurally: taskman exit (or ctx.cancel())
releases the holder whose acm-exit drains/collects the outcome.
The one discipline rule: the caller is the sole result consumer
(ctx.wait_for_result()); the holder never touches it — avoiding the
two-awaiters-on-one-stream shape that plagued run_in_actor's
internals. An un-wait()ed task's outcome is still collected by the
ctx exit-drain and any error propagates into the taskman scope, so
nothing can be silently dropped.
No new handle class needed: Context already carries .cancel(), .wait_for_result() and .open_stream().
Both endpoint flavors
Portal.run() already yields a Context underneath (via Actor.start_remote_task()), so run_soon() can accept BOTH plain
async fns (one-way protocol, no ctx.started() shim needed) and @tractor.context endpoints (full streaming), dispatched on the _tractor_stream/context_function markers — killing the shim
friction the #477 test migration exposed (assert_err_ctx etc.).
Supervision strategies land at the right layer
The taskman scope makes error policy explicit and configurable where ActorNursery never could:
one_cancels_all (default, trio-native): first task error
cancels siblings.
collect: reap-all-errors — the deterministic BEG-of-N that died
with the teardown-reap returns opt-in (the "collect-don't-cancel"
boilerplate now in examples/debugging/multi_subactors.py becomes
a one-liner, and test_multierror-class assertions can
re-tighten).
later: restart strategies (one_for_one et al) — the long-standing
Erlang-supervisor roadmap item finally has a home that isn't the
spawn machinery.
Ditto exit policy: wait (run-once semantics, what the legacy
teardown-reap effectively gave) vs cancel (service semantics, what piker's Services does) as a per-taskman default with per- run_soon override.
Placement, balancing + the worker-pool unification
open_taskman(portal=...): single-actor mode.
open_taskman(an=...): caller-built cluster; strategy= picks the
balancing (round-robin default), tm.run_soon(fn, ptl=...)
overrides per-call for BYO load balancing.
open_worker_pool(count=, names=, modules=): thin sugar stacking open_nursery() + start_actor() xN + open_taskman() — which is the Do we need a streaming equivalent (sugar) for .run_in_actor()? #172 worker-pool ask, and the modernization path for _clustering.open_actor_cluster() (reimplement atop, deprecate).
The mutual-rendezvous boilerplate from a297a32a collapses to:
— rendezvous-safe by construction since all actors outlive every
task in the taskman scope.
Explicitly rejected
Portal.run_soon() as a public method (see core principle above).
to_actor.manage_portal(ptl) as cs: a raw CancelScope yield
invites hard scope-cancel where graceful ctx.cancel()-then-
escalate is the SC-blessed order; tm.cancel() covers it without a
third primitive.
Prior art / lineage
the hilevel_serman branch (tip 93d161bf, needs rebase): tractor/hilevel/_service.py already ports piker's ServiceMngr
(open_service_mngr(), .start_service_task(), .start_service_ctx(), _open_and_supervise_service_ctx()) — the
taskman impl should derive from/converge with this work.
piker.service._mngr.Services + the piker.data.feed.open_feed() gather_contexts() pyramid: the production pattern and the
boilerplate class this design erases downstream.
tractor.trionics.gather_contexts(mngrs, tn=None): the static
(all-at-once) cousin; the taskman is its dynamic
(run_soon-over-time) generalization.
the to_actor.open_one_shot() sketch parked in ai/conc-anal/ria_nursery_removal_plan.md (a single-task special
case of this design).
the (now removed) cancel_on_completion() docstring which foretold
a .hilevel.one_shot_task_nursery() successor.
Open design items
exit-policy default: wait vs cancel?
collect mode: return outcomes vs raise the group?
run_soon() task naming/registry conventions.
teardown ordering: drain/cancel taskman ctxs BEFORE any ActorNursery teardown so channel-close never races ctx-close.
convergence/rebase strategy for hilevel_serman's ServiceMngr
(derive ActorTaskMngr from it? absorb it?).
Task list
PR A: to_actor/_taskman.py MVP — ActorTaskMngr + open_taskman(portal=|an=), parked-holder run_soon(fn, ptl=None, **kws) -> Context (both endpoint flavors), one_cancels_all + collect strategies, exit-policy knob, tm.cancel(); test suite
mirroring test_to_actor.py; docs section under the one-shot
pages. Rebase + derive from hilevel_serman's ServiceMngr
wherever possible.
PR B: open_worker_pool() (absorbs Do we need a streaming equivalent (sugar) for .run_in_actor()? #172) + open_actor_cluster() reimplementation/deprecation; port the
trynamic test/example + multi_subactors collect-pattern + test_multierror-class assertions back onto taskman patterns; examples/parallelism/taskman_pool.py.
PR C (stretch, piker-side): swap Services.start_service_task() internals (and eventually the open_feed() allocation pyramid) onto the upstream taskman —
validation in anger.
(this issue was generated in some part by claude-code)
Follow-up to #477 (see the final checklist bullet in
#477 (comment))
and its removal PR #484: with the legacy non-blocking
ActorNursery.run_in_actor()gone andto_actor.run()covering theblocking one-shot case, the deferred/non-blocking remote-task shape
deserves a properly scoped successor — an explicit supervision scope
for dynamically spawned remote tasks over a (flat) subactor cluster.
Design
Core principle: the scope is the API (no
Portal.run_soon())The distilled lesson of the #477 arc: every non-blocking remote-task
invocation must have an explicitly-owned enclosing scope.
run_in_actor()'s original sin wasn't non-blockingness — it washanging task-lifetime off a connection handle (
Portal) so thatresult-ownership fell to invisible machinery (
._expect_result_ctx+the teardown reap-cluster we just deleted). A
Portal.run_soon()would recreate exactly that shape: a method on a connection object
that needs somebody's scope, forcing either implicit re-coupling to
the parent
ActorNurseryor a mandatory nursery param — at whichpoint the nursery-owning object should just be the API. PR #484
also just shrank
Portal's surface by its whole result-cluster; tasksupervision gets its own noun instead:
run_soon() -> Contextvia parked-holder tasksEach
.run_soon()spawns a holder task into the taskman's privatetriotask-nursery which enters the (implicit)Portal.open_context()acm, hands theContextout to the caller,then parks on a release-event — never on the result. This is
exactly the (production proven) pattern of
piker.service.Services.start_service_task()and solves the"auto-close the implicit
open_context().__aexit__()at taskmanscope" problem structurally: taskman exit (or
ctx.cancel())releases the holder whose acm-exit drains/collects the outcome.
The one discipline rule: the caller is the sole result consumer
(
ctx.wait_for_result()); the holder never touches it — avoiding thetwo-awaiters-on-one-stream shape that plagued
run_in_actor'sinternals. An un-
wait()ed task's outcome is still collected by thectx exit-drain and any error propagates into the taskman scope, so
nothing can be silently dropped.
No new handle class needed:
Contextalready carries.cancel(),.wait_for_result()and.open_stream().Both endpoint flavors
Portal.run()already yields aContextunderneath (viaActor.start_remote_task()), sorun_soon()can accept BOTH plainasync fns (one-way protocol, no
ctx.started()shim needed) and@tractor.contextendpoints (full streaming), dispatched on the_tractor_stream/context_functionmarkers — killing the shimfriction the #477 test migration exposed (
assert_err_ctxetc.).Supervision strategies land at the right layer
The taskman scope makes error policy explicit and configurable where
ActorNurserynever could:one_cancels_all(default,trio-native): first task errorcancels siblings.
collect: reap-all-errors — the deterministicBEG-of-N that diedwith the teardown-reap returns opt-in (the "collect-don't-cancel"
boilerplate now in
examples/debugging/multi_subactors.pybecomesa one-liner, and
test_multierror-class assertions canre-tighten).
one_for_oneet al) — the long-standingErlang-supervisor roadmap item finally has a home that isn't the
spawn machinery.
Ditto exit policy:
wait(run-once semantics, what the legacyteardown-reap effectively gave) vs
cancel(service semantics, whatpiker'sServicesdoes) as a per-taskman default with per-run_soonoverride.Placement, balancing + the worker-pool unification
open_taskman(portal=...): single-actor mode.open_taskman(an=...): caller-built cluster;strategy=picks thebalancing (round-robin default),
tm.run_soon(fn, ptl=...)overrides per-call for BYO load balancing.
open_worker_pool(count=, names=, modules=): thin sugar stackingopen_nursery()+start_actor()xN +open_taskman()— whichis the Do we need a streaming equivalent (sugar) for
.run_in_actor()? #172 worker-pool ask, and the modernization path for_clustering.open_actor_cluster()(reimplement atop, deprecate).The mutual-rendezvous boilerplate from
a297a32a collapses to:
— rendezvous-safe by construction since all actors outlive every
task in the taskman scope.
Explicitly rejected
Portal.run_soon()as a public method (see core principle above).to_actor.manage_portal(ptl) as cs: a rawCancelScopeyieldinvites hard scope-cancel where graceful
ctx.cancel()-then-escalate is the SC-blessed order;
tm.cancel()covers it without athird primitive.
Prior art / lineage
hilevel_sermanbranch (tip93d161bf, needs rebase):
tractor/hilevel/_service.pyalready portspiker'sServiceMngr(
open_service_mngr(),.start_service_task(),.start_service_ctx(),_open_and_supervise_service_ctx()) — thetaskman impl should derive from/converge with this work.
piker.service._mngr.Services+ thepiker.data.feed.open_feed()gather_contexts()pyramid: the production pattern and theboilerplate class this design erases downstream.
tractor.trionics.gather_contexts(mngrs, tn=None): the static(all-at-once) cousin; the taskman is its dynamic
(
run_soon-over-time) generalization.to_actor.open_one_shot()sketch parked inai/conc-anal/ria_nursery_removal_plan.md(a single-task specialcase of this design).
cancel_on_completion()docstring which foretolda
.hilevel.one_shot_task_nursery()successor.Open design items
waitvscancel?collectmode: return outcomes vs raise the group?run_soon()task naming/registry conventions.ActorNurseryteardown so channel-close never races ctx-close.hilevel_serman'sServiceMngr(derive
ActorTaskMngrfrom it? absorb it?).Task list
to_actor/_taskman.pyMVP —ActorTaskMngr+open_taskman(portal=|an=), parked-holderrun_soon(fn, ptl=None, **kws) -> Context(both endpoint flavors),one_cancels_all+collectstrategies, exit-policy knob,tm.cancel(); test suitemirroring
test_to_actor.py; docs section under the one-shotpages. Rebase + derive from
hilevel_serman'sServiceMngrwherever possible.
open_worker_pool()(absorbs Do we need a streaming equivalent (sugar) for.run_in_actor()? #172) +open_actor_cluster()reimplementation/deprecation; port thetrynamic test/example +
multi_subactorscollect-pattern +test_multierror-class assertions back onto taskman patterns;examples/parallelism/taskman_pool.py.piker-side): swapServices.start_service_task()internals (and eventually theopen_feed()allocation pyramid) onto the upstream taskman —validation in anger.
(this issue was generated in some part by
claude-code)