stage19: performance optimierung — LLM concurrency semaphore, priority (HIGH/NORMAL/LOW), limiter

This commit is contained in:
NSCT Agent
2026-08-28 17:28:31 +00:00
parent ce9b031ea0
commit 4335a40d68
7 changed files with 553 additions and 21 deletions

View 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))