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

不可撤销的变更

旧名牌应用仍在运行时迁移列

在 TT Lab 中继续学习

目标

把外星庆典的名牌应用,从 name 迁移到 display_name。让它在旧应用仍在修改名字的期间也能工作,在过渡用应用也退役之后,再删除旧列。

为什么重要

即使只改一个列名,也可能破坏同时运行的应用、批处理和查询工具。要把扩展 → 兼容读写 → 回填 → 约束强化 → 确认退役 → 收缩分开来练习。请先学习 Python 函数和异常、SQL 事务以及前面的条件变更实验。预计 130 分钟。请在到期前用“+时间”延长,并在最长 180 分钟内完成。会话结束后文件会消失。需要的代码请另外保存。

环境与通用契约

产出物是 /root/schema/worker.py。使用镜像里的 Python 3、PostgreSQL 16、psycopg 3.2.3,不需要安装、下载或额外权限。可以用 postgres 用户写入 /root。

评分器会在本地 labdb 的独有临时 schema 中准备虚构的名牌,并且只清理自己建立的 schema。请使用传入的 con 的 search_path 和 DSN。不要碰 public 表,也不要把 schema 名称、数据、ID 写死。表、列、触发器的名称是下面固定的契约,SQL 数据值通过参数传递。

CREATE TABLE badges(id integer PRIMARY KEY, name text NOT NULL);

ID 是不是 bool 的、类型恰好为 int 的 1–2147483647。名字是 str 本身,1–100 个字符,不允许首尾空白,也不允许字符码 0–31 和 127。允许韩文字符、表情符号、单引号。回填的 ids 是 1–16 个元素的 list 本身,拒绝重复 ID。错误的直接输入是 ValueError,不把 SQL 错误随意转成成功值。

所有 con 都是借来的连接,是 autocommit=True、Read Committed,外部调用开始时没有打开的事务。不关闭连接,成功和失败之后也不留下打开的事务。expand、install_bridge、backfill、add_guard、enforce、cutover 在自己的事务中应用 lock_timeout=500ms、statement_timeout=2000ms,并在成功和失败之后恢复原连接的设置。限制以每条 SQL 为准,不是整个函数时间的总和。不要把这些函数包在单独的外部事务里。只有 cutover_file 拥有连接。

fault 没有就省略,有的话以步骤中明确的字符串为参数调用。提交之前的钩子错误,会回滚该函数的全部变更并传递原始错误。after-commit 的错误,在保留已确认变更的情况下传递。expand、install_bridge、add_guard、cutover 是每个前一步骤中只执行一次的函数。不承诺对成功的 DDL 无条件重新执行。如果提交之后丢失了响应,就要确认当前的 schema 和部署记录。

兼容触发器规则

BEFORE INSERT 时,如果只有 name,就复制到 display_name;只有 display_name,就复制到 name。两者相同则原样允许,两者都是 NULL 或互不相同,就以 SQLSTATE 23514 拒绝。

BEFORE UPDATE 时,如果只有 name 变了,就把新 name 复制到 display_name;只有 display_name 变了,就把新 display_name 复制到 name。两个值都变了,只允许彼此相同的非 NULL 值。两者都没变,但 display_name 是 NULL 的已有行,就填入 name。最终两个字段中如果还留有 NULL 或值互不相同,就是 SQLSTATE 23514。这条规则会同步有效的单字段写入,但不会自动纠正清成 NULL 的请求或自相矛盾的两个值。

进行顺序

扩展之前,旧客户端对 name 的查询和写入可以工作。扩展之后,read_compatible 读取已有的行。安装兼容触发器之后,在同时接收新旧客户端写入的同时,按对象执行回填。NOT VALID 约束也可以在全部回填之前添加,但 enforce 只有在没有剩余 NULL 时才会成功。收缩之前,需要真正的 NOT NULL、已验证的约束,以及新旧名字的一致性,全部具备。

legacy_retired 是只写 name 的旧应用,transition_retired 是 COALESCE(display_name,name) 这类过渡用查询和批处理的退役批准。最终应用完全不引用旧列。两个批准标志只是外部确认的记录,并不是函数自动证明组织内所有应用都已终止。在真实服务中,要另外确认所有者、部署版本、查询观测、回滚计划。

步骤

  1. 确定名牌输入的边界——实现继承 Exception 的 Conflict 和 request(person_id,name)。校验下面的输入契约,并返回有 id、name 两个键的新 dict。错误的输入是 ValueError,不随意裁剪空白或转换成数字。
  2. 保留旧列并打开新列——expand(con,fault=None) 在自己的事务中设置时间限制,给 badges 添加可为 null 的 text 列 display_name。顺序是 after-column 钩子、真正 COMMIT 之后的 after-commit 钩子。保留已有的 id、name、行、约束,已有行的新列是 NULL。不做重命名、默认值、立即回填。正常返回值是 None。
  3. 制作回填前后都能读取的过渡用应用——read_compatible(con,person_id) 校验 ID,新列不是 NULL 时返回新名字,是 NULL 时返回旧名字。不存在的 ID 返回 None,且不修改数据。这个函数是从扩展之后到删除旧列之前的过渡用客户端,不是最终客户端。
  4. 同时接收新旧应用的写入——install_bridge(con,fault=None) 创建 sync_badge_name() 触发器函数,调用 after-function,创建 badge_compat BEFORE INSERT OR UPDATE FOR EACH ROW 触发器之后调用 after-trigger,真正 COMMIT 之后调用 after-commit。两个对象的安装是原子的,不回填已有行。触发器遵守下面的双向规则。正常返回值是 None。
  5. 编写不覆盖并发修改的回填——backfill(con,ids,fault=None) 在写入之前校验对象列表,并在自己的事务中设置时间限制。在指定的 ID 中,只把当前 display_name 为 NULL 的行,用数据库中当前的 name 填充。返回被修改 ID 的升序 list。修改之后是 after-write,真正 COMMIT 之后是 after-commit,失败则回滚本次回填的全部。不存在的 ID、已经迁移的 ID 跳过。对同一对象重新执行会得到空列表。
  6. 把已有行的检查和新写入的限制分开——add_guard(con) 在设置了时间限制的自己的事务中,添加 display_present CHECK(display_name IS NOT NULL) NOT VALID,并返回 None。enforce(con,fault=None) 对同一个约束执行 VALIDATE 并调用 after-validate,对真实的列执行 SET NOT NULL 之后调用 after-notnull,真正 COMMIT 之后调用 after-commit,并返回 None。剩余的 NULL 要传递原来的数据库异常,不自动回填。
  7. 只有在两代退役批准之后才删除旧列——cutover(con,approvals,fault=None) 在写入之前,校验 approvals 是只有 legacy_retired、transition_retired 两个键的 dict 本身,并且每个值恰好是 True。在自己的事务中设置时间限制,并获取 badges 的 ACCESS EXCLUSIVE 锁。确认 display_name 的真实 NOT NULL、display_present 的验证完成、所有行的 name 与 display_name 一致,未完成则是 Conflict。只删除 badge_compat 和 sync_badge_name() 之后调用 after-bridge-drop,只删除 name 列之后调用 after-column-drop,真正 COMMIT 之后调用 after-commit。返回 True,并保留已有的 ID 和新名字。
  8. 验证最终应用,以及 DDL 过程中的客户端终止——read_current(con,person_id) 校验 ID,只查询 display_name,返回名字,或者不存在的 ID 时返回 None。write_current(con,person_id,name) 校验输入后,在自己的事务中只修改该 ID 的 display_name,并返回 id、name 的 dict。不存在的 ID 是 Conflict,不添加。cutover_file(dsn,approvals,fault=None) 用 psycopg.connect(dsn,autocommit=True,connect_timeout=2) 拥有连接,传递 cutover 的结果和错误,无论成功还是失败都关闭连接。最终应用在收缩前后都必须能工作。

参考

确定名牌输入的边界

实现继承 Exception 的 Conflict 和 request(person_id,name)。校验下面的输入契约,并返回有 id、name 两个键的新 dict。错误的输入是 ValueError,不随意裁剪空白或转换成数字。

含有 SQL 语法字符的名字也是正常数据。请区分值校验和 SQL 参数化。

保留旧列并打开新列

expand(con,fault=None) 在自己的事务中设置时间限制,给 badges 添加可为 null 的 text 列 display_name。顺序是 after-column 钩子、真正 COMMIT 之后的 after-commit 钩子。保留已有的 id、name、行、约束,已有行的新列是 NULL。不做重命名、默认值、立即回填。正常返回值是 None。

其他连接的 SELECT 也可能让 DDL 等待。不要无限期地等锁,但要保留原来的连接设置。

制作回填前后都能读取的过渡用应用

read_compatible(con,person_id) 校验 ID,新列不是 NULL 时返回新名字,是 NULL 时返回旧名字。不存在的 ID 返回 None,且不修改数据。这个函数是从扩展之后到删除旧列之前的过渡用客户端,不是最终客户端。

请把 COALESCE 的参数顺序和 SQL 中留下的列引用一起看。即使第一个参数有值,也不能引用已经消失的列。

同时接收新旧应用的写入

install_bridge(con,fault=None) 创建 sync_badge_name() 触发器函数,调用 after-function,创建 badge_compat BEFORE INSERT OR UPDATE FOR EACH ROW 触发器之后调用 after-trigger,真正 COMMIT 之后调用 after-commit。两个对象的安装是原子的,不回填已有行。触发器遵守下面的双向规则。正常返回值是 None。

NULL 比较需要 IS DISTINCT FROM。要区分旧值和新值的变化方向,不要悄悄覆盖自相矛盾的两个值。

编写不覆盖并发修改的回填

backfill(con,ids,fault=None) 在写入之前校验对象列表,并在自己的事务中设置时间限制。在指定的 ID 中,只把当前 display_name 为 NULL 的行,用数据库中当前的 name 填充。返回被修改 ID 的升序 list。修改之后是 after-write,真正 COMMIT 之后是 after-commit,失败则回滚本次回填的全部。不存在的 ID、已经迁移的 ID 跳过。对同一对象重新执行会得到空列表。

如果用 SELECT 把旧名字保存在应用里再 UPDATE,就可能丢掉等待期间已经确认的修改。请想一想 UPDATE 当前的 NULL 条件,以及在数据库内部复制值。

把已有行的检查和新写入的限制分开

add_guard(con) 在设置了时间限制的自己的事务中,添加 display_present CHECK(display_name IS NOT NULL) NOT VALID,并返回 None。enforce(con,fault=None) 对同一个约束执行 VALIDATE 并调用 after-validate,对真实的列执行 SET NOT NULL 之后调用 after-notnull,真正 COMMIT 之后调用 after-commit,并返回 None。剩余的 NULL 要传递原来的数据库异常,不自动回填。

NOT VALID 并不意味着约束被关闭。请分别确认已有行的检查是否完成,以及 pg_attribute 中的真实 NOT NULL。

只有在两代退役批准之后才删除旧列

cutover(con,approvals,fault=None) 在写入之前,校验 approvals 是只有 legacy_retired、transition_retired 两个键的 dict 本身,并且每个值恰好是 True。在自己的事务中设置时间限制,并获取 badges 的 ACCESS EXCLUSIVE 锁。确认 display_name 的真实 NOT NULL、display_present 的验证完成、所有行的 name 与 display_name 一致,未完成则是 Conflict。只删除 badge_compat 和 sync_badge_name() 之后调用 after-bridge-drop,只删除 name 列之后调用 after-column-drop,真正 COMMIT 之后调用 after-commit。返回 True,并保留已有的 ID 和新名字。

行数相同并不意味着名字相同。不要把批准确认、数据确认、DDL 分开提交。

验证最终应用,以及 DDL 过程中的客户端终止

read_current(con,person_id) 校验 ID,只查询 display_name,返回名字,或者不存在的 ID 时返回 None。write_current(con,person_id,name) 校验输入后,在自己的事务中只修改该 ID 的 display_name,并返回 id、name 的 dict。不存在的 ID 是 Conflict,不添加。cutover_file(dsn,approvals,fault=None) 用 psycopg.connect(dsn,autocommit=True,connect_timeout=2) 拥有连接,传递 cutover 的结果和错误,无论成功还是失败都关闭连接。最终应用在收缩前后都必须能工作。

在 DDL 过程中真实终止客户端进程。提交之前是整体恢复,提交之后是保留新 schema。不要把响应丢失无条件当作重新调用来处理。