空任务类型在提交时被拒绝,任务还没有 ID;另一项合法任务在计算中故障,已经拥有 ID,最后状态应是 failed 而不是 succeeded。只用一层 except Exception 给两种情况返回“任务失败”,会抹掉有无任务记录、错误发生位置和诊断上下文之间的差异。

tasklab 保持 14 已确定的任务状态入口:输入校验发生在提交前,执行失败才在该任务中记录错误。这里的故障由受信任的实验参数触发,不向任务文本开放任意代码执行。

提交失败与执行失败不共用一个终态

submit(store, "", "invalid") 返回的是 ValueError,任务表仍只有前面已经提交的合法任务,它仍是 queued。在此时写一个 failed 任务,等于给未经接受的输入发了任务 ID,还会破坏先前的“错误输入不推进编号”不变量。

另一个合法任务 ID task-0002 被送进 run(..., fail_at="compute")。受控失败先抛出由 ValueError 引起的 RuntimeError("synthetic computation failure");应用在计算范围捕获该异常,写入 failed、保留简短错误文本,随后抛出 TaskExecutionError,以原异常作为直接原因。任务的 result 保持 None,再次执行失败任务会被拒绝。这不会把短暂的 running 误写成成功结果。

实际异常关系是 TaskExecutionError.__cause__ → RuntimeError.__cause__ → ValueError。Python 3.12 raise 语句对 raise ... from ... 的链路有明确规则;traceback 模块能格式化这些异常。实验将未经删减的错误链打印到标准错误,既能检查根因,又不需要在返回给调用方的简短 error 字段里储存整段机器路径与堆栈。对外发布时还要控制路径和敏感信息,本实验只使用合成输入。

捕获范围本身也是合同

当前执行错误捕获范围限于词频计算和受控故障。参数 fail_at="unknown" 在修改任务状态前就被拒绝,任务仍为 queued;未知任务 ID 的 KeyError 也不会被伪装成某个存在任务的计算失败。KeyboardInterrupt 等不属于 Exception 的强制中断不会走上述捕获路径,因此不能声称所有退出都会执行 failed 写入;35 还需对进程强制终止及重启恢复进行独立实验。

另一方面,failed 只表示应用记录了这项计算的失败,不说明未来外部副作用已被撤销,也不保证文件句柄已关闭。16 会为正常、异常与提前停止的资源持有者建立可验证的退出作用域。

在 examples/python-language/ 下运行:

1
2
PYTHONPATH=src /usr/bin/python3.12 experiments/15_errors.py
PYTHONPATH=src /usr/bin/python3.12 -m unittest discover -s tests -v

两条命令退出码均为 0;演示在标准输出记录 failed_state: "failed"、failed_result: null、前一任务 previous_state: "queued",同时在标准错误输出实际异常链。累计 46 条测试,新增三项分别验证输入拒绝、失败任务原因链和未知注入点不改变状态。examples/python-language/evidence/15/20261007-errors/ 保存完整原始输出与退出码,不能只看演示脚本自身的退出码 0 就说执行任务“成功”。

预测题

如果把 except Exception 放到 submit 外层,所有非法任务类型都返回 TaskExecutionError 并建立记录,会破坏哪些已有断言?

答案:会违反“输入未接受不分配 ID”“旧任务状态不变”“错误类型指明提交入口”的合同。00、05、07、15 的拒绝输入用例都提供了可核对的反例;执行故障必须由已经创建的任务持有终态。

变更题

若删掉 raise TaskExecutionError(...) from error 中的 from error,还足以从测试判断根因吗?

答案:当前断言明确检查 __cause__ 为 RuntimeError,其根因为 ValueError;删除显式原因将改变该链路和格式化后的诊断内容。应保留保存异常链与状态两类断言,而不只检查错误字符串是否包含 failed。错误改动未应用。

下一章将把词元生成器与文件句柄放在同一资源作用域里,验证提前退出不依赖垃圾回收。系列导航只列已生成章节。

参考资料