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 历史并创建新的执行尝试。这样能回答任务失败几次、每次由谁执行、用了哪个输入和产物。

这个平台让我从“给脚本做个页面”走向系统设计。页面只是控制面的一个客户端。真正让内部工具可信的,是任务状态不依赖页面、执行权有租约、每个副作用可追踪、失败后知道从哪里继续。