Skip to content

Commit 7754116

Browse files
walcz-delocalai-org-maint-bot
authored andcommitted
fix(backend/python): dispatch sync servicer behaviors off the event loop
Addresses review feedback: awaiting only awaitable results removed the TypeError, but still ran a sync LoadModel/Embedding -- and stepped a sync stream via next() -- on the asyncio event-loop thread, so a slow load/inference/stream could freeze all aio RPC handling. Route sync behavior through run_in_executor (a worker thread) while awaiting native async behavior directly. A callable wrapper that returns an awaitable is run in the thread and its awaitable awaited back on the loop. Sync streaming pulls each item via the executor with a done sentinel, so StopIteration cannot escape through a Future. Adds regression tests that record the handler thread id and assert it differs from the event-loop thread, for LoadModel, a guarded unary RPC and a sync stream. Signed-off-by: stefanwalcz <stefan.walcz@walcz.de>
1 parent 550abb6 commit 7754116

2 files changed

Lines changed: 125 additions & 14 deletions

File tree

backend/python/common/model_identity.py

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
the model itself.
2121
"""
2222

23+
import asyncio
2324
import inspect
2425
import threading
2526

@@ -182,6 +183,40 @@ def guard(request, context):
182183
return _rebuild(handler, guard)
183184

184185

186+
_STREAM_DONE = object()
187+
188+
189+
def _next_or_done(iterator):
190+
"""next(iterator), returning the _STREAM_DONE sentinel at exhaustion.
191+
192+
StopIteration must not propagate out of a function run via run_in_executor:
193+
it cannot travel through a Future and would surface as an opaque error.
194+
"""
195+
try:
196+
return next(iterator)
197+
except StopIteration:
198+
return _STREAM_DONE
199+
200+
201+
async def _call_behavior(behavior, request, context):
202+
"""Invoke a unary servicer behavior without blocking the event loop.
203+
204+
Native async behavior is awaited directly. A sync behavior -- many backends
205+
define `def LoadModel` / `def Embedding`, not `async def` -- is dispatched to
206+
a worker thread so a slow load/inference cannot freeze all aio RPC handling,
207+
mirroring grpc.aio's own sync-handler adaptation. A callable wrapper that
208+
returns an awaitable is supported too: the (cheap) call runs in the thread,
209+
then the awaitable is awaited back on the loop.
210+
"""
211+
if inspect.iscoroutinefunction(behavior):
212+
return await behavior(request, context)
213+
loop = asyncio.get_running_loop()
214+
result = await loop.run_in_executor(None, behavior, request, context)
215+
if inspect.isawaitable(result):
216+
result = await result
217+
return result
218+
219+
185220
class AsyncModelIdentityInterceptor(grpc.aio.ServerInterceptor):
186221
"""Async counterpart for backends running grpc.aio servers."""
187222

@@ -202,13 +237,10 @@ async def intercept_service(self, continuation, handler_call_details):
202237

203238
async def record(request, context):
204239
# A backend's LoadModel may be a plain sync method (many define
205-
# `def LoadModel`, not `async def`). grpc.aio's own dispatch
206-
# adapts both, but this interceptor calls the behavior directly,
207-
# so it must not await a non-awaitable return -- otherwise a sync
208-
# backend fails with "object <T> can't be used in 'await'".
209-
result = original(request, context)
210-
if inspect.isawaitable(result):
211-
result = await result
240+
# `def LoadModel`, not `async def`). Dispatch it so it neither
241+
# crashes with "object <T> can't be used in 'await'" nor runs its
242+
# (potentially slow) body on the event loop thread.
243+
result = await _call_behavior(original, request, context)
212244
if getattr(result, "success", True):
213245
self.state.record(getattr(request, "Model", ""))
214246
return result
@@ -223,14 +255,20 @@ async def guard_stream(request, context):
223255
if message is not None:
224256
await context.abort(grpc.StatusCode.NOT_FOUND, message)
225257
# A sync backend yields a plain generator, an async one an async
226-
# generator; iterate whichever this is.
258+
# generator. Async: iterate directly. Sync: pull each item via a
259+
# worker thread so a slow producer doesn't block the event loop
260+
# (and so StopIteration can't escape through a Future).
227261
stream = original_stream(request, context)
228262
if hasattr(stream, "__aiter__"):
229263
async for response in stream:
230264
yield response
231265
else:
232-
for response in stream:
233-
yield response
266+
loop = asyncio.get_running_loop()
267+
while True:
268+
item = await loop.run_in_executor(None, _next_or_done, stream)
269+
if item is _STREAM_DONE:
270+
break
271+
yield item
234272

235273
return _rebuild(handler, guard_stream)
236274

@@ -240,9 +278,6 @@ async def guard(request, context):
240278
message = self.state.mismatch(getattr(request, "ModelIdentity", ""))
241279
if message is not None:
242280
await context.abort(grpc.StatusCode.NOT_FOUND, message)
243-
result = original_unary(request, context)
244-
if inspect.isawaitable(result):
245-
result = await result
246-
return result
281+
return await _call_behavior(original_unary, request, context)
247282

248283
return _rebuild(handler, guard)

backend/python/common/model_identity_test.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import asyncio
1313
import os
14+
import threading
1415
import unittest
1516

1617
import grpc
@@ -65,6 +66,13 @@ def _handler(behavior, response_streaming=False):
6566
return grpc.unary_unary_rpc_method_handler(behavior)
6667

6768

69+
def _const_continuation(handler):
70+
async def continuation(_):
71+
return handler
72+
73+
return continuation
74+
75+
6876
class TestInterceptorInstalled(unittest.TestCase):
6977
"""The wiring, which is where this can silently do nothing.
7078
@@ -303,6 +311,8 @@ def test_every_modality_rpc_serves_without_an_identity(self):
303311
def test_codec_rpcs_stay_unguarded(self):
304312
for method in ("/backend.Backend/AudioEncode", "/backend.Backend/AudioDecode"):
305313
self.assertNotIn(method, model_identity._GUARDED_METHODS)
314+
315+
306316
class TestAsyncInterceptorBehavior(unittest.TestCase):
307317
"""The grpc.aio counterpart, which had no behavioral coverage.
308318
@@ -407,6 +417,72 @@ async def behavior(request, context):
407417

408418
self.assertEqual(self._drain_stream(behavior, "a.gguf"), ["a", "b"])
409419

420+
# --- sync behavior must not run on the event-loop thread ---
421+
#
422+
# Awaiting a sync method's return fixed the TypeError, but calling the
423+
# (possibly slow) sync behavior on the event loop still froze all aio RPC
424+
# handling. These record the thread each behavior runs on and assert it is a
425+
# worker thread, not the loop thread.
426+
427+
def _run_capturing_loop_thread(self, method, handler, request):
428+
captured = {}
429+
430+
async def run():
431+
captured["loop"] = threading.get_ident()
432+
wrapped = await self.interceptor.intercept_service(
433+
_const_continuation(handler), _FakeCallDetails(method)
434+
)
435+
behavior = wrapped.unary_stream if handler.response_streaming else wrapped.unary_unary
436+
if handler.response_streaming:
437+
async for _ in behavior(request, _FakeContext()):
438+
pass
439+
else:
440+
await behavior(request, _FakeContext())
441+
442+
asyncio.run(run())
443+
return captured["loop"]
444+
445+
def test_sync_load_runs_off_the_event_loop(self):
446+
ran = {}
447+
448+
def behavior(request, context):
449+
ran["thread"] = threading.get_ident()
450+
return _Result()
451+
452+
loop_thread = self._run_capturing_loop_thread(
453+
"/backend.Backend/LoadModel", _handler(behavior), _Request(Model="a.gguf")
454+
)
455+
self.assertIn("thread", ran)
456+
self.assertNotEqual(ran["thread"], loop_thread)
457+
458+
def test_sync_guarded_unary_runs_off_the_event_loop(self):
459+
self._load(lambda request, context: _Result(), "a.gguf")
460+
ran = {}
461+
462+
def behavior(request, context):
463+
ran["thread"] = threading.get_ident()
464+
return "served"
465+
466+
loop_thread = self._run_capturing_loop_thread(
467+
"/backend.Backend/Predict", _handler(behavior), _Request(ModelIdentity="a.gguf")
468+
)
469+
self.assertNotEqual(ran["thread"], loop_thread)
470+
471+
def test_sync_stream_next_runs_off_the_event_loop(self):
472+
self._load(lambda request, context: _Result(), "a.gguf")
473+
ran = {}
474+
475+
def behavior(request, context):
476+
ran["thread"] = threading.get_ident()
477+
yield "a"
478+
479+
loop_thread = self._run_capturing_loop_thread(
480+
"/backend.Backend/PredictStream",
481+
_handler(behavior, response_streaming=True),
482+
_Request(ModelIdentity="a.gguf"),
483+
)
484+
self.assertNotEqual(ran["thread"], loop_thread)
485+
410486

411487
if __name__ == "__main__":
412488
unittest.main()

0 commit comments

Comments
 (0)