Skip to content

Commit e058478

Browse files
committed
fix: keep worker-local sync calls off the event loop
1 parent a42f5b4 commit e058478

3 files changed

Lines changed: 81 additions & 16 deletions

File tree

‎runpod/apps/handles.py‎

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ async def transcribe(audio_url: str): ...
1818
def load_model(): ... # worker startup hook
1919
"""
2020

21+
import asyncio
2122
import inspect
2223
from typing import (
2324
TYPE_CHECKING,
@@ -30,7 +31,7 @@ def load_model(): ... # worker startup hook
3031
Type,
3132
)
3233

33-
from .context import Context, current_context
34+
from .context import Context, block, current_context
3435
from .discovery_state import DiscoveryInvocationError, in_discovery
3536
from .errors import InvalidResourceError
3637
from .invoker import Invoker, StreamInvoker
@@ -64,7 +65,7 @@ def __init__(
6465
if stamped and not spec.schedule:
6566
spec.schedule = stamped
6667

67-
self.remote = Invoker(self._remote_async)
68+
self.remote = Invoker(self._remote_async, sync_factory=self._remote_sync)
6869
self.stream = StreamInvoker(self._stream_async)
6970
self.spawn = Invoker(self._spawn_async)
7071
self.job = Invoker(self._job_async)
@@ -117,22 +118,46 @@ def local(self, *args: Any, **kwargs: Any) -> Any:
117118
function is async."""
118119
return self._fn(*args, **kwargs)
119120

121+
def _call_local_sync(self, *args: Any, **kwargs: Any) -> Any:
122+
result = self._fn(*args, **kwargs)
123+
return list(result) if inspect.isgenerator(result) else result
124+
125+
@staticmethod
126+
async def _await_local_result(result: Any) -> Any:
127+
if inspect.isawaitable(result):
128+
result = await result
129+
if inspect.isasyncgen(result):
130+
return [chunk async for chunk in result]
131+
return list(result) if inspect.isgenerator(result) else result
132+
133+
def _remote_sync(self, *args: Any, **kwargs: Any) -> Any:
134+
if (
135+
current_context() is Context.WORKER
136+
and self._is_current_worker()
137+
and not inspect.iscoroutinefunction(self._fn)
138+
and not inspect.isasyncgenfunction(self._fn)
139+
):
140+
self._guard_discovery()
141+
self.spec.validate()
142+
result = self._call_local_sync(*args, **kwargs)
143+
if inspect.isawaitable(result) or inspect.isasyncgen(result):
144+
return block(self._await_local_result(result))
145+
return result
146+
return block(self._remote_async(*args, **kwargs))
147+
120148
async def _remote_async(self, *args: Any, **kwargs: Any) -> Any:
121149
self._guard_discovery()
122150
self.spec.validate()
123151
ctx = current_context()
124152

125153
if ctx is Context.WORKER and self._is_current_worker():
126-
result = self._fn(*args, **kwargs)
127-
if inspect.isawaitable(result):
128-
result = await result
129-
# generators aggregate, matching the deployed worker's
130-
# return_aggregate_stream output for .remote()
131-
if inspect.isasyncgen(result):
132-
return [chunk async for chunk in result]
133-
if inspect.isgenerator(result):
134-
return list(result)
135-
return result
154+
if inspect.iscoroutinefunction(self._fn) or inspect.isasyncgenfunction(
155+
self._fn
156+
):
157+
result = self._fn(*args, **kwargs)
158+
else:
159+
result = await asyncio.to_thread(self._call_local_sync, *args, **kwargs)
160+
return await self._await_local_result(result)
136161

137162
target = await self._app._resolve(self.spec)
138163
payload = target.build_payload(self._fn, self.spec, args, kwargs)

‎runpod/apps/invoker.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
returns a sync iterator, `.aio(...)` returns the async iterator.
1111
"""
1212

13-
from typing import Any, AsyncIterator, Callable, Coroutine, Iterator
13+
from typing import Any, AsyncIterator, Callable, Coroutine, Iterator, Optional
1414

1515
from .context import block
1616

@@ -19,12 +19,17 @@
1919

2020

2121
class Invoker:
22-
__slots__ = ("_factory",)
22+
__slots__ = ("_factory", "_sync_factory")
2323

24-
def __init__(self, factory: CoroFactory):
24+
def __init__(
25+
self, factory: CoroFactory, *, sync_factory: Optional[Callable] = None
26+
):
2527
self._factory = factory
28+
self._sync_factory = sync_factory
2629

2730
def __call__(self, *args: Any, **kwargs: Any) -> Any:
31+
if self._sync_factory is not None:
32+
return self._sync_factory(*args, **kwargs)
2833
return block(self._factory(*args, **kwargs))
2934

3035
def aio(self, *args: Any, **kwargs: Any) -> Coroutine[Any, Any, Any]:

‎tests/test_apps/test_dispatch.py‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""tests for context detection and remote dispatch."""
22

3+
import os
4+
import subprocess
5+
import sys
36
from unittest.mock import AsyncMock, patch
47

58
import pytest
@@ -210,6 +213,39 @@ def q(x):
210213

211214
assert q.remote(4) == 40
212215

216+
def test_worker_local_recursion_from_sync_and_async_callers(self):
217+
script = """
218+
import asyncio
219+
import runpod
220+
221+
app = runpod.App("self-smoke")
222+
223+
@app.queue(cpu="cpu3c-2-4")
224+
def countdown(n):
225+
return 0 if n == 0 else 1 + countdown.remote(n - 1)
226+
227+
assert countdown.remote(3) == 3
228+
229+
async def main():
230+
results = await asyncio.gather(*(countdown.remote.aio(4) for _ in range(40)))
231+
assert results == [4] * 40
232+
233+
asyncio.run(main())
234+
"""
235+
subprocess.run(
236+
[sys.executable, "-c", script],
237+
env={
238+
**os.environ,
239+
"RUNPOD_POD_ID": "local-worker",
240+
"RUNPOD_DEV_APP": "self-smoke",
241+
"RUNPOD_DEV_RESOURCE": "countdown",
242+
},
243+
check=True,
244+
capture_output=True,
245+
text=True,
246+
timeout=10,
247+
)
248+
213249
def test_worker_calls_sibling_via_sentinel(self, monkeypatch):
214250
monkeypatch.setenv("RUNPOD_ENDPOINT_ID", "ep-1")
215251
monkeypatch.setenv("FLASH_RESOURCE_NAME", "other")
@@ -409,7 +445,6 @@ async def caller():
409445
class TestModuleSourceShipping:
410446
def test_whole_module_ships(self, tmp_path):
411447
import importlib.util
412-
import sys
413448

414449
mod_file = tmp_path / "shipmod.py"
415450
mod_file.write_text(

0 commit comments

Comments
 (0)