2026-08-11 00:54:05 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 方向 F 黄金测试:轻量异步任务队列(矩阵 88 行剩余)
|
|
|
|
|
|
# 覆盖:提交/轮询/完成+结果、失败记录、取消(pending/running)、
|
|
|
|
|
|
# 取消终态返回 False、列表与统计
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import threading
|
|
|
|
|
|
import time
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
from server.agent_core.async_jobs import JobCancelled, JobQueue, JobQueueFullError
|
2026-08-11 00:54:05 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_submit_poll_done_with_result():
|
|
|
|
|
|
"""提交 → 轮询 → done,result/progress/时间戳完整,poll 可重复读。"""
|
|
|
|
|
|
q = JobQueue()
|
|
|
|
|
|
job_id = q.submit(
|
|
|
|
|
|
"sensitivity.recompute",
|
|
|
|
|
|
lambda rec: {"robustness": 0.92},
|
|
|
|
|
|
params={"strategy": "COMPREHENSIVE"},
|
|
|
|
|
|
actor="planner",
|
|
|
|
|
|
)
|
|
|
|
|
|
record = q.wait(job_id, timeout=10)
|
|
|
|
|
|
assert record["status"] == "done"
|
|
|
|
|
|
assert record["result"] == {"robustness": 0.92}
|
|
|
|
|
|
assert record["progress"] == 100
|
|
|
|
|
|
assert record["params"]["strategy"] == "COMPREHENSIVE"
|
|
|
|
|
|
assert record["actor"] == "planner"
|
|
|
|
|
|
assert record["started_at"] and record["finished_at"]
|
|
|
|
|
|
again = q.poll(job_id)
|
|
|
|
|
|
assert again is not None
|
|
|
|
|
|
assert again["status"] == "done"
|
|
|
|
|
|
assert again["result"]["robustness"] == 0.92
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_failed_job_records_error():
|
|
|
|
|
|
"""fn 抛异常 → failed,error 显式记录,result 为空。"""
|
|
|
|
|
|
q = JobQueue()
|
|
|
|
|
|
|
|
|
|
|
|
def boom(rec):
|
|
|
|
|
|
raise RuntimeError("求解器崩溃")
|
|
|
|
|
|
|
|
|
|
|
|
job_id = q.submit("montecarlo.recompute", boom)
|
|
|
|
|
|
record = q.wait(job_id, timeout=10)
|
|
|
|
|
|
assert record["status"] == "failed"
|
|
|
|
|
|
assert record["error"] == "求解器崩溃"
|
|
|
|
|
|
assert record["result"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cancel_running_job():
|
|
|
|
|
|
"""运行中任务可取消:置标记 → 终态 cancelled,结果丢弃。"""
|
|
|
|
|
|
q = JobQueue()
|
|
|
|
|
|
started = threading.Event()
|
|
|
|
|
|
|
|
|
|
|
|
def long_run(rec):
|
|
|
|
|
|
started.set()
|
|
|
|
|
|
while not rec.cancel_requested:
|
|
|
|
|
|
time.sleep(0.01)
|
|
|
|
|
|
raise JobCancelled()
|
|
|
|
|
|
|
|
|
|
|
|
job_id = q.submit("montecarlo.recompute", long_run)
|
|
|
|
|
|
assert started.wait(timeout=5), "任务未进入运行"
|
|
|
|
|
|
assert q.cancel(job_id) is True
|
|
|
|
|
|
record = q.wait(job_id, timeout=10)
|
|
|
|
|
|
assert record["status"] == "cancelled"
|
|
|
|
|
|
assert record["result"] is None
|
|
|
|
|
|
assert record["cancel_requested"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cancel_unknown_or_terminal_returns_false():
|
|
|
|
|
|
"""不存在或已终态任务取消返回 False。"""
|
|
|
|
|
|
q = JobQueue()
|
|
|
|
|
|
assert q.cancel("job-不存在") is False
|
|
|
|
|
|
job_id = q.submit("t", lambda rec: 1)
|
|
|
|
|
|
q.wait(job_id, timeout=10)
|
|
|
|
|
|
assert q.cancel(job_id) is False
|
|
|
|
|
|
assert q.poll(job_id)["status"] == "done"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_list_and_stats():
|
|
|
|
|
|
"""列表可按状态过滤,统计计数与终态一致。"""
|
|
|
|
|
|
q = JobQueue()
|
|
|
|
|
|
|
|
|
|
|
|
def boom(rec):
|
|
|
|
|
|
raise RuntimeError("x")
|
|
|
|
|
|
|
|
|
|
|
|
q.submit("a", lambda rec: 1)
|
|
|
|
|
|
q.submit("b", boom)
|
|
|
|
|
|
q.submit("c", lambda rec: 2)
|
|
|
|
|
|
for item in q.list():
|
|
|
|
|
|
q.wait(item["job_id"], timeout=10)
|
|
|
|
|
|
stats = q.stats()
|
|
|
|
|
|
assert stats["total"] == 3
|
|
|
|
|
|
assert stats["done"] == 2
|
|
|
|
|
|
assert stats["failed"] == 1
|
|
|
|
|
|
assert stats["pending"] == 0
|
|
|
|
|
|
failed = q.list(status="failed")
|
|
|
|
|
|
assert len(failed) == 1 and failed[0]["kind"] == "b"
|
2026-08-26 00:25:46 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_scope_isolation_applies_to_actor_poll_list_stats_and_cancel():
|
|
|
|
|
|
q = JobQueue()
|
|
|
|
|
|
release = threading.Event()
|
|
|
|
|
|
job_id = q.submit(
|
|
|
|
|
|
"t", lambda rec: release.wait(5),
|
|
|
|
|
|
tenant_uuid="t1", project_id="p1", actor="alice",
|
|
|
|
|
|
)
|
|
|
|
|
|
assert q.poll(job_id, tenant_uuid="t2", project_id="p1") is None
|
|
|
|
|
|
assert q.poll(job_id, tenant_uuid="t1", project_id="p2") is None
|
|
|
|
|
|
assert q.list(tenant_uuid="t2", project_id="p1") == []
|
|
|
|
|
|
assert q.stats(tenant_uuid="t2", project_id="p1")["total"] == 0
|
|
|
|
|
|
assert q.cancel(job_id, tenant_uuid="t2", project_id="p1") is False
|
|
|
|
|
|
assert q.poll(job_id, tenant_uuid="t1", project_id="p1", actor="bob") is None
|
|
|
|
|
|
assert q.list(tenant_uuid="t1", project_id="p1", actor="bob") == []
|
|
|
|
|
|
assert q.stats(tenant_uuid="t1", project_id="p1", actor="bob")["total"] == 0
|
|
|
|
|
|
assert q.cancel(job_id, tenant_uuid="t1", project_id="p1", actor="bob") is False
|
|
|
|
|
|
assert q.poll(job_id, tenant_uuid="t1", project_id="p1", actor="alice") is not None
|
|
|
|
|
|
release.set(); q.wait(job_id, timeout=10)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_queue_rejects_above_inflight_limit_before_creating_more_threads():
|
|
|
|
|
|
q = JobQueue(max_concurrent=1, max_inflight=2)
|
|
|
|
|
|
release = threading.Event()
|
|
|
|
|
|
ids = [q.submit("t", lambda rec: release.wait(5)) for _ in range(2)]
|
|
|
|
|
|
with pytest.raises(JobQueueFullError, match="上限"):
|
|
|
|
|
|
q.submit("overflow", lambda rec: None)
|
|
|
|
|
|
assert q.cancel(ids[1]) is True
|
|
|
|
|
|
release.set()
|
|
|
|
|
|
for job_id in ids:
|
|
|
|
|
|
q.wait(job_id, timeout=10)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cancelled_pending_worker_still_counts_until_thread_exits():
|
|
|
|
|
|
q = JobQueue(max_concurrent=1, max_inflight=2)
|
|
|
|
|
|
release = threading.Event()
|
|
|
|
|
|
first = q.submit("first", lambda rec: release.wait(5))
|
|
|
|
|
|
second = q.submit("second", lambda rec: None)
|
|
|
|
|
|
assert q.cancel(second) is True
|
|
|
|
|
|
with pytest.raises(JobQueueFullError):
|
|
|
|
|
|
q.submit("overflow", lambda rec: None)
|
|
|
|
|
|
release.set(); q.wait(first, timeout=10); q.wait(second, timeout=10)
|
|
|
|
|
|
third = q.submit("third", lambda rec: 3)
|
|
|
|
|
|
assert q.wait(third, timeout=10)["status"] == "done"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cancel_success_cannot_race_to_done_for_non_cooperative_job():
|
|
|
|
|
|
q = JobQueue()
|
|
|
|
|
|
started = threading.Event(); release = threading.Event()
|
|
|
|
|
|
def non_cooperative(rec):
|
|
|
|
|
|
started.set(); release.wait(5); return {"should": "drop"}
|
|
|
|
|
|
job_id = q.submit("race", non_cooperative)
|
|
|
|
|
|
assert started.wait(5)
|
|
|
|
|
|
assert q.cancel(job_id) is True
|
|
|
|
|
|
release.set()
|
|
|
|
|
|
record = q.wait(job_id, timeout=10)
|
|
|
|
|
|
assert record["status"] == "cancelled"
|
|
|
|
|
|
assert record["result"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cancel_success_cannot_be_overwritten_by_business_exception():
|
|
|
|
|
|
q = JobQueue()
|
|
|
|
|
|
started = threading.Event(); release = threading.Event()
|
|
|
|
|
|
def failing_after_cancel(rec):
|
|
|
|
|
|
started.set(); release.wait(5); raise RuntimeError("late failure")
|
|
|
|
|
|
job_id = q.submit("race-error", failing_after_cancel)
|
|
|
|
|
|
assert started.wait(5)
|
|
|
|
|
|
assert q.cancel(job_id) is True
|
|
|
|
|
|
release.set()
|
|
|
|
|
|
record = q.wait(job_id, timeout=10)
|
|
|
|
|
|
assert record["status"] == "cancelled"
|
|
|
|
|
|
assert record["error"] == "cancelled"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_thread_start_failure_rolls_back_job_and_inflight_quota(monkeypatch):
|
|
|
|
|
|
q = JobQueue(max_concurrent=1, max_inflight=1)
|
|
|
|
|
|
def fail_start(self):
|
|
|
|
|
|
raise RuntimeError("thread start failed")
|
|
|
|
|
|
monkeypatch.setattr(threading.Thread, "start", fail_start)
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="thread start failed"):
|
|
|
|
|
|
q.submit("start-fail", lambda rec: None)
|
|
|
|
|
|
assert q._inflight_threads == 0
|
|
|
|
|
|
assert q.list() == []
|