Archived
refactor: worker 子系统全面异步化,拆掉同步转异步的桥接层
拆掉「同步调异步 / 异步桥同步」两类垫片的第二半(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 自用)。
This commit is contained in:
1 parent
65adfc9089
commit
69a50c2c31
35 files changed
+1062
-1005
No files matched your search
@@ -8,6 +8,9 @@
|
||||
|
||||
断言只看「调用后世界里发生了什么」(文件系统 + 任务表),不引用私有函数名,
|
||||
故可跨后续重构复用。
|
||||
|
||||
替身边界:monkeypatch ``services.podcast._http``(core.http_client 的进程级异步
|
||||
客户端),测试用 ``holder['resp']`` 指定本次响应。
|
||||
"""
|
||||
import threading
|
||||
|
||||
@@ -18,8 +21,19 @@ from music_downloader import DownloadCanceled, DownloadException
|
||||
from services import podcast as podcast_svc
|
||||
|
||||
|
||||
class _FakeContent:
|
||||
"""``aiohttp.ClientResponse.content`` 的替身(逐块产出,惰性求值)。"""
|
||||
|
||||
def __init__(self, chunks):
|
||||
self._chunks = chunks
|
||||
|
||||
async def iter_chunked(self, chunk_size=0):
|
||||
for item in self._chunks:
|
||||
yield item
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
"""`_stream_open` 的替身:可控状态码、Content-Length 与分块序列。
|
||||
"""``core.http_client.open`` 的替身:可控状态码、Content-Length 与分块序列。
|
||||
|
||||
注意不能用 list(chunks) 归一化——那会把传入的生成器**立即求值**,使「迭代中
|
||||
才触发的副作用」(如中途置取消位)提前到构造时刻,测出来的就不是想测的路径。
|
||||
@@ -28,16 +42,13 @@ class _FakeResp:
|
||||
def __init__(self, chunks, status=200, content_length=None):
|
||||
self._chunks = chunks
|
||||
self.status = status
|
||||
self.reason = ''
|
||||
self.headers = {} if content_length is None else {'Content-Length': str(content_length)}
|
||||
self.content = _FakeContent(chunks)
|
||||
self.closed = False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def iter_content(self, chunk_size=0):
|
||||
yield from self._chunks
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _CancelAfterFirstChunk:
|
||||
@@ -66,14 +77,15 @@ class _CancelAfterFirstChunk:
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_stream(monkeypatch):
|
||||
"""替换 _stream_open;测试通过 holder['resp'] 指定本次响应。"""
|
||||
"""替换 podcast 模块的 _http 客户端;测试通过 holder['resp'] 指定本次响应。"""
|
||||
holder: dict = {}
|
||||
|
||||
def _open(url, timeout=60):
|
||||
assert url == holder.get('url', url)
|
||||
return holder['resp']
|
||||
class _Client:
|
||||
async def open(self, method, url, **kw):
|
||||
assert url == holder.get('url', url)
|
||||
return holder['resp']
|
||||
|
||||
monkeypatch.setattr(podcast_svc, '_stream_open', _open)
|
||||
monkeypatch.setattr(podcast_svc, '_http', _Client())
|
||||
return holder
|
||||
|
||||
|
||||
@@ -97,91 +109,93 @@ def task_table():
|
||||
# ============ 正常路径 ============
|
||||
|
||||
|
||||
def test_writes_target_and_renames_part(tmp_path, fake_stream):
|
||||
async def test_writes_target_and_renames_part(tmp_path, fake_stream):
|
||||
"""正常下载:返回实际字节数,落地 target,不残留 .part。"""
|
||||
fake_stream['resp'] = _FakeResp([b'a' * 600, b'b' * 400])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
done = podcast_svc._podcast_stream_download('http://u', target, 1000, threading.Event())
|
||||
done = await podcast_svc._podcast_stream_download('http://u', target, 1000, threading.Event())
|
||||
assert done == 1000
|
||||
assert target.read_bytes() == b'a' * 600 + b'b' * 400
|
||||
assert not target.with_name('ep.mp3.part').exists()
|
||||
|
||||
|
||||
def test_empty_chunks_are_skipped(tmp_path, fake_stream):
|
||||
async def test_empty_chunks_are_skipped(tmp_path, fake_stream):
|
||||
"""空分块不计入字节数(上游偶发发空 chunk)。"""
|
||||
fake_stream['resp'] = _FakeResp([b'', b'x' * 50, b''])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
assert podcast_svc._podcast_stream_download('http://u', target, 50, threading.Event()) == 50
|
||||
assert await podcast_svc._podcast_stream_download(
|
||||
'http://u', target, 50, threading.Event()) == 50
|
||||
|
||||
|
||||
def test_content_length_used_when_total_unknown(tmp_path, fake_stream):
|
||||
async def test_content_length_used_when_total_unknown(tmp_path, fake_stream):
|
||||
"""未传期望大小时用 Content-Length 兜底,并据此校验完整性。"""
|
||||
fake_stream['resp'] = _FakeResp([b'x' * 30], content_length=30)
|
||||
target = tmp_path / 'ep.mp3'
|
||||
assert podcast_svc._podcast_stream_download('http://u', target, 0, threading.Event()) == 30
|
||||
assert await podcast_svc._podcast_stream_download(
|
||||
'http://u', target, 0, threading.Event()) == 30
|
||||
|
||||
|
||||
# ============ 完整性校验 ============
|
||||
|
||||
|
||||
def test_size_mismatch_removes_delivered_file(tmp_path, fake_stream):
|
||||
async def test_size_mismatch_removes_delivered_file(tmp_path, fake_stream):
|
||||
"""字节数不符:删掉已落地的文件并报错(不交付截断的坏文件)。"""
|
||||
fake_stream['resp'] = _FakeResp([b'x' * 100])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
with pytest.raises(DownloadException) as ei:
|
||||
podcast_svc._podcast_stream_download('http://u', target, 200, threading.Event())
|
||||
await podcast_svc._podcast_stream_download('http://u', target, 200, threading.Event())
|
||||
assert '下载不完整' in str(ei.value)
|
||||
assert not target.exists(), '坏文件必须被清掉'
|
||||
|
||||
|
||||
def test_empty_download_raises_and_removes(tmp_path, fake_stream):
|
||||
async def test_empty_download_raises_and_removes(tmp_path, fake_stream):
|
||||
"""期望大小未知且一个字节都没拿到 → 报「下载内容为空」。"""
|
||||
fake_stream['resp'] = _FakeResp([])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
with pytest.raises(DownloadException) as ei:
|
||||
podcast_svc._podcast_stream_download('http://u', target, 0, threading.Event())
|
||||
await podcast_svc._podcast_stream_download('http://u', target, 0, threading.Event())
|
||||
assert '下载内容为空' in str(ei.value)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_known_size_does_not_tolerate_partial(tmp_path, fake_stream):
|
||||
async def test_known_size_does_not_tolerate_partial(tmp_path, fake_stream):
|
||||
"""已知期望大小时要求精确相等——95% 容差会放行截断的坏文件。"""
|
||||
fake_stream['resp'] = _FakeResp([b'x' * 95])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
with pytest.raises(DownloadException):
|
||||
podcast_svc._podcast_stream_download('http://u', target, 100, threading.Event())
|
||||
await podcast_svc._podcast_stream_download('http://u', target, 100, threading.Event())
|
||||
|
||||
|
||||
# ============ 上游与取消 ============
|
||||
|
||||
|
||||
def test_upstream_error_status_raises(tmp_path, fake_stream):
|
||||
async def test_upstream_error_status_raises(tmp_path, fake_stream):
|
||||
fake_stream['resp'] = _FakeResp([], status=404)
|
||||
target = tmp_path / 'ep.mp3'
|
||||
with pytest.raises(DownloadException) as ei:
|
||||
podcast_svc._podcast_stream_download('http://u', target, 100, threading.Event())
|
||||
await podcast_svc._podcast_stream_download('http://u', target, 100, threading.Event())
|
||||
assert '上游返回 404' in str(ei.value)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_cancel_before_first_chunk(tmp_path, fake_stream):
|
||||
async def test_cancel_before_first_chunk(tmp_path, fake_stream):
|
||||
"""取消已置位:抛 DownloadCanceled,不发布 target。"""
|
||||
cancel = threading.Event()
|
||||
cancel.set()
|
||||
fake_stream['resp'] = _FakeResp([b'x' * 10])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
with pytest.raises(DownloadCanceled):
|
||||
podcast_svc._podcast_stream_download('http://u', target, 10, cancel)
|
||||
await podcast_svc._podcast_stream_download('http://u', target, 10, cancel)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_cancel_midway_leaves_part_file(tmp_path, fake_stream):
|
||||
async def test_cancel_midway_leaves_part_file(tmp_path, fake_stream):
|
||||
"""下载中途取消:.part 残留(不删)——改名不发生,下次同名下载直接覆盖。"""
|
||||
cancel = threading.Event()
|
||||
fake_stream['resp'] = _FakeResp(_CancelAfterFirstChunk(cancel))
|
||||
target = tmp_path / 'ep.mp3'
|
||||
with pytest.raises(DownloadCanceled):
|
||||
podcast_svc._podcast_stream_download('http://u', target, 20, cancel)
|
||||
await podcast_svc._podcast_stream_download('http://u', target, 20, cancel)
|
||||
assert not target.exists()
|
||||
assert target.with_name('ep.mp3.part').exists(), '既有行为:取消时 .part 留存'
|
||||
|
||||
@@ -189,7 +203,7 @@ def test_cancel_midway_leaves_part_file(tmp_path, fake_stream):
|
||||
# ============ 进度消息 ============
|
||||
|
||||
|
||||
def test_progress_written_for_running_single_task(tmp_path, fake_stream, task_table):
|
||||
async def test_progress_written_for_running_single_task(tmp_path, fake_stream, task_table):
|
||||
"""running 的单集任务:写 msg,并按字节级口径写 live_percent。
|
||||
|
||||
单块即完成 → 百分比被 min(99, ...) 封在 99(进度条不预支 100%,收尾靠终态)。
|
||||
@@ -197,24 +211,25 @@ def test_progress_written_for_running_single_task(tmp_path, fake_stream, task_ta
|
||||
task = task_table()
|
||||
fake_stream['resp'] = _FakeResp([b'x' * 100])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
podcast_svc._podcast_stream_download('http://u', target, 100, threading.Event(),
|
||||
task_id=task['id'], progress_label='下载中')
|
||||
await podcast_svc._podcast_stream_download('http://u', target, 100, threading.Event(),
|
||||
task_id=task['id'], progress_label='下载中')
|
||||
assert '下载中' in task['msg'] and '99%' in task['msg']
|
||||
assert task['live_percent'] == 99.0
|
||||
|
||||
|
||||
def test_progress_not_written_for_finished_task(tmp_path, fake_stream, task_table):
|
||||
async def test_progress_not_written_for_finished_task(tmp_path, fake_stream, task_table):
|
||||
"""已终态的任务不再被写进度(避免覆盖终态消息),但下载照常完成。"""
|
||||
task = task_table(status='done', msg='原消息')
|
||||
fake_stream['resp'] = _FakeResp([b'x' * 50])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
assert podcast_svc._podcast_stream_download('http://u', target, 50, threading.Event(),
|
||||
task_id=task['id']) == 50
|
||||
assert await podcast_svc._podcast_stream_download(
|
||||
'http://u', target, 50, threading.Event(), task_id=task['id']) == 50
|
||||
assert task['msg'] == '原消息'
|
||||
|
||||
|
||||
def test_progress_skipped_without_task_id(tmp_path, fake_stream):
|
||||
async def test_progress_skipped_without_task_id(tmp_path, fake_stream):
|
||||
"""task_id 为空时只下载不写任务表(不得因缺 id 而报错)。"""
|
||||
fake_stream['resp'] = _FakeResp([b'x' * 50])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
assert podcast_svc._podcast_stream_download('http://u', target, 50, threading.Event()) == 50
|
||||
assert await podcast_svc._podcast_stream_download(
|
||||
'http://u', target, 50, threading.Event()) == 50
|
||||
Reference in new issue
Block a user