feat: bound news AI concurrency with immediate task refill
This commit is contained in:
1 parent
f1d060d475
commit
cbd2618a20
15 files changed
+603
-129
No files matched your search
@@ -0,0 +1,130 @@
|
||||
"""Concurrency behavior uses controlled tasks and isolated I/O boundaries."""
|
||||
import asyncio
|
||||
from types import SimpleNamespace as N
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
def fake_session(settings):
|
||||
session = MagicMock()
|
||||
session.__aenter__ = AsyncMock(return_value=session)
|
||||
session.__aexit__ = AsyncMock()
|
||||
session.get = AsyncMock(return_value=settings)
|
||||
session.scalars = AsyncMock(return_value=N(all=lambda: []))
|
||||
session.commit = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
def test_dispatch_refills_without_waiting_for_slow_tasks_and_respects_live_limit(trading, monkeypatch):
|
||||
from alphapulse.news_risk import scheduler
|
||||
async def check():
|
||||
settings = N(enabled=True, max_concurrency=5)
|
||||
session = fake_session(settings)
|
||||
monkeypatch.setattr(scheduler, 'SessionLocal', lambda: session)
|
||||
monkeypatch.setattr(scheduler.redis, 'set', AsyncMock())
|
||||
lock = N(scalar=AsyncMock(return_value=True), execute=AsyncMock(), commit=AsyncMock())
|
||||
releases = [asyncio.Event() for _ in range(10)]
|
||||
started = []
|
||||
async def task(index):
|
||||
started.append(index)
|
||||
await releases[index].wait()
|
||||
async def pick(active, cursor, allow):
|
||||
assert len(active) < settings.max_concurrency
|
||||
index = len(started)
|
||||
# Reserve before the spawned coroutine starts.
|
||||
index = max([key[1] for key in active] + [index-1]) + 1
|
||||
if index >= len(releases): return None
|
||||
return (('grouping', 'analysis', 'events', 'outlook')[index % 4], index), lambda: task(index), cursor
|
||||
monkeypatch.setattr(scheduler, 'candidate', pick)
|
||||
run = asyncio.create_task(scheduler.run(lock))
|
||||
async def until(predicate):
|
||||
async with asyncio.timeout(3):
|
||||
while not predicate(): await asyncio.sleep(.001)
|
||||
try:
|
||||
await until(lambda: len(started) == 5)
|
||||
releases[0].set(); releases[1].set()
|
||||
await until(lambda: len(started) == 7)
|
||||
assert all(not releases[i].is_set() for i in (2,3,4))
|
||||
settings.max_concurrency = 2
|
||||
for i in (2,3,4,5): releases[i].set()
|
||||
await until(lambda: len(started) == 8)
|
||||
assert not releases[6].is_set()
|
||||
assert len(started) == 8
|
||||
settings.enabled = False
|
||||
releases[6].set(); releases[7].set()
|
||||
await asyncio.sleep(.03)
|
||||
assert len(started) == 8
|
||||
settings.enabled = True; settings.max_concurrency = 5
|
||||
await until(lambda: len(started) == 10)
|
||||
finally:
|
||||
run.cancel()
|
||||
with pytest.raises(asyncio.CancelledError): await run
|
||||
assert lock.execute.await_count >= 1
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_failed_tasks_release_capacity(trading):
|
||||
from alphapulse.news_risk.scheduler import Pool
|
||||
async def check():
|
||||
pool = Pool()
|
||||
async def fail(): raise RuntimeError('Synthetic failure')
|
||||
pool.start(('analysis', 1), fail)
|
||||
await pool.reap()
|
||||
assert not pool.tasks
|
||||
await pool.close()
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', [0, -1, 1.5, '5', True, None])
|
||||
def test_concurrency_requires_a_positive_integer(trading, value):
|
||||
from pydantic import ValidationError
|
||||
from alphapulse.api.risk import ConcurrencyIn
|
||||
with pytest.raises(ValidationError): ConcurrencyIn(max_concurrency=value)
|
||||
|
||||
|
||||
def test_operational_update_does_not_pause_or_change_model_settings(trading, monkeypatch):
|
||||
from alphapulse.api import risk
|
||||
settings = N(enabled=True, max_concurrency=5, model='original')
|
||||
monkeypatch.setattr(risk, 'default_settings', AsyncMock(return_value=settings))
|
||||
asyncio.run(risk.save_concurrency(risk.ConcurrencyIn(max_concurrency=3), N(commit=AsyncMock(), add=MagicMock())))
|
||||
assert settings.enabled and settings.model == 'original' and settings.max_concurrency == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize('busy,enabled', [(True,False), (False,True)])
|
||||
def test_validation_never_bypasses_background_tasks(trading, monkeypatch, busy, enabled):
|
||||
from fastapi import HTTPException
|
||||
from alphapulse.api import risk
|
||||
from alphapulse.core import db
|
||||
gate = MagicMock()
|
||||
gate.__aenter__ = AsyncMock(return_value=gate); gate.__aexit__ = AsyncMock(return_value=None)
|
||||
gate.scalar = AsyncMock(return_value=not busy)
|
||||
gate.execute = AsyncMock(); gate.commit = AsyncMock()
|
||||
monkeypatch.setattr(db, 'engine', N(connect=lambda: gate))
|
||||
validate = AsyncMock()
|
||||
monkeypatch.setattr(risk, 'validate_enable', validate)
|
||||
session = N(get=AsyncMock(return_value=N(enabled=enabled)))
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(risk.enable(risk.SettingsIn(),session))
|
||||
assert error.value.status_code == 409
|
||||
validate.assert_not_awaited()
|
||||
|
||||
|
||||
def test_history_completion_is_counted_independently_of_item_order(trading, monkeypatch):
|
||||
from alphapulse.news_risk import reprocess, worker
|
||||
from alphapulse.models.risk import RiskSettings, ArticleAnalysis, ReprocessJob
|
||||
from alphapulse.models import NewsItem
|
||||
job = N(id=4, state='running', position=0, settings={}, failures=[],
|
||||
items=[{'id':i,'hash':'current','state':'pending'} for i in (1,2,3)])
|
||||
article = N(id=30, state='done', error=None)
|
||||
session = fake_session(None)
|
||||
session.scalar = AsyncMock(side_effect=[job, article])
|
||||
session.get = AsyncMock(side_effect=lambda model,*a,**kw: {
|
||||
RiskSettings:N(enabled=True), ReprocessJob:job,
|
||||
ArticleAnalysis:article, NewsItem:N(id=3, content_hash='current')}[model])
|
||||
monkeypatch.setattr(reprocess, 'SessionLocal', lambda:session)
|
||||
monkeypatch.setattr(reprocess, 'frozen_configuration', AsyncMock())
|
||||
monkeypatch.setattr(worker, 'analyze', AsyncMock(return_value='done'))
|
||||
asyncio.run(reprocess.process_job(2))
|
||||
assert job.position == 1
|
||||
assert [i['state'] for i in job.items] == ['pending','pending','done']
|
||||
assert job.failures == []
|
||||
Reference in new issue
Block a user