Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/sessions/261012-issue-fixes/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 上传批次修复设计-note(2026-10-12 批,已批准的降级最小契约)

来源:2026-10-12 工程审查报告(第一性原理审计),主脑已批准其中 A/B/D 的降级方案。
本 PR 只实现 B(#204);A、D 契约随批次另卡落地;C 延后,不在本批实现。

## B(本卡 #204):接收失败不得短路请求槽与未移交在途资源释放

缺陷:`receive_upload` 的 finally 按「写错误记录 → 目录清理 → 预算 → inflight →
slot」顺序执行,任何一步抛异常即跳过其后全部释放。`receive_concurrency`
配置为 1 时(示例配置为 null 时入口 fail-closed 不启用上传,构造信号量的
fallback 1 不代表默认上传启用),写库或目录清理失败一次就让上传入口持续 503
直到进程重启(单点变砖)。
风险排序:slot ≫ inflight > 预算 > 目录(预算与 inflight 是内存账本、重启即清;
目录泄漏无自动回收保证——已核定定时清理仅对 receiving 记录 unlink,
不保证覆盖所有失败目录)。

契约(不变式轴):

| 条件 | request 槽 | 未移交 inflight | 临时目录/预算 | 错误可见性 |
| --- | --- | --- | --- | --- |
| 错误记录抛 OperationalError/rowcount ValueError,清理成功 | 释放 | 释放 | 目录移除、预算归零 | 原异常上抛(HTTP 500) |
| 目录清理失败、未移交 | 释放 | 释放 | 目录保留,其已占用预算同步保留(不假报磁盘已回收) | OSError 上抛(HTTP 500) |
| 正常受理(transferred=True) | 请求结束时释放 | worker 持有并在完成后释放,handler 不夺权 | 媒体不被 handler 删除,预算不由 handler 误清 | 不适用(202) |
| receiver acquire 失败 | 未持有即不释放 | 未取得 | 未取得 | 既有 503 行为不变 |

实现:只调整 uploads.py 现有 finally 控制流——错误记录包进独立 try/finally,
其 finally 内按 inflight → 目录清理(含 exists 复查)→ 预算执行,目录仍在则
预算刻意保留(目录可能随后被定时 sweep,但 reservation 偏保守保留至重启,
不为此加对账/回收机制);slot release 放最外层 finally。不新增重试、资源框架、生命周期
账本或错误协议;既有 header/body/receipt 语义不变。

## A(#226,另卡):cache 合并仅在非 failed 终态清 error

只允许 {generated, skipped_short, skipped_no_timeline, disabled} 终态清除旧
error;pending 与未触碰状态保留旧 error;若 caller 写 pending 则 writer 保留
旧诊断(该场景生产路径未证实,不作宣称)。另补「FAILED→pending 时
error 仍在」用例。

## D(#216,另卡):TMPDIR 指向 scratch 子目录

e2e 子服务 TMPDIR 改为 serverScratch/tmp 子目录(先创建),并在既有
/__e2e__/state 中断言 `tempfile.gettempdir()` 落在 scratch 之下。不引入
108 字节 socket 路径预算:API 走 TCP,未发现带路径名(bind 文件系统路径)
的 AF_UNIX 消费者,该预算缺乏真实需求。

## C(#212):延后

转义接受范围(企微 markdown_v2、飞书 lark_md 实认集合)与 heading 其余调用方
覆盖未验证,按现稿做有引入双重编码的风险,待实测后再立项。
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# upload-release(#204)进度存档

派发:dlg-20261012-112410-ebc035;分支 card/vta-261012-upload-release;base c7dc7fcb。

## 阶段:repairing

### 2026-10-12 第 1 段:批准设计落盘
- 本段结论:现场核查通过(worktree 干净、origin/main=base);已批准的 A/B/D 降级契约落 `docs/sessions/261012-issue-fixes/design.md`,本卡只实现 B(#204)。
- 关键决策:采用「错误记录独立 try/finally + inflight 提前到目录清理之前 + slot 放最外层 finally」结构;目录仍在时预算刻意保留(字节真实占盘,不假报回收)。
- 已否决方案:吞掉 writer 异常继续走(违反 fail-fast);为清理失败补重试/资源框架(超范围,无第二消费者)。
- 下一步唯一动作:新增 `tests/integration/test_upload_intake_cleanup.py`(复用既有真实 fixture),在原实现上验红(AssertionError 因资源未释放)。

### 2026-10-12 第 2 段:TDD 红验与最小修复
- 本段结论:新增回归 3 例(复用既有真实 upload_client/_post/_key 与真实 SQLite/TempFileManager/inflight/worker,只导入不改原文件)。原实现上行1红:`AssertionError: 接收目录必须已被清理`(writer 抛OperationalError 后目录/预算/inflight/槽全部泄漏,HTTP 500);行2红:`AssertionError: 未移交inflight必须已释放 assert 1 == 0`(目录 exists 复查抛OSError 后预算保留正确但 inflight/槽泄漏)。行3原实现即绿(移交路径未破坏)。修复后窄回归 4 文件 41 passed exit=0;新文件连续 5 次退出码 0,0,0,0,0。
- 关键决策:只重构 uploads.py 现有 finally——错误记录包独立 try/finally,inflight 提前到目录清理之前,slot 放最外层 finally;目录仍在则预算刻意保留(不假报回收);未新增重试/框架/错误协议。故障注入全部在 fixture 域(monkeypatch 实例方法),注入命中用调用计数证明、资源断言全部读真实状态。
- 已否决方案:延迟捕获再重抛 writer 异常(嵌套 try/finally 顺序即可达成,少一层状态);patch Path.unlink/shutil.rmtree 注入清理故障(目标不精确,clean_up_task 实例域更准)。
- 下一步唯一动作:最小变异红验(打回 writer-before-release 顺序),随后全量 make test + pyright。

### 2026-10-12 第 3 段:变异红验、全量与类型检查(收尾)
- 本段结论:变异红验成立——仅把错误记录打回释放前位置(writer-before-release),行1用例转红 `AssertionError: 接收目录必须已被清理`;还原该处后窄回归复绿 41 passed exit=0。全量 `TMPDIR=/tmp/vta-B-suite.QgGFIJ make test` 进度到 [100%]、退出码 0(收集约3908例,日志仓外 /tmp/vta-B-full-suite.log);新窄回归连续5次退出码 0,0,0,0,0。pyright:修复版与 base 版同为同一批 7 个既有错误(仅行号随 hunk 偏移),未新增类型错误。tests/README.md 的 B 接收回归命令清单跟改新文件(Scope-Follow)。
- 关键决策:tests/pytest-test-durations.json 仓内不存在且无消费者,不新造;全量用独立短 TMPDIR 隔离本机 AF_UNIX 环境噪声(#3825,非 #216)。
- 已否决方案:等远端 draft CI 再收尾(卡面明确不作执行器完成条件)。
- 下一步唯一动作:推送文档跟改提交,交主脑验收。

### 2026-10-12 第 4 段:feedback 补齐(dlg-20261012-120624-077823)
- 本段结论:补 rowcount ValueError 真实 writer 路径回归(不变式行1第二分支):fixture 域包装 set_local_upload_receiving_path——先真实调用原方法、再真实 SQLite DELETE 删记录,令 finally 内真实 set_local_upload_error 的 UPDATE rowcount=0 自身抛 ValueError;不替身冒充 writer。测试内 sanity 直接调真实 set_local_upload_error 断言 ValueError(注入有效性同状态证明);HTTP 500(非 400 静默)、目录清/预算零/inflight 0/槽可再获取/后继 202 全部锁死。变异红验:仅把错误记录打回释放前位置,新用例转红 AssertionError(见报告原文);还原后复绿。design 三处事实收紧(目录无自愈保证、pending 场景不作生产宣称、AF_UNIX 预算依据改写)。
- 关键决策:注入点选 set_local_upload_receiving_path(writer 前最后一个真实库调用,其后到 writer 之间产品代码无其他查库路径),注入自限一次只命中被测请求、不波及后继;首版全局包装误伤后继请求已修正。
- 已否决方案:替身直接 raise ValueError(feedback 明确不接受);wrapper 里 patch rowcount(那不是真实 writer 路径)。
- 下一步唯一动作:commit+push,更新报告给新最终 SHA 等主脑冻结独立 review。

### 2026-10-12 第 5 段:transfer 账本不被 handler 误释放回归(dlg-20261012-135924-bf492d)
- 本段结论:Opus 验收前复核通过、生产代码零改动。新增 worker 消费起点回归:包装 transcription.process_transcription 入口采样 (task_id, inflight.size, task_id in _upload_reserved_bytes) 后调用 fixture 原 fake,主线程经 worker_results.get 确认采样后断言 (该task, 1, True)。变异红验两处各一次:inflight.release 移出 not transferred → AssertionError assert 0 == 1;release_upload_temp 移出 not transferred → AssertionError assert False is True;均 edit 逆向精确还原,uploads.py 对 HEAD diff 为空。新用例连续 5 次绿(0,0,0,0,0),四文件窄测 43 passed(42+1)。design 缺陷段改「receive_concurrency 配置为 1 时」,实现段预算保留精确注明(目录可能随后被 sweep、reservation 偏保守保留至重启,不加对账/回收机制)。
- 关键决策:采样在 fixture fake 外层包装(复用 fixture 真 asyncio.Queue + 同 loop consumer 的既有时序),断言全部在 pytest 主线程;未新增事件开关/通用 helper/fixture 框架。
- 已否决方案:在后台 worker 内 assert(异常只进日志或 queue 超时,不可靠);为 sampling 新建独立 fixture(无第二消费者)。
- 下一步唯一动作:push 后交主脑冻结最终 H0 独立 review。
29 changes: 29 additions & 0 deletions docs/sessions/261012-issue-fixes/reviews/upload-release-verdict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!-- delegate-outcome: succeeded -->
## H0 独立审查结论

审查对象固定为 `c7dc7fcbb1727057ecf2c4d8c3f38e34d3136f72..db60c385410030fc2e34def510c3c8ab1e0e5117`,风险档位 personal。

failure-visibility: clean

**结论:通过,不阻断交付。Finding:无。** 六项 spec 的资源归属和错误可见性均由当前实现及下列状态断言覆盖;改动只调整已有 `finally` 控制流并复查目录存在性。

## Spec 逐项核对

1. **错误记录失败仍显式 HTTP 500,并清理未移交资源。** `uploads.py:399-420` 在 writer 异常后仍执行 inflight、目录、预算与 request slot 的清理;`test_upload_intake_cleanup.py:29-64` 注入 `OperationalError`,检查真实媒体目录、预算、inflight、slot 和后继 202;`:67-132` 删除真实 SQLite 行后调用原 writer,验证 rowcount=0 的 `ValueError`、HTTP 500 及相同资源终态。
2. **目录未清理时释放 slot/inflight、保留预算并报错。** `uploads.py:406-420` 先释放 inflight,清理后用 `task_dir.exists()` 检查,目录仍在时抛 `OSError`,外层 finally 仍释放 request slot,预算释放被跳过。`test_upload_intake_cleanup.py:135-163` 注入清理返回但不删文件,检查 HTTP 500、真实媒体文件仍在、保留 reservation、inflight 为零且 slot 可再次取得。
3. **成功移交后 handler 只归还 request slot,worker 持有媒体及其资源。** `uploads.py:359,407-420` 设置 `transferred=True` 后跳过 handler 的目录/inflight/预算清理并释放 slot。`test_upload_intake_cleanup.py:166-212` 经真实 HTTP、RuntimeContext、SQLite 与 runtime queue 检查回执、文件字节、worker 收到的字节、worker 开始时 inflight/预算仍持有及消费结束后的释放;既有 `test_upload_intake.py:195-285` 还覆盖连续受理。实际消费者在 `transcription.py:604-658,748-758`。
4. **receiver acquire 失败不释放未持有槽且不读 body。** `uploads.py:263-276` 在进入 finally 管辖区前对 acquire 失败返回既有 503;因此不会执行 slot release。既有 `test_upload_intake.py:425-442` 检查 503 和 body 未消费。
5. **测试保留真实边界且断言实际状态。** 新用例复用 `test_upload_intake.py:108-193` 的 FastAPI/TestClient、SQLite、RuntimeContext、TempFileManager、raw producer bytes 与 queue worker fixture。writer、rowcount 和清理故障的注入均有命中/异常判据;目录、文件字节、DB 行、reservation、inflight、slot 与 queue/worker 状态由实体断言,不以调用次数代替。
6. **文档边界与反熵。** `design.md:14-15,27-30` 明确目录无全面自动回收保证及 reservation 可能保留至重启;`:41-44` 明确不加入无 AF_UNIX 消费者依据的 108 字节预算。文档未宣称生产 pending 事故或所有目录 24 小时自愈,也未升级为 P1。diff 无新运行时抽象、状态、schema、配置、依赖、重试、fallback 或防御式 catch;header/body/receipt/DB schema 逻辑未改。

## 验证

- OCR:envelope `status=reviewed`、`cli_status=complete`、`coverage=complete`、`reason=primary_selected`、0 findings;secondary verify 因无 finding 显示 skipped,不影响主扫描已完成的状态。
- 固定 H0 窄测:`PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src:. python -m pytest -q -p no:cacheprovider tests/integration/test_upload_intake_cleanup.py tests/integration/test_upload_intake.py tests/unit/test_upload_routes.py tests/unit/test_upload_dispatch.py`,在 scratch worktree 退出码 0(43 项)。另核实 cwd 与 git root、route/test 模块导入路径均在 H0 scratch;解释器 Python 3.11.15。
- Base 红验:只向 base scratch 拷入 H0 的 `test_upload_intake_cleanup.py`,运行两个 writer 用例;预期退出码 1,2 项均因未清除的真实媒体文件断言失败。rowcount 用例在相同 SQLite 状态下另以真实 writer `pytest.raises(ValueError)` 确认故障路径,非导入/配置错误。
- H0 时序变异:分别把 inflight 与预算释放移出 `not transferred` 后,`test_worker_consumption_starts_while_intake_resources_still_held` 各以退出码 1 在 pytest 主线程的真实状态断言失败(分别观测到 `0 != 1`、`False is not True`);两次均从 H0 恢复源码并核对 diff 为空。
- H0 worker 时序用例连续五次退出码 `0,0,0,0,0`;冻结 diff 的 `git diff --check` 退出码 0。

## 未验证边界

生产上传开关、并发值与线上事故未实测,因此不作生产触发或 P1 断言。派发时主干基线查询不可用;本轮未跑 CI,继承红/新红无法判定。
43 changes: 28 additions & 15 deletions src/video_transcript_api/api/routes/uploads.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,21 +390,34 @@ async def receive_upload(
failure_code = "queue_full"
raise _reject(503, failure_code, "转录队列已满,上传未正式受理") from exc
finally:
if body_started and not transferred:
failure_code = failure_code or "receive_incomplete"
cache_manager.set_local_upload_error(upload_id, failure_code)
logger.warning("UPLOAD_INTAKE_REJECTED reason={} status=failed", failure_code)
if not transferred:
if task_dir is not None:
temp_manager.clean_up_task(task_id)
if task_dir.exists():
raise OSError(f"failed to remove untransferred upload directory: {task_dir}")
if budget_acquired:
runtime.release_upload_temp(task_id)
if inflight_acquired:
inflight.release("transcription", task_id)
if receiver_acquired:
runtime.upload_receiver_slots.release()
# Ownership: a failure in one cleanup step must not short-circuit the
# remaining releases. The receiver slot is released by the outermost
# finally; inflight goes before directory cleanup; the temp budget is
# intentionally kept while the untransferred directory still occupies
# disk (no false reclamation). Errors still propagate as HTTP 500.
try:
try:
if body_started and not transferred:
failure_code = failure_code or "receive_incomplete"
cache_manager.set_local_upload_error(upload_id, failure_code)
logger.warning(
"UPLOAD_INTAKE_REJECTED reason={} status=failed", failure_code
)
finally:
if not transferred:
if inflight_acquired:
inflight.release("transcription", task_id)
if task_dir is not None:
temp_manager.clean_up_task(task_id)
if task_dir.exists():
raise OSError(
f"failed to remove untransferred upload directory: {task_dir}"
)
if budget_acquired:
runtime.release_upload_temp(task_id)
finally:
if receiver_acquired:
runtime.upload_receiver_slots.release()


@router.get("/by-idempotency-key/{key}")
Expand Down
Loading
Loading