重新举办的节庆与危险的撤销
目标
取消过的庆典又要举办了。只恢复保持着取消之后状态的订单,限制等待,即使丢失了响应,也通过补偿回执继续。
为什么重要
把备份整个覆盖上去,可能抹掉取消之后其他负责人的变更。补偿也是有新批准和记录的变更。请先学习前面的批准版本实验以及 Python 异常和 SQL 事务。预计 110 分钟。请在到期前用“+时间”延长。会话结束后文件会消失。需要的代码请另外保存。
环境与通用契约
产出物是 /root/compensation/worker.py。镜像里有 PostgreSQL 16、psycopg 3.2.3、Python 3,不需要联网安装。可以用 postgres 用户写入 /root,不需要切换用户或添加 capability。
检查器会在本地 labdb 的单独临时 schema 中准备下面的表和虚构的原变更,并且只清理自己建立的 schema。提交的函数使用传入连接的 search_path。不要把 schema、订单 ID、客户、DSN 写死,也不要修改 public 中的原数据。SQL 的值通过参数传递。
CREATE TABLE orders(id integer PRIMARY KEY,tenant text NOT NULL,
qty integer NOT NULL CHECK(qty BETWEEN 1 AND 1000),
state text NOT NULL CHECK(state IN ('pending','paid','cancelled')),
revision integer NOT NULL CHECK(revision>=0));
CREATE TABLE changes(change_id text PRIMARY KEY,tenant text NOT NULL,targets jsonb NOT NULL);
CREATE TABLE audit(change_id text REFERENCES changes(change_id),id integer NOT NULL,
previous_revision integer NOT NULL,new_revision integer NOT NULL,qty integer NOT NULL,
PRIMARY KEY(change_id,id));
CREATE TABLE undo_records(undo_id text PRIMARY KEY,
original_id text NOT NULL UNIQUE REFERENCES changes(change_id),reason text NOT NULL,
tenant text NOT NULL,targets jsonb NOT NULL);
CREATE TABLE undo_audit(undo_id text REFERENCES undo_records(undo_id),id integer NOT NULL,
previous_revision integer NOT NULL,new_revision integer NOT NULL,qty integer NOT NULL,
PRIMARY KEY(undo_id,id));
标识符 undo_id、original_id、tenant 是 ASCII 英文、数字、下划线、连字符 1–64 个字符的 str(类型必须恰好是 str)。reason 是 1–200 个字符的 str(类型必须恰好是 str),不允许有首尾空白,也不允许码点 0–31 和 127。不自动修改请求。对象是只有 id、revision、qty 的 dict 本身,id 是 1–2147483647 的 int,期望 revision 是 0–2147483646 的 int,qty 是 1–1000 的 int。全部拒绝 bool。targets 是 1–16 个不重复的 ID 组成的 list 本身,并规范化为按 ID 排序的新 list 和 dict。plan 是只有 original_id、tenant、targets 的 dict 本身。直接传入的输入错误,在写入之前以 ValueError 报错。
lock_ms 是 50–500 的 int(类型必须恰好是 int),statement_ms 是类型恰好为 int、比 lock_ms 大、且不超过 2000 的值。上限以每次加锁尝试、每条 SQL 为准,不是整个批次耗时的保证。补偿回执的插入也可能等待,所以 compensate 从第一个数据库操作起,就要应用自己事务的预算。
借来的 con 是 autocommit=True、Read Committed,外部调用开始时没有事务。所有函数都不关闭借来的连接,成功和失败之后都不留下打开的事务和设置变更。restore_one、restore_batch 内部的嵌套调用要保留外层事务。不要把 compensate 包在其他外部事务里。只有 undo_file 拥有新连接。记录确认之后不可变,正常订单写入会让 revision 增加,这是契约。它并不会管控不遵守这个前提的生产程序的写入权限。
步骤
- 把补偿请求弄明确——实现继承 Exception 的 Conflict 和 request(undo_id,original_id,reason)。校验下面的标识符、理由契约,并返回有三个键的新 dict。格式错误是 ValueError,不要悄悄修改理由或 ID。
- 核对原回执和审计——load_plan(con,original_id) 读取原 changes 和 audit,返回 original_id、tenant、targets 的 dict。targets 按 ID 排序,是在原批准 revision 上加 1 的取消之后期望值。没有原记录、审计的 ID/前后版本/数量不一致、无效记录、补偿后超过 integer 上限,都是 Conflict。原批准 revision 只允许 0–2147483645。不修改原记录或 orders,修改返回值也不影响原记录。
- 只有处于取消之后的状态才恢复——restore_one(con,tenant,target) 校验输入,把 id、tenant、期望 revision、qty、cancelled 状态放进 UPDATE 条件。一致就改为 pending,把 revision 加 1,返回 id、revision、qty、state 的 dict。不存在或已改变则是 Conflict。以自己的事务处理,但在内部调用中,不提前提交外层事务。不写审计或回执。
- 限制等待,保护整个批次——restore_batch(con,plan,lock_ms=100,statement_ms=800) 在写入之前校验下面的计划和预算契约。在外层事务内应用上限,按 ID 顺序全部恢复,返回 restore_one 结果的列表。无论哪种错误,都连前面的行一起整体回滚并传递原始错误。成功和失败之后,借来的连接的 lock_timeout、statement_timeout 都要保持原值。不修改原记录和对照组。
- 把补偿记录和业务变更一起确认——compensate(con,undo_id,original_id,reason,lock_ms=100,statement_ms=800,fault=None) 在校验请求和预算之后,在一个事务中应用上限,并读取原补偿计划。把补偿 ID、原 ID、理由、客户、期望 targets 放入 undo_records,批次恢复之后调用 fault(after-restore),把所有对象的取消之后 revision、新 revision、qty 放入 undo_audit 之后调用 fault(after-audit),真正 COMMIT 之后调用 fault(after-commit),并返回 True。如果 ID、原 ID、理由、计划都相同,就不修改并返回 False,钩子也不调用。同一个补偿 ID 的内容不同,或用不同的补偿 ID 再次补偿同一个原变更,是 Conflict。钩子只在存在时才调用。提交之前出错,全部回滚;之后出错,保留已确认状态并传递原始错误。不修改原 changes 和 audit。
- 把补偿当时和当前分开读取——inspect_undo(con,undo_id) 没有则返回 None,有则返回 undo_id、original_id、reason、tenant、targets 的 dict。targets 是补偿当时期望值的副本。reconcile_undo(con,undo_id) 没有回执就是 Conflict,有则用一条 SELECT 查询这些 ID 的当前 orders,返回 matching、drifted、missing 的按 ID 排序的 list。客户、qty、pending、revision=回执期望+1 全部相符为 matching,ID 不存在为 missing,其余为 drifted。两个函数都只做读取。
- 继续丢失了响应的补偿——undo_file(dsn,undo_id,original_id,reason,lock_ms=100,statement_ms=800,fault=None) 用 psycopg.connect(dsn,autocommit=True,connect_timeout=2) 拥有连接,调用 compensate 并返回同样的结果。无论成功还是失败都关闭连接,不隐藏错误。检查真实的客户端终止,以及使用相同的、不同的补偿 ID 的两个进程的竞争。
- 只对锁错误重试规定的次数——retry_undo(action,attempts,pause) 只允许类型恰好为 int 的 attempts 取 1–3,错误在第一次调用之前以 ValueError 报错。立即调用 action(),包括 True、False 在内的正常结果原样返回。只重试 psycopg.errors.LockNotAvailable,最后一次失败传递同一个错误。只有在还有剩余尝试时,才调用 pause(刚失败的尝试编号)。包含首次在内共 attempts 次,pause 最多 attempts-1 次。语句取消、Conflict、输入或连接错误以及 pause 的错误,不再追加调用,直接传递。实际检查的 action 用同一个 ID 调用 undo_file。
参考
- 直接诊断:python3 -B /opt/lab/fixtures/compensation/check.py 8 /root/compensation/worker.py。把 8 换成当前的步骤,就会检查到那一步。一次评分上限是 30 秒。
- 传入的 DSN 是可信的一次性本地数据库设置。不要放入真实的客户数据和外部数据库。认证、按客户的权限,以及外部支付、发货的取消,是另外的事。
- 在 fsync、full_page_writes 关闭的已有实验镜像上,数据库服务器保持运行,只终止客户端。这并没有验证服务器断电、磁盘损坏、生产备份的耐久性。
把补偿请求弄明确
实现继承 Exception 的 Conflict 和 request(undo_id,original_id,reason)。校验下面的标识符、理由契约,并返回有三个键的新 dict。格式错误是 ValueError,不要悄悄修改理由或 ID。
标识符按 ASCII 规则检查,理由则要分别检查长度和控制字符。
核对原回执和审计
load_plan(con,original_id) 读取原 changes 和 audit,返回 original_id、tenant、targets 的 dict。targets 按 ID 排序,是在原批准 revision 上加 1 的取消之后期望值。没有原记录、审计的 ID/前后版本/数量不一致、无效记录、补偿后超过 integer 上限,都是 Conflict。原批准 revision 只允许 0–2147483645。不修改原记录或 orders,修改返回值也不影响原记录。
不要仅凭当前是 cancelled 这一观测,去推测它是哪次取消的结果。要把原审计列表的全部,与规范化的批准进行比较。
只有处于取消之后的状态才恢复
restore_one(con,tenant,target) 校验输入,把 id、tenant、期望 revision、qty、cancelled 状态放进 UPDATE 条件。一致就改为 pending,把 revision 加 1,返回 id、revision、qty、state 的 dict。不存在或已改变则是 Conflict。以自己的事务处理,但在内部调用中,不提前提交外层事务。不写审计或回执。
降回过去的版本,过期的批准会再次看起来是匹配的。请用 RETURNING 确认新版本。
限制等待,保护整个批次
restore_batch(con,plan,lock_ms=100,statement_ms=800) 在写入之前校验下面的计划和预算契约。在外层事务内应用上限,按 ID 顺序全部恢复,返回 restore_one 结果的列表。无论哪种错误,都连前面的行一起整体回滚并传递原始错误。成功和失败之后,借来的连接的 lock_timeout、statement_timeout 都要保持原值。不修改原记录和对照组。
请使用事务范围的 set_config。锁上限、语句上限和整个批次的耗时并不相同。
把补偿记录和业务变更一起确认
compensate(con,undo_id,original_id,reason,lock_ms=100,statement_ms=800,fault=None) 在校验请求和预算之后,在一个事务中应用上限,并读取原补偿计划。把补偿 ID、原 ID、理由、客户、期望 targets 放入 undo_records,批次恢复之后调用 fault(after-restore),把所有对象的取消之后 revision、新 revision、qty 放入 undo_audit 之后调用 fault(after-audit),真正 COMMIT 之后调用 fault(after-commit),并返回 True。如果 ID、原 ID、理由、计划都相同,就不修改并返回 False,钩子也不调用。同一个补偿 ID 的内容不同,或用不同的补偿 ID 再次补偿同一个原变更,是 Conflict。钩子只在存在时才调用。提交之前出错,全部回滚;之后出错,保留已确认状态并传递原始错误。不修改原 changes 和 audit。
只剩回执的补偿,以及删除了原记录的补偿,都是失败。请区分两种唯一键冲突各自的含义。
把补偿当时和当前分开读取
inspect_undo(con,undo_id) 没有则返回 None,有则返回 undo_id、original_id、reason、tenant、targets 的 dict。targets 是补偿当时期望值的副本。reconcile_undo(con,undo_id) 没有回执就是 Conflict,有则用一条 SELECT 查询这些 ID 的当前 orders,返回 matching、drifted、missing 的按 ID 排序的 list。客户、qty、pending、revision=回执期望+1 全部相符为 matching,ID 不存在为 missing,其余为 drifted。两个函数都只做读取。
不要用新订单的相同值去填补原 ID 的缺失。也不要因为当前的变化而去修改过去的回执。
继续丢失了响应的补偿
undo_file(dsn,undo_id,original_id,reason,lock_ms=100,statement_ms=800,fault=None) 用 psycopg.connect(dsn,autocommit=True,connect_timeout=2) 拥有连接,调用 compensate 并返回同样的结果。无论成功还是失败都关闭连接,不隐藏错误。检查真实的客户端终止,以及使用相同的、不同的补偿 ID 的两个进程的竞争。
数据库确认的补偿,与调用者收到的响应是两回事。重试要使用同样的补偿编号和内容。
只对锁错误重试规定的次数
retry_undo(action,attempts,pause) 只允许类型恰好为 int 的 attempts 取 1–3,错误在第一次调用之前以 ValueError 报错。立即调用 action(),包括 True、False 在内的正常结果原样返回。只重试 psycopg.errors.LockNotAvailable,最后一次失败传递同一个错误。只有在还有剩余尝试时,才调用 pause(刚失败的尝试编号)。包含首次在内共 attempts 次,pause 最多 attempts-1 次。语句取消、Conflict、输入或连接错误以及 pause 的错误,不再追加调用,直接传递。实际检查的 action 用同一个 ID 调用 undo_file。
这是尝试次数的契约,不是整体耗时上限。如果锁释放之后看到了不同的版本,不要自动生成新批准。