feat: deliver authenticated task APIs, SDK and P1 regression

This commit is contained in:
2026-09-17 07:41:32 +08:00
parent 4f2935e36f
commit 9701b7a071
37 changed files with 3169 additions and 67 deletions

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@
!.env.example !.env.example
.DS_Store .DS_Store
*.log *.log
/.local/
*.credentials.json

View File

@@ -7,7 +7,7 @@ fmt:
test: test:
$(GO) test -race ./... $(GO) test -race ./...
cd sdk/go && $(GO) test ./... cd sdk/go && $(GO) test -race ./...
vet: vet:
$(GO) vet ./... $(GO) vet ./...
@@ -16,6 +16,7 @@ vet:
build: build:
$(GO) build -o bin/orbit ./cmd/orbit $(GO) build -o bin/orbit ./cmd/orbit
$(GO) build -o bin/orbit-migrate ./cmd/orbit-migrate $(GO) build -o bin/orbit-migrate ./cmd/orbit-migrate
$(GO) build -o bin/orbit-admin ./cmd/orbit-admin
integration: integration:
./scripts/test-mysql.sh ./scripts/test-mysql.sh

View File

@@ -8,18 +8,21 @@ Orbit 是面向多个业务项目的通用任务调度与流程编排平台,
## 当前进度 ## 当前进度
已建立基础 Go 骨架、30 个数据模型、MySQL 初始迁移、事务工具、HTTP 健康检查与独立 SDK 健康客户端。任务 API、WSS Worker、调度器和流程执行引擎尚未实现,按 [持续开发计划](docs/08-development-plan.md) 推进。 已实现 30 个数据模型、版本化迁移、凭据及范围校验、队列创建查询、任务幂等提交/查询/分页/待执行取消,以及独立 Go SDK 和可运行示例。WSS Worker、调度器和流程执行引擎按 [持续开发计划](docs/08-development-plan.md) 继续实现。
当前健康接口的 readiness 只表示数据库与迁移记录可用,不表示完整平台已具备生产服务能力。验证证据见 [P0 报告](docs/reports/phase-0.md)。 readiness 只表示数据库与迁移记录可用,不表示完整平台已具备生产服务能力。验证证据见 [P0](docs/reports/phase-0.md) 与 [P1 报告](docs/reports/phase-1.md)。
## 目录 ## 目录
```text ```text
cmd/orbit/ 基础 HTTP 服务入口 cmd/orbit/ 基础 HTTP 服务入口
cmd/orbit-migrate/ 独立数据库迁移命令 cmd/orbit-migrate/ 独立数据库迁移命令
cmd/orbit-admin/ 受控初始化与私密凭据交付
model/ 公共 ID、JSON、摘要与记录原语 model/ 公共 ID、JSON、摘要与记录原语
platform/model/ 应用、命名空间、幂等、事件和审计 platform/model/ 应用、命名空间、幂等、事件和审计
platform/auth/ 凭据格式与动作/队列授权范围
pulse/model/ 任务、尝试、队列、计划和执行许可 pulse/model/ 任务、尝试、队列、计划和执行许可
pulse/service/ 任务与队列应用契约、输入校验
weave/model/ 定义、实例、步骤、信号和查询轮次 weave/model/ 定义、实例、步骤、信号和查询轮次
internal/config/ 配置校验 internal/config/ 配置校验
internal/storage/mysql/ 连接、事务、迁移及集成测试 internal/storage/mysql/ 连接、事务、迁移及集成测试
@@ -49,6 +52,8 @@ cd sdk/go
ORBIT_URL=http://127.0.0.1:8080 go run ./examples/health ORBIT_URL=http://127.0.0.1:8080 go run ./examples/health
``` ```
任务接入从 [初始化凭据](docs/api/authentication.md) 开始,再按 [SDK 接入](docs/sdk/README.md) 运行任务示例。它会实际提交、重复确认、查询并取消一个任务;当前阶段尚不执行业务处理器。
## 开发与回归 ## 开发与回归
```sh ```sh

93
cmd/orbit-admin/main.go Normal file
View File

@@ -0,0 +1,93 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/config"
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
if len(os.Args) < 2 || os.Args[1] != "bootstrap" {
return errors.New("usage: orbit-admin bootstrap --application NAME --namespace NAME --environment NAME --queue NAME --output NEW_FILE")
}
flags := flag.NewFlagSet("bootstrap", flag.ContinueOnError)
var in mysql.BootstrapInput
var output string
flags.StringVar(&in.Application, "application", "", "new application name")
flags.StringVar(&in.Namespace, "namespace", "", "new namespace name")
flags.StringVar(&in.Environment, "environment", "", "environment name")
flags.StringVar(&in.Queue, "queue", "", "initial queue name")
flags.StringVar(&output, "output", "", "new private credentials JSON file")
if err := flags.Parse(os.Args[2:]); err != nil {
return err
}
if flags.NArg() != 0 || output == "" {
return errors.New("--output and named resource arguments are required")
}
cfg, err := config.Load()
if err != nil {
return err
}
file, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
return errors.New("cannot create new private output file (existing files are never overwritten)")
}
defer file.Close()
delivered := false
defer func() {
if !delivered {
_ = os.Remove(output)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
db, err := mysql.Open(ctx, cfg.MySQLDSN, 2, 1)
if err != nil {
return err
}
defer db.Close()
err = mysql.Bootstrap(ctx, db, in, func(result mysql.BootstrapResult) error {
if err := json.NewEncoder(file).Encode(result); err != nil {
return err
}
if err := file.Sync(); err != nil {
return err
}
directory, err := os.Open(filepath.Dir(output))
if err != nil {
return err
}
err = directory.Sync()
closeErr := directory.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
delivered = true
return nil
})
if err != nil {
if delivered {
return errors.New("bootstrap commit could not be confirmed; private output was retained; verify its credential before retrying")
}
return errors.New("bootstrap failed before credential delivery; check schema, names and database access")
}
fmt.Println("Bootstrap committed. Credentials saved in the requested private file; valid for 30 days.")
return nil
}

View File

@@ -14,6 +14,7 @@ import (
"git.yuntuo.hk/k2safe/orbit/internal/config" "git.yuntuo.hk/k2safe/orbit/internal/config"
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql" "git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver" "git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
) )
func main() { func main() {
@@ -32,24 +33,29 @@ func run() error {
defer stop() defer stop()
startup, cancel := context.WithTimeout(root, 10*time.Second) startup, cancel := context.WithTimeout(root, 10*time.Second)
db, err := mysql.Open(startup, cfg.MySQLDSN, cfg.MaxOpen, cfg.MaxIdle) db, err := mysql.Open(startup, cfg.MySQLDSN, cfg.MaxOpen, cfg.MaxIdle)
cancel()
if err != nil { if err != nil {
cancel()
return err return err
} }
defer db.Close() defer db.Close()
if err := mysql.CheckSchema(startup, db); err != nil {
cancel()
return err
}
cancel()
server := httpserver.New(cfg.HTTPAddr, func(ctx context.Context) error { server := httpserver.New(cfg.HTTPAddr, func(ctx context.Context) error {
if err := db.PingContext(ctx); err != nil { if err := db.PingContext(ctx); err != nil {
return err return err
} }
return mysql.CheckSchema(ctx, db) return mysql.CheckSchema(ctx, db)
}) }, service.New(mysql.NewTaskStore(db)))
listener, err := net.Listen("tcp", cfg.HTTPAddr) listener, err := net.Listen("tcp", cfg.HTTPAddr)
if err != nil { if err != nil {
return err return err
} }
done := make(chan error, 1) done := make(chan error, 1)
go func() { done <- server.Serve(listener) }() go func() { done <- server.Serve(listener) }()
slog.Info("Orbit bootstrap HTTP server starting", "address", listener.Addr().String(), "scope", "health endpoints only") slog.Info("Orbit HTTP server starting", "address", listener.Addr().String(), "scope", "health, task and queue management")
select { select {
case err := <-done: case err := <-done:
if errors.Is(err, http.ErrServerClosed) { if errors.Is(err, http.ErrServerClosed) {

View File

@@ -26,7 +26,7 @@
| 状态 | Go 类型常量 + SQL `CHECK`;状态版本独立于时间戳 | | 状态 | Go 类型常量 + SQL `CHECK`;状态版本独立于时间戳 |
| CAS | `revision` 从 1 开始;服务层必须带期望版本条件更新并检查受影响行数 | | CAS | `revision` 从 1 开始;服务层必须带期望版本条件更新并检查受影响行数 |
| 暂停 | 实例执行状态与 `paused` 分开,控制版本与通用门控同事务协调 | | 暂停 | 实例执行状态与 `paused` 分开,控制版本与通用门控同事务协调 |
| 参数 | 版本化 JSON 文档或业务侧引用;平台不内置行业字段 | | 参数 | 版本化 JSON 文档或业务侧引用;业务内容使用 MEDIUMTEXT + JSON_VALID 保留数字精度,平台元数据使用原生 JSON |
| 删除 | 关联数据默认 `RESTRICT`,没有级联删除执行历史 | | 删除 | 关联数据默认 `RESTRICT`,没有级联删除执行历史 |
| 不可变历史 | 流程版本、计划版本、状态事件、审计事件的 `UPDATE` 被数据库触发器拒绝 | | 不可变历史 | 流程版本、计划版本、状态事件、审计事件的 `UPDATE` 被数据库触发器拒绝 |
@@ -84,7 +84,7 @@
## 5. 仍需服务层事务保证的条件 ## 5. 仍需服务层事务保证的条件
外键与 `CHECK` 无法代替执行引擎。以下属于后续服务实现与回归目标: 外键与 `CHECK` 无法代替执行引擎。P1 已完成 HTTP 动作/队列授权、幂等摘要与原回执、受理事件,以及待执行取消的 CAS/审计事务。执行相关的以下条件继续分阶段实现:
1. 权限验证、凭据校验、命名空间过滤和队列消费授权。 1. 权限验证、凭据校验、命名空间过滤和队列消费授权。
2. 同一次事务更新任务、尝试、租约、并发许可、事件与流程交付记录。 2. 同一次事务更新任务、尝试、租约、并发许可、事件与流程交付记录。
@@ -94,7 +94,7 @@
6. 查询轮次、预算、下一任务创建同事务提交;未知结果按证据核查。 6. 查询轮次、预算、下一任务创建同事务提交;未知结果按证据核查。
7. 不可变版本发布前的流程图校验、表达式类型与结构化汇合验证。 7. 不可变版本发布前的流程图校验、表达式类型与结构化汇合验证。
当前模型方法只校验局部状态或预算,不能被当作已经实现了执行授权和业务幂等。 局部模型方法不能被当作已经实现了 Worker 执行授权和业务效果幂等。P1 已实现范围与剩余边界见 [P1 报告](reports/phase-1.md)。
## 6. 迁移与回归 ## 6. 迁移与回归
@@ -102,4 +102,6 @@
已应用的迁移不得修改。后续使用新的前向迁移;回归环境使用独立临时数据库。初始迁移尚未在生产部署。 已应用的迁移不得修改。后续使用新的前向迁移;回归环境使用独立临时数据库。初始迁移尚未在生产部署。
000002_task_api 增加任务分页索引,将业务参数、结果、回执、流程定义/输入/输出等字段改为校验过的 JSON 文本,避免原生 JSON 数字重编码。公共 Go JSON 扫描类型保持兼容,原模型职责不变。响应 DTO 与持久化模型分开;SQL 空结果先扫描到可空字节切片再转换,避免驱动与 json.RawMessage 的可空类型不兼容。
命令与验证记录见 [开发计划](08-development-plan.md) 和 [基础阶段验证报告](reports/phase-0.md)。 命令与验证记录见 [开发计划](08-development-plan.md) 和 [基础阶段验证报告](reports/phase-0.md)。

View File

@@ -25,7 +25,7 @@
| 阶段 | 开发内容 | 回归与完成标准 | 当前状态 | | 阶段 | 开发内容 | 回归与完成标准 | 当前状态 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| P0 基础与数据模型 | Go workspace、独立 SDK module、公共/Pulse/Weave 模型、MySQL 迁移、配置、健康检查 | 编译/竞态/静态检查;真实 MySQL 建表、隔离、唯一性、预算、不可变版本、迁移失败防护;SDK 健康调用 | 已完成并通过阶段回归(见 P0 报告) | | P0 基础与数据模型 | Go workspace、独立 SDK module、公共/Pulse/Weave 模型、MySQL 迁移、配置、健康检查 | 编译/竞态/静态检查;真实 MySQL 建表、隔离、唯一性、预算、不可变版本、迁移失败防护;SDK 健康调用 | 已完成并通过阶段回归(见 P0 报告) |
| P1 任务受理闭环 | 应用凭据与作用域权限、队列、幂等提交、查询、待执行取消、受理事件;SDK 客户端 | API/SDK 创建查询闭环;重复键与内容冲突、响应丢失后重报、跨命名空间拒绝、参数边界 | 待开发 | | P1 任务受理闭环 | 应用凭据与作用域权限、队列、幂等提交、查询、待执行取消、受理事件;SDK 客户端 | API/SDK 创建查询闭环;重复键与内容冲突、响应未确认重报、跨命名空间拒绝、参数边界 | 已完成并通过阶段回归(见 P1 报告;真实断路/提交 ACK 丢失注入留后续故障阶段) |
| P2 Worker 执行闭环 | WSS 接入、能力/容量、领取与开始、租约、续租、结果确认、排空与恢复;Worker SDK | 真 Worker 执行;失联/重连/重复确认/旧尝试拒绝;业务效果未知转核查;节点故障接管 | 待开发 | | P2 Worker 执行闭环 | WSS 接入、能力/容量、领取与开始、租约、续租、结果确认、排空与恢复;Worker SDK | 真 Worker 执行;失联/重连/重复确认/旧尝试拒绝;业务效果未知转核查;节点故障接管 | 待开发 |
| P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、并发许可、配额/背压、可靠结果订阅 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 待开发 | | P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、并发许可、配额/背压、可靠结果订阅 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 待开发 |
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 待开发 | | P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 待开发 |
@@ -72,3 +72,4 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
- [通用场景示例](examples/README.md) - [通用场景示例](examples/README.md)
- [生产验收总清单](05-production-acceptance.md) - [生产验收总清单](05-production-acceptance.md)
- [P0 验证记录](reports/phase-0.md) - [P0 验证记录](reports/phase-0.md)
- [P1 验证记录](reports/phase-1.md)

View File

@@ -2,7 +2,7 @@
- 文档版本:0.2 - 文档版本:0.2
- 整理日期:2026-09-17 - 整理日期:2026-09-17
- 阶段:基础骨架与数据模型已实现,平台完整功能按持续计划开发;尚未完成生产验收 - 阶段:P0 模型基础与 P1 任务受理/SDK 已实现,平台完整功能按持续计划开发;尚未完成生产验收
- 适用范围:Orbit 统一平台,包含 Pulse 任务调度模块、Weave 流程编排模块与公共能力。平台面向多个业务系统复用。 - 适用范围:Orbit 统一平台,包含 Pulse 任务调度模块、Weave 流程编排模块与公共能力。平台面向多个业务系统复用。
## 阅读顺序 ## 阅读顺序
@@ -79,4 +79,4 @@ Orbit 不内置特定行业规则;Pulse 管理执行,Weave 管理步骤依
2026-09-17,文档更新至 0.2:用户确认研发定义发布流程及 Weave 首版功能,明确纳入有界重复查询和暂停/继续等生产管理能力。技术细则与参数继续按建议或待定标注,未将功能确认等同于实现或验收通过。 2026-09-17,文档更新至 0.2:用户确认研发定义发布流程及 Weave 首版功能,明确纳入有界重复查询和暂停/继续等生产管理能力。技术细则与参数继续按建议或待定标注,未将功能确认等同于实现或验收通过。
持续开发目标已建立。当前实现和验收进度以 [开发计划](08-development-plan.md) 与 [验证报告](reports/phase-0.md) 为准;早期设计稿中的“未实现”描述保留其编写阶段语境。 持续开发目标已建立。当前实现和验收进度以 [开发计划](08-development-plan.md)、[P0 报告](reports/phase-0.md)、[P1 报告](reports/phase-1.md) 为准;早期设计稿中的“未实现”描述保留其编写阶段语境。

View File

@@ -8,17 +8,19 @@
| --- | --- | --- | | --- | --- | --- |
| `GET /health/live` | 已实现 | [健康检查](health.md) | | `GET /health/live` | 已实现 | [健康检查](health.md) |
| `GET /health/ready` | 已实现,仅验证数据库与迁移记录 | [健康检查](health.md) | | `GET /health/ready` | 已实现,仅验证数据库与迁移记录 | [健康检查](health.md) |
| `/v1/tasks` 与任务管理 | 计划 P1–P3,尚未实现 | [任务接口规划](tasks.md) | | `/v1/tasks` 提交、查询、分页与待执行取消 | P1 已实现 | [任务接口](tasks.md) |
| `/v1/queues` 创建与查询 | P1 已实现 | [队列接口](queues.md) |
| 凭据校验与初始化 CLI | P1 已实现 | [鉴权](authentication.md) |
| `/v1/worker/connect` | 计划 P2,尚未实现 | [协议草案](../03-http-websocket-protocol.md) | | `/v1/worker/connect` | 计划 P2,尚未实现 | [协议草案](../03-http-websocket-protocol.md) |
| `/v1/schedules` 与计划管理 | 计划 P3,尚未实现 | [协议草案](../03-http-websocket-protocol.md) | | `/v1/schedules` 与计划管理 | 计划 P3,尚未实现 | [协议草案](../03-http-websocket-protocol.md) |
| `/v1/events/connect` | 计划 P3,尚未实现 | [协议草案](../03-http-websocket-protocol.md) | | `/v1/events/connect` | 计划 P3,尚未实现 | [协议草案](../03-http-websocket-protocol.md) |
| `/v1/workflows` 与流程管理 | 计划 P4–P5,尚未实现 | [流程接口规划](workflows.md) | | `/v1/workflows` 与流程管理 | 计划 P4–P5,尚未实现 | [流程接口规划](workflows.md) |
当前服务对未实现路由返回 404。健康检查就绪不表示任务调度、鉴权或流程引擎已经可用。 未实现路由返回 404。健康检查就绪不表示任务执行、定时调度或流程引擎已经可用。
## 业务接口统一要求 ## 业务接口统一要求
下述业务契约尚在实现阶段细化,不能用于声称当前已有业务 API: P1 业务接口已遵守下述约定,后续接口继续沿用:
- 生产使用 HTTPS/WSS,凭据通过授权头或受控握手传递,不放入 URL。 - 生产使用 HTTPS/WSS,凭据通过授权头或受控握手传递,不放入 URL。
- 应用身份、命名空间和操作权限分别校验;知道资源 ID 不等于拥有权限。 - 应用身份、命名空间和操作权限分别校验;知道资源 ID 不等于拥有权限。
@@ -27,7 +29,7 @@
- 时间为 UTC RFC 3339,时长字段带单位;查询和批量操作有上限。 - 时间为 UTC RFC 3339,时长字段带单位;查询和批量操作有上限。
- 错误码、字段、分页游标和限制值在对应阶段实现及回归后冻结。 - 错误码、字段、分页游标和限制值在对应阶段实现及回归后冻结。
健康接口目前直接返回状态对象。业务接口计划使用 `request_id` + `data` / `error` 信封,详见 [通信协议](../03-http-websocket-protocol.md)。 健康接口直接返回状态对象。已实现的业务接口使用 `request_id` + `data` / `error` 信封;当前实际字段以本目录手册为准,其他尚未实现的接口见 [通信协议草案](../03-http-websocket-protocol.md)。
## 每个正式接口的文档模板 ## 每个正式接口的文档模板

View File

@@ -0,0 +1,53 @@
# 凭据、权限与初始化
状态:P1 已实现。业务接口每次在数据库事务内校验凭据摘要、撤销/到期、应用与命名空间启停,以及动作和队列范围。授权与写入使用同一事务;管理变更与已经获得授权锁的请求按事务先后次序生效。
## 初始化一个新的接入应用
前置:专用 MySQL 数据库已经执行 orbit-migrate up,使用持有初始化权限的数据库管理连接。此命令创建新应用、命名空间、队列和凭据,不会覆盖现有同名应用。
```sh
mkdir -p .local
chmod 700 .local
go run ./cmd/orbit-admin bootstrap \
--application sample-app \
--namespace default \
--environment development \
--queue files \
--output .local/development.credentials.json
```
ORBIT_MYSQL_DSN 通过环境注入,命令不读取 .env。所有名称必填,以字母/数字开头,后续允许字母、数字、`_ . : -`;environment 最长 32,其余最长 128。输出文件必须不存在,以 0600 创建;原始凭据不会打印到 stdout/stderr。`.local/` 和 `*.credentials.json` 已被 Git 忽略。
输出 JSON 包含 application_id / namespace_id / queue_id / credential_id / token / expires_at。初始凭据有效期为 30 天,权限包括当前命名空间内的五个 P1 动作和全部队列。初始队列 max_concurrency=4、rate_per_second=10;这是开发初始配置,执行限流随 P2–P3 落地。
资源、凭据摘要和初始化审计在一个事务中提交。交付文件先写入并同步到磁盘,成功后再提交事务。如果文件写入失败则回滚并移除文件;如果已经交付后提交无法确认,保留文件并报错,应使用其中凭据核对结果,不能直接覆盖或重复创建。
初始文件包含真实秘密,应保管在受限目录或导入秘密管理系统。运行账户与迁移/初始化账户分开;生产只通过 HTTPS 使用凭据。凭据管理 HTTP API、轮换与细粒度运维命令在后续阶段实现,当前 bootstrap 主要用于完成独立接入与开发验证。
## 凭据格式与作用域
```text
orb1.<namespace_id>.<credential_id>.<32-byte-base64url-secret>
```
服务端仅保存 SHA-256 秘密摘要。ID 不是授权证明,秘密不进入 URL。客户端请求:
```text
Authorization: Bearer <token>
```
权限存储格式如下,当前由受控初始化及管理侧写入,不能通过普通任务请求修改:
```json
{
"actions":["tasks:submit","tasks:read","tasks:cancel","queues:read","queues:manage"],
"queues":["*"]
}
```
actions 不支持通配符;queues 可列出队列 ID,星号仅表示当前命名空间内全部队列。创建队列必须具有 queues:manage 和全部队列范围。请求不接受自定义 namespace_id 来跨越隔离边界。
原始秘密错误、凭据撤销/过期、应用或命名空间停用统一返回 401。合法身份缺少动作权限返回 403。读取不在范围内的单个资源返回 404,避免披露资源内容。
SDK 使用 `orbit.Options{Token: token}`。健康查询不发送凭据。当前不启用业务权限缓存,撤销校验不依赖缓存过期。

View File

@@ -42,4 +42,4 @@ curl -i http://127.0.0.1:8080/health/ready
对应 `Client.Liveness(ctx)` 和 `Client.Readiness(ctx)`,见 [SDK 接入](../sdk/README.md)。HTTP 503 返回 `*orbit.HTTPError`。 对应 `Client.Liveness(ctx)` 和 `Client.Readiness(ctx)`,见 [SDK 接入](../sdk/README.md)。HTTP 503 返回 `*orbit.HTTPError`。
此阶段的 ready 只表示数据库与迁移基础可用;尚无任务、Worker、流程或业务鉴权服务。SDK 默认请求超时为 10 秒,调用方可通过 context 设置更短期限。 ready 只表示数据库与迁移基础可用;不检查 Worker、任务积压、流程执行或业务效果。P1 启动时先校验迁移,缺失或 dirty 会拒绝启动;运行后数据库不可用时 ready 返回 503。SDK 默认请求超时为 10 秒,调用方可通过 context 设置更短期限。

51
docs/api/queues.md Normal file
View File

@@ -0,0 +1,51 @@
# 队列接口 v1
状态:P1 已实现创建与查询。更新、暂停、实际派发并发与速率控制随 P2–P3 实现;当前参数是后续执行所需的持久化配置。
## POST /v1/queues
需要 queues:manage 和当前命名空间内全部队列范围。鉴权、请求体、幂等键及错误信封与 [任务接口](tasks.md) 一致。
| 字段 | 类型 | 要求 |
| --- | --- | --- |
| name | string | 1–128 字符,以 ASCII 字母/数字开头,其后允许字母、数字、`_ . : -`;命名空间内唯一 |
| max_concurrency | uint32 | 必填,1–100000 |
| rate_per_second | uint32 | 必填,1–1000000 |
```sh
curl "$ORBIT_URL/v1/queues" \
-H "Authorization: Bearer $ORBIT_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: queue-images-v1' \
--data '{"name":"images","max_concurrency":8,"rate_per_second":100}'
```
首次 201:
```json
{
"request_id":"44444444444444444444444444444444",
"data":{
"deduplicated":false,
"queue":{
"id":"11111111111111111111111111111111",
"namespace_id":"33333333333333333333333333333333",
"name":"images","paused":false,"max_concurrency":8,"rate_per_second":100,"revision":"1"
}
}
}
```
同键同内容返回 200、deduplicated=true。同键不同内容返回 409 IDEMPOTENCY_CONFLICT;换键创建同名队列返回 409 STATE_CONFLICT。创建、幂等记录和管理审计同事务提交。
SDK:`Client.CreateQueue(ctx, key, orbit.CreateQueueRequest{...})`。
## GET /v1/queues/{queue_id}
权限:queues:read 和对应队列范围。200 的 data 直接为上方 queue 对象;资源不存在或单资源范围不匹配返回 404。
```sh
curl "$ORBIT_URL/v1/queues/$ORBIT_QUEUE_ID" -H "Authorization: Bearer $ORBIT_TOKEN"
```
SDK:`Client.GetQueue(ctx, queueID)`。范围限制、重复创建和同名冲突已纳入 [P1 回归](../reports/phase-1.md)。这些配置上限不是生产容量承诺。

View File

@@ -1,16 +1,187 @@
# 任务接口规划 # 任务接口 v1
状态:计划 P1–P3,尚未实现。以下为后续落地清单;实际字段与可运行请求将在相应阶段补齐。 状态:P1 已实现并通过真实 MySQL 与 SDK 回归。当前提供受理、查询、分页和待执行取消;Worker 执行与自动重试在 P2–P3 实现。
| 操作 | 候选接口 | 关键行为 | ## 公共约定
| --- | --- | --- |
| 提交 | `POST /v1/tasks` | 携带稳定 `Idempotency-Key`,受理提交完成后响应 |
| 批量提交 | `POST /v1/tasks:batchSubmit` | 每项独立幂等与结果;顶层成功不代表全部成功 |
| 查询 | `GET /v1/tasks/{task_id}` | 校验命名空间和权限,返回当前状态与结果 |
| 执行历史 | `GET /v1/tasks/{task_id}/attempts` | 分页返回历史尝试,不展示执行秘密凭据 |
| 取消 | `POST /v1/tasks/{task_id}:cancel` | 待执行时取消生效;在途工作先记录取消意图 |
| 人工重试 | `POST /v1/tasks/{task_id}:retry` | 带原因与权限;流程关联任务通过 Weave 恢复 |
提交契约需要队列、处理器/参数版本、业务输入或引用、执行时间、时限、恢复安全等级和重试策略。业务参数内容由接入方解释。 业务接口需要 `Authorization: Bearer <token>`;写入需要 `Content-Type: application/json` 和稳定的 `Idempotency-Key`。凭据决定命名空间,请求体不能覆盖它。详见 [鉴权与初始化](authentication.md)。
P1 回归完成前,此页不提供看似可调用的 SDK 方法或 HTTP 请求体。接口实现后同步增加完整请求/响应、错误码及文件转换场景。 请求体上限 512 KiB。拒绝未知字段、大小写别名、重复 JSON 键、无效 UTF-8、孤立 Unicode 代理项和超过 32 层的嵌套。除可空时间外,字段不能显式传 null。请求追踪 ID 由服务端生成,与幂等键分开。
响应使用 `{"request_id":"…","data":…}`;X-Request-ID 与正文一致。revision 是十进制字符串;ID 是 32 位小写十六进制字符串。时间使用 RFC 3339,返回 UTC,最多微秒精度。
## POST /v1/tasks
权限:tasks:submit,并有目标队列的访问范围。首次提交返回 201,同键相同内容返回 200。两者均只表示**数据库已受理**,不表示业务处理完成。
| 字段 | 类型 | 必填 / 默认 | 含义与限制 |
| --- | --- | --- | --- |
| queue_id | string | 必填 | 同一命名空间内的队列 ID |
| handler | string | 必填 | 处理器名称,1–128 字符 |
| handler_version | string | 必填 | 固定处理器版本,1–64 字符 |
| payload_version | string | 必填 | 输入契约版本,1–32 字符 |
| payload | JSON | 必填 | 非 null JSON,最大 256 KiB;平台不解释业务字段 |
| business_key | string | 可选 | 最多 191 个 Unicode 字符,无 NUL/换行;用于关联,不提供业务幂等 |
| available_at | time / null | 受理时数据库时间 | 最早可执行时间;必须早于总截止时间,仅允许最多 1 秒的过去时间容差 |
| start_deadline_at | time / null | 无独立限制 | 必须晚于当前时间,不早于 available_at,不晚于总截止时间 |
| task_timeout_ms | uint64 | 0 或省略为 86400000 | 从受理时计算的总时限,最大 30 天(2592000000 毫秒) |
| recovery_safety | string | RECONCILE_ONLY | 也可为 IDEMPOTENT;必须基于处理器真实的幂等能力声明 |
| retry | object | 见下表 | 受理时冻结的执行策略;当前阶段仅保存 |
处理器及版本名称以 ASCII 字母或数字开头,后续只能包含字母、数字、`_ . : -`。时间年份为 2000–9998。
| retry 字段 | 默认与限制 |
| --- | --- |
| max_attempts | 0 或省略为 1;范围 1–100,包含首次尝试 |
| attempt_timeout_ms | 0 或省略为 30000;必须不超过任务总时限 |
| initial_delay_ms | 多次尝试时 0 或省略为 1000;必须为正且不超过 max_delay_ms;单次尝试默认 0 |
| max_delay_ms | 多次尝试时 0 或省略为 30000;单次尝试默认 0;最大 9223372036854,整体仍受总截止时间限制 |
这些值是当前接口保护边界,不代表已经验证相应吞吐或并发容量。
业务 JSON 以经过校验的文本持久化,保留大整数和小数的精确数字内容。不要在接入方先转成 float64;Go 使用 json.RawMessage、结构体中的精确类型或业务约定的字符串。单个数字最长 256 字符,显式指数和去除尾零后的指数均需在 -308 至 308 内,数值不能超出有限 double 的幅度;这只是可接受范围检查,摘要不使用浮点近似值。
请求示例,队列 ID 替换为初始化结果:
```sh
curl -i "$ORBIT_URL/v1/tasks" \
-H "Authorization: Bearer $ORBIT_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: conversion-operation-001' \
--data '{
"queue_id":"11111111111111111111111111111111",
"handler":"file.convert",
"handler_version":"v1",
"payload_version":"v1",
"payload":{"source_ref":"objects/input.txt","format":"pdf","sequence":9007199254740993},
"business_key":"conversion-001"
}'
```
首次受理的完整响应示例,ID 和时间为示意值:
```json
{
"request_id":"44444444444444444444444444444444",
"data":{
"deduplicated":false,
"task":{
"id":"22222222222222222222222222222222",
"namespace_id":"33333333333333333333333333333333",
"queue_id":"11111111111111111111111111111111",
"state":"WAITING",
"revision":"1",
"handler":"file.convert",
"handler_version":"v1",
"payload_version":"v1",
"payload":{"source_ref":"objects/input.txt","format":"pdf","sequence":9007199254740993},
"business_key":"conversion-001",
"available_at":"2026-09-17T00:00:00Z",
"deadline_at":"2026-09-18T00:00:00Z",
"recovery_safety":"RECONCILE_ONLY",
"retry":{"max_attempts":1,"initial_delay_ms":0,"max_delay_ms":0,"attempt_timeout_ms":30000},
"attempt_count":0,
"created_at":"2026-09-17T00:00:00Z",
"updated_at":"2026-09-17T00:00:00Z"
}
}
}
```
可选响应字段:start_deadline_at、result、cancel_requested_at、completed_at、business_key,未设置时省略。终态有 completed_at。状态集合为 WAITING / LEASED / RUNNING / NEEDS_REVIEW / SUCCEEDED / FAILED / CANCELLED;P1 仅主动产生 WAITING 和 CANCELLED。
### 幂等与失败处理
- 键为 1–128 个可打印 ASCII 字符,不含空格;作用域为命名空间 + 操作类型,取消额外包含任务 ID。不同凭据在同一命名空间共享该身份,重复请求仍需通过当前权限校验。
- 固定默认值、规范化 UTC 时间、排序 JSON 对象键、精确规范化十进制数字,再计算版本化 SHA-256 摘要。1 与 1.0 等价;数组顺序有意义。内部算法名 orbit-json-v1,不宣称采用 RFC 8785。
- 同键不同内容返回 409 IDEMPOTENCY_CONFLICT;不同键即使 business_key 相同,也可以产生两个任务。
- 同一事务保存任务、受理事件、提交者审计、幂等身份与原始回执;验证或写入失败整体回滚。提交审计记录凭据与处理器/队列,不复制业务参数。
- 同键重报返回**首次操作的回执快照**;任务之后取消也不会改写该回执。获取最新状态调用 GET。
- 网络中断、客户端超时或 503 时,不能认定提交未发生;保持原键和原内容查询或重报。SDK 不自动换键或自动重试写操作。
- 幂等记录设置 90 天保留截止时间;当前尚无清理任务,存在的记录持续生效。后续清理不得缩短已承诺窗口。
SDK:`Client.SubmitTask(ctx, key, request)`。
## GET /v1/tasks/{task_id}
权限:tasks:read 和任务所在队列范围。200 的 data 直接为上方 task 对象,返回当前状态和完整参数/已保存结果。不存在、跨命名空间或不在该凭据队列范围时返回 404。
```sh
curl "$ORBIT_URL/v1/tasks/$TASK_ID" -H "Authorization: Bearer $ORBIT_TOKEN"
```
SDK:`Client.GetTask(ctx, taskID)`。
## GET /v1/tasks
权限:tasks:read。查询参数均只能出现一次:
| 参数 | 要求 |
| --- | --- |
| queue_id | 必填,有权限的队列 ID |
| state | 可选,取上述状态之一 |
| limit | 可选,默认 20;1–100 |
| cursor | 可选,上一页返回值;必须保持 queue_id/state 不变,最长 1024 字符 |
按 (created_at DESC, id DESC) 游标分页,不使用大 offset。不承诺跨页快照:读取过程中状态可能变化。列表省略 payload/result/retry 等大字段,返回摘要。
```sh
curl "$ORBIT_URL/v1/tasks?queue_id=$ORBIT_QUEUE_ID&state=WAITING&limit=20" \
-H "Authorization: Bearer $ORBIT_TOKEN"
```
```json
{
"request_id":"44444444444444444444444444444444",
"data":{
"items":[{
"id":"22222222222222222222222222222222",
"queue_id":"11111111111111111111111111111111",
"state":"WAITING","revision":"1",
"handler":"file.convert","handler_version":"v1","business_key":"conversion-001",
"available_at":"2026-09-17T00:00:00Z","deadline_at":"2026-09-18T00:00:00Z",
"attempt_count":0,"created_at":"2026-09-17T00:00:00Z","updated_at":"2026-09-17T00:00:00Z"
}]
}
}
```
还有下一页时返回 data.next_cursor。空结果为 items: []。SDK:`Client.ListTasks(ctx, request)`。
## POST /v1/tasks/{task_id}:cancel
权限:tasks:cancel 和队列范围。当前只允许取消 WAITING 状态且不归属流程的任务。LEASED/RUNNING 或流程任务暂时返回 409,后续由执行与流程控制实现安全取消。
```sh
curl "$ORBIT_URL/v1/tasks/$TASK_ID:cancel" \
-H "Authorization: Bearer $ORBIT_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: conversion-operation-001-cancel' \
--data '{"expected_revision":"1","reason":"source object was replaced"}'
```
expected_revision 必填,为非零 uint64 的十进制字符串;reason 必填且不能全为空白,最多 1024 个 Unicode 字符。服务端以版本和状态条件更新,只有一个竞争请求能成功。
200 的 data 为 `{"task":<完整最新任务>,"deduplicated":false}`。示例任务会变为 state=CANCELLED、revision="2",写入 cancel_requested_at/completed_at,并同事务追加取消事件及审计。首次取消之后使用相同键、原因和**原 expected_revision**重报,返回原取消回执且 deduplicated=true。换键重报已取消任务返回 409。
SDK:`Client.CancelTask(ctx, taskID, key, request)`。此操作不承诺回滚任何已发生的外部业务效果。
## 错误与回归
| HTTP / code | 处理 |
| --- | --- |
| 400 INVALID_ARGUMENT | 修正字段、时间、游标或幂等键 |
| 401 UNAUTHENTICATED | 检查凭据、到期/撤销和应用/命名空间状态 |
| 403 PERMISSION_DENIED | 检查动作或提交/列表所需队列范围 |
| 404 NOT_FOUND | 资源不存在或单资源读取范围不匹配 |
| 409 IDEMPOTENCY_CONFLICT | 原键已对应其他内容,不应盲目换键重试 |
| 409 STATE_CONFLICT | 当前状态或版本不允许操作,先查询 |
| 413 BODY_TOO_LARGE | 减小请求或改为传递业务对象引用 |
| 415 UNSUPPORTED_MEDIA_TYPE | 使用 application/json |
| 503 UNAVAILABLE | 结果可能未知;保留原幂等身份,有限退避后核对 |
```json
{"request_id":"44444444444444444444444444444444","error":{"code":"IDEMPOTENCY_CONFLICT","message":"idempotency key was used with different content"}}
```
批量受理、执行历史、人工重试和运行中取消尚未实现。完整可运行程序见 [SDK 任务示例](../../sdk/go/examples/tasks/main.go),已验证异常场景见 [P1 回归报告](../reports/phase-1.md)。SDK 忽略未知响应字段;更改已有字段语义需升级契约和回归。

View File

@@ -10,11 +10,20 @@
- 异常:数据库不可用或迁移记录异常时 readiness 返回 HTTP 503,SDK 返回类型化状态错误。 - 异常:数据库不可用或迁移记录异常时 readiness 返回 HTTP 503,SDK 返回类型化状态错误。
- 清理:只读调用,无业务资源需要删除。 - 清理:只读调用,无业务资源需要删除。
## 已可运行:文件转换任务的受理与取消
- 源码:[SDK 任务受理示例](../../sdk/go/examples/tasks/main.go)。
- 前置:完成迁移、初始化凭据、启动服务,详见 [SDK 接入](../sdk/README.md)。
- 输入:对象引用、格式与精确整数;提交两次同一身份,再查询、分页并带版本取消。
- 预期:重复请求返回同一任务,最终 CANCELLED/revision=2。当前不启动 Worker,不实际生成文件。
- 异常:凭据失效、权限不足、内容冲突、版本竞争和未知网络结果,见 [任务接口](../api/tasks.md)。
- 清理:示例以取消结束;任务和审计历史保留。重复运行会生成独立示例任务。
## 计划场景与验收标准 ## 计划场景与验收标准
| 场景 | 输入与步骤 | 预期结果 | 必测异常 | 状态 | | 场景 | 输入与步骤 | 预期结果 | 必测异常 | 状态 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 文件转换 | 对象引用 → 提交任务 → Worker 转换 → 结果引用 | 同一操作获得一次业务结果,历史可查 | 重复提交、Worker 在结果上报前中断 | P1–P2 待开发 | | 文件转换 | 对象引用 → 提交任务 → Worker 转换 → 结果引用 | 同一操作获得一次业务结果,历史可查 | 重复提交、Worker 在结果上报前中断 | P1 受理完成,P2 执行待开发 |
| 定期同步 | 发布计划 → 周期触发 → 暂停 → 恢复 | 触发身份唯一,漏跑策略可追溯 | 多节点竞争、停机后补跑上限 | P3 待开发 | | 定期同步 | 发布计划 → 周期触发 → 暂停 → 恢复 | 触发身份唯一,漏跑策略可追溯 | 多节点竞争、停机后补跑上限 | P3 待开发 |
| 并行生成 | 输入对象 → 并行计算两份结果 → 汇合 | 两支满足条件后只创建一份后继工作 | 一支失败、重复结果、汇合竞争 | P4 待开发 | | 并行生成 | 输入对象 → 并行计算两份结果 → 汇合 | 两支满足条件后只创建一份后继工作 | 一支失败、重复结果、汇合竞争 | P4 待开发 |
| 等待审核 | 校验 → 等待信号 → 发布结果 | 只接受授权、匹配当前操作的信号 | 早到、重复、过期与越权信号 | P4 待开发 | | 等待审核 | 校验 → 等待信号 → 发布结果 | 只接受授权、匹配当前操作的信号 | 早到、重复、过期与越权信号 | P4 待开发 |

65
docs/reports/phase-1.md Normal file
View File

@@ -0,0 +1,65 @@
# P1 任务受理与 SDK 回归记录
日期:2026-09-17。结论:任务受理阶段已实现并通过本地真实 MySQL 回归;执行引擎和生产高可用验收仍未完成。
## 实现范围
- 凭据摘要校验、有效期/撤销、应用与命名空间启停、动作和队列范围。
- 受控 bootstrap 命令:创建应用、命名空间、初始队列与凭据,0600 新文件交付,初始化审计。
- 队列创建/查询,任务提交/单条查询/游标分页/WAITING 普通任务取消。
- 同一事务中的幂等预留、语义摘要、资源状态、持久化事件、管理审计和回执。
- 独立 Go SDK 的对应方法、类型化错误、请求身份和精确 JSON 数据。
- Markdown 接口字段、失败行为、SDK 接入、任务受理场景源码。
新增迁移 000002_task_api:业务 JSON 改为 MEDIUMTEXT + JSON_VALID,避免 MySQL 原生 JSON 对业务数字的重新编码;元数据仍使用原生 JSON。增加队列内任务创建时间及状态分页索引。初始迁移校验和保持不变。
## 环境与执行入口
本机 macOS / arm64,Go 1.27.1;Docker 隔离容器 MySQL 8.4.10,回环随机端口、随机密码、用后清理。没有连接已有业务数据库。
```sh
make test
make vet
make build
make integration
go test ./internal/jsonvalue -fuzz FuzzCanonical -fuzztime=5s
cd sdk/go
GOWORK=off go test -race ./...
GOWORK=off go vet ./...
```
Go 未全局安装时,通过 make GO=/absolute/path/to/go 指定。集成脚本支持 GO、DOCKER、ORBIT_TEST_MYSQL_IMAGE。
## 实际验证
| 场景 | 结果 |
| --- | --- |
| 初始迁移 + 前向迁移,模型字段、隔离、预算、不可变历史、dirty/checksum 防护 | 通过 |
| 16 个并发请求共用一个提交键 | 1 次创建、其余复用;同一任务,受理事件和提交审计各 1 条 |
| 首次响应未被调用方确认后的原样重报 | 返回原任务与原回执;deduplicated=true |
| 字段顺序、1 与 1.00 等价;同键不同内容 | 等价请求复用;不同内容 409 |
| 9007199254740993、大整数和多位小数 | 提交、GET、缓存回执不损失数字精度 |
| 错误秘密、过期、撤销、应用/命名空间禁用 | 拒绝 |
| 跨命名空间、只读动作、队列范围 | 拒绝对应越权访问 |
| 未知/重复/别名字段、非法 Unicode、超大 body、过期时间、分页上限 | 拒绝且不留下成功幂等预留 |
| 游标分页 | 无重复项;不带业务 payload;更换过滤条件后游标无效 |
| 两个不同取消键竞争同一版本 | 1 个成功、1 个 409;取消审计 1 条,终结事件 1 条 |
| 同一取消键重复确认 | 原版本仍能取回原取消回执,不重复追加历史 |
| 注入审计 INSERT 失败 | 503;任务仍 WAITING/revision=1;事件和预留整体回滚;HTTP 不泄露 SQL 错误 |
| 初始化交付失败 | 应用、队列、凭据全部回滚 |
| 已取消 context 提交 | 不提交 |
| SDK 重定向、错误解析、精确 revision/JSON、写操作不静默重试 | 通过 |
| 真实 CLI → 服务进程 → 独立 SDK 示例 | alive/ready;duplicate=true;最终 CANCELLED/revision=2 |
| JSON 规范化模糊测试 | 一次约 20 万输入执行,未发现规范化不稳定;不等同于完整形式化证明 |
集成测试会主动制造一条 UNAVAILABLE 日志以验证审计失败回滚,该日志是预期结果。实际回归曾发现可空 SQL 结果直接扫描到 json.RawMessage 的兼容问题,已改为先扫描 []byte,再赋给响应 DTO,并由受理与查询测试覆盖。
## 尚未验证或未实现
- P1 没有 Worker、WebSocket、自动执行、延时扫描、重试扫描或定时计划;任务不会自行完成。
- 取消仅覆盖 WAITING 且非流程所属任务;运行中取消、未知结果核查按 P2/P5 完成。
- 队列并发/速率配置已保存,尚未启动执行限流;整体 API 配额与背压在后续阶段。
- 凭据轮换/管理 API、数据清理、完整监控、备份恢复在后续阶段。
- “响应未确认重报”当前验证为重复发送已经提交的请求;未注入真实网络断路或数据库提交 ACK 丢失。
- 未运行生产拓扑、复制切换、跨故障域恢复或容量压测。测试耗时不作为吞吐/延迟指标。
- SDK 尚未发布版本,代码保存在本地开发分支,当前报告不代表远端发布或生产上线。

View File

@@ -1,57 +1,127 @@
# Go SDK 接入说明 # Go SDK 接入说明
当前已实现客户端创建、存活与就绪查询。任务提交、Worker 和流程构建接口按开发计划逐阶段实现。 已实现健康查询、任务提交/查询/分页/待执行取消、队列创建/查询。Worker 和流程构建接口按 [开发计划](../08-development-plan.md) 继续实现。
SDK module:`git.yuntuo.hk/k2safe/orbit/sdk/go`。服务端与 SDK 在同一 Git 仓库,使用独立 `go.mod`;SDK 当前仅依赖 Go 标准库。 module:`git.yuntuo.hk/k2safe/orbit/sdk/go`。SDK 使用独立 go.mod,当前仅依赖 Go 标准库,不引用服务端模型或数据库实现。
## 1. 本地运行当前示例 ## 1. 从初始化到运行示例
前置条件:Go 1.27.1、MySQL 8.4+,先按项目根目录 README 迁移数据库并启动服务。 需要 Go 1.27.1、MySQL 8.4+。在仓库根目录,通过环境设置专用 ORBIT_MYSQL_DSN:
在仓库根目录执行: ```sh
go run ./cmd/orbit-migrate up
mkdir -p .local
chmod 700 .local
go run ./cmd/orbit-admin bootstrap \
--application sample-app --namespace default --environment development \
--queue files --output .local/development.credentials.json
go run ./cmd/orbit
```
另开终端,从仓库根目录执行:
```sh ```sh
cd sdk/go cd sdk/go
ORBIT_URL=http://127.0.0.1:8080 go run ./examples/health ORBIT_URL=http://127.0.0.1:8080 go run ./examples/health
ORBIT_URL=http://127.0.0.1:8080 \
ORBIT_CREDENTIALS_FILE=../../.local/development.credentials.json \
go run ./examples/tasks
``` ```
期望输出: 预期输出,task 为每次运行生成的实际 ID:
```text ```text
liveness=alive readiness=ready scope=database_and_schema liveness=alive readiness=ready scope=database_and_schema
task=<32位任务ID> duplicate=true listed=1 final_state=CANCELLED revision=2
``` ```
[完整示例源码](../../sdk/go/examples/health/main.go)。示例 context 最多等待 5 秒;数据库或迁移未就绪时返回错误并以非零状态退出。 重复运行示例会新建一个独立任务并取消它,listed 数量可能增加,最多显示当前页 10 条。数据和审计保留,不执行硬删除。示例只演示受理闭环,尚不执行文件转换。
## 2. 当前真实调用方式 - [健康示例源码](../../sdk/go/examples/health/main.go):只读,无业务资源写入。
- [任务示例源码](../../sdk/go/examples/tasks/main.go):提交两次同一身份、获取当前状态、列表、带版本取消。
- 任务示例也接受 ORBIT_TOKEN 和 ORBIT_QUEUE_ID,环境值优先于凭据文件。
- 示例发生错误会打印错误并非零退出;打印内容不包含原始 token。
- 初始凭据 30 天有效。初始化行为和错误核对方式见 [凭据文档](../api/authentication.md)。
- `make integration` 会在一次性数据库中自动完成上面的真实服务与 SDK 示例验证。
## 2. 创建客户端与提交任务
下方是接入函数内的调用片段;完整 imports/main 见示例源码。
```go ```go
client, err := orbit.NewClient("http://127.0.0.1:8080", orbit.Options{}) client, err := orbit.NewClient(baseURL, orbit.Options{Token: token})
if err != nil { if err != nil {
return err return err
} }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
health, err := client.Readiness(ctx) // 应先把 key 与请求内容保存在业务数据库;网络重报必须复用。
key := "file-conversion-operation-001"
request := orbit.SubmitTaskRequest{
QueueID: queueID,
Handler: "file.convert",
HandlerVersion: "v1",
PayloadVersion: "v1",
Payload: json.RawMessage(`{"source_ref":"objects/input.txt","format":"pdf"}`),
RecoverySafety: orbit.RecoveryReconcileOnly,
}
accepted, err := client.SubmitTask(ctx, key, request)
if err != nil { if err != nil {
var responseErr *orbit.HTTPError return err
if errors.As(err, &responseErr) { }
// responseErr.StatusCode 是实际 HTTP 状态,例如 503。 current, err := client.GetTask(ctx, accepted.Task.ID)
if err != nil {
return err
}
fmt.Println(current.ID, current.State, current.Revision)
```
受理回执不是业务执行结果。同键重报返回原始快照;当前状态应使用 GetTask。业务侧创建操作与平台提交之间存在双写边界,接入方应在自身事务中保存操作记录/Outbox,再使用稳定身份提交,不能依靠随机新键补发来“修复”未知结果。
## 3. 取消与错误处理
```go
cancelled, err := client.CancelTask(ctx, current.ID, key+"-cancel", orbit.CancelTaskRequest{
ExpectedRevision: current.Revision,
Reason: "source object was replaced",
})
if err != nil {
var apiErr *orbit.APIError
if errors.As(err, &apiErr) {
// apiErr.Code、StatusCode、RequestID 可用于分支与排查。
// STATE_CONFLICT:先查询;UNAVAILABLE:保留原键和原请求核对。
} }
return err return err
} }
fmt.Println(health.Status, health.Scope) fmt.Println(cancelled.Task.State)
``` ```
片段需在调用函数中使用并导入标准库包和 `orbit`;完整可运行程序见上方源码。 取消重报必须保留第一次取消时的 expected_revision,不能将响应中的新版本塞回同一幂等请求。当前只取消 WAITING 且不归属流程的任务。
`Options.HTTPClient` 可注入专用 HTTP 客户端。SDK 复制其配置,不修改调用方客户端;禁止自动跟随重定向,限制健康响应大小,不自动重试请求,不将服务端响应正文放进错误。 业务错误返回 *orbit.APIError,支持 errors.As 转为 *orbit.HTTPError 查看状态。健康错误或无法识别的网关错误使用 *orbit.HTTPError,不带未知响应正文。网络/上下文错误按标准库错误返回;超时并不能证明服务端没有提交。
## 3. 外部项目的本地接入 ## 4. 方法与限制
仓库尚未发布 SDK 版本。开发期间可使用独立工作区或本地 replace,例如在接入项目中执行: | 方法 | 请求 / 返回 | 文档 |
| --- | --- | --- |
| Liveness / Readiness | Health | [健康](../api/health.md) |
| SubmitTask | SubmitTaskRequest / SubmitTaskResult | [任务](../api/tasks.md) |
| GetTask | taskID / Task | [任务](../api/tasks.md) |
| ListTasks | ListTasksRequest / TaskPage | [任务](../api/tasks.md) |
| CancelTask | taskID、key、CancelTaskRequest / CancelTaskResult | [任务](../api/tasks.md) |
| CreateQueue | key、CreateQueueRequest / CreateQueueResult | [队列](../api/queues.md) |
| GetQueue | queueID / Queue | [队列](../api/queues.md) |
所有方法第一个参数为 context.Context,返回值之外都返回 error。命令方法要求调用方提供稳定 key,不自动生成或重试。ListTasksRequest 接受 QueueID、State、Limit、Cursor。
Options.HTTPClient 可注入专用 HTTP 客户端。SDK 复制配置,不修改调用方实例;默认超时 10 秒,可用 context 缩短。禁止自动跟随重定向,避免凭据被转交其他地址。生产配置 HTTPS;SDK 不承担部署 TLS 终止。
健康响应最多 16 KiB,业务响应最多 1 MiB,请求最多 512 KiB。大业务内容应传对象引用。参数/result 使用 json.RawMessage,不转成 float64;revision 在 Go 中为 uint64,JSON 编码为字符串。服务端字段及上限见各接口手册。
## 5. 外部项目本地接入
SDK 尚未发布版本。开发期可以用本地 replace:
```sh ```sh
go mod edit -require=git.yuntuo.hk/k2safe/orbit/sdk/go@v0.0.0 go mod edit -require=git.yuntuo.hk/k2safe/orbit/sdk/go@v0.0.0
@@ -59,14 +129,8 @@ go mod edit -replace=git.yuntuo.hk/k2safe/orbit/sdk/go=/absolute/path/to/orbit/s
go mod tidy go mod tidy
``` ```
示例路径需替换为本机实际目录。不要将本机绝对路径 replace 提交为发布依赖。后续发布 SDK 时使用独立 module 标签与兼容说明,在真实仓库取包路径验证后给出 `go get` 命令;当前不提供不存在的发行版本。 将示例路径换成真实路径,不要把本机绝对路径作为发布依赖提交。后续会验证独立 module 标签和真实远端取包,再提供 go get 发布命令。当前源码同时验证 GOWORK=off 的独立构建。
## 4. 后续 SDK 交付 ## 6. 后续交付
- P1:凭据配置、幂等提交、查询与待执行取消。 P2 提供 Worker 注册、WSS、能力/容量、执行确认、续租和排空;P3 提供计划与结果订阅;P4–P5 提供流程构建发布、信号、等待、轮询与人工恢复。每项随实现同步交付可运行示例及异常回归。
- P2:处理器注册、Worker 容量、WSS、续租与结果确认、优雅退出。
- P3:计划管理、可靠结果订阅与队列控制。
- P4:流程定义、发布、启动、信号与查询。
- P5:有界查询、暂停继续和人工恢复入口。
每阶段同步更新可运行场景、错误处理与兼容测试。业务幂等、外部结果核查与 Outbox 接入责任会与真实代码示例一起说明。

30
internal/fault/fault.go Normal file
View File

@@ -0,0 +1,30 @@
// Package fault 定义不包含数据库或敏感参数的稳定业务错误。
package fault
import "errors"
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (e *Error) Error() string { return e.Code + ": " + e.Message }
func New(code, message string) *Error { return &Error{Code: code, Message: message} }
func Code(err error) string {
var e *Error
if errors.As(err, &e) {
return e.Code
}
return "UNAVAILABLE"
}
var (
Unauthorized = New("UNAUTHENTICATED", "valid credentials are required")
Forbidden = New("PERMISSION_DENIED", "operation is not allowed")
NotFound = New("NOT_FOUND", "resource not found")
Conflict = New("STATE_CONFLICT", "resource state does not allow this operation")
IdempotencyConflict = New("IDEMPOTENCY_CONFLICT", "idempotency key was used with different content")
Unavailable = New("UNAVAILABLE", "service temporarily unavailable; retry using the original operation identity")
)
func Invalid(message string) error { return New("INVALID_ARGUMENT", message) }

View File

@@ -0,0 +1,194 @@
// Package jsonvalue 比较 JSON 的实际内容,不将 JSON 数值转换为浮点数。
package jsonvalue
import (
"bytes"
"encoding/json"
"errors"
"io"
"sort"
"strconv"
"strings"
"unicode/utf8"
)
// Canonical 生成内部 orbit-json-v1 摘要输入:对象键排序、重复键拒绝、
// 精确十进制数规范化。该格式不是对外 JSON 编码承诺,也不宣称实现 RFC 8785。
func Canonical(raw []byte) ([]byte, error) {
if !utf8.Valid(raw) {
return nil, errors.New("JSON must be valid UTF-8")
}
if err := validEscapes(raw); err != nil {
return nil, err
}
d := json.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
b, err := value(d, 0)
if err != nil {
return nil, err
}
if _, err := d.Token(); err != io.EOF {
return nil, errors.New("exactly one JSON value required")
}
return b, nil
}
func value(d *json.Decoder, depth int) ([]byte, error) {
if depth > 32 {
return nil, errors.New("JSON nesting exceeds 32 levels")
}
t, err := d.Token()
if err != nil {
return nil, err
}
switch v := t.(type) {
case json.Delim:
switch v {
case '{':
fields := map[string][]byte{}
keys := []string{}
for d.More() {
token, err := d.Token()
if err != nil {
return nil, err
}
key, ok := token.(string)
if !ok {
return nil, errors.New("invalid object key")
}
if _, ok := fields[key]; ok {
return nil, errors.New("duplicate JSON object key")
}
b, err := value(d, depth+1)
if err != nil {
return nil, err
}
fields[key] = b
keys = append(keys, key)
}
if close, err := d.Token(); err != nil || close != json.Delim('}') {
return nil, errors.New("unterminated JSON object")
}
sort.Strings(keys)
var b bytes.Buffer
b.WriteByte('{')
for i, k := range keys {
if i > 0 {
b.WriteByte(',')
}
key, _ := json.Marshal(k)
b.Write(key)
b.WriteByte(':')
b.Write(fields[k])
}
b.WriteByte('}')
return b.Bytes(), nil
case '[':
var b bytes.Buffer
b.WriteByte('[')
n := 0
for d.More() {
v, err := value(d, depth+1)
if err != nil {
return nil, err
}
if n > 0 {
b.WriteByte(',')
}
b.Write(v)
n++
}
if close, err := d.Token(); err != nil || close != json.Delim(']') {
return nil, errors.New("unterminated JSON array")
}
b.WriteByte(']')
return b.Bytes(), nil
default:
return nil, errors.New("unexpected JSON delimiter")
}
case json.Number:
return number(string(v))
default:
return json.Marshal(v)
}
}
func number(s string) ([]byte, error) {
if len(s) > 256 {
return nil, errors.New("JSON number exceeds 256 characters")
}
// 仅用作有限范围检查;摘要始终由下面的十进制文本运算生成。
if _, err := strconv.ParseFloat(s, 64); err != nil {
return nil, errors.New("JSON number outside supported range")
}
negative := strings.HasPrefix(s, "-")
s = strings.TrimPrefix(s, "-")
exponent := 0
if i := strings.IndexAny(s, "eE"); i >= 0 {
var err error
exponent, err = strconv.Atoi(s[i+1:])
if err != nil || exponent < -308 || exponent > 308 {
return nil, errors.New("JSON exponent outside supported range")
}
s = s[:i]
}
if i := strings.IndexByte(s, '.'); i >= 0 {
exponent -= len(s) - i - 1
s = s[:i] + s[i+1:]
}
s = strings.TrimLeft(s, "0")
if s == "" {
return []byte("0"), nil
}
for strings.HasSuffix(s, "0") {
s = s[:len(s)-1]
exponent++
}
if exponent < -308 || exponent > 308 {
return nil, errors.New("normalized JSON exponent outside supported range")
}
if exponent+len(s)-1 > 308 {
return nil, errors.New("JSON number outside supported range")
}
if negative {
s = "-" + s
}
if exponent != 0 {
s += "e" + strconv.Itoa(exponent)
}
return []byte(s), nil
}
// encoding/json 会替换孤立 UTF-16 代理项;拒绝它们,避免两个不同输入被悄悄合并。
func validEscapes(raw []byte) error {
for i := 0; i < len(raw); i++ {
if raw[i] != '\\' {
continue
}
i++
if i >= len(raw) || raw[i] != 'u' {
continue
}
if i+4 >= len(raw) {
return errors.New("invalid unicode escape")
}
v, err := strconv.ParseUint(string(raw[i+1:i+5]), 16, 16)
if err != nil {
return err
}
i += 4
if v >= 0xdc00 && v <= 0xdfff {
return errors.New("unpaired unicode surrogate")
}
if v < 0xd800 || v > 0xdbff {
continue
}
if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
return errors.New("unpaired unicode surrogate")
}
v, err = strconv.ParseUint(string(raw[i+3:i+7]), 16, 16)
if err != nil || v < 0xdc00 || v > 0xdfff {
return errors.New("unpaired unicode surrogate")
}
i += 6
}
return nil
}

View File

@@ -0,0 +1,38 @@
package jsonvalue
import "testing"
func TestCanonicalPreservesExactNumbers(t *testing.T) {
for _, pair := range [][2]string{
{`{"z":1.0,"a":9007199254740993}`, `{"a":9007199254740993,"z":1}`},
{`[0,-0,100,0.0001000]`, `[0,0,1e2,1e-4]`},
{`{"x":12345678901234567890.1234567890123456789}`, `{"x":123456789012345678901234567890123456789e-19}`},
{`"\ud83d\ude00"`, `"😀"`},
} {
got, err := Canonical([]byte(pair[0]))
if err != nil || string(got) != pair[1] {
t.Fatalf("%s => %s, %v", pair[0], got, err)
}
}
for _, raw := range []string{`{"a":1,"\u0061":2}`, `{"nested":{"x":1,"x":2}}`, `1 2`, `9e308`, `1e309`, `"\ud800"`, `"\udc00"`, `"\ud800\u1234"`, string([]byte{'"', 0xff, '"'})} {
if _, err := Canonical([]byte(raw)); err == nil {
t.Fatalf("accepted %q", raw)
}
}
}
func FuzzCanonical(f *testing.F) {
for _, s := range []string{`{"a":1.0}`, `[1,true,null]`, `"\ud83d\ude00"`, `0.0000000000000000001`} {
f.Add(s)
}
f.Fuzz(func(t *testing.T, s string) {
b, err := Canonical([]byte(s))
if err != nil {
return
}
again, err := Canonical(b)
if err != nil || string(again) != string(b) {
t.Fatalf("unstable canonical form %s %v", b, err)
}
})
}

View File

@@ -0,0 +1,91 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
)
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
type BootstrapInput struct {
Application string // Application 应用名称,每次初始化创建独立应用。
Namespace string // Namespace 命名空间名称。
Environment string // Environment 环境名称,必须显式指定。
Queue string // Queue 初始队列名称。
}
// BootstrapResult 包含一次性交付的原始凭据,禁止记录到日志或提交到版本库。
type BootstrapResult struct {
ApplicationID core.ID `json:"application_id"` // ApplicationID 新建应用标识。
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。
QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。
Token string `json:"token"` // Token 原始秘密,仅保存到受保护交付文件。
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 初始凭据到期时间,UTC。
}
func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error) error {
for _, v := range []string{in.Application, in.Namespace, in.Queue} {
if !service.ValidName(v, 128) {
return fmt.Errorf("invalid bootstrap resource name")
}
}
if !service.ValidName(in.Environment, 32) || deliver == nil {
return fmt.Errorf("environment and secure delivery are required")
}
if err := CheckSchema(ctx, db); err != nil {
return err
}
var out BootstrapResult
for _, dest := range []*core.ID{&out.ApplicationID, &out.NamespaceID, &out.QueueID, &out.CredentialID} {
v, err := core.NewID()
if err != nil {
return err
}
*dest = v
}
secret, digest, err := auth.Issue(out.NamespaceID, out.CredentialID)
if err != nil {
return err
}
out.Token = secret
permissions := auth.Permissions{Actions: []string{"tasks:submit", "tasks:read", "tasks:cancel", "queues:read", "queues:manage"}, Queues: []string{"*"}}
raw, err := json.Marshal(permissions)
if err != nil {
return err
}
return WithTx(ctx, db, func(tx *sql.Tx) error {
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
out.ExpiresAt = now.Add(30 * 24 * time.Hour)
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_applications(id,name) VALUES (?,?)", out.ApplicationID, in.Application); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,?)", out.NamespaceID, out.ApplicationID, in.Namespace, in.Environment); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, "INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')", out.NamespaceID, out.QueueID, in.Queue); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'bootstrap',?,?,?)", out.NamespaceID, out.CredentialID, digest, string(raw), out.ExpiresAt); err != nil {
return err
}
op, err := core.NewID()
if err != nil {
return err
}
if err := appendAudit(ctx, tx, auth.Token{NamespaceID: out.NamespaceID, CredentialID: out.CredentialID}, op, out.CredentialID, "credential", "bootstrap.create", "database administrator bootstrap", nil, map[string]any{"permissions": permissions, "expires_at": out.ExpiresAt}); err != nil {
return err
}
// 交付文件先持久化再提交;提交失败时保留文件以便核对不确定结果。
return deliver(out)
})
}

View File

@@ -0,0 +1,61 @@
-- 保留业务 JSON 的原始数值文本;验证 JSON 结构但不使用原生 JSON 数值重编码。
ALTER TABLE pulse_tasks MODIFY COLUMN `payload` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
ALTER TABLE pulse_tasks ADD CONSTRAINT ck_pulse_tasks_payload_json CHECK (`payload` IS NULL OR JSON_VALID(`payload`));
ALTER TABLE pulse_tasks MODIFY COLUMN `result` MEDIUMTEXT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
ALTER TABLE pulse_tasks ADD CONSTRAINT ck_pulse_tasks_result_json CHECK (`result` IS NULL OR JSON_VALID(`result`));
ALTER TABLE orbit_idempotency_keys MODIFY COLUMN `response` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
ALTER TABLE orbit_idempotency_keys ADD CONSTRAINT ck_orbit_idempotency_keys_response_json CHECK (`response` IS NULL OR JSON_VALID(`response`));
ALTER TABLE orbit_events MODIFY COLUMN `payload` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
ALTER TABLE orbit_events ADD CONSTRAINT ck_orbit_events_payload_json CHECK (`payload` IS NULL OR JSON_VALID(`payload`));
ALTER TABLE pulse_attempts MODIFY COLUMN `result` MEDIUMTEXT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
ALTER TABLE pulse_attempts ADD CONSTRAINT ck_pulse_attempts_result_json CHECK (`result` IS NULL OR JSON_VALID(`result`));
ALTER TABLE pulse_attempt_receipts MODIFY COLUMN `response` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
ALTER TABLE pulse_attempt_receipts ADD CONSTRAINT ck_pulse_attempt_receipts_response_json CHECK (`response` IS NULL OR JSON_VALID(`response`));
ALTER TABLE weave_definition_versions MODIFY COLUMN `specification` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
ALTER TABLE weave_definition_versions ADD CONSTRAINT ck_weave_definition_versions_specification_json CHECK (`specification` IS NULL OR JSON_VALID(`specification`));
ALTER TABLE weave_instances MODIFY COLUMN `input` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
ALTER TABLE weave_instances ADD CONSTRAINT ck_weave_instances_input_json CHECK (`input` IS NULL OR JSON_VALID(`input`));
ALTER TABLE weave_instances MODIFY COLUMN `output` MEDIUMTEXT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
ALTER TABLE weave_instances ADD CONSTRAINT ck_weave_instances_output_json CHECK (`output` IS NULL OR JSON_VALID(`output`));
ALTER TABLE weave_activations MODIFY COLUMN `input` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
ALTER TABLE weave_activations ADD CONSTRAINT ck_weave_activations_input_json CHECK (`input` IS NULL OR JSON_VALID(`input`));
ALTER TABLE weave_activations MODIFY COLUMN `output` MEDIUMTEXT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
ALTER TABLE weave_activations ADD CONSTRAINT ck_weave_activations_output_json CHECK (`output` IS NULL OR JSON_VALID(`output`));
ALTER TABLE weave_signals MODIFY COLUMN `payload` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
ALTER TABLE weave_signals ADD CONSTRAINT ck_weave_signals_payload_json CHECK (`payload` IS NULL OR JSON_VALID(`payload`));
ALTER TABLE weave_pending_advances MODIFY COLUMN `checkpoint` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
ALTER TABLE weave_pending_advances ADD CONSTRAINT ck_weave_pending_advances_checkpoint_json CHECK (`checkpoint` IS NULL OR JSON_VALID(`checkpoint`));
ALTER TABLE pulse_schedule_versions MODIFY COLUMN `task_template` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
ALTER TABLE pulse_schedule_versions ADD CONSTRAINT ck_pulse_schedule_versions_task_template_json CHECK (`task_template` IS NULL OR JSON_VALID(`task_template`));
ALTER TABLE pulse_tasks ADD KEY ix_task_created (namespace_id, queue_id, created_at, id);
ALTER TABLE pulse_tasks ADD KEY ix_task_state_created (namespace_id, queue_id, state, created_at, id);

View File

@@ -0,0 +1,396 @@
package mysql
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
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/pulse/service"
)
type apiReply struct {
status int
body []byte
data json.RawMessage
code string
}
func TestIntegrationTaskAPI(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 24, 4)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
id := func() core.ID {
t.Helper()
id, err := core.NewID()
if err != nil {
t.Fatal(err)
}
return id
}
bootstrap := func() BootstrapResult {
t.Helper()
var out BootstrapResult
err := Bootstrap(ctx, db, BootstrapInput{Application: "app-" + string(id()), Namespace: "default", Environment: "test", Queue: "files"}, func(v BootstrapResult) error { out = v; return nil })
if err != nil {
t.Fatal(err)
}
return out
}
a, b := bootstrap(), bootstrap()
svc := service.New(NewTaskStore(db))
server := httptest.NewServer(httpserver.New("", func(context.Context) error { return nil }, svc).Handler)
defer server.Close()
call := func(method, path, token, key string, body []byte) (apiReply, error) {
r, err := http.NewRequestWithContext(ctx, method, server.URL+path, bytes.NewReader(body))
if err != nil {
return apiReply{}, err
}
r.Header.Set("Content-Type", "application/json")
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
if key != "" {
r.Header.Set("Idempotency-Key", key)
}
response, err := server.Client().Do(r)
if err != nil {
return apiReply{}, err
}
defer response.Body.Close()
raw, err := io.ReadAll(response.Body)
if err != nil {
return apiReply{}, err
}
var envelope struct {
RequestID string `json:"request_id"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return apiReply{}, fmt.Errorf("invalid response JSON: %s", raw)
}
if core.ID(envelope.RequestID).Validate() != nil || response.Header.Get("X-Request-ID") != envelope.RequestID {
return apiReply{}, errors.New("missing request correlation")
}
return apiReply{response.StatusCode, raw, envelope.Data, envelope.Error.Code}, nil
}
must := func(method, path, token, key string, body []byte, status int) apiReply {
t.Helper()
out, err := call(method, path, token, key, body)
if err != nil {
t.Fatal(err)
}
if out.status != status {
t.Fatalf("%s %s: wanted %d got %d: %s", method, path, status, out.status, out.body)
}
return out
}
marshal := func(v any) []byte {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return raw
}
input := service.SubmitInput{QueueID: a.QueueID, Handler: "file.convert", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"sequence":9007199254740993,"decimal":12345678901234567890.1234567890123456789,"one":1}`)}
raw := marshal(input)
var accepted service.SubmitResult
out := must("POST", "/v1/tasks", a.Token, "stable-submit", raw, 201)
if err := json.Unmarshal(out.data, &accepted); err != nil {
t.Fatal(err)
}
if !bytes.Contains(out.body, []byte("12345678901234567890.1234567890123456789")) || !bytes.Contains(out.body, []byte("9007199254740993")) {
t.Fatal("payload precision lost")
}
taskID := accepted.Task.ID
count := func(table, where string, args ...any) int {
t.Helper()
var n int
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table+" WHERE "+where, args...).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
execute := func(q string, args ...any) {
t.Helper()
if _, err := db.ExecContext(ctx, q, args...); err != nil {
t.Fatal(err)
}
}
t.Run("concurrent_duplicate_and_semantic_request_identity", func(t *testing.T) {
// 模拟首次响应未被业务确认:重复原始请求,必须读到相同提交回执。
again := must("POST", "/v1/tasks", a.Token, "stable-submit", raw, 200)
var dupe service.SubmitResult
if err := json.Unmarshal(again.data, &dupe); err != nil || dupe.Task.ID != taskID || !dupe.Deduplicated {
t.Fatalf("duplicate: %s %v", again.body, err)
}
equivalent := input
equivalent.Payload = json.RawMessage(`{"one":1.00,"decimal":12345678901234567890.123456789012345678900,"sequence":9007199254740993}`)
must("POST", "/v1/tasks", a.Token, "stable-submit", marshal(equivalent), 200)
equivalent.Payload = json.RawMessage(`{"one":2}`)
reply := must("POST", "/v1/tasks", a.Token, "stable-submit", marshal(equivalent), 409)
if reply.code != "IDEMPOTENCY_CONFLICT" {
t.Fatal(reply.code)
}
const n = 16
start := make(chan struct{})
results := make(chan apiReply, n)
errs := make(chan error, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
out, err := call("POST", "/v1/tasks", a.Token, "concurrent-submit", raw)
results <- out
errs <- err
}()
}
close(start)
wg.Wait()
close(results)
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
var shared core.ID
created := 0
for out := range results {
if out.status != 200 && out.status != 201 {
t.Fatalf("concurrent response %d: %s", out.status, out.body)
}
if out.status == 201 {
created++
}
var v service.SubmitResult
if err := json.Unmarshal(out.data, &v); err != nil {
t.Fatal(err)
}
if shared == "" {
shared = v.Task.ID
}
if v.Task.ID != shared {
t.Fatal("duplicate task created")
}
}
if created != 1 || count("orbit_events", "namespace_id=? AND aggregate_id=?", a.NamespaceID, shared) != 1 || count("orbit_audit_events", "namespace_id=? AND resource_id=? AND action='task.submit'", a.NamespaceID, shared) != 1 {
t.Fatalf("creation/event count: %d", created)
}
})
t.Run("authorization_scope_and_revocation", func(t *testing.T) {
must("GET", "/v1/tasks/"+string(taskID), "", "", nil, 401)
wrong := a.Token[:len(a.Token)-43] + strings.Repeat("A", 43)
must("GET", "/v1/tasks/"+string(taskID), wrong, "", nil, 401)
must("GET", "/v1/tasks/"+string(taskID), b.Token, "", nil, 404)
must("POST", "/v1/tasks", b.Token, "cross-submit", raw, 404)
credential := id()
secret, digest, err := auth.Issue(a.NamespaceID, credential)
if err != nil {
t.Fatal(err)
}
permissions := auth.Permissions{Actions: []string{"tasks:read"}, Queues: []string{string(a.QueueID)}}
execute("INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions) VALUES (?,?,?,?,?)", a.NamespaceID, credential, string(credential), digest, string(marshal(permissions)))
must("GET", "/v1/tasks/"+string(taskID), secret, "", nil, 200)
must("POST", "/v1/tasks", secret, "read-only", raw, 403)
execute("UPDATE orbit_credentials SET expires_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?", a.NamespaceID, credential)
must("GET", "/v1/tasks/"+string(taskID), secret, "", nil, 401)
execute("UPDATE orbit_credentials SET expires_at=NULL,revoked_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?", a.NamespaceID, credential)
must("GET", "/v1/tasks/"+string(taskID), secret, "", nil, 401)
execute("UPDATE orbit_credentials SET revoked_at=NULL,permissions=? WHERE namespace_id=? AND id=?", string(marshal(auth.Permissions{Actions: []string{"tasks:read"}, Queues: []string{string(b.QueueID)}})), a.NamespaceID, credential)
must("GET", "/v1/tasks/"+string(taskID), secret, "", nil, 404)
execute("UPDATE orbit_namespaces SET enabled=FALSE WHERE id=?", b.NamespaceID)
must("GET", "/v1/tasks/"+string(taskID), b.Token, "", nil, 401)
execute("UPDATE orbit_namespaces SET enabled=TRUE WHERE id=?", b.NamespaceID)
execute("UPDATE orbit_applications SET enabled=FALSE WHERE id=?", b.ApplicationID)
must("GET", "/v1/tasks/"+string(taskID), b.Token, "", nil, 401)
execute("UPDATE orbit_applications SET enabled=TRUE WHERE id=?", b.ApplicationID)
})
t.Run("strict_input_and_failed_submission_rollback", func(t *testing.T) {
for _, bad := range []string{`null`, `{"namespace_id":"override"}`, `{"handler":"one","handler":"two"}`, `{"Handler":"case-alias"}`, `{"payload":"\ud800"}`, `{"retry":{"max_attempts":null}}`} {
must("POST", "/v1/tasks", a.Token, "invalid-body", []byte(bad), 400)
}
must("POST", "/v1/tasks", a.Token, "oversize", []byte(`{"payload":"`+strings.Repeat("x", 512<<10)+`"}`), 413)
must("POST", "/v1/tasks", a.Token, "", raw, 400)
invalid := input
past := time.Now().UTC().Add(-time.Hour).Truncate(time.Microsecond)
invalid.AvailableAt = &past
must("POST", "/v1/tasks", a.Token, "past", marshal(invalid), 400)
hash := core.Digest(sha256.Sum256([]byte("past")))
if count("orbit_idempotency_keys", "namespace_id=? AND key_digest=?", a.NamespaceID, hash) != 0 {
t.Fatal("failed submission committed a reservation")
}
must("POST", "/v1/tasks", a.Token, "past", raw, 201)
})
t.Run("keyset_pagination_and_opaque_payload", func(t *testing.T) {
seen := map[core.ID]bool{}
path := "/v1/tasks?queue_id=" + string(a.QueueID) + "&limit=1"
for i := 0; i < 10; i++ {
reply := must("GET", path, a.Token, "", nil, 200)
var page service.TaskPage
if err := json.Unmarshal(reply.data, &page); err != nil {
t.Fatal(err)
}
if bytes.Contains(reply.data, []byte(`"payload"`)) {
t.Fatal("list included payload")
}
for _, item := range page.Items {
if seen[item.ID] {
t.Fatal("repeated list item")
}
seen[item.ID] = true
}
if page.NextCursor == "" {
break
}
path = "/v1/tasks?queue_id=" + string(a.QueueID) + "&limit=1&cursor=" + url.QueryEscape(page.NextCursor)
must("GET", path+"&state=CANCELLED", a.Token, "", nil, 400)
}
if len(seen) != 3 {
t.Fatalf("list count %d", len(seen))
}
reply := must("GET", "/v1/tasks/"+string(taskID), a.Token, "", nil, 200)
if !bytes.Contains(reply.data, []byte("12345678901234567890.1234567890123456789")) {
t.Fatal("get changed number")
}
must("GET", "/v1/tasks?queue_id="+string(a.QueueID)+"&limit=101", a.Token, "", nil, 400)
})
t.Run("cancel_transaction_rolls_back_when_audit_fails", func(t *testing.T) {
execute("CREATE TRIGGER orbit_test_reject_audit BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='integration injected audit failure'")
defer execute("DROP TRIGGER orbit_test_reject_audit")
in := marshal(service.CancelInput{Reason: "test rollback", ExpectedRevision: 1})
reply := must("POST", "/v1/tasks/"+string(taskID)+":cancel", a.Token, "cancel-rollback", in, 503)
if strings.Contains(string(reply.body), "injected") {
t.Fatal("leaked internal failure")
}
var task service.TaskView
reply = must("GET", "/v1/tasks/"+string(taskID), a.Token, "", nil, 200)
if err := json.Unmarshal(reply.data, &task); err != nil || task.State != pulse.TaskWaiting || task.Revision != 1 {
t.Fatalf("rollback state: %+v %v", task, err)
}
if count("orbit_events", "namespace_id=? AND aggregate_id=?", a.NamespaceID, taskID) != 1 {
t.Fatal("event escaped rollback")
}
hash := core.Digest(sha256.Sum256([]byte("cancel-rollback")))
if count("orbit_idempotency_keys", "namespace_id=? AND key_digest=?", a.NamespaceID, hash) != 0 {
t.Fatal("reservation escaped rollback")
}
})
t.Run("cancel_compare_and_swap_and_duplicate_receipt", func(t *testing.T) {
in := marshal(service.CancelInput{Reason: "finish task", ExpectedRevision: 1})
replies := make(chan apiReply, 2)
errs := make(chan error, 2)
for _, key := range []string{"cancel-a", "cancel-b"} {
go func() {
out, err := call("POST", "/v1/tasks/"+string(taskID)+":cancel", a.Token, key, in)
replies <- out
errs <- err
}()
}
successes, conflicts := 0, 0
for i := 0; i < 2; i++ {
if err := <-errs; err != nil {
t.Fatal(err)
}
out := <-replies
switch out.status {
case 200:
successes++
case 409:
conflicts++
default:
t.Fatalf("cancel: %d %s", out.status, out.body)
}
}
if successes != 1 || conflicts != 1 {
t.Fatalf("cancel winners=%d conflicts=%d", successes, conflicts)
}
duplicate := 0
for _, key := range []string{"cancel-a", "cancel-b"} {
out, err := call("POST", "/v1/tasks/"+string(taskID)+":cancel", a.Token, key, in)
if err != nil {
t.Fatal(err)
}
if out.status == 200 {
var v service.CancelResult
if err := json.Unmarshal(out.data, &v); err != nil || !v.Deduplicated || v.Task.State != pulse.TaskCancelled || v.Task.Revision != 2 {
t.Fatalf("receipt: %s %v", out.body, err)
}
duplicate++
}
}
if duplicate != 1 || count("orbit_audit_events", "namespace_id=? AND resource_id=? AND action='task.cancel'", a.NamespaceID, taskID) != 1 || count("orbit_events", "namespace_id=? AND aggregate_id=?", a.NamespaceID, taskID) != 2 {
t.Fatal("cancel duplicated effects")
}
reply := must("POST", "/v1/tasks", a.Token, "stable-submit", raw, 200)
var original service.SubmitResult
if err := json.Unmarshal(reply.data, &original); err != nil || original.Task.State != pulse.TaskWaiting {
t.Fatal("original receipt should remain immutable")
}
})
t.Run("queue_management_and_bootstrap_rollback", func(t *testing.T) {
body := marshal(service.QueueInput{Name: "second", MaxConcurrency: 8, RatePerSecond: 100})
reply := must("POST", "/v1/queues", a.Token, "queue-second", body, 201)
var v service.QueueResult
if err := json.Unmarshal(reply.data, &v); err != nil {
t.Fatal(err)
}
must("POST", "/v1/queues", a.Token, "queue-second", body, 200)
must("POST", "/v1/queues", a.Token, "queue-name-conflict", body, 409)
must("GET", "/v1/queues/"+string(v.Queue.ID), a.Token, "", nil, 200)
must("GET", "/v1/queues/"+string(v.Queue.ID), b.Token, "", nil, 404)
name := "failed-" + string(id())
sentinel := errors.New("failed private output")
err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "n", Environment: "test", Queue: "q"}, func(BootstrapResult) error { return sentinel })
if !errors.Is(err, sentinel) {
t.Fatal(err)
}
if count("orbit_applications", "name=?", name) != 0 {
t.Fatal("bootstrap escaped rollback")
}
})
t.Run("cancelled_context_cannot_commit", func(t *testing.T) {
token, err := auth.Parse(a.Token)
if err != nil {
t.Fatal(err)
}
cancelled, stop := context.WithCancel(ctx)
stop()
_, err = svc.Submit(cancelled, token, "cancelled-context", input)
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
})
}

View File

@@ -0,0 +1,443 @@
package mysql
import (
"context"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
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/pulse/service"
driver "github.com/go-sql-driver/mysql"
)
// TaskStore 在单个 MySQL 事务中执行权限、幂等与任务状态写入。
type TaskStore struct{ db *sql.DB }
func NewTaskStore(db *sql.DB) *TaskStore { return &TaskStore{db: db} }
var _ service.Store = (*TaskStore)(nil)
func (s *TaskStore) transaction(ctx context.Context, token auth.Token, action string, fn func(*sql.Tx, auth.Permissions) error) error {
return WithTx(ctx, s.db, func(tx *sql.Tx) error {
p, err := authenticate(ctx, tx, token)
if err != nil {
return err
}
if !p.Allows(action) {
return fault.Forbidden
}
return fn(tx, p)
})
}
func authenticate(ctx context.Context, tx *sql.Tx, token auth.Token) (auth.Permissions, error) {
var digest core.Digest
var raw []byte
err := tx.QueryRowContext(ctx, `SELECT c.secret_digest,c.permissions FROM orbit_credentials c
JOIN orbit_namespaces n ON n.id=c.namespace_id JOIN orbit_applications a ON a.id=n.application_id
WHERE c.namespace_id=? AND c.id=? AND c.revoked_at IS NULL
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)) AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, token.NamespaceID, token.CredentialID).Scan(&digest, &raw)
if errors.Is(err, sql.ErrNoRows) {
return auth.Permissions{}, fault.Unauthorized
}
if err != nil {
return auth.Permissions{}, err
}
if subtle.ConstantTimeCompare(digest[:], token.Digest[:]) != 1 {
return auth.Permissions{}, fault.Unauthorized
}
var p auth.Permissions
if json.Unmarshal(raw, &p) != nil || p.Validate() != nil {
return p, fault.Unauthorized
}
return p, nil
}
func requestDigest(value any) (core.Digest, error) {
raw, err := json.Marshal(value)
if err != nil {
return core.Digest{}, err
}
raw, err = jsonvalue.Canonical(raw)
if err != nil {
return core.Digest{}, err
}
return core.Digest(sha256.Sum256(append([]byte("orbit-json-v1\x00"), raw...))), nil
}
// reserveIdentity 通过唯一键和行锁串行化同一请求。占位记录、资源及最终响应一起提交。
// 事务失败整体回滚;提交结果不明时调用方仍必须使用原键重报。
func reserveIdentity(ctx context.Context, tx *sql.Tx, ns core.ID, operation, scope, key, kind string, resource core.ID, value any) (core.ID, []byte, error) {
id, err := core.NewID()
if err != nil {
return "", nil, err
}
digest, err := requestDigest(value)
if err != nil {
return "", nil, err
}
keyDigest := core.Digest(sha256.Sum256([]byte(key)))
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_idempotency_keys(namespace_id,id,operation,scope_key,key_digest,request_digest,resource_kind,resource_id,response,expires_at)
VALUES (?,?,?,?,?,?,?,?, '{}',DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 90 DAY))
ON DUPLICATE KEY UPDATE id=orbit_idempotency_keys.id`, ns, id, operation, scope, keyDigest, digest, kind, resource)
if err != nil {
return "", nil, err
}
var storedID core.ID
var storedDigest core.Digest
var response []byte
err = tx.QueryRowContext(ctx, `SELECT id,request_digest,response FROM orbit_idempotency_keys
WHERE namespace_id=? AND operation=? AND scope_key=? AND key_digest=? FOR UPDATE`, ns, operation, scope, keyDigest).Scan(&storedID, &storedDigest, &response)
if err != nil {
return "", nil, err
}
if subtle.ConstantTimeCompare(digest[:], storedDigest[:]) != 1 {
return "", nil, fault.IdempotencyConflict
}
if storedID == id {
return id, nil, nil
}
return storedID, response, nil
}
func saveIdentity(ctx context.Context, tx *sql.Tx, ns, id core.ID, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, "UPDATE orbit_idempotency_keys SET response=?,revision=revision+1,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?", string(raw), ns, id)
return err
}
func emitEvent(ctx context.Context, tx *sql.Tx, ns, resource core.ID, revision uint64, kind, event string, payload any) error {
id, err := core.NewID()
if err != nil {
return err
}
raw, err := json.Marshal(payload)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_events(namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at) VALUES (?,?,?,?,?,?,?,UTC_TIMESTAMP(6))`, ns, id, kind, resource, revision, event, string(raw))
return err
}
func appendAudit(ctx context.Context, tx *sql.Tx, token auth.Token, operation, resource core.ID, kind, action, reason string, before, after any) error {
id, err := core.NewID()
if err != nil {
return err
}
old, err := json.Marshal(before)
if err != nil {
return err
}
current, err := json.Marshal(after)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,`before`,`after`) VALUES (?,?,?,?,?,?,?,?,?,?)", token.NamespaceID, id, operation, string(token.CredentialID), action, kind, resource, reason, string(old), string(current))
return err
}
func dbNow(ctx context.Context, tx *sql.Tx) (time.Time, error) {
var now time.Time
err := tx.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now)
return now, err
}
func (s *TaskStore) Submit(ctx context.Context, token auth.Token, key string, in service.SubmitInput) (service.SubmitResult, error) {
var out service.SubmitResult
err := s.transaction(ctx, token, "tasks:submit", func(tx *sql.Tx, p auth.Permissions) error {
if !p.AllowsQueue(in.QueueID) {
return fault.Forbidden
}
taskID, err := core.NewID()
if err != nil {
return err
}
identity, cached, err := reserveIdentity(ctx, tx, token.NamespaceID, "task.submit", "", key, "task", taskID, 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 queue core.ID
if err := tx.QueryRowContext(ctx, "SELECT id FROM pulse_queues WHERE namespace_id=? AND id=? FOR SHARE", token.NamespaceID, in.QueueID).Scan(&queue); errors.Is(err, sql.ErrNoRows) {
return fault.NotFound
} else if err != nil {
return err
}
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
available := now
if in.AvailableAt != nil {
available = *in.AvailableAt
}
deadline := now.Add(time.Duration(in.TaskTimeoutMS) * time.Millisecond)
if available.Before(now.Add(-time.Second)) || !available.Before(deadline) {
return fault.Invalid("available_at must be current/future and before the task deadline")
}
if in.StartDeadlineAt != nil && (!in.StartDeadlineAt.After(now) || in.StartDeadlineAt.Before(available) || in.StartDeadlineAt.After(deadline)) {
return fault.Invalid("start_deadline_at must be after now, at or after available_at, and no later than the task deadline")
}
policy, err := json.Marshal(in.Retry)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO pulse_tasks(namespace_id,id,queue_id,handler,handler_version,payload_version,payload,policy_snapshot,recovery_safety,business_key,available_at,start_deadline_at,deadline_at,attempt_timeout_ms,max_attempts,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,NULLIF(?,''),?,?,?,?,?,?,?)`, token.NamespaceID, taskID, in.QueueID, in.Handler, in.HandlerVersion, in.PayloadVersion, string(in.Payload), string(policy), in.RecoverySafety, in.BusinessKey, available, in.StartDeadlineAt, deadline, in.Retry.AttemptTimeoutMS, in.Retry.MaxAttempts, now, now)
if err != nil {
return err
}
out.Task, err = readTask(ctx, tx, token.NamespaceID, taskID, false)
if err != nil {
return err
}
if err := emitEvent(ctx, tx, token.NamespaceID, taskID, 1, "task", "task.accepted", map[string]any{"task_id": taskID, "state": pulse.TaskWaiting}); err != nil {
return err
}
if err := appendAudit(ctx, tx, token, identity, taskID, "task", "task.submit", "submit task", nil, map[string]any{"state": pulse.TaskWaiting, "queue_id": in.QueueID, "handler": in.Handler, "handler_version": in.HandlerVersion}); err != nil {
return err
}
return saveIdentity(ctx, tx, token.NamespaceID, identity, out)
})
return out, err
}
const taskColumns = `id,namespace_id,queue_id,state,revision,handler,handler_version,payload_version,payload,result,COALESCE(business_key,''),available_at,start_deadline_at,deadline_at,recovery_safety,policy_snapshot,attempt_count,cancel_requested_at,completed_at,created_at,updated_at`
type scanner interface{ Scan(...any) error }
func scanTask(row scanner) (service.TaskView, error) {
var v service.TaskView
var policy, payload, result []byte
err := row.Scan(&v.ID, &v.NamespaceID, &v.QueueID, &v.State, &v.Revision, &v.Handler, &v.HandlerVersion, &v.PayloadVersion, &payload, &result, &v.BusinessKey, &v.AvailableAt, &v.StartDeadlineAt, &v.DeadlineAt, &v.RecoverySafety, &policy, &v.AttemptCount, &v.CancelRequestedAt, &v.CompletedAt, &v.CreatedAt, &v.UpdatedAt)
if err != nil {
return v, err
}
v.Payload = payload
v.Result = result
err = json.Unmarshal(policy, &v.Retry)
return v, err
}
func readTask(ctx context.Context, tx *sql.Tx, ns, id core.ID, lock bool) (service.TaskView, error) {
q := "SELECT " + taskColumns + " FROM pulse_tasks WHERE namespace_id=? AND id=?"
if lock {
q += " FOR UPDATE"
}
v, err := scanTask(tx.QueryRowContext(ctx, q, ns, id))
if errors.Is(err, sql.ErrNoRows) {
err = fault.NotFound
}
return v, err
}
func (s *TaskStore) GetTask(ctx context.Context, token auth.Token, id core.ID) (service.TaskView, error) {
var out service.TaskView
err := s.transaction(ctx, token, "tasks:read", func(tx *sql.Tx, p auth.Permissions) error {
var err error
out, err = readTask(ctx, tx, token.NamespaceID, id, false)
if err != nil {
return err
}
if !p.AllowsQueue(out.QueueID) {
return fault.NotFound
}
return nil
})
return out, err
}
type taskCursor struct {
Namespace core.ID `json:"n"`
Queue core.ID `json:"q"`
State pulse.TaskState `json:"s"`
Created time.Time `json:"t"`
ID core.ID `json:"i"`
}
func (s *TaskStore) ListTasks(ctx context.Context, token auth.Token, in service.ListInput) (service.TaskPage, error) {
out := service.TaskPage{Items: []service.TaskSummary{}}
err := s.transaction(ctx, token, "tasks:read", func(tx *sql.Tx, p auth.Permissions) error {
if !p.AllowsQueue(in.QueueID) {
return fault.Forbidden
}
q := `SELECT id,queue_id,state,revision,handler,handler_version,COALESCE(business_key,''),available_at,deadline_at,attempt_count,created_at,updated_at FROM pulse_tasks WHERE namespace_id=? AND queue_id=?`
args := []any{token.NamespaceID, in.QueueID}
if in.State != "" {
q += " AND state=?"
args = append(args, in.State)
}
if in.Cursor != "" {
raw, err := base64.RawURLEncoding.DecodeString(in.Cursor)
var c taskCursor
if err != nil || json.Unmarshal(raw, &c) != nil || c.Namespace != token.NamespaceID || c.Queue != in.QueueID || c.State != in.State || c.Created.IsZero() || c.ID.Validate() != nil {
return fault.Invalid("cursor does not match this query")
}
q += " AND (created_at < ? OR (created_at=? AND id<?))"
args = append(args, c.Created, c.Created, c.ID)
}
q += " ORDER BY created_at DESC,id DESC LIMIT ?"
args = append(args, in.Limit+1)
rows, err := tx.QueryContext(ctx, q, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var v service.TaskSummary
if err := rows.Scan(&v.ID, &v.QueueID, &v.State, &v.Revision, &v.Handler, &v.HandlerVersion, &v.BusinessKey, &v.AvailableAt, &v.DeadlineAt, &v.AttemptCount, &v.CreatedAt, &v.UpdatedAt); err != nil {
return err
}
out.Items = append(out.Items, v)
}
if err := rows.Err(); err != nil {
return err
}
if len(out.Items) > in.Limit {
out.Items = out.Items[:in.Limit]
last := out.Items[len(out.Items)-1]
raw, err := json.Marshal(taskCursor{token.NamespaceID, in.QueueID, in.State, last.CreatedAt, last.ID})
if err != nil {
return err
}
out.NextCursor = base64.RawURLEncoding.EncodeToString(raw)
}
return nil
})
return out, err
}
func (s *TaskStore) CancelTask(ctx context.Context, token auth.Token, id core.ID, key string, in service.CancelInput) (service.CancelResult, error) {
var out service.CancelResult
err := s.transaction(ctx, token, "tasks:cancel", func(tx *sql.Tx, p auth.Permissions) error {
// 先以查询权限范围核对队列,再锁幂等记录,统一所有写操作的锁定顺序。
task, err := readTask(ctx, tx, token.NamespaceID, id, false)
if err != nil {
return err
}
if !p.AllowsQueue(task.QueueID) {
return fault.NotFound
}
identity, cached, err := reserveIdentity(ctx, tx, token.NamespaceID, "task.cancel", string(id), key, "task", 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
}
task, err = readTask(ctx, tx, token.NamespaceID, id, true)
if err != nil {
return err
}
if task.Revision != in.ExpectedRevision {
return fault.Conflict
}
if task.State != pulse.TaskWaiting {
return fault.Conflict
}
var owner sql.NullString
if err := tx.QueryRowContext(ctx, "SELECT owner_kind FROM pulse_tasks WHERE namespace_id=? AND id=?", token.NamespaceID, id).Scan(&owner); err != nil {
return err
}
if owner.Valid {
return fault.Conflict
}
result, err := tx.ExecContext(ctx, `UPDATE pulse_tasks SET state='CANCELLED',cancel_requested_at=UTC_TIMESTAMP(6),completed_at=UTC_TIMESTAMP(6),updated_at=UTC_TIMESTAMP(6),revision=revision+1 WHERE namespace_id=? AND id=? AND revision=? AND state='WAITING'`, token.NamespaceID, id, in.ExpectedRevision)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return fault.Conflict
}
out.Task, err = readTask(ctx, tx, token.NamespaceID, id, false)
if err != nil {
return err
}
if err := emitEvent(ctx, tx, token.NamespaceID, id, out.Task.Revision, "task", "task.cancelled", map[string]any{"task_id": id, "state": out.Task.State}); err != nil {
return err
}
if err := appendAudit(ctx, tx, token, identity, id, "task", "task.cancel", in.Reason, map[string]any{"state": task.State, "revision": fmt.Sprint(task.Revision)}, map[string]any{"state": out.Task.State, "revision": fmt.Sprint(out.Task.Revision)}); err != nil {
return err
}
return saveIdentity(ctx, tx, token.NamespaceID, identity, out)
})
return out, err
}
func (s *TaskStore) CreateQueue(ctx context.Context, token auth.Token, key string, in service.QueueInput) (service.QueueResult, error) {
var out service.QueueResult
err := s.transaction(ctx, token, "queues:manage", func(tx *sql.Tx, p auth.Permissions) error {
if !p.AllowsQueue("") {
return fault.Forbidden
}
id, err := core.NewID()
if err != nil {
return err
}
identity, cached, err := reserveIdentity(ctx, tx, token.NamespaceID, "queue.create", "", key, "queue", 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
}
_, err = tx.ExecContext(ctx, "INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,?,?,'{}')", token.NamespaceID, id, in.Name, in.MaxConcurrency, in.RatePerSecond)
var dbErr *driver.MySQLError
if errors.As(err, &dbErr) && dbErr.Number == 1062 {
return fault.Conflict
}
if err != nil {
return err
}
out.Queue, err = readQueue(ctx, tx, token.NamespaceID, id)
if err != nil {
return err
}
if err := appendAudit(ctx, tx, token, identity, id, "queue", "queue.create", "create queue", nil, out.Queue); err != nil {
return err
}
return saveIdentity(ctx, tx, token.NamespaceID, identity, out)
})
return out, err
}
func readQueue(ctx context.Context, tx *sql.Tx, ns, id core.ID) (service.QueueView, error) {
var q service.QueueView
err := tx.QueryRowContext(ctx, "SELECT id,namespace_id,name,paused,max_concurrency,rate_per_second,revision FROM pulse_queues WHERE namespace_id=? AND id=?", ns, id).Scan(&q.ID, &q.NamespaceID, &q.Name, &q.Paused, &q.MaxConcurrency, &q.RatePerSecond, &q.Revision)
if errors.Is(err, sql.ErrNoRows) {
err = fault.NotFound
}
return q, err
}
func (s *TaskStore) GetQueue(ctx context.Context, token auth.Token, id core.ID) (service.QueueView, error) {
var out service.QueueView
err := s.transaction(ctx, token, "queues:read", func(tx *sql.Tx, p auth.Permissions) error {
if !p.AllowsQueue(id) {
return fault.NotFound
}
var err error
out, err = readQueue(ctx, tx, token.NamespaceID, id)
return err
})
return out, err
}

View File

@@ -1,17 +1,17 @@
// Package httpserver exposes bootstrap health endpoints only. Task, workflow, // Package httpserver exposes health and authenticated task-management endpoints.
// authentication and WebSocket APIs will be implemented in subsequent slices.
package httpserver package httpserver
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
"net/http" "net/http"
"time" "time"
) )
type Checker func(context.Context) error type Checker func(context.Context) error
func New(addr string, ready Checker) *http.Server { func New(addr string, ready Checker, tasks ...*service.Service) *http.Server {
mux := http.NewServeMux() mux := http.NewServeMux()
write := func(w http.ResponseWriter, status int, body map[string]string) { write := func(w http.ResponseWriter, status int, body map[string]string) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -30,5 +30,8 @@ func New(addr string, ready Checker) *http.Server {
} }
write(w, http.StatusOK, map[string]string{"status": "ready", "scope": "database_and_schema"}) write(w, http.StatusOK, map[string]string{"status": "ready", "scope": "database_and_schema"})
}) })
return &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10} if len(tasks) > 0 && tasks[0] != nil {
registerTasks(mux, tasks[0])
}
return &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
} }

View File

@@ -0,0 +1,241 @@
package httpserver
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"mime"
"net/http"
"net/url"
"reflect"
"strconv"
"strings"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
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/pulse/service"
)
type endpoint func(*http.Request, auth.Token) (any, int, error)
func registerTasks(mux *http.ServeMux, s *service.Service) {
mux.HandleFunc("POST /v1/tasks", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
var in service.SubmitInput
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.Submit(r.Context(), t, key, in)
return out, createdStatus(out.Deduplicated), err
}))
mux.HandleFunc("GET /v1/tasks/{id}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
out, err := s.GetTask(r.Context(), t, core.ID(r.PathValue("id")))
return out, 200, err
}))
mux.HandleFunc("GET /v1/tasks", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
q, queryErr := url.ParseQuery(r.URL.RawQuery)
if queryErr != nil {
return nil, 0, fault.Invalid("invalid query encoding")
}
for k, v := range q {
if len(v) != 1 || (k != "queue_id" && k != "state" && k != "limit" && k != "cursor") {
return nil, 0, fault.Invalid("invalid query parameter")
}
}
limit := 0
if q.Has("limit") {
var err error
limit, err = strconv.Atoi(q.Get("limit"))
if err != nil || limit <= 0 {
return nil, 0, fault.Invalid("invalid limit")
}
}
out, err := s.ListTasks(r.Context(), t, service.ListInput{QueueID: core.ID(q.Get("queue_id")), State: pulse.TaskState(q.Get("state")), Limit: limit, Cursor: q.Get("cursor")})
return out, 200, err
}))
mux.HandleFunc("POST /v1/tasks/{command}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
command := r.PathValue("command")
if !strings.HasSuffix(command, ":cancel") {
return nil, 0, fault.NotFound
}
var in service.CancelInput
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.CancelTask(r.Context(), t, core.ID(strings.TrimSuffix(command, ":cancel")), key, in)
return out, 200, err
}))
mux.HandleFunc("POST /v1/queues", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
var in service.QueueInput
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.CreateQueue(r.Context(), t, key, in)
return out, createdStatus(out.Deduplicated), err
}))
mux.HandleFunc("GET /v1/queues/{id}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
out, err := s.GetQueue(r.Context(), t, core.ID(r.PathValue("id")))
return out, 200, err
}))
}
func createdStatus(duplicate bool) int {
if duplicate {
return http.StatusOK
}
return http.StatusCreated
}
func idempotencyKey(r *http.Request) (string, error) {
values := r.Header.Values("Idempotency-Key")
if len(values) != 1 {
return "", fault.Invalid("exactly one Idempotency-Key is required")
}
return values[0], nil
}
func wrap(next endpoint) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := core.NewID()
if err != nil {
writeError(w, "", fault.Unavailable)
return
}
w.Header().Set("X-Request-ID", string(id))
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
values := r.Header.Values("Authorization")
var token auth.Token
if len(values) != 1 {
writeError(w, string(id), fault.Unauthorized)
return
}
scheme, value, ok := strings.Cut(values[0], " ")
if !ok || !strings.EqualFold(scheme, "Bearer") {
writeError(w, string(id), fault.Unauthorized)
return
}
token, err = auth.Parse(value)
if err != nil {
writeError(w, string(id), fault.Unauthorized)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
data, status, err := next(r.WithContext(ctx), token)
if err != nil {
if fault.Code(err) == "UNAVAILABLE" {
slog.Error("Orbit API operation failed", "request_id", id, "code", "UNAVAILABLE")
}
writeError(w, string(id), err)
return
}
writeJSON(w, status, map[string]any{"request_id": id, "data": data})
}
}
func writeJSON(w http.ResponseWriter, status int, body any) {
raw, err := json.Marshal(body)
if err != nil {
status = 503
raw = []byte(`{"error":{"code":"UNAVAILABLE","message":"operation unavailable"}}`)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(append(raw, '\n'))
}
func writeError(w http.ResponseWriter, id string, err error) {
var known *fault.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}[known.Code]
if status == 0 {
status = 503
}
if status == 401 {
w.Header().Set("WWW-Authenticate", `Bearer realm="orbit"`)
}
writeJSON(w, status, map[string]any{"request_id": id, "error": known})
}
func decodeBody(r *http.Request, out any) error {
content, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || content != "application/json" {
return fault.New("UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json")
}
const maxBody = 512 << 10
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
if err != nil {
return fault.Invalid("cannot read request body")
}
if len(raw) > maxBody {
return fault.New("BODY_TOO_LARGE", "request body exceeds 512 KiB")
}
if _, err := jsonvalue.Canonical(raw); err != nil {
return fault.Invalid("invalid JSON, duplicate keys, or unsupported depth/numeric range")
}
if err := exactFields(raw, reflect.TypeOf(out).Elem()); err != nil {
return fault.Invalid("unknown field, null, or invalid object structure")
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(out); err != nil {
return fault.Invalid("invalid field value or type")
}
return nil
}
// 精确匹配结构字段,防止 encoding/json 将 Handler 和 handler 解释为同一个字段。
func exactFields(raw []byte, t reflect.Type) error {
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
if t.Kind() == reflect.Pointer {
return nil
}
return errors.New("unexpected null")
}
if t.Kind() == reflect.Pointer {
return exactFields(raw, t.Elem())
}
if t == reflect.TypeOf(time.Time{}) || t == reflect.TypeOf(json.RawMessage{}) {
return nil
}
if t.Kind() != reflect.Struct {
return nil
}
var object map[string]json.RawMessage
if err := json.Unmarshal(raw, &object); err != nil {
return err
}
names := map[string]reflect.Type{}
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
}
}
for name, value := range object {
field, ok := names[name]
if !ok {
return errors.New("unknown field")
}
if err := exactFields(value, field); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,44 @@
package httpserver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
)
func TestBodyAndErrorBoundary(t *testing.T) {
for _, test := range []struct{ body, content, code string }{
{`{"payload":{"n":1,"n":2}}`, "application/json", "INVALID_ARGUMENT"},
{`{"retry":{"MaxAttempts":2}}`, "application/json", "INVALID_ARGUMENT"},
{`{"retry":{"max_attempts":1},"Retry":{"max_attempts":2}}`, "application/json", "INVALID_ARGUMENT"},
{`{}`, "text/plain", "UNSUPPORTED_MEDIA_TYPE"},
{`{"payload":{"arbitrary_key":9007199254740993}}`, "application/json", ""},
} {
r := httptest.NewRequest(http.MethodPost, "/v1/tasks", strings.NewReader(test.body))
r.Header.Set("Content-Type", test.content)
var in service.SubmitInput
err := decodeBody(r, &in)
if test.code == "" {
if err != nil {
t.Fatal(err)
}
} else if fault.Code(err) != test.code {
t.Fatalf("body %s: %v", test.body, err)
}
}
w := httptest.NewRecorder()
writeError(w, "request", fault.Forbidden)
var envelope struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &envelope); err != nil || envelope.Error.Code != "PERMISSION_DENIED" || w.Code != 403 {
t.Fatalf("bad error contract %s", w.Body.String())
}
}

94
platform/auth/token.go Normal file
View File

@@ -0,0 +1,94 @@
// Package auth 定义凭据格式与权限快照;凭据原文仅在签发时交付。
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"strings"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
)
// Token 是已解析但尚未认证的身份,服务端仍须核对数据库摘要与有效期。
type Token struct {
// NamespaceID 凭据所属命名空间,不能由请求体覆盖。
NamespaceID core.ID
// CredentialID 凭据记录标识。
CredentialID core.ID
// Digest 来自原始秘密的摘要,不包含可重放的原始秘密。
Digest core.Digest
}
func Issue(namespace, credential core.ID) (string, core.Digest, error) {
if err := namespace.Validate(); err != nil {
return "", core.Digest{}, err
}
if err := credential.Validate(); err != nil {
return "", core.Digest{}, err
}
var secret [32]byte
if _, err := rand.Read(secret[:]); err != nil {
return "", core.Digest{}, err
}
return "orb1." + string(namespace) + "." + string(credential) + "." + base64.RawURLEncoding.EncodeToString(secret[:]), core.Digest(sha256.Sum256(secret[:])), nil
}
func Parse(value string) (Token, error) {
p := strings.Split(value, ".")
if len(p) != 4 || p[0] != "orb1" || len(p[3]) != 43 {
return Token{}, fault.Unauthorized
}
ns, id := core.ID(p[1]), core.ID(p[2])
if ns.Validate() != nil || id.Validate() != nil {
return Token{}, fault.Unauthorized
}
secret, err := base64.RawURLEncoding.Strict().DecodeString(p[3])
if err != nil || len(secret) != 32 {
return Token{}, fault.Unauthorized
}
return Token{NamespaceID: ns, CredentialID: id, Digest: core.Digest(sha256.Sum256(secret))}, nil
}
// Permissions 是凭据的授权快照,动作与队列范围必须同时匹配。
type Permissions struct {
// Actions 明确授权的动作集合,首版不使用动作通配符。
Actions []string `json:"actions"`
// Queues 可访问的队列 ID;星号表示当前命名空间内全部队列。
Queues []string `json:"queues"`
}
var supported = map[string]bool{"tasks:submit": true, "tasks:read": true, "tasks:cancel": true, "queues:read": true, "queues:manage": true}
func (p Permissions) Validate() error {
if len(p.Actions) == 0 || len(p.Actions) > 32 || len(p.Queues) == 0 || len(p.Queues) > 128 {
return fault.Invalid("invalid permission scope")
}
for _, a := range p.Actions {
if !supported[a] {
return fault.Invalid("unsupported permission action")
}
}
for _, q := range p.Queues {
if q != "*" && core.ID(q).Validate() != nil {
return fault.Invalid("invalid queue permission")
}
}
return nil
}
func (p Permissions) Allows(action string) bool {
for _, a := range p.Actions {
if a == action {
return true
}
}
return false
}
func (p Permissions) AllowsQueue(queue core.ID) bool {
for _, q := range p.Queues {
if q == "*" || q == string(queue) {
return true
}
}
return false
}

View File

@@ -0,0 +1,29 @@
package auth
import (
core "git.yuntuo.hk/k2safe/orbit/model"
"strings"
"testing"
)
func TestTokenAndScope(t *testing.T) {
ns := core.ID(strings.Repeat("1", 32))
id := core.ID(strings.Repeat("2", 32))
secret, digest, err := Issue(ns, id)
if err != nil {
t.Fatal(err)
}
token, err := Parse(secret)
if err != nil || token.NamespaceID != ns || token.CredentialID != id || token.Digest != digest {
t.Fatalf("token round trip: %v", err)
}
for _, bad := range []string{secret + "=", secret + "\n", "orb1.bad.id.secret", strings.Replace(secret, "orb1.", "orb2.", 1)} {
if _, err := Parse(bad); err == nil {
t.Fatal("accepted malformed token")
}
}
p := Permissions{Actions: []string{"tasks:read"}, Queues: []string{string(id)}}
if p.Validate() != nil || !p.Allows("tasks:read") || p.Allows("tasks:submit") || !p.AllowsQueue(id) || p.AllowsQueue(ns) {
t.Fatal("scope mismatch")
}
}

156
pulse/service/service.go Normal file
View File

@@ -0,0 +1,156 @@
package service
import (
"bytes"
"context"
"regexp"
"strings"
"time"
"unicode/utf8"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
)
type Service struct{ store Store }
func New(store Store) *Service { return &Service{store: store} }
var namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]*$`)
func ValidName(s string, max int) bool {
return len(s) > 0 && len(s) <= max && namePattern.MatchString(s)
}
func ValidateKey(key string) error {
if len(key) < 1 || len(key) > 128 {
return fault.Invalid("Idempotency-Key must contain 1 to 128 printable ASCII characters")
}
for _, c := range key {
if c < 33 || c > 126 {
return fault.Invalid("Idempotency-Key must contain printable ASCII characters without spaces")
}
}
return nil
}
func NormalizeSubmit(in SubmitInput) (SubmitInput, error) {
if in.QueueID.Validate() != nil {
return in, fault.Invalid("queue_id is invalid")
}
if !ValidName(in.Handler, 128) || !ValidName(in.HandlerVersion, 64) || !ValidName(in.PayloadVersion, 32) {
return in, fault.Invalid("handler and version names are required ASCII identifiers")
}
if len(in.Payload) == 0 || len(in.Payload) > 256<<10 || bytes.Equal(bytes.TrimSpace(in.Payload), []byte("null")) {
return in, fault.Invalid("payload must be a non-null JSON value up to 256 KiB")
}
if _, err := jsonvalue.Canonical(in.Payload); err != nil {
return in, fault.Invalid("payload is invalid, duplicated, too deep or outside numeric limits")
}
if !utf8.ValidString(in.BusinessKey) || utf8.RuneCountInString(in.BusinessKey) > 191 || strings.ContainsAny(in.BusinessKey, "\x00\r\n") {
return in, fault.Invalid("business_key must be at most 191 characters without control newlines")
}
if in.TaskTimeoutMS == 0 {
in.TaskTimeoutMS = 24 * 60 * 60 * 1000
}
if in.TaskTimeoutMS > 30*24*60*60*1000 {
return in, fault.Invalid("task_timeout_ms exceeds 30 days")
}
if in.RecoverySafety == "" {
in.RecoverySafety = pulse.RecoveryReconcileOnly
}
if in.RecoverySafety != pulse.RecoveryReconcileOnly && in.RecoverySafety != pulse.RecoveryIdempotent {
return in, fault.Invalid("invalid recovery_safety")
}
if in.Retry.MaxAttempts == 0 {
in.Retry.MaxAttempts = 1
}
if in.Retry.AttemptTimeoutMS == 0 {
in.Retry.AttemptTimeoutMS = 30000
}
if in.Retry.MaxAttempts > 100 || in.Retry.AttemptTimeoutMS > in.TaskTimeoutMS {
return in, fault.Invalid("retry attempts or timeout exceed limits")
}
if in.Retry.MaxAttempts > 1 {
if in.Retry.InitialDelayMS == 0 {
in.Retry.InitialDelayMS = 1000
}
if in.Retry.MaxDelayMS == 0 {
in.Retry.MaxDelayMS = 30000
}
}
if err := in.Retry.Validate(); err != nil {
return in, fault.Invalid("invalid retry policy")
}
for _, t := range []**time.Time{&in.AvailableAt, &in.StartDeadlineAt} {
if *t != nil {
v := (**t).UTC()
if v.Year() < 2000 || v.Year() > 9998 || v.Nanosecond()%1000 != 0 {
return in, fault.Invalid("times must use microsecond precision and supported years")
}
*t = &v
}
}
return in, nil
}
func (s *Service) Submit(ctx context.Context, token auth.Token, key string, in SubmitInput) (SubmitResult, error) {
if err := ValidateKey(key); err != nil {
return SubmitResult{}, err
}
in, err := NormalizeSubmit(in)
if err != nil {
return SubmitResult{}, err
}
return s.store.Submit(ctx, token, key, in)
}
func (s *Service) GetTask(ctx context.Context, token auth.Token, id core.ID) (TaskView, error) {
if id.Validate() != nil {
return TaskView{}, fault.Invalid("task id is invalid")
}
return s.store.GetTask(ctx, token, id)
}
func (s *Service) ListTasks(ctx context.Context, token auth.Token, in ListInput) (TaskPage, error) {
if in.QueueID.Validate() != nil {
return TaskPage{}, fault.Invalid("queue_id is required")
}
if in.Limit == 0 {
in.Limit = 20
}
if in.Limit < 1 || in.Limit > 100 || len(in.Cursor) > 1024 {
return TaskPage{}, fault.Invalid("invalid page limit or cursor")
}
switch in.State {
case "", pulse.TaskWaiting, pulse.TaskLeased, pulse.TaskRunning, pulse.TaskNeedsReview, pulse.TaskSucceeded, pulse.TaskFailed, pulse.TaskCancelled:
default:
return TaskPage{}, fault.Invalid("invalid task state")
}
return s.store.ListTasks(ctx, token, in)
}
func (s *Service) CancelTask(ctx context.Context, token auth.Token, id core.ID, key string, in CancelInput) (CancelResult, error) {
if id.Validate() != nil {
return CancelResult{}, fault.Invalid("task id is invalid")
}
if err := ValidateKey(key); err != nil {
return CancelResult{}, err
}
if strings.TrimSpace(in.Reason) == "" || !utf8.ValidString(in.Reason) || utf8.RuneCountInString(in.Reason) > 1024 || in.ExpectedRevision == 0 {
return CancelResult{}, fault.Invalid("reason and nonzero expected_revision are required")
}
return s.store.CancelTask(ctx, token, id, key, in)
}
func (s *Service) CreateQueue(ctx context.Context, token auth.Token, key string, in QueueInput) (QueueResult, error) {
if err := ValidateKey(key); err != nil {
return QueueResult{}, err
}
if !ValidName(in.Name, 128) || in.MaxConcurrency == 0 || in.MaxConcurrency > 100000 || in.RatePerSecond == 0 || in.RatePerSecond > 1000000 {
return QueueResult{}, fault.Invalid("queue name, concurrency or rate is invalid")
}
return s.store.CreateQueue(ctx, token, key, in)
}
func (s *Service) GetQueue(ctx context.Context, token auth.Token, id core.ID) (QueueView, error) {
if id.Validate() != nil {
return QueueView{}, fault.Invalid("queue id is invalid")
}
return s.store.GetQueue(ctx, token, id)
}

View File

@@ -0,0 +1,42 @@
package service
import (
"encoding/json"
"strings"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
)
func TestSubmitNormalizationAndBounds(t *testing.T) {
base := SubmitInput{QueueID: core.ID(strings.Repeat("1", 32)), Handler: "file.convert", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"v":9007199254740993}`)}
out, err := NormalizeSubmit(base)
if err != nil || out.RecoverySafety != pulse.RecoveryReconcileOnly || out.Retry.MaxAttempts != 1 || out.TaskTimeoutMS != 86400000 {
t.Fatalf("defaults %+v %v", out, err)
}
for name, change := range map[string]func(*SubmitInput){
"missing queue": func(v *SubmitInput) { v.QueueID = "" },
"missing handler version": func(v *SubmitInput) { v.HandlerVersion = "" },
"null payload": func(v *SubmitInput) { v.Payload = json.RawMessage("null") },
"oversize payload": func(v *SubmitInput) { v.Payload = json.RawMessage(`"` + strings.Repeat("a", 256<<10) + `"`) },
"unbounded attempts": func(v *SubmitInput) { v.Retry.MaxAttempts = 101 },
"unknown recovery policy": func(v *SubmitInput) { v.RecoverySafety = "UNKNOWN" },
"excess timeout": func(v *SubmitInput) { v.TaskTimeoutMS = 30*86400000 + 1 },
"sub-microsecond time": func(v *SubmitInput) { now := time.Date(2026, 1, 1, 0, 0, 0, 1, time.UTC); v.AvailableAt = &now },
} {
t.Run(name, func(t *testing.T) {
in := base
change(&in)
if _, err := NormalizeSubmit(in); err == nil {
t.Fatal("accepted invalid input")
}
})
}
for _, key := range []string{"", strings.Repeat("a", 129), "with space", "带中文", "line\n"} {
if ValidateKey(key) == nil {
t.Fatal("accepted invalid key")
}
}
}

186
pulse/service/types.go Normal file
View File

@@ -0,0 +1,186 @@
// Package service 定义任务应用层契约与校验,不直接操作 HTTP 或 SQL。
package service
import (
"context"
"encoding/json"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
)
// SubmitInput 是任务受理命令;可变配置在受理时固定为快照。
type SubmitInput struct {
// QueueID 目标队列,必须位于凭据所属命名空间。
QueueID core.ID `json:"queue_id"`
// Handler 业务 Worker 的处理器名称。
Handler string `json:"handler"`
// HandlerVersion 固定处理器版本。
HandlerVersion string `json:"handler_version"`
// PayloadVersion 输入参数结构版本。
PayloadVersion string `json:"payload_version"`
// Payload 业务无关 JSON 文档,最大 256 KiB,保留精确数值文本。
Payload json.RawMessage `json:"payload"`
// BusinessKey 可选关联键,不提供业务效果幂等保证。
BusinessKey string `json:"business_key,omitempty"`
// AvailableAt 最早执行时间;为空表示受理后即可进入派发范围。
AvailableAt *time.Time `json:"available_at,omitempty"`
// StartDeadlineAt 最晚开始时间;为空表示使用任务总截止时间。
StartDeadlineAt *time.Time `json:"start_deadline_at,omitempty"`
// TaskTimeoutMS 从受理时计算的总时限,单位毫秒。
TaskTimeoutMS uint64 `json:"task_timeout_ms"`
// RecoverySafety 外部效果未知时的恢复安全等级。
RecoverySafety pulse.RecoverySafety `json:"recovery_safety"`
// Retry 每次执行的有限尝试策略,包含首次尝试。
Retry pulse.RetryPolicy `json:"retry"`
}
// TaskView 是对外任务快照,不包含 Worker 令牌或凭据摘要。
type TaskView struct {
// ID 资源标识,32 位小写十六进制字符串。
ID core.ID `json:"id"`
// NamespaceID 命名空间标识,由凭据确定隔离范围。
NamespaceID core.ID `json:"namespace_id"`
// QueueID 队列标识,必须属于当前命名空间。
QueueID core.ID `json:"queue_id"`
// State 当前任务状态;终态不会被重复请求重开。
State pulse.TaskState `json:"state"`
// Revision 并发版本,JSON 使用十进制字符串避免跨语言精度丢失。
Revision uint64 `json:"revision,string"`
// Handler Worker 处理器名称。
Handler string `json:"handler"`
// HandlerVersion 受理时固定的处理器版本。
HandlerVersion string `json:"handler_version"`
// PayloadVersion 受理时固定的参数契约版本。
PayloadVersion string `json:"payload_version"`
// Payload 业务无关 JSON 输入,保留数字精度。
Payload json.RawMessage `json:"payload"`
// Result 业务 JSON 结果;未取得结果时为空并省略。
Result json.RawMessage `json:"result,omitempty"`
// BusinessKey 可选业务关联键,不自动提供业务效果幂等。
BusinessKey string `json:"business_key,omitempty"`
// AvailableAt 最早可执行时间,UTC。
AvailableAt time.Time `json:"available_at"`
// StartDeadlineAt 最晚开始时间;为空时使用任务总截止时间。
StartDeadlineAt *time.Time `json:"start_deadline_at,omitempty"`
// DeadlineAt 任务总截止时间,UTC。
DeadlineAt time.Time `json:"deadline_at"`
// RecoverySafety 未知外部效果的恢复安全等级。
RecoverySafety pulse.RecoverySafety `json:"recovery_safety"`
// Retry 受理时冻结的尝试次数与时限策略。
Retry pulse.RetryPolicy `json:"retry"`
// AttemptCount 已创建的尝试次数,包含首次尝试。
AttemptCount uint32 `json:"attempt_count"`
// CancelRequestedAt 取消意图时间;为空表示未请求取消。
CancelRequestedAt *time.Time `json:"cancel_requested_at,omitempty"`
// CompletedAt 终结时间;非终态为空。
CompletedAt *time.Time `json:"completed_at,omitempty"`
// CreatedAt 创建时间,UTC 微秒精度。
CreatedAt time.Time `json:"created_at"`
// UpdatedAt 最后变更时间,不能替代并发版本。
UpdatedAt time.Time `json:"updated_at"`
}
type SubmitResult struct {
// Task 本次操作的提交回执快照;当前状态应另行查询。
Task TaskView `json:"task"`
// Deduplicated 是否复用了同一幂等身份的原始回执。
Deduplicated bool `json:"deduplicated"`
}
type CancelInput struct {
// Reason 管理操作原因,写入审计历史。
Reason string `json:"reason"`
// ExpectedRevision 调用者预期的资源版本,JSON 为十进制字符串。
ExpectedRevision uint64 `json:"expected_revision,string"`
}
type CancelResult struct {
// Task 本次操作的提交回执快照;当前状态应另行查询。
Task TaskView `json:"task"`
// Deduplicated 是否复用了同一幂等身份的原始回执。
Deduplicated bool `json:"deduplicated"`
}
type ListInput struct {
// QueueID 队列标识,必须属于当前命名空间。
QueueID core.ID
// State 当前任务状态;终态不会被重复请求重开。
State pulse.TaskState
// Limit 分页大小,默认 20,上限 100。
Limit int
// Cursor 上页返回的游标,必须复用相同查询条件。
Cursor string
}
// TaskSummary 用于列表,避免在分页中重复传输大参数和结果。
type TaskSummary struct {
// ID 资源标识,32 位小写十六进制字符串。
ID core.ID `json:"id"`
// QueueID 队列标识,必须属于当前命名空间。
QueueID core.ID `json:"queue_id"`
// State 当前任务状态;终态不会被重复请求重开。
State pulse.TaskState `json:"state"`
// Revision 并发版本,JSON 使用十进制字符串避免跨语言精度丢失。
Revision uint64 `json:"revision,string"`
// Handler Worker 处理器名称。
Handler string `json:"handler"`
// HandlerVersion 受理时固定的处理器版本。
HandlerVersion string `json:"handler_version"`
// BusinessKey 可选业务关联键,不自动提供业务效果幂等。
BusinessKey string `json:"business_key,omitempty"`
// AvailableAt 最早可执行时间,UTC。
AvailableAt time.Time `json:"available_at"`
// DeadlineAt 任务总截止时间,UTC。
DeadlineAt time.Time `json:"deadline_at"`
// AttemptCount 已创建的尝试次数,包含首次尝试。
AttemptCount uint32 `json:"attempt_count"`
// CreatedAt 创建时间,UTC 微秒精度。
CreatedAt time.Time `json:"created_at"`
// UpdatedAt 最后变更时间,不能替代并发版本。
UpdatedAt time.Time `json:"updated_at"`
}
type TaskPage struct {
// Items 当前页任务摘要,不包含大体积参数与结果。
Items []TaskSummary `json:"items"`
// NextCursor 下一页游标;为空表示当前查询已无下一页。
NextCursor string `json:"next_cursor,omitempty"`
}
type QueueInput struct {
// Name 队列名称,同一命名空间内唯一。
Name string `json:"name"`
// MaxConcurrency 最大同时执行数量,供执行阶段使用。
MaxConcurrency uint32 `json:"max_concurrency"`
// RatePerSecond 每秒开始数量上限,供执行阶段使用。
RatePerSecond uint32 `json:"rate_per_second"`
}
type QueueView struct {
// ID 资源标识,32 位小写十六进制字符串。
ID core.ID `json:"id"`
// NamespaceID 命名空间标识,由凭据确定隔离范围。
NamespaceID core.ID `json:"namespace_id"`
// Name 队列名称,同一命名空间内唯一。
Name string `json:"name"`
// Paused 是否停止新执行;不禁止受理新任务。
Paused bool `json:"paused"`
// MaxConcurrency 最大同时执行数量,供执行阶段使用。
MaxConcurrency uint32 `json:"max_concurrency"`
// RatePerSecond 每秒开始数量上限,供执行阶段使用。
RatePerSecond uint32 `json:"rate_per_second"`
// Revision 并发版本,JSON 使用十进制字符串避免跨语言精度丢失。
Revision uint64 `json:"revision,string"`
}
type QueueResult struct {
// Queue 本次操作返回的队列快照。
Queue QueueView `json:"queue"`
// Deduplicated 是否复用了同一幂等身份的原始回执。
Deduplicated bool `json:"deduplicated"`
}
// Store 实现命名空间隔离、事务授权与持久化,不在事务外产生业务副作用。
type Store interface {
Submit(context.Context, auth.Token, string, SubmitInput) (SubmitResult, error)
GetTask(context.Context, auth.Token, core.ID) (TaskView, error)
ListTasks(context.Context, auth.Token, ListInput) (TaskPage, error)
CancelTask(context.Context, auth.Token, core.ID, string, CancelInput) (CancelResult, error)
CreateQueue(context.Context, auth.Token, string, QueueInput) (QueueResult, error)
GetQueue(context.Context, auth.Token, core.ID) (QueueView, error)
}

View File

@@ -33,7 +33,9 @@ export ORBIT_TEST_MYSQL_DSN="root:$TASK_DB_PASSWORD@tcp(127.0.0.1:$TASK_DB_PORT)
"$TASK_GO" build -o "$TASK_TEST_DIR/orbit" ./cmd/orbit "$TASK_GO" build -o "$TASK_TEST_DIR/orbit" ./cmd/orbit
"$TASK_GO" build -o "$TASK_TEST_DIR/orbit-migrate" ./cmd/orbit-migrate "$TASK_GO" build -o "$TASK_TEST_DIR/orbit-migrate" ./cmd/orbit-migrate
"$TASK_GO" build -o "$TASK_TEST_DIR/orbit-admin" ./cmd/orbit-admin
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-migrate" status ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-migrate" status
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application sdk-example --namespace default --environment test --queue files --output "$TASK_TEST_DIR/sdk.credentials.json"
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" ORBIT_HTTP_ADDR=127.0.0.1:0 "$TASK_TEST_DIR/orbit" > "$TASK_TEST_DIR/server.log" 2>&1 & ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" ORBIT_HTTP_ADDR=127.0.0.1:0 "$TASK_TEST_DIR/orbit" > "$TASK_TEST_DIR/server.log" 2>&1 &
TASK_SERVER_PID=$! TASK_SERVER_PID=$!
TASK_HTTP_PORT= TASK_HTTP_PORT=
@@ -45,3 +47,4 @@ for task_attempt in $(seq 1 20); do
done done
if [ -z "$TASK_HTTP_PORT" ]; then printf '%s\n' 'Bootstrap HTTP server did not start.' >&2; exit 1; fi if [ -z "$TASK_HTTP_PORT" ]; then printf '%s\n' 'Bootstrap HTTP server did not start.' >&2; exit 1; fi
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" "$TASK_GO" run ./examples/health) (cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" "$TASK_GO" run ./examples/health)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/sdk.credentials.json" "$TASK_GO" run ./examples/tasks)

View File

@@ -12,12 +12,15 @@ import (
"time" "time"
) )
// Options currently configures bootstrap health calls. Task, Worker and workflow // Options 配置传输与命名空间凭据;健康查询不使用凭据。
// clients will be added alongside their corresponding server endpoints. type Options struct {
type Options struct{ HTTPClient *http.Client } HTTPClient *http.Client
Token string
}
type Client struct { type Client struct {
baseURL string baseURL string
http *http.Client http *http.Client
token string
} }
type Health struct { type Health struct {
Status string `json:"status"` Status string `json:"status"`
@@ -34,6 +37,9 @@ func NewClient(baseURL string, opts Options) (*Client, error) {
if err != nil || u == nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" { if err != nil || u == nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return nil, errors.New("Orbit base URL must be absolute HTTP(S), without credentials, query or fragment") return nil, errors.New("Orbit base URL must be absolute HTTP(S), without credentials, query or fragment")
} }
if len(opts.Token) > 1024 || strings.ContainsAny(opts.Token, " \t\r\n") {
return nil, errors.New("invalid Orbit token")
}
client := opts.HTTPClient client := opts.HTTPClient
if client == nil { if client == nil {
client = &http.Client{Timeout: 10 * time.Second} client = &http.Client{Timeout: 10 * time.Second}
@@ -41,7 +47,7 @@ func NewClient(baseURL string, opts Options) (*Client, error) {
clone := *client clone := *client
// Do not silently forward requests to another origin through redirects. // Do not silently forward requests to another origin through redirects.
clone.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } clone.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return &Client{baseURL: strings.TrimRight(u.String(), "/"), http: &clone}, nil return &Client{baseURL: strings.TrimRight(u.String(), "/"), http: &clone, token: opts.Token}, nil
} }
func (c *Client) Liveness(ctx context.Context) (Health, error) { return c.health(ctx, "/health/live") } func (c *Client) Liveness(ctx context.Context) (Health, error) { return c.health(ctx, "/health/live") }
func (c *Client) Readiness(ctx context.Context) (Health, error) { func (c *Client) Readiness(ctx context.Context) (Health, error) {

View File

@@ -0,0 +1,87 @@
// tasks 演示受理、重复提交、查询、分页与取消;此示例不启动业务 Worker。
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
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 fmt.Errorf("read private credentials: %w", err)
}
var credentials struct {
Token string `json:"token"`
QueueID string `json:"queue_id"`
}
if err := json.Unmarshal(raw, &credentials); err != nil {
return fmt.Errorf("invalid credentials 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
}
if queue == "" {
return fmt.Errorf("ORBIT_QUEUE_ID is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var operation [16]byte
if _, err := rand.Read(operation[:]); err != nil {
return err
}
key := "example-" + hex.EncodeToString(operation[:])
// 生产接入应先在业务库保存稳定键;本示例的两次请求共用同一个键。
in := orbit.SubmitTaskRequest{QueueID: queue, Handler: "file.convert", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"source_ref":"example/input.txt","format":"pdf","sequence":9007199254740993}`)}
first, err := client.SubmitTask(ctx, key, in)
if err != nil {
return err
}
again, err := client.SubmitTask(ctx, key, in)
if err != nil {
return err
}
if first.Task.ID != again.Task.ID || !again.Deduplicated {
return fmt.Errorf("duplicate submission did not return the original task")
}
current, err := client.GetTask(ctx, first.Task.ID)
if err != nil {
return err
}
page, err := client.ListTasks(ctx, orbit.ListTasksRequest{QueueID: queue, Limit: 10})
if err != nil {
return err
}
cancelled, err := client.CancelTask(ctx, current.ID, key+"-cancel", orbit.CancelTaskRequest{Reason: "finish task acceptance example", ExpectedRevision: current.Revision})
if err != nil {
return err
}
fmt.Printf("task=%s duplicate=%t listed=%d final_state=%s revision=%d\n", current.ID, again.Deduplicated, len(page.Items), cancelled.Task.State, cancelled.Task.Revision)
if cancelled.Task.State != orbit.TaskCancelled {
return fmt.Errorf("expected CANCELLED")
}
return nil
}

161
sdk/go/tasks.go Normal file
View File

@@ -0,0 +1,161 @@
package orbit
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
)
// APIError 提供可分支处理的错误码和请求追踪标识,不包含内部数据库错误。
type APIError struct {
StatusCode int
Code string
Message string
RequestID string
}
func (e *APIError) Error() string {
return fmt.Sprintf("orbit %s (HTTP %d, request %s): %s", e.Code, e.StatusCode, e.RequestID, e.Message)
}
func (e *APIError) Unwrap() error { return &HTTPError{StatusCode: e.StatusCode} }
// SubmitTask 使用调用方持久化的幂等键;SDK 不自动生成新键或自动重试写请求。
func (c *Client) SubmitTask(ctx context.Context, key string, in SubmitTaskRequest) (SubmitTaskResult, error) {
var out SubmitTaskResult
err := c.call(ctx, "POST", "/v1/tasks", key, in, &out)
return out, err
}
func (c *Client) GetTask(ctx context.Context, id string) (Task, error) {
var out Task
if !validID(id) {
return out, errors.New("invalid task id")
}
err := c.call(ctx, "GET", "/v1/tasks/"+id, "", nil, &out)
return out, err
}
func (c *Client) ListTasks(ctx context.Context, in ListTasksRequest) (TaskPage, error) {
var out TaskPage
q := url.Values{}
q.Set("queue_id", in.QueueID)
if in.State != "" {
q.Set("state", string(in.State))
}
if in.Limit != 0 {
q.Set("limit", strconv.Itoa(in.Limit))
}
if in.Cursor != "" {
q.Set("cursor", in.Cursor)
}
err := c.call(ctx, "GET", "/v1/tasks?"+q.Encode(), "", nil, &out)
return out, err
}
// CancelTask 当前只取消 WAITING 状态且不归属流程的任务;重复请求使用原键与原版本。
func (c *Client) CancelTask(ctx context.Context, id, key string, in CancelTaskRequest) (CancelTaskResult, error) {
var out CancelTaskResult
if !validID(id) {
return out, errors.New("invalid task id")
}
err := c.call(ctx, "POST", "/v1/tasks/"+id+":cancel", key, in, &out)
return out, err
}
func (c *Client) CreateQueue(ctx context.Context, key string, in CreateQueueRequest) (CreateQueueResult, error) {
var out CreateQueueResult
err := c.call(ctx, "POST", "/v1/queues", key, in, &out)
return out, err
}
func (c *Client) GetQueue(ctx context.Context, id string) (Queue, error) {
var out Queue
if !validID(id) {
return out, errors.New("invalid queue id")
}
err := c.call(ctx, "GET", "/v1/queues/"+id, "", nil, &out)
return out, err
}
func validID(id string) bool {
if len(id) != 32 {
return false
}
for _, ch := range id {
if !(ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'f') {
return false
}
}
return true
}
func (c *Client) call(ctx context.Context, method, path, key string, in, out any) error {
if c.token == "" {
return errors.New("Orbit token is required for business APIs")
}
if method != "GET" {
if len(key) < 1 || len(key) > 128 {
return errors.New("a stable Idempotency-Key of 1 to 128 ASCII characters is required")
}
for _, ch := range key {
if ch < 33 || ch > 126 {
return errors.New("Idempotency-Key must not contain spaces or control characters")
}
}
}
var body []byte
var err error
if in != nil {
body, err = json.Marshal(in)
if err != nil {
return err
}
}
if len(body) > 512<<10 {
return errors.New("Orbit request exceeds 512 KiB")
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
response, err := c.http.Do(req)
if err != nil {
return err
}
defer response.Body.Close()
const maxResponse = 1 << 20
raw, err := io.ReadAll(io.LimitReader(response.Body, maxResponse+1))
if err != nil {
return err
}
if len(raw) > maxResponse {
return errors.New("Orbit response exceeds 1 MiB")
}
var envelope struct {
RequestID string `json:"request_id"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
decoded := json.Unmarshal(raw, &envelope)
if response.StatusCode < 200 || response.StatusCode >= 300 {
if decoded == nil && envelope.Error != nil && len(envelope.Error.Code) <= 64 && len(envelope.Error.Message) <= 1024 && validID(envelope.RequestID) {
return &APIError{response.StatusCode, envelope.Error.Code, envelope.Error.Message, envelope.RequestID}
}
return &HTTPError{StatusCode: response.StatusCode}
}
if decoded != nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" || envelope.Error != nil || !validID(envelope.RequestID) {
return errors.New("invalid Orbit response envelope")
}
return json.Unmarshal(envelope.Data, out)
}

72
sdk/go/tasks_test.go Normal file
View File

@@ -0,0 +1,72 @@
package orbit
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func TestTaskClientContractAndNoSilentRetry(t *testing.T) {
const id = "11111111111111111111111111111111"
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.Header.Get("Authorization") != "Bearer example-token" || r.Header.Get("Idempotency-Key") != "stable" {
t.Error("lost authorization or identity")
}
raw, _ := io.ReadAll(r.Body)
if !strings.Contains(string(raw), "9007199254740993") {
t.Error("payload lost integer precision")
}
if calls.Load() == 1 {
w.WriteHeader(503)
_, _ = io.WriteString(w, `{"request_id":"`+id+`","error":{"code":"UNAVAILABLE","message":"retry original identity"}}`)
return
}
_, _ = io.WriteString(w, `{"request_id":"`+id+`","data":{"deduplicated":true,"task":{"id":"`+id+`","revision":"9007199254740993","payload":{"sequence":9007199254740993}}}}`)
}))
defer srv.Close()
client, err := NewClient(srv.URL, Options{Token: "example-token"})
if err != nil {
t.Fatal(err)
}
request := SubmitTaskRequest{QueueID: id, Payload: json.RawMessage(`{"sequence":9007199254740993}`)}
_, err = client.SubmitTask(context.Background(), "stable", request)
var apiErr *APIError
var httpErr *HTTPError
if !errors.As(err, &apiErr) || apiErr.Code != "UNAVAILABLE" || apiErr.RequestID != id || !errors.As(err, &httpErr) || httpErr.StatusCode != 503 || calls.Load() != 1 {
t.Fatalf("error/retry contract: %v, calls=%d", err, calls.Load())
}
out, err := client.SubmitTask(context.Background(), "stable", request)
if err != nil || !out.Deduplicated || out.Task.Revision != 9007199254740993 || string(out.Task.Payload) != `{"sequence":9007199254740993}` {
t.Fatalf("response: %+v %v", out, err)
}
}
func TestBusinessClientRedirectAndPathBoundaries(t *testing.T) {
var reached atomic.Bool
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached.Store(true) }))
defer target.Close()
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, 307) }))
defer source.Close()
client, _ := NewClient(source.URL, Options{Token: "example-token"})
_, err := client.GetTask(context.Background(), strings.Repeat("1", 32))
var failure *HTTPError
if !errors.As(err, &failure) || failure.StatusCode != 307 || reached.Load() {
t.Fatalf("redirect leaked request: %v", err)
}
for _, id := range []string{"../queues", "abc?token=bad", strings.Repeat("A", 32)} {
if _, err := client.GetTask(context.Background(), id); err == nil {
t.Fatal("accepted unsafe ID")
}
}
missing, _ := NewClient(source.URL, Options{})
if _, err := missing.GetQueue(context.Background(), strings.Repeat("1", 32)); err == nil {
t.Fatal("missing token allowed")
}
}

200
sdk/go/types.go Normal file
View File

@@ -0,0 +1,200 @@
// Package orbit provides the standalone Orbit Go SDK.
package orbit
import (
"encoding/json"
"time"
)
// SubmitTaskRequest 是任务受理命令;可变配置在受理时固定为快照。
type SubmitTaskRequest struct {
// QueueID 目标队列,必须位于凭据所属命名空间。
QueueID string `json:"queue_id"`
// Handler 业务 Worker 的处理器名称。
Handler string `json:"handler"`
// HandlerVersion 固定处理器版本。
HandlerVersion string `json:"handler_version"`
// PayloadVersion 输入参数结构版本。
PayloadVersion string `json:"payload_version"`
// Payload 业务无关 JSON 文档,最大 256 KiB,保留精确数值文本。
Payload json.RawMessage `json:"payload"`
// BusinessKey 可选关联键,不提供业务效果幂等保证。
BusinessKey string `json:"business_key,omitempty"`
// AvailableAt 最早执行时间;为空表示受理后即可进入派发范围。
AvailableAt *time.Time `json:"available_at,omitempty"`
// StartDeadlineAt 最晚开始时间;为空表示使用任务总截止时间。
StartDeadlineAt *time.Time `json:"start_deadline_at,omitempty"`
// TaskTimeoutMS 从受理时计算的总时限,单位毫秒。
TaskTimeoutMS uint64 `json:"task_timeout_ms"`
// RecoverySafety 外部效果未知时的恢复安全等级。
RecoverySafety RecoverySafety `json:"recovery_safety"`
// Retry 每次执行的有限尝试策略,包含首次尝试。
Retry RetryPolicy `json:"retry"`
}
// Task 是对外任务快照,不包含 Worker 令牌或凭据摘要。
type Task struct {
// ID 资源标识,32 位小写十六进制字符串。
ID string `json:"id"`
// NamespaceID 命名空间标识,由凭据确定隔离范围。
NamespaceID string `json:"namespace_id"`
// QueueID 队列标识,必须属于当前命名空间。
QueueID string `json:"queue_id"`
// State 当前任务状态;终态不会被重复请求重开。
State TaskState `json:"state"`
// Revision 并发版本,JSON 使用十进制字符串避免跨语言精度丢失。
Revision uint64 `json:"revision,string"`
// Handler Worker 处理器名称。
Handler string `json:"handler"`
// HandlerVersion 受理时固定的处理器版本。
HandlerVersion string `json:"handler_version"`
// PayloadVersion 受理时固定的参数契约版本。
PayloadVersion string `json:"payload_version"`
// Payload 业务无关 JSON 输入,保留数字精度。
Payload json.RawMessage `json:"payload"`
// Result 业务 JSON 结果;未取得结果时为空并省略。
Result json.RawMessage `json:"result,omitempty"`
// BusinessKey 可选业务关联键,不自动提供业务效果幂等。
BusinessKey string `json:"business_key,omitempty"`
// AvailableAt 最早可执行时间,UTC。
AvailableAt time.Time `json:"available_at"`
// StartDeadlineAt 最晚开始时间;为空时使用任务总截止时间。
StartDeadlineAt *time.Time `json:"start_deadline_at,omitempty"`
// DeadlineAt 任务总截止时间,UTC。
DeadlineAt time.Time `json:"deadline_at"`
// RecoverySafety 未知外部效果的恢复安全等级。
RecoverySafety RecoverySafety `json:"recovery_safety"`
// Retry 受理时冻结的尝试次数与时限策略。
Retry RetryPolicy `json:"retry"`
// AttemptCount 已创建的尝试次数,包含首次尝试。
AttemptCount uint32 `json:"attempt_count"`
// CancelRequestedAt 取消意图时间;为空表示未请求取消。
CancelRequestedAt *time.Time `json:"cancel_requested_at,omitempty"`
// CompletedAt 终结时间;非终态为空。
CompletedAt *time.Time `json:"completed_at,omitempty"`
// CreatedAt 创建时间,UTC 微秒精度。
CreatedAt time.Time `json:"created_at"`
// UpdatedAt 最后变更时间,不能替代并发版本。
UpdatedAt time.Time `json:"updated_at"`
}
type SubmitTaskResult struct {
// Task 本次操作的提交回执快照;当前状态应另行查询。
Task Task `json:"task"`
// Deduplicated 是否复用了同一幂等身份的原始回执。
Deduplicated bool `json:"deduplicated"`
}
type CancelTaskRequest struct {
// Reason 管理操作原因,写入审计历史。
Reason string `json:"reason"`
// ExpectedRevision 调用者预期的资源版本,JSON 为十进制字符串。
ExpectedRevision uint64 `json:"expected_revision,string"`
}
type CancelTaskResult struct {
// Task 本次操作的提交回执快照;当前状态应另行查询。
Task Task `json:"task"`
// Deduplicated 是否复用了同一幂等身份的原始回执。
Deduplicated bool `json:"deduplicated"`
}
type ListTasksRequest struct {
// QueueID 队列标识,必须属于当前命名空间。
QueueID string
// State 当前任务状态;终态不会被重复请求重开。
State TaskState
// Limit 分页大小,默认 20,上限 100。
Limit int
// Cursor 上页返回的游标,必须复用相同查询条件。
Cursor string
}
// TaskSummary 用于列表,避免在分页中重复传输大参数和结果。
type TaskSummary struct {
// ID 资源标识,32 位小写十六进制字符串。
ID string `json:"id"`
// QueueID 队列标识,必须属于当前命名空间。
QueueID string `json:"queue_id"`
// State 当前任务状态;终态不会被重复请求重开。
State TaskState `json:"state"`
// Revision 并发版本,JSON 使用十进制字符串避免跨语言精度丢失。
Revision uint64 `json:"revision,string"`
// Handler Worker 处理器名称。
Handler string `json:"handler"`
// HandlerVersion 受理时固定的处理器版本。
HandlerVersion string `json:"handler_version"`
// BusinessKey 可选业务关联键,不自动提供业务效果幂等。
BusinessKey string `json:"business_key,omitempty"`
// AvailableAt 最早可执行时间,UTC。
AvailableAt time.Time `json:"available_at"`
// DeadlineAt 任务总截止时间,UTC。
DeadlineAt time.Time `json:"deadline_at"`
// AttemptCount 已创建的尝试次数,包含首次尝试。
AttemptCount uint32 `json:"attempt_count"`
// CreatedAt 创建时间,UTC 微秒精度。
CreatedAt time.Time `json:"created_at"`
// UpdatedAt 最后变更时间,不能替代并发版本。
UpdatedAt time.Time `json:"updated_at"`
}
type TaskPage struct {
// Items 当前页任务摘要,不包含大体积参数与结果。
Items []TaskSummary `json:"items"`
// NextCursor 下一页游标;为空表示当前查询已无下一页。
NextCursor string `json:"next_cursor,omitempty"`
}
type CreateQueueRequest struct {
// Name 队列名称,同一命名空间内唯一。
Name string `json:"name"`
// MaxConcurrency 最大同时执行数量,供执行阶段使用。
MaxConcurrency uint32 `json:"max_concurrency"`
// RatePerSecond 每秒开始数量上限,供执行阶段使用。
RatePerSecond uint32 `json:"rate_per_second"`
}
type Queue struct {
// ID 资源标识,32 位小写十六进制字符串。
ID string `json:"id"`
// NamespaceID 命名空间标识,由凭据确定隔离范围。
NamespaceID string `json:"namespace_id"`
// Name 队列名称,同一命名空间内唯一。
Name string `json:"name"`
// Paused 是否停止新执行;不禁止受理新任务。
Paused bool `json:"paused"`
// MaxConcurrency 最大同时执行数量,供执行阶段使用。
MaxConcurrency uint32 `json:"max_concurrency"`
// RatePerSecond 每秒开始数量上限,供执行阶段使用。
RatePerSecond uint32 `json:"rate_per_second"`
// Revision 并发版本,JSON 使用十进制字符串避免跨语言精度丢失。
Revision uint64 `json:"revision,string"`
}
type CreateQueueResult struct {
// Queue 本次操作返回的队列快照。
Queue Queue `json:"queue"`
// Deduplicated 是否复用了同一幂等身份的原始回执。
Deduplicated bool `json:"deduplicated"`
}
// TaskState 是服务端任务状态;未知状态应保留供升级兼容判断。
type TaskState string
const (
TaskWaiting TaskState = "WAITING"
TaskLeased TaskState = "LEASED"
TaskRunning TaskState = "RUNNING"
TaskNeedsReview TaskState = "NEEDS_REVIEW"
TaskSucceeded TaskState = "SUCCEEDED"
TaskFailed TaskState = "FAILED"
TaskCancelled TaskState = "CANCELLED"
)
// RecoverySafety 声明业务处理器面对未知外部效果时是否可以安全重入。
type RecoverySafety string
const (
RecoveryReconcileOnly RecoverySafety = "RECONCILE_ONLY"
RecoveryIdempotent RecoverySafety = "IDEMPOTENT"
)
// RetryPolicy 是有限执行策略;单位为毫秒,次数包含首次尝试。
type RetryPolicy struct {
MaxAttempts uint32 `json:"max_attempts"` // MaxAttempts 总尝试次数上限。
InitialDelayMS uint64 `json:"initial_delay_ms"` // InitialDelayMS 首次重试间隔。
MaxDelayMS uint64 `json:"max_delay_ms"` // MaxDelayMS 间隔上限。
AttemptTimeoutMS uint64 `json:"attempt_timeout_ms"` // AttemptTimeoutMS 单次尝试时限。
}