TT Lab
开始
学习 学习路径 课程

不可撤销的变更

庆典已取消,完成按钮却还是红色

在 TT Lab 中继续学习

目标

编写一个协调器,接收太空庆典的变更请求,并把它连接到批准、执行、观测、报告、独立补偿。用真实的数据库和 CLI 确认:即使 SQL 操作是对的,协调器出错也会造成的事故。

为什么重要

请在完成前面的批准版本、补偿、证据报告课程之后再进行。需要会使用 Python 函数、dict、list、异常、JSON、CLI。这次不把已经学过的数据库代码全部重写,而是组合提供的操作。预计 140 分钟,所以请在到期前用“+时间”延长。最长 180 分钟,会话结束后文件会消失。需要的代码请另外保存。

环境与产出物

产出物是 /root/change-capstone/coordinator.py。镜像里有 PostgreSQL 16、psycopg 3.2.3、Python 3,不需要额外安装、网络或权限。学员用 postgres 用户写入 /root。

提供的适配器是 /opt/lab/fixtures/change_capstone/operations.py 的 Operations(dsn)。同一文件夹的 revision_ops.py、compensation_ops.py、evidence_ops.py 是从前面三个单元的累积答案生成的库。可以阅读,其中不包含本次协调器的答案。评分器会在本地 labdb 的 UUID 临时 schema 中创建虚构的订单、批准、审计、补偿表,并且只清理自己建立的 schema。使用传入的 ops、DSN、输出路径,不修改 public 或生产数据库。

请求的准确格式

所有请求都是 dict 本身,按 action 只允许下面的键。

action 是对应的英文 str 其中之一。change_id、tenant、undo_id 是 str 本身,ASCII 英文、数字、下划线、连字符 1–64 个字符。apply、undo 的 approved 只允许真正的 True,拒绝 1、字符串、False。这个标志不是认证或签名,而是虚构业务的输入契约。

ids 是装有类型恰好为 int 的 1–2147483647 的 list 本身,有 1–16 个,规范化为无重复的升序。targets 是 1–16 个只有 id、revision、qty 三个键的 dict 本身所组成的 list。id 的范围同上,revision 是类型恰好为 int 的 0–2147483646,qty 是类型恰好为 int 的 1–1000。不把 bool 当作整数接受。禁止重复 ID,并生成按 ID 排序的深拷贝。所有函数都不修改原请求。

reason 是 1–200 个字符的 str 本身,禁止首尾空白、控制字符 U+0000–001F 和 U+007F。report_path 是 str 本身的绝对路径,是已有的、自己拥有的目录里的普通文件位置。允许文件不存在,但拒绝符号链接、目录、相对路径,也不自动创建父目录。以可信的、自己拥有的文件夹为前提,这份契约并不能防住所有替换父路径的攻击。

提供的操作契约

每次调用都会打开并清理自己的数据库连接。协调器不要用外部事务把它们包起来,也不要另外关闭连接。不要把值、ID、路径写死,也不要重新实现 SQL。

评分时也会提供让每个方法抛出 Exception 或做出契约之外返回的替身。只把校验过的请求传过去之后的操作错误,归类为各阶段的状态。这并不是让你捕获 KeyboardInterrupt、SystemExit 这类全部的 BaseException。ops.Conflict 与学员自己的 Conflict 是不同的类。

协调结果与 CLI 契约

apply 的 dispatch 结果有 change_id、execution、observed、report、sha256 五个键。execution 是 applied、replayed、conflict、uncertain 之一,不因观测结果而抹掉或改变。observed 是 complete、incomplete、hold、unknown、mismatch、not_checked。report 是 saved、failed、not_attempted,保存失败或未尝试时 sha256=None。

preview 的 dispatch 只返回 approved=False 的 apply 提议。report 的 dispatch 返回 change_id 和 report 函数结果这三个键,undo 的 dispatch 返回 compensate 结果的三个键。dispatch 要先校验请求,并遵守各动作的边界。即使是 applied,只要观测是 unknown,就不尝试报告;即使是 replayed,如果观测是 hold,也可以原样报告那个结果。

decode(raw) 把类型恰好为 bytes 的 1–65536 个字节按 UTF-8 JSON 解析,并返回经过 validate 的请求。重复键、非有限常量、被截断的 JSON、错误的 UTF-8、过大的输入是 ValueError。main(argv,ops) 只接收一个请求文件路径,最多读取 65537 个字节,然后 decode→dispatch。成功时,把一行结果 JSON 输出到 stdout 并返回 0。如果参数、读取、解析、处理出现 Exception,则返回一行 {"error":"request_failed"} 和 2,不输出 DSN、原始异常和 traceback。

以脚本运行时,要把 /opt/lab/fixtures/change_capstone 加入 sys.path,并读取 operations.Operations。用环境变量 LABHUB_CAPSTONE_DSN 里的仅限本地的 DSN 创建 Operations,并以 main(sys.argv[1:],ops) 的返回值退出。这个环境变量在正常的 CLI 验证中一定会提供。作为模块被 import 时,不能运行 CLI。CLI 的代码 0 是请求处理成功,并不意味着业务完成。

观测和报告材料的采集是另外的调用。observed 是之前的观测,report 是之后文件的保存状态,不保证两次调用在同一时点。不要仅因为 saved,就假定文件里的业务结论是 complete。请另外确认报告的实际观测时间和分类。

步骤

  1. 只把明确的批准当作输入——实现 Conflict(Exception) 和 validate(value)。检查下面按动作区分的准确的键、类型、范围,并返回把列表排序后的深拷贝。apply、undo 只有在 approved 是真正的 True 时才允许,错误是 ValueError。
  2. 不自动批准预览——preview(value,ops) 校验 preview 请求,并以排序后的 ids 调用一次 ops.preview(tenant,...)。校验并排序收到的 targets,核对 ID 列表是否与请求完全相同。不同则是 Conflict。相同则返回 action=apply、原 change_id、tenant、report_path、观测的 targets、approved=False 的提议 dict。
  3. 区分同一个 ID 的不同批准——compare(value,observation) 校验 apply 请求。observation 是 None 则返回 unknown。否则,在下面的观测格式中,核对 change_id、tenant、规范化 targets 是否与请求相同,不同则是 Conflict。相同则原样返回 report.decision 的 complete、incomplete、hold。其他顶层键或未知判定是 Conflict。
  4. 把执行响应和结果不明分开——execute(value,ops) 校验 apply 请求之后,恰好调用一次 ops.apply(change_id,tenant,targets)。True 是 applied,False 是 replayed,ops.Conflict 是 conflict,其他 Exception 或非 bool 的返回是 uncertain。调用之外的校验错误原样传递。
  5. 观测已确认的记录,把不确定性暴露出来——settle(value,ops,outcome) 校验 apply 请求和四种执行结果值。如果是 conflict,不调用 observe,observed=not_checked。其他情况调用一次 ops.observe(change_id),并用 compare 确定 observed。查询的 Exception 是 unknown,比较中的 Conflict、ValueError、KeyError、TypeError 是 mismatch。返回 change_id、execution=原 outcome、observed 的 dict。
  6. 不碰业务,重新生成报告——report(value,ops) 只允许 apply 或 report 请求。调用一次 ops.publish(change_id,report_path),如果返回小写 64 位 SHA-256 str,就返回 report=saved、sha256=该值的 dict;调用 Exception 或错误的返回,则返回 report=failed、sha256=None 的 dict。
  7. 只执行另外批准的补偿——compensate(value,ops) 校验 undo 请求之后,调用一次 ops.undo(undo_id,change_id,reason)。把 True、False、ops.Conflict、其他 Exception 或返回,分别归类为 applied、replayed、conflict、uncertain。返回 change_id、undo_id、compensation 的 dict,不做后续调用。
  8. 把真实的 JSON 请求和 CLI 贯通到底——按下面的契约实现 dispatch(value,ops)、decode(raw)、main(argv,ops) 和 CLI 入口。apply 在 execute→settle 之后,只对 complete、incomplete、hold 的观测调用 report。其余情况是 report=not_attempted、sha256=None。其他三个动作只调用对应的函数。用真实的数据库、文件、CLI 检查整个流程。

参考

只把明确的批准当作输入

实现 Conflict(Exception) 和 validate(value)。检查下面按动作区分的准确的键、类型、范围,并返回把列表排序后的深拷贝。apply、undo 只有在 approved 是真正的 True 时才允许,错误是 ValueError。

bool 是 int 的子类型。有真值,与明确的批准,是两回事。

不自动批准预览

preview(value,ops) 校验 preview 请求,并以排序后的 ids 调用一次 ops.preview(tenant,...)。校验并排序收到的 targets,核对 ID 列表是否与请求完全相同。不同则是 Conflict。相同则返回 action=apply、原 change_id、tenant、report_path、观测的 targets、approved=False 的提议 dict。

提议必须无法通过 validate 直接执行。不要在这里调用执行、补偿、报告操作。

区分同一个 ID 的不同批准

compare(value,observation) 校验 apply 请求。observation 是 None 则返回 unknown。否则,在下面的观测格式中,核对 change_id、tenant、规范化 targets 是否与请求相同,不同则是 Conflict。相同则原样返回 report.decision 的 complete、incomplete、hold。其他顶层键或未知判定是 Conflict。

提供的 observe 会执行前面单元的证据采集和分类。这次的职责,是确认那次观测与当前批准是否相同。

把执行响应和结果不明分开

execute(value,ops) 校验 apply 请求之后,恰好调用一次 ops.apply(change_id,tenant,targets)。True 是 applied,False 是 replayed,ops.Conflict 是 conflict,其他 Exception 或非 bool 的返回是 uncertain。调用之外的校验错误原样传递。

即使收到了异常,也可能已经提交。不要创造新的 ID、新的目标和自动重试。

观测已确认的记录,把不确定性暴露出来

settle(value,ops,outcome) 校验 apply 请求和四种执行结果值。如果是 conflict,不调用 observe,observed=not_checked。其他情况调用一次 ops.observe(change_id),并用 compare 确定 observed。查询的 Exception 是 unknown,比较中的 Conflict、ValueError、KeyError、TypeError 是 mismatch。返回 change_id、execution=原 outcome、observed 的 dict。

不要把查询失败和“是另一个批准”这个事实合并成同一个状态。这一步不做写入操作。

不碰业务,重新生成报告

report(value,ops) 只允许 apply 或 report 请求。调用一次 ops.publish(change_id,report_path),如果返回小写 64 位 SHA-256 str,就返回 report=saved、sha256=该值的 dict;调用 Exception 或错误的返回,则返回 report=failed、sha256=None 的 dict。

不重新解读执行响应,也不做补偿。即使保存失败之后,也不要追加调用业务操作。

只执行另外批准的补偿

compensate(value,ops) 校验 undo 请求之后,调用一次 ops.undo(undo_id,change_id,reason)。把 True、False、ops.Conflict、其他 Exception 或返回,分别归类为 applied、replayed、conflict、uncertain。返回 change_id、undo_id、compensation 的 dict,不做后续调用。

原批准并不意味着补偿的批准。补偿的异常也不要用新的 undo_id 自动重试。

把真实的 JSON 请求和 CLI 贯通到底

按下面的契约实现 dispatch(value,ops)、decode(raw)、main(argv,ops) 和 CLI 入口。apply 在 execute→settle 之后,只对 complete、incomplete、hold 的观测调用 report。其余情况是 report=not_attempted、sha256=None。其他三个动作只调用对应的函数。用真实的数据库、文件、CLI 检查整个流程。

退出码 0 不是业务完成,而是请求处理成功。要分别读取 JSON 中的执行、观测、文件状态。