From 4295f20fd696ca4c8f6ecb609938ced40b904e79 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 24 Aug 2026 20:46:35 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E6=B8=B2=E6=9F=93=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E7=9B=AE=E5=BD=95=E4=B8=8D=E5=9C=A8render=5F?= =?UTF-8?q?plan=E4=B8=AD=E6=8F=90=E5=89=8D=E6=B8=85=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E7=94=B1=E8=B0=83=E7=94=A8=E6=96=B9=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E5=90=8E=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render_plan 的 finally 块在 _do_render 返回后立即清理临时目录, 但 generation.py 的 _upload_and_record 还需要读取其中的 output_path 进行 OSS 上传,导致 FileNotFoundError。 修复方案: - RenderAdapterResult 增加 temp_dir 字段 - render_plan 成功时将 temp_dir 转移给 result,并设 temp_dir=None 阻止 finally 清理;失败时 finally 仍正常清理 - _render_from_edit_plan 返回 temp_dir - generation.py 上传代码包裹在 try/finally 中,finally 清理临时目录 - 新增4个回归测试 --- .../worker/video_processing/render_adapter.py | 8 ++- apps/worker/worker_app/tasks/generation.py | 66 +++++++++++-------- tests/unit/test_temp_dir_cleanup_race.py | 62 +++++++++++++++++ 3 files changed, 109 insertions(+), 27 deletions(-) create mode 100644 tests/unit/test_temp_dir_cleanup_race.py diff --git a/apps/worker/video_processing/render_adapter.py b/apps/worker/video_processing/render_adapter.py index 9d4a48808..295d32464 100755 --- a/apps/worker/video_processing/render_adapter.py +++ b/apps/worker/video_processing/render_adapter.py @@ -84,6 +84,7 @@ class RenderAdapterResult: cover_candidates: list[dict] | None = ( None # 封面候选帧 [{"image_url": "...", "frame_time": 5.0, "storage_key": "..."}] ) + temp_dir: str | None = None # 渲染临时目录,成功时由调用方清理,失败时由 finally 清理 def __post_init__(self): if self.rendered_clip_ids is None: @@ -200,7 +201,7 @@ class RenderAdapter: self._report_progress(progress_cb, 35.0, "准备 BGM 音频") # 3~6. 统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传) - return self._do_render( + result = self._do_render( plan=plan, clips=ready_clips, asset_path_map=asset_path_map, @@ -212,6 +213,11 @@ class RenderAdapter: failed_clip_ids=failed_clip_ids, voiceover_audio_path=voiceover_audio_path, ) + # 成功时将临时目录所有权转移给调用方,阻止 finally 清理 + if result.success and temp_dir: + result.temp_dir = temp_dir + temp_dir = None # 阻止 finally 块清理 + return result except subprocess.CalledProcessError as exc: stderr_text = (exc.stderr or "").strip() diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index 7d1a19261..4e866227a 100644 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -535,11 +535,11 @@ def _render_from_edit_plan( task_id: str, source_edit_plan_id: str, task_info: dict, -) -> tuple[Path, float, list[dict] | None]: +) -> tuple[Path, float, list[dict] | None, str | None, str | None]: """从 EditPlan 数据库记录直接渲染(不再内存重建clips)。 Returns: - (output_path, render_duration, cover_candidates, voiceover_path) + (output_path, render_duration, cover_candidates, voiceover_path, temp_dir) """ from video_processing.render_adapter import RenderAdapter from worker_app.db import SessionLocal @@ -578,8 +578,9 @@ def _render_from_edit_plan( output_path = result.output_path cover_candidates = getattr(result, "cover_candidates", None) + render_temp_dir = getattr(result, "temp_dir", None) - return output_path, result.duration, cover_candidates, voiceover_path + return output_path, result.duration, cover_candidates, voiceover_path, render_temp_dir finally: db.close() @@ -678,6 +679,7 @@ def generate_video(self, task_id: str) -> dict: source_edit_plan_id = task_info.get("source_edit_plan_id", "") if source_edit_plan_id: voiceover_tmp_path: str | None = None + render_temp_dir: str | None = None try: logger.info( "[task_id=%s] 使用 EditPlan 数据库路径渲染: plan_id=%s", @@ -690,10 +692,12 @@ def generate_video(self, task_id: str) -> dict: gen_task.append_log("渲染模式", "从草稿数据渲染(与预览一致)") _flush_logs(task_id, gen_task) - output_path, render_duration, cover_candidates, voiceover_tmp_path = _render_from_edit_plan( - task_id=task_id, - source_edit_plan_id=source_edit_plan_id, - task_info=task_info, + output_path, render_duration, cover_candidates, voiceover_tmp_path, render_temp_dir = ( + _render_from_edit_plan( + task_id=task_id, + source_edit_plan_id=source_edit_plan_id, + task_info=task_info, + ) ) if gen_task: @@ -702,28 +706,38 @@ def generate_video(self, task_id: str) -> dict: _update_task_progress(task_id, 80, "渲染完成") - # ── 4. 上传 OSS + 查重记录 ─────────────────────────────── + # ── 4. 上传 OSS + 查重记录(临时目录在 finally 中清理)─────── _update_task_progress(task_id, 85, "开始上传") - file_url, duration, file_size, video_count = _upload_and_record( - task_id=task_id, - output_path=output_path, - project_id=project_id, - batch_id=batch_id, - editing_mode=editing_mode, - user_id=user_id, - video_name=task_info.get("video_title", ""), - ) - - if gen_task: - gen_task.append_log( - "OSS上传", - f"上传成功, 大小={file_size}", - file_size=file_size, - file_url=file_url, + try: + file_url, duration, file_size, video_count = _upload_and_record( + task_id=task_id, + output_path=output_path, + project_id=project_id, + batch_id=batch_id, + editing_mode=editing_mode, + user_id=user_id, + video_name=task_info.get("video_title", ""), ) - _flush_logs(task_id, gen_task) - _update_task_progress(task_id, 95, "上传完成") + if gen_task: + gen_task.append_log( + "OSS上传", + f"上传成功, 大小={file_size}", + file_size=file_size, + file_url=file_url, + ) + _flush_logs(task_id, gen_task) + + _update_task_progress(task_id, 95, "上传完成") + finally: + # 清理渲染临时目录(无论上传成功与否都清理) + if render_temp_dir: + import shutil + + shutil.rmtree(render_temp_dir, ignore_errors=True) + logger.info( + "[task_id=%s] 渲染临时目录已清理: %s", task_id, render_temp_dir + ) # ── 4.5 封面帧持久化 ──────────────────────────────────────────── try: diff --git a/tests/unit/test_temp_dir_cleanup_race.py b/tests/unit/test_temp_dir_cleanup_race.py new file mode 100644 index 000000000..50be30524 --- /dev/null +++ b/tests/unit/test_temp_dir_cleanup_race.py @@ -0,0 +1,62 @@ +"""回归测试:渲染产物临时目录不在 render_plan 中提前清理。 + +根因:render_plan 的 finally 块在返回前清理了临时目录, +但 generation.py 还需要访问其中的文件进行 OSS 上传。 +修复:将清理责任交给调用方(generation.py),render_plan 只在失败时清理。 +""" + +import ast + + +def test_render_adapter_result_has_temp_dir_field(): + """RenderAdapterResult 包含 temp_dir 字段""" + with open("apps/worker/video_processing/render_adapter.py") as f: + source = f.read() + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name == "RenderAdapterResult": + for item in node.body: + if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + if item.target.id == "temp_dir": + return + raise AssertionError("RenderAdapterResult 缺少 temp_dir 字段") + + +def test_render_plan_does_not_cleanup_on_success(): + """render_plan 成功时不在 finally 中清理临时目录(通过将 temp_dir 置为 None)""" + with open("apps/worker/video_processing/render_adapter.py") as f: + source = f.read() + + # 成功路径必须将 temp_dir 置为 None,以阻止 finally 清理 + assert "temp_dir = None" in source, ( + "render_plan 成功时应将 temp_dir 置为 None 以阻止 finally 清理" + ) + + +def test_render_plan_passes_temp_dir_to_result(): + """render_plan 将 temp_dir 传递给返回结果""" + with open("apps/worker/video_processing/render_adapter.py") as f: + source = f.read() + + assert "result.temp_dir = temp_dir" in source, ( + "render_plan 应将 temp_dir 设置到 result 上" + ) + + +def test_generation_cleans_up_temp_dir(): + """generation.py 在上传完成后清理临时目录""" + with open("apps/worker/worker_app/tasks/generation.py") as f: + source = f.read() + + # 验证 _render_from_edit_plan 返回 temp_dir + assert "render_temp_dir" in source, "generation.py 应接收 render_temp_dir" + + # 验证有清理逻辑(shutil.rmtree(render_temp_dir...) + assert "rmtree(render_temp_dir" in source, "generation.py 应清理 render_temp_dir" + + # 验证清理发生在上传之后(通过查找顺序) + upload_pos = source.find("_upload_and_record") + cleanup_pos = source.find("rmtree(render_temp_dir") + assert upload_pos > 0 and cleanup_pos > upload_pos, ( + "清理临时目录应在 _upload_and_record 之后执行" + ) -- 2.54.0 From 6b67bb948b256c5452739ee8d37ec0dfea86008c Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 24 Aug 2026 20:59:13 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E5=B0=86try=E5=9D=97=E4=B8=8A?= =?UTF-8?q?=E7=A7=BB=E5=88=B0render=5Ftemp=5Fdir=E8=B5=8B=E5=80=BC?= =?UTF-8?q?=E4=B9=8B=E5=90=8E=EF=BC=8C=E7=A1=AE=E4=BF=9D=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E6=97=B6=E4=B9=9F=E6=B8=85=E7=90=86=E4=B8=B4=E6=97=B6=E7=9B=AE?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/worker/worker_app/tasks/generation.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index 4e866227a..e40f74e52 100644 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -699,16 +699,16 @@ def generate_video(self, task_id: str) -> dict: task_info=task_info, ) ) - - if gen_task: - gen_task.append_log("渲染", f"渲染完成, 时长={render_duration:.1f}s") - _flush_logs(task_id, gen_task) - - _update_task_progress(task_id, 80, "渲染完成") - - # ── 4. 上传 OSS + 查重记录(临时目录在 finally 中清理)─────── - _update_task_progress(task_id, 85, "开始上传") + # 从这里开始,render_temp_dir 已赋值,必须确保异常时也能清理 try: + if gen_task: + gen_task.append_log("渲染", f"渲染完成, 时长={render_duration:.1f}s") + _flush_logs(task_id, gen_task) + + _update_task_progress(task_id, 80, "渲染完成") + + # ── 4. 上传 OSS + 查重记录 ─────────────────────────────── + _update_task_progress(task_id, 85, "开始上传") file_url, duration, file_size, video_count = _upload_and_record( task_id=task_id, output_path=output_path, @@ -730,7 +730,7 @@ def generate_video(self, task_id: str) -> dict: _update_task_progress(task_id, 95, "上传完成") finally: - # 清理渲染临时目录(无论上传成功与否都清理) + # 清理渲染临时目录(无论后续步骤成功与否都清理) if render_temp_dir: import shutil -- 2.54.0 From c9fb83a88c8b9b66cdc8b5f0b3ccf1a0f3a90d58 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 24 Aug 2026 13:04:19 +0000 Subject: [PATCH 3/3] style: auto-format with black + isort + prettier [skip ci-format-check] --- apps/worker/worker_app/tasks/generation.py | 4 +--- tests/unit/test_temp_dir_cleanup_race.py | 12 +++--------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index e40f74e52..8dbbde32b 100644 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -735,9 +735,7 @@ def generate_video(self, task_id: str) -> dict: import shutil shutil.rmtree(render_temp_dir, ignore_errors=True) - logger.info( - "[task_id=%s] 渲染临时目录已清理: %s", task_id, render_temp_dir - ) + logger.info("[task_id=%s] 渲染临时目录已清理: %s", task_id, render_temp_dir) # ── 4.5 封面帧持久化 ──────────────────────────────────────────── try: diff --git a/tests/unit/test_temp_dir_cleanup_race.py b/tests/unit/test_temp_dir_cleanup_race.py index 50be30524..78cbbc3d9 100644 --- a/tests/unit/test_temp_dir_cleanup_race.py +++ b/tests/unit/test_temp_dir_cleanup_race.py @@ -28,9 +28,7 @@ def test_render_plan_does_not_cleanup_on_success(): source = f.read() # 成功路径必须将 temp_dir 置为 None,以阻止 finally 清理 - assert "temp_dir = None" in source, ( - "render_plan 成功时应将 temp_dir 置为 None 以阻止 finally 清理" - ) + assert "temp_dir = None" in source, "render_plan 成功时应将 temp_dir 置为 None 以阻止 finally 清理" def test_render_plan_passes_temp_dir_to_result(): @@ -38,9 +36,7 @@ def test_render_plan_passes_temp_dir_to_result(): with open("apps/worker/video_processing/render_adapter.py") as f: source = f.read() - assert "result.temp_dir = temp_dir" in source, ( - "render_plan 应将 temp_dir 设置到 result 上" - ) + assert "result.temp_dir = temp_dir" in source, "render_plan 应将 temp_dir 设置到 result 上" def test_generation_cleans_up_temp_dir(): @@ -57,6 +53,4 @@ def test_generation_cleans_up_temp_dir(): # 验证清理发生在上传之后(通过查找顺序) upload_pos = source.find("_upload_and_record") cleanup_pos = source.find("rmtree(render_temp_dir") - assert upload_pos > 0 and cleanup_pos > upload_pos, ( - "清理临时目录应在 _upload_and_record 之后执行" - ) + assert upload_pos > 0 and cleanup_pos > upload_pos, "清理临时目录应在 _upload_and_record 之后执行" -- 2.54.0