Archived
为什么先测后改:_batch_zip_worker(347 行) 与 _podcast_zip_worker(295 行) 近乎 复制——连 _wait_if_paused 那 16 行都逐字相同(注释只差一行)。重复的代价是实打实 的:上一次修的 zip_valid「变量可能未绑定」缺陷就是**同型出现在这两处**。 但暂停/恢复是一条并发状态机,此前**零测试覆盖**,直接改不符合先测量后改动。 所以本 commit 分两步落地。 第一步(新增 tests/test_zip_workers.py,9 个用例)——行为级契约 断言只用「调用 worker 后世界里发生了什么」:任务终态、ZIP 内条目、账本守恒 (delivered / refunded)、暂停/恢复/取消后的余额。不引用任何内部函数名, 这样第二步重构时测试不必跟着改,才具备等价性验证能力。 - batch:正常完成 / 暂停→恢复 / 暂停→取消 / 全失败退费 / 循环中途取消 / 发布前一刻取消 - podcast:正常完成 / 暂停→恢复 / 全失败退费 - 脚手架一律用 reserve_and_open 真预扣(ledger_open 只记账不扣款, 会让「退款」凭空给余额加次数,守恒断言就失去意义) 写测试过程中暴露两处代码与注释不符,如实钉住而非粉饰: 1. **循环内取消并不会「交付已打包部分」**。zip_valid = True 位于 with zipfile 块**之后**,而 while 循环内的任何取消都以异常**穿过**该块 (zipfile.__exit__ 仍写出中央目录,但那行赋值不执行)→ zip_valid 恒为 False → delivered = ok_count if zip_valid else 0 恒为 0。源码注释所称的 「部分成功后取消照常交付(成果守恒)」只在发布前最后一个检查点可达, 已分别用两个用例把两条路径都钉住。 2. podcast 的 _wait_if_paused 与 batch 版本除 docstring 外完全一致。 第二步——抽取(只抽逐字相同、零语义差异的部分) - _taskcore._wait_if_paused(task, pause_ev, cancel_ev, done):done 写成参数 而非捕获闭包,使函数脱离两个 worker 各自的局部状态 - _taskcore._task_work_dir(task_id):任务专属目录约定(ZIP 与中间文件都落 tasks/<task_id>/,对根目录的缓存清扫免疫) - 两个 worker 各自的 16 行副本与 2 行 mkdir 删除,调用点改为显式传参 复杂度实测量(同口径,git HEAD 对比): 两个 worker 合计 行数 642 → 604(-38) 决策点 152 → 140(-12) 新增共享实现 _wait_if_paused 27 行/7 决策点 · _task_work_dir 11 行/1 **净行数基本持平(总量 -38 +38)**——重复代码抽取的收益从来不是行数变少, 而是**唯一真源**:以后改暂停语义只需改一处,不会再出现「同型缺陷出现在两地」。 验证:pytest 184 passed(175 → 184,+9)· ruff 0 · pyright 0 errors/0 warnings
157 lines
6.5 KiB
Python
157 lines
6.5 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""任务生命周期原语(启动 / 终态 / 文件收尾 / 结算)。
|
||
"""
|
||
|
||
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,
|
||
)
|
||
|
||
|
||
def _start_task_thread(task_id: str, target, args=()) -> str | None:
|
||
"""启动任务 worker(启动失败时收敛任务终态并按账本退费,不留僵尸 queued 记录)。
|
||
退款只走账本(本处 settle 后装饰器看到账已关,不会重复退)。
|
||
线程句柄存入任务字典——监控循环对 4h 超时任务若发现 worker 线程已死
|
||
(无法自行走到终态结算),可代为按账本结算退款(settle 幂等,无双退)。"""
|
||
try:
|
||
th = threading.Thread(target=target, args=args, daemon=True)
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS.get(task_id)
|
||
if t is not None:
|
||
t['worker_thread'] = th
|
||
th.start()
|
||
return None
|
||
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}'
|
||
|
||
|
||
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'):
|
||
th = t.get('worker_thread')
|
||
if th is not None and not th.is_alive():
|
||
need_settle = True
|
||
# settle 移到任务锁外——ledger_settle 内部要抢 _DB_LOCK 写库
|
||
# (busy 最长 15s),持任务锁调用会让全站进度/提交/取消/领取随库停摆;
|
||
# settle 幂等,锁外调用无双退
|
||
if need_settle:
|
||
try:
|
||
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
|
||
|
||
|
||
def _settle_batch_billing(task_id: str, ok_count: int, final_status: str = 'settled') -> int:
|
||
"""批量任务结算(走持久账本,按实际预扣-实际交付幂等退款;
|
||
重复调用/重启后调用都只会退一次)"""
|
||
try:
|
||
refund = 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 = 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 '?'
|
||
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
|
||
|
||
|
||
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'):
|
||
time.sleep(1)
|
||
if cancel_ev.is_set():
|
||
raise DownloadCanceled("任务已取消")
|
||
pause_ev.clear()
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
task['status'] = 'running'
|
||
task['msg'] = '继续下载...'
|