Clarify `to_actor.run()` ownership

The guides described every placement as spawn-run-reap and omitted
the trampoline allowlist required when reusing an existing actor.

- distinguish call-owned children from caller-owned portal actors
- document stable module-global target addresses and allowlists
- add the #477 feature news fragment

(this patch was generated in some part by `opencode` using
`gpt-5.6-sol` (`openai`))
wkt/to_actor_subpkg
Gud Boi 2026-08-20 17:21:03 -04:00
parent 086962ca36
commit a849161fa5
7 changed files with 110 additions and 68 deletions

View File

@ -54,15 +54,16 @@ One-shot task actors
.. note:: .. note::
:func:`tractor.to_actor.run` (parlance of Without ``portal=``, :func:`tractor.to_actor.run` (parlance of
``trio.to_thread.run_sync()`` and friends) is the ``trio.to_thread.run_sync()`` and friends) is the convenience
*convenience* one-shot — spawn, run a single task, block on one-shot: spawn, run one task, block on its result and reap. It
its result, reap — built entirely on combines :meth:`ActorNursery.start_actor`, a linked
:meth:`ActorNursery.start_actor`, a linked :meth:`Portal.open_context` call and per-child reaping. With
:meth:`Portal.open_context` call and per-child cancellation/reaping, ``portal=`` it owns only the linked task and leaves the existing
so don't design around it as the core model. It supersedes the actor's lifetime to the portal owner; that actor must expose both
legacy, non-blocking ``ActorNursery.run_in_actor()`` retained only the target module and ``tractor.to_actor.MODULE``. It supersedes
for compatibility until its removal in PR #484. the legacy, non-blocking ``ActorNursery.run_in_actor()`` retained
only for compatibility until its removal in PR #484.
.. deprecated:: 0.1.0a6 .. deprecated:: 0.1.0a6

View File

@ -62,7 +62,10 @@ one kwarg away,
.. code:: python .. code:: python
async with tractor.open_actor_cluster( async with tractor.open_actor_cluster(
modules=['mylib.workers'], modules=[
'mylib.workers',
tractor.to_actor.MODULE,
],
count=4, count=4,
names=['scout', 'miner', 'smelter', 'smith'], names=['scout', 'miner', 'smelter', 'smith'],
debug_mode=True, # whole-fleet crash-to-REPL debug_mode=True, # whole-fleet crash-to-REPL
@ -71,9 +74,11 @@ one kwarg away,
From here the composition patterns are the usual ``tractor`` fare: From here the composition patterns are the usual ``tractor`` fare:
``portal.run()`` for bare one-shot RPCs (as in the demo), ``portal.run()`` for bare one-shot RPCs (as in the demo),
``tractor.to_actor.run(..., portal=portal)`` for linked one-shot calls, ``tractor.to_actor.run(..., portal=portal)`` for cancellation-linked
or — for a persistent bidirectional dialog per worker — concurrently one-shot tasks in an existing worker (include
enter N ``portal.open_context()`` blocks with ``tractor.to_actor.MODULE`` in ``modules``; the cluster still owns
the worker's lifetime), or — for a persistent bidirectional dialog
per worker — concurrently enter N ``portal.open_context()`` blocks with
``tractor.trionics.gather_contexts()``; see :doc:`/guide/context` ``tractor.trionics.gather_contexts()``; see :doc:`/guide/context`
for that whole layer. for that whole layer.

View File

@ -15,12 +15,12 @@ a single `structured concurrency`_ (SC) scope over IPC.
:alt: sequence diagram of the context handshake msg flow :alt: sequence diagram of the context handshake msg flow
Pretty much everything else is (or is slated to be) built on this Pretty much everything else is (or is slated to be) built on this
one primitive: ``tractor.to_actor.run()`` is a convenience for one primitive: ``tractor.to_actor.run()`` uses it for a linked
"spawn, run the lone task, await the result, tear down"; plain one-shot task, spawning and reaping an actor only when no ``portal=``
``Portal.run()`` RPC is planned to be re-implemented on top of it; is supplied; plain ``Portal.run()`` RPC is planned to be
the multi-process debugger's tree-wide REPL lock rides one. Grok re-implemented on top of it; the multi-process debugger's tree-wide
this page and the rest of the library reads as convenience REPL lock rides one. Grok this page and the rest of the library reads
wrappers B) as convenience wrappers B)
The endpoint contract The endpoint contract
--------------------- ---------------------

View File

@ -82,10 +82,9 @@ don't build your app on it.
One-shot subactors: ``to_actor.run()`` One-shot subactors: ``to_actor.run()``
-------------------------------------- --------------------------------------
When a subactor's *entire job* is a single function call, skip When the call should own a fresh subactor whose entire job is one
the portal plumbing with :func:`tractor.to_actor.run`: spawn, function call, :func:`tractor.to_actor.run` spawns it, runs the task,
run the lone task, return its result and reap the process — all returns its result and reaps the process — all in one blocking call:
in one blocking call:
.. code:: python .. code:: python
@ -101,16 +100,37 @@ Semantics worth knowing:
- it blocks until the remote task returns, re-raising any - it blocks until the remote task returns, re-raising any
remote error in the usual boxed form right in the calling remote error in the usual boxed form right in the calling
task. task.
- "placement" is composable: ``an=`` spawns from an existing - placement also determines process ownership: ``an=`` spawns and
actor-nursery, ``portal=`` reuses an already-running actor reaps a fresh child in an existing actor nursery, while passing
(no spawn/reap, just a linked neither does the same in a private call-scoped nursery (booting
:meth:`~tractor.Portal.open_context` call; see the the runtime if needed). ``portal=`` instead runs one linked task
:doc:`context guide </guide/context>`), and passing neither in an existing actor; it neither spawns nor reaps that actor, so
opens a private call-scoped nursery (booting the runtime if needed). the portal's owner remains responsible for its lifetime.
- concurrency composes the plain ``trio`` way: schedule - concurrency composes the plain ``trio`` way: schedule
multiple ``run()`` calls into a local task nursery (see multiple ``run()`` calls into a local task nursery (see
``examples/parallelism/to_actor_one_shots.py``). ``examples/parallelism/to_actor_one_shots.py``).
A reused actor must expose both the target module and the
``to_actor`` context trampoline:
.. code:: python
async with tractor.open_nursery() as an:
portal = await an.start_actor(
'worker',
enable_modules=[
__name__,
tractor.to_actor.MODULE,
],
)
try:
final = await tractor.to_actor.run(
partial(fib, n=10),
portal=portal,
)
finally:
await portal.cancel_actor()
Pure RPC daemons: ``run_daemon()`` Pure RPC daemons: ``run_daemon()``
---------------------------------- ----------------------------------
When a process's *only* job is to sit at the root of its own When a process's *only* job is to sit at the root of its own

View File

@ -105,9 +105,9 @@ What's going on here?
``to_actor.run()``: quick one-shot parallelism ``to_actor.run()``: quick one-shot parallelism
---------------------------------------------- ----------------------------------------------
:func:`tractor.to_actor.run` is the convenience wrapper: spawn Without ``portal=``, :func:`tractor.to_actor.run` is the convenience
an actor, run exactly one async function in it, block on the wrapper: spawn an actor, run exactly one async function in it, block
result, then reap the process — the distributed sibling of on the result, then reap the process — the distributed sibling of
``trio.to_thread.run_sync()``. ``trio.to_thread.run_sync()``.
.. code:: python .. code:: python
@ -126,6 +126,10 @@ A few details worth knowing:
``name='something_cuter'``. ``name='something_cuter'``.
- the function's module is auto-added to the child's - the function's module is auto-added to the child's
``enable_modules`` allowlist. ``enable_modules`` allowlist.
- the target must be a module-global async function, or a
``functools.partial`` thereof. Nested functions, methods and callable
objects have no stable ``module:name`` RPC address and are rejected
before actor startup.
- target arguments are positional; use ``functools.partial()`` - target arguments are positional; use ``functools.partial()``
to bind target keyword arguments. Keywords passed directly to to bind target keyword arguments. Keywords passed directly to
``run()`` configure actor placement and spawning. ``run()`` configure actor placement and spawning.
@ -133,18 +137,23 @@ A few details worth knowing:
child is *auto-cancelled* (reaped) right after — so remote child is *auto-cancelled* (reaped) right after — so remote
errors raise directly in your calling task (causality_ is errors raise directly in your calling task (causality_ is
paramount!). paramount!).
- "placement" composes: ``an=`` spawns from a caller-managed - "placement" composes: ``an=`` spawns a call-owned child from an
actor-nursery, ``portal=`` reuses an already-running actor existing actor nursery, while passing neither opens a private
(no spawn/reap), and passing neither opens a private call-scoped nursery. ``portal=`` instead reuses an existing actor:
call-scoped nursery (booting the runtime if needed). the call scopes only its linked remote task, neither spawns nor
reaps the actor, and leaves its lifetime with the portal's owner.
That actor must expose both the target module and
``tractor.to_actor.MODULE``.
.. note:: .. note::
:func:`tractor.to_actor.run` is a convenience, **not** the core :func:`tractor.to_actor.run` is a convenience, **not** the core
model — it's built *entirely* on model. For actor-owning placements it combines
:meth:`~tractor.ActorNursery.start_actor` plus a linked :meth:`~tractor.ActorNursery.start_actor`, a linked
:meth:`~tractor.Portal.open_context` call and per-child :meth:`~tractor.Portal.open_context` call, and per-child
cancellation/reaping. Teach your fingers to use it for quick cancellation/reaping. With ``portal=`` it uses only the linked
context call and leaves the existing actor's lifetime untouched.
Teach your fingers to use it for quick
fire-and-collect parallelism — think a per-function trio-parallel_ fire-and-collect parallelism — think a per-function trio-parallel_
style one-shot — and reach for style one-shot — and reach for
:meth:`~tractor.ActorNursery.start_actor` plus :meth:`~tractor.ActorNursery.start_actor` plus
@ -153,25 +162,25 @@ A few details worth knowing:
Actor lifetimes and teardown order Actor lifetimes and teardown order
---------------------------------- ----------------------------------
So we have two lifetime flavors: There are two actor-lifetime flavors:
- **one-shot** (``to_actor.run()``): lives exactly as long as - **call-owned one-shot** (``to_actor.run()`` without ``portal=``):
its single task; reaped the moment its result (or error) spawned for one task, then cancelled and joined before ``run()``
arrives back in the (blocking) call. returns its result or raises its error.
- **daemon** (:meth:`~tractor.ActorNursery.start_actor`): lives - **caller-owned daemon** (:meth:`~tractor.ActorNursery.start_actor`),
until *someone* cancels it — an explicit including an actor later reused through
``to_actor.run(..., portal=portal)``: lives until *someone*
cancels it via an explicit
:meth:`~tractor.Portal.cancel_actor`, a bulk :meth:`~tractor.Portal.cancel_actor`, a bulk
:meth:`~tractor.ActorNursery.cancel`, or the one-cancels-all :meth:`~tractor.ActorNursery.cancel`, or the one-cancels-all
strategy kicking in on error. strategy kicking in on error.
On a clean exit of the nursery block the teardown order is: On a clean exit of the nursery block the teardown order is:
1. one-shot actors never make it to nursery exit: each is 1. call-owned actors do not survive their own ``to_actor.run()``
reaped inside its own ``to_actor.run()`` call, any error calls; each is reaped before its call returns.
raising immediately in the calling task so your code 2. the nursery waits on caller-owned daemon actors
(acting as supervisor) gets first crack at handling it. **indefinitely**. If you spawned one, you own its lifetime.
2. the nursery then waits on daemon actors — **indefinitely**.
If you spawned a daemon, you own its lifetime.
When a child *is* cancelled, teardown is graceful-first per SC When a child *is* cancelled, teardown is graceful-first per SC
discipline: the runtime sends an IPC cancel request and gives discipline: the runtime sends an IPC cancel request and gives

View File

@ -43,15 +43,16 @@ Run it::
What's going on here? What's going on here?
- ``trio.run(main)`` starts the **root actor**; the ``tractor`` - ``trio.run(main)`` starts the **root actor**; the ``tractor``
runtime boots *implicitly* inside ``tractor.to_actor.run()`` runtime boots *implicitly* inside this ``tractor.to_actor.run()``
whenever it isn't already up. No special entrypoint, no call because neither ``an=`` nor ``portal=`` was supplied. No
framework takeover - it's just a ``trio`` app, special entrypoint, no framework takeover - it's just a ``trio``
app,
- inside ``main()`` a *subactor* is spawned via - inside ``main()`` a *subactor* is spawned via
``tractor.to_actor.run()`` and told to run exactly one ``tractor.to_actor.run()`` and told to run exactly one
function: ``cellar_door()``, function: ``cellar_door()``,
- the subactor, *some_linguist*, boots a fresh ``trio.run()`` in - the subactor, *some_linguist*, boots a fresh ``trio.run()`` in
a **new process** and executes ``cellar_door()`` as its *main a **new process** and executes ``cellar_door()`` as its linked
task* (note the child proving it is *not* the root with one-shot task (note the child proving it is *not* the root with
``tractor.is_root_process()``), then ships the return value ``tractor.is_root_process()``), then ships the return value
back over IPC, back over IPC,
- the call *blocks* until that final result arrives, then - the call *blocks* until that final result arrives, then
@ -67,10 +68,10 @@ What's going on here?
.. note:: .. note::
``to_actor.run()`` (parlance of ``trio.to_thread`` and Without ``portal=``, ``to_actor.run()`` (parlance of
friends) is the *convenience* wrapper: one-shot ``trio.to_thread`` and friends) is the *convenience* wrapper:
spawn-run-reap semantics for when a subactor's entire job is one-shot spawn-run-reap semantics for when a subactor's entire
a single function call. The core primitives are job is a single function call. The core primitives are
:meth:`~tractor.ActorNursery.start_actor` (next up) — which :meth:`~tractor.ActorNursery.start_actor` (next up) — which
hands you a ``Portal``, your handle for invoking tasks in the hands you a ``Portal``, your handle for invoking tasks in the
new process's (separate!) memory domain — paired with new process's (separate!) memory domain — paired with
@ -79,10 +80,10 @@ What's going on here?
Daemon actors and RPC Daemon actors and RPC
--------------------- ---------------------
A ``to_actor.run()`` one-shot subactor terminates when its lone A subactor spawned by ``to_actor.run()`` terminates after its lone
task returns. But often you want long-lived *daemon* actors task returns. But often you want long-lived *daemon* actors instead:
instead: spawned once, then serving (allowlisted) RPC requests spawned once, then serving (allowlisted) RPC requests until told
until told otherwise. That's ``start_actor()``: otherwise. That's ``start_actor()``:
.. literalinclude:: ../../examples/actor_spawning_and_causality_with_daemon.py .. literalinclude:: ../../examples/actor_spawning_and_causality_with_daemon.py
:caption: examples/actor_spawning_and_causality_with_daemon.py :caption: examples/actor_spawning_and_causality_with_daemon.py
@ -90,14 +91,17 @@ until told otherwise. That's ``start_actor()``:
Two lifetime rules to internalize: Two lifetime rules to internalize:
- a ``to_actor.run()`` one-shot actor lives exactly as long as - a subactor spawned and owned by ``to_actor.run()`` is cancelled
its lone task; the call blocks until that function (and thus and reaped before the call returns its result or raises its error,
the process) completes,
- a ``start_actor()`` actor *lives forever* - an RPC daemon the - a ``start_actor()`` actor *lives forever* - an RPC daemon the
nursery will happily wait on **indefinitely** - until some nursery will happily wait on **indefinitely** - until some
task explicitly cancels it via ``Portal.cancel_actor()`` (as task explicitly cancels it via ``Portal.cancel_actor()`` (as
above), or its parent nursery is cancelled wholesale. above), or its parent nursery is cancelled wholesale.
Passing ``portal=`` is different: the call owns only the linked
remote task. It neither spawns nor reaps the existing actor; the
portal's owner must end that actor's lifetime.
.. tip:: .. tip::
Want your *entire program* to just be a long-lived RPC Want your *entire program* to just be a long-lived RPC

View File

@ -0,0 +1,3 @@
Add ``tractor.to_actor.run()`` for Trio-style one-shot async calls in
new or existing actors, with caller-scoped result/error propagation,
linked cancellation, and deterministic reaping of call-owned children.