-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathjobqueue.py
More file actions
150 lines (131 loc) · 6.11 KB
/
Copy pathjobqueue.py
File metadata and controls
150 lines (131 loc) · 6.11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
"""内部任务队列:submit 立刻返回 job_id,后台按并发上限逐个跑,调用方轮询取结果。
为什么必须异步:MCP 是请求/响应模型,客户端(weft)侧超时上限只有 ~120s,而一条视频转写要几分钟到
几十分钟。所以 submit 秒回、结果另行轮询 —— 这是 MCP 上跑长任务的标准做法。
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import shutil
import time
import uuid
from dataclasses import dataclass, field, asdict
import engines
from config import CFG
from media import MediaError, extract_audio, fetch_media, probe_duration, workspace
logger = logging.getLogger("vt.queue")
@dataclass
class Job:
id: str
media_url: str
title: str = ""
engine: str = ""
model: str = "" # 本任务用的 ASR 模型;空=服务端默认(CFG.model)
hotwords: str = "" # 热词/上下文偏置,仅对支持的模型生效(见 models.supports_hotwords)
status: str = "queued" # queued | running | done | failed | canceled
text: str = ""
error: str = ""
audio_seconds: float = 0.0 # 音频时长
asr_seconds: float = 0.0 # 转写耗时 → 与上者比即"实时率",用来评估算力
created_at: float = field(default_factory=time.time)
started_at: float = 0.0
finished_at: float = 0.0
def public(self) -> dict:
d = asdict(self)
for k in ("audio_seconds", "asr_seconds"):
d[k] = round(d[k], 1)
return d
class JobQueue:
def __init__(self) -> None:
self._jobs: dict[str, Job] = {}
self._q: asyncio.Queue[str] = asyncio.Queue()
self._workers: list[asyncio.Task] = []
self._started = False
def start(self) -> None:
if self._started:
return
self._started = True
for i in range(max(1, CFG.concurrency)):
self._workers.append(asyncio.create_task(self._worker(i)))
self._workers.append(asyncio.create_task(self._gc()))
logger.info("任务队列启动(并发 %d)", max(1, CFG.concurrency))
def submit(self, media_url: str, title: str = "", engine: str = "",
model: str = "", hotwords: str = "") -> Job:
job = Job(id=uuid.uuid4().hex[:16], media_url=media_url, title=title,
engine=engine or CFG.engine, model=(model or CFG.model),
hotwords=hotwords or "")
self._jobs[job.id] = job
self._q.put_nowait(job.id)
return job
def get(self, job_id: str) -> Job | None:
return self._jobs.get(job_id)
def cancel(self, job_id: str) -> bool:
"""只能取消尚未开跑的任务(已在跑的让它跑完,避免中断留下半截文件)。"""
j = self._jobs.get(job_id)
if j and j.status == "queued":
j.status = "canceled"
j.finished_at = time.time()
return True
return False
def stats(self) -> dict:
by: dict[str, int] = {}
for j in self._jobs.values():
by[j.status] = by.get(j.status, 0) + 1
return {"total": len(self._jobs), "by_status": by, "pending": self._q.qsize()}
async def _worker(self, idx: int) -> None:
while True:
job_id = await self._q.get()
job = self._jobs.get(job_id)
try:
if job and job.status == "queued": # 可能已被取消
await self._run(job)
except Exception: # noqa: BLE001 worker 必须长活
logger.exception("任务处理异常 job=%s", job_id)
if job:
job.status, job.error = "failed", "内部错误"
job.finished_at = time.time()
finally:
self._q.task_done()
async def _run(self, job: Job) -> None:
job.status, job.started_at = "running", time.time()
work = workspace()
try:
src = await asyncio.wait_for(fetch_media(job.media_url, work), timeout=CFG.job_timeout_s)
wav = await asyncio.wait_for(extract_audio(src, work), timeout=CFG.job_timeout_s)
job.audio_seconds = await probe_duration(wav)
eng = await engines.get(job.engine, job.model)
t0 = time.time()
text = await asyncio.wait_for(eng.transcribe(wav, job.hotwords),
timeout=CFG.job_timeout_s)
job.asr_seconds = time.time() - t0
job.text = (text or "").strip()
job.status = "done" if job.text else "failed"
if not job.text:
job.error = "转写结果为空(可能音频无人声)"
rt = (job.asr_seconds / job.audio_seconds) if job.audio_seconds else 0
logger.info("完成 job=%s 音频%.0fs 转写%.0fs 实时率%.2fx 字数%d",
job.id, job.audio_seconds, job.asr_seconds, rt, len(job.text))
except (MediaError, ValueError) as e:
job.status, job.error = "failed", str(e)[:500]
logger.warning("任务失败 job=%s: %s", job.id, job.error)
except asyncio.TimeoutError:
job.status, job.error = "failed", f"处理超时(>{CFG.job_timeout_s}s)"
except Exception as e: # noqa: BLE001
job.status, job.error = "failed", f"{type(e).__name__}: {str(e)[:400]}"
logger.exception("任务失败 job=%s", job.id)
finally:
job.finished_at = time.time()
with contextlib.suppress(Exception): # 临时文件用完即删(视频很占地方)
shutil.rmtree(work, ignore_errors=True)
async def _gc(self) -> None:
"""定期清掉过期的已完成任务,防止长期运行内存无限涨。"""
while True:
await asyncio.sleep(600)
now = time.time()
stale = [k for k, j in self._jobs.items()
if j.finished_at and now - j.finished_at > CFG.result_ttl_s]
for k in stale:
self._jobs.pop(k, None)
if stale:
logger.info("清理过期任务 %d 条", len(stale))
QUEUE = JobQueue()