Archived
拆掉「同步调异步 / 异步桥同步」两类垫片的第二半(HTTP 线 B2 见前序提交)。 worker 侧此前是「起线程跑同步函数、函数体内部再用 asyncio.to_thread 把活塞给 另一条循环」——线程与协程互等,取消/暂停只能靠轮询。本轮把 worker 自身变成 协程,由 _start_task 直接挂到应用事件循环上,桥接层随之失去存在理由。 改动(按子系统): - music_downloader:downloader / _aria2 / _concurrency / cover / _http / __main__ 全链路协程化。per-file 锁改 asyncio.Lock;计数闸改 「active 计数 + await asyncio.sleep(0.5)」的可取消等待(原阻塞式 with 会让 已取消任务仍要等全局名额腾出,卡死并拖后腿退款);aria2p 的同步 RPC 仍经 to_thread,轮询改 asyncio.sleep;新增 core.http_client.iter_chunks,把**流式 读取期**的 aiohttp.ClientError 收敛成 UpstreamNetworkError(请求建立阶段早已 有这条路径,读取阶段此前没有,断流会绕过调用方的 UpstreamError 分类)。 删除 get_http_session / _SESSION_LOCAL。 models._RetryFromScratch 不再继承 requests.RequestException 而继承 Exception: 它是「续传起点失效、残片已清、从头重下」,既不是确定性失败 (DownloadException),也不是残片已保留的网络失败(UpstreamError),必须独立 成类且在 except 链中先于二者捕获。 - music_api:podcast.py 全部函数协程化(直链解析内部改 await NeteaseAPI.get_song_url);compat.py 删除——16 个死掉的同步门面早已 清空,文件只剩空壳。 - services:_taskcore / _zipcore / batch / downloads / podcast / tasks 协程化。 threading.Thread → loop.create_task(任务句柄字段 worker_thread → worker_task),ThreadPoolExecutor → asyncio.gather + asyncio.Semaphore, 终态函数与结算回调全部改 await;监控线程里「worker 已死」判定改 task.done()。 删除 services/streaming.py(同步流垫片,唯一消费者已迁到 core.http_client.open)。zipfile 实例仍非线程安全,故「下载」并行、 「入包」严格串行。 - web:路由层 130+ 处 to_thread 拆除——被包住的目标自己已是协程,再包一层只是 把协程丢进线程里跑一圈。tasks_registry.start_task 直接 create_task。 web/app.py 关闭段去掉 async_bridge.shutdown()(不再有第二条约循环)。 web/routers/download.py 的播客端点补上 3 处漏掉的 await (extract_podcast_ref / get_radio_detail)——漏掉时不报错、只静默返回协程对象, 靠 pyright 抓出来后修正。 留在 to_thread 上的都是**正当的阻塞卸载**:同步 SQLite 落库与账本结算、 mutagen 全量重写标签、zipfile 整段拷贝、shutil.rmtree、aria2p 的同步 RPC、 PBKDF2 的 10 万次迭代。ruff 对残留的本地元数据操作(exists/stat/os.replace/ 往 .part 顺序写分块)按「微秒级、卸载的调度开销大于操作本身」放行,pyproject 逐条注明理由。 取消/暂停仍由 threading.Event 承载:置位方是请求线程/监控线程,读取方是 worker 协程,只有「置位」与「轮询读取」两种操作,既不需要跨循环唤醒也不会卡住事件 循环——比 asyncio.Event 更省心(ASYNC110 同理按此豁免)。 测试:10 个文件的替身边界同步迁移——网络替身从 core.sync_http 的 get_http_session 换成 downloader/服务模块的 _http.open + **真实** iter_chunks (顺带钉住读取期异常收敛);`_run` 改为在独立线程里 asyncio.run(worker(...)); aria2 的假时钟从 time.sleep 换成 _POLL_INTERVAL=0;monitor 的线程句柄替身换成 done() 可控的轻量替身(asyncio.Task 的构造需要运行中的事件循环,而监控循环跑在 常驻线程里);podcast / 封面 / 结算包装等替身一律改 async。 门禁:ruff 全过 / pyright 0-0 / pytest 383 项全绿(0 失败)。 下一步 B2:auth_system 12 个模块改 aiosqlite 背板、web 层剩余 to_thread 收尾; B3:删 core/sync_http.py、core/async_bridge.py、core/http_errors.py(现仅 sync_http 自用)。
188 lines
8.1 KiB
Python
188 lines
8.1 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""任务生命周期原语(启动 / 终态 / 文件收尾 / 结算)。
|
||
|
||
worker 自本轮起是协程:``_start_task`` 把 ``worker(*args)`` 包成 asyncio 任务挂到
|
||
应用事件循环上(原先起一条线程)。任务字典里的句柄随之由 ``worker_thread`` 换成
|
||
``worker_task``,监控循环对「worker 已结束但没人走终态结算」的兜底判断改为
|
||
``task.done()``——语义不变(settle 幂等,无双退)。
|
||
|
||
取消/暂停位仍是 ``threading.Event``:置位方可能是监控线程或请求线程,读取方是
|
||
worker 协程,只有"置位"与"轮询读取"两种操作,既不需要跨循环唤醒也不会被事件
|
||
循环卡住,比 asyncio.Event 更省心。
|
||
"""
|
||
|
||
import asyncio
|
||
import threading
|
||
import time
|
||
import traceback
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import auth_system
|
||
from core.state import (
|
||
DOWNLOAD_TASKS,
|
||
DOWNLOAD_TASKS_LOCK,
|
||
)
|
||
from music_downloader import (
|
||
DownloadCanceled,
|
||
)
|
||
|
||
from .api_service import (
|
||
api_service,
|
||
)
|
||
|
||
# 存活期的后台任务强引用——asyncio 只持弱引用,不留引用的话任务可能中途被 GC
|
||
_BACKGROUND_TASKS: set[asyncio.Task] = set()
|
||
|
||
|
||
def _log_worker_crash(handle: asyncio.Task) -> None:
|
||
"""worker 的异常逃逸到任务层时留痕。
|
||
|
||
正常情况下 worker 自带四路终态收敛,不会抛到这里;真抛出来说明收尾路径本身
|
||
出了问题,必须留下 traceback,否则任务永远停在 running 且无人知晓。
|
||
"""
|
||
if handle.cancelled():
|
||
return
|
||
exc = handle.exception()
|
||
if exc is not None:
|
||
api_service.logger.error(f"任务 worker 异常退出: {exc}", exc_info=exc)
|
||
|
||
|
||
def _start_task(task_id: str, worker, args: tuple = ()) -> str | None:
|
||
"""启动任务 worker(启动失败时收敛任务终态并按账本退费,不留僵尸 queued 记录)。
|
||
退款只走账本(本处 settle 后装饰器看到账已关,不会重复退)。
|
||
任务句柄存入任务字典——监控循环对 4h 超时任务若发现 worker 已结束
|
||
(无法自行走到终态结算),可代为按账本结算退款(settle 幂等,无双退)。"""
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
handle = loop.create_task(worker(*args), name=f'task:{task_id}')
|
||
except Exception as e:
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS.pop(task_id, None)
|
||
if t:
|
||
try:
|
||
auth_system.ledger_settle(task_id, 0, 'submit_failed')
|
||
except Exception:
|
||
pass
|
||
api_service.logger.error(f"任务启动失败: {task_id} {e}")
|
||
return f'任务启动失败:{e}'
|
||
_BACKGROUND_TASKS.add(handle)
|
||
handle.add_done_callback(_BACKGROUND_TASKS.discard)
|
||
handle.add_done_callback(_log_worker_crash)
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS.get(task_id)
|
||
if t is not None:
|
||
t['worker_task'] = handle
|
||
return None
|
||
|
||
|
||
async def _mark_task_terminal(task_id: str, status: str, msg: str = '') -> None:
|
||
"""任务进入终态的统一入口(记录 completed_at,保留期从完成时刻起算,
|
||
而不是从提交时刻起算)。
|
||
/F-E:置 error/canceled 终态时若 worker 已结束(无人再走正常结算),
|
||
代为按账本结算退款——ledger_settle 幂等,worker 已自行结算过则此处为 0。
|
||
终态先到先得——已终态(done/error/canceled)的任务
|
||
不再被覆盖(堵住 canceled→error 的非法迁移边:取消后解析阶段的自身失败
|
||
不得把"已取消"改写成"失败",completed_at 也不再刷新)。"""
|
||
need_settle = False
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS.get(task_id)
|
||
if t and t.get('status') in ('done', 'error', 'canceled'):
|
||
return
|
||
if t:
|
||
t['status'] = status
|
||
t['msg'] = msg or t.get('msg', '')
|
||
t['completed_at'] = time.time()
|
||
if status in ('canceled', 'error'):
|
||
wt = t.get('worker_task')
|
||
if wt is not None and wt.done():
|
||
need_settle = True
|
||
# settle 移到任务锁外——ledger_settle 内部要抢 SQLite 写锁
|
||
# (busy 最长 15s),持任务锁调用会让全站进度/提交/取消/领取随库停摆;
|
||
# settle 幂等,锁外调用无双退。落库是同步阻塞调用,经 to_thread 卸载
|
||
if need_settle:
|
||
try:
|
||
await asyncio.to_thread(auth_system.ledger_settle, task_id, 0, status)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _task_files(task: dict[str, Any]) -> list[str]:
|
||
"""任务当前关联的可清理文件路径(交付 ZIP/交付副本;缓存文件不在此列)"""
|
||
result = task.get('result') or {}
|
||
files = []
|
||
for f in (result.get('zip_path'), result.get('file_path')):
|
||
if f:
|
||
files.append(f)
|
||
return files
|
||
|
||
|
||
async def _settle_batch_billing(task_id: str, ok_count: int, final_status: str = 'settled') -> int:
|
||
"""批量任务结算(走持久账本,按实际预扣-实际交付幂等退款;
|
||
重复调用/重启后调用都只会退一次)"""
|
||
try:
|
||
refund = await asyncio.to_thread(
|
||
auth_system.ledger_settle, task_id, ok_count, final_status)
|
||
except Exception:
|
||
# 本函数多在异常处理器内被调用——结算再抛错(库忙超时)
|
||
# 会杀死 worker 收尾路径,跳过终态标记;失败留周期对账兜底(幂等)
|
||
api_service.logger.error(
|
||
f"批量结算异常(留对账兜底): {task_id}\n{traceback.format_exc()}")
|
||
return 0
|
||
if refund:
|
||
try:
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS.get(task_id) or {}
|
||
uid = int(t.get('billing_user_id') or 0)
|
||
u = await asyncio.to_thread(auth_system.get_user_by_id, uid) if uid else None
|
||
uname = u['username'] if u else 'system'
|
||
remaining = u['duration_seconds'] if u else '?'
|
||
await asyncio.to_thread(
|
||
auth_system.add_log, 'success', uname, 'system', '批量退费',
|
||
f'失败部分 {refund} 次已退回,剩余 {remaining} 次')
|
||
except Exception:
|
||
pass
|
||
return refund
|
||
|
||
|
||
def _task_work_dir(task_id: str) -> Path:
|
||
"""任务专属工作目录(交付 ZIP 与中间文件都落这里)。
|
||
|
||
原实现把临时包建在 downloads 根目录,会被缓存清扫线程按 mtime 回收
|
||
(Linux 上已打开文件同样能被 unlink),排队超时的任务于是出现
|
||
「扣费后领取 404」的悬空交付;任务目录从此对根目录清扫免疫。
|
||
音乐歌单打包与播客整台打包共用本目录约定。
|
||
"""
|
||
path = api_service.downloads_path / 'tasks' / task_id
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
return path
|
||
|
||
|
||
async def _wait_if_paused(task: dict[str, Any], pause_ev: threading.Event,
|
||
cancel_ev: threading.Event, done: int) -> None:
|
||
"""断线暂停闸门:置 paused 态并等待恢复或取消,恢复后回到 running。
|
||
|
||
两个打包 worker(音乐歌单 / 播客整台)共用同一套暂停语义——此前各自持有
|
||
一份逐字相同的副本(连注释都只差一行),是「同型缺陷同时出现在两处」的温床。
|
||
|
||
调用点位于窗口循环顶部,等待期间**不持有** BATCH_ZIP_SEM:名额交还队列
|
||
给下一位,这正是「断线不占坑」的实现方式。
|
||
|
||
``done`` 由调用方按当前进度传入(ok_count + len(failed))——写成参数而非
|
||
捕获闭包,是为了让本函数脱离两个 worker 各自的局部状态。
|
||
"""
|
||
if not (pause_ev.is_set() or task.get('status') == 'paused'):
|
||
return
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
task['status'] = 'paused'
|
||
task['done'] = done
|
||
task['msg'] = '已暂停(重新打开页面自动恢复)'
|
||
while not cancel_ev.is_set() and (pause_ev.is_set() or task.get('status') == 'paused'):
|
||
await asyncio.sleep(1)
|
||
if cancel_ev.is_set():
|
||
raise DownloadCanceled("任务已取消")
|
||
pause_ev.clear()
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
task['status'] = 'running'
|
||
task['msg'] = '继续下载...'
|