Archived
拆掉两类垫片的最后一处:auth_system 原为同步 sqlite3 栈,web 层与 services 的
worker 只能靠 asyncio.to_thread 逐个桥接才不阻塞事件循环——每次 DB 访问都白付一次
线程池调度,且「同步调异步」的边界散落在 100+ 个调用点。B1 已把 worker 变成协程,
本批把访问层本身也迁到原生异步,两条线在此合拢。
改动(按子系统):
- auth_system:db.py 照 core/db.py 现有范式重建为原生异步访问层(aiosqlite + WAL)
——读路径 _q 每次独立连接、无锁、可并发;写路径 _x / write_transaction 用
asyncio.Lock 串行化,写者不阻塞读者。users / cards / credits / ledger / logs /
settings / mail / geo / backup 九模块随之全部 async/await;security.py 是纯计算
(pbkdf2/normalize)不动。__init__ 去掉 _DB_LOCK 与 get_conn 导出。
- web:app / deps / billing / tasks_registry 与 routers/{admin_site,auth,music,
register,user} 直接 await,拆掉包裹的 to_thread。deps.secret_key 改 async,
连带 _parse_cookie 也 async(会话解析要读密钥)。
- services:_taskcore / tasks / downloads / podcast 的账本调用与查询直接 await。
4 处 start_task 调用点(download.py)确认已 await。
设计取舍(为什么这么做,而不是直接包一层):
- 锁按**事件循环**隔离(_LOOP_LOCKS 以 id(loop) 为键):asyncio.Lock 绑定创建它的
loop,应用主 loop 与测试各自的 loop 并存,跨 loop 复用会抛 "bound to a different
event loop"。
- WAL 切换用**独立锁** _wal_lock 而非 _write_lock:execute() 已持写锁,再在锁内
connect→_configure 会自锁死(asyncio.Lock 非重入)。切换 + 标记在专用锁内串行并
二次复查,否则并发首连会同时执行 PRAGMA journal_mode=WAL 争排他锁报
database is locked(core/db.py 同款教训,实测复现)。
- force_wal 保留:journal_mode 持久化在库文件头,库文件被删重建时新文件不继承,
必须重设。exists() 判定须在 aiosqlite.connect **之前**(connect 会创建空文件)。
- connect() 用 DB_PATH.parent 兜底建目录,优于 DATA_DIR:生产两者相同,但测试
monkeypatch 只换 DB_PATH 时兜底仍能命中正确目录。
留在 to_thread 上的仅剩**正当的阻塞卸载**,不是同步门面:PBKDF2 的 10 万次迭代
(登录链路每次都要跑)、smtplib 发信、备份文件的 copy2 / os.replace、secret.key
的文件 IO。原有 aria2 / downloader / zip 侧的 to_thread 未动。
不变量逐条保持(迁移只改执行模型,不改语义):
- ledger_settle 幂等:账本非 open 直接 return 0,重启后重放不重复退款;
- 终态先到先得:_mark_task_terminal 对已 done/error/canceled 的任务直接 return,
堵住 canceled→error 的非法迁移边;
- settle 必须在任务锁**外**调用(ledger_settle 要抢 SQLite 写锁,busy 最长 15s,
持任务锁会让全站进度/提交/取消随库停摆);
- reserve_and_open 的预扣与开账同事务,消除原两步之间的崩溃丢账窗口。
路径真源红线:DATA_DIR / DB_PATH / BACKUP_DIR / SECRET_KEY_PATH 仍是 auth_system.db
的**模块全局**(用与 core.db 相同的表达式,但**不是**它的别名)。测试只 patch
auth_system.db.*,若改成 core.db 的别名会静默把测试写到生产库。已核 data/site.db
读写前后 sha256 完全一致。
顺带清掉 get_conn:迁移后它只剩「转调 connect()」一层空壳、全仓零消费者(测试
直接开裸连接、备份用 db.connect()),保留等于留一条「拿裸连接自己写」的口子。
测试:19 个文件同步迁移——10 个文件的 web.deps.secret_key 替身改 async;
test_db_conn.py 的多线程首连用例按异步语义拆成 2 条(单 loop gather(16) 覆盖批量
并发首连、WAL 就绪后 8 线程各自 loop 建连覆盖跨 loop);test_billing_scope.py 去掉
多余的 @pytest.mark.anyio;被 await 且**正常返回**的 monkeypatch 替身一律改 async
(仅 raise 的同步替身仍可用)。
门禁:ruff 全过 / pyright 0-0-0(standard 模式含 tests 与顶层模块)/ pytest
384 项全绿(0 失败 0 error 0 skipped;383→384 因 test_db_conn 拆用例)。
data/site.db 读写前后 sha256 一致(a19f5db…6487),无 -wal/-shm 边车残留。
下一步 B3:删 core/sync_http.py、core/async_bridge.py、core/http_errors.py 及
tests/test_async_bridge.py——三者在生产侧已零消费者(http_errors 仅 sync_http
自用,requests 已非声明依赖)。
186 lines
8.0 KiB
Python
186 lines
8.0 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)
|
||
|
||
|
||
async 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:
|
||
await 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 幂等,锁外调用无双退。落库已迁移为原生异步,直接 await。
|
||
if need_settle:
|
||
try:
|
||
await 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 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 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 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'] = '继续下载...'
|