851 lines
29 KiB
Python
851 lines
29 KiB
Python
"""Offline tests for priority_limit_async_func_call's cross-worker global
|
|
concurrency mode (concurrency_group + shared-storage gating).
|
|
|
|
Single-process shared storage exercises the exact code paths used under
|
|
gunicorn multi-worker mode (namespaces and keyed locks degrade to in-process
|
|
primitives).
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from lightrag.kg import shared_storage as ss
|
|
|
|
# Accessed via the module (not ``from``-imports) on purpose: another test in
|
|
# the suite reloads lightrag.utils in place, which would leave from-imported
|
|
# class references (e.g. QueueFullError) pointing at the pre-reload class and
|
|
# break pytest.raises identity checks when run in the same xdist worker.
|
|
from lightrag import utils as lr_utils
|
|
|
|
pytestmark = pytest.mark.offline
|
|
|
|
|
|
GROUP = "llm:test"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_shared_storage():
|
|
ss.finalize_share_data()
|
|
yield
|
|
ss.finalize_share_data()
|
|
|
|
|
|
def _init(limits=None):
|
|
ss.initialize_share_data(1, global_concurrency_limits=limits)
|
|
|
|
|
|
async def _wait_until(predicate, timeout=5.0, interval=0.01):
|
|
deadline = asyncio.get_running_loop().time() + timeout
|
|
while not predicate():
|
|
if asyncio.get_running_loop().time() > deadline:
|
|
raise AssertionError("condition not met within timeout")
|
|
await asyncio.sleep(interval)
|
|
|
|
|
|
async def _wait_drained(wrapped, timeout=5.0, interval=0.01):
|
|
"""Poll until the wrapper's accounting fully quiesces to zero.
|
|
|
|
Asserts the live_queued exactly-once invariant at its strongest point:
|
|
once every task has drained, the logical reservation counter
|
|
(``queued`` == live_queued), the physical queue, and task_states
|
|
(``in_flight``) must all be back to zero. A leaked reservation
|
|
(up-drift) leaves ``queued`` stuck above zero forever; a leaked
|
|
task_states entry leaves ``in_flight`` stuck. Returns the final stats.
|
|
"""
|
|
deadline = asyncio.get_running_loop().time() + timeout
|
|
while True:
|
|
stats = await wrapped.get_queue_stats()
|
|
if (
|
|
stats["queued"] == 0
|
|
and stats["in_flight"] == 0
|
|
and stats.get("physical_queued", 0) == 0
|
|
):
|
|
return stats
|
|
if asyncio.get_running_loop().time() > deadline:
|
|
raise AssertionError(f"queue did not drain to zero: {stats}")
|
|
await asyncio.sleep(interval)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core: global cap on in-flight executions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_global_limit_caps_inflight_executions():
|
|
_init({GROUP: 3})
|
|
inflight = 0
|
|
peak = 0
|
|
|
|
async def slow_func(value):
|
|
nonlocal inflight, peak
|
|
inflight += 1
|
|
peak = max(peak, inflight)
|
|
await asyncio.sleep(0.05)
|
|
inflight -= 1
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
10, queue_name="cap test", concurrency_group=GROUP
|
|
)(slow_func)
|
|
try:
|
|
results = await asyncio.gather(*(wrapped(i) for i in range(10)))
|
|
assert sorted(results) == list(range(10))
|
|
assert peak <= 3
|
|
# Slots all returned after completion.
|
|
assert await ss.global_concurrency_in_use(GROUP) == 0
|
|
finally:
|
|
await wrapped.shutdown()
|
|
|
|
|
|
async def test_limit_shared_across_wrappers_of_same_group():
|
|
"""Two wrappers (≈ two processes / instances) share one global gate."""
|
|
_init({GROUP: 1})
|
|
inflight = 0
|
|
peak = 0
|
|
|
|
async def slow_func(value):
|
|
nonlocal inflight, peak
|
|
inflight += 1
|
|
peak = max(peak, inflight)
|
|
await asyncio.sleep(0.05)
|
|
inflight -= 1
|
|
return value
|
|
|
|
wrapped_a = lr_utils.priority_limit_async_func_call(
|
|
4, queue_name="share a", concurrency_group=GROUP
|
|
)(slow_func)
|
|
wrapped_b = lr_utils.priority_limit_async_func_call(
|
|
4, queue_name="share b", concurrency_group=GROUP
|
|
)(slow_func)
|
|
try:
|
|
results = await asyncio.gather(
|
|
*(wrapped_a(i) for i in range(3)), *(wrapped_b(i) for i in range(3))
|
|
)
|
|
assert len(results) == 6
|
|
assert peak == 1
|
|
finally:
|
|
await wrapped_a.shutdown()
|
|
await wrapped_b.shutdown()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unconfigured / standalone paths stay untouched
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_no_global_limit_keeps_original_path(monkeypatch):
|
|
_init() # initialized, but no limits configured
|
|
|
|
calls = []
|
|
original_plain = ss.try_acquire_global_slot
|
|
original_tracked = ss.try_acquire_global_slot_tracked
|
|
|
|
async def spy_plain(group):
|
|
calls.append(group)
|
|
return await original_plain(group)
|
|
|
|
async def spy_tracked(group):
|
|
calls.append(group)
|
|
return await original_tracked(group)
|
|
|
|
monkeypatch.setattr(ss, "try_acquire_global_slot", spy_plain)
|
|
monkeypatch.setattr(ss, "try_acquire_global_slot_tracked", spy_tracked)
|
|
|
|
async def fast_func(value):
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2, queue_name="plain test", concurrency_group=GROUP
|
|
)(fast_func)
|
|
try:
|
|
assert await asyncio.gather(*(wrapped(i) for i in range(5))) == list(range(5))
|
|
assert calls == [] # slot acquisition never touched
|
|
stats = await wrapped.get_queue_stats()
|
|
assert "physical_queued" not in stats # bounded-queue (default) mode
|
|
finally:
|
|
await wrapped.shutdown()
|
|
|
|
|
|
async def test_standalone_group_none_never_touches_shared_storage(monkeypatch):
|
|
# Shared storage NOT initialized at all (library-external usage).
|
|
def boom(*_args, **_kwargs):
|
|
raise AssertionError("shared_storage API must not be called")
|
|
|
|
monkeypatch.setattr(ss, "try_acquire_global_slot", boom)
|
|
monkeypatch.setattr(ss, "try_acquire_global_slot_tracked", boom)
|
|
monkeypatch.setattr(ss, "publish_queue_stats", boom)
|
|
monkeypatch.setattr(ss, "aggregate_queue_stats", boom)
|
|
|
|
async def fast_func(value):
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(2, queue_name="standalone test")(
|
|
fast_func
|
|
)
|
|
try:
|
|
assert await wrapped("ok") == "ok"
|
|
stats = await wrapped.get_aggregated_queue_stats()
|
|
assert stats["completed_total"] == 1
|
|
finally:
|
|
await wrapped.shutdown()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slot waiting: cancellation, stuck detection, priority
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_user_timeout_while_waiting_for_slot_never_calls_func():
|
|
_init({GROUP: 1})
|
|
external = await ss.try_acquire_global_slot(GROUP) # saturate the gate
|
|
calls = []
|
|
|
|
async def func(value):
|
|
calls.append(value)
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2,
|
|
queue_name="slot wait test",
|
|
concurrency_group=GROUP,
|
|
cleanup_timeout=0.1,
|
|
)(func)
|
|
try:
|
|
with pytest.raises(TimeoutError, match="User timeout"):
|
|
await wrapped("never", _timeout=0.3)
|
|
assert calls == []
|
|
# The lease count is unchanged — the cancelled task never held one.
|
|
assert await ss.global_concurrency_in_use(GROUP) == 1
|
|
|
|
stats = await wrapped.get_queue_stats()
|
|
assert stats["running"] == 0 # never misjudged as running/stuck
|
|
assert stats["cancelled_total"] == 1
|
|
|
|
# Once the external holder releases, new requests get the slot.
|
|
await ss.release_global_slot(GROUP, external)
|
|
assert await wrapped("now") == "now"
|
|
assert calls == ["now"]
|
|
finally:
|
|
await wrapped.shutdown()
|
|
|
|
|
|
async def test_priority_overtakes_within_process():
|
|
_init({GROUP: 1})
|
|
external = await ss.try_acquire_global_slot(GROUP)
|
|
order = []
|
|
|
|
async def func(value):
|
|
order.append(value)
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2, queue_name="priority test", concurrency_group=GROUP
|
|
)(func)
|
|
try:
|
|
low = [asyncio.create_task(wrapped(f"low{i}", _priority=10)) for i in range(3)]
|
|
await asyncio.sleep(0.05)
|
|
high = asyncio.create_task(wrapped("high", _priority=1))
|
|
await asyncio.sleep(0.05)
|
|
|
|
await ss.release_global_slot(GROUP, external)
|
|
await asyncio.gather(high, *low)
|
|
assert order[0] == "high"
|
|
finally:
|
|
await wrapped.shutdown()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slot pump: one acquirer per process, no speculative slot grabs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_pump_acquires_only_for_live_work(monkeypatch):
|
|
"""Idle workers must not grab slots speculatively: with one task and
|
|
many local workers, exactly one slot acquisition succeeds (the herd
|
|
used to acquire-and-release, inflating global_in_use and resetting the
|
|
process's waiter seniority)."""
|
|
_init({GROUP: 4})
|
|
successes = 0
|
|
original = ss.try_acquire_global_slot_tracked
|
|
|
|
async def counting(group):
|
|
nonlocal successes
|
|
lease, is_priority = await original(group)
|
|
if lease is not None:
|
|
successes += 1
|
|
return lease, is_priority
|
|
|
|
monkeypatch.setattr(ss, "try_acquire_global_slot_tracked", counting)
|
|
|
|
async def func(value):
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
4, queue_name="pump only-live test", concurrency_group=GROUP
|
|
)(func)
|
|
try:
|
|
assert await wrapped("only") == "only"
|
|
await asyncio.sleep(0.3) # idle period: nothing else may be acquired
|
|
assert successes == 1
|
|
assert await ss.global_concurrency_in_use(GROUP) == 0
|
|
finally:
|
|
await wrapped.shutdown()
|
|
|
|
|
|
async def test_pump_never_holds_slots_without_free_worker():
|
|
"""A slot is requested only when an idle local worker can run the task
|
|
immediately — scarce global capacity is never parked behind a busy
|
|
process."""
|
|
_init({GROUP: 4})
|
|
release = asyncio.Event()
|
|
started = asyncio.Event()
|
|
|
|
async def gated(value):
|
|
started.set()
|
|
await release.wait()
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
1, queue_name="pump no-hoard test", concurrency_group=GROUP
|
|
)(gated)
|
|
try:
|
|
first = asyncio.create_task(wrapped("a"))
|
|
await started.wait()
|
|
others = [asyncio.create_task(wrapped(f"b{i}")) for i in range(3)]
|
|
await asyncio.sleep(0.3)
|
|
# Only the running task holds a slot; the queued tasks wait for the
|
|
# single local worker, not for global slots.
|
|
assert await ss.global_concurrency_in_use(GROUP) == 1
|
|
release.set()
|
|
assert await first == "a"
|
|
assert sorted(await asyncio.gather(*others)) == ["b0", "b1", "b2"]
|
|
finally:
|
|
await wrapped.shutdown()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Admission semantics (live_queued)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_admission_counts_only_live_queued_tasks():
|
|
"""max_queue_size limits QUEUED tasks; running tasks don't consume it."""
|
|
_init({GROUP: 1})
|
|
release = asyncio.Event()
|
|
started = asyncio.Event()
|
|
|
|
async def gated(value):
|
|
started.set()
|
|
await release.wait()
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2,
|
|
queue_name="admission test",
|
|
concurrency_group=GROUP,
|
|
max_queue_size=2,
|
|
)(gated)
|
|
try:
|
|
running = asyncio.create_task(wrapped("running"))
|
|
await started.wait() # now running (holds the only slot), not queued
|
|
|
|
queued = [asyncio.create_task(wrapped(f"q{i}")) for i in range(2)]
|
|
|
|
async def _queued_count():
|
|
return (await wrapped.get_queue_stats())["queued"]
|
|
|
|
deadline = asyncio.get_running_loop().time() + 5.0
|
|
while await _queued_count() != 2:
|
|
assert asyncio.get_running_loop().time() < deadline
|
|
await asyncio.sleep(0.01)
|
|
|
|
stats = await wrapped.get_queue_stats()
|
|
assert stats["queued"] == 2 # both fit: running not counted
|
|
|
|
# The third queued request exceeds capacity -> QueueFullError.
|
|
with pytest.raises(lr_utils.QueueFullError):
|
|
await wrapped("overflow", _queue_timeout=0.2)
|
|
|
|
release.set()
|
|
assert await running == "running"
|
|
assert sorted(await asyncio.gather(*queued)) == ["q0", "q1"]
|
|
finally:
|
|
await wrapped.shutdown()
|
|
|
|
|
|
async def test_zombies_do_not_consume_admission_capacity():
|
|
_init({GROUP: 1})
|
|
external = await ss.try_acquire_global_slot(GROUP)
|
|
|
|
async def func(value):
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2,
|
|
queue_name="zombie admission test",
|
|
concurrency_group=GROUP,
|
|
max_queue_size=2,
|
|
cleanup_timeout=0.05,
|
|
)(func)
|
|
try:
|
|
# Fill the queue, then let both requests time out -> zombies.
|
|
for i in range(2):
|
|
with pytest.raises(TimeoutError):
|
|
await wrapped(f"dead{i}", _timeout=0.1)
|
|
|
|
stats = await wrapped.get_queue_stats()
|
|
assert stats["queued"] == 0 # logical count excludes zombies
|
|
assert stats["physical_queued"] == 2 # tuples still in the queue
|
|
|
|
# New requests are admitted despite the zombie tuples...
|
|
new_tasks = [
|
|
asyncio.create_task(wrapped(f"new{i}", _queue_timeout=1.0))
|
|
for i in range(2)
|
|
]
|
|
await asyncio.sleep(0.05)
|
|
assert all(not t.done() for t in new_tasks) # admitted, waiting for slot
|
|
|
|
# ...and run fine once a slot frees up; zombies are drained without
|
|
# ever calling the provider for them.
|
|
await ss.release_global_slot(GROUP, external)
|
|
assert sorted(await asyncio.gather(*new_tasks)) == ["new0", "new1"]
|
|
finally:
|
|
await wrapped.shutdown(graceful=False)
|
|
|
|
|
|
async def test_max_queue_size_zero_means_unlimited_admission():
|
|
_init({GROUP: 2})
|
|
|
|
async def func(value):
|
|
await asyncio.sleep(0.01)
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2,
|
|
queue_name="unlimited admission test",
|
|
concurrency_group=GROUP,
|
|
max_queue_size=0,
|
|
)(func)
|
|
try:
|
|
results = await asyncio.gather(*(wrapped(i) for i in range(20)))
|
|
assert len(results) == 20
|
|
finally:
|
|
await wrapped.shutdown()
|
|
|
|
|
|
async def test_shutdown_wakes_admission_waiters():
|
|
_init({GROUP: 1})
|
|
await ss.try_acquire_global_slot(GROUP) # saturate so nothing dequeues
|
|
|
|
async def func(value):
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2,
|
|
queue_name="shutdown wake test",
|
|
concurrency_group=GROUP,
|
|
max_queue_size=1,
|
|
)(func)
|
|
try:
|
|
filler = asyncio.create_task(wrapped("filler"))
|
|
await asyncio.sleep(0.05)
|
|
# No _queue_timeout: sleeps on the admission condition.
|
|
waiter = asyncio.create_task(wrapped("waiter"))
|
|
await asyncio.sleep(0.05)
|
|
assert not waiter.done()
|
|
|
|
shutdown_task = asyncio.create_task(
|
|
wrapped.shutdown(graceful=False, timeout=0.1)
|
|
)
|
|
with pytest.raises(RuntimeError, match="Queue is shutting down"):
|
|
await asyncio.wait_for(waiter, timeout=2.0)
|
|
await shutdown_task
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await filler
|
|
finally:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Failure policy: fail-closed acquire, safe release, best-effort stats
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_fail_closed_acquire_keeps_task_queued_until_recovery(monkeypatch):
|
|
_init({GROUP: 2})
|
|
|
|
fail = True
|
|
original = ss.try_acquire_global_slot_tracked
|
|
|
|
async def flaky(group):
|
|
if fail:
|
|
raise RuntimeError("manager down")
|
|
return await original(group)
|
|
|
|
monkeypatch.setattr(ss, "try_acquire_global_slot_tracked", flaky)
|
|
|
|
async def func(value):
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2, queue_name="fail closed test", concurrency_group=GROUP
|
|
)(func)
|
|
try:
|
|
task = asyncio.create_task(wrapped("delayed"))
|
|
await asyncio.sleep(0.3)
|
|
assert not task.done() # never let through while acquire fails
|
|
|
|
fail = False
|
|
assert await asyncio.wait_for(task, timeout=2.0) == "delayed"
|
|
finally:
|
|
await wrapped.shutdown()
|
|
|
|
|
|
async def test_release_failure_preserves_result_and_retries_later(monkeypatch):
|
|
_init({GROUP: 1})
|
|
|
|
fail_release = True
|
|
original_release = ss.release_global_slot
|
|
|
|
async def flaky_release(group, lease_id):
|
|
if fail_release:
|
|
raise RuntimeError("release boom")
|
|
return await original_release(group, lease_id)
|
|
|
|
monkeypatch.setattr(ss, "release_global_slot", flaky_release)
|
|
|
|
async def func(value):
|
|
if value == "explode":
|
|
raise ValueError("business error")
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2, queue_name="release fail test", concurrency_group=GROUP
|
|
)(func)
|
|
try:
|
|
# Result passes through even though the lease release failed; the
|
|
# lease is parked for retry (still occupying its slot until then).
|
|
assert await wrapped("ok") == "ok"
|
|
assert await ss.global_concurrency_in_use(GROUP) >= 1
|
|
fail_release = False
|
|
await wrapped.run_maintenance() # health-check retry frees the slot
|
|
assert await ss.global_concurrency_in_use(GROUP) == 0
|
|
|
|
# Same for a failing call: the original business exception is never
|
|
# masked by the release failure.
|
|
fail_release = True
|
|
with pytest.raises(ValueError, match="business error"):
|
|
await wrapped("explode")
|
|
assert await ss.global_concurrency_in_use(GROUP) >= 1
|
|
fail_release = False
|
|
await wrapped.run_maintenance()
|
|
assert await ss.global_concurrency_in_use(GROUP) == 0
|
|
finally:
|
|
await wrapped.shutdown()
|
|
|
|
|
|
async def test_stats_failures_never_break_calls(monkeypatch):
|
|
_init({GROUP: 2})
|
|
|
|
def boom(*_args, **_kwargs):
|
|
raise RuntimeError("stats backend down")
|
|
|
|
monkeypatch.setattr(ss, "publish_queue_stats", boom)
|
|
monkeypatch.setattr(ss, "aggregate_queue_stats", boom)
|
|
monkeypatch.setattr(ss, "reconcile_global_slots", boom)
|
|
|
|
async def func(value):
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2, queue_name="best effort test", concurrency_group=GROUP
|
|
)(func)
|
|
try:
|
|
assert await wrapped("fine") == "fine"
|
|
await wrapped.run_maintenance() # must not raise
|
|
# Aggregation failure falls back to the local snapshot.
|
|
stats = await wrapped.get_aggregated_queue_stats()
|
|
assert stats["completed_total"] == 1
|
|
assert "reporting_workers" not in stats
|
|
finally:
|
|
await wrapped.shutdown()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Zombie drain & physical queue compaction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_worker_drains_zombie_backlog_then_runs_live_task():
|
|
_init({GROUP: 1})
|
|
external = await ss.try_acquire_global_slot(GROUP)
|
|
calls = []
|
|
|
|
async def func(value):
|
|
calls.append(value)
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2,
|
|
queue_name="drain test",
|
|
concurrency_group=GROUP,
|
|
max_queue_size=0,
|
|
cleanup_timeout=0.05,
|
|
)(func)
|
|
try:
|
|
# Pile up zombies well past the per-slot drain limit (16).
|
|
zombies = [wrapped(f"dead{i}", _priority=1, _timeout=0.05) for i in range(40)]
|
|
results = await asyncio.gather(*zombies, return_exceptions=True)
|
|
assert all(isinstance(r, TimeoutError) for r in results)
|
|
|
|
live = asyncio.create_task(wrapped("live", _priority=5))
|
|
await asyncio.sleep(0.05)
|
|
await ss.release_global_slot(GROUP, external)
|
|
|
|
# The worker drains zombies in bounded batches (releasing the slot
|
|
# in between) and the live task still completes; no zombie ever
|
|
# reached the provider.
|
|
assert await asyncio.wait_for(live, timeout=5.0) == "live"
|
|
assert calls == ["live"]
|
|
|
|
# live_queued invariant: after the 40 zombies drained and the live
|
|
# task completed, the reservation counter and task_states must be
|
|
# fully back to zero (no leaked reservation, no orphaned tuple).
|
|
await _wait_drained(wrapped)
|
|
finally:
|
|
await wrapped.shutdown(graceful=False)
|
|
|
|
|
|
async def test_compaction_bounds_physical_queue_and_keeps_join_working(monkeypatch):
|
|
_init({GROUP: 1})
|
|
|
|
async def acquire_always_fails(_group):
|
|
raise RuntimeError("manager down") # permanent fail-closed
|
|
|
|
monkeypatch.setattr(ss, "try_acquire_global_slot_tracked", acquire_always_fails)
|
|
|
|
async def func(value):
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2,
|
|
queue_name="compaction test",
|
|
concurrency_group=GROUP,
|
|
max_queue_size=0, # threshold = 64
|
|
cleanup_timeout=0.05,
|
|
)(func)
|
|
try:
|
|
zombies = [wrapped(f"dead{i}", _timeout=0.05) for i in range(80)]
|
|
results = await asyncio.gather(*zombies, return_exceptions=True)
|
|
assert all(isinstance(r, TimeoutError) for r in results)
|
|
|
|
stats = await wrapped.get_queue_stats()
|
|
assert stats["physical_queued"] == 80
|
|
assert stats["queued"] == 0
|
|
# All 80 timed-out tasks already returned their reservations and
|
|
# popped task_states in their finally blocks; only the physical
|
|
# tuples linger.
|
|
assert stats["in_flight"] == 0
|
|
|
|
await wrapped.run_maintenance() # triggers compaction
|
|
stats = await wrapped.get_queue_stats()
|
|
assert stats["physical_queued"] == 0
|
|
# Compaction must not perturb the logical accounting: live_queued
|
|
# and task_states stay at zero (it only drains dead physical tuples).
|
|
assert stats["queued"] == 0
|
|
assert stats["in_flight"] == 0
|
|
|
|
# task_done bookkeeping must be exact: graceful shutdown relies on
|
|
# queue.join() and must complete promptly (not hit the drain timeout).
|
|
await asyncio.wait_for(wrapped.shutdown(graceful=True, timeout=5.0), 10.0)
|
|
finally:
|
|
pass
|
|
|
|
|
|
async def test_compaction_keeps_live_tasks(monkeypatch):
|
|
_init({GROUP: 1})
|
|
external = await ss.try_acquire_global_slot(GROUP)
|
|
|
|
async def func(value):
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2,
|
|
queue_name="compaction live test",
|
|
concurrency_group=GROUP,
|
|
max_queue_size=0,
|
|
cleanup_timeout=0.05,
|
|
)(func)
|
|
try:
|
|
zombies = [wrapped(f"dead{i}", _timeout=0.05) for i in range(70)]
|
|
await asyncio.gather(*zombies, return_exceptions=True)
|
|
live = asyncio.create_task(wrapped("live"))
|
|
await asyncio.sleep(0.05)
|
|
|
|
await wrapped.run_maintenance()
|
|
stats = await wrapped.get_queue_stats()
|
|
assert stats["queued"] == 1 # live survived compaction
|
|
assert stats["physical_queued"] == 1
|
|
# live_queued invariant at a held steady state: the external slot
|
|
# blocks pickup, so the one live task is queued-not-started
|
|
# (running == 0) and is the only task_states entry (in_flight == 1).
|
|
# live_queued must therefore equal in_flight - running, i.e. exactly
|
|
# the count of not-yet-started tasks, with no phantom reservation.
|
|
assert stats["running"] == 0
|
|
assert stats["in_flight"] == 1
|
|
assert stats["queued"] == stats["in_flight"] - stats["running"]
|
|
|
|
await ss.release_global_slot(GROUP, external)
|
|
assert await asyncio.wait_for(live, timeout=5.0) == "live"
|
|
|
|
# And once it runs and drains, accounting returns fully to zero.
|
|
await _wait_drained(wrapped)
|
|
finally:
|
|
await wrapped.shutdown(graceful=False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stats publishing & aggregation through the wrapper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_aggregated_stats_schema_and_global_fields():
|
|
_init({GROUP: 2})
|
|
|
|
async def func(value):
|
|
if value == "fail":
|
|
raise ValueError("x")
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
3, queue_name="agg schema test", concurrency_group=GROUP
|
|
)(func)
|
|
try:
|
|
await wrapped("a")
|
|
await wrapped("b")
|
|
with pytest.raises(ValueError):
|
|
await wrapped("fail")
|
|
|
|
# The worker releases its lease in a finally block right after the
|
|
# future resolves; wait for the release before checking in_use.
|
|
async def _in_use_zero():
|
|
return await ss.global_concurrency_in_use(GROUP) == 0
|
|
|
|
deadline = asyncio.get_running_loop().time() + 2.0
|
|
while not await _in_use_zero():
|
|
assert asyncio.get_running_loop().time() < deadline
|
|
await asyncio.sleep(0.01)
|
|
|
|
stats = await wrapped.get_aggregated_queue_stats()
|
|
# Original flat schema intact (webui/health compatibility).
|
|
for field in (
|
|
"queue_name",
|
|
"max_async",
|
|
"max_queue_size",
|
|
"queued",
|
|
"running",
|
|
"in_flight",
|
|
"worker_count",
|
|
"initialized",
|
|
"submitted_total",
|
|
"completed_total",
|
|
"failed_total",
|
|
"cancelled_total",
|
|
"rejected_total",
|
|
):
|
|
assert field in stats
|
|
assert stats["completed_total"] == 2
|
|
assert stats["failed_total"] == 1
|
|
# Aggregation extras.
|
|
assert stats["reporting_workers"] == 1
|
|
assert list(stats["per_worker"])
|
|
assert stats["global_limit"] == 2
|
|
assert stats["global_in_use"] == 0
|
|
finally:
|
|
await wrapped.shutdown()
|
|
|
|
|
|
async def test_shutdown_clears_waiter_record():
|
|
_init({GROUP: 1})
|
|
await ss.try_acquire_global_slot(GROUP) # saturate so workers poll
|
|
|
|
async def func(value):
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2, queue_name="waiter cleanup test", concurrency_group=GROUP
|
|
)(func)
|
|
pending = asyncio.create_task(wrapped("stuck"))
|
|
try:
|
|
# Wait until a polling worker registers this process as a waiter.
|
|
deadline = asyncio.get_running_loop().time() + 5.0
|
|
while not await ss.global_slot_waiters(GROUP):
|
|
assert asyncio.get_running_loop().time() < deadline
|
|
await asyncio.sleep(0.01)
|
|
finally:
|
|
await wrapped.shutdown(graceful=False, timeout=0.1)
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await pending
|
|
# No-longer-polling process must not linger in the longest-waiter seat.
|
|
assert await ss.global_slot_waiters(GROUP) == []
|
|
|
|
|
|
async def test_aggregated_stats_report_slot_waiters():
|
|
_init({GROUP: 1})
|
|
external = await ss.try_acquire_global_slot(GROUP)
|
|
|
|
async def func(value):
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2, queue_name="waiter stats test", concurrency_group=GROUP
|
|
)(func)
|
|
task = asyncio.create_task(wrapped("waiting"))
|
|
try:
|
|
deadline = asyncio.get_running_loop().time() + 5.0
|
|
while not await ss.global_slot_waiters(GROUP):
|
|
assert asyncio.get_running_loop().time() < deadline
|
|
await asyncio.sleep(0.01)
|
|
|
|
stats = await wrapped.get_aggregated_queue_stats()
|
|
assert stats["global_waiting_workers"] == 1
|
|
assert stats["global_longest_wait"] >= 0.0
|
|
|
|
await ss.release_global_slot(GROUP, external)
|
|
assert await asyncio.wait_for(task, timeout=5.0) == "waiting"
|
|
finally:
|
|
await wrapped.shutdown()
|
|
|
|
|
|
async def test_maintenance_renews_held_leases(monkeypatch):
|
|
monkeypatch.setattr(ss, "_heartbeat_ttl", 0.2)
|
|
monkeypatch.setattr(ss, "_suspect_grace", 0.2)
|
|
_init({GROUP: 1})
|
|
release = asyncio.Event()
|
|
started = asyncio.Event()
|
|
|
|
async def long_func(value):
|
|
started.set()
|
|
await release.wait()
|
|
return value
|
|
|
|
wrapped = lr_utils.priority_limit_async_func_call(
|
|
2, queue_name="renew test", concurrency_group=GROUP
|
|
)(long_func)
|
|
try:
|
|
task = asyncio.create_task(wrapped("long"))
|
|
await started.wait()
|
|
|
|
# Run well past the heartbeat TTL, renewing like the health check.
|
|
for _ in range(5):
|
|
await asyncio.sleep(0.1)
|
|
await wrapped.run_maintenance()
|
|
|
|
# The legal long task's lease was never reclaimed.
|
|
assert await ss.global_concurrency_in_use(GROUP) == 1
|
|
release.set()
|
|
assert await task == "long"
|
|
finally:
|
|
await wrapped.shutdown()
|