Polar Ship Safety:面向非原子化部署的代码评审安全核查实战指南
【免费下载链接】polarPolar — A billing platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/po/polar
导读
本文围绕 Polar 开源仓库(Billing platform for the intelligence era)中的部署安全评审 Skill——ship-safety(见 .agents/skills/ship-safety/SKILL.md)展开。它回答一个非常具体的问题:在一个迁移先跑、API 先于 Worker 上线、旧前端可能对着新后端运行、队列里还残留旧代码入队任务的非原子化部署环境里,一个 diff 在「合并瞬间」到「完全部署完成」之间到底会不会把生产环境打挂。读完本文,你将掌握一套可执行的五类检查清单(Schema 先于代码、阻塞性 DDL、在途任务、锁与吞吐量、是否拆分 PR),并能在合并涉及迁移、任务、模型、端点删除的 PR 之前,产出一份规范的 Ship Safety 评审报告。
一、为什么需要 Ship Safety:Polar 的部署窗口
Ship Safety 的核心句是:handle 是 diff,证据是「合并那一刻到完全部署完成之间到底什么会坏」(见 .agents/skills/ship-safety/SKILL.md)。
Polar 并不做原子化部署,部署窗口里存在四类真实风险:
- 迁移先于代码执行:migrations 先跑,随后 API 才上线。
- API 先于 Worker 部署:API 先于 worker 上线,worker 滞后。
- 旧前端可以对着新后端运行:前端与后端发布不同步。
- 队列中残留旧名字入队的任务:合并时队列里已经躺着由旧代码按旧 actor_name、旧参数入队的任务。
因此在最终状态下「逻辑正确」的代码,在通往最终状态的路上依然可能把生产环境打崩。Ship Safety 检查的就是这段"在路上"的窗口。
适用范围(Scope)
需要跑 Ship Safety 的 diff 覆盖以下五类(.agents/skills/ship-safety/SKILL.md):
- 触及
server/migrations/versions/的迁移文件; - 触及
**/tasks.py的任务代码; - 触及
polar/models/的模型; - 触及
server/scripts/的运维脚本; - 删除或重命名了某个 endpoint;
- 同时跨越
server/与clients/且二者存在依赖关系的改动。
也就是说,只要 PR 动了数据库结构、异步任务、模型、脚本,或动了对外 API 契约,就应当跑一遍 Ship Safety。
与 ADR-0006 的职责边界
文档明确划定了"Owned elsewhere"(由其他机制负责、不要重复阐述)的部分(.agents/skills/ship-safety/SKILL.md):
- 迁移规则由
ADR-0006覆盖:锁超时、nullable → 批量回填脚本(run_batched_update)→ 跨多个 PR 再收紧为 NOT NULL、enforce 迁移中的无条件UPDATE、迁移 PR 与代码 PR 隔离。CI 通过Migration Isolation Check强制执行,adr-check报告违规; - 重复造轮子的 helper由
reuse-check负责; - 计费相关的锁与循环规则由
billing-review负责。
Ship Safety 只负责ADR-0006 没有说到的剩余部分——即上面的部署窗口本身。
二、检查 1:Schema 先于代码(Schema ahead of code)
ADR-0006 阻止了「代码跑在 schema 之前」,但反过来依然会咬人:迁移和新代码之间,旧代码会跑在新 schema 上(.agents/skills/ship-safety/SKILL.md)。
两条硬规则:
- 删除列或表会立即打挂正在运行的应用。原因在源码层面有明确依据:SQLAlchemy 在 import 时就会把每个模型映射到表(
polar/models/下的模型文件会在模块加载阶段建立表映射),所以一张被 drop 的表可能在任何查询执行之前就导致导入失败。正确顺序是:先停止使用该列/表 → 部署 → 在后续的另一个 PR里再 drop。 - 重命名永远是两步走:加新名 → 部署 → 删旧名,拆成两个 PR。
对每一个 schema 变更都要问同一个问题:当前已部署的旧代码,在迁移执行后依然正确吗?
源码佐证:Polar 的迁移模板在upgrade()/downgrade()开头都强制执行SET LOCAL lock_timeout = '5s'(见 server/migrations/script.py.mako),而 ADR-0006 的完整决策记录在 handbook/engineering/decisions/0006-migration-and-backfill-safety.mdx。生成迁移的入口命令是alembic revision --autogenerate -m "your message"(见 server/migrations/README.md)。
三、检查 2:阻塞性 DDL(Blocking DDL)
针对大表上的结构性变更,Ship Safety 要求给出明确的锁行为判断(.agents/skills/ship-safety/SKILL.md):
- 大表建索引必须带
postgresql_concurrently=True,且迁移必须放在事务外执行(CONCURRENTLY 不能在事务块内跑)。 - 大表加 NOT NULL:优先
CHECK ... NOT VALID再VALIDATE CONSTRAINT,让 Postgres 跳过全表锁(ACCESS EXCLUSIVE)。如果表很小,这套操作就是仪式感——评审时要明确说明你认为哪种情况适用。 - 涉及金额(money)表的新外键必须带
ondelete="restrict",防止级联删除破坏财务数据完整性。
这也是 ADR-0006 决策背景的直接体现:阻塞性ALTER或未分批的UPDATE在热表上会拿ACCESS EXCLUSIVE锁,可能让线上 API 流量一直 stall 到操作完成。Polar 通过模板层的lock_timeout = '5s'保证锁等待最多 5 秒快速失败,而不是把数据库拖死(见 server/migrations/script.py.mako)。
四、检查 3:队列里已经在途的任务(Tasks already in flight)
当 PR 合并时,队列里已经存在由旧代码入队的任务。这一节是 Ship Safety 的重头戏(.agents/skills/ship-safety/SKILL.md)。
3.1 重命名 actor:会让所有排队任务搁浅
Worker 按actor_name查找任务处理器,改名字后旧任务全部找不到处理器。SKILL.md 给出的标准操作序列是:
- 新增
order.invoice.v2并开始用新名字入队; - 部署;
- 等待旧队列排空(drain);
- 删除旧 actor,把名字换回。
把 actor挪到另一个队列是同样的问题,同样适用"新旧并存 → 排空 → 退役"三步。
源码印证:Polar 的 order 任务里真实存在这种演进痕迹——order.invoice任务指定了queue_name=TaskQueue.INVOICES_AND_RECEIPTS(见 server/polar/order/tasks.py),说明「换队列」与「actor 重命名」都是会真实发生的操作。
3.2 修改签名:破坏用旧参数入队的任务
改了函数签名,会破坏所有按旧参数入队的任务。新参数必须带默认值,让旧任务仍能反序列化执行。源码印证:order.trigger_payment的payment_trigger: str | None = None就是典型的可空默认参数写法(见 server/polar/order/tasks.py)。
3.3 队列优先级:HIGH 是 checkout 专用
TaskPriority.HIGH只能用于checkout 路径(支付主链路);- 分析(analytics)、导出(exports)、回填(backfills)一律走
LOW; - 慢任务放
HIGH会饿死 checkout。
源码印证:checkout.handle_free_success、checkout.expired使用TaskPriority.HIGH,而checkout.expire_open_checkouts这种周期清扫用LOW(见 server/polar/checkout/tasks.py);order 域里order.invoice、order_created、order.confirmation_email等非关键路径全部是LOW(见 server/polar/order/tasks.py)。
3.4 Cron actor:新增定时任务必须回答"漏跑怎么办"
新增一个cron_trigger前,必须回答"错过一次运行怎么办"。一个正向遍历跳过周期的catch-up 循环通常会算错状态;更好的选择是不变量告警(invariant alert)——直接告警"这次运行没发生",而不是补跑。
源码印证:Polar 的 cron actor 使用CronTrigger.from_crontab(...)声明,如order.process_dunning(每小时)、order.enqueue_stale_payment_locks(每小时 15 分)、checkout.expire_open_checkouts(每 15 分钟),见 server/polar/order/tasks.py 与 server/polar/checkout/tasks.py。
3.5 重试语义:新失败路径必须 raise,而不是吞掉
Polar 依赖自动重试:
- 新的失败路径必须raise,绝不能swallow异常;
max_retries=0必须是有意为之的决定,而不是默认值。
源码印证:order.trigger_payment对 Stripe 网络类错误(APIConnectionError/APIError/RateLimitError)用raise Retry()明确重试,而对PaymentFailed这类业务失败选择记录日志不重试(交给 dunning 流程处理),见 server/polar/order/tasks.py。
五、检查 4:锁与吞吐量(Locks and volume)
- 新的
with_for_update应该放在拥有该工作单元的 task 或 service 里,而不是塞进某个 processor 专用的 helper。每一个锁都必须回答:如果进程死了,谁来释放这把锁? - 只允许在一条路径上释放。一条锁在两条路径上释放,就是一个等着爆发的 bug。
- 把每一行匹配数据都加载进内存的做法,在线上吞吐量下活不下去——必须用分批/流式查询。
- 扫描整张繁忙表的定时清扫任务,必须带索引或限定窗口(bounded window)。
源码印证:Polar 为此实现了"陈旧支付锁"的回收机制——order.enqueue_stale_payment_locks(cron,每小时 15 分)通过stream_stale_payment_lock()流式扫描带锁的订单,order.process_stale_payment_lock则用for_update=True加行锁、检查is_payment_lock_stale,再把过期锁按"手动重试失败"处理释放(见 server/polar/order/tasks.py)。这正是「每个锁都要回答进程死后谁来释放」的工程答案:由专用 cron 兜底恢复,而不是靠人工。
六、检查 5:拆分这个 PR(Split this PR)
以下四种情况应标记为需要拆分(.agents/skills/ship-safety/SKILL.md):
- 删除了后端 endpoint 同时又改了它的前端调用方——旧前端可能还 live 对着新后端跑;
- 重命名 actor 时同时删除旧 actor——要等旧队列排空再删;
- 原生移动端代码与 TypeScript 一起改——native 会阻断 over-the-air 发布,应先把纯 TS 改动发布出去;
- 评审中已被标注"应该移到另一个模块"——后续 PR 是合理答案,但要明确说出来,而不是默默推迟。
七、输出格式:一份可直接粘贴的评审报告模板
Ship Safety 的产出是一份结构化报告(.agents/skills/ship-safety/SKILL.md),完整模板如下:
## Ship Safety ### 🔴 Blocking - `file:line` — <what breaks, in which window>. Fix: <fix> ### 🟠 Should fix - `file:line` — <what happens under load>. Fix: <fix> ### 🟡 Question - `file:line` — <question, including "split this PR?"> ### Notes - run before merge: <script path, or none> - manual step after deploy: <e.g. "remove order.invoice v1 after 4h", or none> ### Verdict ✅ Safe to ship | ❌ n blocking, n should-fix模板的使用要点:
- 分级:
🔴 Blocking(会坏、在哪个窗口坏、怎么修)、🟠 Should fix(负载下会怎样)、🟡 Question(包括"要不要拆 PR"); - Notes 即使 verdict 是绿色也必须填写——一条必须跑的脚本没写下来,就等于这条脚本不会跑;部署后的手动步骤(例如"4 小时后移除 order.invoice v1")也必须落到 Notes 里;
- Verdict用
✅ Safe to ship或❌ n blocking, n should-fix收尾。
这套格式非常适合直接作为 GitHub PR 评论或 Code Review 模板复用:它把「部署窗口风险」显式变成可审查、可追踪的条目,而不是评审者脑中的模糊担忧。
八、结语:把 Ship Safety 纳入合并前流程
把整个 Skill 串成一条工作流:
- 收到 diff 先看 Scope——是否触及
migrations/versions/、tasks.py、models/、scripts/、endpoint 契约,或server/+clients/联动; - 对每个 schema 变更回答"旧代码在新 schema 上还正确吗";
- 判断 DDL 是否阻塞(CONCURRENTLY / NOT VALID / ondelete="restrict");
- 审视队列:actor 重命名、签名变更、优先级、cron 漏跑、重试语义;
- 审查锁与扫描:锁的归属、释放路径唯一性、内存加载量、清扫窗口;
- 判断是否拆 PR;
- 按模板输出报告,Notes 与 Verdict 一起提交。
这套检查与 Polar 仓库内的 ADR-0006 决策(handbook/engineering/decisions/0006-migration-and-backfill-safety.mdx)、迁移模板(server/migrations/script.py.mako)以及真实的 task 实现(server/polar/order/tasks.py、server/polar/checkout/tasks.py)相互印证——它不是纸面规范,而是 Polar 日常发布前真实执行的部署安全守则。在你的项目里,即使没有这套 Skill 基础设施,把同样的检查清单落到 CI 评论或人工评审模板中,也能显著降低非原子化部署引入的事故面。
【免费下载链接】polarPolar — A billing platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/po/polar
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考