11 KiB
to_actor.open_taskman() design + impl plan (issue #485)
Self-contained hand-off doc: everything needed to implement the tracking issue https://github.com/goodboy/tractor/issues/485 without prior session context. Read alongside the (shorter) issue body; where they differ THIS doc is the more detailed and the issue is the contract.
Context (why this exists)
Issue #477 removed the legacy non-blocking ActorNursery.run_in_actor() (see PR #484, branch drop_ria_nursery, + ria_nursery_removal_plan.md in this dir): its “result at nursery-teardown” semantic required an internal reap-cluster whose unowned result-waits produced an unbounded-hang class. The blocking successor to_actor.run() (PR #481) covers one-shots; the deferred/non-blocking shape now needs a properly scoped home — an explicit supervision scope for dynamically spawned remote tasks over a (flat) subactor cluster.
Core principle distilled from the #477 arc: every non-blocking remote-task invocation must have an explicitly-owned enclosing scope. Hence NO Portal.run_soon() public method (a connection handle must not own task lifetimes) — the scope-owning object is the API.
Prior art (all three MUST be mined)
hilevel_sermanbranch (tip93d161bf, 2024-12, 4 commits):tractor/hilevel/{__init__,_service.py}— thepiker.service.Servicesport.open_service_mngr()(actor-global singleton acm stackingopen_nursery()+trio.open_nursery()),ServiceMngrdataclass w/.start_service_task()(local bg task w/ cs+done-event),.start_service_ctx()(bg-task-supervised remote ctx via_open_and_supervise_service_ctx()),.start_service()(spawn actor + service ctx),.cancel_service[_task]().- PR #363 (
oco_supervisor_prototype, OPEN): thetrionics“taskman” prototype — atrio.Nursery-like per-task-scope-manager via user-defined single-yield-generator hooks. Mine for the per-task scope-hook shape + naming; its in-code ref appears (typo’d as “#346”) in_service.py’s “TODO, unify this interface with ourTaskManagerPR!”. piker.service._mngr.Services+ thepiker.data.feed.open_feed()gather_contexts()pyramid: the production usage this design must be able to replace.
Also related in-tree: trionics.gather_contexts(mngrs, tn=None) (the static fan-out cousin) and the to_actor.open_one_shot() single-task sketch in ria_nursery_removal_plan.md.
hilevel_serman rebase + convergence map
Recon (as of drop_ria_nursery @ c62f93a8):
- 266 commits behind
main;tractor/hilevel/is a NEW dir so NO file-level merge conflicts expected. - imports only top-level names (
ActorNursery,Context,Portal,current_actor,ContextCancelled,log) — all still exported post-subsystem-reorg. Rebase = API-drift work. - KNOWN BREAKAGE:
_service.py:292awaitsportal.wait_for_result()inside_open_and_supervise_service_ctx()— that API was DELETED by PR #484 (2a59cefb). Fix: drop the call; the tuple-return becomes justctx_res(thePortal“main result” notion no longer exists; the ctx result is the only result). .uidusages (canceller != portal.chan.uid) may want the newer.aid.uidspelling — check against currentChannel.log.info(f'pikerdservice ...')strings still say “pikerd” — de-piker-ify while touching.
Convergence: which ServiceMngr piece informs which taskman piece,
hilevel_serman |
taskman |
|---|---|
_open_and_supervise_service_ctx() |
the parked-holder task (core primitive, factor + share) |
.start_service_ctx() |
tm.run_soon() (ctx-endpoint flavor) |
.start_service_task() |
(local-task variant; out of scope for taskman, stays service-only) |
.start_service() |
open_worker_pool()-ish spawn+run compose |
.cancel_service[_task]() |
ctx.cancel() / tm.cancel() |
singleton open_service_mngr() |
NOT copied — taskman is plain-scoped, no singleton |
Key semantic DELTA vs ServiceMngr: the service holder’s finally: await portal.cancel_actor() couples ACTOR lifetime to ctx lifetime. The taskman must NOT do this — in an= (cluster) placement actors are reused across tasks and their lifetime belongs to the caller’s ActorNursery/pool layer. Actor-reaping stays out of the holder task entirely.
The design
API surface
async with to_actor.open_taskman(
# placement (pass at most one; both None -> private
# call-scoped `open_nursery()` matching `to_actor.run()`)
an: ActorNursery|None = None,
portal: Portal|None = None,
# error strategy: 'one_cancels_all' (default) | 'collect'
strategy: str = 'one_cancels_all',
# exit policy: 'wait' (default, trio-nursery-like) |
# 'cancel' (service-style)
on_exit: str = 'wait',
) as tm:
ctx: Context = await tm.run_soon(
fn, # plain async fn OR @tractor.context ep
ptl=None, # explicit worker override (BYO balancing)
name=None, # task name, default fn.__name__ (+ dedup)
**fn_kwargs,
)
...
res = await ctx.wait_for_result() # optional; caller-onlytm.tasks: dict[str, Context]registry; name collision ->ValueError.tm.cancel(): graceful per-ctxctx.cancel()sweep then holder-release; NO rawCancelScopeexposure (the rejectedmanage_portal() as csidea invites hard-cancel where graceful-then-escalate is the SC-blessed order).- cluster balancing when
an=-placed:round_robindefault overan’s portals;ptl=per-call override.
The parked-holder core (per run_soon())
One holder task spawned into the tm’s PRIVATE trio nursery:
async def _holder(...):
async with portal.open_context(fn, **kws) as (ctx, first):
started_ps.started(ctx) # hand ctx out to caller
await release_evt.wait() # park — NEVER on the result
# acm exit drains/collects the ctx outcome; any error
# raises HERE into the tm scope- discipline rule: the caller is the SOLE result consumer (
ctx.wait_for_result()); the holder never touches it. This avoids the two-awaiters-on-one-stream shape that plaguedrun_in_actor()internals (._expect_result_ctx). - an un-
wait()ed task’s error still propagates: ctx exit-drain raises into the holder -> tm nursery -> tm scope. Nothing can be silently dropped. - release triggers: task’s remote completion (see impl note below),
ctx.cancel(),tm.cancel(), tm scope exit. - impl note: “park until done OR released” wants
await ctx._scope...-ish completion OR simply parking on the event and letting exit-drain block on a still-running task per theon_exit='wait'policy. Start simple: park on the event;on_exit='wait'-> tm exit sets events then the acm exits naturally block until each remote task completes;on_exit='cancel'->ctx.cancel()each live task first.
Endpoint flavors
run_soon() accepts BOTH,
@tractor.contextendpoints ->portal.open_context()(full streaming; the holder shape above).- plain async fns -> the
Portal.run()-styleActor.start_remote_task()ctx (one-way protocol; noctx.started()handshake sofirstis None-ish). Dispatch on the_tractor_context_function/_tractor_stream_functionmarkers (seeto_actor._api._validate_one_shot_fn()+Portal.run()for the existing dispatch precedents).
This kills the @context-shim friction the #477 migration exposed (assert_err_ctx etc. in test_cancellation.py).
Supervision strategies
one_cancels_all(default): holder error propagates through the tm nursery -> siblings cancelled — plain trio semantics.collect: each holder catches its task’sRemoteActorError, stashes it, keeps siblings running; at tm exit raise the lot as aBaseExceptionGroup(single error collapses per the runtimecollapse_eg()convention). This resurrects the deterministic reap-allBEG-of-N opt-in:test_multierror-class assertions can re-tighten and the collect-don’t-cancel boilerplate inexamples/debugging/multi_subactors.pybecomes a one-liner.- future (NOT PR A): restart strategies (
one_for_one…) — the Erlang-supervisor roadmap finally has a natural home.
Resolved design items (w/ reasoning)
- exit-policy default =
wait: matchestrio.Nurseryblock-exit semantics (least surprise for the “task nursery” framing); service-style forever-tasks opt intoon_exit='cancel'(whatpiker’sServiceseffectively does) or usetm.cancel(). collectreturns nothing extra: errors group-raise at exit; VALUES stay per-ctx (ctx.wait_for_result()) — the tm carries error policy, not result aggregation (keeps the surface minimal; a gather-style values helper can wrap later).- teardown ordering: tm ctx-drain/cancel completes before any actor teardown structurally —
open_taskman(an=an)nests inside theanblock so acm exit order guarantees it; the holder never actor-cancels (see the ServiceMngr delta above). hilevel_sermaninclusion: rebase it as PR A’s opening commits (it’s self-contained, +618 lines, no conflicts, one API fix) and factor the shared supervised-ctx core soServiceMngr+ taskman diverge only in policy (actor-coupled + singleton vs plain-scoped). If the rebase fights back, fall back to pattern-mining only and leavehilevel_sermanfor its own PR — do NOT let it block the taskman.
PR plan
- PR A (
to_actor/_taskman.pyMVP):- rebase
hilevel_serman-> currentmain-line (fix theportal.wait_for_result()breakage, de-piker-ify strs). - factor the parked-holder core; implement
ActorTaskMngr+open_taskman()+run_soon()per above. - test suite
tests/test_taskman.pymirroringtest_to_actor.py’s structure: placement variants, both endpoint flavors, both strategies, both exit policies,tm.cancel(), error-relay + collect-BEG shapes, name-collision, cluster round-robin +ptl=override. - docs: extend the one-shot sections (
docs/guide/{spawning,rpc}.rst,docs/api/core.rst).
- rebase
- PR B:
open_worker_pool(count=, names=, modules=)(absorbs #172) + reimplement/deprecate_clustering.open_actor_cluster()atop; porttest_trynamic_trio+a_trynamic_first_scene.py(the mutual-rendezvous boilerplate froma297a32a) +multi_subactorscollect pattern + re-tightentest_multierror-class assertions; addexamples/parallelism/taskman_pool.py. - PR C (stretch,
piker-side): swapServices.start_service_task()internals (and eventually theopen_feed()pyramid) onto the upstream taskman.
Verification gates (repo conventions)
- per-commit module gates on the
triobackend +mp_spawnspot-gates; ALWAYS includetests/test_infected_asyncio.pyin any gate touching spawn/supervise machinery (the #477 lesson). tests/devx/test_debugger.pymust stay byte-identical if anyexamples/debugging/file is touched.- full suite (
uv run pytest tests/w/UV_PROJECT_ENVIRONMENT=py313) green ontriobefore PR; NB the full-sessionmp_spawnrun mass-fails pre-existing (KeyErrorinan.cancel(); unexercised by CI’s trio-only matrix) — do not chase it here. - docs
uv run --group docs make -C docs htmlclean.