Skip to content

Add to_actor.open_taskman(): an explicit remote-task supervision scope #485

Description

@goodboy

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:

async with to_actor.open_taskman(
    an=an,        # or `portal=` for single-actor mode
) as tm:
    ctx: Context = await tm.run_soon(some_async_fn, **kwargs)
    ...
    res = await ctx.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:

async with to_actor.open_worker_pool(
    names=('donny', 'gretchen'),
    modules=[__name__],
) as tm:
    d_ctx = await tm.run_soon(say_hello, ptl='donny', other_actor='gretchen')
    g_ctx = await tm.run_soon(say_hello, ptl='gretchen', other_actor='donny')
    print(await g_ctx.wait_for_result())
    print(await d_ctx.wait_for_result())

— 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions