Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. All rights reserved.
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# See file LICENSE for terms.

Expand All @@ -12,6 +12,9 @@ ignore-words-list = "inout,unparseable,falsy,couldn,Couldn,thirdparty"
builtin = "clear"
quiet-level = 3

[tool.cython-lint]
max-line-length = 120

[tool.isort]
# Define known first party modules explicitly to avoid directory name dependency.
# Without this the isort/cython checks may fail if the repository clone is not
Expand Down
4 changes: 2 additions & 2 deletions python/rapidsmpf/rapidsmpf/streaming/core/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# =================================================================================
# cmake-format: off
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# cmake-format: on
# =================================================================================

set(cython_modules actor.pyx channel.pyx context.pyx fanout.pyx leaf_actor.pyx
set(cython_modules actor.pyx cancellation.pyx channel.pyx context.pyx fanout.pyx leaf_actor.pyx
memory_reserve_or_wait.pyx message.pyx spillable_messages.pyx
)

Expand Down
76 changes: 53 additions & 23 deletions python/rapidsmpf/rapidsmpf/streaming/core/actor.pyx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from cpython.object cimport PyObject
Expand All @@ -11,7 +11,7 @@ from libcpp.vector cimport vector
import asyncio
import inspect
from collections.abc import Iterable, Mapping
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import Future, ThreadPoolExecutor
from functools import partial, wraps

from rapidsmpf._detail.exception_handling cimport ex_handler
Expand All @@ -20,6 +20,8 @@ from rapidsmpf.streaming._detail.libcoro_spawn_task cimport cpp_set_py_future
from rapidsmpf.streaming.chunks.utils cimport py_deleter
from rapidsmpf.streaming.core.context cimport Context, cpp_Context

from rapidsmpf.streaming.core.cancellation import (await_cpp_future,
shutdown_channels)
from rapidsmpf.streaming.core.channel import Channel
from rapidsmpf.streaming.core.context import Context

Expand Down Expand Up @@ -155,8 +157,7 @@ async def py_actor(func, extra_channels, /, *args, **kwargs):
try:
return await func(*args, **kwargs)
finally:
for ch in channels_to_shutdown:
await ch.shutdown(ctx)
await shutdown_channels(ctx, *channels_to_shutdown)


cdef decorate_actor(extra_channels, func):
Expand Down Expand Up @@ -224,7 +225,28 @@ def define_actor(*, extra_channels=()):
return partial(decorate_actor, extra_channels)


def sync_wait(coro):
async def run_and_publish_task(coro, task_ready):
"""
Run a coroutine and expose its event loop and task to another thread.

Parameters
----------
coro
Coroutine to run.
task_ready
Rendezvous point shared with calling thread. Receives the running
event loop and cancellable task before ``coro`` starts, allowing
the caller to cancel the task from outside the event loop.

Returns
-------
Result returned by ``coro``.
"""
Comment thread
wence- marked this conversation as resolved.
task_ready.set_result((asyncio.get_running_loop(), asyncio.current_task()))
return await coro


def sync_wait(coro, task_ready):
"""
Run, and wait for completion of, a coroutine.

Expand All @@ -237,8 +259,13 @@ def sync_wait(coro):
This should always be called from a thread we control to ensure no live
event loop is running.
"""
with asyncio.Runner() as runner:
runner.run(coro)
try:
with asyncio.Runner() as runner:
return runner.run(run_and_publish_task(coro, task_ready))
except BaseException as error:
if not task_ready.done():
task_ready.set_exception(error)
raise


async def when_all(Context ctx not None, list cpp_actors):
Expand Down Expand Up @@ -268,21 +295,7 @@ async def when_all(Context ctx not None, list cpp_actors):
cpp_set_py_future,
move(cpp_OwningWrapper(<void*><PyObject*>ret, py_deleter))
)
try:
# This shield makes sure that if a cancellation is raised, the
# future is not cancelled.
await asyncio.shield(ret)
except asyncio.CancelledError as cancel:
# The outer awaitable was cancelled, but we must still ensure the
# C++ future still runs to completion.
try:
# This could still fail so we catch and reraise the
# cancellation but recording the C++ exception as well.
await asyncio.shield(ret)
except Exception as cpp_except:
raise cancel from cpp_except
# Otherwise just reraise the cancellation
raise cancel
await await_cpp_future(ret)


def run_actor_network(Context ctx not None, *, actors):
Expand Down Expand Up @@ -344,5 +357,22 @@ def run_actor_network(Context ctx not None, *, actors):
py_actors = [when_all(ctx, cpp_actors), *py_actors]
# Need to run in a separate thread in case the cluster runtime already
# has an async event loop.
task_ready = Future()
with ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(sync_wait, run_py_actors(py_actors)).result()
worker = executor.submit(sync_wait, run_py_actors(py_actors), task_ready)
loop, task = task_ready.result()

try:
worker.result()
except BaseException as error:
if worker.done():
raise
# Inner task is not done, let's cancel it and wait for cleanup to complete
loop.call_soon_threadsafe(task.cancel)
try:
worker.result()
except asyncio.CancelledError:
pass
except BaseException as cleanup:
raise error from cleanup
raise
13 changes: 13 additions & 0 deletions python/rapidsmpf/rapidsmpf/streaming/core/cancellation.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

import asyncio
Comment thread
wence- marked this conversation as resolved.
from collections.abc import Awaitable, Callable

from rapidsmpf.streaming.core.channel import Channel
from rapidsmpf.streaming.core.context import Context

async def shutdown_channels(ctx: Context, *chs: Channel) -> None: ...
async def await_cpp_future(
future: asyncio.Future[None], *, on_cancel: Callable[[], Awaitable[None]] | None
) -> None: ...
77 changes: 77 additions & 0 deletions python/rapidsmpf/rapidsmpf/streaming/core/cancellation.pyx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
import asyncio

from rapidsmpf.streaming.core.context cimport Context


async def shutdown_channels(Context ctx not None, *chs):
"""
Shutdown channels, recording and then propagating any exceptions

Parameters
----------
ctx
Streaming context for channel shutdown
chs
Channels to shutdown

Raises
------
Any exceptions that shutting down the channels produces.
"""
results = await asyncio.gather(
*(ch.shutdown(ctx) for ch in chs), return_exceptions=True
)
errors = [r for r in results if isinstance(r, BaseException)]
if errors:
cause = (
errors[0]
if len(errors) == 1
else ExceptionGroup("Errors during shutdown of Channels", errors)
)
raise cause


async def await_cpp_future(future, *, on_cancel=None):
"""
Await a C++ future handling cancellation.

Parameters
----------
future
Awaitable future returning None bridging into libcoro.
on_cancel
Optional callback to run if cancellation is raised while awaiting
the future. Must be a zero-argument function that returns an
awaitable.
Comment thread
wence- marked this conversation as resolved.
"""
try:
# This shield makes sure that if a cancellation is raised, the
# future is not cancelled.
await asyncio.shield(future)
except asyncio.CancelledError as cancelled:
# The outer awaitable was cancelled, but we must still ensure the
# C++ future still runs to completion.
errors = []
if on_cancel is not None:
try:
# Run cancellation callback, e.g. to shutdown channels that are live
await on_cancel()
except BaseException as error:
errors.append(error)
try:
# This could still fail so we catch and reraise the
# cancellation but recording the C++ exception as well.
await asyncio.shield(future)
except BaseException as error:
errors.append(error)
if errors:
cause = (
errors[0]
if len(errors) == 1
else ExceptionGroup("Errors during cancellation of C++ awaitable", errors)
)
raise cancelled from cause
# Otherwise just reraise the cancellation
raise
20 changes: 11 additions & 9 deletions python/rapidsmpf/rapidsmpf/streaming/core/channel.pyx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from cpython.object cimport PyObject
Expand All @@ -15,6 +15,8 @@ from rapidsmpf.streaming.core.message cimport Message, cpp_Message

import asyncio

from rapidsmpf.streaming.core.cancellation import await_cpp_future


cdef extern from * nogil:
"""
Expand Down Expand Up @@ -372,7 +374,7 @@ cdef class Channel:
cpp_set_py_future,
move(cpp_OwningWrapper(<void*><PyObject*>ret, py_deleter)),
)
await ret
await await_cpp_future(ret, on_cancel=lambda: self.shutdown(ctx))

async def drain_metadata(self, Context ctx not None):
"""
Expand All @@ -392,7 +394,7 @@ cdef class Channel:
cpp_set_py_future,
move(cpp_OwningWrapper(<void*><PyObject*>ret, py_deleter)),
)
await ret
await await_cpp_future(ret, on_cancel=lambda: self.shutdown_metadata(ctx))

async def shutdown(self, Context ctx not None):
"""
Expand All @@ -418,7 +420,7 @@ cdef class Channel:
cpp_set_py_future,
move(cpp_OwningWrapper(<void*><PyObject*>ret, py_deleter)),
)
await ret
await await_cpp_future(ret)

async def shutdown_metadata(self, Context ctx not None):
"""
Expand All @@ -440,7 +442,7 @@ cdef class Channel:
cpp_set_py_future,
move(cpp_OwningWrapper(<void*><PyObject*>ret, py_deleter)),
)
await ret
await await_cpp_future(ret)

async def send(self, Context ctx, Message msg not None):
"""
Expand All @@ -467,7 +469,7 @@ cdef class Channel:
cpp_set_py_future,
move(cpp_OwningWrapper(<void*><PyObject*>ret, py_deleter)),
)
await ret
await await_cpp_future(ret, on_cancel=lambda: self.shutdown(ctx))

async def send_metadata(self, Context ctx, Message msg not None):
"""
Expand All @@ -494,7 +496,7 @@ cdef class Channel:
cpp_set_py_future,
move(cpp_OwningWrapper(<void*><PyObject*>ret, py_deleter)),
)
await ret
await await_cpp_future(ret, on_cancel=lambda: self.shutdown_metadata(ctx))

async def recv(self, Context ctx not None):
"""
Expand All @@ -520,7 +522,7 @@ cdef class Channel:
cpp_set_py_future,
move(cpp_OwningWrapper(<void*><PyObject*>ret, py_deleter))
)
await ret
await await_cpp_future(ret, on_cancel=lambda: self.shutdown(ctx))
if deref(c_msg).empty():
return None
return Message.from_handle(move(deref(c_msg)))
Expand Down Expand Up @@ -549,7 +551,7 @@ cdef class Channel:
cpp_set_py_future,
move(cpp_OwningWrapper(<void*><PyObject*>ret, py_deleter))
)
await ret
await await_cpp_future(ret, on_cancel=lambda: self.shutdown_metadata(ctx))
if deref(c_msg).empty():
return None
return Message.from_handle(move(deref(c_msg)))
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from cpython.object cimport PyObject
Expand All @@ -22,6 +22,7 @@ from rapidsmpf.streaming.core.context cimport Context, cpp_Context
import asyncio

import rapidsmpf.utils.string
from rapidsmpf.streaming.core.cancellation import await_cpp_future
from rapidsmpf.utils.memory import check_reservation_size

# Sentinel indicating that net_memory_delta estimation has not yet been implemented.
Expand Down Expand Up @@ -337,7 +338,7 @@ cdef class MemoryReserveOrWait:
cpp_set_py_future,
move(cpp_OwningWrapper(<void*><PyObject*>ret, py_deleter)),
)
await ret
await await_cpp_future(ret)

async def reserve_or_wait(self, size_t size, *, int64_t net_memory_delta):
"""
Expand Down Expand Up @@ -407,7 +408,8 @@ cdef class MemoryReserveOrWait:
cpp_set_py_future,
move(cpp_OwningWrapper(<void*><PyObject*>future, py_deleter))
)
await future
await await_cpp_future(future, on_cancel=self.shutdown)

if not c_ret:
assert False, "something went wrong, task returned a null pointer!"
return MemoryReservation.from_handle(move(deref(c_ret)), self._br)
Expand Down Expand Up @@ -458,7 +460,8 @@ cdef class MemoryReserveOrWait:
cpp_set_py_future,
move(cpp_OwningWrapper(<void*><PyObject*>future, py_deleter)),
)
await future
await await_cpp_future(future, on_cancel=self.shutdown)

if not c_ret:
assert False, "something went wrong, task returned a null pointer!"
return (
Expand Down Expand Up @@ -512,7 +515,8 @@ cdef class MemoryReserveOrWait:
cpp_set_py_future,
move(cpp_OwningWrapper(<void*><PyObject*>future, py_deleter))
)
await future
await await_cpp_future(future, on_cancel=self.shutdown)

if not c_ret:
assert False, "something went wrong, task returned a null pointer!"
return MemoryReservation.from_handle(move(deref(c_ret)), self._br)
Expand Down
Loading