2021 年内部研发平台最早只有一个按钮:用户选择仓库和分支,后端 spawn 一段构建脚本,把 stdout 通过 WebSocket 打回页面。演示很直观,第二个人同时使用时就开始混乱:日志串在一起,进程重启后任务消失,用户不知道按钮点下去到底有没有执行。
问题不是页面缺少 loading,而是系统没有控制面。用户意图、任务状态和执行进程被绑在一次 HTTP 连接里,任何一层断开都失去事实来源。
图 1:页面提交任务定义,Worker 执行租约;任务状态和产物不依赖任何一条长连接存活。
创建任务先落库,再异步调度
type Job = {
id: string;
type: "build" | "deploy" | "data-sync";
status: "queued" | "running" | "succeeded" | "failed" | "cancelled";
input: unknown;
createdBy: string;
createdAt: string;
attempt: number;
leaseOwner?: string;
leaseExpiresAt?: string;
};API 在事务中写入 Job 和审计记录,然后返回 202 Accepted 与 jobId。页面通过 jobId 订阅状态;即使刷新或换电脑,也能回到同一任务。
输入不是任意脚本字符串,而是按任务类型校验的结构。构建任务允许仓库、提交 SHA、构建配置;部署任务只接受已经存在的 artifactId。控制面决定“允许做什么”,执行器不解释用户自由文本。
Worker 用租约领取任务
如果只把状态从 queued 改为 running,Worker 进程崩溃后任务会永久卡住。领取操作写入有过期时间的租约,并周期续约:
UPDATE jobs
SET status = 'running',
lease_owner = :worker_id,
lease_expires_at = NOW() + INTERVAL 30 SECOND,
attempt = attempt + 1
WHERE id = :job_id
AND status = 'queued';调度器扫描过期租约,根据任务类型决定重试还是人工恢复。执行结果更新必须校验 leaseOwner,避免旧 Worker 恢复后覆盖新 Worker 的结果。
取消是协议,不是 kill -9
用户点击取消后,控制面把 cancel_requested_at 写入任务。Worker 在安全检查点读取取消标记,停止后续步骤并执行清理。只有超时不响应才由执行环境强制终止。
| 任务阶段 | 取消策略 |
|---|---|
| 尚未领取 | 直接转 cancelled |
| 下载依赖 | 中止下载,清理临时目录 |
| 构建中 | 发送终止信号,等待子进程退出 |
| 发布切流量 | 进入补偿流程,不能直接杀进程 |
| 已完成 | 取消无效,必要时创建回滚任务 |
“取消按钮”如果没有定义每个阶段的业务语义,只是给用户一个虚假的控制感。
日志和产物属于证据面
stdout 不再只通过 WebSocket 转发。Worker 给每一行加 jobId、attempt、step 和 sequence,批量写对象存储;实时通道只是低延迟视图,断开后可以按 sequence 补读。构建产物也用 artifactId 保存校验和、来源 SHA 和工具版本。
{
"jobId": "job_42",
"attempt": 2,
"step": "compile",
"sequence": 183,
"level": "info",
"message": "bundle completed",
"at": "2021-02-20T10:31:42Z"
}状态机不允许任意跳转
只有控制面能执行 queued -> running -> succeeded/failed。重试不是把 failed 改回 queued,而是保留 attempt 历史并创建新的执行尝试。这样能回答任务失败几次、每次由谁执行、用了哪个输入和产物。
这个平台让我从“给脚本做个页面”走向系统设计。页面只是控制面的一个客户端。真正让内部工具可信的,是任务状态不依赖页面、执行权有租约、每个副作用可追踪、失败后知道从哪里继续。