From 63df5534bb217ab274473ae4a11a94a7503fa61d Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 2 Jul 2026 12:10:19 -0400 Subject: [PATCH] Add `tractor.to_actor` one-shot task API subpkg First cut at the `to_thread`/`to_process`-style "run it over there" wrapper layer from issue #477: a single-remote-task invocation API decoupled from the `ActorNursery` spawn machinery, composed purely from the lower level daemon-actor + portal primitives, - `to_actor.run(fn, **fn_kwargs)` spawns a subactor via `ActorNursery.start_actor()`, schedules `fn` as its lone task with `Portal.run()` and ALWAYS reaps it via a `finally`-scoped `Portal.cancel_actor()` (whose bounded cancel-req wait is internally shielded so the reap also runs under caller-scope cancellation). - remote errors raise directly in the caller's task as boxed `RemoteActorError`s, moving error collection/propagation up into whatever local `trio` scope encloses the call. - "placement" opts: `portal=` reuses a running actor (no spawn/reap), `an=` spawns from a caller-managed actor-nursery, neither opens a private call-scoped `open_nursery()` (implicitly booting the runtime, tunable via pass-through `runtime_kwargs`). - fail-fast validation BEFORE any spawn: non-streaming async fn only (same constraint as `Portal.run()`), `portal=`/`an=` mutual exclusion and no `runtime_kwargs` alongside a placement opt. Also, - x-ref the successor API from `.run_in_actor()`'s deprecation TODO + docstring; emitting a formal `DeprecationWarning` waits on migrating in-repo usage. - log prompt-io provenance per NLNet policy incl. the driver prompt file. Prompt-IO: ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.md (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .../20260702T154255Z_65bf9df5_prompt_io.md | 79 ++++++ ...20260702T154255Z_65bf9df5_prompt_io.raw.md | 100 ++++++++ ai/prompt-io/prompts/issue_477.md | 7 + tractor/__init__.py | 1 + tractor/runtime/_supervise.py | 18 +- tractor/to_actor/__init__.py | 33 +++ tractor/to_actor/_api.py | 226 ++++++++++++++++++ 7 files changed, 458 insertions(+), 6 deletions(-) create mode 100644 ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.md create mode 100644 ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.raw.md create mode 100644 ai/prompt-io/prompts/issue_477.md create mode 100644 tractor/to_actor/__init__.py create mode 100644 tractor/to_actor/_api.py diff --git a/ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.md b/ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.md new file mode 100644 index 00000000..7fecf36d --- /dev/null +++ b/ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.md @@ -0,0 +1,79 @@ +--- +model: claude-fable-5 +service: claude +session: f6c84722-471a-4458-9a80-e453fea9029f +timestamp: 2026-07-02T15:42:55Z +git_ref: 65bf9df5 +scope: code +substantive: true +raw_file: 20260702T154255Z_65bf9df5_prompt_io.raw.md +--- + +## Prompt + +Driver prompt file `ai/prompt-io/prompts/issue_477.md`: + +> attempt to resolve +> https://github.com/goodboy/tractor/issues/477 +> do it with /open-wkt. + +(plus a hard stop-for-human-review deadline of 12:50PM +EST the same day) + +Issue #477 asks to factor `ActorNursery.run_in_actor()` +(and possibly `Portal.run()`) out of the nursery +internals into a new `tractor.to_actor` wrapper +subpackage of "higher level one shot" single-remote-task +APIs, adopting the `trio.to_thread`/`anyio.to_process` +parlance, so that error collection/propagation moves up +into the caller's local `trio` scope and the nursery's +spawn machinery can eventually drop the +`._ria_nursery` coupling. + +## Response summary + +First-cut `tractor.to_actor` subpkg delivering the +one-shot API composed purely from the existing +daemon-spawn + portal primitives (`start_actor()` + +`Portal.run()` + `Portal.cancel_actor()`), leaving the +legacy `.run_in_actor()` machinery untouched (formal +deprecation deferred until in-repo usage migrates): + +- `to_actor.run(fn, **fn_kwargs) -> Any`: spawn a + subactor, schedule `fn` as its lone remote task, wait + on and return its result, ALWAYS reaping the subactor + (shield-safe `finally`). Remote errors raise in the + caller's task as boxed `RemoteActorError`s. +- placement variants: `portal=` reuses a running actor + (no spawn/reap), `an=` spawns from a caller-managed + actor-nursery, neither opens a call-scoped private + `open_nursery()` (implicitly booting the runtime, + configurable via `runtime_kwargs`). +- fail-fast validation before any spawn: non-streaming + async fn required; `portal=`/`an=` mutually + exclusive; `runtime_kwargs` rejected alongside any + placement opt. +- `run_in_actor()` TODO/docstring now cross-reference + the successor API. + +## Files changed + +- `tractor/to_actor/__init__.py` — new subpkg, + re-exports `run` +- `tractor/to_actor/_api.py` — `run()` + + `_invoke_in_subactor()` + `_validate_one_shot_fn()` +- `tractor/__init__.py` — top-level `to_actor` + re-export +- `tractor/runtime/_supervise.py` — comment/docstring + pointers from `run_in_actor()` to the successor +- `tests/test_to_actor.py` — 11-test suite covering + all placement variants, error relay, the concurrent + worker-pool-ish pattern and arg validation +- `examples/parallelism/to_actor_one_shots.py` — + runnable demo (auto-collected by + `test_docs_examples.py`) + +## Human edits + +None yet — pending human review (work paused before the +12:50PM EST deadline per the driver prompt). diff --git a/ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.raw.md b/ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.raw.md new file mode 100644 index 00000000..0000dab8 --- /dev/null +++ b/ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.raw.md @@ -0,0 +1,100 @@ +--- +model: claude-fable-5 +service: claude +timestamp: 2026-07-02T15:42:55Z +git_ref: 65bf9df5 +diff_cmd: git diff main..wkt/to_actor_subpkg +--- + +# Raw AI output (diff-ref mode) + +All generated code is committed on the +`wkt/to_actor_subpkg` branch; per diff-ref mode each +file's verbatim content is reachable via the pointers +below rather than duplicated here. + +## Generated files + +> `git diff main..wkt/to_actor_subpkg -- tractor/to_actor/__init__.py` + +New subpackage init: module docstring establishing the +`trio.to_thread`/`anyio.to_process` "run it over there" +parlance for actors, plus the single public re-export +`run as run` from `._api`. + +> `git diff main..wkt/to_actor_subpkg -- tractor/to_actor/_api.py` + +The one-shot invocation impl, composed entirely from the +lower level daemon-spawn + portal primitives as +prescribed by issue #477: + +- `_validate_one_shot_fn()`: the `Portal.run()` + non-streaming-async-fn constraint checked up-front, + before any subactor is spawned. +- `_invoke_in_subactor()`: `an.start_actor()` -> + `Portal.run()` -> always-reap via + `Portal.cancel_actor()` in a `finally` (the cancel + req's bounded wait is internally shielded so the reap + also runs under caller-scope cancellation). +- `run()`: the public API. Placement options: + `portal=` (reuse a running actor, no spawn/reap), + `an=` (spawn from a caller-managed nursery), or + neither (private `open_nursery()` scoped to the call, + implicitly booting the runtime when needed, tunable + via pass-through `runtime_kwargs`). Spawn opts mirror + `ActorNursery.start_actor()`; `**fn_kwargs` are + relayed to the remote task. Errors raise in the + caller's task as boxed `RemoteActorError`s. + `runtime_kwargs` alongside any placement opt is a + hard `ValueError`, never silently ignored. + +> `git diff main..wkt/to_actor_subpkg -- tractor/__init__.py` + +Top-level `from . import to_actor as to_actor` +re-export. + +> `git diff main..wkt/to_actor_subpkg -- tractor/runtime/_supervise.py` + +Comment/docstring-only: the `run_in_actor()` deprecation +TODO now points at the implemented `.to_actor.run()` +successor (checkbox ticked) and the method docstring +gains a NOTE steering users to the new API; remaining +TODO items are the `DeprecationWarning` emission + +in-repo usage migration. + +> `git diff main..wkt/to_actor_subpkg -- tests/test_to_actor.py` + +11-test suite: private-nursery one-shot, implicit +runtime boot via `runtime_kwargs`, remote-error relay to +the caller's task (bare + caller-managed nursery), +caller-nursery spawn, portal reuse w/o implicit reap, +the concurrent worker-pool-ish pattern (local `trio` +nursery x shared `an`), and the four validation +rejections (sync fn, async-gen fn, `portal`+`an` +combo, `runtime_kwargs`+placement combo). + +> `git diff main..wkt/to_actor_subpkg -- examples/parallelism/to_actor_one_shots.py` + +Runnable example (auto-collected by +`test_docs_examples.py`): the fully-implicit one-shot +plus the concurrent worker-pool-ish prime-check pattern +against a shared caller-managed actor-nursery. + +## Test runs (verbatim) + +``` +tests/test_to_actor.py .......... [100%] +============= 10 passed in 4.29s ============= +``` + +Regression subset for touched modules +(`test_local.py test_rpc.py test_spawning.py +test_cancellation.py`): + +``` +38 passed, 1 xfailed, 24 warnings in 80.71s (0:01:20) +``` + +(warnings are pre-existing stdlib `os.fork()` +DeprecationWarnings from the mp spawn backends, not +introduced by this change) diff --git a/ai/prompt-io/prompts/issue_477.md b/ai/prompt-io/prompts/issue_477.md new file mode 100644 index 00000000..3949e81d --- /dev/null +++ b/ai/prompt-io/prompts/issue_477.md @@ -0,0 +1,7 @@ +NOTE: you MUST pause this work at 12:50PM EST (BEFORE your weekly +limit reset) for review by a human! + +--- + +attempt to resolve https://github.com/goodboy/tractor/issues/477 +do it with /open-wkt. diff --git a/tractor/__init__.py b/tractor/__init__.py index 287ac940..6c7a6634 100644 --- a/tractor/__init__.py +++ b/tractor/__init__.py @@ -62,6 +62,7 @@ from .devx import ( post_mortem as post_mortem, ) from . import msg as msg +from . import to_actor as to_actor from ._root import ( run_daemon as run_daemon, open_root_actor as open_root_actor, diff --git a/tractor/runtime/_supervise.py b/tractor/runtime/_supervise.py index f5c5e786..9cb30941 100644 --- a/tractor/runtime/_supervise.py +++ b/tractor/runtime/_supervise.py @@ -383,12 +383,13 @@ class ActorNursery: ) # TODO: DEPRECATE THIS: - # -[ ] impl instead as a hilevel wrapper on - # top of a `@context` style invocation. - # |_ dynamic @context decoration on child side - # |_ implicit `Portal.open_context() as (ctx, first):` - # and `return first` on parent side. - # |_ mention how it's similar to `trio-parallel` API? + # -[x] impl instead as a hilevel wrapper on top of + # the lower level daemon-spawn + portal APIs + # |_ see `.to_actor.run()` (issue #477) which does + # `.start_actor()` + `Portal.run()` + a one-shot + # reap via `Portal.cancel_actor()`. + # -[ ] emit a `DeprecationWarning` here (requires + # migrating all in-repo usage first!) # -[ ] use @api_frame on the wrapper async def run_in_actor( self, @@ -416,6 +417,11 @@ class ActorNursery: until the task spawned by executing ``fn`` completes at which point the actor is terminated. + NOTE: prefer the (eventual) replacement API + `tractor.to_actor.run()` which delivers the same + one-shot semantics decoupled from this nursery's + internal spawn machinery; see issue #477. + ''' __runtimeframe__: int = 1 # noqa mod_path: str = fn.__module__ diff --git a/tractor/to_actor/__init__.py b/tractor/to_actor/__init__.py new file mode 100644 index 00000000..bbec4133 --- /dev/null +++ b/tractor/to_actor/__init__.py @@ -0,0 +1,33 @@ +# tractor: distributed structured concurrency. +# Copyright 2018-eternity Tyler Goodlet. + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +''' +`tractor.to_actor`: high-level "one-shot" remote-task APIs. + +Adopts the "run it over there" parlance from analogous +(sibling-library) APIs like `trio.to_thread` and +`anyio.to_process` but for SC-supervised actors: spawn (or +reuse) a subactor, schedule a single remote task, wait on +its result and (when the call owns the subactor) reap it. + +The "spiritual successor" to (and eventual replacement of) +the `ActorNursery.run_in_actor()` API; see +https://github.com/goodboy/tractor/issues/477 + +''' +from ._api import ( + run as run, +) diff --git a/tractor/to_actor/_api.py b/tractor/to_actor/_api.py new file mode 100644 index 00000000..fc15193f --- /dev/null +++ b/tractor/to_actor/_api.py @@ -0,0 +1,226 @@ +# tractor: distributed structured concurrency. +# Copyright 2018-eternity Tyler Goodlet. + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +''' +One-shot remote-task invocation built on spawn-and-portal +primitives. + +Implemented (as prescribed by #477) entirely "on top of" +the lower level daemon-actor spawn + portal APIs, + +- `ActorNursery.start_actor()` for (daemon-style) subactor + spawning, +- `Portal.run()` for scheduling the lone remote task and + waiting on its result, +- `Portal.cancel_actor()` for reaping the subactor once + that result (or error) arrives, + +such that error collection and propagation happens in the +*caller's task* (and thus whatever `trio` nursery/scope +encloses it) instead of inside the actor-nursery's +spawn-machinery nurseries as with the (to be deprecated) +`ActorNursery.run_in_actor()` API. + +''' +from __future__ import annotations +import inspect +from typing import ( + Any, + Callable, + TYPE_CHECKING, +) + +from ..runtime._supervise import ( + ActorNursery, + open_nursery, +) + +if TYPE_CHECKING: + from ..discovery._addr import UnwrappedAddress + from ..runtime._portal import Portal + + +def _validate_one_shot_fn( + fn: Callable, +) -> None: + ''' + Ensure `fn` is a non-streaming async function, raise + a `TypeError` otherwise. + + The same constraint enforced by `Portal.run()` but + checked up-front, BEFORE any subactor is spawned. + + ''' + if not ( + inspect.iscoroutinefunction(fn) + and + not getattr( + fn, + '_tractor_stream_function', + False, + ) + ): + raise TypeError( + f'{fn!r} must be a non-streaming async ' + f'function!' + ) + + +async def _invoke_in_subactor( + an: ActorNursery, + fn: Callable, + name: str, + spawn_kwargs: dict[str, Any], + fn_kwargs: dict[str, Any], +) -> Any: + ''' + Spawn a (daemon) subactor via `an.start_actor()`, + schedule `fn` as its lone remote task via + `Portal.run()` and, ALWAYS, reap the subactor once + that task's result (or error) has been delivered. + + ''' + portal: Portal = await an.start_actor( + name, + **spawn_kwargs, + ) + try: + return await portal.run( + fn, + **fn_kwargs, + ) + finally: + # one-shot semantics: the subactor's lifetime is + # bound to its lone task's completion; the + # cancel-req's bounded wait is shielded + # internally (see `Portal.cancel_actor()`) so + # this reap also runs when the caller's scope + # was itself cancelled. + await portal.cancel_actor() + + +async def run( + fn: Callable, + *, + + # actor "placement": reuse an already-running peer + # via its `portal`, spawn a fresh subactor from + # a caller-managed `an: ActorNursery`, or, when + # neither is provided, open a private actor-nursery + # (implicitly booting the actor-runtime as needed) + # scoped to just this call. + portal: Portal|None = None, + an: ActorNursery|None = None, + + # subactor spawn opts passed (mostly) verbatim to + # `ActorNursery.start_actor()`; unused when `portal` + # is provided. + name: str|None = None, + bind_addrs: list[UnwrappedAddress]|None = None, + enable_modules: list[str]|None = None, + loglevel: str|None = None, + debug_mode: bool|None = None, + infect_asyncio: bool = False, + inherit_parent_main: bool = True, + proc_kwargs: dict[str, Any]|None = None, + + # passed verbatim to the private `open_nursery()` + # (and in turn any implicit `open_root_actor()`) + # when NO `an`/`portal` is provided. + runtime_kwargs: dict[str, Any]|None = None, + + **fn_kwargs, # explicit (keyword) args to `fn` + +) -> Any: + ''' + Run the async `fn` as the lone task in a (new) + subactor, block waiting on its result and return it; + the distributed-parallelism equivalent of + `trio.to_thread.run_sync()`. + + Unlike `ActorNursery.run_in_actor()` (which returns + a `Portal` whose result is only collected at + actor-nursery teardown) this is a plain "call and + wait" primitive: any remote error is raised HERE, in + the caller's task. Concurrency is composed the usual + `trio` way by scheduling multiple `run()` calls in + a local task nursery, ideally against a shared + caller-managed `an: ActorNursery` (see the test + suite for the canonical worker-pool-ish pattern). + + ''' + __runtimeframe__: int = 1 # noqa + _validate_one_shot_fn(fn) + + if ( + runtime_kwargs + and + ( + an is not None + or + portal is not None + ) + ): + raise ValueError( + '`runtime_kwargs` only applies when this ' + 'call opens its own private actor-nursery ' + '(no `an`/`portal` provided)!' + ) + + if portal is not None: + if an is not None: + raise ValueError( + 'Pass at most ONE of `portal` or `an`, ' + 'not both!' + ) + return await portal.run( + fn, + **fn_kwargs, + ) + + name: str = name or fn.__name__ + spawn_kwargs: dict[str, Any] = dict( + enable_modules=( + [fn.__module__] + + + (enable_modules or []) + ), + bind_addrs=bind_addrs, + loglevel=loglevel, + debug_mode=debug_mode, + infect_asyncio=infect_asyncio, + inherit_parent_main=inherit_parent_main, + proc_kwargs=proc_kwargs, + ) + if an is not None: + return await _invoke_in_subactor( + an, + fn, + name, + spawn_kwargs, + fn_kwargs, + ) + + async with open_nursery( + **(runtime_kwargs or {}), + ) as an: + return await _invoke_in_subactor( + an, + fn, + name, + spawn_kwargs, + fn_kwargs, + )