tractor/tests/test_to_actor.py

345 lines
8.4 KiB
Python
Raw Normal View History

'''
`tractor.to_actor`: one-shot single-remote-task API suite.
Verifies the "spiritual successor" to (and eventual
replacement of) `ActorNursery.run_in_actor()`; see
https://github.com/goodboy/tractor/issues/477
'''
from functools import partial
import pytest
import trio
import tractor
from tractor import (
RemoteActorError,
to_actor,
)
from tractor._testing import tractor_test
async def add_one(
n: int,
) -> int:
return n + 1
async def raise_value_error() -> None:
raise ValueError('kaboom')
@tractor_test
async def test_one_shot_in_private_nursery(
start_method: str,
debug_mode: bool,
):
'''
No `an`/`portal` provided: a private actor-nursery
is opened (and torn down) scoped to just the call.
'''
assert await to_actor.run(
add_one,
n=1,
) == 2
def test_one_shot_boots_implicit_runtime(
reg_addr: tuple,
start_method: str,
loglevel: str,
):
'''
Outside any actor-runtime `to_actor.run()` boots one
implicitly (just like bare `open_nursery()` usage)
configured via pass-through `runtime_kwargs`.
'''
async def main() -> None:
assert tractor.current_actor(
err_on_no_runtime=False,
) is None
result = await to_actor.run(
add_one,
n=41,
runtime_kwargs=dict(
registry_addrs=[reg_addr],
start_method=start_method,
loglevel=loglevel,
),
)
assert result == 42
trio.run(main)
@tractor_test
async def test_remote_error_relayed_to_caller_task(
start_method: str,
debug_mode: bool,
):
'''
A remote task error is raised directly in the
caller's task as a boxed `RemoteActorError` instead
of surfacing at actor-nursery teardown as with the
legacy `.run_in_actor()` API.
'''
with pytest.raises(RemoteActorError) as excinfo:
await to_actor.run(raise_value_error)
assert excinfo.value.boxed_type is ValueError
@tractor_test
async def test_spawn_from_caller_nursery(
start_method: str,
debug_mode: bool,
):
'''
Pass a caller-managed `an: ActorNursery` for the spawn.
Previously `to_actor.run()` treated an actor-runtime cancel ack
as process reaping, so the call returned while the child monitor
and its `ActorNursery._children` record remained alive until the
entire nursery exited. The assertion inside the still-open
nursery proves child-process joining and record removal now
complete before the one-shot call returns.
'''
async with tractor.open_nursery() as an:
assert await to_actor.run(
add_one,
an=an,
n=10,
) == 11
assert not an._children
@tractor_test
async def test_cancel_ack_failure_hard_reaps_child(
monkeypatch: pytest.MonkeyPatch,
start_method: str,
debug_mode: bool,
):
'''
Escalate a failed cancel acknowledgement and reap the child.
`Portal.cancel_actor()` can return `False` when its transport is
already closed without confirming runtime cancellation. The old
one-shot path ignored that result, released the nursery-wide join
gate and then waited forever for a still-running process. This
test forces that exact result without cancelling the actor, caps
the call to detect the former hang and verifies the child monitor
removes its `ActorNursery._children` record before returning.
'''
async def cancel_without_ack(
portal: tractor.Portal,
timeout: float|None = None,
raise_on_timeout: bool = False,
) -> bool:
assert raise_on_timeout
return False
monkeypatch.setattr(
tractor.Portal,
'cancel_actor',
cancel_without_ack,
)
async with tractor.open_nursery() as an:
with trio.fail_after(5):
assert await to_actor.run(
add_one,
an=an,
n=20,
) == 21
assert not an._children
def test_late_child_reap_registration_is_released():
'''
Preserve a nursery-wide reap request across child startup.
A child monitor can checkpoint while connecting to its parent as
the surrounding `ActorNursery` begins teardown. Previously the
nursery signalled only already-registered child events, so a
monitor registering afterward waited forever. This models that
ordering by publishing the nursery-wide request first and proves
the later per-child event inherits its set state immediately.
'''
an = object.__new__(tractor.ActorNursery)
an._join_procs = trio.Event()
an._child_reap_requests = {}
an._child_reaped = {}
an._join_procs.set()
reap_request, _ = an._register_child_reap(
('late_child', 'uid'),
)
assert reap_request.is_set()
@tractor_test
async def test_remote_error_from_caller_nursery(
start_method: str,
debug_mode: bool,
):
'''
With a caller-managed `an` the remote error also
surfaces in the caller's task, INSIDE the nursery
block, allowing inline (supervision-style) handling.
'''
async with tractor.open_nursery() as an:
with pytest.raises(RemoteActorError) as excinfo:
await to_actor.run(
raise_value_error,
an=an,
)
assert excinfo.value.boxed_type is ValueError
@tractor_test
async def test_reuse_existing_actor_via_portal(
start_method: str,
debug_mode: bool,
):
'''
Pass `portal=` to schedule the one-shot task in an
already-running actor; no spawn, no implicit reap.
'''
async with tractor.open_nursery() as an:
portal: tractor.Portal = await an.start_actor(
'one_shot_worker',
enable_modules=[__name__],
)
for i in range(3):
assert await to_actor.run(
add_one,
portal=portal,
n=i,
) == i + 1
# still alive: caller owns the actor's lifetime.
await portal.cancel_actor()
@tractor_test
async def test_concurrent_one_shots_from_task_nursery(
start_method: str,
debug_mode: bool,
):
'''
The worker-pool-ish pattern from #477: concurrency
is composed with a plain (caller-side) `trio` task
nursery scheduling multiple one-shot calls against
a shared caller-managed actor-nursery; error
collection thus lives entirely in caller-code.
'''
results: dict[int, int] = {}
async def one_shot(
an: tractor.ActorNursery,
i: int,
) -> None:
results[i] = await to_actor.run(
add_one,
an=an,
name=f'one_shot_{i}',
n=i,
)
async with (
tractor.open_nursery() as an,
trio.open_nursery() as tn,
):
for i in range(4):
tn.start_soon(one_shot, an, i)
assert results == {
i: i + 1 for i in range(4)
}
def test_rejects_sync_fn():
'''
Non-async callables error BEFORE any spawn (or even
runtime-boot) happens.
'''
def not_async() -> None:
...
with pytest.raises(TypeError):
trio.run(
partial(
to_actor.run,
not_async,
)
)
def test_rejects_streaming_fn():
'''
Async-gen (streaming) fns are not one-shot-able,
same constraint as `Portal.run()`.
'''
async def agen():
yield 1
with pytest.raises(TypeError):
trio.run(
partial(
to_actor.run,
agen,
)
)
def test_rejects_portal_and_an_combo():
'''
`portal=` and `an=` are mutually exclusive
placement options.
'''
with pytest.raises(ValueError):
trio.run(
partial(
to_actor.run,
add_one,
portal=object(),
an=object(),
n=1,
)
)
def test_rejects_runtime_kwargs_with_placement():
'''
`runtime_kwargs` only applies when the call opens
its own private actor-nursery; passing it alongside
a placement opt is an error, never silently
ignored.
'''
with pytest.raises(ValueError):
trio.run(
partial(
to_actor.run,
add_one,
an=object(),
runtime_kwargs=dict(
loglevel='cancel',
),
n=1,
)
)