feat(core): 定时任务薄封装 scheduler / 跨插件任务槽 task_slots / 出站媒体显式发送方式
- scheduler.py:apscheduler 薄封装(统一 Asia/Shanghai、misfire_grace_time、起止/异常日志) - task_slots.py:长任务任务槽(进度上报 / 同槽去重 / SSE 推送 / 取消), video_analysis temp 清理等长任务接入 - outbound_media:新增 HEXI_MEDIA_MODE 显式发送方式(http/base64/file,/hub 可改), 修复 alconna 对 base64 载荷补 https:// 前缀导致的 fetch failed(repair_mangled_url) - learning_chat 定时任务迁入 hexi.core.scheduler - 测试:新增 test_scheduler / test_task_slots,扩充 test_outbound_media Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
a789a44f5d
commit
0ac1958932
8 files changed
+1353
-12
No files matched your search
@@ -0,0 +1,347 @@
|
||||
"""任务槽(hexi/core/task_slots.py)单元测试
|
||||
|
||||
只测注册表本身:执行/去重/进度/取消/推送/回收。模块只依赖 asyncio + nonebot.log,
|
||||
按路径裸加载即可(与 test_rate_limit 同法)。
|
||||
|
||||
任务体跑在线程池里,进度回调是"切回事件循环再改状态",所以断言进度时必须等
|
||||
状态落地 —— 用 `_until()` 轮询事件循环,别用 `sleep` 猜时序。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_MODULE_PATH = Path(__file__).resolve().parents[1] / "hexi" / "core" / "task_slots.py"
|
||||
_MOD_NAME = "hexi_core_task_slots_under_test"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts():
|
||||
spec = importlib.util.spec_from_file_location(_MOD_NAME, _MODULE_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[_MOD_NAME] = module
|
||||
spec.loader.exec_module(module)
|
||||
yield module
|
||||
sys.modules.pop(_MOD_NAME, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reg(ts):
|
||||
"""每条用例一个干净的注册表(不用全局单例,避免用例互相污染)。"""
|
||||
return ts.TaskSlots()
|
||||
|
||||
|
||||
async def _until(pred, timeout: float = 2.0) -> None:
|
||||
"""轮询事件循环直到条件成立(进度是跨线程/跨回调落地的,不能靠猜时序)。"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if pred():
|
||||
return
|
||||
await asyncio.sleep(0.01)
|
||||
raise AssertionError("等待条件超时")
|
||||
|
||||
|
||||
# ───────────────────────── 执行与结果 ─────────────────────────
|
||||
|
||||
|
||||
async def test_runs_in_thread_and_records_result(reg):
|
||||
def work(report):
|
||||
return "done!"
|
||||
|
||||
task, reused = reg.submit(slot="plug:job", title="干活", func=work, plugin_id="plug")
|
||||
assert reused is False
|
||||
assert task.active # submit 即返回,此时后台任务还没被调度
|
||||
|
||||
await asyncio.wait_for(reg.wait(task), 2)
|
||||
assert task.status == "done"
|
||||
assert task.result == "done!"
|
||||
assert task.started_at > 0
|
||||
assert task.finished_at >= task.started_at
|
||||
assert task.duration >= 0
|
||||
assert reg.active("plug:job") is None
|
||||
|
||||
|
||||
async def test_exception_marks_failed_without_raising(reg):
|
||||
def boom(report):
|
||||
raise RuntimeError("炸了")
|
||||
|
||||
task, _ = reg.submit(slot="plug:boom", title="会炸", func=boom)
|
||||
await asyncio.wait_for(reg.wait(task), 2)
|
||||
assert task.status == "failed"
|
||||
assert "炸了" in task.error
|
||||
assert task.percent == 100 # 终态一律显示满(前端别再转圈)
|
||||
|
||||
|
||||
# ───────────────────────── 同槽去重 ─────────────────────────
|
||||
|
||||
|
||||
async def test_same_slot_reuses_running_task(reg):
|
||||
calls = []
|
||||
gate = threading.Event()
|
||||
|
||||
def slow(report):
|
||||
calls.append(1)
|
||||
gate.wait(2)
|
||||
return "ok"
|
||||
|
||||
first, reused = reg.submit(slot="plug:clean", title="清理", func=slow)
|
||||
second, reused2 = reg.submit(slot="plug:clean", title="清理(再点一次)", func=slow)
|
||||
|
||||
assert reused is False and reused2 is True
|
||||
assert second.id == first.id # 返回的是正在跑的那个
|
||||
gate.set()
|
||||
await asyncio.wait_for(reg.wait(first), 2)
|
||||
assert calls == [1] # 任务体只跑了一次
|
||||
|
||||
|
||||
async def test_same_slot_available_again_after_finish(reg):
|
||||
def work(report):
|
||||
return 1
|
||||
|
||||
first, _ = reg.submit(slot="plug:clean", title="第一次", func=work)
|
||||
await asyncio.wait_for(reg.wait(first), 2)
|
||||
|
||||
second, reused = reg.submit(slot="plug:clean", title="第二次", func=work)
|
||||
assert reused is False and second.id != first.id
|
||||
await asyncio.wait_for(reg.wait(second), 2)
|
||||
|
||||
|
||||
async def test_different_slots_run_in_parallel(reg):
|
||||
gate = threading.Event()
|
||||
|
||||
def slow(report):
|
||||
gate.wait(2)
|
||||
|
||||
a, _ = reg.submit(slot="plug:a", title="A", func=slow)
|
||||
b, reused = reg.submit(slot="plug:b", title="B", func=slow)
|
||||
assert reused is False and b.id != a.id
|
||||
gate.set()
|
||||
await asyncio.gather(reg.wait(a), reg.wait(b))
|
||||
|
||||
|
||||
# ───────────────────────── 进度 ─────────────────────────
|
||||
|
||||
|
||||
async def test_progress_fields_and_percent(reg):
|
||||
gate = threading.Event()
|
||||
|
||||
def work(report):
|
||||
report(done=3, total=12, phase="清理中", unit="个文件", message="已删除 2 个")
|
||||
gate.wait(2)
|
||||
return "ok"
|
||||
|
||||
task, _ = reg.submit(slot="plug:job", title="进度", func=work)
|
||||
await _until(lambda: task.done == 3)
|
||||
|
||||
assert (task.total, task.unit, task.phase) == (12, "个文件", "清理中")
|
||||
assert task.message == "已删除 2 个"
|
||||
assert task.percent == 25
|
||||
|
||||
payload = task.to_dict()["progress"]
|
||||
assert payload == {
|
||||
"percent": 25, "done": 3, "total": 12, "unit": "个文件",
|
||||
"phase": "清理中", "message": "已删除 2 个",
|
||||
}
|
||||
|
||||
gate.set()
|
||||
await asyncio.wait_for(reg.wait(task), 2)
|
||||
|
||||
|
||||
async def test_unknown_total_reports_none_percent(reg):
|
||||
"""扫描阶段总数未知 → percent None(前端显示不确定进度而不是 0%)。"""
|
||||
gate = threading.Event()
|
||||
|
||||
def work(report):
|
||||
report(phase="扫描中", unit="个文件", message="正在统计文件…")
|
||||
gate.wait(2)
|
||||
|
||||
task, _ = reg.submit(slot="plug:job", title="扫描", func=work)
|
||||
await _until(lambda: task.phase == "扫描中")
|
||||
assert task.percent is None
|
||||
gate.set()
|
||||
await asyncio.wait_for(reg.wait(task), 2)
|
||||
|
||||
|
||||
async def test_warnings_accumulate(reg):
|
||||
def work(report):
|
||||
report(warning="跳过 a.mp4(被占用)")
|
||||
report(warning="跳过 b.mp4(被占用)")
|
||||
return (0, 2)
|
||||
|
||||
task, _ = reg.submit(slot="plug:job", title="部分失败", func=work)
|
||||
await asyncio.wait_for(reg.wait(task), 2)
|
||||
assert task.to_dict()["warnings"] == ["跳过 a.mp4(被占用)", "跳过 b.mp4(被占用)"]
|
||||
|
||||
|
||||
async def test_progress_is_throttled(reg):
|
||||
"""上千次上报不能变成上千次推送(按百分比桶 + 只在有意义的字段变化时发)。"""
|
||||
queue = reg.subscribe()
|
||||
count = 500
|
||||
|
||||
def work(report):
|
||||
for i in range(1, count + 1):
|
||||
report(done=i, total=count)
|
||||
return "ok"
|
||||
|
||||
task, _ = reg.submit(slot="plug:job", title="节流", func=work)
|
||||
await asyncio.wait_for(reg.wait(task), 2)
|
||||
|
||||
frames = 0
|
||||
while not queue.empty():
|
||||
queue.get_nowait()
|
||||
frames += 1
|
||||
# 理论上限:每个百分点一帧 + 起止几帧;远小于 500 次上报
|
||||
assert frames <= 110, f"推送 {frames} 帧,节流没生效"
|
||||
assert frames >= 2 # 但也不能一帧都不发
|
||||
reg.unsubscribe(queue)
|
||||
|
||||
|
||||
# ───────────────────────── 协作式取消 ─────────────────────────
|
||||
|
||||
|
||||
async def test_cancel_stops_cooperative_task(reg):
|
||||
def work(report):
|
||||
for i in range(1, 1000):
|
||||
if not report(done=i, total=999):
|
||||
return ("canceled", i) # 任务体自己收尾,带上已有进度
|
||||
time.sleep(0.005)
|
||||
return ("finished", 999)
|
||||
|
||||
task, _ = reg.submit(slot="plug:job", title="可取消", func=work)
|
||||
await _until(lambda: task.done >= 1)
|
||||
|
||||
assert reg.cancel(task.id) is True
|
||||
assert task.cancel_requested is True
|
||||
|
||||
await asyncio.wait_for(reg.wait(task), 2)
|
||||
assert task.status == "canceled"
|
||||
assert task.result[0] == "canceled"
|
||||
# 取消不是硬杀:任务体返回前的进度照常保留
|
||||
assert task.done < 999
|
||||
|
||||
|
||||
async def test_cancel_finished_or_unknown_task_is_noop(reg):
|
||||
def work(report):
|
||||
return 1
|
||||
|
||||
task, _ = reg.submit(slot="plug:job", title="快活", func=work)
|
||||
await asyncio.wait_for(reg.wait(task), 2)
|
||||
assert reg.cancel(task.id) is False
|
||||
assert reg.cancel("不存在的 id") is False
|
||||
|
||||
|
||||
# ───────────────────────── 订阅 / 回收 ─────────────────────────
|
||||
|
||||
|
||||
async def test_subscribe_receives_snapshots(reg):
|
||||
queue = reg.subscribe()
|
||||
assert queue.empty()
|
||||
|
||||
def work(report):
|
||||
report(done=1, total=2, phase="干活")
|
||||
return "ok"
|
||||
|
||||
task, _ = reg.submit(slot="plug:job", title="推送", func=work)
|
||||
await asyncio.wait_for(reg.wait(task), 2)
|
||||
|
||||
assert not queue.empty()
|
||||
frames = []
|
||||
while not queue.empty():
|
||||
frames.append(queue.get_nowait())
|
||||
assert frames[0][0]["title"] == "推送"
|
||||
assert frames[-1][0]["status"] == "done"
|
||||
# 每帧都是全量快照
|
||||
assert isinstance(frames[-1], list)
|
||||
|
||||
reg.unsubscribe(queue)
|
||||
reg.unsubscribe(queue) # 幂等
|
||||
|
||||
|
||||
async def test_clear_finished_keeps_running(reg):
|
||||
gate = threading.Event()
|
||||
|
||||
def slow(report):
|
||||
gate.wait(2)
|
||||
|
||||
def quick(report):
|
||||
return 1
|
||||
|
||||
running, _ = reg.submit(slot="plug:slow", title="在跑", func=slow)
|
||||
done, _ = reg.submit(slot="plug:quick", title="跑完了", func=quick)
|
||||
await asyncio.wait_for(reg.wait(done), 2)
|
||||
|
||||
assert reg.clear_finished() == 1
|
||||
assert reg.get(done.id) is None
|
||||
assert reg.get(running.id) is not None
|
||||
|
||||
gate.set()
|
||||
await asyncio.wait_for(reg.wait(running), 2)
|
||||
|
||||
|
||||
async def test_prune_by_max_tasks_keeps_running(reg):
|
||||
small = type(reg)() # 同类型新实例
|
||||
small.max_tasks = 2
|
||||
|
||||
gate = threading.Event()
|
||||
|
||||
def slow(report):
|
||||
gate.wait(2)
|
||||
|
||||
def quick(report):
|
||||
return 1
|
||||
|
||||
running, _ = small.submit(slot="s:run", title="在跑", func=slow)
|
||||
for i in range(3):
|
||||
t, _ = small.submit(slot=f"s:{i}", title=f"第{i}次", func=quick)
|
||||
await asyncio.wait_for(small.wait(t), 2)
|
||||
|
||||
assert len(small.list()) <= 2 # 上限生效(终态的被砍)
|
||||
assert small.get(running.id) is not None # 在跑的绝不动
|
||||
gate.set()
|
||||
await asyncio.wait_for(small.wait(running), 2)
|
||||
|
||||
|
||||
async def test_dismiss_only_finished(reg):
|
||||
"""已结束的能删,在跑的删不掉(删了状态就没人回传了)。"""
|
||||
gate = threading.Event()
|
||||
|
||||
def slow(report):
|
||||
gate.wait(2)
|
||||
|
||||
def quick(report):
|
||||
return 1
|
||||
|
||||
running, _ = reg.submit(slot="plug:slow", title="在跑", func=slow)
|
||||
done, _ = reg.submit(slot="plug:quick", title="跑完了", func=quick)
|
||||
await asyncio.wait_for(reg.wait(done), 2)
|
||||
|
||||
assert reg.dismiss(running.id) is False # 在跑:拒绝
|
||||
assert reg.get(running.id) is not None
|
||||
assert reg.dismiss(done.id) is True
|
||||
assert reg.get(done.id) is None
|
||||
assert reg.dismiss(done.id) is False # 已删过 → 再删是 no-op
|
||||
assert reg.dismiss("不存在的 id") is False
|
||||
|
||||
gate.set()
|
||||
await asyncio.wait_for(reg.wait(running), 2)
|
||||
|
||||
|
||||
async def test_audit_fields_round_trip(reg):
|
||||
def work(report):
|
||||
return "ok"
|
||||
|
||||
task, _ = reg.submit(
|
||||
slot="plug:job", title="审计", func=work,
|
||||
plugin_id="video_analysis", trigger="web", actor="admin", group_id="123456",
|
||||
)
|
||||
await asyncio.wait_for(reg.wait(task), 2)
|
||||
d = task.to_dict()
|
||||
assert (d["plugin_id"], d["trigger"], d["actor"], d["group_id"]) == (
|
||||
"video_analysis", "web", "admin", "123456",
|
||||
)
|
||||
assert d["status"] == "done" and d["active"] is False
|
||||
Reference in new issue
Block a user