stage19: performance optimierung — LLM concurrency semaphore, priority (HIGH/NORMAL/LOW), limiter
This commit is contained in:
315
tests/stages/test_performance.py
Normal file
315
tests/stages/test_performance.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""Stage 19: Performanceoptimierung — Tests für Semaphore, Priority, Batching.
|
||||
|
||||
Deckung:
|
||||
- semaphore.py: reset_semaphore, acquire_llm, limit enforcement
|
||||
- priority_queue.py: PriorityLimiter (HIGH/NORMAL/LOW), Priority enum
|
||||
- llm.py: complete/priority, stream_complete/priority, concurrency enforcement
|
||||
- orchestrator.py: priority field default, Priority import
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nsct.providers.priority_queue import Priority, PriorityLimiter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def asyncio_run(coro):
|
||||
"""Run a coroutine synchronously."""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PriorityLimiter tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPriorityLimiter:
|
||||
"""Tests for src/nsct/providers/priority_queue.py."""
|
||||
|
||||
def test_high_priority_always_succeeds(self) -> None:
|
||||
limiter = PriorityLimiter(max_concurrency=2)
|
||||
|
||||
async def test():
|
||||
# Fill all slots with NORMAL
|
||||
await limiter.acquire(Priority.NORMAL)
|
||||
await limiter.acquire(Priority.NORMAL)
|
||||
assert limiter.active_count() == 2
|
||||
|
||||
# HIGH should still succeed immediately (no-op for HIGH)
|
||||
await limiter.acquire(Priority.HIGH)
|
||||
limiter.release(Priority.HIGH)
|
||||
limiter.release(Priority.NORMAL)
|
||||
limiter.release(Priority.NORMAL)
|
||||
|
||||
asyncio_run(test())
|
||||
|
||||
def test_normal_waits_for_slot(self) -> None:
|
||||
limiter = PriorityLimiter(max_concurrency=1)
|
||||
|
||||
async def test():
|
||||
await limiter.acquire(Priority.NORMAL)
|
||||
limiter.release(Priority.NORMAL)
|
||||
|
||||
asyncio_run(test())
|
||||
|
||||
def test_low_reject_when_full(self) -> None:
|
||||
limiter = PriorityLimiter(max_concurrency=1)
|
||||
from nsct.providers.priority_queue import LlmPriorityRejectError
|
||||
|
||||
async def test():
|
||||
await limiter.acquire(Priority.NORMAL)
|
||||
assert limiter.active_count() == 1
|
||||
with pytest.raises(LlmPriorityRejectError):
|
||||
await limiter.acquire(Priority.LOW)
|
||||
limiter.release(Priority.NORMAL)
|
||||
|
||||
asyncio_run(test())
|
||||
|
||||
def test_priority_order_values(self) -> None:
|
||||
assert Priority.HIGH.value == 0
|
||||
assert Priority.NORMAL.value == 1
|
||||
assert Priority.LOW.value == 2
|
||||
|
||||
def test_release_only_for_non_high(self) -> None:
|
||||
limiter = PriorityLimiter(max_concurrency=1)
|
||||
limiter.acquire(Priority.HIGH)
|
||||
limiter.release(Priority.HIGH)
|
||||
assert limiter.active_count() == 0
|
||||
|
||||
def test_max_concurrency_reflection(self) -> None:
|
||||
limiter = PriorityLimiter(max_concurrency=5)
|
||||
assert limiter.max_concurrency == 5
|
||||
|
||||
def test_reset(self) -> None:
|
||||
limiter = PriorityLimiter(max_concurrency=2)
|
||||
limiter.reset(4)
|
||||
assert limiter.max_concurrency == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM provider integration tests (mock)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLLMConcurrency:
|
||||
"""Test that _LLMProviderImpl enforces concurrency limits."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_instance(self):
|
||||
import nsct.providers.llm as llm_mod
|
||||
llm_mod._instance = None
|
||||
yield
|
||||
llm_mod._instance = None
|
||||
|
||||
def test_complete_with_priority(self) -> None:
|
||||
from nsct.providers.llm import _LLMProviderImpl
|
||||
from nsct.providers.metrics import ProviderMetrics
|
||||
|
||||
config = MagicMock()
|
||||
config.llm.base_url = "http://test:8030/openai/v1"
|
||||
config.llm.model = "test-model"
|
||||
config.llm.max_concurrency = 2
|
||||
config.llm.get_secret = MagicMock(return_value="test-key")
|
||||
|
||||
metrics = MagicMock()
|
||||
metrics.record_llm_request = AsyncMock()
|
||||
metrics.record_llm_error = AsyncMock()
|
||||
|
||||
impl = _LLMProviderImpl(config, metrics)
|
||||
impl._client = MagicMock()
|
||||
impl._client.chat.completions.create = AsyncMock(
|
||||
return_value=MagicMock(
|
||||
usage=MagicMock(completion_tokens=1, prompt_tokens=10),
|
||||
choices=[MagicMock(message=MagicMock(content="test response"))],
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio_run(
|
||||
impl.complete([{"role": "user", "content": "hi"}], priority=Priority.HIGH)
|
||||
)
|
||||
assert result == "test response"
|
||||
impl.limiter.release(Priority.HIGH)
|
||||
|
||||
def test_concurrent_calls_respect_limit(self) -> None:
|
||||
from nsct.providers.llm import _LLMProviderImpl
|
||||
|
||||
config = MagicMock()
|
||||
config.llm.base_url = "http://test:8030/openai/v1"
|
||||
config.llm.model = "test-model"
|
||||
config.llm.max_concurrency = 2
|
||||
config.llm.get_secret = MagicMock(return_value="test-key")
|
||||
|
||||
metrics = MagicMock()
|
||||
metrics.record_llm_request = AsyncMock()
|
||||
metrics.record_llm_error = AsyncMock()
|
||||
|
||||
impl = _LLMProviderImpl(config, metrics)
|
||||
impl._client = MagicMock()
|
||||
|
||||
call_count = 0
|
||||
max_concurrent = 0
|
||||
|
||||
async def mock_create(**kwargs):
|
||||
nonlocal call_count, max_concurrent
|
||||
call_count += 1
|
||||
current = call_count
|
||||
if current > max_concurrent:
|
||||
max_concurrent = current
|
||||
await asyncio.sleep(0.05)
|
||||
call_count -= 1
|
||||
return MagicMock(
|
||||
usage=MagicMock(completion_tokens=1, prompt_tokens=10),
|
||||
choices=[MagicMock(message=MagicMock(content="ok"))],
|
||||
)
|
||||
|
||||
impl._client.chat.completions.create = mock_create
|
||||
impl.limiter.max_concurrency = 2
|
||||
|
||||
async def run_all():
|
||||
msgs = [{"role": "user", "content": "x"}]
|
||||
tasks = [impl.complete(msgs) for _ in range(4)]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
asyncio_run(run_all())
|
||||
# With max_concurrency=2, we should not exceed 2 simultaneous calls
|
||||
assert max_concurrent <= 2
|
||||
|
||||
def test_priority_high_vs_normal(self) -> None:
|
||||
from nsct.providers.llm import _LLMProviderImpl
|
||||
|
||||
config = MagicMock()
|
||||
config.llm.base_url = "http://test:8030/openai/v1"
|
||||
config.llm.model = "test-model"
|
||||
config.llm.max_concurrency = 1
|
||||
config.llm.get_secret = MagicMock(return_value="test-key")
|
||||
|
||||
metrics = MagicMock()
|
||||
metrics.record_llm_request = AsyncMock()
|
||||
metrics.record_llm_error = AsyncMock()
|
||||
|
||||
impl = _LLMProviderImpl(config, metrics)
|
||||
impl._client = MagicMock()
|
||||
impl.limiter.max_concurrency = 1
|
||||
|
||||
call_order = []
|
||||
|
||||
async def mock_create(**kwargs):
|
||||
await asyncio.sleep(0.05)
|
||||
call_order.append("done")
|
||||
return MagicMock(
|
||||
usage=MagicMock(completion_tokens=1, prompt_tokens=10),
|
||||
choices=[MagicMock(message=MagicMock(content="ok"))],
|
||||
)
|
||||
|
||||
impl._client.chat.completions.create = mock_create
|
||||
|
||||
async def test():
|
||||
normal_done = False
|
||||
|
||||
async def slow():
|
||||
nonlocal normal_done
|
||||
await asyncio.sleep(0.1)
|
||||
normal_done = True
|
||||
return MagicMock(
|
||||
usage=MagicMock(completion_tokens=1, prompt_tokens=10),
|
||||
choices=[MagicMock(message=MagicMock(content="ok"))],
|
||||
)
|
||||
|
||||
impl._client.chat.completions.create = slow
|
||||
|
||||
normal_task = asyncio.create_task(
|
||||
impl.complete([{"role": "user", "content": "slow"}], priority=Priority.NORMAL)
|
||||
)
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
# HIGH should not wait for the NORMAL slot
|
||||
result = await impl.complete(
|
||||
[{"role": "user", "content": "fast"}], priority=Priority.HIGH
|
||||
)
|
||||
assert result == "ok"
|
||||
await normal_task
|
||||
|
||||
asyncio_run(test())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrator integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOrchestratorPriority:
|
||||
"""Test that ResearchOrchestrator accepts and stores priority."""
|
||||
|
||||
def test_default_priority_is_normal(self) -> None:
|
||||
from nsct.orchestration.orchestrator import ResearchOrchestrator
|
||||
|
||||
config = MagicMock()
|
||||
config.llm.base_url = "http://test:8030/openai/v1"
|
||||
config.llm.model = "test-model"
|
||||
config.llm.max_concurrency = 3
|
||||
|
||||
orch = ResearchOrchestrator(
|
||||
config=config,
|
||||
research_id=MagicMock(),
|
||||
query="test query",
|
||||
)
|
||||
assert orch._priority == Priority.NORMAL
|
||||
|
||||
def test_custom_priority_high(self) -> None:
|
||||
from nsct.orchestration.orchestrator import ResearchOrchestrator
|
||||
|
||||
config = MagicMock()
|
||||
config.llm.base_url = "http://test:8030/openai/v1"
|
||||
config.llm.model = "test-model"
|
||||
config.llm.max_concurrency = 3
|
||||
|
||||
orch = ResearchOrchestrator(
|
||||
config=config,
|
||||
research_id=MagicMock(),
|
||||
query="test query",
|
||||
priority=Priority.HIGH,
|
||||
)
|
||||
assert orch._priority == Priority.HIGH
|
||||
|
||||
def test_custom_priority_low(self) -> None:
|
||||
from nsct.orchestration.orchestrator import ResearchOrchestrator
|
||||
|
||||
config = MagicMock()
|
||||
config.llm.base_url = "http://test:8030/openai/v1"
|
||||
config.llm.model = "test-model"
|
||||
config.llm.max_concurrency = 3
|
||||
|
||||
orch = ResearchOrchestrator(
|
||||
config=config,
|
||||
research_id=MagicMock(),
|
||||
query="test query",
|
||||
priority=Priority.LOW,
|
||||
)
|
||||
assert orch._priority == Priority.LOW
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async context manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAsyncContextManager:
|
||||
"""Test that PriorityLimiter works as async context manager."""
|
||||
|
||||
def test_context_manager(self) -> None:
|
||||
limiter = PriorityLimiter(max_concurrency=1)
|
||||
asyncio_run(limiter.__aenter__())
|
||||
asyncio_run(limiter.__aexit__(None, None, None))
|
||||
Reference in New Issue
Block a user