Files
Jellyfish/backend/tests/test_task_manager.py

239 lines
8.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
from typing import Any, AsyncIterator
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.db import Base
from app.core.task_manager import (
AsyncPollingDeliveryStrategy,
DeliveryMode,
InMemoryTaskStore,
SqlAlchemyTaskStore,
StreamingDeliveryStrategy,
TaskManager,
)
from app.core.task_manager.types import BaseTask, TaskStatus
from app.models.task_links import GenerationTaskLink, GenerationTaskLinkStatus
class DummyTask(BaseTask):
"""用于满足 TaskManager.create 的最小 Task 实现。"""
run_mode = "async_result"
def __init__(self) -> None:
self._result: Any = None
async def run(self, *args: Any, **kwargs: Any) -> AsyncIterator[Any] | None: # type: ignore[override]
self._result = {"args": args, "kwargs": kwargs}
return None
async def status(self) -> dict[str, Any]: # type: ignore[override]
return {}
async def is_done(self) -> bool: # type: ignore[override]
return self._result is not None
async def get_result(self) -> Any: # type: ignore[override]
return self._result
@pytest.mark.asyncio
async def test_inmemory_async_polling_strategy_updates_status_progress_and_result() -> None:
store = InMemoryTaskStore()
async def worker(task_record, task_store) -> None:
await task_store.set_progress(task_record.id, 10)
await task_store.set_progress(task_record.id, 120) # clamp to 100
await task_store.set_result(task_record.id, {"url": "x"})
await task_store.set_status(task_record.id, TaskStatus.succeeded)
tm = TaskManager(
store=store,
strategies={
DeliveryMode.async_polling: AsyncPollingDeliveryStrategy(store, worker),
},
)
task = await tm.create(task=DummyTask(), mode=DeliveryMode.async_polling, run_args={"a": 1})
assert task.status == TaskStatus.pending
assert task.progress == 0
await tm.start(task_id=task.id)
# 等待后台 task 跑完(AsyncPollingDeliveryStrategy 使用 asyncio.create_task)
for _ in range(50):
view = await tm.get_status(task_id=task.id)
if view.status in (TaskStatus.succeeded, TaskStatus.failed):
break
await asyncio.sleep(0.01)
view = await tm.get_status(task_id=task.id)
assert view.status == TaskStatus.succeeded
assert view.progress == 100
assert view.result == {"url": "x"}
assert view.error == ""
assert view.started_at_ts is not None
assert view.finished_at_ts is not None
assert view.elapsed_ms is not None
@pytest.mark.asyncio
async def test_inmemory_streaming_strategy_yields_chunks_and_marks_succeeded() -> None:
store = InMemoryTaskStore()
async def streaming_fn(payload: dict[str, Any]):
assert "run_args" in payload
yield {"chunk": 1}
yield {"chunk": 2}
tm = TaskManager(
store=store,
strategies={
DeliveryMode.streaming: StreamingDeliveryStrategy(store, streaming_fn),
},
)
task = await tm.create(task=DummyTask(), mode=DeliveryMode.streaming, run_args={"p": "x"})
it = await tm.start(task_id=task.id)
assert it is not None
chunks = []
async for c in it:
chunks.append(c)
assert chunks == [{"chunk": 1}, {"chunk": 2}]
view = await tm.get_status(task_id=task.id)
assert view.status == TaskStatus.succeeded
assert view.progress == 100
@pytest.mark.asyncio
async def test_sqlalchemy_store_create_and_get_status_view_sqlite_memory() -> None:
# 使用 sqlite in-memory,验证 SqlAlchemyTaskStore 的读写与轻量轮询查询
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
# 确保 generation_tasks 表在 metadata 中已注册
import app.models.task # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as db:
store = SqlAlchemyTaskStore(db)
tm = TaskManager(
store=store,
strategies={
DeliveryMode.async_polling: AsyncPollingDeliveryStrategy(store, lambda *_: asyncio.sleep(0)),
},
)
task = await tm.create(task=DummyTask(), mode=DeliveryMode.async_polling, run_args={"k": "v"})
view = await tm.get_status(task_id=task.id)
assert view.id == task.id
assert view.status == TaskStatus.pending
assert view.progress == 0
await store.set_status(task.id, TaskStatus.running)
await store.set_progress(task.id, 55)
await store.set_result(task.id, {"url": "db"})
await store.set_status(task.id, TaskStatus.succeeded)
view2 = await tm.get_status(task_id=task.id)
assert view2.status == TaskStatus.succeeded
assert view2.progress == 55
assert view2.result == {"url": "db"}
assert view2.started_at_ts is not None
assert view2.finished_at_ts is not None
assert view2.elapsed_ms is not None
await engine.dispose()
@pytest.mark.asyncio
async def test_sqlalchemy_store_list_task_views_relation_filter_deduplicates_tasks() -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
import app.models.task # noqa: F401
import app.models.task_links # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as db:
store = SqlAlchemyTaskStore(db)
first = await store.create(
payload={"run_args": {"chapter_id": "chapter-1"}},
mode=DeliveryMode.async_polling,
task_kind="script_extract",
)
second = await store.create(
payload={"run_args": {"chapter_id": "chapter-2"}},
mode=DeliveryMode.async_polling,
task_kind="script_extract",
)
await store.set_status(first.id, TaskStatus.running)
await store.set_status(second.id, TaskStatus.running)
db.add_all(
[
GenerationTaskLink(
task_id=first.id,
resource_type="text",
relation_type="chapter",
relation_entity_id="chapter-1",
status=GenerationTaskLinkStatus.todo,
),
GenerationTaskLink(
task_id=first.id,
resource_type="text",
relation_type="chapter",
relation_entity_id="chapter-1-draft",
status=GenerationTaskLinkStatus.todo,
),
GenerationTaskLink(
task_id=second.id,
resource_type="text",
relation_type="chapter",
relation_entity_id="chapter-2",
status=GenerationTaskLinkStatus.todo,
),
]
)
await db.flush()
items, total = await store.list_task_views(
relation_type="chapter",
page=1,
page_size=20,
)
assert total == 2
assert len(items) == 2
assert {item.id for item in items} == {first.id, second.id}
await engine.dispose()
@pytest.mark.asyncio
async def test_request_cancel_keeps_running_task_pending_cancellation() -> None:
store = InMemoryTaskStore()
tm = TaskManager(store=store, strategies={})
task = await tm.create(task=DummyTask(), mode=DeliveryMode.async_polling, run_args={"a": 1})
await store.set_status(task.id, TaskStatus.running)
rec = await tm.request_cancel(task_id=task.id, reason="用户取消")
assert rec.cancel_requested is True
assert rec.status == TaskStatus.running
view = await tm.get_status(task_id=task.id)
assert view.cancel_requested is True
assert view.status == TaskStatus.running