Files
hkuds--lightrag/tests/test_sync_wrapper_guard.py
2026-07-13 12:08:54 +08:00

197 lines
6.7 KiB
Python

"""Regression tests for the synchronous-wrapper event-loop guard.
``LightRAG``'s synchronous wrappers (``insert``, ``query``,
``delete_by_entity`` …) all delegate to :func:`lightrag.lightrag._run_sync`,
which drives the matching ``a*`` coroutine via ``loop.run_until_complete()``.
That call is only valid when (a) no event loop is already running on the
current thread, and (b) the loop it drives is the same one the instance's
storages were initialized on (``LightRAG._owning_loop``). Two misuse modes
break this:
* Called from within a running loop it raises ``RuntimeError: This event loop
is already running`` — a fail-fast error, *not* a deadlock.
* Driven from a different — but still alive — loop, e.g.
``loop.run_in_executor(None, rag.insert, …)`` runs the wrapper on a pool
thread that spins up a fresh loop while the app's loop keeps running — binds
the shared ``asyncio.Lock`` objects in ``lightrag.kg.shared_storage`` to the
wrong loop (``<Lock> is bound to a different event loop`` / a stall).
The cross-loop check only fires while ``owning_loop`` is still open. A *closed*
owning loop (the ``rag = asyncio.run(initialize_rag())`` pattern, where
``asyncio.run`` closes the loop after ``initialize_storages()``) is let through
to run on a fresh loop, preserving long-standing behavior.
Both misuse modes have the same fix: use the ``a*`` coroutine directly.
``_run_sync`` detects the running loop up front and compares the loop it is
about to drive against ``owning_loop``, raising one clear, actionable error for
each. These tests pin that behaviour (and guard against regressing back to the
misleading "deadlock" wording / un-awaited-coroutine leak). See
HKUDS/LightRAG #1968.
"""
import asyncio
from concurrent.futures import ThreadPoolExecutor
import pytest
from lightrag.lightrag import _run_sync
@pytest.mark.offline
def test_run_sync_runs_coroutine_when_no_loop_running():
"""With no running loop, the coroutine runs to completion and returns."""
def factory():
async def _coro():
return 42
return _coro()
result = _run_sync(factory, sync_name="insert", async_name="ainsert")
assert result == 42
@pytest.mark.offline
def test_run_sync_raises_clear_error_inside_running_loop():
"""Inside a running loop the guard raises a RuntimeError pointing at the
async alternative — and the message is honest (no "deadlock" claim)."""
def factory(): # pragma: no cover - must never be reached
raise AssertionError("coro_factory must not be called inside a loop")
async def _inside_loop():
return _run_sync(factory, sync_name="insert", async_name="ainsert")
with pytest.raises(RuntimeError) as exc_info:
asyncio.run(_inside_loop())
message = str(exc_info.value)
assert "insert()" in message
assert "await ainsert(" in message
# The previous fix (PR #3245) mislabelled this as a deadlock; it is an
# immediate RuntimeError. Keep the message free of that wording.
assert "deadlock" not in message.lower()
@pytest.mark.offline
def test_run_sync_does_not_create_coroutine_when_it_raises():
"""The factory is invoked lazily, so a guard failure never leaves an
un-awaited coroutine behind (which would emit a RuntimeWarning)."""
calls: list[bool] = []
def factory():
calls.append(True)
async def _coro():
return None
return _coro()
async def _inside_loop():
return _run_sync(factory, sync_name="query", async_name="aquery")
with pytest.raises(RuntimeError):
asyncio.run(_inside_loop())
assert calls == [], "coro_factory should not run when the guard rejects"
@pytest.mark.offline
def test_run_sync_runs_when_owning_loop_matches():
"""When the loop being driven is the instance's owning loop, the coroutine
runs to completion — the cross-loop guard only fires on a mismatch."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
def factory():
async def _coro():
return 42
return _coro()
try:
# always_get_an_event_loop() returns the loop set above, which is the
# one we declare as owning -> match -> the coroutine runs.
result = _run_sync(
factory, sync_name="insert", async_name="ainsert", owning_loop=loop
)
assert result == 42
finally:
loop.close()
asyncio.set_event_loop(None)
@pytest.mark.offline
def test_run_sync_raises_when_driven_from_a_different_loop():
"""Driving a sync wrapper from a thread with no event loop of its own
(the ``loop.run_in_executor(None, rag.insert, …)`` anti-pattern) lands on a
freshly created loop, not the instance's owning loop. The guard must fail
fast pointing at the async alternative, without running the coroutine."""
owning_loop = asyncio.new_event_loop()
calls: list[bool] = []
def factory(): # pragma: no cover - must never be reached
calls.append(True)
raise AssertionError("coro_factory must not run on the wrong loop")
def call_off_thread():
# No loop is set on this worker thread, so always_get_an_event_loop()
# creates a brand-new one — different from owning_loop.
return _run_sync(
factory,
sync_name="insert",
async_name="ainsert",
owning_loop=owning_loop,
)
try:
with ThreadPoolExecutor(max_workers=1) as executor:
with pytest.raises(RuntimeError) as exc_info:
executor.submit(call_off_thread).result()
finally:
owning_loop.close()
message = str(exc_info.value)
assert "insert()" in message
assert "await ainsert(" in message
assert "deadlock" not in message.lower()
assert calls == [], "coro_factory should not run on a mismatched loop"
@pytest.mark.offline
def test_run_sync_runs_when_owning_loop_is_closed():
"""A *closed* owning loop means the loop the storages were initialized on is
gone — the common ``rag = asyncio.run(initialize_rag())`` pattern, where
``asyncio.run`` closes the loop after ``initialize_storages()``. There is no
live loop to clash with, so the guard must let the call through and run the
coroutine on a fresh loop (preserving long-standing behavior), not reject
it."""
closed_owning_loop = asyncio.new_event_loop()
closed_owning_loop.close()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
def factory():
async def _coro():
return 42
return _coro()
try:
result = _run_sync(
factory,
sync_name="insert",
async_name="ainsert",
owning_loop=closed_owning_loop,
)
assert result == 42
finally:
loop.close()
asyncio.set_event_loop(None)