feat: publish immutable workflow definitions with typed SDK
This commit is contained in:
@@ -28,3 +28,7 @@
|
||||
- 业务事务Outbox示例使用独立module和业务数据库,核心SDK不得引入SQL驱动。业务记录/待发命令同事务;原请求、操作ID、目标及提交键冻结,HTTP在事务外,确认按最新租约代次与业务关联同事务。24小时/100次预算内原键核对,禁止换键、重置预算或把ACCEPTED解释成业务成功;REVIEW保留待核查数据。
|
||||
|
||||
- 相对延迟delay_ms只在首次受理事务按数据库时间计算;原时长参与摘要,零值omitempty保持旧摘要。重报先返回原回执,禁止重算执行时间;总时限包含等待。计划模板拒绝非零delay_ms,人工重试不自动继承首次等待。
|
||||
|
||||
- P4定义发布使用schema_version=1有限结构,词法作用域禁止跨分支/前向读取,任务策略显式冻结;SDK类型独立。发布按认证→应用额度→操作身份→定义→排序队列持锁;原键与同版本去重仍校验当前权限。定义/版本/revision/审计/回执同事务;同版本不同内容不得覆盖。保留精确数字,校验存储原文与结构摘要;不得把发布通过或DSL支持误称为实例执行已实现。
|
||||
|
||||
- P4定义子项已通过真实MySQL、HTTP丢回执、独立SDK和全量回归(docs/reports/phase-4-definitions.md)。下一步实例启动/固定版本/首批任务同事务、内部事件接续、顺序与组合推进、持久化等待;P4整体和P5–P7仍未完成。
|
||||
|
||||
@@ -10,6 +10,8 @@ Orbit 是面向多个业务项目的通用任务调度与流程编排平台,
|
||||
|
||||
已完成 P0–P3 平台功能阶段验收:35 个数据模型、版本化迁移、凭据及范围校验、队列与任务 API、WSS 执行、独立 SDK、自动 Worker 和 [执行尝试/证据查询](docs/api/execution-history.md)。[Worker Schema](docs/api/worker-schema.md) 与真实进程强杀恢复回归已通过;[安全重试](docs/api/retries.md)、[定时计划](docs/api/schedules.md)、[运行中取消](docs/api/cancellation.md) 、[队列资源控制](docs/api/queues.md) 、[应用共享配额](docs/api/application-resources.md) 、[资源键互斥](docs/api/keyed-concurrency.md) 和 [优先级/节点公平派发](docs/api/priority-and-fairness.md) 已接入服务端和 SDK,[可靠结果订阅](docs/api/subscriptions.md) 已实现持久化核心、HTTP/WSS、管理 SDK 与示例,[消费 SDK](docs/sdk/event-consumer.md) 和 [独立 MySQL Inbox 示例](docs/examples/result-inbox.md) 已实现;[批量任务受理](docs/api/batch-tasks.md) 已提供逐项幂等、失败分类与 SDK 示例;[人工核查与重试](docs/api/task-recovery.md) 已提供带证据的管理接口、关联新任务、审计查询及运行示例;[业务键与时间范围查询](docs/api/task-queries.md) 已提供过滤、分页与SDK场景;[任务来源查询](docs/api/task-source.md) 已提供人工重试父任务、计划触发记录与固定版本;[业务事务Outbox](docs/examples/task-outbox.md) 已提供独立业务库、稳定提交身份、发送恢复与CLI;[相对延迟执行](docs/examples/delayed-task.md)已提供数据库计时及原请求恢复;完整范围核对见[P3验收](docs/reports/phase-3.md),流程引擎与生产验证按 [持续开发计划](docs/08-development-plan.md) 继续实现。
|
||||
|
||||
P4已实现[流程定义与不可变版本发布](docs/api/workflow-definitions.md)、[结构版本1](docs/api/workflow-definition-format.md)及[SDK发布例子](docs/examples/workflow-definition.md)。实例执行、持久化等待和恢复继续开发;发布定义不会启动任务。
|
||||
|
||||
数据库禁止外键,关联一致性通过服务端事务校验,索引、唯一约束和 CHECK 保留;readiness 同时检查迁移及零外键要求。这不表示完整平台已具备生产服务能力。验证证据见 [P0](docs/reports/phase-0.md)、[P1](docs/reports/phase-1.md)、[P2 阶段验收](docs/reports/phase-2.md)、[安全重试](docs/reports/phase-3-safe-retry.md)、[计划调度](docs/reports/phase-3-schedules.md)、[取消与无外键回归](docs/reports/phase-3-cancellation.md) 及 [队列资源回归](docs/reports/phase-3-queue-resources.md)。
|
||||
|
||||
## 目录
|
||||
@@ -28,6 +30,7 @@ pulse/retry/ 冻结策略、有限退避与未知效果决策
|
||||
pulse/schedule/ Cron/间隔、漏跑、版本及计划接口
|
||||
internal/timezone/ 随计划冻结的内置时区数据快照
|
||||
weave/model/ 定义、实例、步骤、信号和查询轮次
|
||||
weave/definition/ 有限结构/类型校验与定义发布契约
|
||||
internal/config/ 配置校验
|
||||
internal/storage/mysql/ 连接、事务、迁移及集成测试
|
||||
internal/transport/httpserver/ HTTP 接入
|
||||
|
||||
@@ -42,6 +42,7 @@ func run() error {
|
||||
flags.BoolVar(&in.ScheduleAccess, "schedule-access", false, "also grant schedule read and manage actions for development")
|
||||
flags.BoolVar(&in.EventAccess, "event-access", false, "also grant result subscription read, manage and consume actions")
|
||||
flags.BoolVar(&in.RecoveryAccess, "recovery-access", false, "explicitly grant task review, manual retry and operation audit read actions")
|
||||
flags.BoolVar(&in.WorkflowAccess, "workflow-access", false, "explicitly grant workflow definition read and publish actions")
|
||||
if err := flags.Parse(os.Args[2:]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/schedule"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -79,6 +80,7 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
httpserver.RegisterSchedules(server, schedule.New(schedules))
|
||||
httpserver.RegisterWorkflowDefinitions(server, definition.New(mysql.NewWorkflowDefinitionStore(db)))
|
||||
scheduleCtx, stopSchedules := context.WithCancel(root)
|
||||
scheduleDone := make(chan struct{})
|
||||
go func() {
|
||||
@@ -132,7 +134,7 @@ func run() error {
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- server.Serve(listener) }()
|
||||
slog.Info("Orbit HTTP server starting", "address", listener.Addr().String(), "scope", "health, task, queue, schedule, subscription and Worker APIs", "node_id", node)
|
||||
slog.Info("Orbit HTTP server starting", "address", listener.Addr().String(), "scope", "health, task, queue, schedule, subscription, workflow definition and Worker APIs", "node_id", node)
|
||||
select {
|
||||
case err := <-done:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Weave 流程编排功能与设计
|
||||
|
||||
> 版本 0.2 · 2026-09-17。首版功能范围及研发定义发布方式已确认,包含有界重复查询和生产管理能力。具体字段、状态表示、算法及参数仍待技术评审;未实现、未验收。
|
||||
> 版本 0.3 · 2026-09-18。首版功能范围已确认。定义结构、发布校验与不可变版本已实现,见[定义格式](api/workflow-definition-format.md)和[发布API](api/workflow-definitions.md);下述实例推进、等待、查询与管理仍待P4–P5实现验收。
|
||||
|
||||
## 1. 职责与边界
|
||||
|
||||
@@ -30,13 +30,13 @@ Weave 不执行特定业务逻辑,也不把整段业务代码放入平台进
|
||||
|
||||
首版通过 SDK / 管理 API 提供运行查看与管理能力;后台操作界面可以调用这些 API,完整图形化控制台的交付范围另定。业务人员自由拖拽编排不纳入本次确认范围。
|
||||
|
||||
人工审核可以由业务系统完成,再提交经过授权的信号。Weave 记录提交者和证据,不代替业务系统判断“谁有权批准这笔账”。
|
||||
人工审核可以由业务系统完成,再提交经过授权的信号。Weave 记录提交者和证据,不代替业务系统判断“谁有权批准这次操作”。
|
||||
|
||||
## 3. 定义与实例身份
|
||||
|
||||
研发在代码中通过 Go SDK 描述步骤与规则,定义源码跟随业务项目进行版本管理和评审,再显式发布到 Orbit。业务服务每次启动不应自动覆盖已发布定义。
|
||||
|
||||
建议 SDK 将流程描述生成为可校验的 JSON 定义,表达步骤、输入映射、结果结构、条件、等待、时限和失败策略。Orbit 不执行上传的任意 Go 函数;业务计算和外部请求通过命名、版本化处理器完成。SDK 的构建 API、定义 Schema 及发布命令形式待协议评审。
|
||||
建议 SDK 将流程描述生成为可校验的 JSON 定义,表达步骤、输入映射、结果结构、条件、等待、时限和失败策略。Orbit 不执行上传的任意 Go 函数;业务计算和外部请求通过命名、版本化处理器完成。SDK采用强类型结构及显式引用,schema_version=1已按[定义格式](api/workflow-definition-format.md)实现,发布接口见[手册](api/workflow-definitions.md)。
|
||||
|
||||
发布前至少验证引用、可达性、顶层无环、分支穷尽性、结构化汇合、类型匹配与资源上限。有界重复查询封装为一个节点,在节点内部保存轮次和截止时间,不将次数上限展开成大量静态节点。定义发布需要独立权限和审计,版本内容不可变;重复发布同版本同内容可幂等确认,不同内容明确冲突。
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| P1 任务受理闭环 | 应用凭据与作用域权限、队列、幂等提交、查询、待执行取消、受理事件;SDK 客户端 | API/SDK 创建查询闭环;重复键与内容冲突、响应未确认重报、跨命名空间拒绝、参数边界 | 已完成并通过阶段回归(见 P1 报告;真实断路/提交 ACK 丢失注入留后续故障阶段) |
|
||||
| P2 Worker 执行闭环 | WSS 接入、能力/容量、领取与开始、租约、续租、结果确认、排空与恢复;Worker SDK | 真 Worker 执行;失联/重连/重复确认/旧尝试拒绝;业务效果未知转核查;节点故障接管 | 已完成并通过阶段回归:服务端、自动 SDK、历史查询、完整 Schema 与真实进程强杀恢复;测试拓扑和剩余生产故障矩阵见 P2 验收报告 |
|
||||
| P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、运行中取消、并发许可、配额/背压、可靠结果订阅、批量受理、独立任务核查/人工重试、查询筛选与事务Outbox接入 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 已完成平台功能阶段验收:[原范围逐项核对](reports/phase-3.md)、[本轮完整回归](reports/phase-3-delay-recovery.md)。补齐相对延迟、改参数关联示例和四种重试全停恢复场景;20个可运行示例全部通过。全平台生产门槛及SDK首版交付剩余项仍按P6–P7推进 |
|
||||
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 下一阶段:先实现定义校验、不可变版本发布及独立SDK,再接实例推进与持久化等待 |
|
||||
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 定义格式/词法类型校验、不可变发布、独立SDK及六类节点定义例子已实现,已通过[子项验收](reports/phase-4-definitions.md);实例推进、事件接续与持久化等待仍待实现 |
|
||||
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 待开发 |
|
||||
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 |
|
||||
| P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 待开发 |
|
||||
@@ -83,6 +83,7 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
|
||||
| 业务事务Outbox | 业务记录/请求同事务 → 原键发送 → 受理/业务关联同事务 → 故障恢复 | P3,已实现独立模块与完整示例 |
|
||||
| 业务操作定位 | 精确业务键 → 创建区间 → 游标分页 → 查询详情 | P3,已实现完整示例 |
|
||||
| 独立任务人工恢复 | 未知结果 → 证据核查 → 保留原任务 → 关联新任务 → 审计查询 | P3,已实现完整示例 |
|
||||
| 研发定义发布 | 六类节点描述 → 校验/发布 → 原键确认 → 新版本 → 读取旧版本 | P4定义子项,[已实现示例](examples/workflow-definition.md) |
|
||||
| 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4 |
|
||||
| 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4 |
|
||||
| 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Orbit HTTP / WebSocket 接口手册
|
||||
|
||||
更新:2026-09-17。接口实现状态随 [开发计划](../08-development-plan.md) 更新。
|
||||
更新:2026-09-18。接口实现状态随 [开发计划](../08-development-plan.md) 更新。
|
||||
|
||||
## 当前接口状态
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
| `/v1/schedules`、不可变版本、暂停/恢复及触发查询 | P3 已实现 | [计划接口与 SDK 场景](schedules.md) |
|
||||
| `/v1/subscriptions` 及管理、交付查询与重试 | 已实现 HTTP、管理 SDK 和完整管理示例 | [订阅手册](subscriptions.md) |
|
||||
| `/v1/events/connect` | 已实现 WSS、消息 Schema、连接 SDK 和自动消费器 | [消息协议](subscriptions.md#5-websocket-建连与边界) / [消费 SDK](../sdk/event-consumer.md) |
|
||||
| `/v1/workflow-definitions` 创建、发布与固定版本读取 | P4定义子项已实现,实例执行尚未实现 | [定义接口](workflow-definitions.md)、[格式规范](workflow-definition-format.md) |
|
||||
| `/v1/workflows` 与流程管理 | 计划 P4–P5,尚未实现 | [流程接口规划](workflows.md) |
|
||||
|
||||
未实现路由返回 404。健康检查就绪不表示任务执行、定时调度或流程引擎已经可用。
|
||||
|
||||
@@ -61,3 +61,5 @@ P3 应用额度管理新增 `application:resources:read` 和 `application:resour
|
||||
P3 独立任务核查、人工重试和操作审计分别增加 `tasks:review`、`tasks:retry`、`tasks:audit:read`,bootstrap显式 `--recovery-access` 才授予三项;默认凭据和 `--worker-access` 都不自动获得。核查/重试记录真实认证凭据及证据,读取任务详情仍需tasks:read,见[人工恢复](task-recovery.md)。
|
||||
|
||||
P3 结果订阅核心新增 `events:read`、`events:manage` 和 `events:consume`,bootstrap 仅显式 `--event-access` 时授予。订阅绑定创建凭据与单个队列,不能用同命名空间的另一张凭据消费或管理。当前 HTTP/WSS、管理及消费 SDK 已接入;凭据重绑继续开发,详见 [订阅接口](subscriptions.md)。
|
||||
|
||||
P4定义发布新增`workflows:definitions:read`与`workflows:definitions:publish`,仅显式`--workflow-access`授予。创建/读取元数据作用于命名空间,发布及读取完整版本额外校验全部引用队列;默认提交/Worker凭据不获此权限。发布不创建任务,不要求tasks:submit,详见[定义API](workflow-definitions.md)。
|
||||
|
||||
102
docs/api/workflow-definition-format.md
Normal file
102
docs/api/workflow-definition-format.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# 流程定义结构版本1
|
||||
|
||||
本页冻结已实现的发布格式与静态校验。TASK、BRANCH、PARALLEL、SIGNAL、TIMER、POLL均可描述并发布;对应实例执行器仍按P4–P5开发,发布成功不能当作流程执行成功。API见[定义发布](workflow-definitions.md)。
|
||||
|
||||
## 1. 顶层、顺序块与类型
|
||||
|
||||
顶层`schema_version`必须为1;`input_type`声明实例输入;`timeout_ms`为1毫秒至30天的总时限;`failure_policy`必填`REVIEW`或`FAIL_AFTER_SETTLED`;`body`为顺序块。未来实例总时限包含等待和暂停,不能因恢复重新计时;未知外部效果始终需要核查,不能由失败策略覆盖。
|
||||
|
||||
一个块包含`steps`(可省略为空,不能为null)、必填`output_type`和`output`。步骤顺序固定,输出表达式在全部步骤完成后解析。空块可以直接映射输入或常量。组合节点只把该块输出暴露给外部,禁止从外部读取内部步骤。
|
||||
|
||||
类型对象使用`kind`:
|
||||
|
||||
| kind | 附加字段 | 契约 |
|
||||
| --- | --- | --- |
|
||||
| STRING | 可选`enum: string[]` | 非空枚举最多32项,值互异且每项最多128字节;无enum表示任意有效JSON字符串 |
|
||||
| NUMBER | 无 | 精确JSON数字;数字token最长256字节,指数范围等沿用平台JSON边界,不以float64比较 |
|
||||
| BOOLEAN / NULL | 无 | 必须精确匹配布尔或null,不做隐式转换 |
|
||||
| OBJECT | `fields: map<string,Type>` | 0–64个字段,所有声明字段必填,额外字段不允许;空映射可省略 |
|
||||
| ARRAY | 必填`items: Type` | 所有元素同型;运行数据校验最多4096项 |
|
||||
| JSON | 无 | 任意合法JSON整体值;不允许根据未知字段或下标做引用 |
|
||||
|
||||
类型嵌套最多8层。字段名1–64 ASCII字符,首位字母或数字,其余可含`.`、`_`、`-`;每个path元素按完整字段名匹配,点号不自动展开。类型不支持可选字段、隐式null、联合类型或脚本。
|
||||
|
||||
输入/结果校验同时受256 KiB及平台JSON深度和数字边界限制。NUMBER和STRING不互相转换;字符串枚举来源必须是目标枚举子集。OBJECT字段集合必须相同且递归兼容。目标JSON可以接收任意合法类型,JSON来源不能窄化为未验证的具体类型。
|
||||
|
||||
## 2. 数据表达式与条件
|
||||
|
||||
| Value.kind | 配置 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| LITERAL | 必填`literal` | 任意合法常量,显式null仅用于允许NULL/JSON的目标;保留原数字文本 |
|
||||
| REFERENCE | 必填`reference:{source,path?}` | source为`input`、`workflow`或当前词法作用域已完成步骤key;path最多8个已声明OBJECT字段 |
|
||||
| OBJECT | `fields: map<string,Value>` | 构造对象,最多64个字段;输出契约为OBJECT时必须覆盖全部字段 |
|
||||
| ARRAY | `items: Value[]` | 构造数组,最多256项;更大业务数组可整体引用 |
|
||||
|
||||
表达式最多8层;禁止前向、自引用、跨兄弟分支引用和越过组合节点引用内部步骤。空path引用整个值,不支持数组索引、动态路径、字符串插值、当前时间、随机数或任意代码。`workflow`内置类型为`{id: STRING, business_key: STRING}`。
|
||||
|
||||
条件为`Predicate`:比较操作`EQ/NE/LT/LE/GT/GE`必须有`left`和`right`,且类型相同;有序比较只支持STRING、NUMBER,布尔和NULL只支持相等/不等。`AND/OR`使用2–16项`arguments`,`NOT`恰好1项,不能同时携带比较操作数;条件最多8层。STRING按精确文本比较,NUMBER后续执行必须保留十进制精度。
|
||||
|
||||
不属于当前kind的非空配置会拒绝;可省略集合与空集合使用统一编码。任何未识别字段仍直接拒绝。
|
||||
|
||||
## 3. 步骤
|
||||
|
||||
每步必填`key`和`kind`。key在整个定义中唯一,1–64 ASCII字符,名称规则同字段名,保留`input`、`workflow`。必须且只能携带对应的一种配置。
|
||||
|
||||
### TASK:普通业务任务
|
||||
|
||||
`task`字段:
|
||||
|
||||
| 字段 | 类型 / 规则 |
|
||||
| --- | --- |
|
||||
| queue_id | 必填32位ID;发布时核对当前命名空间、队列存在和权限 |
|
||||
| handler / handler_version / payload_version | 必填,沿用Pulse命名与版本规则,Worker承载对应实现 |
|
||||
| input_type / input | 必填,处理器参数契约及构造表达式 |
|
||||
| result_type | 必填,权威成功结果契约;未来不匹配不能推进后续步骤 |
|
||||
| timeout_ms | 必填正值,不超过流程总时限;执行时还受实例剩余时间约束 |
|
||||
| retry | 必填显式策略:max_attempts包含首次,1–100;attempt_timeout_ms等按[Pulse规则](retries.md)校验;多次尝试须提供合法退避区间 |
|
||||
| recovery_safety | 必填RECONCILE_ONLY或IDEMPOTENT,不接受隐式默认策略 |
|
||||
| priority | 可省略为0,0–9 |
|
||||
| business_key / concurrency_key | 可选STRING表达式;业务键只用于追踪,资源键按Pulse许可语义使用 |
|
||||
|
||||
输入和结果类型不能声明为NULL,任务输入不能是字面null。JSON来源仍可能在运行时产生null,实例引擎必须再次检查实际输入/结果、键长度和Pulse限制,不能把静态通过当作运行数据有效。处理器名/版本是否有在线Worker不在发布时检查,发布不要求Worker正在运行。
|
||||
|
||||
### BRANCH:条件分支
|
||||
|
||||
`branch.cases`为1–16项`{when: Predicate, body: Block}`,按顺序取第一个匹配路径;`branch.default`必须给出Block。所有路径的output_type必须等价。对外结果是选中路径输出,不允许绕过默认分支或引用仅部分路径存在的内部节点。
|
||||
|
||||
### PARALLEL:有限并行与汇合
|
||||
|
||||
`parallel.branches`为2–8项`{name,body}`,name在本节点内唯一,规则同字段名。分支分别使用进入该节点前的作用域;全部成功后汇合。节点结果为以分支name命名的OBJECT,例如`outputs.preview`通过source=`outputs`、path=`["preview"]`读取。没有任意交叉边、动态分支或任意JOIN节点。
|
||||
|
||||
### SIGNAL:等待输入
|
||||
|
||||
`signal`必填`name`(最多128)、`payload_version`(最多32)、`correlation`(STRING表达式)、`payload_type`和`timeout_ms`。名称规则同字段名;等待时限为正值且不超过流程总时限。correlation字面值必须非空且最多512字节,动态值在激活时再检查并冻结。节点输出为经payload_type验证的信号内容。
|
||||
|
||||
信号的提前到达、重复、激活关联、授权和竞态处理属于后续实例引擎;本次只验证并保存等待契约。
|
||||
|
||||
### TIMER:等待时间
|
||||
|
||||
`timer.delay_ms`为正值且不超过流程总时限;输出类型为NULL。等待不占业务Worker并发槽,实际持久化计时和恢复随后实现。
|
||||
|
||||
### POLL:有界重复查询
|
||||
|
||||
| 字段 | 规则 |
|
||||
| --- | --- |
|
||||
| task | 完整TASK配置,固定查询处理器与输入;只能查询既有作业,不能重复发起原业务提交 |
|
||||
| max_rounds | 1–1000,包含首次查询;每轮单独Pulse任务,单轮重试不增加查询轮次 |
|
||||
| timeout_ms | 正值,不超过流程时限;task.timeout_ms不得超过此值 |
|
||||
| interval_ms | 至少1000,必须小于查询总时限;从“仍在处理”结果受理后起算,不补发错过轮次 |
|
||||
| status_path | 可省略为空,引用task.result_type中的STRING枚举;空表示整个结果就是枚举 |
|
||||
| pending / succeeded | 各至少1个枚举值 |
|
||||
| failed / review | 可省略为空,与前两组互斥;四组必须无重复、完整覆盖状态枚举 |
|
||||
| final_signal | 可选SIGNAL;类型必须等价于查询结果,timeout_ms必须等于查询总时限 |
|
||||
|
||||
查询成功且PENDING意味着继续等待;网络或执行错误才按该轮Pulse策略重试。两种预算独立,节点总时限不延长。最终信号不得是PENDING;轮次/时限耗尽、信号与查询同时到达的处理仍由后续运行器实现。发布格式中的配置不提供任意循环能力。
|
||||
|
||||
## 4. 整体预算与兼容
|
||||
|
||||
最多128个静态步骤(组合节点也计数),顶层块深度0,嵌套块深度不超过4;嵌套并行计算出的业务任务同时运行上界不超过32。SIGNAL/TIMER不占此业务任务并发。所有静态TASK的max_attempts加所有POLL的`max_rounds × max_attempts`不超过10000;条件各路径也全部计入保守预算,不依据当前输入缩减。
|
||||
|
||||
定义大小、JSON总深度、类型深度和块深度同时适用,任何一项超限都拒绝;不能仅满足步骤数就忽略深度。完整定义在调用方编码后才有最终字节大小。
|
||||
|
||||
结构版本1的既有定义解释必须兼容;将来若改变语义或冻结内容格式,应增加schema_version和独立校验/执行分支,不能原地修改旧版本。业务版本名由研发显式推进;运行实例固定版本及后续恢复兼容是P4–P7待验收项。
|
||||
159
docs/api/workflow-definitions.md
Normal file
159
docs/api/workflow-definitions.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# 流程定义与不可变版本
|
||||
|
||||
状态:P4 定义校验、持久化发布及独立 Go SDK 已实现;实例启动、推进、等待和恢复尚未实现。规则格式见[结构版本1](workflow-definition-format.md),可运行源码及命令见[研发发布场景](../examples/workflow-definition.md)。
|
||||
|
||||
## 1. 权限、传输与范围
|
||||
|
||||
使用 HTTPS、`Authorization: Bearer <token>`、统一 `request_id` 信封。POST 必须有唯一 `Idempotency-Key` 头及 `Content-Type: application/json`。请求体最多512 KiB,完整定义编码最多256 KiB,JSON总深度最多32;拒绝未知字段、大小写别名、重复字段、非法Unicode、无效数字和不符合类型的null。
|
||||
|
||||
| 接口 | 权限 | 范围 |
|
||||
| --- | --- | --- |
|
||||
| `POST /v1/workflow-definitions` | `workflows:definitions:publish` | 当前凭据命名空间 |
|
||||
| `GET /v1/workflow-definitions/{definition_id}` | `workflows:definitions:read` | 当前命名空间元数据,不返回规则 |
|
||||
| `POST /v1/workflow-definitions/{definition_id}/versions` | `workflows:definitions:publish` | 定义及其引用的全部队列 |
|
||||
| `GET /v1/workflow-definitions/{definition_id}/versions/{version}` | `workflows:definitions:read` | 定义及其引用的全部队列 |
|
||||
|
||||
发布与完整版本读取均在事务中核对队列存在、命名空间归属及当前凭据队列范围。发布不要求`tasks:submit`,不创建任务;发布者可以与运行方分权。默认Bootstrap凭据没有流程权限,显式`--workflow-access`才增加上述两个动作。读取元数据只受命名空间级定义权限限制。
|
||||
|
||||
GET不接受查询参数。当前没有定义列表、修改、删除或latest接口;调用方保存创建回执中的定义ID,并显式使用版本名。
|
||||
|
||||
## 2. 创建定义
|
||||
|
||||
`POST /v1/workflow-definitions`
|
||||
|
||||
| 字段 | 类型 | 规则 |
|
||||
| --- | --- | --- |
|
||||
| name | string | 必填,1–128 ASCII字符;首位字母或数字,其余可含`.`、`_`、`-`;同命名空间唯一且区分大小写 |
|
||||
| description | string | 可省略为空,最多1024个Unicode字符,不允许NUL |
|
||||
|
||||
```json
|
||||
{"name":"object.pipeline","description":"通用对象作业"}
|
||||
```
|
||||
|
||||
首次创建201,原键重报200。回执示意(实际ID、时间由服务端生成):
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id":"0123456789abcdef0123456789abcdef",
|
||||
"data":{
|
||||
"definition":{
|
||||
"id":"1123456789abcdef0123456789abcdef",
|
||||
"namespace_id":"2123456789abcdef0123456789abcdef",
|
||||
"name":"object.pipeline",
|
||||
"description":"通用对象作业",
|
||||
"revision":"1",
|
||||
"created_at":"2026-09-18T00:00:00Z",
|
||||
"updated_at":"2026-09-18T00:00:00Z"
|
||||
},
|
||||
"deduplicated":false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`GET /v1/workflow-definitions/{definition_id}`的`data`直接返回上述`definition`对象。`revision`为字符串编码的uint64,初始1,每个新版本发布增加1。名称和说明在本阶段没有修改入口。原创建回执保留创建时的revision,当前值通过GET查询。
|
||||
|
||||
每个命名空间最多100个定义。不同键重用同一名称返回409;恢复创建结果必须使用原键,不能用名称猜测操作是否成功。
|
||||
|
||||
## 3. 发布版本
|
||||
|
||||
`POST /v1/workflow-definitions/{definition_id}/versions`
|
||||
|
||||
| 字段 | 类型 | 规则 |
|
||||
| --- | --- | --- |
|
||||
| version | string | 必填,1–64 ASCII字符,名称规则同上;研发指定的业务版本名,不与schema_version混用 |
|
||||
| specification | object | 必填,完整结构版本1定义;所有生效任务策略显式冻结 |
|
||||
| reason | string | 必填非空白,最多1024个Unicode字符,不允许NUL,写入发布审计 |
|
||||
|
||||
最小定义直接返回输入,可用于验证发布链路:
|
||||
|
||||
```json
|
||||
{
|
||||
"version":"v1",
|
||||
"reason":"initial developer release",
|
||||
"specification":{
|
||||
"schema_version":1,
|
||||
"input_type":{"kind":"STRING"},
|
||||
"timeout_ms":60000,
|
||||
"failure_policy":"REVIEW",
|
||||
"body":{
|
||||
"output_type":{"kind":"STRING"},
|
||||
"output":{"kind":"REFERENCE","reference":{"source":"input"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
首次发布201;原键重报或不同键的同版本同内容发布200。信封`data`为:
|
||||
|
||||
```json
|
||||
{
|
||||
"version":{
|
||||
"id":"3123456789abcdef0123456789abcdef",
|
||||
"namespace_id":"2123456789abcdef0123456789abcdef",
|
||||
"definition_id":"1123456789abcdef0123456789abcdef",
|
||||
"version":"v1",
|
||||
"specification":{
|
||||
"schema_version":1,
|
||||
"input_type":{"kind":"STRING"},
|
||||
"timeout_ms":60000,
|
||||
"failure_policy":"REVIEW",
|
||||
"body":{
|
||||
"output_type":{"kind":"STRING"},
|
||||
"output":{"kind":"REFERENCE","reference":{"source":"input"}}
|
||||
}
|
||||
},
|
||||
"content_digest":"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"published_by":"4123456789abcdef0123456789abcdef",
|
||||
"published_at":"2026-09-18T00:00:00Z",
|
||||
"analysis":{"steps":0,"max_concurrency":0,"max_task_attempts":0,"queues":[]}
|
||||
},
|
||||
"deduplicated":false
|
||||
}
|
||||
```
|
||||
|
||||
示意中的全零摘要不是上述请求的实际摘要。实际`content_digest`为64位小写十六进制SHA-256:对已校验结构的规范JSON加域前缀`orbit.weave.spec.v1\0`后计算。对象键顺序、数字的等值十进制表示不影响内容比较;数组顺序属于内容,包括枚举/状态分组数组。数字全程保留精度,不使用float64生成内容摘要。
|
||||
|
||||
`published_by`是最初发布凭据ID,`published_at`是原始数据库UTC时间;重复发布不改写。`analysis`按全部静态路径保守计算节点、并发与任务尝试上界,队列排序去重且始终为数组。它不是运行用量或已授予配额。
|
||||
|
||||
每个定义最多100个版本。达到上限仍可原键核对和同版本同内容确认。发布v2不改写v1。数据库不可变触发器拒绝UPDATE,关联关系由事务校验,无外键、无级联删除。
|
||||
|
||||
## 4. 幂等、并发及未知结果
|
||||
|
||||
- 创建操作按命名空间和`workflow.definition.create`隔离;发布按命名空间、`workflow.version.publish`及定义ID隔离。幂等键格式和90天保留窗口沿用[公共规则](tasks.md)。
|
||||
- 原键绑定整个命令,包括`reason`。原键不同内容为`IDEMPOTENCY_CONFLICT`;换键不允许覆盖已存在版本,不同规则为`STATE_CONFLICT`。
|
||||
- 不同键、相同版本及相同规则返回第一次的版本。后续请求的原因不替换原审计,不增加父定义revision,不重复产生发布审计。
|
||||
- 并发控制顺序为认证共享锁→应用额度行→操作身份→定义行→排序后的引用队列。定义、版本、父定义revision、审计和幂等回执同事务提交,审计失败全部回滚。
|
||||
- 任何超时、连接中断或503都可能发生在提交前后。保留原ID、版本、内容、原因和键核对,禁止生成新身份或盲目修改规则重发。
|
||||
- 每次重报仍核对当前权限和引用队列;权限被收回、队列缺失时不得用旧回执绕过。版本读取还验证存储原文、结构版本和内容摘要,损坏或不兼容数据返回503,不能默默忽略后继续使用。
|
||||
|
||||
## 5. 读取与错误
|
||||
|
||||
`GET /v1/workflow-definitions/{definition_id}/versions/{version}`的`data`直接返回上述`version`对象,200。输入版本名被严格限制,不能携带路径片段。
|
||||
|
||||
| 状态 / code | 处理 |
|
||||
| --- | --- |
|
||||
| 400 / INVALID_ARGUMENT | 修正结构、引用、类型、预算或字段;此校验拒绝不发布版本 |
|
||||
| 401 / UNAUTHENTICATED | 核对凭据与有效期 |
|
||||
| 403 / PERMISSION_DENIED | 缺少定义动作权限 |
|
||||
| 404 / NOT_FOUND | 定义/版本/队列不存在、不同命名空间或队列范围不允许;不据此猜测他人资源 |
|
||||
| 409 / IDEMPOTENCY_CONFLICT | 原键绑定不同命令;回查原操作,不直接换键 |
|
||||
| 409 / STATE_CONFLICT | 名称冲突或同版本不同规则;有意更新需显式发布新版本 |
|
||||
| 409 / WORKFLOW_DEFINITION_LIMIT | 命名空间达到100个定义,原创建回执仍可恢复 |
|
||||
| 409 / WORKFLOW_VERSION_LIMIT | 定义达到100个版本,旧版本确认与读取不受此上限影响 |
|
||||
| 413 / BODY_TOO_LARGE | 整个HTTP请求超过512 KiB |
|
||||
| 415 / UNSUPPORTED_MEDIA_TYPE | 必须使用application/json |
|
||||
| 503 / UNAVAILABLE | 服务/事务/存储完整性异常,原身份核对;持续异常需要运维核查 |
|
||||
|
||||
## 6. SDK
|
||||
|
||||
独立module为`git.yuntuo.hk/k2safe/orbit/sdk/go`,无服务端模型、数据库或ORM依赖。
|
||||
|
||||
| 方法 | 输入 → 返回 |
|
||||
| --- | --- |
|
||||
| `CreateWorkflowDefinition(ctx,key,in)` | CreateWorkflowDefinitionRequest → WorkflowDefinitionResult |
|
||||
| `GetWorkflowDefinition(ctx,id)` | 定义ID → WorkflowDefinition |
|
||||
| `PublishWorkflowVersion(ctx,id,key,in)` | PublishWorkflowVersionRequest → WorkflowVersionResult |
|
||||
| `GetWorkflowVersion(ctx,id,version)` | 定义ID及固定版本 → WorkflowVersion |
|
||||
| `WorkflowRef(source,path...)` | 构造有类型校验的引用表达式;发布时校验作用域 |
|
||||
|
||||
规则通过`WorkflowSpecification`、`WorkflowStep`等强类型结构构建,常量使用`json.RawMessage`,避免把大整数先转float64。SDK不复制服务端完整校验器;发布返回权威校验结果。网络错误不会自动重试发布,也不自动变更版本。完整示例见[发布场景](../examples/workflow-definition.md)。
|
||||
@@ -1,10 +1,9 @@
|
||||
# 流程接口规划
|
||||
# 流程接口状态
|
||||
|
||||
状态:计划 P4–P5,尚未实现。研发通过 Go SDK 描述流程并显式发布,服务端保存不可变定义版本。
|
||||
状态:定义格式校验、创建与不可变版本发布已实现,详见[定义接口](workflow-definitions.md)、[格式规范](workflow-definition-format.md)和[可运行SDK例子](../examples/workflow-definition.md)。实例执行与管理仍按P4–P5开发。下表仅列后续实例接口。
|
||||
|
||||
| 操作 | 候选接口 | 完成要求 |
|
||||
| --- | --- | --- |
|
||||
| 发布版本 | `POST /v1/workflow-definitions/{definition_id}/versions` | 图结构、类型、预算校验;同版本同内容幂等,不同内容冲突 |
|
||||
| 启动实例 | `POST /v1/workflows` | 固定版本并幂等启动,首批任务与实例原子创建 |
|
||||
| 查询实例 | `GET /v1/workflows/{workflow_id}` | 状态、暂停标记、版本、等待原因、预算摘要 |
|
||||
| 查询步骤 | `GET /v1/workflows/{workflow_id}/steps` | 步骤、激活、查询轮次、输入/结果引用与任务关联 |
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
示例只使用通用的作业、对象、文件、资源与审核信号,不包含接入方项目名称或行业规则。
|
||||
|
||||
## 已可运行:研发定义与发布流程
|
||||
|
||||
- [完整命令和结果](workflow-definition.md)、[SDK源码](../../sdk/go/examples/workflow-definition/main.go)。
|
||||
- 六类节点的有限结构定义、原键及同版本确认、新版本发布、旧版本读取保持不变。
|
||||
- 当前只发布定义,实例执行仍在P4–P5开发。
|
||||
|
||||
## 已可运行:业务事务与任务提交Outbox
|
||||
|
||||
- 独立模块:[task-outbox](../../examples/task-outbox/README.md),[完整前置、命令、Go方法及故障处理](task-outbox.md)。
|
||||
|
||||
54
docs/examples/workflow-definition.md
Normal file
54
docs/examples/workflow-definition.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 研发定义与发布流程
|
||||
|
||||
本例运行真实HTTP发布与读取,验证六种节点的格式以及不可变版本。当前不创建流程实例、不执行外部业务。实例运行示例随P4–P5交付。
|
||||
|
||||
源码:[workflow-definition/main.go](../../sdk/go/examples/workflow-definition/main.go)。接口:[定义发布](../api/workflow-definitions.md);字段规范:[结构版本1](../api/workflow-definition-format.md)。
|
||||
|
||||
## 1. 准备与运行
|
||||
|
||||
使用专用开发环境,MySQL已完成当前迁移、服务已启动,配置方式见[SDK接入](../sdk/README.md)。从仓库根目录初始化专用发布凭据(ORBIT_MYSQL_DSN已在当前环境安全设置):
|
||||
|
||||
```sh
|
||||
bin/orbit-admin bootstrap --application definition-demo --namespace default --environment test --queue workflow --workflow-access --output /tmp/orbit-definition.credentials.json
|
||||
```
|
||||
|
||||
首次创建文件后保留,不要覆盖或打印凭据内容。该标志只增加定义读取和发布动作;生产凭据应限制到需要的命名空间及队列。
|
||||
|
||||
```sh
|
||||
cd sdk/go
|
||||
ORBIT_URL=http://127.0.0.1:8080 \
|
||||
ORBIT_CREDENTIALS_FILE=/tmp/orbit-definition.credentials.json \
|
||||
ORBIT_OPERATION_ID=object-definition-demo-001 \
|
||||
go run ./examples/workflow-definition
|
||||
```
|
||||
|
||||
开发回环地址可用HTTP,生产使用HTTPS。可改用`ORBIT_TOKEN`及`ORBIT_QUEUE_ID`,不把Token写入URL或提交版本库。不传`ORBIT_OPERATION_ID`时随机生成并先输出;发生未知结果时保存此操作ID,保持相同队列及源码再次运行。已成功的相同操作ID也可以重复执行,旧版本与revision不会增加。
|
||||
|
||||
## 2. 定义内容与调用链
|
||||
|
||||
输入契约为`{object_ref: STRING, operation: STRING, approved: BOOLEAN}`。示例依次定义:
|
||||
|
||||
1. `prepare`:对象准备TASK,引用input.object_ref,输出STRING。
|
||||
2. `route`:根据input.approved选择路径,带显式默认分支。
|
||||
3. `outputs`:并行定义preview与manifest两个TASK,全部成功后输出命名结果。
|
||||
4. `approval`:等待与input.operation关联的布尔审核信号。
|
||||
5. `settle`:1000毫秒TIMER等待。
|
||||
6. `result`:有界POLL查询,最多5轮、每轮最多2次尝试、总时限20秒;PENDING、DONE、FAILED、REVIEW四种状态完全分组,并声明可选最终回执。
|
||||
|
||||
实际调用:CreateWorkflowDefinition → PublishWorkflowVersion(v1) → 原键确认v1 → 新键同内容确认v1 → 发布v2 → GetWorkflowVersion(v1) → GetWorkflowDefinition。v2仅把整个流程时限从60秒改到90秒;测试确认v1内容和原发布身份保持不变。
|
||||
|
||||
可直接在自己的Go代码中构建强类型`orbit.WorkflowSpecification`;`orbit.WorkflowRef("input","object_ref")`生成引用。常量使用`json.RawMessage`保留精确JSON数字。条件、步骤与处理器都在研发代码中声明,不上传任意可执行代码。
|
||||
|
||||
## 3. 预期输出与异常
|
||||
|
||||
输出包含操作ID、定义ID、v1/v2版本记录ID以及以下静态校验结果:
|
||||
|
||||
```text
|
||||
revision=3
|
||||
steps=8 max_concurrency=2 max_task_attempts=13
|
||||
Both definitions are stored. No workflow instance or business task has been started.
|
||||
```
|
||||
|
||||
HTTP响应未知时使用原操作ID及相同源码核对,不临时更换版本或键。若有意更改v1内容则返回409,需研发评审并显式发布新业务版本。缺少定义权限为403;引用队列不存在或越权为404;类型/引用/预算错误为400。审计失败不会留下半个发布版本,持续503需核查存储及服务状态。
|
||||
|
||||
本例保留定义和发布审计,不直接删库清理;目前没有删除接口。每命名空间最多100个定义,每定义100个版本,重复运行优先复用操作ID。完整隔离回归由`scripts/test-mysql.sh`负责数据库销毁,不能对共享环境做硬删除。
|
||||
48
docs/reports/phase-4-definitions.md
Normal file
48
docs/reports/phase-4-definitions.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# P4 子项:定义校验与不可变发布
|
||||
|
||||
日期:2026-09-18。本子项已通过验收。范围是研发定义格式、静态校验、定义创建、版本发布/读取及独立SDK。实例启动、推进、信号/时间等待、暂停/恢复和轮询执行仍未实现;本报告不构成P4整体或生产高可用验收。
|
||||
|
||||
## 实现
|
||||
|
||||
- `weave/definition`提供schema_version=1结构及类型契约,六类节点、词法引用、条件、并行汇合和有限预算校验。不会执行上传源码。
|
||||
- 创建/发布/读取四个HTTP路由,两个显式权限与`--workflow-access`初始化标志。API严格匹配字段,映射值递归检查;仅DSL常量允许显式null,普通任务参数限制保持不变。
|
||||
- 复用现有两张定义表,未新增迁移、外键、级联或数据库依赖。发布在应用→身份→定义→队列顺序下串行化,版本/revision/审计/回执同事务。中文字段注释随代码保留。
|
||||
- 同键绑定完整命令,同版本按规则摘要比较;数字使用精确JSON规范化。读取核对原文及结构摘要,损坏或悬空关联拒绝;重复请求仍检查当前队列权限。
|
||||
- 独立SDK强类型定义、引用构建及四个客户端方法;完整六类节点发布例子和[API](../api/workflow-definitions.md)、[格式](../api/workflow-definition-format.md)、[场景](../examples/workflow-definition.md)同步。
|
||||
|
||||
## 回归证据
|
||||
|
||||
环境:macOS arm64,Go 1.27.1;Docker隔离MySQL 8.4.10,随机秘密/回环端口,由脚本创建并清理临时容器,不连接已有业务库。
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| make fmt / test(race)/ vet / build | 通过;覆盖服务端、SDK与独立Inbox/Outbox模块 |
|
||||
| DSL定向单测 | 顺序/分支/并行类型与作用域、禁用前向/跨分支引用、默认路径、策略完整性、轮次与尝试预算、状态完整分组、深度/数量上限、精确数值与非变更检查通过 |
|
||||
| HTTP严格JSON | 嵌套map大小写别名/未知字段/重复键/null集合拒绝;DSL显式null常量可用,普通任务null仍拒绝 |
|
||||
| 数据校验模糊测试 | 2个并行worker、10秒目标,15699次执行,通过(工具耗时11.278秒) |
|
||||
| 真MySQL发布定向回归 | `TestIntegrationWorkflowDefinitions -count=3`通过,总12.756秒;包含并发、权限、无外键关联、回滚、损坏及数量上限 |
|
||||
| 真HTTP提交后回执丢失 | 新SDK集成回归通过,1.314秒;服务确认201后代理断开连接,首次调用明确报错,原键重报返回同一版本,revision不重复增长 |
|
||||
| 六类节点可运行发布例子 | 通过;v1/v2发布及旧版读取,revision=3、steps=8、max_concurrency=2、max_task_attempts=13;没有启动实例 |
|
||||
| 独立SDK依赖 | GOWORK=off列出依赖,不包含服务端internal/model/platform/pulse/weave、MySQL或ORM |
|
||||
| 完整隔离链路 | scripts/test-mysql.sh终态退出0;所有19个SDK例子及独立Inbox/Outbox两个例子通过,临时容器已清理 |
|
||||
|
||||
并发用例以12个调用竞争6个幂等身份,均取得同一不可变版本,只有一次新发布;随后验证等值小数重报、相邻大整数内容冲突、v2不改变v1、审计次数与父revision,数据库触发器拒绝直接UPDATE。发布不产生Pulse任务。
|
||||
|
||||
权限用例覆盖默认凭据无定义权限、原回执队列权限收回、跨命名空间队列/定义、不存在的父定义/队列、手工删除关联后的拒绝。真实数据库断言外键为0。
|
||||
|
||||
故障用例通过隔离库审计写入失败使版本/父revision/幂等身份全部回滚,再用原键成功发布;另外新增损坏摘要记录及删除父记录,确认读取失败。100个定义/100个版本上限真实由API填满,达到上限仍恢复旧回执。
|
||||
|
||||
首次完整回归在发布人读取上发现VARCHAR文本标识被当作BINARY ID扫描;修正为文本读取后再校验ID。三次定向回归已覆盖修正。本报告保留此失败记录,未将此前未到达的SDK链路视为通过。
|
||||
|
||||
|
||||
最终完整回归中,存储集成108.239秒(定义子项2.42秒),Worker SDK91.133秒,计划5.782秒,队列4.458秒,批量1.378秒,查询1.335秒,订阅管理33.363秒,事件消费52.103秒,任务恢复3.418秒,定义SDK1.314秒;独立Inbox35.669秒、Outbox33.839秒,应用额度1.481秒。10个SDK进程故障父用例全部通过,总95.274秒,其中重试全停恢复四场景33.10秒;独立Inbox/Outbox另各有一个进程故障父用例。本次全链路零失败,已确认终态退出及容器清理,没有以日志片段或等待超时替代完成结论。
|
||||
|
||||
复现:`make fmt test vet build`、`make integration`;定向用例为`go test -race -count=3 -run '^TestIntegrationWorkflowDefinitions$' ./internal/storage/mysql`(只在隔离库设置ORBIT_TEST_MYSQL_DSN)。SDK定义用例在全量脚本中使用专门签发的流程权限运行。最后完整回归后仅补文档证据,没有修改运行代码。
|
||||
|
||||
## 后续边界
|
||||
|
||||
P4下一步是启动实例、固定版本与输入、同事务创建首批任务、内部事件接续和有界推进;继而交付顺序/条件/并行、信号与持久化定时等待。P5补齐轮询运行、门控、取消及有证据人工恢复。现在发布格式支持节点,不等于节点运行器已存在。
|
||||
|
||||
启动时必须再次核对定义版本、当前权限、队列与实际运行参数;JSON来源可能为null或超出Pulse字段限制,不能以静态校验代替运行校验。数据库列、已保存规则和SDK版本兼容都需要进入后续升级回归。
|
||||
|
||||
独立发布操作的数据库COMMIT回执丢失尚未单独注入;已有回滚及HTTP提交后回执丢失场景不能替代全部存储故障。定义列表、删除/归档、配置化额度、凭据轮换、容量、MySQL故障切换、备份和滚动升级继续按P6–P7处理;不承诺未经验证的生产吞吐和可用性。
|
||||
@@ -198,3 +198,7 @@ P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md)
|
||||
## 订阅管理示例
|
||||
|
||||
[完整运行方法](../examples/subscriptions.md) 使用显式 --event-access 凭据,执行创建/同键核对/暂停/恢复/查询,再暂停本次订阅并保留审计。源码见 [examples/subscriptions](../../sdk/go/examples/subscriptions/main.go)。WSS 连接 SDK 与自动消费器见 [消费手册](event-consumer.md),可运行 [协议示例](../examples/event-protocol.md) 验证跨连接 ACK;[持久化 Inbox 示例](../examples/result-inbox.md) 已独立交付,核心SDK不新增数据库驱动。
|
||||
|
||||
## 流程定义发布
|
||||
|
||||
已提供独立WorkflowSpecification类型及CreateWorkflowDefinition、GetWorkflowDefinition、PublishWorkflowVersion、GetWorkflowVersion四个HTTP方法。业务侧研发显式发布,服务端冻结版本;当前不包含实例执行方法。见[接口与字段](../api/workflow-definitions.md)、[结构版本1](../api/workflow-definition-format.md)、[完整可运行场景](../examples/workflow-definition.md)。
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
|
||||
type BootstrapInput struct {
|
||||
WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。
|
||||
RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。
|
||||
EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。
|
||||
Application string // Application 新应用名称,与 ExistingApplicationID 二选一。
|
||||
@@ -76,6 +77,9 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
||||
if in.WorkerAccess {
|
||||
permissions.Actions = append(permissions.Actions, "workers:connect", "workers:consume")
|
||||
}
|
||||
if in.WorkflowAccess {
|
||||
permissions.Actions = append(permissions.Actions, "workflows:definitions:read", "workflows:definitions:publish")
|
||||
}
|
||||
if in.ScheduleAccess {
|
||||
permissions.Actions = append(permissions.Actions, "schedules:read", "schedules:manage")
|
||||
}
|
||||
|
||||
258
internal/storage/mysql/workflow_definitions.go
Normal file
258
internal/storage/mysql/workflow_definitions.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// WorkflowDefinitionStore 只管理定义与不可变版本,关联校验、幂等和审计同事务,不创建业务任务。
|
||||
type WorkflowDefinitionStore struct{ *TaskStore }
|
||||
|
||||
func NewWorkflowDefinitionStore(db *sql.DB) *WorkflowDefinitionStore {
|
||||
return &WorkflowDefinitionStore{NewTaskStore(db)}
|
||||
}
|
||||
|
||||
var _ definition.Store = (*WorkflowDefinitionStore)(nil)
|
||||
|
||||
func readWorkflowDefinition(ctx context.Context, tx *sql.Tx, ns, id core.ID, lock bool) (definition.Summary, error) {
|
||||
var out definition.Summary
|
||||
q := `SELECT id,namespace_id,name,description,revision,created_at,updated_at FROM weave_definitions WHERE namespace_id=? AND id=?`
|
||||
if lock {
|
||||
q += ` FOR UPDATE`
|
||||
}
|
||||
err := tx.QueryRowContext(ctx, q, ns, id).Scan(&out.ID, &out.NamespaceID, &out.Name, &out.Description, &out.Revision, &out.CreatedAt, &out.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = fault.NotFound
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
func workflowAnalysis(c definition.Checked) definition.Analysis {
|
||||
queues := append([]core.ID{}, c.Queues...)
|
||||
return definition.Analysis{Steps: c.Steps, MaxConcurrency: c.MaxConcurrency, MaxTaskAttempts: c.MaxTaskAttempts, Queues: queues}
|
||||
}
|
||||
func readWorkflowVersion(ctx context.Context, tx *sql.Tx, ns, id core.ID, version string) (definition.VersionView, error) {
|
||||
var out definition.VersionView
|
||||
var raw []byte
|
||||
var schema uint32
|
||||
var actor string
|
||||
var digest core.Digest
|
||||
err := tx.QueryRowContext(ctx, `SELECT id,namespace_id,definition_id,version,schema_version,specification,content_digest,published_by,created_at FROM weave_definition_versions WHERE namespace_id=? AND definition_id=? AND version=?`, ns, id, version).Scan(&out.ID, &out.NamespaceID, &out.DefinitionID, &out.Version, &schema, &raw, &digest, &actor, &out.PublishedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return out, fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.PublishedBy = core.ID(actor)
|
||||
if out.PublishedBy.Validate() != nil {
|
||||
return out, errors.New("invalid stored workflow publisher")
|
||||
}
|
||||
if len(raw) > definition.MaxSpecificationBytes || json.Unmarshal(raw, &out.Specification) != nil || out.Specification.SchemaVersion != schema {
|
||||
return out, errors.New("invalid stored workflow specification")
|
||||
}
|
||||
checked, err := definition.Check(out.Specification)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
// 同时检查原文,不能让JSON解码忽略的未知字段或大小写别名绕过完整性验证。
|
||||
canonical, err := jsonvalue.Canonical(raw)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
rawDigest := core.Digest(sha256.Sum256(append([]byte("orbit.weave.spec.v1\x00"), canonical...)))
|
||||
if digest != checked.Digest || digest != rawDigest {
|
||||
return out, errors.New("workflow version digest mismatch")
|
||||
}
|
||||
out.ContentDigest = hex.EncodeToString(digest[:])
|
||||
out.Analysis = workflowAnalysis(checked)
|
||||
return out, nil
|
||||
}
|
||||
func authorizeWorkflowQueues(ctx context.Context, tx *sql.Tx, ns core.ID, p auth.Permissions, queues []core.ID) error {
|
||||
for _, id := range queues {
|
||||
if !p.AllowsQueue(id) {
|
||||
return fault.NotFound
|
||||
}
|
||||
var found core.ID
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM pulse_queues WHERE namespace_id=? AND id=? FOR SHARE`, ns, id).Scan(&found)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *WorkflowDefinitionStore) Create(ctx context.Context, t auth.Token, key string, in definition.CreateInput) (definition.CreateResult, error) {
|
||||
var out definition.CreateResult
|
||||
err := s.transaction(ctx, t, definition.PublishAction, func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if _, err := lockApplicationResources(ctx, tx, t.NamespaceID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "workflow.definition.create", "", key, "workflow_definition", id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
if err := json.Unmarshal(cached, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM weave_definitions WHERE namespace_id=?`, t.NamespaceID).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= definition.MaxDefinitions {
|
||||
return fault.New("WORKFLOW_DEFINITION_LIMIT", "namespace workflow definition limit reached")
|
||||
}
|
||||
now, err := dbNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO weave_definitions(namespace_id,id,name,description,created_at,updated_at) VALUES (?,?,?,?,?,?)`, t.NamespaceID, id, in.Name, in.Description, now, now)
|
||||
var duplicate *driver.MySQLError
|
||||
if errors.As(err, &duplicate) && duplicate.Number == 1062 {
|
||||
return fault.Conflict
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Definition, err = readWorkflowDefinition(ctx, tx, t.NamespaceID, id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAudit(ctx, tx, t, identity, id, "workflow_definition", "workflow.definition.create", "create workflow definition", nil, out.Definition); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *WorkflowDefinitionStore) Get(ctx context.Context, t auth.Token, id core.ID) (definition.Summary, error) {
|
||||
var out definition.Summary
|
||||
err := s.transaction(ctx, t, definition.ReadAction, func(tx *sql.Tx, _ auth.Permissions) error {
|
||||
var err error
|
||||
out, err = readWorkflowDefinition(ctx, tx, t.NamespaceID, id, false)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *WorkflowDefinitionStore) Publish(ctx context.Context, t auth.Token, id core.ID, key string, in definition.PublishInput) (definition.PublishResult, error) {
|
||||
var out definition.PublishResult
|
||||
checked, err := definition.Check(in.Specification)
|
||||
if err != nil {
|
||||
return out, fault.Invalid(err.Error())
|
||||
}
|
||||
err = s.transaction(ctx, t, definition.PublishAction, func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if _, err := lockApplicationResources(ctx, tx, t.NamespaceID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
// 摘要身份冻结整个命令(含原因),同版本去重只比较specification内容摘要。
|
||||
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "workflow.version.publish", string(id), key, "workflow_definition", id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := readWorkflowDefinition(ctx, tx, t.NamespaceID, id, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := authorizeWorkflowQueues(ctx, tx, t.NamespaceID, p, checked.Queues); err != nil {
|
||||
return err
|
||||
}
|
||||
stored, err := readWorkflowVersion(ctx, tx, t.NamespaceID, id, in.Version)
|
||||
if err != nil && !errors.Is(err, fault.NotFound) {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
if err != nil {
|
||||
return errors.New("published workflow version missing")
|
||||
}
|
||||
if err := json.Unmarshal(cached, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if out.Version.ID != stored.ID || stored.ContentDigest != hex.EncodeToString(checked.Digest[:]) {
|
||||
return errors.New("workflow publication receipt mismatch")
|
||||
}
|
||||
out.Version = stored
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
if err == nil {
|
||||
if stored.ContentDigest != hex.EncodeToString(checked.Digest[:]) {
|
||||
return fault.Conflict
|
||||
}
|
||||
out = definition.PublishResult{Version: stored, Deduplicated: true}
|
||||
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
|
||||
}
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM weave_definition_versions WHERE namespace_id=? AND definition_id=?`, t.NamespaceID, id).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= definition.MaxVersions {
|
||||
return fault.New("WORKFLOW_VERSION_LIMIT", "workflow definition version limit reached")
|
||||
}
|
||||
versionID, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := jsonwire.Marshal(in.Specification)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now, err := dbNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO weave_definition_versions(namespace_id,id,definition_id,version,schema_version,specification,content_digest,published_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)`, t.NamespaceID, versionID, id, in.Version, in.Specification.SchemaVersion, string(raw), checked.Digest, string(t.CredentialID), now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE weave_definitions SET revision=revision+1,updated_at=? WHERE namespace_id=? AND id=?`, now, t.NamespaceID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Version, err = readWorkflowVersion(ctx, tx, t.NamespaceID, id, in.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 审计记录固定版本、摘要和预算,不复制潜在敏感的业务常量。
|
||||
audit := map[string]any{"version_id": versionID, "version": in.Version, "content_digest": out.Version.ContentDigest, "analysis": out.Version.Analysis, "revision": before.Revision + 1}
|
||||
if err := appendAudit(ctx, tx, t, identity, id, "workflow_definition", "workflow.version.publish", in.Reason, map[string]any{"revision": before.Revision}, audit); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *WorkflowDefinitionStore) Version(ctx context.Context, t auth.Token, id core.ID, version string) (definition.VersionView, error) {
|
||||
var out definition.VersionView
|
||||
err := s.transaction(ctx, t, definition.ReadAction, func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if _, err := readWorkflowDefinition(ctx, tx, t.NamespaceID, id, false); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
out, err = readWorkflowVersion(ctx, tx, t.NamespaceID, id, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return authorizeWorkflowQueues(ctx, tx, t.NamespaceID, p, out.Analysis.Queues)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
295
internal/storage/mysql/workflow_definitions_integration_test.go
Normal file
295
internal/storage/mysql/workflow_definitions_integration_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
)
|
||||
|
||||
type workflowFixture struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
db *sql.DB
|
||||
resource BootstrapResult
|
||||
token auth.Token
|
||||
api *definition.Service
|
||||
}
|
||||
|
||||
func newWorkflowFixture(t *testing.T, ctx context.Context, db *sql.DB, access bool) *workflowFixture {
|
||||
t.Helper()
|
||||
f := &workflowFixture{t: t, ctx: ctx, db: db, api: definition.New(NewWorkflowDefinitionStore(db))}
|
||||
if err := Bootstrap(ctx, db, BootstrapInput{Application: "workflow-" + string(executionID(t)), Namespace: "test", Environment: "test", Queue: "work", WorkflowAccess: access}, func(v BootstrapResult) error { f.resource = v; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var err error
|
||||
f.token, err = auth.Parse(f.resource.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
func (f *workflowFixture) create(key string) definition.CreateResult {
|
||||
f.t.Helper()
|
||||
v, err := f.api.Create(f.ctx, f.token, key, definition.CreateInput{Name: "flow-" + key, Description: "通用作业处理"})
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
func (f *workflowFixture) count(q string, args ...any) int {
|
||||
f.t.Helper()
|
||||
var n int
|
||||
if err := f.db.QueryRowContext(f.ctx, q, args...).Scan(&n); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
func (f *workflowFixture) exec(q string, args ...any) {
|
||||
f.t.Helper()
|
||||
if _, err := f.db.ExecContext(f.ctx, q, args...); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func workflowTestSpec(queue core.ID) definition.Specification {
|
||||
typ := definition.Type{Kind: "OBJECT", Fields: map[string]definition.Type{"n": {Kind: "NUMBER"}}}
|
||||
task := definition.Task{QueueID: queue, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", InputType: typ, Input: definition.Value{Kind: "LITERAL", Literal: json.RawMessage(`{"n":9007199254740993}`)}, ResultType: typ, TimeoutMS: 5000, Retry: pulse.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 3000}, RecoverySafety: pulse.RecoveryReconcileOnly}
|
||||
return definition.Specification{SchemaVersion: 1, InputType: typ, TimeoutMS: 30000, FailurePolicy: "REVIEW", Body: definition.Block{Steps: []definition.Step{{Key: "prepare", Kind: "TASK", Task: &task}}, OutputType: typ, Output: definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: "prepare"}}}}
|
||||
}
|
||||
func TestIntegrationWorkflowDefinitions(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
db, err := Open(ctx, dsn, 24, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Run("concurrency_precision_and_immutable_versions", func(t *testing.T) {
|
||||
f := newWorkflowFixture(t, ctx, db, true)
|
||||
key := string(executionID(t))
|
||||
created := f.create(key)
|
||||
id := created.Definition.ID
|
||||
dupe, err := f.api.Create(ctx, f.token, key, definition.CreateInput{Name: "flow-" + key, Description: "通用作业处理"})
|
||||
if err != nil || !dupe.Deduplicated || dupe.Definition.ID != id {
|
||||
t.Fatal("create replay", err)
|
||||
}
|
||||
in := definition.PublishInput{Version: "v1", Specification: workflowTestSpec(f.resource.QueueID), Reason: "release fixed specification"}
|
||||
type result struct {
|
||||
out definition.PublishResult
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 12)
|
||||
var wg sync.WaitGroup
|
||||
// 原键竞争和不同键的同版本竞争都必须只提交一次发布。
|
||||
for i := 0; i < 12; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
k := fmt.Sprintf("release-%d", i/2)
|
||||
out, err := f.api.Publish(ctx, f.token, id, k, in)
|
||||
ch <- result{out, err}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(ch)
|
||||
fresh := 0
|
||||
var original definition.VersionView
|
||||
for r := range ch {
|
||||
if r.err != nil {
|
||||
t.Fatal(r.err)
|
||||
}
|
||||
if original.ID == "" {
|
||||
original = r.out.Version
|
||||
}
|
||||
if !reflect.DeepEqual(original, r.out.Version) {
|
||||
t.Fatal("version changed across replies")
|
||||
}
|
||||
if !r.out.Deduplicated {
|
||||
fresh++
|
||||
}
|
||||
}
|
||||
if fresh != 1 {
|
||||
t.Fatalf("new publications=%d", fresh)
|
||||
}
|
||||
in.Specification.Body.Steps[0].Task.Input.Literal = json.RawMessage(`{"n":9007199254740993.0}`)
|
||||
equal, err := f.api.Publish(ctx, f.token, id, "release-0", in)
|
||||
if err != nil || !equal.Deduplicated || !reflect.DeepEqual(equal.Version, original) {
|
||||
t.Fatal("canonical replay", err)
|
||||
}
|
||||
in.Specification.Body.Steps[0].Task.Input.Literal = json.RawMessage(`{"n":9007199254740992}`)
|
||||
_, err = f.api.Publish(ctx, f.token, id, "release-0", in)
|
||||
expectWorkerCode(t, err, "IDEMPOTENCY_CONFLICT")
|
||||
_, err = f.api.Publish(ctx, f.token, id, "changed-content", in)
|
||||
expectWorkerCode(t, err, "STATE_CONFLICT")
|
||||
in.Version = "v2"
|
||||
v2, err := f.api.Publish(ctx, f.token, id, "release-v2", in)
|
||||
if err != nil || v2.Version.ContentDigest == original.ContentDigest {
|
||||
t.Fatal("publish v2", err)
|
||||
}
|
||||
v1, err := f.api.Version(ctx, f.token, id, "v1")
|
||||
if err != nil || !reflect.DeepEqual(v1, original) {
|
||||
t.Fatal("old version changed", err)
|
||||
}
|
||||
metadata, err := f.api.Get(ctx, f.token, id)
|
||||
if err != nil || metadata.Revision != 3 {
|
||||
t.Fatal("metadata revision", err)
|
||||
}
|
||||
if n := f.count(`SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND resource_id=? AND action='workflow.version.publish'`, f.resource.NamespaceID, id); n != 2 {
|
||||
t.Fatalf("audit=%d", n)
|
||||
}
|
||||
if n := f.count(`SELECT COUNT(*) FROM pulse_tasks WHERE namespace_id=?`, f.resource.NamespaceID); n != 0 {
|
||||
t.Fatal("publish created tasks")
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE weave_definition_versions SET version='modified' WHERE namespace_id=? AND id=?`, f.resource.NamespaceID, original.ID); err == nil {
|
||||
t.Fatal("immutable update allowed")
|
||||
}
|
||||
})
|
||||
t.Run("authorization_and_associations_without_foreign_keys", func(t *testing.T) {
|
||||
f := newWorkflowFixture(t, ctx, db, true)
|
||||
g := newWorkflowFixture(t, ctx, db, false)
|
||||
id := f.create(string(executionID(t))).Definition.ID
|
||||
_, err := g.api.Create(ctx, g.token, string(executionID(t)), definition.CreateInput{Name: "forbidden"})
|
||||
expectWorkerCode(t, err, "PERMISSION_DENIED")
|
||||
in := definition.PublishInput{Version: "v1", Reason: "authorization regression", Specification: workflowTestSpec(f.resource.QueueID)}
|
||||
accepted, err := f.api.Publish(ctx, f.token, id, "pub", in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
permissions := func(actions []string, queues []string) {
|
||||
raw, _ := json.Marshal(auth.Permissions{Actions: actions, Queues: queues})
|
||||
f.exec(`UPDATE orbit_credentials SET permissions=? WHERE namespace_id=? AND id=?`, string(raw), f.resource.NamespaceID, f.resource.CredentialID)
|
||||
}
|
||||
permissions([]string{definition.ReadAction, definition.PublishAction}, []string{string(g.resource.QueueID)})
|
||||
if _, err := f.api.Get(ctx, f.token, id); err != nil {
|
||||
t.Fatal("metadata scope", err)
|
||||
}
|
||||
_, err = f.api.Version(ctx, f.token, id, "v1")
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
_, err = f.api.Publish(ctx, f.token, id, "pub", in)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
in.Version = "v2"
|
||||
in.Specification.Body.Steps[0].Task.QueueID = g.resource.QueueID
|
||||
_, err = f.api.Publish(ctx, f.token, id, "foreign-queue", in)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
permissions([]string{definition.ReadAction, definition.PublishAction}, []string{"*"})
|
||||
in.Specification.Body.Steps[0].Task.QueueID = executionID(t)
|
||||
_, err = f.api.Publish(ctx, f.token, id, "absent-queue", in)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
_, err = f.api.Publish(ctx, f.token, executionID(t), "absent-parent", in)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
raw, _ := json.Marshal(auth.Permissions{Actions: []string{definition.ReadAction, definition.PublishAction}, Queues: []string{"*"}})
|
||||
g.exec(`UPDATE orbit_credentials SET permissions=? WHERE namespace_id=? AND id=?`, string(raw), g.resource.NamespaceID, g.resource.CredentialID)
|
||||
_, err = g.api.Version(ctx, g.token, id, "v1")
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
// 队列被人工删除后原版本不可用,不能依赖数据库外键兜底。
|
||||
f.exec(`DELETE FROM pulse_queues WHERE namespace_id=? AND id=?`, f.resource.NamespaceID, f.resource.QueueID)
|
||||
_, err = f.api.Version(ctx, f.token, id, "v1")
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
if n := f.count(`SELECT COUNT(*) FROM weave_definition_versions WHERE namespace_id=? AND definition_id=?`, f.resource.NamespaceID, id); n != 1 {
|
||||
t.Fatal("rejected publication persisted")
|
||||
}
|
||||
if n := f.count(`SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`); n != 0 {
|
||||
t.Fatal("foreign keys exist")
|
||||
}
|
||||
if accepted.Version.ID == "" {
|
||||
t.Fatal("missing publication")
|
||||
}
|
||||
})
|
||||
t.Run("audit_failure_rolls_back_entire_publication", func(t *testing.T) {
|
||||
f := newWorkflowFixture(t, ctx, db, true)
|
||||
id := f.create(string(executionID(t))).Definition.ID
|
||||
in := definition.PublishInput{Version: "v1", Reason: "atomic publication", Specification: workflowTestSpec(f.resource.QueueID)}
|
||||
f.exec(`CREATE TRIGGER orbit_test_workflow_audit BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='publication audit failure'`)
|
||||
_, err := f.api.Publish(ctx, f.token, id, "atomic", in)
|
||||
f.exec(`DROP TRIGGER orbit_test_workflow_audit`)
|
||||
if err == nil {
|
||||
t.Fatal("audit failure accepted")
|
||||
}
|
||||
for _, q := range []string{`SELECT COUNT(*) FROM weave_definition_versions WHERE namespace_id=?`, `SELECT COUNT(*) FROM orbit_idempotency_keys WHERE namespace_id=? AND operation='workflow.version.publish'`} {
|
||||
if f.count(q, f.resource.NamespaceID) != 0 {
|
||||
t.Fatal("partial publication committed")
|
||||
}
|
||||
}
|
||||
metadata, err := f.api.Get(ctx, f.token, id)
|
||||
if err != nil || metadata.Revision != 1 {
|
||||
t.Fatal("revision not rolled back", err)
|
||||
}
|
||||
out, err := f.api.Publish(ctx, f.token, id, "atomic", in)
|
||||
if err != nil || out.Deduplicated {
|
||||
t.Fatal("retry rolled back publication", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("damaged_or_orphaned_versions_fail_closed", func(t *testing.T) {
|
||||
f := newWorkflowFixture(t, ctx, db, true)
|
||||
id := f.create(string(executionID(t))).Definition.ID
|
||||
in := definition.PublishInput{Version: "v1", Reason: "stored integrity", Specification: workflowTestSpec(f.resource.QueueID)}
|
||||
out, err := f.api.Publish(ctx, f.token, id, "original", in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 只在隔离库新增损坏记录,不停用不可变触发器;不依赖外键阻止这些写入。
|
||||
f.exec(`INSERT INTO weave_definition_versions(namespace_id,id,definition_id,version,schema_version,specification,content_digest,published_by) SELECT namespace_id,?,definition_id,'damaged',schema_version,specification,?,published_by FROM weave_definition_versions WHERE namespace_id=? AND id=?`, executionID(t), core.Digest{}, f.resource.NamespaceID, out.Version.ID)
|
||||
_, err = f.api.Version(ctx, f.token, id, "damaged")
|
||||
if err == nil {
|
||||
t.Fatal("damaged digest accepted")
|
||||
}
|
||||
f.exec(`DELETE FROM weave_definitions WHERE namespace_id=? AND id=?`, f.resource.NamespaceID, id)
|
||||
_, err = f.api.Version(ctx, f.token, id, "v1")
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
_, err = f.api.Publish(ctx, f.token, id, "original", in)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
})
|
||||
|
||||
t.Run("bounded_metadata_and_receipts_at_capacity", func(t *testing.T) {
|
||||
f := newWorkflowFixture(t, ctx, db, true)
|
||||
first := f.create("first")
|
||||
id := first.Definition.ID
|
||||
// 使用真实API填满元数据限额;重复回执必须仍可读取。
|
||||
for i := 1; i < definition.MaxDefinitions; i++ {
|
||||
f.create(fmt.Sprintf("cap-%d", i))
|
||||
}
|
||||
_, err := f.api.Create(ctx, f.token, "overflow", definition.CreateInput{Name: "overflow"})
|
||||
expectWorkerCode(t, err, "WORKFLOW_DEFINITION_LIMIT")
|
||||
out, err := f.api.Create(ctx, f.token, "first", definition.CreateInput{Name: "flow-first", Description: "通用作业处理"})
|
||||
if err != nil || !out.Deduplicated {
|
||||
t.Fatal("create replay at limit", err)
|
||||
}
|
||||
in := definition.PublishInput{Reason: "bounded version history", Specification: workflowTestSpec(f.resource.QueueID)}
|
||||
for i := 0; i < definition.MaxVersions; i++ {
|
||||
in.Version = fmt.Sprintf("v%d", i)
|
||||
if _, err := f.api.Publish(ctx, f.token, id, in.Version, in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
in.Version = "overflow"
|
||||
_, err = f.api.Publish(ctx, f.token, id, "overflow", in)
|
||||
expectWorkerCode(t, err, "WORKFLOW_VERSION_LIMIT")
|
||||
in.Version = "v0"
|
||||
same, err := f.api.Publish(ctx, f.token, id, "v0", in)
|
||||
if err != nil || !same.Deduplicated {
|
||||
t.Fatal("original version replay at limit", err)
|
||||
}
|
||||
same, err = f.api.Publish(ctx, f.token, id, "new-key-same-version", in)
|
||||
if err != nil || !same.Deduplicated {
|
||||
t.Fatal("same version replay at limit", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -233,7 +233,7 @@ func writeError(w http.ResponseWriter, id string, err error) {
|
||||
if !errors.As(err, &known) {
|
||||
known = fault.Unavailable
|
||||
}
|
||||
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415, "DELIVERY_LEASE_LOST": 409, "EVENT_HISTORY_EXPIRED": 410, "SUBSCRIPTION_LIMIT": 409}[known.Code]
|
||||
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415, "DELIVERY_LEASE_LOST": 409, "EVENT_HISTORY_EXPIRED": 410, "SUBSCRIPTION_LIMIT": 409, "WORKFLOW_DEFINITION_LIMIT": 409, "WORKFLOW_VERSION_LIMIT": 409}[known.Code]
|
||||
if status == 0 {
|
||||
status = 503
|
||||
}
|
||||
@@ -299,6 +299,18 @@ func exactFields(raw []byte, t reflect.Type) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if t.Kind() == reflect.Map {
|
||||
var values map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, value := range values {
|
||||
if err := exactFields(value, t.Elem()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if t.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
@@ -307,11 +319,13 @@ func exactFields(raw []byte, t reflect.Type) error {
|
||||
return err
|
||||
}
|
||||
names := map[string]reflect.Type{}
|
||||
nullable := map[string]bool{}
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
name := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if name != "" && name != "-" {
|
||||
names[name] = field.Type
|
||||
nullable[name] = field.Tag.Get("nullable") == "true"
|
||||
if field.Tag.Get("required") == "true" {
|
||||
if _, ok := object[name]; !ok {
|
||||
return errors.New("missing required field")
|
||||
@@ -324,6 +338,9 @@ func exactFields(raw []byte, t reflect.Type) error {
|
||||
if !ok {
|
||||
return errors.New("unknown field")
|
||||
}
|
||||
if nullable[name] && bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
continue
|
||||
}
|
||||
if err := exactFields(value, field); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
51
internal/transport/httpserver/workflow_definitions.go
Normal file
51
internal/transport/httpserver/workflow_definitions.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// RegisterWorkflowDefinitions 提供显式研发发布入口,复用严格JSON、事务认证和幂等信封。
|
||||
func RegisterWorkflowDefinitions(server *http.Server, s *definition.Service) {
|
||||
mux := server.Handler.(*http.ServeMux)
|
||||
mux.HandleFunc("POST /v1/workflow-definitions", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
var in definition.CreateInput
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Create(r.Context(), t, key, in)
|
||||
return out, createdStatus(out.Deduplicated), err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/workflow-definitions/{id}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if _, err := historyQuery(r, false); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Get(r.Context(), t, core.ID(r.PathValue("id")))
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("POST /v1/workflow-definitions/{id}/versions", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
var in definition.PublishInput
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Publish(r.Context(), t, core.ID(r.PathValue("id")), key, in)
|
||||
return out, createdStatus(out.Deduplicated), err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/workflow-definitions/{id}/versions/{version}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if _, err := historyQuery(r, false); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Version(r.Context(), t, core.ID(r.PathValue("id")), r.PathValue("version"))
|
||||
return out, 200, err
|
||||
}))
|
||||
}
|
||||
58
internal/transport/httpserver/workflow_definitions_test.go
Normal file
58
internal/transport/httpserver/workflow_definitions_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
)
|
||||
|
||||
func TestWorkflowStrictJSONAndNullableLiteral(t *testing.T) {
|
||||
valid := `{"version":"v1","reason":"release","specification":{"schema_version":1,"input_type":{"kind":"OBJECT","fields":{"number":{"kind":"NUMBER"}}},"timeout_ms":1000,"failure_policy":"REVIEW","body":{"output_type":{"kind":"NULL"},"output":{"kind":"LITERAL","literal":null}}}}`
|
||||
for _, tc := range []struct {
|
||||
name, raw string
|
||||
ok bool
|
||||
}{
|
||||
{"explicit null literal", valid, true},
|
||||
{"map type case alias", strings.Replace(valid, `"number":{"kind"`, `"number":{"Kind"`, 1), false},
|
||||
{"map unknown property", strings.Replace(valid, `"number":{"kind":"NUMBER"}`, `"number":{"kind":"NUMBER","code":"script"}`, 1), false},
|
||||
{"null type map", strings.Replace(valid, `"number":{"kind":"NUMBER"}`, `"number":null`, 1), false},
|
||||
{"null collection", strings.Replace(valid, `"body":{`, `"body":{"steps":null,`, 1), false},
|
||||
{"duplicate field", strings.Replace(valid, `"schema_version":1`, `"schema_version":1,"schema_version":2`, 1), false},
|
||||
{"extra top property", strings.Replace(valid, `"reason":"release"`, `"reason":"release","Code":"arbitrary"`, 1), false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(tc.raw))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
var in definition.PublishInput
|
||||
err := decodeBody(r, &in)
|
||||
if (err == nil) != tc.ok {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if tc.ok {
|
||||
if _, err := definition.Check(in.Specification); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// Value.fields 同样递归检查精确字段名;普通任务payload仍不得显式null。
|
||||
for _, raw := range []string{`{"kind":"OBJECT","fields":{"x":{"Kind":"LITERAL","literal":1}}}`, `{"kind":"OBJECT","fields":{"x":null}}`} {
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(raw))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
var value definition.Value
|
||||
if decodeBody(r, &value) == nil {
|
||||
t.Fatal("invalid value map accepted")
|
||||
}
|
||||
}
|
||||
var ordinary struct {
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"payload":null}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if decodeBody(r, &ordinary) == nil {
|
||||
t.Fatal("nullable exception leaked to task payload")
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ type Permissions struct {
|
||||
Queues []string `json:"queues"`
|
||||
}
|
||||
|
||||
var supported = map[string]bool{"tasks:review": true, "tasks:retry": true, "tasks:audit:read": true, "events:read": true, "events:manage": true, "events:consume": true, "application:resources:read": true, "application:resources:manage": true, "tasks:submit": true, "tasks:read": true, "tasks:cancel": true, "queues:read": true, "queues:manage": true, "workers:connect": true, "workers:consume": true, "schedules:read": true, "schedules:manage": true}
|
||||
var supported = map[string]bool{"workflows:definitions:read": true, "workflows:definitions:publish": true, "tasks:review": true, "tasks:retry": true, "tasks:audit:read": true, "events:read": true, "events:manage": true, "events:consume": true, "application:resources:read": true, "application:resources:manage": true, "tasks:submit": true, "tasks:read": true, "tasks:cancel": true, "queues:read": true, "queues:manage": true, "workers:connect": true, "workers:consume": true, "schedules:read": true, "schedules:manage": true}
|
||||
|
||||
func (p Permissions) Validate() error {
|
||||
if len(p.Actions) == 0 || len(p.Actions) > 32 || len(p.Queues) == 0 || len(p.Queues) > 128 {
|
||||
|
||||
@@ -76,6 +76,11 @@ ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap -
|
||||
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/recovery.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationTaskRecoverySDK$' -v .)
|
||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/recovery.credentials.json" "$TASK_GO" run ./examples/recovery)
|
||||
|
||||
# 定义发布权限独立签发;示例只发布版本,不宣称实例引擎已运行。
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application sdk-workflow --namespace default --environment test --queue workflow --workflow-access --output "$TASK_TEST_DIR/workflow.credentials.json"
|
||||
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/workflow.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationWorkflowDefinitionSDK$' -v .)
|
||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/workflow.credentials.json" "$TASK_GO" run ./examples/workflow-definition)
|
||||
|
||||
# 接收方业务数据独立建库;数据库驱动仅属于此示例模块,不加入核心SDK。
|
||||
"$TASK_DOCKER" exec "$TASK_TEST_NAME" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=tcp -e "CREATE DATABASE orbit_receiver_test CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"'
|
||||
export ORBIT_TEST_RECEIVER_DSN="root:$TASK_DB_PASSWORD@tcp(127.0.0.1:$TASK_DB_PORT)/orbit_receiver_test"
|
||||
|
||||
132
sdk/go/examples/workflow-definition/main.go
Normal file
132
sdk/go/examples/workflow-definition/main.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// workflow-definition 演示研发定义与不可变版本发布;当前不启动流程实例。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
||||
"os"
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func run() error {
|
||||
token, queue := os.Getenv("ORBIT_TOKEN"), os.Getenv("ORBIT_QUEUE_ID")
|
||||
if path := os.Getenv("ORBIT_CREDENTIALS_FILE"); path != "" {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var credentials struct {
|
||||
Token string `json:"token"`
|
||||
QueueID string `json:"queue_id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &credentials) != nil {
|
||||
return errors.New("invalid private credential file")
|
||||
}
|
||||
if token == "" {
|
||||
token = credentials.Token
|
||||
}
|
||||
if queue == "" {
|
||||
queue = credentials.QueueID
|
||||
}
|
||||
}
|
||||
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
op := os.Getenv("ORBIT_OPERATION_ID")
|
||||
if op == "" {
|
||||
op, err = orbit.NewWorkerMessageID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 先输出稳定操作身份;响应丢失时用同一身份、同一版本和同一源码重新运行。
|
||||
fmt.Printf("operation=%s\n", op)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
created, err := client.CreateWorkflowDefinition(ctx, op+".create", orbit.CreateWorkflowDefinitionRequest{Name: "object-flow." + op, Description: "对象准备、并行生成结果与有界状态查询"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := created.Definition.ID
|
||||
in := orbit.PublishWorkflowVersionRequest{Version: "v1", Reason: "initial developer release", Specification: specification(queue)}
|
||||
published, err := client.PublishWorkflowVersion(ctx, id, op+".v1", in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
again, err := client.PublishWorkflowVersion(ctx, id, op+".v1", in)
|
||||
if err != nil || !again.Deduplicated || !reflect.DeepEqual(again.Version, published.Version) {
|
||||
return fmt.Errorf("original publication replay mismatch: %v", err)
|
||||
}
|
||||
same, err := client.PublishWorkflowVersion(ctx, id, op+".v1.same", in)
|
||||
if err != nil || !same.Deduplicated || same.Version.ID != published.Version.ID {
|
||||
return fmt.Errorf("same-version replay mismatch: %v", err)
|
||||
}
|
||||
in.Version = "v2"
|
||||
in.Reason = "extend finite workflow deadline"
|
||||
in.Specification.TimeoutMS = 90000
|
||||
next, err := client.PublishWorkflowVersion(ctx, id, op+".v2", in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
original, err := client.GetWorkflowVersion(ctx, id, "v1")
|
||||
if err != nil || !reflect.DeepEqual(original, published.Version) {
|
||||
return fmt.Errorf("original immutable version changed: %v", err)
|
||||
}
|
||||
meta, err := client.GetWorkflowDefinition(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("definition=%s revision=%d v1=%s v2=%s steps=%d max_concurrency=%d max_task_attempts=%d\n", id, meta.Revision, original.ID, next.Version.ID, original.Analysis.Steps, original.Analysis.MaxConcurrency, original.Analysis.MaxTaskAttempts)
|
||||
fmt.Println("Both definitions are stored. No workflow instance or business task has been started.")
|
||||
return nil
|
||||
}
|
||||
func specification(queue string) orbit.WorkflowSpecification {
|
||||
str := orbit.WorkflowType{Kind: "STRING"}
|
||||
boolean := orbit.WorkflowType{Kind: "BOOLEAN"}
|
||||
input := orbit.WorkflowType{Kind: "OBJECT", Fields: map[string]orbit.WorkflowType{"object_ref": str, "operation": str, "approved": boolean}}
|
||||
literal := func(raw string) orbit.WorkflowValue {
|
||||
return orbit.WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(raw)}
|
||||
}
|
||||
block := func(value orbit.WorkflowValue) orbit.WorkflowBlock {
|
||||
return orbit.WorkflowBlock{OutputType: str, Output: value}
|
||||
}
|
||||
task := func(handler string, value orbit.WorkflowValue) orbit.WorkflowTask {
|
||||
return orbit.WorkflowTask{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", InputType: str, Input: value, ResultType: str, TimeoutMS: 5000, Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 3000}, RecoverySafety: orbit.RecoveryReconcileOnly}
|
||||
}
|
||||
prepare := task("object.prepare", orbit.WorkflowRef("input", "object_ref"))
|
||||
branchDefault := block(literal(`"standard"`))
|
||||
left, right := orbit.WorkflowRef("input", "approved"), literal(`true`)
|
||||
branch := orbit.WorkflowBranch{Cases: []orbit.WorkflowBranchCase{{When: orbit.WorkflowPredicate{Op: "EQ", Left: &left, Right: &right}, Body: block(literal(`"approved"`))}}, Default: &branchDefault}
|
||||
parallel := orbit.WorkflowParallel{}
|
||||
for _, name := range []string{"preview", "manifest"} {
|
||||
work := task("object."+name, orbit.WorkflowRef("prepare"))
|
||||
body := block(orbit.WorkflowRef(name))
|
||||
body.Steps = []orbit.WorkflowStep{{Key: name, Kind: "TASK", Task: &work}}
|
||||
parallel.Branches = append(parallel.Branches, orbit.WorkflowParallelBranch{Name: name, Body: body})
|
||||
}
|
||||
status := orbit.WorkflowType{Kind: "OBJECT", Fields: map[string]orbit.WorkflowType{"status": {Kind: "STRING", Enum: []string{"PENDING", "DONE", "FAILED", "REVIEW"}}}}
|
||||
query := task("job.query", orbit.WorkflowRef("input", "operation"))
|
||||
query.ResultType = status
|
||||
query.Retry = orbit.RetryPolicy{MaxAttempts: 2, AttemptTimeoutMS: 1000, InitialDelayMS: 100, MaxDelayMS: 200}
|
||||
query.RecoverySafety = orbit.RecoveryIdempotent
|
||||
poll := orbit.WorkflowPoll{Task: query, MaxRounds: 5, TimeoutMS: 20000, IntervalMS: 1000, StatusPath: []string{"status"}, Pending: []string{"PENDING"}, Succeeded: []string{"DONE"}, Failed: []string{"FAILED"}, Review: []string{"REVIEW"}, FinalSignal: &orbit.WorkflowSignal{Name: "job.final", PayloadVersion: "v1", Correlation: orbit.WorkflowRef("input", "operation"), PayloadType: status, TimeoutMS: 20000}}
|
||||
return orbit.WorkflowSpecification{SchemaVersion: 1, InputType: input, TimeoutMS: 60000, FailurePolicy: "REVIEW", Body: orbit.WorkflowBlock{Steps: []orbit.WorkflowStep{
|
||||
{Key: "prepare", Kind: "TASK", Task: &prepare},
|
||||
{Key: "route", Kind: "BRANCH", Branch: &branch},
|
||||
{Key: "outputs", Kind: "PARALLEL", Parallel: ¶llel},
|
||||
{Key: "approval", Kind: "SIGNAL", Signal: &orbit.WorkflowSignal{Name: "object.approval", PayloadVersion: "v1", Correlation: orbit.WorkflowRef("input", "operation"), PayloadType: boolean, TimeoutMS: 10000}},
|
||||
{Key: "settle", Kind: "TIMER", Timer: &orbit.WorkflowTimer{DelayMS: 1000}},
|
||||
{Key: "result", Kind: "POLL", Poll: &poll},
|
||||
}, OutputType: status, Output: orbit.WorkflowRef("result")}}
|
||||
}
|
||||
55
sdk/go/workflow_definitions.go
Normal file
55
sdk/go/workflow_definitions.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// WorkflowRef 构造输入或已完成步骤的字段引用;合法作用域及类型在发布时由服务端校验。
|
||||
func WorkflowRef(source string, path ...string) WorkflowValue {
|
||||
return WorkflowValue{Kind: "REFERENCE", Reference: &WorkflowReference{Source: source, Path: append([]string(nil), path...)}}
|
||||
}
|
||||
|
||||
// CreateWorkflowDefinition 幂等创建稳定定义身份,不自动发布或启动流程。
|
||||
func (c *Client) CreateWorkflowDefinition(ctx context.Context, key string, in CreateWorkflowDefinitionRequest) (WorkflowDefinitionResult, error) {
|
||||
var out WorkflowDefinitionResult
|
||||
err := c.call(ctx, "POST", "/v1/workflow-definitions", key, in, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) GetWorkflowDefinition(ctx context.Context, id string) (WorkflowDefinition, error) {
|
||||
var out WorkflowDefinition
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid definition id")
|
||||
}
|
||||
err := c.call(ctx, "GET", "/v1/workflow-definitions/"+id, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// PublishWorkflowVersion 冻结规则;网络结果不明时保留原定义ID、版本、内容、原因和幂等键重报。
|
||||
func (c *Client) PublishWorkflowVersion(ctx context.Context, id, key string, in PublishWorkflowVersionRequest) (WorkflowVersionResult, error) {
|
||||
var out WorkflowVersionResult
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid definition id")
|
||||
}
|
||||
err := c.call(ctx, "POST", "/v1/workflow-definitions/"+id+"/versions", key, in, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) GetWorkflowVersion(ctx context.Context, id, version string) (WorkflowVersion, error) {
|
||||
var out WorkflowVersion
|
||||
if !validID(id) || !workflowVersionName(version) {
|
||||
return out, errors.New("invalid definition id or version")
|
||||
}
|
||||
err := c.call(ctx, "GET", "/v1/workflow-definitions/"+id+"/versions/"+version, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
func workflowVersionName(s string) bool {
|
||||
if len(s) == 0 || len(s) > 64 {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || i > 0 && (r == '_' || r == '.' || r == '-')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
109
sdk/go/workflow_definitions_integration_test.go
Normal file
109
sdk/go/workflow_definitions_integration_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIntegrationWorkflowDefinitionSDK(t *testing.T) {
|
||||
api, queue, _ := integrationWorkerClient(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
key := workerTestID(t)
|
||||
created, err := api.CreateWorkflowDefinition(ctx, key, CreateWorkflowDefinitionRequest{Name: "workflow." + key, Description: "精确数字发布回归"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := created.Definition.ID
|
||||
typ := WorkflowType{Kind: "OBJECT", Fields: map[string]WorkflowType{"n": {Kind: "NUMBER"}}}
|
||||
task := WorkflowTask{QueueID: queue, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", InputType: typ, Input: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`{"n":9007199254740993}`)}, ResultType: typ, TimeoutMS: 5000, Retry: RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 3000}, RecoverySafety: RecoveryReconcileOnly}
|
||||
in := PublishWorkflowVersionRequest{Version: "v1", Reason: "SDK publication", Specification: WorkflowSpecification{SchemaVersion: 1, TimeoutMS: 30000, FailurePolicy: "REVIEW", InputType: typ, Body: WorkflowBlock{Steps: []WorkflowStep{{Key: "prepare", Kind: "TASK", Task: &task}, {Key: "wait", Kind: "TIMER", Timer: &WorkflowTimer{DelayMS: 1000}}}, OutputType: typ, Output: WorkflowRef("prepare")}}}
|
||||
// 真服务确认发布提交后,代理关闭客户端连接;SDK不得自动换键或默默重试。
|
||||
target, _ := url.Parse(api.baseURL)
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
var dropped atomic.Bool
|
||||
var calls atomic.Int32
|
||||
proxy.ModifyResponse = func(r *http.Response) error {
|
||||
calls.Add(1)
|
||||
if dropped.CompareAndSwap(false, true) {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.StatusCode != 201 || !strings.Contains(string(raw), `"deduplicated":false`) {
|
||||
t.Error("publication was not committed before response loss")
|
||||
}
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
conn, _, e := w.(http.Hijacker).Hijack()
|
||||
if e == nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
gateway := httptest.NewServer(proxy)
|
||||
defer gateway.Close()
|
||||
client, err := NewClient(gateway.URL, Options{Token: api.token})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.PublishWorkflowVersion(ctx, id, key, in); err == nil || calls.Load() != 1 || !dropped.Load() {
|
||||
t.Fatal("response loss not surfaced", err)
|
||||
}
|
||||
published, err := client.PublishWorkflowVersion(ctx, id, key, in)
|
||||
if err != nil || !published.Deduplicated {
|
||||
t.Fatal("lost publication not recovered", err)
|
||||
}
|
||||
original := published.Version
|
||||
if string(original.Specification.Body.Steps[0].Task.Input.Literal) != `{"n":9007199254740993}` || len(original.ContentDigest) != 64 || original.Analysis.Steps != 2 || original.Analysis.MaxTaskAttempts != 1 {
|
||||
t.Fatal("SDK result changed precision or analysis")
|
||||
}
|
||||
same, err := api.PublishWorkflowVersion(ctx, id, workerTestID(t), in)
|
||||
if err != nil || !same.Deduplicated || !reflect.DeepEqual(same.Version, original) {
|
||||
t.Fatal("same version", err)
|
||||
}
|
||||
in.Specification.Body.Steps[0].Task.Input.Literal = json.RawMessage(`{"n":9007199254740992}`)
|
||||
_, err = api.PublishWorkflowVersion(ctx, id, workerTestID(t), in)
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.Code != "STATE_CONFLICT" {
|
||||
t.Fatal("overwrite accepted", err)
|
||||
}
|
||||
in.Version = "v2"
|
||||
next, err := api.PublishWorkflowVersion(ctx, id, workerTestID(t), in)
|
||||
if err != nil || next.Version.ContentDigest == original.ContentDigest {
|
||||
t.Fatal("v2", err)
|
||||
}
|
||||
stored, err := api.GetWorkflowVersion(ctx, id, "v1")
|
||||
if err != nil || !reflect.DeepEqual(stored, original) {
|
||||
t.Fatal("immutable v1", err)
|
||||
}
|
||||
metadata, err := api.GetWorkflowDefinition(ctx, id)
|
||||
if err != nil || metadata.Revision != 3 {
|
||||
t.Fatal("unexpected version count", err)
|
||||
}
|
||||
// 类型的空块输出支持显式null,不放宽普通任务的null参数约束。
|
||||
in.Version = "v3"
|
||||
in.Specification.Body = WorkflowBlock{OutputType: WorkflowType{Kind: "NULL"}, Output: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`null`)}}
|
||||
v3, err := api.PublishWorkflowVersion(ctx, id, workerTestID(t), in)
|
||||
if err != nil || v3.Version.Analysis.Queues == nil || len(v3.Version.Analysis.Queues) != 0 {
|
||||
t.Fatal("null block", err)
|
||||
}
|
||||
if _, err := api.GetWorkflowVersion(ctx, id, "../other"); err == nil {
|
||||
t.Fatal("unsafe path accepted")
|
||||
}
|
||||
t.Log("real HTTP publication response dropped after commit; original identity restored precise immutable version, no second revision; all SDK definition routes exercised")
|
||||
}
|
||||
115
sdk/go/workflow_types.go
Normal file
115
sdk/go/workflow_types.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package orbit
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// WorkflowSpecification 是结构版本1的完整定义。版本发布后禁止原地修改。
|
||||
type WorkflowSpecification struct {
|
||||
SchemaVersion uint32 `json:"schema_version"` // SchemaVersion 当前必须为1,和业务发布版本分开。
|
||||
InputType WorkflowType `json:"input_type"` // InputType 实例输入契约,OBJECT字段全部必填且不允许额外字段。
|
||||
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 从实例受理起计的有限总时限,1毫秒至30天,暂停不续期。
|
||||
FailurePolicy string `json:"failure_policy"` // FailurePolicy REVIEW或FAIL_AFTER_SETTLED;未知效果始终先核查。
|
||||
Body WorkflowBlock `json:"body"` // Body 顶层顺序块;组合节点内部同样使用有限顺序块。
|
||||
}
|
||||
|
||||
// WorkflowType 是有限的类型描述;JSON表示只能整体传递的任意JSON,不能据此访问未知字段。
|
||||
type WorkflowType struct {
|
||||
Kind string `json:"kind"` // Kind STRING、NUMBER、BOOLEAN、NULL、OBJECT、ARRAY或JSON。
|
||||
Fields map[string]WorkflowType `json:"fields,omitempty"` // Fields 仅OBJECT使用,最多64项;字段名称为ASCII标识。
|
||||
Items *WorkflowType `json:"items,omitempty"` // Items 仅ARRAY使用,数组元素采用同一契约。
|
||||
Enum []string `json:"enum,omitempty"` // Enum 仅STRING使用,可选的1至32个互异值,按精确文本比较。
|
||||
}
|
||||
|
||||
// WorkflowValue 明确区分常量、引用和构造对象/数组,禁止字符串插值和任意脚本。
|
||||
type WorkflowValue struct {
|
||||
Kind string `json:"kind"` // Kind LITERAL、REFERENCE、OBJECT或ARRAY。
|
||||
Literal json.RawMessage `json:"literal,omitempty"` // Literal 仅LITERAL使用,保留精确数字,可以显式为null。
|
||||
Reference *WorkflowReference `json:"reference,omitempty"` // WorkflowReference 仅REFERENCE使用,必须指向当前路径可用的输入或结果。
|
||||
Fields map[string]WorkflowValue `json:"fields,omitempty"` // Fields 仅OBJECT使用,字段值递归解析,最多64项。
|
||||
Items []WorkflowValue `json:"items,omitempty"` // Items 仅ARRAY使用,最多256项;业务大数组可整体引用。
|
||||
}
|
||||
|
||||
type WorkflowReference struct {
|
||||
Source string `json:"source"` // Source input、workflow或在当前词法作用域已完成的步骤key。
|
||||
Path []string `json:"path,omitempty"` // Path 最多8层已声明OBJECT字段;空路径引用整个值,不支持动态路径或数组下标。
|
||||
}
|
||||
|
||||
// WorkflowBlock 的输出是组合节点向外暴露的唯一数据出口;内部步骤不能被外部直接引用。
|
||||
type WorkflowBlock struct {
|
||||
Steps []WorkflowStep `json:"steps,omitempty"` // Steps 按顺序执行;空块可直接映射输入,所有分支仍需显式输出。
|
||||
OutputType WorkflowType `json:"output_type"` // OutputType 本块输出契约。
|
||||
Output WorkflowValue `json:"output"` // Output 全部步骤完成后解析的输出表达式。
|
||||
}
|
||||
|
||||
type WorkflowStep struct {
|
||||
Key string `json:"key"` // Key 全定义唯一ASCII标识,1至64字符,不能为input或workflow。
|
||||
Kind string `json:"kind"` // Kind TASK、BRANCH、PARALLEL、SIGNAL、TIMER或POLL;必须只提供对应配置。
|
||||
Task *WorkflowTask `json:"task,omitempty"` // WorkflowTask 普通业务工作,交由独立Worker处理。
|
||||
Branch *WorkflowBranch `json:"branch,omitempty"` // WorkflowBranch 按顺序选择第一个匹配路径,必须有默认路径。
|
||||
Parallel *WorkflowParallel `json:"parallel,omitempty"` // WorkflowParallel 静态有限分支,全部成功后汇合。
|
||||
Signal *WorkflowSignal `json:"signal,omitempty"` // WorkflowSignal 等待一次按步骤和业务操作匹配的外部输入。
|
||||
Timer *WorkflowTimer `json:"timer,omitempty"` // WorkflowTimer 持久化时间等待,不占业务Worker槽位。
|
||||
Poll *WorkflowPoll `json:"poll,omitempty"` // WorkflowPoll 有界查询,轮次预算与每轮Pulse尝试预算独立。
|
||||
}
|
||||
|
||||
type WorkflowTask struct {
|
||||
QueueID string `json:"queue_id"` // QueueID 发布及启动时都需按命名空间和当前权限校验。
|
||||
Handler string `json:"handler"` // Handler 已命名的业务处理器;定义不携带可执行源码。
|
||||
HandlerVersion string `json:"handler_version"` // HandlerVersion 固定的处理器版本。
|
||||
PayloadVersion string `json:"payload_version"` // PayloadVersion 固定的输入契约版本。
|
||||
InputType WorkflowType `json:"input_type"` // InputType 输入映射必须满足的处理器参数契约。
|
||||
Input WorkflowValue `json:"input"` // Input 在激活时根据已保存数据解析,不使用当前时间或随机数。
|
||||
ResultType WorkflowType `json:"result_type"` // ResultType 权威成功结果契约,不匹配时进入核查而不推进。
|
||||
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 单任务总时限,正值且不超过流程时限,运行时还受实例剩余时限限制。
|
||||
Retry RetryPolicy `json:"retry"` // Retry 显式冻结有限策略,包含首次尝试,最多100次。
|
||||
RecoverySafety RecoverySafety `json:"recovery_safety"` // RecoverySafety 显式RECONCILE_ONLY或IDEMPOTENT。
|
||||
Priority int16 `json:"priority,omitempty"` // Priority 冻结优先级0至9。
|
||||
BusinessKey *WorkflowValue `json:"business_key,omitempty"` // BusinessKey 可选字符串映射,只用于业务追踪,不代表幂等。
|
||||
ConcurrencyKey *WorkflowValue `json:"concurrency_key,omitempty"` // ConcurrencyKey 可选字符串映射,采用Pulse共享资源许可规则。
|
||||
}
|
||||
|
||||
type WorkflowPredicate struct {
|
||||
Op string `json:"op"` // Op EQ/NE/LT/LE/GT/GE或AND/OR/NOT;只比较已声明的标量。
|
||||
Left *WorkflowValue `json:"left,omitempty"` // Left 比较操作左值,逻辑操作不得携带。
|
||||
Right *WorkflowValue `json:"right,omitempty"` // Right 比较操作右值,必须与左值同种标量。
|
||||
Arguments []WorkflowPredicate `json:"arguments,omitempty"` // Arguments AND/OR为2至16项,NOT为1项,深度最多8层。
|
||||
}
|
||||
|
||||
type WorkflowBranch struct {
|
||||
Cases []WorkflowBranchCase `json:"cases"` // Cases 1至16个按顺序匹配的条件路径。
|
||||
Default *WorkflowBlock `json:"default"` // Default 必填,穷尽未匹配情况,不能静默跳过。
|
||||
}
|
||||
type WorkflowBranchCase struct {
|
||||
When WorkflowPredicate `json:"when"` // When 在分支激活时根据前置持久化数据计算。
|
||||
Body WorkflowBlock `json:"body"` // Body 选中路径;所有路径输出类型必须一致。
|
||||
}
|
||||
type WorkflowParallel struct {
|
||||
Branches []WorkflowParallelBranch `json:"branches"` // Branches 2至8个静态分支;结果为各分支名称对应输出的OBJECT。
|
||||
}
|
||||
type WorkflowParallelBranch struct {
|
||||
Name string `json:"name"` // Name 本并行节点内唯一ASCII字段标识。
|
||||
Body WorkflowBlock `json:"body"` // Body 分支内部独立顺序块;不能读取兄弟分支尚未汇合的数据。
|
||||
}
|
||||
|
||||
type WorkflowSignal struct {
|
||||
Name string `json:"name"` // Name 外部信号类型,需同时匹配实例、步骤激活和业务关联。
|
||||
PayloadVersion string `json:"payload_version"` // PayloadVersion 外部输入契约版本。
|
||||
Correlation WorkflowValue `json:"correlation"` // Correlation 必须解析为非空业务操作字符串,运行时冻结在等待记录。
|
||||
PayloadType WorkflowType `json:"payload_type"` // PayloadType 信号内容及该节点成功输出的类型。
|
||||
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 激活起的有限等待时限,不超过流程时限;暂停不续期。
|
||||
}
|
||||
type WorkflowTimer struct {
|
||||
DelayMS uint64 `json:"delay_ms"` // DelayMS 激活起的正等待时长,不超过流程时限;输出为NULL。
|
||||
}
|
||||
|
||||
type WorkflowPoll struct {
|
||||
Task WorkflowTask `json:"task"` // WorkflowTask 同一查询处理器及固定输入;查询不得再次调用原业务提交接口。
|
||||
MaxRounds uint32 `json:"max_rounds"` // MaxRounds 1至1000,轮次含首次;每轮一个任务,单轮内部重试不新增轮次。
|
||||
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 激活起的总查询时限,不超过流程时限。
|
||||
IntervalMS uint64 `json:"interval_ms"` // IntervalMS 从上一轮处理中结果受理后等待,至少1000毫秒且小于查询总时限;不补发错过的轮次。
|
||||
StatusPath []string `json:"status_path,omitempty"` // StatusPath 成功查询结果内的STRING枚举字段路径,空表示整个结果就是枚举。
|
||||
Pending []string `json:"pending"` // Pending 表示仍在处理的枚举值,至少1项。
|
||||
Succeeded []string `json:"succeeded"` // Succeeded 表示业务确认成功的枚举值,至少1项。
|
||||
Failed []string `json:"failed,omitempty"` // Failed 表示业务确认失败的枚举值。
|
||||
Review []string `json:"review,omitempty"` // Review 表示需要核查的枚举值;四组必须无交集并覆盖整个状态枚举。
|
||||
FinalSignal *WorkflowSignal `json:"final_signal,omitempty"` // FinalSignal 可选最终回执,其内容类型与查询结果一致,只接受非Pending状态。
|
||||
}
|
||||
56
sdk/go/workflow_views.go
Normal file
56
sdk/go/workflow_views.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package orbit
|
||||
|
||||
import "time"
|
||||
|
||||
// CreateWorkflowDefinitionRequest 创建命名空间内的稳定定义身份,不发布版本或启动实例。
|
||||
type CreateWorkflowDefinitionRequest struct {
|
||||
Name string `json:"name"` // Name 命名空间内唯一ASCII名称,最长128字符。
|
||||
Description string `json:"description"` // Description 用途说明,最多1024个Unicode字符。
|
||||
}
|
||||
|
||||
// PublishWorkflowVersionRequest 发布不可变版本;同版本内容冲突不能通过更换幂等键覆盖。
|
||||
type PublishWorkflowVersionRequest struct {
|
||||
Version string `json:"version"` // Version 研发指定的ASCII版本名,最长64字符,不包含路径分隔符。
|
||||
Specification WorkflowSpecification `json:"specification"` // WorkflowSpecification 完整且显式的冻结规则。
|
||||
Reason string `json:"reason"` // Reason 非空发布原因,最多1024个字符,写入审计。
|
||||
}
|
||||
|
||||
// WorkflowDefinition 仅含命名空间级元数据;读取完整版本还须授权所有引用队列。
|
||||
type WorkflowDefinition struct {
|
||||
ID string `json:"id"` // ID 定义稳定标识。
|
||||
NamespaceID string `json:"namespace_id"` // NamespaceID 定义隔离范围。
|
||||
Name string `json:"name"` // Name 创建时冻结的唯一名称。
|
||||
Description string `json:"description"` // Description 创建时冻结的用途说明。
|
||||
Revision uint64 `json:"revision,string"` // Revision 从1开始,每个新发布版本增加1,不能替代业务版本名。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt UTC数据库创建时间。
|
||||
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最近新版本发布的UTC数据库时间。
|
||||
}
|
||||
|
||||
// WorkflowAnalysis 是静态预算上界;不表示业务任务已执行或已分配配额。
|
||||
type WorkflowAnalysis struct {
|
||||
Steps uint32 `json:"steps"` // Steps 全定义静态步骤数。
|
||||
MaxConcurrency uint32 `json:"max_concurrency"` // MaxConcurrency 并行业务任务上界。
|
||||
MaxTaskAttempts uint32 `json:"max_task_attempts"` // MaxTaskAttempts 所有路径的保守尝试预算总和。
|
||||
Queues []string `json:"queues"` // Queues 排序去重队列,始终为数组。
|
||||
}
|
||||
|
||||
// WorkflowVersion 返回原始发布内容和身份,不使用可变的latest别名。
|
||||
type WorkflowVersion struct {
|
||||
ID string `json:"id"` // ID 不可变版本记录标识。
|
||||
NamespaceID string `json:"namespace_id"` // NamespaceID 隔离范围。
|
||||
DefinitionID string `json:"definition_id"` // DefinitionID 父定义标识,由应用事务校验。
|
||||
Version string `json:"version"` // Version 发布版本名。
|
||||
Specification WorkflowSpecification `json:"specification"` // WorkflowSpecification 已冻结规则,数字精度不经float64转换。
|
||||
ContentDigest string `json:"content_digest"` // ContentDigest 带域分隔的SHA-256,64位小写十六进制。
|
||||
PublishedBy string `json:"published_by"` // PublishedBy 原发布凭据标识,不包含凭据秘密。
|
||||
PublishedAt time.Time `json:"published_at"` // PublishedAt 原发布时间,重复发布不更新。
|
||||
Analysis WorkflowAnalysis `json:"analysis"` // WorkflowAnalysis 根据结构版本1规则计算的静态检查结果。
|
||||
}
|
||||
type WorkflowDefinitionResult struct {
|
||||
Definition WorkflowDefinition `json:"definition"` // Definition 创建事务的原始快照。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 是否返回原回执。
|
||||
}
|
||||
type WorkflowVersionResult struct {
|
||||
Version WorkflowVersion `json:"version"` // Version 原版本快照,不包含当前父定义状态。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 原键或同版本同内容重复请求。
|
||||
}
|
||||
519
weave/definition/check.go
Normal file
519
weave/definition/check.go
Normal file
@@ -0,0 +1,519 @@
|
||||
package definition
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
const MaxSpecificationBytes = 256 << 10
|
||||
const maxTimeoutMS = 30 * 86400000
|
||||
|
||||
type checker struct {
|
||||
result Checked
|
||||
keys map[string]bool
|
||||
queues map[core.ID]bool
|
||||
timeout uint64
|
||||
}
|
||||
type scope map[string]Type
|
||||
|
||||
// Check 验证完整结构、词法数据依赖和资源边界;不修改调用方定义,也不访问数据库。
|
||||
// 队列存在性、当前权限和不可变版本身份由发布事务另行核验。
|
||||
func Check(in Specification) (Checked, error) {
|
||||
if in.SchemaVersion != 1 || in.TimeoutMS == 0 || in.TimeoutMS > maxTimeoutMS || (in.FailurePolicy != "REVIEW" && in.FailurePolicy != "FAIL_AFTER_SETTLED") {
|
||||
return Checked{}, invalid("specification", "schema_version=1, finite timeout and explicit failure policy required")
|
||||
}
|
||||
if err := checkType(in.InputType, 0); err != nil {
|
||||
return Checked{}, err
|
||||
}
|
||||
c := checker{keys: map[string]bool{}, queues: map[core.ID]bool{}, timeout: in.TimeoutMS}
|
||||
initial := scope{"input": in.InputType, "workflow": {Kind: "OBJECT", Fields: map[string]Type{"id": {Kind: "STRING"}, "business_key": {Kind: "STRING"}}}}
|
||||
width, err := c.block(in.Body, initial, 0)
|
||||
if err != nil {
|
||||
return Checked{}, err
|
||||
}
|
||||
if width > 32 || c.result.MaxTaskAttempts > 10000 {
|
||||
return Checked{}, invalid("specification", "parallel or total attempt budget exceeded")
|
||||
}
|
||||
raw, err := jsonwire.Marshal(in)
|
||||
if err != nil || len(raw) > MaxSpecificationBytes {
|
||||
return Checked{}, invalid("specification", "encoded definition exceeds 256 KiB or is invalid")
|
||||
}
|
||||
canonical, err := jsonvalue.Canonical(raw)
|
||||
if err != nil {
|
||||
return Checked{}, invalid("specification", "definition JSON exceeds supported depth or value limits")
|
||||
}
|
||||
c.result.Digest = core.Digest(sha256.Sum256(append([]byte("orbit.weave.spec.v1\x00"), canonical...)))
|
||||
c.result.MaxConcurrency = width
|
||||
for q := range c.queues {
|
||||
c.result.Queues = append(c.result.Queues, q)
|
||||
}
|
||||
sort.Slice(c.result.Queues, func(i, j int) bool { return c.result.Queues[i] < c.result.Queues[j] })
|
||||
return c.result, nil
|
||||
}
|
||||
|
||||
func invalid(where, message string) error { return fmt.Errorf("%s: %s", where, message) }
|
||||
func name(s string, limit int) bool {
|
||||
if len(s) == 0 || len(s) > limit {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || i > 0 && (r == '_' || r == '.' || r == '-')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func cloneScope(in scope) scope {
|
||||
out := make(scope, len(in)+8)
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (c *checker) block(b Block, parent scope, depth int) (uint32, error) {
|
||||
if depth > 4 || len(b.Steps) > 128 {
|
||||
return 0, invalid("body", "block depth or step limit exceeded")
|
||||
}
|
||||
if err := checkType(b.OutputType, 0); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
env := cloneScope(parent)
|
||||
var width uint32
|
||||
for _, step := range b.Steps {
|
||||
if !name(step.Key, 64) || step.Key == "input" || step.Key == "workflow" || c.keys[step.Key] {
|
||||
return 0, invalid("step", "key must be unique and nonreserved")
|
||||
}
|
||||
c.keys[step.Key] = true
|
||||
c.result.Steps++
|
||||
if c.result.Steps > 128 {
|
||||
return 0, invalid("body", "at most 128 static steps allowed")
|
||||
}
|
||||
result, concurrency, err := c.step(step, env, depth)
|
||||
if err != nil {
|
||||
return 0, invalid(step.Key, err.Error())
|
||||
}
|
||||
env[step.Key] = result
|
||||
if concurrency > width {
|
||||
width = concurrency
|
||||
}
|
||||
}
|
||||
if err := checkValue(b.Output, b.OutputType, env, 0); err != nil {
|
||||
return 0, invalid("output", err.Error())
|
||||
}
|
||||
return width, nil
|
||||
}
|
||||
func (c *checker) step(s Step, env scope, depth int) (Type, uint32, error) {
|
||||
n := 0
|
||||
for _, present := range []bool{s.Task != nil, s.Branch != nil, s.Parallel != nil, s.Signal != nil, s.Timer != nil, s.Poll != nil} {
|
||||
if present {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
return Type{}, 0, invalid("step", "exactly one kind configuration required")
|
||||
}
|
||||
switch s.Kind {
|
||||
case "TASK":
|
||||
if s.Task != nil {
|
||||
if err := c.task(*s.Task, env); err != nil {
|
||||
return Type{}, 0, err
|
||||
}
|
||||
c.result.MaxTaskAttempts += s.Task.Retry.MaxAttempts
|
||||
return s.Task.ResultType, 1, nil
|
||||
}
|
||||
case "SIGNAL":
|
||||
if s.Signal != nil {
|
||||
return s.Signal.PayloadType, 0, c.signal(*s.Signal, env)
|
||||
}
|
||||
case "TIMER":
|
||||
if s.Timer != nil && s.Timer.DelayMS > 0 && s.Timer.DelayMS <= c.timeout {
|
||||
return Type{Kind: "NULL"}, 0, nil
|
||||
}
|
||||
case "BRANCH":
|
||||
if s.Branch != nil {
|
||||
b := s.Branch
|
||||
if b.Default == nil || len(b.Cases) < 1 || len(b.Cases) > 16 {
|
||||
return Type{}, 0, invalid("branch", "1 to 16 cases and explicit default required")
|
||||
}
|
||||
width, err := c.block(*b.Default, env, depth+1)
|
||||
if err != nil {
|
||||
return Type{}, 0, err
|
||||
}
|
||||
for _, path := range b.Cases {
|
||||
if err := checkPredicate(path.When, env, 0); err != nil {
|
||||
return Type{}, 0, err
|
||||
}
|
||||
w, err := c.block(path.Body, env, depth+1)
|
||||
if err != nil {
|
||||
return Type{}, 0, err
|
||||
}
|
||||
if !sameType(path.Body.OutputType, b.Default.OutputType) {
|
||||
return Type{}, 0, invalid("branch", "all path output types must match")
|
||||
}
|
||||
if w > width {
|
||||
width = w
|
||||
}
|
||||
}
|
||||
return b.Default.OutputType, width, nil
|
||||
}
|
||||
case "PARALLEL":
|
||||
if s.Parallel != nil {
|
||||
if len(s.Parallel.Branches) < 2 || len(s.Parallel.Branches) > 8 {
|
||||
return Type{}, 0, invalid("parallel", "2 to 8 named branches required")
|
||||
}
|
||||
result := Type{Kind: "OBJECT", Fields: map[string]Type{}}
|
||||
var width uint32
|
||||
for _, branch := range s.Parallel.Branches {
|
||||
if !name(branch.Name, 64) {
|
||||
return Type{}, 0, invalid("parallel", "invalid branch name")
|
||||
}
|
||||
if _, exists := result.Fields[branch.Name]; exists {
|
||||
return Type{}, 0, invalid("parallel", "duplicate branch name")
|
||||
}
|
||||
w, err := c.block(branch.Body, env, depth+1)
|
||||
if err != nil {
|
||||
return Type{}, 0, err
|
||||
}
|
||||
width += w
|
||||
result.Fields[branch.Name] = branch.Body.OutputType
|
||||
}
|
||||
if width > 32 {
|
||||
return Type{}, 0, invalid("parallel", "at most 32 concurrent business tasks allowed")
|
||||
}
|
||||
return result, width, nil
|
||||
}
|
||||
case "POLL":
|
||||
if s.Poll != nil {
|
||||
p := s.Poll
|
||||
if p.MaxRounds == 0 || p.MaxRounds > 1000 || p.TimeoutMS == 0 || p.TimeoutMS > c.timeout || p.IntervalMS < 1000 || p.IntervalMS >= p.TimeoutMS || p.Task.TimeoutMS > p.TimeoutMS {
|
||||
return Type{}, 0, invalid("poll", "finite rounds, interval and compatible time budgets required")
|
||||
}
|
||||
if err := c.task(p.Task, env); err != nil {
|
||||
return Type{}, 0, err
|
||||
}
|
||||
status, err := pathType(p.Task.ResultType, p.StatusPath)
|
||||
if err != nil || status.Kind != "STRING" || len(status.Enum) == 0 || len(p.Pending) == 0 || len(p.Succeeded) == 0 {
|
||||
return Type{}, 0, invalid("poll", "status must be a declared string enum with pending and success cases")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, group := range [][]string{p.Pending, p.Succeeded, p.Failed, p.Review} {
|
||||
for _, v := range group {
|
||||
if !contains(status.Enum, v) || seen[v] {
|
||||
return Type{}, 0, invalid("poll", "status groups must form an exact disjoint partition")
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
if len(seen) != len(status.Enum) {
|
||||
return Type{}, 0, invalid("poll", "unmapped status enum value")
|
||||
}
|
||||
if p.FinalSignal != nil {
|
||||
if err := c.signal(*p.FinalSignal, env); err != nil {
|
||||
return Type{}, 0, err
|
||||
}
|
||||
if p.FinalSignal.TimeoutMS != p.TimeoutMS || !sameType(p.FinalSignal.PayloadType, p.Task.ResultType) {
|
||||
return Type{}, 0, invalid("poll", "final signal must share result type and poll deadline")
|
||||
}
|
||||
}
|
||||
c.result.MaxTaskAttempts += p.MaxRounds * p.Task.Retry.MaxAttempts
|
||||
return p.Task.ResultType, 1, nil
|
||||
}
|
||||
}
|
||||
return Type{}, 0, invalid("step", "unknown kind, mismatched configuration or invalid timer")
|
||||
}
|
||||
|
||||
func (c *checker) task(t Task, env scope) error {
|
||||
if t.TimeoutMS == 0 || t.TimeoutMS > c.timeout || t.InputType.Kind == "NULL" || t.ResultType.Kind == "NULL" {
|
||||
return invalid("task", "finite timeout and non-null payload/result types required")
|
||||
}
|
||||
// 复用Pulse的处理器/策略边界,但定义必须显式提供生效值,不能在后续发布时解释不同默认值。
|
||||
v, err := service.NormalizeSubmit(service.SubmitInput{QueueID: t.QueueID, Handler: t.Handler, HandlerVersion: t.HandlerVersion, PayloadVersion: t.PayloadVersion, Payload: json.RawMessage(`{}`), TaskTimeoutMS: t.TimeoutMS, RecoverySafety: t.RecoverySafety, Retry: t.Retry, Priority: t.Priority})
|
||||
if err != nil || v.Retry != t.Retry || v.RecoverySafety != t.RecoverySafety {
|
||||
return invalid("task", "invalid or incomplete frozen Pulse policy")
|
||||
}
|
||||
if err := checkType(t.InputType, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkType(t.ResultType, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkValue(t.Input, t.InputType, env, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if t.Input.Kind == "LITERAL" && bytes.Equal(bytes.TrimSpace(t.Input.Literal), []byte("null")) {
|
||||
return invalid("task", "null task input is not accepted by Pulse")
|
||||
}
|
||||
for _, key := range []*Value{t.BusinessKey, t.ConcurrencyKey} {
|
||||
if key != nil {
|
||||
if err := checkValue(*key, Type{Kind: "STRING"}, env, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
c.queues[t.QueueID] = true
|
||||
return nil
|
||||
}
|
||||
func (c *checker) signal(s Signal, env scope) error {
|
||||
if !name(s.Name, 128) || !name(s.PayloadVersion, 32) || s.TimeoutMS == 0 || s.TimeoutMS > c.timeout {
|
||||
return invalid("signal", "name, payload version and finite timeout required")
|
||||
}
|
||||
if err := checkType(s.PayloadType, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkValue(s.Correlation, Type{Kind: "STRING"}, env, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Correlation.Kind == "LITERAL" {
|
||||
var value string
|
||||
if json.Unmarshal(s.Correlation.Literal, &value) != nil || len(value) == 0 || len(value) > 512 {
|
||||
return invalid("signal", "literal correlation must be a nonempty string up to 512 bytes")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkType(t Type, depth int) error {
|
||||
if depth > 8 || len(t.Fields) > 64 || len(t.Enum) > 32 {
|
||||
return invalid("type", "schema depth or width limit exceeded")
|
||||
}
|
||||
if t.Kind != "OBJECT" && len(t.Fields) != 0 || t.Kind != "ARRAY" && t.Items != nil || t.Kind != "STRING" && len(t.Enum) != 0 {
|
||||
return invalid("type", "fields, items or enum do not match kind")
|
||||
}
|
||||
switch t.Kind {
|
||||
case "JSON", "NUMBER", "BOOLEAN", "NULL":
|
||||
case "STRING":
|
||||
seen := map[string]bool{}
|
||||
for _, value := range t.Enum {
|
||||
if !utf8.ValidString(value) || len(value) > 128 || seen[value] {
|
||||
return invalid("type", "invalid or duplicate string enum value")
|
||||
}
|
||||
seen[value] = true
|
||||
}
|
||||
case "OBJECT":
|
||||
for key, field := range t.Fields {
|
||||
if !name(key, 64) {
|
||||
return invalid("type", "invalid object field name")
|
||||
}
|
||||
if err := checkType(field, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "ARRAY":
|
||||
if t.Items == nil {
|
||||
return invalid("type", "array items type required")
|
||||
}
|
||||
return checkType(*t.Items, depth+1)
|
||||
default:
|
||||
return invalid("type", "unknown kind")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func sameType(a, b Type) bool { return assignable(a, b) && assignable(b, a) }
|
||||
func assignable(actual, expected Type) bool {
|
||||
if expected.Kind == "JSON" {
|
||||
return true
|
||||
}
|
||||
if actual.Kind != expected.Kind {
|
||||
return false
|
||||
}
|
||||
switch expected.Kind {
|
||||
case "OBJECT":
|
||||
if len(actual.Fields) != len(expected.Fields) {
|
||||
return false
|
||||
}
|
||||
for key, t := range expected.Fields {
|
||||
v, ok := actual.Fields[key]
|
||||
if !ok || !assignable(v, t) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
case "ARRAY":
|
||||
return actual.Items != nil && expected.Items != nil && assignable(*actual.Items, *expected.Items)
|
||||
case "STRING":
|
||||
if len(expected.Enum) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(actual.Enum) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range actual.Enum {
|
||||
if !contains(expected.Enum, v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func contains(values []string, value string) bool {
|
||||
for _, v := range values {
|
||||
if v == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func pathType(t Type, path []string) (Type, error) {
|
||||
if len(path) > 8 {
|
||||
return Type{}, invalid("reference", "path exceeds 8 fields")
|
||||
}
|
||||
for _, key := range path {
|
||||
if t.Kind != "OBJECT" {
|
||||
return Type{}, invalid("reference", "path crosses a non-object or untyped JSON value")
|
||||
}
|
||||
var ok bool
|
||||
t, ok = t.Fields[key]
|
||||
if !ok {
|
||||
return Type{}, invalid("reference", "field not declared")
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
func referenceType(r Reference, env scope) (Type, error) {
|
||||
t, ok := env[r.Source]
|
||||
if !ok {
|
||||
return Type{}, invalid("reference", "source is missing, forward, conditional or outside this block")
|
||||
}
|
||||
return pathType(t, r.Path)
|
||||
}
|
||||
|
||||
func checkValue(v Value, expected Type, env scope, depth int) error {
|
||||
if depth > 8 {
|
||||
return invalid("value", "expression depth exceeds 8")
|
||||
}
|
||||
if v.Kind != "LITERAL" && len(v.Literal) != 0 || v.Kind != "REFERENCE" && v.Reference != nil || v.Kind != "OBJECT" && len(v.Fields) != 0 || v.Kind != "ARRAY" && len(v.Items) != 0 {
|
||||
return invalid("value", "configuration does not match kind")
|
||||
}
|
||||
switch v.Kind {
|
||||
case "LITERAL":
|
||||
return ValidateData(expected, v.Literal)
|
||||
case "REFERENCE":
|
||||
if v.Reference == nil {
|
||||
return invalid("reference", "source required")
|
||||
}
|
||||
actual, err := referenceType(*v.Reference, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !assignable(actual, expected) {
|
||||
return invalid("reference", "source and target types are incompatible")
|
||||
}
|
||||
case "OBJECT":
|
||||
if expected.Kind != "OBJECT" && expected.Kind != "JSON" || len(v.Fields) > 64 {
|
||||
return invalid("value", "object expression has incompatible type or too many fields")
|
||||
}
|
||||
if expected.Kind == "OBJECT" && len(v.Fields) != len(expected.Fields) {
|
||||
return invalid("value", "object expression must define every field exactly")
|
||||
}
|
||||
for key, child := range v.Fields {
|
||||
if !name(key, 64) {
|
||||
return invalid("value", "invalid object field")
|
||||
}
|
||||
t := Type{Kind: "JSON"}
|
||||
if expected.Kind == "OBJECT" {
|
||||
var ok bool
|
||||
t, ok = expected.Fields[key]
|
||||
if !ok {
|
||||
return invalid("value", "extra object field")
|
||||
}
|
||||
}
|
||||
if err := checkValue(child, t, env, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "ARRAY":
|
||||
if expected.Kind != "ARRAY" && expected.Kind != "JSON" || len(v.Items) > 256 {
|
||||
return invalid("value", "array expression has incompatible type or too many elements")
|
||||
}
|
||||
t := Type{Kind: "JSON"}
|
||||
if expected.Kind == "ARRAY" {
|
||||
t = *expected.Items
|
||||
}
|
||||
for _, child := range v.Items {
|
||||
if err := checkValue(child, t, env, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return invalid("value", "unknown kind")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scalarType(v Value, env scope) (string, error) {
|
||||
if err := checkValue(v, Type{Kind: "JSON"}, env, 0); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var kind string
|
||||
switch v.Kind {
|
||||
case "REFERENCE":
|
||||
t, err := referenceType(*v.Reference, env)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
kind = t.Kind
|
||||
case "LITERAL":
|
||||
var raw any
|
||||
d := json.NewDecoder(bytes.NewReader(v.Literal))
|
||||
d.UseNumber()
|
||||
if err := d.Decode(&raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch raw.(type) {
|
||||
case nil:
|
||||
kind = "NULL"
|
||||
case string:
|
||||
kind = "STRING"
|
||||
case bool:
|
||||
kind = "BOOLEAN"
|
||||
case json.Number:
|
||||
kind = "NUMBER"
|
||||
}
|
||||
}
|
||||
if kind != "STRING" && kind != "NUMBER" && kind != "BOOLEAN" && kind != "NULL" {
|
||||
return "", invalid("predicate", "only typed scalar comparisons allowed")
|
||||
}
|
||||
return kind, nil
|
||||
}
|
||||
func checkPredicate(p Predicate, env scope, depth int) error {
|
||||
if depth > 8 {
|
||||
return invalid("predicate", "expression depth exceeds 8")
|
||||
}
|
||||
switch p.Op {
|
||||
case "AND", "OR", "NOT":
|
||||
if p.Left != nil || p.Right != nil || len(p.Arguments) > 16 || (p.Op == "NOT" && len(p.Arguments) != 1) || (p.Op != "NOT" && len(p.Arguments) < 2) {
|
||||
return invalid("predicate", "invalid logical arguments")
|
||||
}
|
||||
for _, child := range p.Arguments {
|
||||
if err := checkPredicate(child, env, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "EQ", "NE", "LT", "LE", "GT", "GE":
|
||||
if p.Left == nil || p.Right == nil || len(p.Arguments) != 0 {
|
||||
return invalid("predicate", "exactly two comparison operands required")
|
||||
}
|
||||
left, err := scalarType(*p.Left, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
right, err := scalarType(*p.Right, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if left != right || (p.Op != "EQ" && p.Op != "NE" && left != "STRING" && left != "NUMBER") {
|
||||
return invalid("predicate", "incompatible comparison operands")
|
||||
}
|
||||
default:
|
||||
return invalid("predicate", "unknown operator")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
211
weave/definition/check_test.go
Normal file
211
weave/definition/check_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package definition
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||
)
|
||||
|
||||
func literal(raw string) Value { return Value{Kind: "LITERAL", Literal: json.RawMessage(raw)} }
|
||||
func ref(source string, path ...string) Value {
|
||||
return Value{Kind: "REFERENCE", Reference: &Reference{Source: source, Path: path}}
|
||||
}
|
||||
func scalar(kind string) Type { return Type{Kind: kind} }
|
||||
func block(result Type, value Value, steps ...Step) Block {
|
||||
return Block{Steps: steps, OutputType: result, Output: value}
|
||||
}
|
||||
func testTask() Task {
|
||||
return Task{QueueID: "11111111111111111111111111111111", Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", InputType: scalar("STRING"), Input: ref("input", "operation"), ResultType: scalar("STRING"), TimeoutMS: 10000, Retry: pulse.RetryPolicy{MaxAttempts: 2, AttemptTimeoutMS: 3000, InitialDelayMS: 1000, MaxDelayMS: 2000}, RecoverySafety: pulse.RecoveryReconcileOnly}
|
||||
}
|
||||
func testSpecification() Specification {
|
||||
t := testTask()
|
||||
return Specification{SchemaVersion: 1, TimeoutMS: 60000, FailurePolicy: "REVIEW", InputType: Type{Kind: "OBJECT", Fields: map[string]Type{"operation": scalar("STRING"), "enabled": scalar("BOOLEAN")}}, Body: block(scalar("STRING"), ref("prepare"), Step{Key: "prepare", Kind: "TASK", Task: &t})}
|
||||
}
|
||||
func TestStructuredDefinitionScopesAndBudgets(t *testing.T) {
|
||||
base := testSpecification()
|
||||
left, right := ref("input", "enabled"), literal("true")
|
||||
task := testTask()
|
||||
branch := Branch{Cases: []BranchCase{{When: Predicate{Op: "EQ", Left: &left, Right: &right}, Body: block(scalar("STRING"), ref("chosen"), Step{Key: "chosen", Kind: "TASK", Task: &task})}}, Default: ptrBlock(block(scalar("STRING"), literal(`"skipped"`)))}
|
||||
parallel := Parallel{Branches: []ParallelBranch{{Name: "first", Body: block(scalar("STRING"), ref("choice"))}, {Name: "second", Body: block(scalar("NULL"), ref("timer"), Step{Key: "timer", Kind: "TIMER", Timer: &Timer{DelayMS: 1}})}}}
|
||||
base.Body = block(Type{Kind: "OBJECT", Fields: map[string]Type{"first": scalar("STRING"), "second": scalar("NULL")}}, ref("joined"), Step{Key: "choice", Kind: "BRANCH", Branch: &branch}, Step{Key: "joined", Kind: "PARALLEL", Parallel: ¶llel})
|
||||
got, err := Check(base)
|
||||
if err != nil || got.Steps != 4 || got.MaxConcurrency != 1 || got.MaxTaskAttempts != 2 || len(got.Queues) != 1 {
|
||||
t.Fatal("structured definition", got, err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
change func(*Specification)
|
||||
}{
|
||||
{"forward_reference", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("later") }},
|
||||
{"self_reference", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("prepare") }},
|
||||
{"missing_property", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("input", "missing") }},
|
||||
{"wrong_type", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("input", "enabled") }},
|
||||
{"default_policy_not_frozen", func(s *Specification) { s.Body.Steps[0].Task.Retry.MaxAttempts = 0 }},
|
||||
{"unknown_kind", func(s *Specification) { s.Body.Steps[0].Kind = "SCRIPT" }},
|
||||
{"extra_kind_body", func(s *Specification) { s.Body.Steps[0].Timer = &Timer{DelayMS: 1} }},
|
||||
{"reserved_step_name", func(s *Specification) { s.Body.Steps[0].Key = "input" }},
|
||||
{"type_cycle", func(s *Specification) { x := scalar("ARRAY"); x.Items = &x; s.InputType = x }},
|
||||
{"invalid_unicode", func(s *Specification) { s.Body.Steps[0].Task.Input = literal(`"\ud800"`) }},
|
||||
{"duplicate_literal_key", func(s *Specification) {
|
||||
s.Body.Steps[0].Task.InputType = scalar("JSON")
|
||||
s.Body.Steps[0].Task.Input = literal(`{"a":1,"a":2}`)
|
||||
}},
|
||||
{"null_task_literal", func(s *Specification) {
|
||||
s.Body.Steps[0].Task.InputType = scalar("JSON")
|
||||
s.Body.Steps[0].Task.Input = literal(" null ")
|
||||
}},
|
||||
{"empty_task_deadline", func(s *Specification) { s.Body.Steps[0].Task.TimeoutMS = 0 }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
s := testSpecification()
|
||||
test.change(&s)
|
||||
if _, err := Check(s); err == nil {
|
||||
t.Fatal("invalid definition accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
branch.Cases[0].Body.Output = ref("joined", "first")
|
||||
if _, err := Check(base); err == nil {
|
||||
t.Fatal("future join result was visible inside a branch")
|
||||
}
|
||||
branch.Cases[0].Body.Output = ref("chosen")
|
||||
base.Body.Output = ref("chosen")
|
||||
if _, err := Check(base); err == nil {
|
||||
t.Fatal("conditional child leaked outside composite")
|
||||
}
|
||||
base.Body.Output = ref("joined")
|
||||
parallel.Branches[1].Body.Output = ref("chosen")
|
||||
if _, err := Check(base); err == nil {
|
||||
t.Fatal("sibling/unselected result leaked")
|
||||
}
|
||||
parallel.Branches[1].Body.Output = ref("timer")
|
||||
branch.Default = nil
|
||||
if _, err := Check(base); err == nil {
|
||||
t.Fatal("nonexhaustive branch accepted")
|
||||
}
|
||||
}
|
||||
func ptrBlock(b Block) *Block { return &b }
|
||||
|
||||
func TestDefinitionParallelBudgetAndDuplicateIdentity(t *testing.T) {
|
||||
makeParallel := func(prefix string) Step {
|
||||
p := Parallel{}
|
||||
for _, suffix := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
|
||||
task := testTask()
|
||||
key := prefix + suffix
|
||||
p.Branches = append(p.Branches, ParallelBranch{Name: suffix, Body: block(scalar("STRING"), ref(key), Step{Key: key, Kind: "TASK", Task: &task})})
|
||||
}
|
||||
return Step{Key: prefix, Kind: "PARALLEL", Parallel: &p}
|
||||
}
|
||||
s := testSpecification()
|
||||
p := makeParallel("wide")
|
||||
s.Body = block(scalar("JSON"), ref("wide"), p)
|
||||
got, err := Check(s)
|
||||
if err != nil || got.MaxConcurrency != 8 || got.Steps != 9 || got.MaxTaskAttempts != 16 {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
p.Parallel.Branches[1].Body.Steps[0].Key = "widea"
|
||||
if _, err := Check(s); err == nil {
|
||||
t.Fatal("duplicate step identities accepted")
|
||||
}
|
||||
outer := Parallel{}
|
||||
for _, prefix := range []string{"a", "b", "c", "d", "e"} {
|
||||
child := makeParallel(prefix)
|
||||
outer.Branches = append(outer.Branches, ParallelBranch{Name: prefix, Body: block(scalar("JSON"), ref(prefix), child)})
|
||||
}
|
||||
s.Body = block(scalar("JSON"), ref("outer"), Step{Key: "outer", Kind: "PARALLEL", Parallel: &outer})
|
||||
if _, err := Check(s); err == nil {
|
||||
t.Fatal("nested concurrency over 32 accepted")
|
||||
}
|
||||
s = testSpecification()
|
||||
for i := 0; i < 128; i++ {
|
||||
task := testTask()
|
||||
s.Body.Steps = append(s.Body.Steps, Step{Key: "extra" + strings.Repeat("x", i+1), Kind: "TASK", Task: &task})
|
||||
}
|
||||
if _, err := Check(s); err == nil {
|
||||
t.Fatal("overlong block accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollPartitionsAndIndependentBudgets(t *testing.T) {
|
||||
s := testSpecification()
|
||||
task := testTask()
|
||||
task.ResultType = Type{Kind: "OBJECT", Fields: map[string]Type{"state": {Kind: "STRING", Enum: []string{"processing", "done", "failed", "review"}}, "reference": scalar("STRING")}}
|
||||
p := Poll{Task: task, MaxRounds: 3, TimeoutMS: 30000, IntervalMS: 1000, StatusPath: []string{"state"}, Pending: []string{"processing"}, Succeeded: []string{"done"}, Failed: []string{"failed"}, Review: []string{"review"}, FinalSignal: &Signal{Name: "completion", PayloadVersion: "v1", Correlation: ref("input", "operation"), PayloadType: task.ResultType, TimeoutMS: 30000}}
|
||||
s.Body = block(task.ResultType, ref("poll"), Step{Key: "poll", Kind: "POLL", Poll: &p})
|
||||
got, err := Check(s)
|
||||
if err != nil || got.MaxTaskAttempts != 6 || got.Steps != 1 {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
for _, change := range []func(*Poll){
|
||||
func(p *Poll) { p.MaxRounds = 0 }, func(p *Poll) { p.MaxRounds = 1001 }, func(p *Poll) { p.IntervalMS = 0 }, func(p *Poll) { p.IntervalMS = p.TimeoutMS },
|
||||
func(p *Poll) { p.Review = nil }, func(p *Poll) { p.Failed = []string{"processing"} }, func(p *Poll) { p.StatusPath = []string{"reference"} },
|
||||
func(p *Poll) { p.MaxRounds = 1000; p.Task.Retry.MaxAttempts = 100 }, func(p *Poll) { p.Task.TimeoutMS = p.TimeoutMS + 1 },
|
||||
func(p *Poll) { v := *p.FinalSignal; v.TimeoutMS++; p.FinalSignal = &v },
|
||||
} {
|
||||
copy := p
|
||||
change(©)
|
||||
s.Body.Steps[0].Poll = ©
|
||||
if _, err := Check(s); err == nil {
|
||||
t.Fatal("unbounded or ambiguous poll accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefinitionDigestAndPreciseData(t *testing.T) {
|
||||
s := testSpecification()
|
||||
s.Body.Steps[0].Task.InputType = scalar("JSON")
|
||||
s.Body.Steps[0].Task.Input = literal(`{"n":9007199254740993,"decimal":12345678901234567890.1234567890123456789}`)
|
||||
a, err := Check(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := json.Marshal(s)
|
||||
s.Body.Steps[0].Task.Input = literal(`{"decimal":12345678901234567890.123456789012345678900,"n":9007199254740993.0}`)
|
||||
b, err := Check(s)
|
||||
if err != nil || a.Digest != b.Digest {
|
||||
t.Fatal("semantic numeric digest changed", err)
|
||||
}
|
||||
s.Body.Steps[0].Task.Input = literal(`{"n":9007199254740992,"decimal":12345678901234567890.1234567890123456789}`)
|
||||
c, err := Check(s)
|
||||
if err != nil || a.Digest == c.Digest {
|
||||
t.Fatal("large integer rounded", err)
|
||||
}
|
||||
var old Specification
|
||||
if json.Unmarshal(before, &old) != nil {
|
||||
t.Fatal("snapshot")
|
||||
}
|
||||
if _, err := Check(old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := json.Marshal(old)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("check mutated definition")
|
||||
}
|
||||
for _, raw := range []string{`"bad"`, `null`, `{"extra":1}`, `{"operation":"x","enabled":true,"extra":1}`, `{"operation":"x","enabled":1}`, `{"operation":"x","operation":"y","enabled":true}`} {
|
||||
if ValidateData(old.InputType, json.RawMessage(raw)) == nil {
|
||||
t.Fatal("bad instance input accepted")
|
||||
}
|
||||
}
|
||||
if err := ValidateData(old.InputType, json.RawMessage(`{"operation":"x","enabled":true}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s = Specification{SchemaVersion: 1, InputType: scalar("NULL"), TimeoutMS: 1, FailurePolicy: "REVIEW", Body: block(scalar("NULL"), literal("null"))}
|
||||
if _, err := Check(s); err != nil {
|
||||
t.Fatal("empty typed block must be valid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzDefinitionDataNeverPanics(f *testing.F) {
|
||||
for _, raw := range []string{`{"operation":"x","enabled":true}`, `null`, `{"n":9007199254740993}`, `{"n":1,"n":2}`} {
|
||||
f.Add([]byte(raw))
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, raw []byte) {
|
||||
if len(raw) > 4096 {
|
||||
return
|
||||
}
|
||||
_ = ValidateData(testSpecification().InputType, raw)
|
||||
})
|
||||
}
|
||||
75
weave/definition/data.go
Normal file
75
weave/definition/data.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package definition
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
)
|
||||
|
||||
// ValidateData 在实例输入、任务成功结果、信号和块输出边界复用同一类型契约。
|
||||
// 使用精确JSON数值文本;大小、重复字段、Unicode和嵌套限制先于类型检查。
|
||||
func ValidateData(schema Type, raw json.RawMessage) error {
|
||||
if err := checkType(schema, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) == 0 || len(raw) > 256<<10 {
|
||||
return invalid("data", "JSON value required, maximum 256 KiB")
|
||||
}
|
||||
if _, err := jsonvalue.Canonical(raw); err != nil {
|
||||
return invalid("data", "invalid JSON, duplicate fields or unsupported value limits")
|
||||
}
|
||||
var value any
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.UseNumber()
|
||||
if err := d.Decode(&value); err != nil {
|
||||
return invalid("data", "invalid JSON value")
|
||||
}
|
||||
return matchData(schema, value)
|
||||
}
|
||||
func matchData(schema Type, value any) error {
|
||||
valid := false
|
||||
switch schema.Kind {
|
||||
case "JSON":
|
||||
return nil
|
||||
case "NULL":
|
||||
valid = value == nil
|
||||
case "STRING":
|
||||
v, ok := value.(string)
|
||||
valid = ok && (len(schema.Enum) == 0 || contains(schema.Enum, v))
|
||||
case "NUMBER":
|
||||
_, valid = value.(json.Number)
|
||||
case "BOOLEAN":
|
||||
_, valid = value.(bool)
|
||||
case "OBJECT":
|
||||
v, ok := value.(map[string]any)
|
||||
if !ok || len(v) != len(schema.Fields) {
|
||||
return invalid("data", "object fields do not match declared type")
|
||||
}
|
||||
for key, t := range schema.Fields {
|
||||
child, ok := v[key]
|
||||
if !ok {
|
||||
return invalid("data", "required object field missing")
|
||||
}
|
||||
if err := matchData(t, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case "ARRAY":
|
||||
v, ok := value.([]any)
|
||||
if !ok || len(v) > 4096 {
|
||||
return invalid("data", "array required, maximum 4096 elements")
|
||||
}
|
||||
for _, child := range v {
|
||||
if err := matchData(*schema.Items, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !valid {
|
||||
return invalid("data", "value does not match declared type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
116
weave/definition/service.go
Normal file
116
weave/definition/service.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package definition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
const ReadAction = "workflows:definitions:read"
|
||||
const PublishAction = "workflows:definitions:publish"
|
||||
const MaxDefinitions = 100
|
||||
const MaxVersions = 100
|
||||
|
||||
// CreateInput 创建命名空间内的稳定定义身份,不发布版本或启动实例。
|
||||
type CreateInput struct {
|
||||
Name string `json:"name"` // Name 命名空间内唯一ASCII名称,最长128字符。
|
||||
Description string `json:"description"` // Description 用途说明,最多1024个Unicode字符。
|
||||
}
|
||||
|
||||
// PublishInput 发布不可变版本;同版本内容冲突不能通过更换幂等键覆盖。
|
||||
type PublishInput struct {
|
||||
Version string `json:"version"` // Version 研发指定的ASCII版本名,最长64字符,不包含路径分隔符。
|
||||
Specification Specification `json:"specification"` // Specification 完整且显式的冻结规则。
|
||||
Reason string `json:"reason"` // Reason 非空发布原因,最多1024个字符,写入审计。
|
||||
}
|
||||
|
||||
// Summary 仅含命名空间级元数据;读取完整版本还须授权所有引用队列。
|
||||
type Summary struct {
|
||||
ID core.ID `json:"id"` // ID 定义稳定标识。
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 定义隔离范围。
|
||||
Name string `json:"name"` // Name 创建时冻结的唯一名称。
|
||||
Description string `json:"description"` // Description 创建时冻结的用途说明。
|
||||
Revision uint64 `json:"revision,string"` // Revision 从1开始,每个新发布版本增加1,不能替代业务版本名。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt UTC数据库创建时间。
|
||||
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最近新版本发布的UTC数据库时间。
|
||||
}
|
||||
|
||||
// Analysis 是静态预算上界;不表示业务任务已执行或已分配配额。
|
||||
type Analysis struct {
|
||||
Steps uint32 `json:"steps"` // Steps 全定义静态步骤数。
|
||||
MaxConcurrency uint32 `json:"max_concurrency"` // MaxConcurrency 并行业务任务上界。
|
||||
MaxTaskAttempts uint32 `json:"max_task_attempts"` // MaxTaskAttempts 所有路径的保守尝试预算总和。
|
||||
Queues []core.ID `json:"queues"` // Queues 排序去重队列,始终为数组。
|
||||
}
|
||||
|
||||
// VersionView 返回原始发布内容和身份,不使用可变的latest别名。
|
||||
type VersionView struct {
|
||||
ID core.ID `json:"id"` // ID 不可变版本记录标识。
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 隔离范围。
|
||||
DefinitionID core.ID `json:"definition_id"` // DefinitionID 父定义标识,由应用事务校验。
|
||||
Version string `json:"version"` // Version 发布版本名。
|
||||
Specification Specification `json:"specification"` // Specification 已冻结规则,数字精度不经float64转换。
|
||||
ContentDigest string `json:"content_digest"` // ContentDigest 带域分隔的SHA-256,64位小写十六进制。
|
||||
PublishedBy core.ID `json:"published_by"` // PublishedBy 原发布凭据标识,不包含凭据秘密。
|
||||
PublishedAt time.Time `json:"published_at"` // PublishedAt 原发布时间,重复发布不更新。
|
||||
Analysis Analysis `json:"analysis"` // Analysis 根据结构版本1规则计算的静态检查结果。
|
||||
}
|
||||
type CreateResult struct {
|
||||
Definition Summary `json:"definition"` // Definition 创建事务的原始快照。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 是否返回原回执。
|
||||
}
|
||||
type PublishResult struct {
|
||||
Version VersionView `json:"version"` // Version 原版本快照,不包含当前父定义状态。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 原键或同版本同内容重复请求。
|
||||
}
|
||||
type Store interface {
|
||||
Create(context.Context, auth.Token, string, CreateInput) (CreateResult, error)
|
||||
Get(context.Context, auth.Token, core.ID) (Summary, error)
|
||||
Publish(context.Context, auth.Token, core.ID, string, PublishInput) (PublishResult, error)
|
||||
Version(context.Context, auth.Token, core.ID, string) (VersionView, error)
|
||||
}
|
||||
type Service struct{ store Store }
|
||||
|
||||
func New(store Store) *Service { return &Service{store: store} }
|
||||
func (s *Service) Create(ctx context.Context, t auth.Token, key string, in CreateInput) (CreateResult, error) {
|
||||
if err := service.ValidateKey(key); err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
if !name(in.Name, 128) || !textValid(in.Description, false) {
|
||||
return CreateResult{}, fault.Invalid("invalid workflow definition name or description")
|
||||
}
|
||||
return s.store.Create(ctx, t, key, in)
|
||||
}
|
||||
func textValid(s string, required bool) bool {
|
||||
return utf8.ValidString(s) && utf8.RuneCountInString(s) <= 1024 && !strings.ContainsRune(s, 0) && (!required || strings.TrimSpace(s) != "")
|
||||
}
|
||||
func (s *Service) Get(ctx context.Context, t auth.Token, id core.ID) (Summary, error) {
|
||||
if id.Validate() != nil {
|
||||
return Summary{}, fault.Invalid("invalid definition id")
|
||||
}
|
||||
return s.store.Get(ctx, t, id)
|
||||
}
|
||||
func (s *Service) Publish(ctx context.Context, t auth.Token, id core.ID, key string, in PublishInput) (PublishResult, error) {
|
||||
if err := service.ValidateKey(key); err != nil {
|
||||
return PublishResult{}, err
|
||||
}
|
||||
if id.Validate() != nil || !name(in.Version, 64) || !textValid(in.Reason, true) {
|
||||
return PublishResult{}, fault.Invalid("valid definition id, version and reason required")
|
||||
}
|
||||
if _, err := Check(in.Specification); err != nil {
|
||||
return PublishResult{}, fault.Invalid(err.Error())
|
||||
}
|
||||
return s.store.Publish(ctx, t, id, key, in)
|
||||
}
|
||||
func (s *Service) Version(ctx context.Context, t auth.Token, id core.ID, version string) (VersionView, error) {
|
||||
if id.Validate() != nil || !name(version, 64) {
|
||||
return VersionView{}, fault.Invalid("invalid definition id or version")
|
||||
}
|
||||
return s.store.Version(ctx, t, id, version)
|
||||
}
|
||||
130
weave/definition/types.go
Normal file
130
weave/definition/types.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Package definition 描述可发布的有限流程;只解释类型和数据引用,不执行上传的业务代码。
|
||||
package definition
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||
)
|
||||
|
||||
// Specification 是结构版本1的完整定义。版本发布后禁止原地修改。
|
||||
type Specification struct {
|
||||
SchemaVersion uint32 `json:"schema_version"` // SchemaVersion 当前必须为1,和业务发布版本分开。
|
||||
InputType Type `json:"input_type"` // InputType 实例输入契约,OBJECT字段全部必填且不允许额外字段。
|
||||
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 从实例受理起计的有限总时限,1毫秒至30天,暂停不续期。
|
||||
FailurePolicy string `json:"failure_policy"` // FailurePolicy REVIEW或FAIL_AFTER_SETTLED;未知效果始终先核查。
|
||||
Body Block `json:"body"` // Body 顶层顺序块;组合节点内部同样使用有限顺序块。
|
||||
}
|
||||
|
||||
// Type 是有限的类型描述;JSON表示只能整体传递的任意JSON,不能据此访问未知字段。
|
||||
type Type struct {
|
||||
Kind string `json:"kind"` // Kind STRING、NUMBER、BOOLEAN、NULL、OBJECT、ARRAY或JSON。
|
||||
Fields map[string]Type `json:"fields,omitempty"` // Fields 仅OBJECT使用,最多64项;字段名称为ASCII标识。
|
||||
Items *Type `json:"items,omitempty"` // Items 仅ARRAY使用,数组元素采用同一契约。
|
||||
Enum []string `json:"enum,omitempty"` // Enum 仅STRING使用,可选的1至32个互异值,按精确文本比较。
|
||||
}
|
||||
|
||||
// Value 明确区分常量、引用和构造对象/数组,禁止字符串插值和任意脚本。
|
||||
type Value struct {
|
||||
Kind string `json:"kind"` // Kind LITERAL、REFERENCE、OBJECT或ARRAY。
|
||||
Literal json.RawMessage `json:"literal,omitempty" nullable:"true"` // Literal 仅LITERAL使用,保留精确数字,可以显式为null。
|
||||
Reference *Reference `json:"reference,omitempty"` // Reference 仅REFERENCE使用,必须指向当前路径可用的输入或结果。
|
||||
Fields map[string]Value `json:"fields,omitempty"` // Fields 仅OBJECT使用,字段值递归解析,最多64项。
|
||||
Items []Value `json:"items,omitempty"` // Items 仅ARRAY使用,最多256项;业务大数组可整体引用。
|
||||
}
|
||||
|
||||
type Reference struct {
|
||||
Source string `json:"source"` // Source input、workflow或在当前词法作用域已完成的步骤key。
|
||||
Path []string `json:"path,omitempty"` // Path 最多8层已声明OBJECT字段;空路径引用整个值,不支持动态路径或数组下标。
|
||||
}
|
||||
|
||||
// Block 的输出是组合节点向外暴露的唯一数据出口;内部步骤不能被外部直接引用。
|
||||
type Block struct {
|
||||
Steps []Step `json:"steps,omitempty"` // Steps 按顺序执行;空块可直接映射输入,所有分支仍需显式输出。
|
||||
OutputType Type `json:"output_type"` // OutputType 本块输出契约。
|
||||
Output Value `json:"output"` // Output 全部步骤完成后解析的输出表达式。
|
||||
}
|
||||
|
||||
type Step struct {
|
||||
Key string `json:"key"` // Key 全定义唯一ASCII标识,1至64字符,不能为input或workflow。
|
||||
Kind string `json:"kind"` // Kind TASK、BRANCH、PARALLEL、SIGNAL、TIMER或POLL;必须只提供对应配置。
|
||||
Task *Task `json:"task,omitempty"` // Task 普通业务工作,交由独立Worker处理。
|
||||
Branch *Branch `json:"branch,omitempty"` // Branch 按顺序选择第一个匹配路径,必须有默认路径。
|
||||
Parallel *Parallel `json:"parallel,omitempty"` // Parallel 静态有限分支,全部成功后汇合。
|
||||
Signal *Signal `json:"signal,omitempty"` // Signal 等待一次按步骤和业务操作匹配的外部输入。
|
||||
Timer *Timer `json:"timer,omitempty"` // Timer 持久化时间等待,不占业务Worker槽位。
|
||||
Poll *Poll `json:"poll,omitempty"` // Poll 有界查询,轮次预算与每轮Pulse尝试预算独立。
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
QueueID core.ID `json:"queue_id"` // QueueID 发布及启动时都需按命名空间和当前权限校验。
|
||||
Handler string `json:"handler"` // Handler 已命名的业务处理器;定义不携带可执行源码。
|
||||
HandlerVersion string `json:"handler_version"` // HandlerVersion 固定的处理器版本。
|
||||
PayloadVersion string `json:"payload_version"` // PayloadVersion 固定的输入契约版本。
|
||||
InputType Type `json:"input_type"` // InputType 输入映射必须满足的处理器参数契约。
|
||||
Input Value `json:"input"` // Input 在激活时根据已保存数据解析,不使用当前时间或随机数。
|
||||
ResultType Type `json:"result_type"` // ResultType 权威成功结果契约,不匹配时进入核查而不推进。
|
||||
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 单任务总时限,正值且不超过流程时限,运行时还受实例剩余时限限制。
|
||||
Retry pulse.RetryPolicy `json:"retry"` // Retry 显式冻结有限策略,包含首次尝试,最多100次。
|
||||
RecoverySafety pulse.RecoverySafety `json:"recovery_safety"` // RecoverySafety 显式RECONCILE_ONLY或IDEMPOTENT。
|
||||
Priority int16 `json:"priority,omitempty"` // Priority 冻结优先级0至9。
|
||||
BusinessKey *Value `json:"business_key,omitempty"` // BusinessKey 可选字符串映射,只用于业务追踪,不代表幂等。
|
||||
ConcurrencyKey *Value `json:"concurrency_key,omitempty"` // ConcurrencyKey 可选字符串映射,采用Pulse共享资源许可规则。
|
||||
}
|
||||
|
||||
type Predicate struct {
|
||||
Op string `json:"op"` // Op EQ/NE/LT/LE/GT/GE或AND/OR/NOT;只比较已声明的标量。
|
||||
Left *Value `json:"left,omitempty"` // Left 比较操作左值,逻辑操作不得携带。
|
||||
Right *Value `json:"right,omitempty"` // Right 比较操作右值,必须与左值同种标量。
|
||||
Arguments []Predicate `json:"arguments,omitempty"` // Arguments AND/OR为2至16项,NOT为1项,深度最多8层。
|
||||
}
|
||||
|
||||
type Branch struct {
|
||||
Cases []BranchCase `json:"cases"` // Cases 1至16个按顺序匹配的条件路径。
|
||||
Default *Block `json:"default"` // Default 必填,穷尽未匹配情况,不能静默跳过。
|
||||
}
|
||||
type BranchCase struct {
|
||||
When Predicate `json:"when"` // When 在分支激活时根据前置持久化数据计算。
|
||||
Body Block `json:"body"` // Body 选中路径;所有路径输出类型必须一致。
|
||||
}
|
||||
type Parallel struct {
|
||||
Branches []ParallelBranch `json:"branches"` // Branches 2至8个静态分支;结果为各分支名称对应输出的OBJECT。
|
||||
}
|
||||
type ParallelBranch struct {
|
||||
Name string `json:"name"` // Name 本并行节点内唯一ASCII字段标识。
|
||||
Body Block `json:"body"` // Body 分支内部独立顺序块;不能读取兄弟分支尚未汇合的数据。
|
||||
}
|
||||
|
||||
type Signal struct {
|
||||
Name string `json:"name"` // Name 外部信号类型,需同时匹配实例、步骤激活和业务关联。
|
||||
PayloadVersion string `json:"payload_version"` // PayloadVersion 外部输入契约版本。
|
||||
Correlation Value `json:"correlation"` // Correlation 必须解析为非空业务操作字符串,运行时冻结在等待记录。
|
||||
PayloadType Type `json:"payload_type"` // PayloadType 信号内容及该节点成功输出的类型。
|
||||
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 激活起的有限等待时限,不超过流程时限;暂停不续期。
|
||||
}
|
||||
type Timer struct {
|
||||
DelayMS uint64 `json:"delay_ms"` // DelayMS 激活起的正等待时长,不超过流程时限;输出为NULL。
|
||||
}
|
||||
|
||||
type Poll struct {
|
||||
Task Task `json:"task"` // Task 同一查询处理器及固定输入;查询不得再次调用原业务提交接口。
|
||||
MaxRounds uint32 `json:"max_rounds"` // MaxRounds 1至1000,轮次含首次;每轮一个任务,单轮内部重试不新增轮次。
|
||||
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 激活起的总查询时限,不超过流程时限。
|
||||
IntervalMS uint64 `json:"interval_ms"` // IntervalMS 从上一轮处理中结果受理后等待,至少1000毫秒且小于查询总时限;不补发错过的轮次。
|
||||
StatusPath []string `json:"status_path,omitempty"` // StatusPath 成功查询结果内的STRING枚举字段路径,空表示整个结果就是枚举。
|
||||
Pending []string `json:"pending"` // Pending 表示仍在处理的枚举值,至少1项。
|
||||
Succeeded []string `json:"succeeded"` // Succeeded 表示业务确认成功的枚举值,至少1项。
|
||||
Failed []string `json:"failed,omitempty"` // Failed 表示业务确认失败的枚举值。
|
||||
Review []string `json:"review,omitempty"` // Review 表示需要核查的枚举值;四组必须无交集并覆盖整个状态枚举。
|
||||
FinalSignal *Signal `json:"final_signal,omitempty"` // FinalSignal 可选最终回执,其内容类型与查询结果一致,只接受非Pending状态。
|
||||
}
|
||||
|
||||
// Checked 是发布验证的计算结果;资源上限按所有静态路径保守计数,不代表运行已发生。
|
||||
type Checked struct {
|
||||
Steps uint32 // Steps 静态节点总数,不按轮次展开,最多128。
|
||||
MaxConcurrency uint32 // MaxConcurrency 结构可产生的并行工作上界,最多32。
|
||||
MaxTaskAttempts uint32 // MaxTaskAttempts 各静态任务尝试数与查询轮次乘积总和,最多10000。
|
||||
Queues []core.ID // Queues 排序且去重的引用队列,发布/启动必须检查权限和归属。
|
||||
Digest core.Digest // Digest 规范化JSON的SHA-256,用于不可变版本内容比较。
|
||||
}
|
||||
Reference in New Issue
Block a user