fix: 修复订阅筛选清空与任务进度竞态

This commit is contained in:
jxxghp
2026-08-27 12:02:04 +08:00
parent cd4e79ef57
commit 49d30e5fdf
5 changed files with 155 additions and 9 deletions
+44 -5
View File
@@ -340,16 +340,15 @@ async def test_sync_job_callback_and_finish_handles_are_owned(monkeypatch) -> No
scheduler = _scheduler("callback-handles", job)
await asyncio.to_thread(scheduler.start, "callback-handles")
await asyncio.wait_for(
asyncio.gather(update_started.wait(), finish_started.wait()),
timeout=1,
)
await asyncio.wait_for(update_started.wait(), timeout=1)
await asyncio.sleep(0)
assert len(scheduler._handles) == 2
assert not finish_started.is_set()
await scheduler.stop_async()
assert cancelled == 2
assert cancelled == 1
assert scheduler._handles == {}
@@ -387,6 +386,46 @@ async def test_stale_progress_cannot_update_replaced_job(monkeypatch) -> None:
assert scheduler._handles == {}
@pytest.mark.anyio
async def test_final_progress_waits_for_pending_update(monkeypatch) -> None:
"""任务终态必须等待已提交的进度回调,避免 running 快照迟到覆盖。"""
update_started = asyncio.Event()
allow_update = asyncio.Event()
finished = asyncio.Event()
writes = []
class BlockingProgress(_AsyncProgressStub):
"""把中间进度写停在终态收尾之前。"""
async def update(self, **_kwargs) -> None:
update_started.set()
await allow_update.wait()
writes.append("update")
async def end(self, **_kwargs) -> None:
writes.append("end")
finished.set()
async def job(progress_callback) -> None:
progress_callback(value=100, text="业务处理完成")
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", BlockingProgress)
scheduler = _scheduler("progress-order", job)
assert scheduler.start("progress-order") is True
await asyncio.wait_for(update_started.wait(), timeout=1)
await asyncio.sleep(0)
assert writes == []
assert not finished.is_set()
allow_update.set()
await asyncio.wait_for(finished.wait(), timeout=1)
assert writes == ["update", "end"]
@pytest.mark.anyio
async def test_replaced_job_keeps_active_state_without_stale_progress(monkeypatch) -> None:
"""同 ID 新 generation 显示真实运行态,但不继承旧任务进度详情。"""
+7 -1
View File
@@ -136,7 +136,13 @@ def test_scheduler_runs_async_job_from_current_event_loop(monkeypatch):
async def run_task():
"""从已运行的事件循环启动定时服务。"""
scheduler.start(job_id)
await asyncio.sleep(0)
async def wait_until_finished() -> None:
"""等待任务及其异步进度句柄全部收敛。"""
while scheduler._handles or scheduler._active_job_generations:
await asyncio.sleep(0)
await asyncio.wait_for(wait_until_finished(), timeout=1)
scheduler = _build_scheduler(job_id, task)
target_loop = asyncio.new_event_loop()
+66
View File
@@ -0,0 +1,66 @@
"""订阅更新显式清空筛选条件的回归测试。"""
from types import SimpleNamespace
import pytest
from app.api.endpoints.subscribe import update_subscribe
from app.schemas.subscribe import Subscribe
class _SubscribeRow:
"""提供更新端点所需字段的最小订阅替身。"""
def __init__(self) -> None:
self.id = 1
self.username = "alice"
self.type = "电影"
self.resolution = "4K"
self.total_episode = 0
self.lack_episode = 0
def to_dict(self) -> dict:
"""返回当前订阅快照。"""
return dict(self.__dict__)
class _MutationService:
"""记录端点交给订阅写服务的更新 payload。"""
def __init__(self, subscribe: _SubscribeRow) -> None:
self.subscribe = subscribe
self.payload = None
async def get_accessible(self, _subscribe_id: int, _actor) -> _SubscribeRow:
"""返回当前用户可访问的订阅。"""
return self.subscribe
async def update(self, _subscribe_id: int, payload: dict, _actor, **_kwargs):
"""应用更新并返回已发布事件的变更结果。"""
old = self.subscribe.to_dict()
self.payload = dict(payload)
self.subscribe.__dict__.update(payload)
return SimpleNamespace(
old=old,
new=self.subscribe.to_dict(),
event_published=True,
)
@pytest.mark.anyio
async def test_update_subscribe_clears_explicit_empty_resolution() -> None:
"""从 4K 切换到全部时,空字符串必须作为显式 None 写入而非被忽略。"""
subscribe = _SubscribeRow()
mutation = _MutationService(subscribe)
subscribe_in = Subscribe(id=1, resolution="")
response = await update_subscribe(
subscribe_in=subscribe_in,
mutation=mutation,
current_user=SimpleNamespace(name="alice", is_superuser=False),
)
assert response.success is True
assert "resolution" in subscribe_in.model_fields_set
assert mutation.payload["resolution"] is None
assert subscribe.resolution is None