节庆取消按钮与已经变化的订单
目标
庆典订单取消获得批准之后,其他负责人改了数据。只取消所批准的客户、ID、版本、数量;遇到中途冲突时保全整体;响应丢失时,用确认记录来确认事实。
为什么重要
阅读审批文档期间,数据也在变化。即使“改了两笔”这个数字是对的,如果取消的是别的订单,那也是失败。把变更条件、外层事务、回执和当前核对分开实现,并用真实的两个数据库连接、客户端终止来确认。需要具备 PostgreSQL、SQL UPDATE 和 Python 异常处理的基础。
预计 100 分钟。请在到期前点击“+时间”延长会话。会话结束后文件会消失。需要的代码请另外保存。
环境与数据契约
产出物是 /root/revision/worker.py。镜像里已经准备好 Python 3、psycopg 3.2.3 和 PostgreSQL 16,所以不需要联网安装。容器以 postgres 用户运行,镜像中已经做了准备,以便可以写入 /root。不做用户切换或添加 capability。
检查器会在实验用的 labdb 里建立单独的临时 schema 和下面三张表,并且只清理自己建立的 schema。不要直接修改全局 public 表或原来的实验数据。提交的函数直接使用传入的 con 的 search_path,不要把 schema、DSN、订单值写死。
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));
标识符 tenant 和 change_id 是 ASCII 英文、数字、下划线、连字符 1–64 个字符的 str(类型必须恰好是 str)。格式和范围错误,要在写入之前以 ValueError 拒绝。SQL 的值要通过参数传递,而不是拼接字符串。订单的正常变更会让 revision 增加,changes 和 audit 的契约是确认之后不再修改。它并不是能管控无视这些规则的其他程序写入的权限系统。
从外部借来的 con 是 autocommit=True、Read Committed,调用开始时没有打开的事务。函数不关闭借来的连接,成功和失败之后都不留下打开的事务。内部函数之间嵌套调用时,要保留外层事务。只有 change_file 新拥有连接。数据库连接错误和未知异常,不要当作成功藏起来,而要传递出去。
步骤
- 规范化批准对象——在 worker.py 中实现继承 Exception 的 Conflict 和 validate_targets(targets)。targets 必须是 list 本身(不是子类),有 1–16 个元素。每个元素是只有 id、revision、qty 的 dict 本身。id 是 1–2147483647 的 int,revision 是 0–2147483646 的 int,qty 是 1–1000 的 int,并且全部拒绝 bool。重复的 ID 以及格式和范围错误是 ValueError。返回按 ID 升序排列的新 list 和新 dict,并且不修改输入。
- 按客户和明确的 ID 读取当前状态——preview(con,tenant,ids) 接收下面所述的标识符 tenant 和类型恰好为 list 的 ids。ids 必须包含 1–16 个不重复、类型恰好为 int 的 ID(1–2147483647),错误是 ValueError。用一条 SELECT 从 orders 中读取该客户、明确的 ID、pending 状态,返回按 ID 排序的 id、revision、qty dict 列表。只要有一个对象不存在,或者属于其他客户或状态,就是 Conflict。返回数据也要遵守 validate_targets 的契约,并且不修改数据库。
- 只取消批准版本的那一行——cancel_one(con,tenant,target) 校验标识符和单个批准项,在 UPDATE 中同时比较 id、tenant、revision、qty 和 pending。一致就改为 state=cancelled、revision=revision+1,并返回 id、revision、qty、state 的 dict。没有返回行就是 Conflict,并且不修改其他行。独立调用以一个事务完成,在下面的批次函数内部,也不能提前提交外层事务。
- 中途冲突时,前面的变更也要回滚——cancel_many(con,tenant,targets) 先校验所有输入,按 ID 顺序取消每个对象,并返回 cancel_one 结果的 list。整体作为一个外层事务处理,只要有一个失败,就连前面已改的行一起全部回滚,然后传递原始错误。这个函数不生成审计或回执。要保留输入和对照组。
- 把批准回执、变更、审计一起确认——audited_cancel(con,change_id,tenant,targets,fault=None) 校验两个标识符和批准集合。在一个事务中,把 change_id、tenant、规范化的 targets 放入 changes,取消整个批次之后,调用 fault('after-update')。把每个对象的 id、原来的 revision、新的 revision=原来+1、qty 作为同一个 change_id 的 audit 行放入,调用 fault('after-audit'),整体 COMMIT 之后调用 fault('after-commit'),并返回 True。如果已有的 change_id 上有相同的 tenant 和规范化 targets,就不做任何修改并返回 False,内容不同则是 Conflict。重复返回时不调用 fault。同时进来的相同请求也只应用一个。fault 只有存在时才调用,提交之前出错就整体回滚,提交之后出错就保留已确认状态并传递原始错误。
- 查询当时确认的批准内容——inspect_change(con,change_id) 校验标识符,如果 changes 中没有回执就返回 None,有就返回 change_id、tenant、targets 的 dict。targets 是保存下来的批准当时的内容,之后 orders 的变更或对返回值的修改,都不会改变所保存的回执。这个函数不会用批准内容去覆盖读取当前 orders 的状态。
- 确认的是同一对象,而不是同样的数量——如果没有回执,reconcile(con,change_id) 就是 Conflict。用一条 SELECT 从当前 orders 查询回执中的各个 ID,返回含 matching、drifted、missing 三个键的 ID list。不存在为 missing,tenant 是当时的客户、并且 revision=批准+1、qty=批准数量、state=cancelled 全部相符为 matching,其余为 drifted。每个 list 按 ID 排序,回执中没有的 ID 不要放进去。只做读取,不修改当前行或审计、回执。
- 用同一个 ID 继续丢失了响应的变更——change_file(dsn,change_id,tenant,targets,fault=None) 用 psycopg.connect(dsn,autocommit=True,connect_timeout=2) 打开自己的连接,返回 audited_cancel 的结果,无论成功还是失败都关闭连接。DSN 是检查器传入的、可信的一次性本地数据库连接设置。最终检查会执行:在提交前后真实终止客户端进程之后重新连接、同一变更的并发请求、真实的行锁等待之后重新评估条件。不会终止数据库服务器本身。
参考
- 直接诊断:python3 -B /opt/lab/fixtures/revision/check.py 8 /root/revision/worker.py。把 8 换成当前的步骤,就会检查到那一步。一次评分的上限是 30 秒,不会修改提交的文件。
- 只应用明确的对象,并保留对照组。不要自动把失败批准的版本修正为当前值。回执为 None 与当前缺失的行,是不同的状态。
- 本实验以权限负责人在本地测试数据库上调用为前提。不要放入真实的客户信息和外部数据库。登录、按客户的权限、生产审计留存策略是另外的事。
- 已有数据库镜像的 fsync 和 full_page_writes 是关闭的。这里验证的是连接着正常运行的服务器的客户端进程被终止的情形,并不是服务器断电、磁盘损坏、生产备份恢复的保证。
规范化批准对象
在 worker.py 中实现继承 Exception 的 Conflict 和 validate_targets(targets)。targets 必须是 list 本身(不是子类),有 1–16 个元素。每个元素是只有 id、revision、qty 的 dict 本身。id 是 1–2147483647 的 int,revision 是 0–2147483646 的 int,qty 是 1–1000 的 int,并且全部拒绝 bool。重复的 ID 以及格式和范围错误是 ValueError。返回按 ID 升序排列的新 list 和新 dict,并且不修改输入。
原来的列表和返回的列表,连内部的 dict 也必须各自独立。不要把空的批准当作无效果的成功来处理。
按客户和明确的 ID 读取当前状态
preview(con,tenant,ids) 接收下面所述的标识符 tenant 和类型恰好为 list 的 ids。ids 必须包含 1–16 个不重复、类型恰好为 int 的 ID(1–2147483647),错误是 ValueError。用一条 SELECT 从 orders 中读取该客户、明确的 ID、pending 状态,返回按 ID 排序的 id、revision、qty dict 列表。只要有一个对象不存在,或者属于其他客户或状态,就是 Conflict。返回数据也要遵守 validate_targets 的契约,并且不修改数据库。
比较的不是整体的 pending 数量,而是明确指定的对象集合。不要插入批准当时并不存在的新订单。
只取消批准版本的那一行
cancel_one(con,tenant,target) 校验标识符和单个批准项,在 UPDATE 中同时比较 id、tenant、revision、qty 和 pending。一致就改为 state=cancelled、revision=revision+1,并返回 id、revision、qty、state 的 dict。没有返回行就是 Conflict,并且不修改其他行。独立调用以一个事务完成,在下面的批次函数内部,也不能提前提交外层事务。
仅靠没有条件的 UPDATE 之前的 SELECT,挡不住竞争。请用 RETURNING 确认实际被修改的行。
中途冲突时,前面的变更也要回滚
cancel_many(con,tenant,targets) 先校验所有输入,按 ID 顺序取消每个对象,并返回 cancel_one 结果的 list。整体作为一个外层事务处理,只要有一个失败,就连前面已改的行一起全部回滚,然后传递原始错误。这个函数不生成审计或回执。要保留输入和对照组。
一行函数的成功,并不等于整个批次已确认。请确认嵌套的 transaction 上下文与 savepoint 的关系。
把批准回执、变更、审计一起确认
audited_cancel(con,change_id,tenant,targets,fault=None) 校验两个标识符和批准集合。在一个事务中,把 change_id、tenant、规范化的 targets 放入 changes,取消整个批次之后,调用 fault('after-update')。把每个对象的 id、原来的 revision、新的 revision=原来+1、qty 作为同一个 change_id 的 audit 行放入,调用 fault('after-audit'),整体 COMMIT 之后调用 fault('after-commit'),并返回 True。如果已有的 change_id 上有相同的 tenant 和规范化 targets,就不做任何修改并返回 False,内容不同则是 Conflict。重复返回时不调用 fault。同时进来的相同请求也只应用一个。fault 只有存在时才调用,提交之前出错就整体回滚,提交之后出错就保留已确认状态并传递原始错误。
用回执的唯一键来协调并发请求,但要连发生冲突的键的内容也加以核对。只提交回执,就会变成没有实际变更的成功记录。
查询当时确认的批准内容
inspect_change(con,change_id) 校验标识符,如果 changes 中没有回执就返回 None,有就返回 change_id、tenant、targets 的 dict。targets 是保存下来的批准当时的内容,之后 orders 的变更或对返回值的修改,都不会改变所保存的回执。这个函数不会用批准内容去覆盖读取当前 orders 的状态。
当前状态和过去的确认记录,是对不同问题的回答。不要在回执查询中掺入业务状态。
确认的是同一对象,而不是同样的数量
如果没有回执,reconcile(con,change_id) 就是 Conflict。用一条 SELECT 从当前 orders 查询回执中的各个 ID,返回含 matching、drifted、missing 三个键的 ID list。不存在为 missing,tenant 是当时的客户、并且 revision=批准+1、qty=批准数量、state=cancelled 全部相符为 matching,其余为 drifted。每个 list 按 ID 排序,回执中没有的 ID 不要放进去。只做读取,不修改当前行或审计、回执。
即使数量相同,revision 更高就是之后的变更。不要用数量去抵消“原来的 ID 没了、却出现了新 ID”这种情况。
用同一个 ID 继续丢失了响应的变更
change_file(dsn,change_id,tenant,targets,fault=None) 用 psycopg.connect(dsn,autocommit=True,connect_timeout=2) 打开自己的连接,返回 audited_cancel 的结果,无论成功还是失败都关闭连接。DSN 是检查器传入的、可信的一次性本地数据库连接设置。最终检查会执行:在提交前后真实终止客户端进程之后重新连接、同一变更的并发请求、真实的行锁等待之后重新评估条件。不会终止数据库服务器本身。
提交之后丢失响应的情形,要靠回执来判断是否重试。请区分借用连接的函数和拥有连接的函数。