摘要
我正在从零开发一个新项目:AI Workload Platform。它面向 Agent(能够围绕目标调用模型、工具或外部服务的软件组件)与确定性程序任务,负责管理多步骤任务的依赖、状态、重试、取消和恢复。
模块 1 已经证明工作流内核可以在单个进程中运行,但"内核能运行"不等于"其他程序能稳定使用"。模块 2 为它增加 HTTP 控制面、不可变工作流版本、PostgreSQL 持久化、幂等请求、身份权限、Go SDK(Software Development Kit,软件开发工具包)和启动恢复协调。
本文从零解释这些技术为什么需要一起出现,重点讨论 HTTP、REST、OpenAPI、数据库事务、幂等、revision、迁移、Advisory Lock、Bearer Token、Docker Compose,以及 PostgreSQL 与 MySQL 的取舍。最后结合真实实现、自动化验证和人工故障实验,说明"数据库提交成功但后台执行尚未开始"这个崩溃窗口如何恢复、数据库断开时为什么要安全退出,以及当前方案仍然不能解决什么。
目录
- 本章要解决的问题
- 前置知识
- 一句话理解控制面
- 从本地命令到网络服务
- Workflow、版本和 Run
- PostgreSQL 如何保存运行事实
- 事务、幂等与 revision
- 迁移、认证、SDK 与 Docker
- 一次 Run 的完整数据流
- 技术选型与替代方案
- 常见错误和故障窗口
- 实际验证与发现的问题
- 进一步思考
- 总结与限制
- 官方参考资料
1. 本章要解决的问题
模块 1 的本地命令可以读取一份 JSON 工作流定义,执行任务,再把完整状态保存到文件。它适合验证调度规则,却有几个明显限制:
- 只有登录到运行机器的人才能使用命令;
- 每次运行都直接读取文件,没有统一的定义版本;
- 多个客户端同时写文件时很难保证一致性;
- 很难按状态、时间或任务分页查询;
- 进程退出后,没有服务负责自动扫描和恢复未完成的 Run;
- 不能给"只读查询"和"创建、取消"设置不同权限。
一种直觉做法是给本地命令外面套一层 HTTP。问题在于,网络会引入超时、重试和重复请求,多客户端会引入并发写入,服务重启会引入数据库状态与内存调度之间的窗口。如果只增加路由而不处理这些问题,系统反而更容易产生重复 Run、状态覆盖和静默丢失。
模块 2 要解决的核心问题是:怎样把一套单机工作流内核变成可被其他程序稳定调用、状态可查询、进程崩溃后可恢复的单实例服务。
2. 前置知识
阅读本文只需要先理解五个基础概念:
- 客户端(Client):主动发起请求的程序,例如命令行工具或另一个后端服务;
- 服务端(Server):监听网络请求、执行业务规则并返回响应的程序;
- 数据库(Database):把结构化数据持久保存,并提供查询和并发控制的软件;
- 进程(Process):程序一次正在运行的实例,退出后内存数据会消失;
- JSON(JavaScript Object Notation):一种用文本表达对象和数组的数据格式。
Executor(执行器) 是接收任务尝试、解释 Action 并返回结果的组件。Action 是工作流定义中交给执行器解释的动作标识。本文中的控制面仍然只启动一个服务实例,任务也仍由 Mock Executor(模拟执行器)处理;当前模拟执行器不会把 Action 当作本机命令运行,也不会调用真实模型。
后文还会反复使用以下项目术语:
- Workflow(工作流):描述任务、依赖、超时和重试规则的定义;
- Task(任务):Workflow 中一个可调度的工作单元;
- TaskKey(任务键):任务在一份 Workflow 中稳定且唯一的字符串标识;
- Workflow Version(工作流版本):一份创建后不再原地修改的 Workflow 定义;
- Run(运行实例) :某个 Workflow Version 的一次实际运行;每个 Run 都有唯一的 RunID(运行标识);
- TaskRun(任务运行状态):一个 Task 在某次 Run 中的状态记录;
- Attempt(执行尝试):TaskRun 的一次具体执行,失败后重试会产生新的 Attempt;
- Engine(工作流引擎):根据依赖和状态规则推进 Run,并把每次状态变化交给存储层提交的核心组件;
- Worker(工作进程):从平台领取任务并调用执行器完成工作的进程。模块 2 尚未实现独立 Worker,任务仍在控制面进程中执行。
3. 一句话理解控制面
控制面(Control Plane) 是接收用户操作、校验规则并管理系统状态的服务入口。
在本项目中,它负责创建工作流版本、启动和取消 Run、查询任务与事件,并协调工作流内核和 PostgreSQL。它不负责真正完成清洗文档、调用模型等业务动作;这些动作属于后续执行器或 Worker。
因此,"控制面"描述的是职责边界,不是某种特定框架:它决定什么可以创建、哪一份状态是事实、谁有权限操作以及运行由谁协调;执行器只接收已经确定的任务尝试并返回结果。
4. 从本地命令到网络服务
4.1 HTTP、REST 和 OpenAPI 分别是什么
API(Application Programming Interface,应用程序编程接口) 是程序之间约定好的调用入口和数据格式。控制面 API 让命令行工具、SDK 或其他服务通过网络创建和查询 Workflow、Run 等资源。
HTTP(Hypertext Transfer Protocol,超文本传输协议) 规定客户端怎样发送请求、服务端怎样返回状态码、Header 和 Body。Body 是请求或响应承载的数据,Header 是认证、内容类型等元数据。
REST(Representational State Transfer,表述性状态转移) 是一种组织网络接口的架构风格。它把 Workflow、Run 等对象视为资源,通过 HTTP 方法表达操作:
text
POST /api/v1/workflows 创建 Workflow v1
POST /api/v1/workflows/{id}/versions 创建不可变新版本
POST /api/v1/workflows/{id}/versions/1/runs 启动指定版本的 Run
GET /api/v1/runs/{run-id} 查询 Run
POST /api/v1/runs/{run-id}/cancel 请求取消
这里的 /api/v1 是接口版本前缀。它让将来出现不兼容修改时可以保留旧契约,而不是让所有客户端在同一天被迫升级。
OpenAPI(OpenAPI Specification,开放 API 规范) 是用机器可读文档描述 HTTP 路径、参数、认证和响应的标准。Schema 在这里是对 JSON 对象字段、类型、必填项和取值范围的结构定义。模块 2 使用 OpenAPI 3.1,并用结构化测试检查 15 个实际操作、成功响应 Schema、公共错误响应和本地引用。它不是服务实现,也不会自动保证代码正确;它的作用是形成可检查的接口契约,并为文档或未来生成多语言客户端提供输入。
服务中的 HTTP Handler(HTTP 请求处理器) 接收一个具体请求,完成路由、认证和参数解析,再调用应用服务。OpenAPI 描述 Handler 对外应遵守的契约,行为测试验证 Handler 实际返回的内容,两者需要同时存在。
4.2 为什么当前使用 HTTP/REST
HTTP 调试成本低,浏览器、curl 和各语言标准库都能调用,适合先建立公开控制面。当前没有选择:
- gRPC:它基于 Protocol Buffers 提供强类型和高效二进制通信。Protocol Buffers 是一种描述消息结构并把数据序列化为二进制格式的机制。gRPC 更适合内部服务间调用,但浏览器和人工调试需要额外工具;
- GraphQL:它允许客户端组合查询字段,适合复杂前端数据需求,但本模块的资源和操作边界较固定,引入查询语言会增加不必要复杂度。
如果后续 Worker 需要高频双向流式通信,可以单独评估 gRPC;这不要求把当前外部 REST API 推翻。
5. Workflow、版本和 Run
5.1 为什么定义必须版本化
Workflow 是逻辑工作流,例如 document-pipeline;Workflow Version 是它的一份不可变定义。
假设 v1 的第二步是"清洗文档",v2 把它改成"清洗并脱敏"。已经用 v1 启动的 Run 必须继续指向 v1,否则查询历史时会出现"状态记录说执行了旧步骤,定义页面却显示新步骤"的矛盾。
因此模块 2 采用:
text
Workflow: document-pipeline
v1: 创建后不可修改
v2: 创建后不可修改
Run A -> 永久引用 v1
Run B -> 永久引用 v2
"不可变"不是说数据库不能删除任何数据,而是说同一个版本号对应的定义内容不能被原地改写。需要修改时创建下一个版本。
5.2 为什么创建时和运行时都要编译
创建版本时,控制面调用模块 1 的编译器检查 TaskKey、依赖、环、超时和重试参数,并保存经过默认值归一化的定义。归一化是把省略值转换成明确值,例如把未填写的最大尝试次数补为 1。
运行时需要取得这份确定版本对应的编译结果,因为调度器需要 TaskKey 到数组下标的映射、依赖表和下游表。进程内缓存可以复用不可变编译结果;缓存丢失时,服务从数据库读取定义并重新编译。
创建时校验解决"非法定义不能入库",运行时编译解决"内存调度需要高效结构"。二者目的不同,不是无意义地重复工作。
5.3 为什么 Run 异步启动
HTTP 启动请求只等待初始 Run 持久化成功,然后返回 202 Accepted 和 RunID;工作流在后台执行。
202 Accepted 表示服务已经接受请求,但工作尚未完成。如果 HTTP 一直等到整个工作流结束,长任务会占用连接,客户端超时后也无法判断服务到底有没有创建 Run。
6. PostgreSQL 如何保存运行事实
6.1 从完整 JSON 文件到关系表
模块 1 每个 Run 保存一个完整 JSON 快照。模块 2 改用多张关系表:
text
workflows
workflow_versions
workflow_runs
task_runs
attempts
state_events
idempotency_records
关系表(Relational Table) 使用行和列保存数据,并通过主键、外键和约束表达关系。例如 attempts 必须引用一个已存在的 task_runs,这能让数据库拒绝孤立的 Attempt。
完整 JSON 写入直观,但每次任务变化都要重写整个 Run,分页查询也需要先加载全部内容。关系表允许只更新受影响的 Run、TaskRun、Attempt 和事件,并直接按索引查询。
数据库仍保存完整的不可变 Workflow Definition JSON,因为定义需要整体读取和重新编译;运行状态则拆为关系行。这是"整体定义 + 增量运行事实"的组合,而不是在文件和数据库各保存一份同样的 Run。
6.2 索引是什么
数据库索引(Database Index) 是数据库根据一个或多个字段额外维护的有序查找结构。查询条件与索引顺序匹配时,数据库可以从对应位置读取目标行,不必扫描整张表。模块 2 为恢复状态、Workflow 版本、等待重试任务和事件时间等实际查询建立索引。
索引会占用磁盘,并让写入多一步维护成本,因此不是越多越好。当前索引围绕已经存在的查询建立;没有查询证据的字段不提前添加。
7. 事务、幂等与 revision
7.1 什么是事务
事务(Transaction) 是一组要么全部成功、要么全部回滚的数据库操作。回滚表示撤销本次事务已经产生但尚未提交的修改。
创建 Run 不是只插入一行:还要写全部初始 TaskRun、状态事件和幂等记录。如果写到一半数据库报错,而前半部分已经永久生效,就会留下无法执行的残缺 Run。
正确边界是:
text
BEGIN
插入 workflow_runs
插入全部 task_runs
插入初始 state_events
插入 idempotency_records
COMMIT
任一步失败就 ROLLBACK。只有 COMMIT 成功,应用层才把 Run 放入后台执行队列。
事务不能让外部模型调用和数据库写入自动变成一个原子操作。执行器成功、状态提交前进程崩溃时,外部动作仍可能在恢复后重复执行,这就是至少执行一次语义仍然需要幂等副作用的原因。
7.2 什么是幂等
幂等(Idempotency) 表示同一个操作重复执行一次或多次,最终业务效果与执行一次相同。
网络客户端可能没有收到响应,于是重试创建 Run。服务端要求写请求携带 Idempotency-Key:
http
Idempotency-Key: demo-run-20260820-001
服务端在同一事务中保存 Key、请求哈希和资源 ID:
- 相同 Key、相同请求:返回第一次创建的资源;
- 相同 Key、不同请求:返回冲突;
- 不同 Key:视为新的业务操作。
请求哈希是根据规范化请求计算的固定长度摘要。它用来比较两次请求是否表达同一件事,不用来保存密码,也不能替代身份认证。
7.3 revision 和乐观并发控制
revision 是 Run 每次成功提交状态后递增的版本号。
两个执行路径可能同时从 revision 5 读取状态,各自计算新状态。数据库更新使用:
SQL(Structured Query Language,结构化查询语言) 是关系数据库用于查询和修改数据的语言。下面这条 SQL 只有在数据库中的 revision 仍为 5 时才会更新 Run:
sql
UPDATE workflow_runs
SET revision = 6, status = $new_status
WHERE run_id = $run_id AND revision = 5;
第一个事务更新一行并成功;第二个事务发现 revision 已不是 5,更新零行并报告冲突。这样旧状态不能静默覆盖新状态。
这种方法叫乐观并发控制(Optimistic Concurrency Control):先假设冲突不是常态,提交时再检查版本。它没有让冲突消失,而是把"悄悄写错"变成"明确失败并重新读取事实"。
7.4 为什么列表使用键集游标
列表不能一次返回全部数据,因此接口会限制每页数量,并在还有下一页时返回 next_cursor。键集分页(Keyset Pagination) 是根据上一页最后一条记录的稳定排序键继续查询。例如 Run 按 (created_at, run_id) 排序,下一页只读取排序键更大的记录。
这与**偏移分页(Offset Pagination)**不同。OFFSET 1000 表示跳过当前结果中的前 1000 行;页间发生插入或删除时,行的位置会移动,可能造成重复或遗漏。键集分页不依赖行的位置,数据库也可以从索引中的已知位置继续扫描。
模块 2 的游标是 URL 安全的 Base64 编码版本化 JSON。URL(Uniform Resource Locator,统一资源定位符) 是网络资源地址;"URL 安全"表示编码结果避开在地址中有特殊含义的字符。Base64 是把二进制数据转换为文本字符的编码方式,不是加密,客户端仍可能解码其中内容。不透明游标 表示客户端不应依赖其内部格式或修改内容,只需原样回传。服务端仍把游标当作不可信输入,限制解码大小,并验证资源类型、WorkflowID、RunID 和过滤条件。Run 列表支持 workflow_id 与 status 组合过滤,游标会绑定这组条件;更换条件后继续使用旧游标会返回 400。
键集分页避免的是页间行位移问题,不会把多次 HTTP 请求变成同一个数据库快照。新数据如果排序在已经翻过的位置,当前翻页过程不会回头返回它。
8. 迁移、认证、SDK 与 Docker
8.1 数据库迁移
数据库迁移(Database Migration) 是随代码版本管理、按顺序修改数据库结构的 SQL 文件。模块 2 使用显式 migrate up 命令创建表和约束,服务启动时只检查迁移版本,不擅自改表。
显式迁移让部署者知道何时发生结构变化,也避免多个服务实例在启动时同时修改生产数据库。代价是部署多一步操作;换电脑或创建新数据库时不能忘记执行迁移。
8.2 Advisory Lock
Advisory Lock(咨询锁) 是 PostgreSQL 提供的应用级协调锁。数据库不会自动把它绑定到某张业务表,应用用一个固定整数表示"控制面执行协调者"身份。
模块 2 在专用数据库连接上持有该锁。同一数据库的第二个服务实例拿不到锁就启动失败;连接断开时锁自动释放。
它只证明"当前只有一个协调者",不是高可用方案,也不是未来多 Worker 的任务租约。单实例进程故障时仍有短暂停机。
运行监督器(Coordinator) 是控制面进程中统一管理新 Run、恢复 Run 和锁健康的组件。它会周期性检查专用连接和锁所有权,并观察每次 Engine 执行返回的系统错误。一旦无法证明自己仍持有锁,就采用 fail-stop(故障即停止) :先把 ready 设为 false,中断本机执行,停止 HTTP 服务,再让进程以错误退出。
Context(Go 上下文) 是 Go 在调用链中传递取消、截止时间和请求范围信号的标准机制。上层 Context 取消造成的基础设施中断不会写成业务 canceled;只有取消 API、显式 Engine.Cancel 或执行器主动取消才会进入取消状态。
原进程不在连接恢复后自动重新加锁。原因是断连期间可能已有新进程取得锁,旧进程继续运行会产生两个协调者。更强的方案需要 fencing(栅栏机制):为每次所有权分配单调递增的令牌,存储和下游系统拒绝旧令牌的迟到写入。模块 2 还没有这种分布式所有权协议,因此选择退出并由新进程恢复更可靠。
8.3 Bearer Token
Bearer Token(持有者令牌) 是放在 HTTP Authorization Header 中的凭据:
http
Authorization: Bearer <token>
谁持有有效 Token,谁就拥有对应权限,因此 Token 必须像密码一样保护。模块 2 配置一个 viewer Token 和一个 operator Token:viewer 只能查询,operator 可以创建和取消。
这是本地单实例阶段的最小认证,不支持用户账号、Token 轮换和细粒度资源授权。服务日志只记录请求 ID、路径、状态和错误码,不记录 Authorization Header、Token 或请求体。
8.4 SDK 与 CLI
Go SDK 封装 HTTP、Bearer Token、JSON、错误和分页。CLI(Command-Line Interface,命令行界面)复用 SDK,不再自己拼接 HTTP 请求。
SDK 不默认重试写请求,因为库无法替调用方决定一次失败是否应该重试。调用方确实重试时必须复用原来的幂等 Key。
SDK 的公开返回类型定义在公开包中,不能泄露 Go 的 internal 类型;否则仓库外的程序无法正常声明这些类型,这会让"公开 SDK"只在项目内部好用。
8.5 Docker Compose
Docker 是构建、分发和运行容器化应用的平台。容器(Container) 是受到一定隔离的进程及其运行环境;它通常比虚拟机轻量,但仍然共享所在 Linux 系统的内核,不能被当作绝对安全边界。
在 macOS 上安装的 Docker Desktop 不只是一个 Dashboard。它同时提供:
- Docker CLI :终端里的
docker命令,是客户端; - Docker Engine:真正创建镜像、容器、网络和数据卷的服务端;
- 一个轻量 Linux 虚拟机:因为容器依赖 Linux 内核,而 macOS 本身不是 Linux;
- Docker Compose :读取
compose.yaml,按声明启动一组相关服务; - Dashboard:把命令行管理的对象用图形界面展示出来。
因此,在 Apple Silicon Mac 上运行 docker version 时,客户端可能显示 darwin/arm64,服务端显示 linux/arm64。这不是架构不一致,而是 macOS 客户端正在控制 Docker Desktop Linux 虚拟机中的 Engine。
理解 Dashboard 之前,需要先分清四个对象:
| 对象 | 含义 | 当前项目中的实例 |
|---|---|---|
| 镜像(Image) | 只读的运行模板,包含程序和基础文件 | postgres:16.10-bookworm |
| 容器(Container) | 根据镜像创建的运行实例,可以启动、停止或重新创建 | Compose 服务 postgres 对应的 PostgreSQL 容器 |
| 数据卷(Volume) | 由 Docker 管理、生命周期独立于普通容器的数据目录 | workload-postgres-data,保存数据库文件 |
| 端口映射(Port Mapping) | 把宿主机端口转发到容器端口 | 本机 127.0.0.1:5432 转发到容器的 5432 |
镜像本身只读,一个镜像可以创建多个彼此独立的容器。容器被删除不会自动删除镜像;数据卷也有独立生命周期,所以删除并重建 PostgreSQL 容器后,只要保留原数据卷,数据库文件仍可继续使用。
Docker Compose 是用一份 YAML 配置文件声明相关容器、端口、环境变量、健康检查和数据卷的工具。YAML 是一种用缩进表达层级结构的文本数据格式。当前仓库的 compose.yaml 声明了一个 PostgreSQL 服务:
text
postgres:16.10-bookworm 镜像
-> postgres 容器
-> 监听容器内 5432 端口
-> 映射到本机 127.0.0.1:5432
-> 数据写入 workload-postgres-data 数据卷
端口绑定使用 127.0.0.1,表示默认只允许当前电脑访问,不把开发数据库直接暴露给局域网。健康检查会在容器启动后调用 PostgreSQL 的 pg_isready,Dashboard 或 docker compose ps 显示 healthy,才表示数据库已经可以接受连接。
模块 2 的 Compose 只启动 PostgreSQL 16:
text
Go 控制面:本机进程
PostgreSQL:Docker 容器
工作流任务:安全的 Mock Executor
它解决的是"换电脑后如何得到一致的开发数据库",不表示工作流任务已经容器化,更不表示可以安全执行任意代码。真实任务容器和资源限制属于后续模块。
8.5.1 Dashboard 中应该看什么
Docker Desktop 不同版本的界面名称可能略有变化,但核心对象一致:
- Containers :可以看到当前 Compose 项目和其中的
postgres容器;重点检查运行状态、健康状态、端口和日志; - Images:可以看到已经拉取到本机的 PostgreSQL 镜像;删除镜像后,下次启动可能需要重新下载;
- Volumes :可以看到名称通常带 Compose 项目前缀的
workload-postgres-data数据卷;数据库数据是否保留主要取决于它; - Builds:展示根据 Dockerfile 构建镜像的记录;模块 2 直接拉取公开 PostgreSQL 镜像,没有构建项目镜像,因此这里可能为空。
Dashboard 的 Start、Stop、Restart 和 Delete 分别对应启动、停止、重启和删除容器。停止容器不会删除数据;删除容器后,Compose 可以根据配置重新创建它;删除 PostgreSQL 数据卷则会删除本机开发数据库内容。执行删除操作前,必须先确认目标是容器、镜像还是数据卷。
8.5.2 为什么不登录 Docker 账号也能运行
Docker Desktop 账号用于访问私有镜像、云端功能或更高的镜像拉取额度,不是启动本地 Engine 的必要条件。当前项目使用公开的 PostgreSQL 镜像,所以未登录状态也可以拉取和运行。
如果匿名拉取遇到 Docker Hub 限流,登录可能改善拉取额度;如果是网络连接被重置,登录通常不能修复网络问题。此时应先重试拉取、检查网络,或者使用可访问的镜像来源,而不是把"登录"当成固定安装步骤。
8.5.3 为什么当前选择 Compose
也可以把 PostgreSQL 直接安装到 macOS,或者用 Kubernetes 启动。Kubernetes 是用于部署、调度和管理容器化应用的集群编排系统。当前选择 Compose,是因为它把数据库版本、端口、初始账号、健康检查和数据卷写进仓库,换电脑后可以用一条命令得到接近一致的开发依赖,又不会过早引入 Kubernetes 的集群概念和排查层级。
Compose 的代价是必须安装 Docker Desktop,并理解容器和数据卷的生命周期。它也不负责数据库迁移、应用 Token 或 Go 服务启动;这些仍由项目自己的配置和命令管理。
9. 一次 Run 的完整数据流
下面是从客户端请求到任务完成的关键顺序:
text
CLI / SDK
-> HTTP POST + Bearer Token + Idempotency-Key
-> 控制面认证、解析严格 JSON
-> 加载指定不可变 Workflow Version
-> 取得或重建编译缓存
-> 创建 pending Run 候选状态
-> PostgreSQL 事务写 Run、TaskRun、事件和幂等记录
-> COMMIT 成功
-> Coordinator 接收 RunID 并监督后台执行
-> HTTP 返回 202 和 RunID
-> 后台 Engine 执行
-> 每次状态变化带 expected revision 增量提交
-> 客户端分页查询 Run、Task、Attempt 和事件
这里最重要的顺序是"先提交,后入队"。如果反过来,执行器可能已经产生外部副作用,数据库里却没有这个 Run。
10. 技术选型与替代方案
10.1 为什么选择 PostgreSQL,而不是 MySQL
PostgreSQL 和 MySQL 8 都支持事务、索引、JSON 和行锁,都能实现本模块,并不是 MySQL 性能或能力不够。
当前选择 PostgreSQL 的原因是:
jsonb、部分索引和复杂状态查询组合较自然;jsonb是 PostgreSQL 支持查询和建立索引的二进制 JSON 存储类型;- Advisory Lock 可以直接用于单实例协调实验;
- 后续可用
RETURNING、SKIP LOCKED等能力验证并发领取;RETURNING可以直接返回写入后的行,SKIP LOCKED可以在并发领取时跳过已被其他事务锁定的行; - 项目只维护一套 SQL,避免第一阶段同时承担两种数据库方言和测试矩阵。
没有优先选 MySQL 的原因是当前没有既有 MySQL 运维环境、兼容客户或团队标准要求。若真实使用方以 MySQL 为统一基础设施,或者 PostgreSQL 成为部署阻力,就应基于 Store 边界评估 MySQL 适配,而不是坚持技术偏好。
10.2 为什么不继续只用 FileStore
FileStore 是模块 1 中把每个 Run 的完整 JSON 快照保存为独立文件的存储实现。它依赖少、便于理解和恢复演示,但不适合多个客户端并发写、关系查询和分页。它仍保留为本地模式,不承担网络控制面的主存储。
10.3 为什么不先加 Redis 或消息队列
Redis 是常用于缓存和内存数据结构的独立服务;消息队列(Message Queue) 用于在生产者与消费者之间暂存并传递消息。它们能解决缓存、通知或大规模任务分发问题,但不能替代本模块需要的关系事务和历史查询。当前先用 PostgreSQL 提交事实,再由进程内后台执行;当多 Worker 实验出现明确的领取吞吐或通知瓶颈时,再评估队列。
11. 常见错误和故障窗口
11.1 数据库提交成功,后台执行前崩溃
这是模块 2 最重要的故障窗口:
text
事务 COMMIT 成功
-> 进程崩溃
-> 尚未调用后台 Execute
Run 已经是数据库事实,不能因为内存队列丢失而永远停留。服务下次启动时先取得 Advisory Lock,再扫描并加载 pending、running、waiting_retry 等非终态 Run,把它们交给受监督的后台恢复,然后进入 ready。这里等待的是"恢复所有权已经交接",不是等待所有 Run 执行到终态;否则一个长任务或很久以后才能重试的任务会阻塞整个 HTTP 服务启动。
11.2 保存原始定义与编译定义不一致
编译器会补默认重试次数。如果数据库保存用户原始 JSON,而内存缓存保存补过默认值的定义,同一个版本就出现两种内容,创建 Run 时的定义一致性检查会失败。
正确做法是保存编译结果返回的规范化定义,并基于同一份内容计算哈希。这不是修改用户业务含义,而是让省略的默认值变成持久化事实。
11.3 把所有应用错误都映射成 500
JSON 语法正确不代表业务输入有效。重复 TaskKey、缺失依赖、非法分页游标,以及 URL WorkflowID 与定义 ID 不一致,都应该返回 400;数据库连接失败等服务端问题才返回 500。
应用层需要稳定的错误类别,HTTP 层根据类别映射状态码,不能靠匹配错误字符串。
11.4 测试共用数据库
Repository(仓储层) 是封装数据库读写、向应用层提供持久化接口的组件。E2E(End-to-End,端到端)测试 会从 HTTP 等外部入口开始,经过应用服务和数据库验证完整链路。如果 Repository 测试会删除公共表,而 E2E 测试同时使用同一个数据库,Go 跨包并行测试可能互相破坏。即使连续运行几次没失败,也只是时序碰巧没有重叠。
模块 2 的集成测试为每个测试创建独立临时数据库。仅使用不同 schema(数据库中的命名空间)仍不能隔离数据库级 Advisory Lock,因此这里选择独立数据库,并在测试结束后清理。
11.5 数据库断开后在原进程继续运行
数据库连接断开会自动释放 Advisory Lock。即使网络随后恢复,旧进程也不能假设自己仍是唯一协调者,因为这段时间可能有新进程取得锁。模块 2 因此不在原进程重连并继续执行,而是让运行监督器报告首个致命错误,统一中断 Engine,关闭 HTTP,再由新进程从 PostgreSQL 已提交状态恢复。
这个顺序还要求区分"执行中断"和"业务取消"。进程退出只是停止本机工作,不代表用户要求取消 Run。旧进程不能在数据库故障时把 Run 写成 canceled;新进程恢复时会把遗留的 running Attempt 记为 interrupted,再根据剩余尝试次数继续或失败。
12. 实际验证与发现的问题
模块 2 使用真实 PostgreSQL 16、HTTP Handler、工作流 Engine 和 Mock Executor 完成了以下验证:
| 验证主题 | 已验证行为 |
|---|---|
| 事务 | 中途写入失败时 Run 和幂等记录一起回滚 |
| 幂等 | 相同 Key 和请求返回同一资源,不同请求返回冲突 |
| 并发 | 相同幂等 Key 的并发创建最终只有一个 Run |
| 版本 | 创建 v2 后,旧 Run 仍引用 v1 |
| 查询 | Run 摘要、Task、Attempt 和事件可以分页或按单任务读取 |
| 取消 | 运行中任务收到取消,Run、TaskRun 和 Attempt 收敛到 canceled |
| 单实例 | 第二个服务实例因为 Advisory Lock 被占用而拒绝启动 |
| 崩溃窗口 | 已提交但未入队的 pending Run 能被启动恢复扫描并执行成功 |
| 运行监督 | Execute、Resume 或锁检查的首个系统错误会令服务不可就绪并中断其他执行 |
| 中断语义 | 父 Context 中断不写 canceled,显式取消仍持久化 canceled |
| 运行期停库 | PostgreSQL 中断后旧控制面安全退出;新进程恢复同一 Run,Attempt 1 为 interrupted、Attempt 2 为 succeeded |
| 迁移保护 | 数据库存在当前程序未知的迁移版本时拒绝启动和继续迁移 |
| 稳定分页 | Workflow、Version、Run 和 Task 使用键集游标,Run 游标绑定过滤条件 |
| API 契约 | OpenAPI 3.1 结构化测试检查全部实际操作、Schema、鉴权、错误和引用 |
| 权限 | viewer 不能执行 operator 写操作 |
| 日志 | request_id(请求标识)、状态和错误码可关联,凭据不进入请求日志 |
| 性能 | 1 个任务、每轮 100 次操作的本机 HTTP/PostgreSQL 基准运行 5 轮 |
验证还暴露并修正了多类容易被普通成功路径遗漏的问题:规范化定义与数据库内容不一致、公开 SDK 泄露项目内部类型、启动恢复等待 Run 终态、后台执行错误无人观察、数据库断开被误写成业务取消,以及偏移游标在数据变化时不稳定。
首轮性能基线中,Run 串行查询五轮平均约 0.281 ms/op,10 逻辑 CPU 并行查询的总耗时折算约 0.081 ms/op,创建一个包含 1 个任务的 pending Run 平均约 1.234 ms/op。测试使用本机回环 HTTP、没有启用 TLS(Transport Layer Security,传输层安全协议)加密、编译缓存命中,并关闭后台执行;并行结果是吞吐口径,不是单请求尾延迟。P95 和 P99 分别表示 95% 和 99% 的请求耗时不超过对应值,用于观察少数慢请求。
自动化验证之外,项目还完成了一次真实进程故障实验:先用 90 秒 Mock 延迟让任务保持 running,再停止 PostgreSQL。旧控制面在 Advisory Lock 健康检查收到连接终止错误后以非零状态退出;数据库恢复并启动新控制面后,ready 检查通过,同一个 Run 最终为 succeeded,第一次 Attempt 为 interrupted,第二次 Attempt 为 succeeded。这验证了第 11.5 节描述的安全退出和恢复路径,也验证了基础设施中断没有被记录成用户取消。
这些自动化结果和一次人工故障实验能证明当前单实例控制面的主要代码路径,并提供一个小规模性能回归起点,但不能证明生产吞吐、高可用或多 Worker 安全性。当前没有 P95、P99 等尾延迟分位数、系统饱和点、跨机器数据库、后台执行竞争数据,也没有重复停库和长时间稳定性实验,因此本文不作生产性能承诺。
13. 进一步思考
完成模块 2 后,有三个问题需要在后续实验中继续回答:
- 单实例恢复会扫描全部非终态 Run;数量变大后,应该怎样分页、限速并避免启动风暴?
- 多 Worker 出现后,数据库事实、任务租约和消息通知怎样组合,才能让旧 Worker 的迟到结果不能覆盖新租约?
- Agent 调用模型和工具会产生不可逆副作用,哪些操作可以用幂等 Key,哪些必须采用结果去重或补偿流程?
这些问题不能靠增加几个字段提前"设计完成",需要在多 Worker、Agent Runtime 和故障注入模块中分别验证。
14. 总结与限制
从单机内核到控制面,不是简单增加 HTTP 路由,而是同时建立网络契约、不可变版本、事务、幂等、并发控制、认证和恢复顺序。
本模块最重要的技术结论是:数据库提交才是 Run 已被系统接受的事实;内存队列只是推进执行的手段。先持久化、后执行,再用启动扫描修复两者之间的崩溃窗口,才能避免任务静默丢失。
当前实现仍是单控制面实例和 Mock Executor,没有多 Worker、真实 Agent、动态租约、完整指标、生产容量验证或高可用。已有性能数据只是本机回环环境下的回归基线,不能直接推导生产容量。Bearer Token 也只适合本地和早期演示。下一阶段会在这套稳定的结构化提交流程上接入 Agent Runtime、模型与工具边界,并加入自然语言生成工作流草稿和用户确认流程。
15. 官方参考资料
- HTTP Semantics, RFC 9110
- The OAuth 2.0 Authorization Framework: Bearer Token Usage, RFC 6750
- OpenAPI Specification
- PostgreSQL: Transaction Isolation
- PostgreSQL: Explicit Locking and Advisory Locks
- PostgreSQL: JSON Types
- MySQL 8.4 Reference Manual: InnoDB Transaction Model
- Docker Docs: How Compose Works
- Docker Docs: Docker Architecture
- Docker Docs: Volumes
- Docker Docs: Publishing and Exposing Ports
- Go Documentation: log/slog