commit 4f2935e36f0a68026dca121210408d6502c79a28 Author: k2safe Date: Thu Sep 17 07:04:21 2026 +0800 feat: establish Orbit models, migrations and development baseline diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c662341 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.go] +indent_style = tab + +[*.{md,sql,yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6f40f11 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Example only. Load environment variables explicitly; binaries do not read .env. +ORBIT_HTTP_ADDR=127.0.0.1:8080 +# Set a real DSN through your secret manager. No credentials are committed. +ORBIT_MYSQL_DSN= +ORBIT_DB_MAX_OPEN=20 +ORBIT_DB_MAX_IDLE=5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..142ac07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/bin/ +/coverage.out +.env +.env.* +!.env.example +.DS_Store +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..54ad8f1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# Orbit 开发约定 + +- Orbit 是通用平台。代码、文档和示例不得包含接入方项目名称或行业定制规则。 +- 服务端分为公共能力、Pulse 与 Weave;SDK 是独立 Go module,不引用服务端模型或存储实现。 +- 新增或修改数据模型必须提供中文注释,覆盖模型职责及每个持久化字段的含义。明确单位、可空语义、版本/幂等/租约/预算等约束,避免仅重复字段名。 +- SQL 数据表和字段同步维护中文 COMMENT;已应用的迁移不得回改,后续演进新增前向迁移。 +- 状态变更、租约、门控、许可、事件及审计必须按设计的事务边界实现,不用内存状态代替持久化确认。 +- 研发任务持续更新 docs/08-development-plan.md。接口、SDK、可运行示例和对应回归随功能同步交付。 +- 模型/迁移变更运行真实 MySQL 集成测试;执行和恢复变更覆盖竞争、重复、迟到及故障场景。以实际测试报告判断完成,不把设计文档当作已实现能力。 +- 编译和基础回归入口是 make test、make vet、make build;隔离数据库端到端回归入口是 make integration。 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..97507b3 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +GO ?= go + +.PHONY: fmt test vet build integration +fmt: + $(GO) fmt ./... + cd sdk/go && $(GO) fmt ./... + +test: + $(GO) test -race ./... + cd sdk/go && $(GO) test ./... + +vet: + $(GO) vet ./... + cd sdk/go && $(GO) vet ./... + +build: + $(GO) build -o bin/orbit ./cmd/orbit + $(GO) build -o bin/orbit-migrate ./cmd/orbit-migrate + +integration: + ./scripts/test-mysql.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..b80d532 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Orbit · 星轨 + +Orbit 是面向多个业务项目的通用任务调度与流程编排平台,采用 **Go + MySQL + HTTP/WebSocket**。 + +- **Pulse · 脉动**:异步任务、定时调度、执行授权与安全重试。 +- **Weave · 织流**:研发定义发布流程,管理步骤、等待、有界查询与恢复。 +- 业务处理器运行在接入方 Worker 中;平台不内置行业规则。 + +## 当前进度 + +已建立基础 Go 骨架、30 个数据模型、MySQL 初始迁移、事务工具、HTTP 健康检查与独立 SDK 健康客户端。任务 API、WSS Worker、调度器和流程执行引擎尚未实现,按 [持续开发计划](docs/08-development-plan.md) 推进。 + +当前健康接口的 readiness 只表示数据库与迁移记录可用,不表示完整平台已具备生产服务能力。验证证据见 [P0 报告](docs/reports/phase-0.md)。 + +## 目录 + +```text +cmd/orbit/ 基础 HTTP 服务入口 +cmd/orbit-migrate/ 独立数据库迁移命令 +model/ 公共 ID、JSON、摘要与记录原语 +platform/model/ 应用、命名空间、幂等、事件和审计 +pulse/model/ 任务、尝试、队列、计划和执行许可 +weave/model/ 定义、实例、步骤、信号和查询轮次 +internal/config/ 配置校验 +internal/storage/mysql/ 连接、事务、迁移及集成测试 +internal/transport/httpserver/ HTTP 接入 +sdk/go/ 独立 Go SDK module 与示例 +scripts/ 可复现的回归工具 +docs/ 设计、接口、SDK、场景与验证报告 +``` + +## 本地运行 + +要求 Go 1.27.1、MySQL 8.4+。先准备专用数据库和凭据,通过环境变量注入;程序不会自动读取 `.env` 文件。 + +```sh +export ORBIT_MYSQL_DSN='user:password@tcp(127.0.0.1:3306)/orbit' +export ORBIT_HTTP_ADDR='127.0.0.1:8080' +go run ./cmd/orbit-migrate up +go run ./cmd/orbit +``` + +上方 DSN 为本地占位示例,需替换为实际凭据。部署时使用秘密管理和经过验证的 TLS 配置;迁移账户与运行账户分开授权。运行程序不自动迁移数据库。 + +另一个终端执行 SDK 示例: + +```sh +cd sdk/go +ORBIT_URL=http://127.0.0.1:8080 go run ./examples/health +``` + +## 开发与回归 + +```sh +make fmt +make test +make vet +make build +make integration +``` + +`make integration` 要求 Docker、OpenSSL 等标准命令,启动独立的临时 MySQL 容器,使用随机凭据和回环随机端口;完成后删除该测试容器。不会连接已有业务数据库。可通过 `GO`、`DOCKER` 和 `ORBIT_TEST_MYSQL_IMAGE` 指定工具/测试镜像。 + +修改已应用迁移时会触发校验和拒绝;新增结构使用新的前向迁移。初始结构尚未部署生产。 + +## 文档入口 + +- [持续开发与回归计划](docs/08-development-plan.md) +- [数据模型与约束](docs/07-data-model.md) +- [HTTP / WebSocket 接口手册](docs/api/README.md) +- [Go SDK 接入](docs/sdk/README.md) +- [通用场景示例](docs/examples/README.md) +- [设计文档总览](docs/README.md) + +Git 远端:`https://git.yuntuo.hk/k2safe/orbit.git`。当前在本地开发分支推进,SDK 尚未发布版本。 diff --git a/cmd/orbit-migrate/main.go b/cmd/orbit-migrate/main.go new file mode 100644 index 0000000..2fd64ba --- /dev/null +++ b/cmd/orbit-migrate/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "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] != "up" && os.Args[1] != "status" { + return errors.New("usage: orbit-migrate up|status (ORBIT_MYSQL_DSN required)") + } + cfg, err := config.Load() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + db, err := mysql.Open(ctx, cfg.MySQLDSN, 2, 1) + if err != nil { + return err + } + defer db.Close() + if os.Args[1] == "up" { + if err := mysql.Migrate(ctx, db); err != nil { + return err + } + } + status, err := mysql.Status(ctx, db) + if err != nil { + return err + } + return json.NewEncoder(os.Stdout).Encode(status) +} diff --git a/cmd/orbit/main.go b/cmd/orbit/main.go new file mode 100644 index 0000000..fea81f8 --- /dev/null +++ b/cmd/orbit/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.yuntuo.hk/k2safe/orbit/internal/config" + "git.yuntuo.hk/k2safe/orbit/internal/storage/mysql" + "git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver" +) + +func main() { + if err := run(); err != nil { + slog.Error("orbit stopped", "error", err) + os.Exit(1) + } +} +func run() error { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + cfg, err := config.Load() + if err != nil { + return err + } + root, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + startup, cancel := context.WithTimeout(root, 10*time.Second) + db, err := mysql.Open(startup, cfg.MySQLDSN, cfg.MaxOpen, cfg.MaxIdle) + cancel() + if err != nil { + return err + } + defer db.Close() + server := httpserver.New(cfg.HTTPAddr, func(ctx context.Context) error { + if err := db.PingContext(ctx); err != nil { + return err + } + return mysql.CheckSchema(ctx, db) + }) + listener, err := net.Listen("tcp", cfg.HTTPAddr) + if err != nil { + return err + } + done := make(chan error, 1) + go func() { done <- server.Serve(listener) }() + slog.Info("Orbit bootstrap HTTP server starting", "address", listener.Addr().String(), "scope", "health endpoints only") + select { + case err := <-done: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + case <-root.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdown); err != nil { + _ = server.Close() + return err + } + err := <-done + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} diff --git a/docs/01-functional-spec.md b/docs/01-functional-spec.md new file mode 100644 index 0000000..149897a --- /dev/null +++ b/docs/01-functional-spec.md @@ -0,0 +1,135 @@ +# Orbit 功能说明:Pulse 与公共能力 + +> 版本 0.2 · 2026-09-17。Pulse 与 Weave 首版功能范围已确认;标注“建议”的细节待评审。Weave 具体范围另见流程设计。基础骨架已实现,任务业务能力仍待开发;实现状态见持续开发计划。 + +## 1. 目标与责任边界 + +Orbit 统一提供任务调度与流程编排。Pulse 接收一个明确的工作单元,按照时间、容量、权限和重试策略交给业务 Worker 执行,管理可查询、可恢复的执行过程。Weave 将多个工作单元按流程规则连接起来,具体方案见 [流程设计](06-workflow-spec.md)。 + +| 参与方 | 职责 | +| --- | --- | +| Pulse | 任务受理、调度、派发、执行权、重试控制、运行记录、结果交付 | +| 业务系统 / Worker | 业务校验、业务授权、外部调用、业务幂等、业务结果与证据 | +| Weave(内部模块) | 研发定义发布、多步骤依赖、分支、汇合、持久化等待、有界重复查询、暂停/继续、人工恢复与流程版本 | +| MySQL 运维 | 数据持久化与复制配置、备份、故障切换、恢复演练 | + +任务可以是一次文件转换、一次数据准备或一次外部作业结果查询。Pulse 管理任务执行过程,具体业务结果和完成条件由处理器契约定义。 + +任务类型、参数和标识采用通用模型,不要求平台理解接入方的专用业务概念。 + +## 2. 基本概念 + +| 概念 | 含义 | +| --- | --- | +| 应用 / 环境 / 命名空间 | 接入身份与资源边界;建议一个应用环境对应一个命名空间 | +| 队列 | 任务路由、资源配置与 Worker 消费范围 | +| 任务类型与版本 | 处理器身份及其兼容版本 | +| 任务 | 一项已受理工作,具有稳定的任务 ID | +| 执行尝试 | 一次派发与执行过程,具有独立尝试 ID 和执行凭据 | +| 调度计划 | 产生任务的时间规则,每次实际触发产生独立任务 | +| 业务关联键 | 由业务方提供的查找、追踪标识 | +| 并发控制键 | 在规定作用域内限制同时有效执行许可的通用标识 | + +自动重试沿用任务 ID,产生新的执行尝试。HTTP 请求重试、任务重试和定时计划的下一次触发是三件不同的事情。 + +Weave 有界查询的一轮对应一个 Pulse 任务;查询得到“处理中”后,Weave 等待并创建下一轮任务。当前轮请求失败由 Pulse 在原任务下重试,不能通过开启新轮次绕过重试上限。 + +## 3. 任务提交与管理 + +### 3.1 提交方式 + +- **立即执行**:受理后尽快进入可派发范围,实际开始时间受资源和排队影响。 +- **延迟执行**:在指定等待时长之后才允许开始。 +- **指定时间执行**:在指定时间之后才允许开始,不承诺硬实时触发。 +- **批量提交**:多项普通任务的提交方式;建议逐项返回结果,每项独立幂等,失败项独立补交。 + +任务至少说明:归属、队列、处理器与版本、参数版本、执行时间、时限、重试规则和提交幂等标识。已受理参数与生效策略保存快照;修改配置不静默改写历史任务。 + +最晚允许开始时间、单次执行超时、任务总时限分别定义。到期后停止授权新的执行;已经发出的业务请求是否产生效果,需要按处理器的结果确认规则判断。 + +### 3.2 操作行为 + +| 操作 | 目标行为 | +| --- | --- | +| 幂等提交 | 同作用域、同标识、同语义请求返回原任务;同标识不同请求明确冲突 | +| 查询 | 返回当前任务状态、关联计划、执行尝试、错误与结果引用 | +| 取消待执行任务 | 在与开始执行的竞争中原子决定取消是否生效 | +| 取消执行中任务 | 持久化取消意图并通知 Worker,等待停止或最终结果,已有业务效果不自动撤销 | +| 人工重试 | 校验权限、错误类别及业务确认条件,保留原历史,记录操作人和原因 | +| 修改业务参数 | 新建关联任务,原任务参数不变 | + +**设计建议:人工重试创建带 `retry_of` 的新任务。** 原任务保留终态及结果,避免外部订阅方已经消费终态后任务又悄悄恢复。业务幂等键应按原操作语义保留;平台新任务 ID 不代表允许重复产生业务副作用。此项在协议冻结前确认。 + +流程关联任务的人工恢复统一经过 Weave,校验流程版本、步骤归属和当前恢复条件;不能直接从普通任务重试入口生成无人接续的流程任务。 + +流程暂停的门控建议由 Pulse 通用执行控制承载,阻止关联任务的新领取、开始和执行重试,同时继续收集已授权执行的结果。它与定时计划暂停、队列暂停分别建模;具体规则见 [Weave 暂停与继续](06-workflow-spec.md#71-暂停与继续)。 + +任务成功表示处理器完成约定的这一步。例如“渠道提交已受理”可以是一个处理器的成功结果,最终业务成功应由查询或回执处理任务另行确认。 + +## 4. 定时计划 + +- 支持 Cron、固定间隔、时区、生效与结束边界、启用、暂停、恢复和修改计划。 +- **设计建议**:首版采用五段 Cron;固定间隔首先定义为按基准时间推进的 fixed-rate。是否增加“完成后再等待”的 fixed-delay 待确认。 +- 每个实际触发点具有唯一身份,记录采用的计划版本与原计划时间。 +- 计划变更明确生效边界;已生成任务保留原参数与策略快照。 +- 暂停计划阻止新的触发,不隐式取消已经生成或正在执行的任务。 + +创建计划时必须明确以下策略,不能在故障后临时猜测: + +| 情况 | 可选策略及限制 | +| --- | --- | +| 漏跑 | 跳过、合并为一次、按次补跑;补跑必须有数量/时间窗口与速率上限 | +| 上一轮未完成 | 允许并行、串行等待、跳过本次;串行等待也必须有积压上限 | +| 时区夏令时变化 | 明确不存在时刻、重复时刻的处理,首版选定并测试后发布 | +| 停机期间修改计划 | 保存版本与生效边界,按明确的历史版本计算漏跑 | + +跳过和合并需留下原因、原计划时间或覆盖范围。业务日历由业务系统决定,Pulse 执行其提交的时间计划。 + +## 5. Worker 与执行恢复 + +- Worker 通过 WSS 主动连接,经过鉴权后声明处理器版本、消费队列及执行容量。 +- SDK 封装消息收发、任务去重、租约续期、结果重报、带抖动的重连和优雅退出。 +- 同一 Worker 可执行多个任务,网络读写不能被业务处理器阻塞。 +- 任务只分配给版本兼容且有容量的 Worker;无兼容 Worker 时可观测地等待,并受时限约束。 +- 连接心跳检测通信;任务续租确认具体执行权,两者独立。 +- 断线后先核对任务,不直接认定业务失败;无有效租约时 Worker 应停止推进并尝试取消可取消的操作。 +- 优雅退出时停止接新任务,给已有任务留出受控完成时间,退出超时按恢复规则处理。 + +错误至少区分:可安全重试、明确不可重试、需要外部核查、需要人工处理。策略包含退避、抖动、尝试上限或总时限,禁止无限快速重试。 + +副作用是否发生不明确时,不允许因为“网络错误”自动重复提交业务操作。任务类型必须声明恢复安全等级;声明缺失时采用保守处理。 + +## 6. 队列与资源控制 + +- 支持队列并发、应用配额、速率限制、优先级和项目间公平性。 +- 支持按业务提供的并发控制键限制有效执行许可。例如可用资源标识限制同一资源的一类操作。 +- 跨节点限制必须由共享状态协调,单节点内存计数只能约束该节点。 +- 并发控制不自动提供跨任务严格顺序;需要顺序的业务必须明确顺序协议或交由流程编排。 +- 任务数量、参数大小、发送缓冲、事件积压和重试均有有限上限,并提供拒绝/背压行为。 + +过期 Worker 仍可能在物理上运行。业务资源端还需幂等或执行代次校验,避免旧执行产生额外效果。 + +## 7. 多项目、安全与管理 + +支持应用与环境隔离、可轮换凭据、队列级提交和消费授权、查询权限与管理权限。命名空间和 Worker 标识不能替代身份认证。 + +管理操作包括暂停队列派发、调整配额、取消、人工重试和计划变更。都应记录操作人、时间、原因和前后状态。暂停派发不等于强制停止运行中的处理器。 + +参数、结果与日志设置访问控制和脱敏规则。大文件使用业务侧引用;凭据不作为普通任务参数或日志传递。 + +## 8. 查询、审计、告警与结果交付 + +- 通过任务 ID、业务关联键、状态、队列、时间范围查找任务。 +- 保存执行尝试和关键状态转换,关联 Worker、版本、错误分类与结果引用。 +- 监控任务积压、到期派发延迟、重试、失联、数据库异常、结果通知积压和资源用量。 +- 提供可靠结果订阅:事件持久化,接收方处理后确认,未确认可再次交付。 +- 运行统计可最终一致;影响派发与终态判断的状态必须按权威状态读取。 +- Orbit 运行审计与业务审计通过关联标识衔接,保存策略分别制定。 + +## 9. 首版范围与后续扩展 + +上述生产相关行为属于首版设计范围。首版首先交付 Go SDK、管理 API 与必要运行指标。完整图形化控制台、其他语言 SDK、高级全文检索及跨地域主动写入作为后续扩展评估。 + +流程依赖、等待及有界查询由平台内的 Weave 承担,已确认范围见 [流程功能与设计](06-workflow-spec.md)。审批人员与业务权限规则、专用规则计算由业务系统承担。通用 SQL 查询平台、文件处理平台不纳入 Orbit。 + +功能是否达标以 [生产验收](05-production-acceptance.md) 的结果为依据,不以接口存在或演示运行成功代替。 diff --git a/docs/02-technical-design.md b/docs/02-technical-design.md new file mode 100644 index 0000000..30874bf --- /dev/null +++ b/docs/02-technical-design.md @@ -0,0 +1,143 @@ +# Orbit 技术方案 + +> 版本 0.2 · 2026-09-17。Orbit 统一项目内保留 Pulse 与 Weave 两个模块。本文描述总体技术方案;基础部分已实施,执行引擎尚未完成。 + +## 1. 技术基线 + +| 项目 | 决策或建议 | +| --- | --- | +| 项目形态 | Orbit 通用平台,Pulse 负责任务与时间调度,Weave 负责流程编排 | +| 语言 | 当前基础使用 Go 1.27.1 | +| 接入 | HTTP + WebSocket,JSON;生产使用 HTTPS / WSS | +| 存储 | MySQL 8.4+,保存两个模块的权威状态;生产高可用拓扑待实际部署验收 | +| 服务端结构 | 核心应用逻辑独立于 HTTP / WS 适配层;建议显式依赖装配 | +| 数据访问 | 当前采用显式 SQL、普通 Go 模型、短事务;未引入 ORM | +| SDK | 首版 Go SDK,统一连接配置与鉴权,分别暴露任务与流程能力 | +| 必需外部组件 | MySQL;首版运行不依赖独立 Redis、消息队列或协调服务 | +| 运维输出 | 结构化日志、健康接口、指标与追踪接口;可接入既有监控系统 | + +基础实现已选择 MySQL 8.4+,本轮在 8.4.10 测试。HTTP 与 WS 可复用成熟 Go 库;基础库被编译进程序不构成新增独立中间件服务。 + +## 2. 模块边界 + +| 模块 | 拥有的数据与行为 | +| --- | --- | +| 公共接入与治理 | 身份、环境、权限、协议、限额、公共审计与可观测接口 | +| Pulse | 任务、尝试、租约、队列额度、定时计划、执行结果和任务恢复 | +| Weave | 流程定义与版本、实例、步骤、分支、等待、信号、查询轮次、暂停与流程恢复 | + +Weave 通过 Pulse 的应用层接口创建或取消任务。Pulse 的任务内核只保存通用关联信息,不理解行业业务或特定流程图。 + +两个模块可以共享一次事务,但不能任意直接修改对方的表。由应用服务显式传递事务上下文,在各自模块内完成约束校验。公共事务协调代码不应演变为包含所有业务规则的大对象。 + +执行一次任务的自动重试由 Pulse 管理;Weave 根据步骤最终结果决定下一步、等待处理或结束。Weave 不为同一执行失败再偷偷启动一套叠加重试循环。 + +当前 `pulse/model/` 与 `weave/model/` 已包含模型,服务入口、SDK 与迁移结构见 [数据模型基线](07-data-model.md)。执行引擎与业务接口按开发计划推进,模块边界通过接口与数据所有权保持。 + +## 3. 运行结构 + +```mermaid +flowchart LR + B[业务系统] -->|HTTPS 提交与管理| H[HTTP 接入] + W[业务 Worker / SDK] <-->|WSS 双向通信| G[WS 会话网关] + H --> P[Pulse 任务与调度] + H --> F[Weave 流程编排] + F -->|模块接口创建步骤任务| P + G --> P + P --> D[(MySQL 权威状态)] + F --> D + D --> R[恢复与内部事件处理] + R --> P + R --> F +``` + +首版建议同一个服务程序包含 HTTP、WS、调度、派发、恢复、流程推进及事件交付等角色,多实例运行。配置预留角色启停,后续可按负载拆运行进程;拆进程不要求先拆仓库或协议体系。 + +业务处理器留在 接入方业务项目的 Worker 中。平台内部定时唤醒等内置处理器仍需遵守任务状态、幂等和资源边界,不通过直接改业务状态绕过机制。 + +## 4. 数据与内存的分工 + +- MySQL 保存权威任务状态、授权租约、流程检查点、计划进度、幂等与待交付事件。 +- 内存保存 WS 连接、候选任务引用、近期时间索引及可重建的统计。 +- 内存唤醒只减少等待;遗漏唤醒必须能通过有界、索引化的数据库检查恢复。 +- 领取、续租、完成及流程推进等决策访问权威写入节点;不能基于滞后的只读副本授予执行权。 +- 关键状态落库后才确认成功。仅把日志写入数据库、把执行事实长期留在内存,不满足该设计。 + +MySQL 的事务与行锁提供并发更新基础,但任务状态机和幂等语义仍由平台实现。[MySQL 事务模型](https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-model.html) + +## 5. 多实例派发与 WS 归属 + +每个 Worker 的连接由当前接入节点管理。该节点根据本地连接的授权队列、兼容版本与容量,按需领取任务并持久化分配,然后通过本地连接发送。 + +提交请求可能进入 A 节点,而可执行 Worker 连接在 B 节点。B 通过共享 MySQL 中的待执行记录发现任务;首版不要求另设消息总线来转发节点间通知。 + +领取采用有界批次、明确索引与短事务,可评估 `SKIP LOCKED` 或条件更新。发送 WS 消息、等待确认与执行业务均在事务之外。 + +数据库检查由平台按活跃队列/分区统一调度,不能为每一个空闲连接都启动高频数据库扫描。跨节点发现延迟进入调度延迟指标,不能宣称 WS 消除了数据库轮询。 + +节点失效后 Worker 重连其他节点,先依据 MySQL 核对会话与租约,再续接或停止旧尝试;不依赖负载均衡的粘性会话保证正确性。 + +## 6. 定时与流程推进 + +Pulse 扫描有索引的到期计划或任务,受控地产生可执行工作。近期时间索引可以缓存在内存,进程重启后按计划进度重建。 + +Weave 与 Pulse 同库时利用两个明确的事务边界: + +1. **步骤任务创建事务**:更新流程/步骤状态,并通过 Pulse 接口创建下一步任务,一起提交。 +2. **任务结果接续**:Pulse 保存终态时同时保存内部结果事件;Weave 可异步消费该事件,在一个新事务内更新步骤、推进流程、创建后续任务并标记事件已处理。 + +此处不需要跨服务 RPC 或外置 MQ。事件仍必须持久化并可去重,防止进程在任务完成后、流程推进前宕机造成停滞。 + +Weave 的信号、等待、有界查询、暂停、取消和人工恢复细节见 [流程设计](06-workflow-spec.md)。进入待核查状态也必须产生内部状态事件,不能只有任务终态才能让流程感知异常。 + +有界查询通过“查询任务 → 保存处理中结果 → 持久化等待 → 下一轮任务”推进。轮次与任务创建原子提交,单轮请求的自动重试仍归 Pulse;等待不占用业务 Worker。查询总时限、轮次数与单轮尝试组合都有限制。 + +暂停建议通过共享事务更新流程控制版本和 Pulse 的通用执行门控;业务任务领取、开始及后续尝试必须校验门控。控制门限与领取竞争采用一致的锁定/版本协议,单次读取后脱离事务决定开始不满足要求。Pulse 不需要读取流程图或理解业务含义。已授权执行、内部结果收集与到期记录继续进行,恢复所需推进记录必须持久化。 + +流程定义和分支计算不能在长数据库事务里等待外部调用。一次推进设工作量上限;大规模并行展开分批持久化,使用检查点恢复。 + +## 7. 并发、限流与扩展 + +- 根据队列或稳定路由键建立逻辑分区;队列数与租户数分开,不能默认一个租户创建一个平台队列或一条连接。 +- 使用短事务协调跨节点的有效并发额度和同业务键执行许可,明确获取与释放顺序。 +- 一条连接慢或一个应用积压时,通过有界缓冲、配额与公平调度控制影响。 +- 心跳续租可按批发送,但逐项校验身份;批量减少通信或事务开销,不取消可靠持久化要求。 +- 活跃状态和长期历史分开组织,归档、扫描及补跑都有资源预算。 +- 先通过基准与故障测试验证容量,再决定物理分库。物理分片前需明确流程与步骤任务的同事务路由范围。 + +首版不承诺全局 FIFO、跨地域多活写入或无限队列容量。严格顺序应通过明确的业务序号与流程依赖表达,不能从 WS 单连接传输顺序推导出来。 + +## 8. SDK 与业务接入 + +建议 SDK 独立 Go module,避免把服务端 SQL、迁移和存储依赖带入业务项目。服务端实现和 SDK 使用同一份协议规范。 + +目标 SDK 能力包括:任务/流程客户端、Worker 运行组件、连接恢复、执行上下文、结果确认、追踪传播,以及可选的业务 Outbox 适配层。 + +已确认由研发通过 Go SDK 描述并发布流程。建议 SDK 生成版本化 JSON 定义,服务端校验并保存不可变版本;流程发布与实例启动是独立操作。定义构建 API 不上传业务闭包或要求 Orbit 执行业务 Go 源码,完整接口形式仍待评审。 + +业务处理器接收任务身份、参数、取消上下文及当前执行信息。接入文档必须解释副作用幂等、结果未知、续租失败与停止行为,不能让 SDK 的自动重试掩盖这些责任。 + +## 9. 生产部署与数据安全 + +建议至少部署两个平台实例用于应用层故障接管,放在适当的故障域。MySQL 必须采用明确的高可用与备份方案;其具体拓扑和数据确认语义单独验收。 + +MySQL 的 InnoDB Cluster 等成熟方案可以提供复制与故障切换,但配置、维护与恢复责任仍需要落实。[MySQL 高可用说明](https://dev.mysql.com/doc/refman/8.4/en/mysql-innodb-cluster-introduction.html) + +数据库不可用时,停止新的受理确认、执行授权与流程状态推进;续租未确认的 Worker 按已确认租约边界处理。不能切换到只有内存确认的临时模式。 + +发布需排空连接、保留兼容的旧处理器版本、按步骤迁移数据库;禁止多实例启动时无协调地自动修改表结构。备份恢复需要核对已经发生的外部业务效果。 + +具体容量、消息大小、扫描周期、心跳、租约、连接上限与恢复时限由 [生产验收](05-production-acceptance.md) 给出测试证据后确定。 + +## 10. 工程规范建议 + +以下作为实施前的工程基线草案,与具体框架、路由库的选择分开讨论: + +- **依赖方向**:协议适配层调用应用服务,应用服务协调领域规则与存储接口;核心状态机不依赖 HTTP / WS 对象。Pulse 不反向依赖 Weave 的流程规则,公共包不得包含接入方的专用业务逻辑。 +- **并发与生命周期**:后台循环、连接读写、处理器均有明确的创建与退出归属;通过上下文传播取消与时限,限制 goroutine、缓冲及数据库连接数量。捕获处理器 panic 后按副作用安全等级处理,不能默认重跑。 +- **错误契约**:区分协议错误、权限错误、状态冲突、临时基础设施错误、业务失败与结果未知。错误码保持兼容,日志保留内部原因,对外响应不泄露 SQL、密钥或敏感参数。 +- **配置**:启动校验租约、续租、超时、容量与保留期限之间的约束;动态配置有版本、审计和明确生效范围。密钥独立管理,不放入仓库或普通配置快照。 +- **数据库演进**:迁移脚本版本化,通过受控单一执行入口应用;滚动升级先做兼容性扩展,再迁移数据和收缩旧结构。数据修复使用可审计操作流程。 +- **可观测性**:任务、尝试、流程、步骤与请求 ID 用于日志和追踪关联;租户 ID、任务 ID 等无界值不作为常规指标标签。审计事件与调试日志分别保留。 +- **质量检查**:建立格式、静态检查、适用的 Go 竞态检测、协议兼容和迁移检查;使用真实 MySQL 验证事务、锁竞争与恢复,不能仅依靠存储 Mock 得出并发正确性结论。 +- **发布与接入**:服务端、SDK 与处理器维护独立版本和兼容矩阵;变更给出升级顺序与迁移说明。业务接入验收覆盖幂等、未知结果、取消和恢复,不能仅以 SDK 调用成功作为完成标准。 diff --git a/docs/03-http-websocket-protocol.md b/docs/03-http-websocket-protocol.md new file mode 100644 index 0000000..ccc0225 --- /dev/null +++ b/docs/03-http-websocket-protocol.md @@ -0,0 +1,178 @@ +# Orbit HTTP 与 WebSocket 协议草案 + +> 版本 0.2 · 2026-09-17。HTTP + WS 已确认。本文的具体路径、消息名、字段及状态码是首版设计建议,业务协议尚未实现,需评审后冻结;健康接口的已实现契约见接口手册。 + +## 1. 通用契约 + +- 生产使用 HTTPS / WSS;HTTP 承担任务提交、流程启动与信号、查询、管理,WS 承担 Worker 双向通信与结果订阅。 +- 数据采用 JSON;HTTP 契约使用 OpenAPI 描述,WS 消息使用版本化 JSON Schema 与交互时序描述。 +- ID 与执行代次等可能超出跨语言整数精度的值使用字符串。时间使用 RFC 3339 UTC 字符串,时长字段注明单位。 +- 请求体、消息、批量条数、查询页大小和发送缓冲都必须有限制;具体上限见待定参数表。 +- 鉴权后校验应用、环境、队列和操作权限。请求中的命名空间、Worker ID、任务 ID 不能代替授权。 +- SDK 与服务端维护兼容矩阵;不兼容的协议或处理器版本明确拒绝,不静默解释为当前版本。 + +## 2. HTTP 接口草案 + +| 方法与路径 | 行为 | +| --- | --- | +| `POST /v1/tasks` | 幂等提交一项任务 | +| `POST /v1/tasks:batchSubmit` | 批量逐项提交,每项独立幂等 | +| `GET /v1/tasks/{task_id}` | 查询权威任务状态与结果摘要 | +| `GET /v1/tasks/{task_id}/attempts` | 分页查询执行尝试 | +| `GET /v1/tasks` | 按有索引的字段分页查询,支持提交幂等键精确查找 | +| `POST /v1/tasks/{task_id}:cancel` | 请求取消,记录原因 | +| `POST /v1/tasks/{task_id}:retry` | 普通任务人工重试;流程关联任务须经 Weave 恢复入口 | +| `POST /v1/schedules` | 创建计划,明确漏跑和重叠策略 | +| `PATCH /v1/schedules/{schedule_id}` | 带预期版本修改计划,冲突时拒绝覆盖 | +| `POST /v1/schedules/{schedule_id}:pause` | 暂停计划的新触发 | +| `POST /v1/schedules/{schedule_id}:resume` | 按漏跑策略恢复计划 | +| `GET /v1/schedules/{schedule_id}/occurrences` | 查询计划触发记录 | +| `GET /health/live`、`GET /health/ready` | 进程存活与接单就绪状态,二者独立 | + +应用、凭据、队列、配额和审计管理接口沿用同一鉴权、版本与错误规范,其完整路径清单在接口评审时补齐。就绪检查必须反映关键依赖,数据库不可用时不能继续宣称可安全接单。 + +Weave 的补充接口建议如下,功能范围已确认,具体技术细则以 [流程设计](06-workflow-spec.md) 为准: + +| 方法与路径 | 行为 | +| --- | --- | +| `POST /v1/workflow-definitions/{definition_id}/versions` | 研发通过 SDK 发布不可变定义;同版本同内容幂等,不同内容冲突 | +| `POST /v1/workflows` | 幂等启动流程,固定定义版本 | +| `GET /v1/workflows/{workflow_id}` | 查询实例、定义版本、执行状态、暂停标记与等待摘要 | +| `GET /v1/workflows/{workflow_id}/steps` | 分页查询步骤、输入/结果引用、激活、查询轮次、已用预算与关联任务 | +| `POST /v1/workflows/{workflow_id}/signals` | 幂等受理信号,不等于信号已经被消费 | +| `GET /v1/workflows/{workflow_id}/signals/{signal_id}` | 查询信号受理、匹配与消费结果 | +| `POST /v1/workflows/{workflow_id}:pause` | 持久化暂停门控与审计;不代表在途业务已停止 | +| `POST /v1/workflows/{workflow_id}:resume` | 解除暂停并安排受控推进;不重置预算、核查状态或原版本 | +| `POST /v1/workflows/{workflow_id}:cancel` | 记录取消意图,结清关联工作 | + +流程列表、事件查询与人工核查/恢复接口待细化;恢复必须带预期状态版本、稳定操作身份、原因与证据,不提供任意修改状态的通用入口。 + +暂停和继续使用独立管理权限、预期控制版本、幂等操作身份与原因;同一操作重报返回原确认,过期控制版本不能覆盖后来的操作。建议 `200` 确认门控已提交,后续执行进度通过查询观察;门控失效条件和终态操作错误在协议评审时固定。 + +### 2.1 受理、幂等与错误 + +- 首次创建任务、流程或信号记录的建议响应为 `201 Created`,表示资源及幂等记录已可靠提交,不表示业务执行或信号消费完成。 +- 相同幂等请求返回 `200` 和原资源 ID,标记 `deduplicated: true`;相同键不同语义请求返回 `409`。 +- 单项提交使用 `Idempotency-Key`;批量每项使用 `submission_key`,由 SDK 统一映射为提交幂等标识。 +- 普通任务提交幂等作用域为命名空间内的任务提交操作;流程启动使用独立操作作用域,信号按命名空间、实例与信号 ID 去重。其他管理写操作按命名空间、操作和资源界定作用域,避免同一个键在不同 API 间意外冲突。 +- HTTP `request_id` 仅用于追踪,与业务幂等键分开。 +- 请求超时后,使用原幂等键查询或重试;不能通过换一个键来猜测原请求是否失败。 +- 语义哈希排除请求追踪、连接身份等传输字段,明确 JSON 规范化、缺省值和数组顺序;不能依赖普通 map 的序列化顺序。算法在协议冻结时确定。 + +建议统一响应结构:成功包含 `request_id` 和 `data`;失败包含 `request_id` 和 `error`,其中 `error` 含稳定的 `code`、可读 `message` 和受控的 `details`。 + +| HTTP 状态 | 用途 | +| --- | --- | +| 200 / 201 | 查询或已确认的操作结果 / 新资源可靠创建 | +| 202 | 取消等操作已记录意图,最终行为仍在推进 | +| 400 / 422 | 协议格式错误 / 语义校验失败 | +| 401 / 403 | 未认证 / 无操作权限 | +| 404 | 不存在或按访问策略隐藏的资源 | +| 409 | 幂等内容冲突、资源版本冲突或操作状态冲突 | +| 429 / 503 | 容量限制 / 服务暂不可用,可提供 `Retry-After` | + +批量请求顶层校验通过后可返回 `200`,但每项必须给出索引、任务 ID 或错误。顶层成功不表示每项均被受理。限流或断线后的补交仍使用原逐项幂等键。 + +## 3. Worker 连接 + +建议入口:`GET /v1/worker/connect`,使用 WebSocket Upgrade,协商子协议 `pulse.worker.v1`。SDK 在握手中携带应用凭据,不把长期密钥放在 URL 查询参数中。 + +连接生命周期:鉴权 → 协议协商 → 能力登记 → 恢复核对 → 可接单 → 排空 / 断线。 + +- `worker_instance_id` 标识一个进程实例,重连保留,重启建议新建;可另设可读名称。 +- `session_id` 标识本次连接。新连接需有新的会话身份,旧连接消息不得继续修改新会话的执行权。 +- 恢复执行必须校验认证身份、执行凭据和数据库中的当前归属,不能仅凭相同 Worker 名称认领。 +- Worker 声明队列、处理器兼容版本及容量。平台授权与配额优先于客户端声明。 + +## 4. 消息信封 + +以下为示意,时间与数值不是生产默认参数: + +```json +{ + "protocol_version": "1", + "type": "task.assign", + "message_id": "msg_example", + "session_id": "session_example", + "task_id": "task_example", + "attempt_id": "attempt_example", + "body": { + "handler": "report.generate", + "handler_version": "1", + "payload_version": "1", + "payload": {"report_ref": "report_example"}, + "lease_token": "example-only-not-a-real-secret", + "lease_expires_at": "2026-09-17T08:00:30Z" + } +} +``` + +响应通过 `reply_to` 关联原消息。需要可靠确认的操作使用稳定消息 ID;跨重连重报时保留操作身份,但使用当前会话认证。传输会话变化不改变操作的语义哈希。 + +执行凭据属于敏感信息,不进入普通日志、管理查询或指标标签。Task / Attempt / Message / Session 四种 ID 分别表达业务工作、执行尝试、通信操作和连接。 + +## 5. 消息类型与确认 + +| 消息 | 方向 | 语义 | +| --- | --- | --- | +| `worker.hello` / `worker.welcome` | 双向 | 协商能力、版本、会话与协议限制 | +| `worker.resume` / `worker.resume.result` | 双向 | 核对断线前的执行尝试,逐项给出续接、停止或核查结果 | +| `worker.capacity` | Worker → 平台 | 更新容量声明;带会话内序号,重复上报不得重复增加额度 | +| `task.assign` | 平台 → Worker | 派发已持久化授权的任务与执行凭据 | +| `task.start` | Worker → 平台 | 确认接单并申请开始;建议获平台确认后再调用处理器 | +| `task.renew` | Worker → 平台 | 续期有效执行租约,可批量但逐项确认 | +| `task.complete` | Worker → 平台 | 提交结果或结果引用,等待持久化确认 | +| `task.fail` | Worker → 平台 | 提交错误分类及已知业务结果,平台按冻结策略决定后续行为 | +| `task.cancel` | 平台 → Worker | 传递已保存的取消意图,不代表副作用被撤销 | +| `task.cancelled` | Worker → 平台 | 报告处理器停止及已知效果;未知效果转核查,不自动成为取消终态 | +| `worker.drain` | 双向 | 停止接收新任务,受控完成已有任务 | +| `ack` / `error` | 双向 | 关联明确操作的确认或错误,不能用连接 Pong 替代 | + +建议采用两步派发:平台先保存 `LEASED` 并发送任务;Worker 发 `task.start`,平台原子确认有效租约和未被取消后保存 `RUNNING`,再确认执行。消息丢失时按同一尝试重报或核对,不能未经重新授权自行执行。 + +如关联执行门控已暂停,尚未获开始授权的 `task.start` 不能被确认可执行;已确认开始的操作重报只补取原确认,不授予第二次执行。具体暂挂、释放预占容量和恢复派发的消息细节在协议冻结时补齐。 + +`RUNNING` 表示平台已授权开始,不足以证明业务侧已经发生某项效果。租约前后派发与取消的竞争见 [可靠性模型](04-reliability-and-data.md)。 + +### 5.1 完成确认丢失 + +1. 平台在同一事务内保存任务终态、尝试结果与待交付事件。 +2. 提交成功后回复 `ack`。 +3. SDK 未收到确认时,重报相同结果或查询任务。 +4. 已确认的同一尝试、同一结果再次上报,返回原确认,即使此时原租约已经到期。 +5. 不同结果冲突或过期尝试试图写入新结果时拒绝修改,保留可诊断记录。 + +先验证权限,再识别已完成操作的幂等重报,最后对新操作校验租约,避免将合法的确认补取误判为任务失败。 + +SDK 可以在进程存活期间缓存未确认消息;不得宣称内存缓存能跨进程崩溃恢复。业务效果已发生但结果尚未回报的情况,依赖业务幂等、业务侧操作记录或结果查询恢复。 + +## 6. 心跳、重连与背压 + +- Ping/Pong 用于连接活性;具体任务的续租必须单独经过授权与持久化确认。[WebSocket 标准](https://www.rfc-editor.org/rfc/rfc6455.html#section-5.5.2) +- 任务续租失败后,SDK 不能仅因连接仍在线就继续假定执行权有效。 +- SDK 根据服务端确认的租约期限,结合请求耗时、安全余量及本地单调时钟管理停止边界;不直接信任客户端墙上时钟与服务器完全一致。 +- 重连使用有上限的指数退避与抖动;支持凭据刷新、重新鉴权和恢复核对。 +- 断线后允许在最后确认的有效租约内按策略推进;无法及时恢复有效执行权时,停止推进并尽力取消可取消操作。外部已发请求仍需核查。 +- 重连可进入任一平台节点;有效旧尝试经原子校验迁移到新会话,失效尝试不得因重连复活。 +- 平台为已派发未确认的任务预占容量。容量消息采用序号和明确的覆盖/计算规则,重复消息不能释放或重复发放额度。 +- 单连接读写由独立循环管理;设置消息上限、读写期限和有界发送队列,处理器运行不占网络循环。 +- 慢消费者停止追加派发;非关键进度更新可合并,关键状态与结果必须保留恢复路径。 +- 负载均衡器支持 Upgrade、长连接超时和排空;网络断开不能触发无条件的业务重试。 + +## 7. 可靠结果订阅 + +建议独立使用 WSS 入口 `/v1/events/connect` 与子协议 `orbit.events.v1`,区分 Worker 消费权和结果订阅权。具体路径待评审。 + +事件包含 `event_id`、资源类型、资源 ID、状态版本、事件类型及结果引用,可表达任务与流程事件。平台先持久化事件与交付状态,再发送 `event.deliver`;接收方完成自身的可靠处理后发送 `event.ack`。 + +未确认事件可重新发送,订阅方按事件 ID 去重;同一资源按状态版本处理迟到信息,不承诺跨资源全局顺序。事件保留到期、订阅失效与补偿查询行为必须公开。 + +实时控制台展示可使用合并后的进度通知,但业务流程推进不能依赖只有在线时才能收到的消息。 + +Weave 消费 Pulse 的内部持久化事件,不经此对外订阅入口;其事务与去重要求见 [流程协作](06-workflow-spec.md#5-pulse-与-weave-的事务协作)。 + +## 8. 版本与协议验证 + +HTTP `/v1` 与 WS 子协议分别标识兼容边界,业务处理器版本独立。新增可选字段应兼容旧客户端;未知关键消息、状态或枚举不能被当作成功。 + +协议测试应覆盖断线、重复、迟到、会话切换、局部批量失败与凭据撤销。OpenAPI 与 WS Schema 要进入版本控制和兼容性检查,禁止服务端与 SDK 各自维护不同的状态解释。 diff --git a/docs/04-reliability-and-data.md b/docs/04-reliability-and-data.md new file mode 100644 index 0000000..0240f20 --- /dev/null +++ b/docs/04-reliability-and-data.md @@ -0,0 +1,160 @@ +# Orbit 可靠性与数据模型 + +> 版本 0.2 · 2026-09-17。本文定义首版设计约束与建议状态机,不是已实现的可靠性保证。 + +## 1. 权威状态与核心不变量 + +MySQL 保存任务、执行权、计划进度、流程、步骤、信号、结果与幂等索引的权威状态。内存队列、连接表与时间索引是加速结构,丢失后可以从权威状态重建。 + +必须维持以下不变量: + +1. 确认任务受理前,任务、提交幂等记录及必要事件已经按约定持久化。 +2. 在一个任务当前状态版本中,至多存在一个有效执行授权;跨节点领取使用事务与条件更新协调。 +3. 新操作必须携带当前有效的执行尝试和租约凭据;旧执行不能覆盖新状态。 +4. 任务终态、尝试结果和必要的结果事件在同一个数据库事务中更新。 +5. 调度触发记录、对应任务和计划进度的推进原子提交。 +6. 重复提交、重复派发、重复结果和重复事件交付都有明确去重范围。 +7. 没有证据确定业务副作用时,不将通信失败直接解释为业务失败并无条件重跑。 +8. 人工干预保留历史、原因与身份;不能通过直接修改数据库状态绕过业务确认。 +9. 流程步骤推进与下一步任务创建共享事务;异步接续的内部事件必须持久化并可去重。 +10. 流程绑定的定义版本不被发布操作替换;信号和任务结果只能推进匹配的步骤激活。 +11. 有界查询轮次、对应任务与唤醒关联持久化并去重;重启、暂停继续及执行重试不重置预算。 +12. 按暂停设计,流程控制版本与通用执行门控一致更新;阻止新的授权,同时保留在途结果及可恢复的待续推进记录。 + +平台允许按策略重复投递或恢复执行,不承诺任意外部业务效果“恰好一次”。业务幂等和资源端执行代次校验仍由接入方落实。 + +## 2. 建议任务状态机 + +| 状态 | 含义 | +| --- | --- | +| `WAITING` | 等待执行,包含未来执行与退避等待,以 `available_at` 决定最早派发时间 | +| `LEASED` | 已分配给一个执行尝试,等待开始确认 | +| `RUNNING` | 已授权处理器开始,租约管理执行权 | +| `NEEDS_REVIEW` | 结果未知或需要人工/业务核查,不自动重新产生副作用 | +| `SUCCEEDED` | 已保存处理器约定的成功结果 | +| `FAILED` | 已知失败、不可重试,或符合结束条件 | +| `CANCELLED` | 已阻止开始,或 Worker 已报告停止且取消结果可确认 | + +```mermaid +stateDiagram-v2 + [*] --> WAITING + WAITING --> LEASED: 原子取得执行授权 + LEASED --> RUNNING: 确认开始 + LEASED --> WAITING: 未授权开始且领取失效 + LEASED --> FAILED: 未授权开始且时限或策略已耗尽 + WAITING --> CANCELLED: 取消生效 + LEASED --> CANCELLED: 开始前取消生效 + WAITING --> FAILED: 时限或策略不再允许执行 + RUNNING --> SUCCEEDED: 结果可靠保存 + RUNNING --> WAITING: 满足安全重试条件 + RUNNING --> FAILED: 已知失败且结束 + RUNNING --> NEEDS_REVIEW: 结果未知且不可安全重试 + RUNNING --> CANCELLED: 停止已确认 + NEEDS_REVIEW --> SUCCEEDED: 核查确认成功 + NEEDS_REVIEW --> FAILED: 核查确认失败并结束 +``` + +`cancel_requested` 是独立的持久化意图,不直接把运行任务变为 `CANCELLED`。取消请求与成功上报竞争时按事务确定的有效转换处理;不能覆盖已经确认的成功结果。 + +单次超时、总时限和租约过期都要结合当前阶段与任务安全等级判断。运行中无法确认效果的任务应进入核查;不能仅因为超时就声称业务已停止。 + +核查操作需要证据引用、权限和审计。核查前的迟到结果可以作为证据保存,但无效尝试不能直接改写任务终态。 + +人工重试的建议是创建关联新任务,旧任务终态不回退;此项参见 [待确认决策](README.md#需要继续确认的事项)。 + +管理审计随对应状态变更同事务追加,应用日常权限不允许改写历史审计。更正通过新的关联记录表达;归档、导出和到期清理另设权限与审计。数据库管理员级别的防篡改证据及长期留存方案在生产审计要求确定后另行设计,不能将普通日志等同于业务审计证据。 + +## 3. 执行尝试与租约 + +一次尝试记录独立 ID、所属任务、授权代次、Worker 实例、当前会话、有效期、批准开始时间、结束时间、错误与结果摘要。终结的尝试不被下一次尝试覆盖。 + +- 使用数据库时间作为租约判定依据;数据库及运行环境需要时钟监控。 +- 领取、开始、续租、完成、取消与恢复采用明确的条件更新和受影响行数判断。 +- 处理器在数据库事务之外运行。不得持锁等待网络、执行业务逻辑或等待人工。 +- 过期回收必须再次校验租约和状态版本,避免与刚成功的续租或完成操作竞争。 +- 租约过期与重试安全性分别判断;失联不会自动证明旧处理器没有产生副作用。 +- 执行凭据应具备不可猜测性;顺序代次与秘密凭据职责分开,具体表示在协议评审中确定。 + +同业务键的并发限制只约束平台授予的有效许可。旧进程或已经发出的外部请求无法被数据库锁自动撤回,接入方仍需业务唯一约束、幂等或资源端 fencing。 + +## 4. 数据分组建议 + +下表是概念分组。实际 Go 模型、表名和已落实约束见 [数据模型基线](07-data-model.md),初始 DDL 已建立: + +| 数据组 | 建议实体及关键约束 | +| --- | --- | +| 接入与资源 | 应用、命名空间、凭据引用、队列、配额;作用域校验覆盖读写 | +| 任务 | `tasks`;保存当前状态、状态版本、处理器与参数快照、到期时间、当前尝试 | +| 提交幂等 | `submission_keys`;命名空间与键唯一,保存请求语义摘要及原任务引用 | +| 执行尝试 | `task_attempts`;尝试 ID 唯一,保留每次授权与结果 | +| 关键状态事件 | `task_events`;状态转换追加记录,与任务状态版本关联 | +| Worker 与执行许可 | 会话代次、恢复所需归属、可回收的并发许可;连接对象本身仅在内存 | +| 通用执行门控 | Pulse 管理的门控标识、允许/暂停标记和控制版本;流程任务关联门控,领取/开始与控制更新按统一锁定或版本协议串行化 | +| 计划 | `schedules`;定义版本、时区、生效边界、下一触发点与策略 | +| 触发 | `schedule_occurrences`;建议同计划同一 UTC 计划触发时间唯一 | +| 结果交付 | `result_events`、`event_deliveries`;事件与订阅交付唯一,确认和补投状态持久化 | +| 流程定义 | `workflow_definitions`、`workflow_definition_versions`;发布版本不可变,记录定义摘要及发布审计 | +| 流程实例 | `workflow_instances`;实例、启动幂等身份、定义版本、状态版本、暂停标记、控制版本、门控关联与策略快照 | +| 步骤与激活 | `workflow_steps`、`workflow_activations`;实例内步骤/激活唯一,绑定当前 Pulse 任务 | +| 信号与等待 | `workflow_signals`、`workflow_waits`;实例内信号身份唯一,保存目标关联、截止时间与消费状态 | +| 有界查询 | 查询节点检查点与 `workflow_poll_rounds`;命名空间/实例/步骤激活/轮次唯一,保存任务关联、已用轮次、截止时间、下次查询时间及结果 | +| 流程接续 | `workflow_inbox`、待续推进记录;源事件/消费者唯一,推进与消费确认同事务 | +| 管理审计 | 操作主体、原因、资源版本、前后摘要和证据引用 | + +活跃任务访问路径与历史归档分离。索引围绕命名空间、队列/逻辑分区、状态、到期时间和租约到期时间设计,并以真实执行计划验证。 + +同一任务、流程及步骤任务需要原子更新的数据必须处于同一事务可覆盖的数据库中。未来物理分片时先明确路由单元及全局配额边界,不能让跨库更新沿用单库原子性假设。流程状态模型以 [Weave 设计](06-workflow-spec.md) 为准,不能混用任务与流程状态。 + +## 5. 关键事务 + +| 事务 | 必须一起提交的内容 | +| --- | --- | +| 受理 | 提交幂等记录、任务、受理事件 | +| 领取 | 校验可执行条件与有效门控、取得额度、创建尝试、更新当前执行归属 | +| 开始 | 校验当前授权、门控与取消意图、记录批准开始状态 | +| 续租 | 校验当前尝试/会话/版本、更新有效期;批量操作逐项给出结果 | +| 完成 / 失败 / 转核查 | 尝试结果、任务状态、额度释放、关键事件、必要结果通知与内部流程交付记录 | +| 取消 | 取消意图或开始前取消终态、管理审计、必要通知 | +| 调度触发 | 触发去重记录、任务创建、计划进度推进 | +| 流程启动 / 推进 | 实例与步骤变化、待续推进检查点、下一步任务或等待建立、流程事件 | +| 内部事件消费 | 去重校验、步骤与流程推进、后续任务创建、消费确认 | +| 信号受理 / 消费 | 受理时保存幂等与待处理记录;消费时原子校验等待、推进并确认消费 | +| 流程暂停 / 继续 | 实例控制版本、暂停标记、Pulse 通用执行门控、管理审计与必要待续推进记录 | +| 查询下一轮 | 校验门控与预算、写入唯一轮次、计数、创建对应任务、确认内部唤醒消费 | + +任务领取可评估短事务配合 `SELECT ... FOR UPDATE SKIP LOCKED`;其适用场景是队列类访问,不应被用于跳过业务一致性校验。[MySQL 锁定读取说明](https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html) + +所有事务明确锁定顺序,处理死锁和提交结果不明。提交结果不明时先通过稳定操作身份核对,不能换身份重试造成额外任务。 + +## 6. 可靠衔接 + +### 6.1 业务系统到平台 + +接入方保存业务数据与向 Orbit 提交任务、启动流程或发送信号之间跨越两个数据库边界。业务侧可在本地事务内写入业务变更和 Outbox,再以稳定幂等键提交对应接口,受理后标记已送达。 + +Orbit 数据库事务不能自动包含 接入方的业务事务。SDK 的事务接入组件只辅助接入方在自己的数据库写入待发请求,核心 HTTP / WS 客户端不绑定业务 ORM。[事务 Outbox 参考](https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html) + +### 6.2 平台到结果接收方 + +结果事件与终态同事务保存,投递状态独立跟踪。接收方可靠处理后确认;确认丢失则重复交付,由接收方去重。 + +事件投递不能仅依赖内存通知,也不能未经处理提交次序问题就用自增 ID 的最大值作为“此前全部已处理”的游标。首版建议使用明确的订阅交付记录。 + +### 6.3 Pulse 到 Weave + +任务结果和待核查等关键状态变化,原子产生持久化内部事件与交付记录。Weave 使用事件身份、任务状态版本、当前步骤激活去重,在推进事务中确认消费。事件处理失败可再次消费;重复消费不得创建第二份后续任务。 + +内部事件暂时未消费时,流程详情应能区分“任务已有结果”和“编排尚未推进”,积压需要监控及恢复扫描。具体事务边界见 [流程协作](06-workflow-spec.md#5-pulse-与-weave-的事务协作)。 + +暂停不停止内部结果的可靠记录。若消费事件时暂不允许新步骤推进,应同时保存待续推进检查点;只标记事件已消费却没有后续恢复记录,会导致流程在继续后停滞。有界查询同样不能仅在内存增加轮次、设置定时器或保存未完成的下一轮计划。 + +## 7. 保留、归档与恢复 + +- 任务与流程历史、幂等键、信号、内外部事件、管理审计分别制定保留策略。 +- 清理任务主体不自动清理幂等索引。去重窗口须覆盖约定的补发与恢复窗口,长期业务幂等由业务系统维护。 +- 有未确认交付的事件不能被普通归档清理无声删除;保留到期应告警并执行已定义的补偿流程。 +- 备份需覆盖任务、计划、幂等、执行门控、流程定义、实例、查询轮次与预算、信号、事件及配置的一致恢复范围,包含必要的日志与密钥管理安排。 +- 恢复旧备份后,可能已有外部业务效果而平台记录回退。必须停止派发、使恢复前的会话与租约失效,并完成必要对账后再开放执行。 +- 数据库故障切换与备份恢复是不同故障模型,分别定义验证和可接受损失范围。 + +MySQL 持久化建议基线为 `innodb_flush_log_at_trx_commit=1`,启用 binlog 时采用 `sync_binlog=1`。副本故障切换的确认语义、RPO / RTO 仍要由具体高可用方案决定。[MySQL 持久化配置](https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit) diff --git a/docs/05-production-acceptance.md b/docs/05-production-acceptance.md new file mode 100644 index 0000000..938d9c3 --- /dev/null +++ b/docs/05-production-acceptance.md @@ -0,0 +1,110 @@ +# Orbit 生产验收与发布门槛 + +> 版本 0.2 · 2026-09-17。本文是验收计划,所有项目当前均为待实现、待验证。没有已通过的测试或容量结论。 + +## 1. 验收对象与证据 + +统一验证平台中的 Pulse 任务模块、Weave 流程模块、MySQL、HTTP / WS 协议和业务 SDK。包含模拟业务处理器及模拟外部渠道,以验证副作用与结果确认边界。 + +每份验收报告记录:版本、数据库配置、拓扑、硬件、网络条件、参数、数据量、复现步骤、预期与实际结果、关键指标、异常记录和遗留问题。 + +稳定性目标必须表述为在指定故障模型与负载下满足约定,不使用脱离环境的“绝不丢失”或“支持无限租户”作为测试结论。 + +## 2. 必须覆盖的正确性与故障场景 + +| 编号 | 场景 | 期望结果 | +| --- | --- | --- | +| A01 | 相同幂等键重复提交、并发提交 | 返回同一任务,不创建第二份工作;参数冲突明确拒绝 | +| A02 | 受理已提交,HTTP 响应丢失 | 使用原键可查到任务,重试不重复创建 | +| A03 | 多节点同时领取同一任务 | 仅一个有效授权,配额与尝试记录一致 | +| A04 | 派发落库后节点宕机,消息未送达 | 未开始尝试可回收,任务仍可追踪并恢复 | +| A05 | 开始确认或完成确认丢失 | SDK 按操作身份核对/重报,不能错误地再执行一次已确认操作 | +| A06 | 业务写入成功,Worker 在上报前崩溃 | 平台允许恢复;业务幂等返回原结果,副作用未知时转核查 | +| A07 | 旧 Worker 暂停后恢复,租约已被替代 | 旧结果不覆盖新状态,资源端防护按接入契约生效 | +| A08 | 续租与过期回收、完成操作竞争 | 事务条件决定唯一合法转换,额度不重复释放 | +| A09 | WS 断线后重连不同节点 | 重新鉴权与恢复核对,旧会话不能继续控制新会话归属 | +| A10 | Worker 容量消息重复、发送缓冲满 | 不重复发放额度,不无限积压内存,不丢弃关键结果恢复信息 | +| A11 | 取消与开始/完成竞争 | 终态与已知结果一致,取消意图不伪装成业务回滚 | +| A12 | 外部平台已受理但响应超时 | 按处理器安全策略核查,不能盲目重复提交 | +| A13 | 多调度节点处理同一触发点 | 仅一个有效触发实例,任务与计划进度原子更新 | +| A14 | 停机、时区变化、计划修改与恢复 | 补跑/跳过/合并遵守已定义版本边界,全部可追溯 | +| A15 | 结果接收方停机或确认丢失 | 事件保留并可重投,接收方去重,积压可观测 | +| A16 | 同业务键并发与跨节点配额竞争 | 有效许可满足约束,过期执行副作用按业务防护处理 | +| A17 | 使用另一应用/队列身份读写或恢复任务 | 被拒绝,敏感参数与凭据不泄露 | +| A18 | MySQL 短暂不可用或主节点切换 | 不发出无依据的成功确认;恢复后任务可核对 | +| A19 | 平台与 Worker 滚动升级 | 兼容版本继续服务,旧任务找到兼容处理器或明确等待 | +| A20 | 清理与归档同时发生重复补交/事件补投 | 幂等与未确认事件不被无声破坏 | +| W01 | 创建流程下一步任务时中断事务 | 流程状态与任务创建同时提交或回滚 | +| W02 | 任务完成后流程处理节点宕机 | 内部结果事件可恢复消费,步骤不重复推进 | +| W03 | 信号提前、重复或迟到 | 已存在实例可保存早到信号,消费去重且匹配正确步骤 | +| W04 | 人工等待或定时等待期间重启 | 等待状态恢复,不长期占用业务 Worker | +| W05 | 并行步骤部分成功、部分失败 | 汇合规则明确,成功副作用保留,重试不越过业务安全规则 | +| W06 | 新流程版本发布 | 已运行实例继续使用冻结版本,不悄悄改变历史步骤 | +| W07 | 取消流程时仍有任务执行 | 流程如实反映取消推进与待核查状态,不宣称已撤销副作用 | +| W08 | 同一汇合点的多个结果并发到达 | 按汇合规则只推进一次、只创建一份后继任务 | +| W09 | 信号受理、定时唤醒与取消竞争 | 原子决定唯一有效推进;已按时受理信号不因消费积压被误判超时 | +| W10 | 人工恢复后收到旧激活结果或重复恢复请求 | 旧结果不推进新激活,恢复去重,保留审计与业务幂等 | +| W11 | 查询正常返回处理中,期间部分请求失败 | 查询轮次与 Pulse 尝试独立计数;处理中按间隔进入下一轮,请求重试不新增轮次 | +| W12 | 轮次用完、总时限到达或单轮重试耗尽 | 按规则转核查;不换任务或轮次绕过上限,不再次调用业务提交接口 | +| W13 | 保存查询结果、建立等待或创建下一轮时宕机 | 从持久化轮次与检查点恢复,同一轮只创建一项任务,预算不丢失或重复扣减 | +| W14 | 暂停与创建步骤、任务领取、开始及重试竞争 | 按控制版本及门控决定唯一有效顺序,暂停后不新增授权;已授权工作继续收集结果 | +| W15 | 暂停期间收到结果/信号、到期或重启,然后继续 | 结果和待续推进记录可恢复,原版本与预算保留;按冻结计时规则处理到期,不集中补查 | +| W16 | 最终回执、查询结果与下一轮唤醒并发或迟到 | 只完成一次等待并创建一份后继工作,停止后续轮次,冲突或旧结果不能覆盖结论 | +| W17 | SDK 重复发布、同版本内容冲突或发布非法定义 | 同内容幂等,不同内容拒绝;非法表达式、引用及无界预算被拒绝,运行实例版本不变 | + +W11–W17 中的字段与暂停/计时算法按技术评审后的规范执行;这些验收项不表示当前建议已经实现或测试通过。 + +验收还需覆盖日志脱敏、权限撤销、消息超限、死锁、连接重连风暴、磁盘容量告警及进程强制终止。 + +## 3. 性能负载模型 + +租户数量用于推导业务量,不能直接等同于连接数、队列数或每秒任务数。至少建立以下维度: + +- 平均与峰值提交任务数、批量大小、瞬时集中触发规模。 +- 同时运行任务数、任务耗时分布、续租频率与重试比例。 +- Worker 连接数、每连接并发、空闲连接比例、重连频率。 +- 参数/结果大小、事件数量、保存周期、历史查询并发。 +- 定时计划数、到期分布、漏跑补偿规模与允许的恢复窗口。 +- 流程实例数、每实例步骤数、并行度、等待实例数与信号速率。 +- 查询轮次与单轮尝试分布、渠道请求峰值,以及大量暂停实例继续时的待续推进规模。 + +采用短任务、中长任务、混合负载、集中触发、长期积压和故障恢复六类场景。既测空处理器的框架开销,也测可控延迟的模拟外部调用。 + +## 4. 必须度量的指标 + +| 指标 | 定义 | +| --- | --- | +| 受理延迟 | 发起请求至可靠受理响应,统计 p50 / p95 / p99 | +| 调度延迟 | 原计划到期时间至变为可派发状态,单独统计漏跑补偿 | +| 排队与派发延迟 | 可执行时间至授予执行权,以及授予至 Worker 收到/开始 | +| 吞吐 | 可靠受理、开始、完成、事件交付分别统计 | +| 数据库负载 | 查询/写入、锁等待、死锁、连接池、日志与磁盘 I/O、复制滞后 | +| 连接与内存 | 连接数、重连、发送队列、内存、GC、CPU 与网络 | +| 恢复效果 | 故障发现、重新授权、流程恢复、积压清空的时间与错误数量 | +| 公平性 | 单个应用突发时其他应用的延迟与额度兑现情况 | +| 流程推进 | 内部事件至步骤完成/后续任务创建的延迟与积压 | +| 有界查询与暂停 | 活跃查询节点、轮次/尝试消耗、到期转核查量、暂停实例数、继续后的推进延迟及渠道请求速率 | + +开启生产所需持久化、复制、鉴权和审计后再测,报告任何性能优化对可靠性或恢复窗口的影响。 + +## 5. 发布前必须确定的参数 + +以下数值当前均为待定:容量目标、延迟目标、可用性目标、RPO / RTO、消息和批量上限、租约/续租/心跳/重连参数、扫描与补跑预算、查询轮次/间隔/总时限与单轮重试预算、暂停继续的推进预算、保留周期、兼容版本范围。 + +配置之间需要校验,例如续租间隔与安全余量必须允许在租约到期前发现失败;代理空闲超时需与 WS 活性策略协调;最长恢复补发窗口应与幂等和事件保留周期匹配。 + +## 6. 备份恢复与上线门槛 + +恢复演练必须实际恢复到隔离环境,核对任务、尝试、计划、幂等、结果事件、流程、暂停控制与执行门控、查询轮次及预算、信号和权限配置。恢复旧数据后需要隔离旧连接与执行凭据,并核查可能已发生的外部效果。 + +上线前至少具备: + +- 已评审的功能、状态机、协议与兼容矩阵。 +- 实际 MySQL 的持久化、复制确认、故障切换及恢复说明。 +- 上述关键故障场景的复现证据与结果核对。 +- 满足约定峰值且保留容量余量的压力测试记录。 +- 监控告警、值守与恢复操作说明,必要的管理权限和审计。 +- 业务接入方的幂等及结果核查验收;真实业务副作用前先完成隔离环境演练。 +- 发布、迁移、回滚/前向修复策略,以及明确的停止扩量条件。 + +仅有单机演示、正常路径测试或一次吞吐测试,不构成本项目的生产验收通过。 diff --git a/docs/06-workflow-spec.md b/docs/06-workflow-spec.md new file mode 100644 index 0000000..0ac7dc5 --- /dev/null +++ b/docs/06-workflow-spec.md @@ -0,0 +1,210 @@ +# Weave 流程编排功能与设计 + +> 版本 0.2 · 2026-09-17。首版功能范围及研发定义发布方式已确认,包含有界重复查询和生产管理能力。具体字段、状态表示、算法及参数仍待技术评审;未实现、未验收。 + +## 1. 职责与边界 + +Weave 保存“流程到了哪一步、下一步何时可以开始、正在等待什么”。Pulse 保存“任务交给谁执行、执行权是否有效、能否安全重试、结果是什么”。公共身份、权限、协议、审计和存储由 Orbit 统一提供。 + +Weave 不执行特定业务逻辑,也不把整段业务代码放入平台进程。实际工作通过 Pulse 派给业务 Worker,业务系统负责业务正确性、幂等、外部结果核查与授权。 + +首版由研发通过 Go SDK 描述、发布流程,业务处理器仍在业务 Worker 中执行。技术上建议采用显式流程定义与持久化状态机,根据已保存的定义、步骤结果和信号计算推进,不实现任意 Go 业务函数的执行历史重放。 + +## 2. 已确认的首版能力 + +| 能力 | 行为与边界 | +| --- | --- | +| 顺序步骤 | 前一步达到定义的完成条件后,创建下一步任务 | +| 条件分支 | 基于已保存、经过类型校验的结果或信号选择路径;不执行任意脚本 | +| 有限并行与汇合 | 建议先支持结构化并行分支、全部成功后汇合;限制分支数,不默认支持任意交叉汇合 | +| 等待业务信号 | 持久化等待回执、审核结果等外部输入,不长期占用 Worker | +| 定时等待 | 持久化截止时间,通过 Pulse 的内部定时任务唤醒 | +| 有界重复查询 | 外部业务尚在处理中时按间隔查询,达到次数或总时限后转人工核查;不与 Pulse 执行重试混用 | +| 暂停与继续 | 暂停后停止推进新步骤,继续接收在途结果;恢复后从已保存进度继续 | +| 异常与人工恢复 | 保存失败或未知结果,按定义暂停相关推进并核查;干预必须有权限、原因与证据 | +| 取消 | 阻止新步骤,持久化下游取消意图,等待已开始工作的可确认结果 | +| 查询与审计 | 查询流程版本、每步输入/结果引用、关联任务、执行次数、查询轮次、等待原因、信号、人工操作与状态转换 | +| 版本固定 | 实例绑定不可变定义版本及实际生效配置,发布新版本不改变运行实例 | + +完整审批产品、可视化编辑器、任意循环、动态无限展开、子流程、跨库流程迁移及运行中替换定义不默认进入首版。有界重复查询作为明确受限的节点能力纳入首版,不开放任意回边或无限循环。是否需要补偿步骤及其首版范围另行讨论。 + +首版通过 SDK / 管理 API 提供运行查看与管理能力;后台操作界面可以调用这些 API,完整图形化控制台的交付范围另定。业务人员自由拖拽编排不纳入本次确认范围。 + +人工审核可以由业务系统完成,再提交经过授权的信号。Weave 记录提交者和证据,不代替业务系统判断“谁有权批准这笔账”。 + +## 3. 定义与实例身份 + +研发在代码中通过 Go SDK 描述步骤与规则,定义源码跟随业务项目进行版本管理和评审,再显式发布到 Orbit。业务服务每次启动不应自动覆盖已发布定义。 + +建议 SDK 将流程描述生成为可校验的 JSON 定义,表达步骤、输入映射、结果结构、条件、等待、时限和失败策略。Orbit 不执行上传的任意 Go 函数;业务计算和外部请求通过命名、版本化处理器完成。SDK 的构建 API、定义 Schema 及发布命令形式待协议评审。 + +发布前至少验证引用、可达性、顶层无环、分支穷尽性、结构化汇合、类型匹配与资源上限。有界重复查询封装为一个节点,在节点内部保存轮次和截止时间,不将次数上限展开成大量静态节点。定义发布需要独立权限和审计,版本内容不可变;重复发布同版本同内容可幂等确认,不同内容明确冲突。 + +| 身份 | 用途 | +| --- | --- | +| `definition_id` / `definition_version` | 稳定定义与不可变版本 | +| `workflow_id` | 一次流程实例;启动请求重试返回原实例 | +| `step_id` / `activation_id` | 定义中的步骤与该步骤一次具体激活,区分后续人工恢复 | +| `task_id` / `attempt_id` | Pulse 的一次任务与执行尝试,不能与流程或步骤身份混用 | +| `poll_round` | 同一次查询节点激活内的查询轮次;一轮关联一个 Pulse 任务,可包含多次执行尝试 | +| `signal_id` / 关联键 | 信号去重及匹配指定实例、步骤激活或业务操作 | + +启动时绑定明确的定义版本、参数版本、处理器兼容要求和策略快照。若允许以“当前版本”启动,首次受理时解析并固定;相同幂等键重报仍返回首次绑定的实例。 + +初次启动的幂等键至少在命名空间和操作类型内唯一。同键不同语义输入必须冲突;不因新定义发布就生成另一份流程。 + +## 4. 建议状态模型 + +流程状态与任务状态分别管理,不能用“最后一个任务成功”替代整个流程的成功条件。 + +| 流程状态 | 含义 | +| --- | --- | +| `RUNNING` | 存在可推进步骤或仍在执行/排队/退避的任务 | +| `WAITING` | 没有可立即推进的步骤,剩余路径等待信号或时间 | +| `CANCELLING` | 已接受取消意图,正在确认关联工作的停止或结果 | +| `NEEDS_REVIEW` | 存在阻塞推进的失败、未知效果或需人工处理事项 | +| `SUCCEEDED` | 所有要求完成的路径和汇合条件已经满足 | +| `FAILED` | 已确定无法完成,且所有已激活工作均已结清或确认停止 | +| `CANCELLED` | 取消已完成;不表示历史上已成功的业务效果被撤销 | + +存在未知副作用时不能进入一个暗示“全部已结清”的终态。建议异常核查优先显示为 `NEEDS_REVIEW`,同时保留取消意图及每个分支的真实状态;状态汇总优先级在协议评审中固定。 + +暂停建议用独立持久化标记 `paused` 与控制版本表达,不覆盖上述执行状态:实例可以同时显示“已暂停”和“有任务执行中”或“待核查”。恢复仅解除暂停,不自动解除核查或撤销取消。具体字段与汇总展示规则仍待协议评审。 + +步骤单独保存等待前置条件、等待任务、等待信号/时间、完成、失败、跳过或待核查等阶段及状态版本。步骤任务排队不代表业务已经运行;详情应展示 Pulse 的实际执行阶段。 + +实例终态不静默回退。对已结束流程重新执行,建议创建带来源关联的新实例,重新校验业务幂等;首版是否支持从已完成步骤继续、以及需要哪些业务证据,另行评审。 + +## 5. Pulse 与 Weave 的事务协作 + +首版两个模块处于同一 MySQL 事务范围,通过应用层接口明确传递事务上下文,不互相任意写表。 + +| 操作 | 原子提交内容 | +| --- | --- | +| 启动流程 | 启动幂等记录、实例与首批激活步骤、对应 Pulse 任务、状态事件 | +| 保存任务结果 | Pulse 任务/尝试结果、额度释放、结果事件及内部交付记录 | +| 接续任务结果 | 核对实例与步骤激活、消费去重、步骤变化、流程推进、后续任务或等待记录、内部事件处理确认 | +| 建立定时等待 | 步骤等待条件、截止时间、对应的 Pulse 内部定时任务 | +| 接收信号 | 幂等记录、信号内容与身份、持久化待处理记录 | +| 消费信号 / 定时唤醒 | 校验等待条件与状态版本、消费标记、流程推进、后续任务创建 | +| 查询轮次推进 | 校验暂停/取消、轮次/时间预算,创建唯一轮次记录与对应任务,或保存下一次唤醒 | +| 暂停 / 继续 | 控制标记与版本、通用执行门控、管理审计、必要的持久化待续推进记录 | + +内部结果交付可使用公共持久化事件表加 `workflow_inbox` 记录。事件生成与交付记录必须跟随 Pulse 的状态变化一起提交,不能先提交任务再仅通过内存调用 Weave。 + +任务自动重试期间步骤仍关联原任务;任务进入 `NEEDS_REVIEW` 时发送内部状态事件,让流程可见地进入核查。任务后来被核查确认为终态时,再由新的状态版本事件推进。 + +处理事件时校验命名空间、流程、步骤激活、查询轮次、当前任务 ID 与源状态版本。重复事件只能产生一次推进;旧激活或旧轮次的迟到事件保留记录,不启动当前激活的后续步骤。并行汇合在短事务内协调同一实例/汇合点状态,确保后继任务只生成一次。 + +同一实例的一次推进有步骤数和耗时预算。超过预算时原子保存检查点和待续推进记录,再由有界扫描继续;不能提交部分展开状态后只依赖内存继续循环。 + +这套内部交付不经过对外 WS 订阅。WS 用于业务 Worker 通信或业务结果通知,不承担两个模块之间唯一的可靠通道。 + +## 6. 信号与持久化等待 + +### 6.1 信号与时间唤醒 + +信号携带稳定幂等身份、目标实例、信号类型、目标步骤/业务操作关联、参数版本及证据引用。认证身份与提交范围由服务端核验,不能仅凭流程 ID 注入信号。 + +- 实例已经存在但尚未进入等待时,允许将匹配该定义的早到信号持久化;达到匹配条件后再消费。 +- 实例不存在时明确返回未找到,不创建无归属信号。业务侧先完成幂等启动,再通过 Outbox 补交信号。 +- 相同信号 ID、相同语义内容返回原受理结果;不同内容明确冲突。 +- 一次等待默认消费一次匹配信号;广播、多次聚合和跨实例信号不隐式支持。 +- 信号关联必须能区分具体业务操作,不能只按“审核通过”等类型匹配,避免旧业务批次、旧步骤或旧恢复轮次的信号误用。 +- 信号受理成功只表示已可靠保存,消费与步骤推进状态可另外查询。 + +定时等待使用已保存的截止时间和内部定时任务。定时唤醒由平台执行角色处理,不需要业务 Worker 长期占用连接槽位。任务结果与后续等待消费仍遵守幂等和事务规则。 + +信号、超时与取消竞争时,按等待记录的状态版本原子决定结果。采用信号持久化受理时间与等待截止时间判断是否按时到达,不能因消费积压把已经按时受理的信号误判为超时;截止点的包含规则待协议冻结。取消另一条唤醒路径后,迟到事件只留痕,不再次推进。 + +未匹配信号数量、参数大小、保留时间与等待时限必须有上限。到期清理需要可观测且有明确规则,不能悄悄丢掉仍有效的等待依据。 + +### 6.2 有界重复查询 + +**功能已确认**:查询成功但外部业务仍在处理中,由 Weave 等待后发起下一轮;查询请求失败,由 Pulse 在当前轮次内按安全策略重试。达到轮次或总时限上限后转人工核查,不直接认定外部业务失败。 + +以下为实现建议,具体字段与边界待技术评审: + +| 内容 | 建议约束 | +| --- | --- | +| 查询目标 | 固定查询处理器、版本和外部业务操作标识;恢复查询不能重新调用提交接口 | +| 轮次上限 | `max_rounds` 为有限正整数;创建轮次记录与查询任务时原子计数,不因重复事件或重启重复增加 | +| 总时限 | 从节点激活时固定 `deadline_at`;建议同时设置轮次与时间上限,先到者阻止新一轮 | +| 查询间隔 | 建议从上一轮完成且结果仍为处理中时计算下一次时间,可设有上限的退避与抖动;不按错过的间隔集中补跑 | +| 单轮执行重试 | Pulse 使用独立且有限的尝试预算,自动重试保持该轮任务 ID;受节点剩余时限约束 | +| 结果映射 | 明确区分处理中、业务确认成功、业务确认失败和需要核查;未知值不能默认为成功或继续循环 | + +查询任务自身成功表示取得了一份有效结果。其中“处理中”使 Weave 保存等待与下一次唤醒;最终业务结果则结束查询节点,按定义推进或进入失败处理。单轮请求重试耗尽应转核查,不能通过新建一轮绕过已耗尽的重试预算。 + +每轮只创建一个查询任务,以命名空间、实例、步骤激活和轮次形成稳定去重身份。轮次之间释放业务 Worker 槽位,等待依赖数据库记录与 Pulse 内部定时唤醒;SDK 不在处理器中长期 sleep 或维护内存轮询循环。 + +发布和受理时校验轮次、单轮尝试、超时和总时限的组合上限,避免两层次数相乘后超过外部渠道或平台预算。未完成上一轮前不得并发启动下一轮。最后一轮仍为处理中、等待到期或当前轮次重试耗尽时,保存核查原因及已知结果。 + +截止时间到达后不再授权新查询或其执行重试;已经授权的请求仍需收集结果。迟到结果可保存为证据,不自动清除到期后的核查状态。建议以结果持久化受理时间而非内部事件消费时间判断是否按时到达,避免消费积压造成误判。 + +若查询期间也允许接收业务最终回执,回执与查询结果竞争同一等待/激活版本,只能完成一次。最终回执胜出后停止后续轮次;在途只读查询仍收集结果并审计,迟到或冲突结果不得再次推进或覆盖已确认结果。 + +## 7. 重试、失败与人工干预 + +### 7.1 暂停与继续 + +**功能已确认**:暂停阻止新步骤推进,保留已完成进度,继续接收在途结果与外部回执;继续操作从持久化进度恢复。暂停不表示已经停止外部业务操作。 + +以下细则为设计建议: + +- 暂停与继续带预期控制版本、稳定操作身份、操作人及原因;状态与审计一起提交。新步骤创建必须原子检查控制版本。 +- 建议暂停还阻止关联任务的新领取、开始授权和执行重试,包含已经创建但尚未开始的任务。由 Pulse 的通用执行门控与 Weave 控制状态同事务更新,领取/开始时读取权威门控,避免仅暂停流程循环却继续派发旧队列任务。 +- 暂停前已获得开始授权的尝试仍可运行、续租和上报,保留时限与安全约束。暂停与开始竞争按事务顺序决定;“暂停成功”只表示门控已持久化,不表示所有在途请求已停止。 +- 在暂停期间可靠保存任务结果、信号和到期事实,必要时进入核查或结清已有工作;不创建新的业务步骤或查询轮次。即使内部事件已确认消费,也要保存待续推进状态,避免恢复后无事件可推进。 +- 建议暂停不延长原业务截止时间,查询节点、任务时限与信号有效期继续计时。恢复时先核对已受理结果、信号和原截止时间,不能把过期查询重新激活;时间策略在协议冻结前确认。 +- 恢复按配额和推进预算处理积压,不重置已用查询轮次和执行尝试,不补发每一个错过的查询时间点。恢复不改变原流程版本,不清除取消或待核查条件。 + +### 7.2 执行失败与人工恢复 + +一个业务步骤的自动执行重试归 Pulse 管理。Weave 不对 Pulse 的每次暂时失败再创建新任务,也不叠加另一层指数重试。 + +建议步骤最终失败后,按固定策略进入流程核查或结束处理。并行分支部分失败时,不创建依赖该汇合点的后续步骤;按定义停止新派发、请求取消其余活动分支并收集结果。只有所有活动工作均已结清且没有未知效果,才可按策略进入失败终态。 + +对仍处于核查中的流程进行人工重试时,必须通过 Weave 的恢复入口: + +1. 校验权限、当前状态版本、步骤归属、失败类别与副作用证据。 +2. 确认符合安全重试条件;“未收到响应”本身不构成条件。 +3. 在一个事务内记录管理操作,创建新的步骤激活及关联 Pulse 任务,并固定关联关系。 +4. 保留旧激活与旧任务结果,沿用恰当的业务幂等身份。 + +这是人工恢复的建议模型,具体 API 在评审后冻结。普通任务人工重试入口应拒绝绕过流程管理的关联任务请求。已经确认成功的步骤不能通过“重试流程”随意再执行一次。 + +取消和补偿是不同业务操作。若未来增加补偿,补偿本身也必须是有权限、可审计、可重试或可核查的业务步骤;不能宣称撤销流程就能撤回已经生效的外部业务操作。 + +## 8. 通用接入示例 + +以下只展示模块协作,不替代接入方的业务规则与审核: + +```text +业务系统提交外部作业处理流程 + → Pulse 执行输入校验任务 + → Weave 按结果进入业务审核信号等待 + → Pulse 执行数据准备任务(业务侧保证同一操作幂等) + → Pulse 执行外部作业提交任务 + → Weave 等待回执信号,或通过有界重复查询节点获取最终结果 + → 根据业务确认的最终结果完成 / 进入核查 +``` + +“外部作业提交任务成功”可能仅代表渠道受理,不能直接等同于外部作业最终成功。首版有界重复查询节点负责受限轮询,业务方也可以在平台外负责回执采集,再以信号推进流程。查询和核查围绕原外部操作身份进行,不能因没有最终结果就重复提交外部作业。 + +## 9. 容量、版本与验收 + +租户数不等于流程并行分支数。禁止将大规模跨租户批处理塞入一个无界流程;按业务单元创建受限实例,批量启动也要配额、背压和逐项幂等。 + +限制实例步骤数、并行度、查询轮次、单轮重试、每次推进量、输入/输出大小、信号积压与历史保留量。长时间等待只保留持久化状态;扫描依赖到期索引和待处理记录,不能全表遍历所有等待实例。 + +旧定义、参数解释与处理器版本在仍有运行实例时必须保持可用,或明确进入可观测的版本不兼容等待;升级不能悄悄把旧实例转到新逻辑。 + +功能范围已确认。实施前仍需评审:SDK 构建 API、定义 Schema、分支表达式、并行失败策略、暂停门控及计时规则、轮询预算与截止边界、人工恢复身份模型、协议字段、资源上限与保留周期。验收覆盖 [生产验收](05-production-acceptance.md) 的流程场景,尤其是暂停竞争、轮次恢复、查询与回执竞争及版本发布。 + +## 10. 决策记录 + +- 0.1:提出流程功能草案,Weave 首版范围待讨论。 +- 0.2:确认研发通过 Go SDK 定义发布,确认顺序、分支、并行汇合、信号和定时等待,将有界重复查询纳入首版;确认暂停/继续、取消、人工核查恢复、版本固定与全程追踪。具体技术细则保留为建议,尚未通过实现验证。 + +公共数据约束见 [可靠性与数据模型](04-reliability-and-data.md),对外接口草案见 [HTTP 与 WS 协议](03-http-websocket-protocol.md)。 diff --git a/docs/07-data-model.md b/docs/07-data-model.md new file mode 100644 index 0000000..795e791 --- /dev/null +++ b/docs/07-data-model.md @@ -0,0 +1,105 @@ +# 数据模型与实现基线 + +更新:2026-09-17。当前为基础实现阶段,完整调度与编排引擎仍在开发计划中。 + +## 1. 模块与依赖 + +- `model/`:ID、摘要、JSON、命名空间记录等公共原语。 +- `platform/model/`:应用、命名空间、凭据、幂等、事件、订阅交付及审计。 +- `pulse/model/`:队列、执行门控、会话、任务、尝试、回执、并发许可和调度计划。 +- `weave/model/`:定义及不可变版本、实例、步骤激活、等待、信号、查询轮次与恢复记录。 +- `internal/storage/mysql/`:连接、事务与显式迁移,不向 SDK 暴露。 +- `sdk/go/`:独立 Go module;对外 DTO 与服务端模型分别维护。 + +模型使用普通 Go struct 与 `db` 标签,模型与持久化字段提供中文注释,SQL 表和字段同步维护中文 COMMENT。SQL 是可审查的版本化迁移。目前未引入 ORM,也没有自动同步表结构。 + +## 2. 存储约定 + +| 项目 | 当前实现 | +| --- | --- | +| 数据库基线 | MySQL 8.4+,本轮在 8.4.10 验证 | +| Go 工具链 | Go 1.27.1;服务端和 SDK 分别编译测试 | +| 标识 | 128 位加密随机 ID;数据库 `BINARY(16)`,对外 32 位小写十六进制字符串 | +| 摘要 | SHA-256,数据库 `BINARY(32)`;执行令牌及凭据原文不进入模型 | +| 租户隔离 | 运行实体以 `(namespace_id, id)` 为主键,具体资源关联使用包含命名空间的复合外键 | +| 时间 | `DATETIME(6)`,数据库会话与驱动统一 UTC;运行授权依赖数据库时间 | +| 状态 | Go 类型常量 + SQL `CHECK`;状态版本独立于时间戳 | +| CAS | `revision` 从 1 开始;服务层必须带期望版本条件更新并检查受影响行数 | +| 暂停 | 实例执行状态与 `paused` 分开,控制版本与通用门控同事务协调 | +| 参数 | 版本化 JSON 文档或业务侧引用;平台不内置行业字段 | +| 删除 | 关联数据默认 `RESTRICT`,没有级联删除执行历史 | +| 不可变历史 | 流程版本、计划版本、状态事件、审计事件的 `UPDATE` 被数据库触发器拒绝 | + +随机 ID 的索引成本需要后续容量测试,不将该选择描述为已经验证的大规模吞吐保证。运行账户应最小授权,迁移与保留清理使用不同身份;触发器不是对数据库管理员的防篡改保证。 + +## 3. 实体清单 + +下表与初始迁移中的 30 个实体一致;迁移自身另有 `orbit_schema_migrations` 记录。 + +| 模块 | Go 模型 | 数据表 | +| --- | --- | --- | +| platform | `Application` | `orbit_applications` | +| platform | `Namespace` | `orbit_namespaces` | +| platform | `Credential` | `orbit_credentials` | +| platform | `IdempotencyKey` | `orbit_idempotency_keys` | +| platform | `Event` | `orbit_events` | +| platform | `Subscription` | `orbit_subscriptions` | +| platform | `Delivery` | `orbit_deliveries` | +| platform | `AuditEvent` | `orbit_audit_events` | +| pulse | `Queue` | `pulse_queues` | +| pulse | `ExecutionGate` | `pulse_execution_gates` | +| pulse | `WorkerSession` | `pulse_worker_sessions` | +| pulse | `Task` | `pulse_tasks` | +| pulse | `Attempt` | `pulse_attempts` | +| pulse | `AttemptReceipt` | `pulse_attempt_receipts` | +| pulse | `ConcurrencyLimit` | `pulse_concurrency_limits` | +| pulse | `Permit` | `pulse_permits` | +| pulse | `Schedule` | `pulse_schedules` | +| pulse | `ScheduleVersion` | `pulse_schedule_versions` | +| pulse | `Occurrence` | `pulse_schedule_occurrences` | +| weave | `Definition` | `weave_definitions` | +| weave | `DefinitionVersion` | `weave_definition_versions` | +| weave | `Instance` | `weave_instances` | +| weave | `Step` | `weave_steps` | +| weave | `Activation` | `weave_activations` | +| weave | `Wait` | `weave_waits` | +| weave | `Signal` | `weave_signals` | +| weave | `PollState` | `weave_poll_states` | +| weave | `PollRound` | `weave_poll_rounds` | +| weave | `InboxEvent` | `weave_inbox` | +| weave | `PendingAdvance` | `weave_pending_advances` | + +设计文档中的 `task_events` / `result_events` 当前合并为 `orbit_events`,通过聚合类型、事件类型和状态版本区分;对外投递使用 `orbit_deliveries`,内部流程接续使用 `weave_inbox`。`weave_pending_advances` 保存暂停或分批推进后的续接检查点。 + +## 4. 已落实的数据库约束 + +- 幂等身份在命名空间、操作和资源作用域内唯一;保存请求摘要和原响应。 +- 一个任务的执行代次唯一,当前尝试必须属于同命名空间、同任务。 +- 计划触发按计划与 UTC 原触发时间唯一,计划修改不会绕过触发去重。 +- 定义版本不可覆盖,实例绑定明确的版本记录。 +- 步骤属于指定实例,激活属于指定步骤;避免同租户内错接另一个流程。 +- 查询轮次与任务分别唯一;轮次、单轮尝试预算和截止时间独立保存。 +- 内部事件与外部订阅交付有去重键,租用状态要求有效租用字段。 +- 终态必须有结束时间,待执行任务不能伪造有效执行租约。 + +## 5. 仍需服务层事务保证的条件 + +外键与 `CHECK` 无法代替执行引擎。以下属于后续服务实现与回归目标: + +1. 权限验证、凭据校验、命名空间过滤和队列消费授权。 +2. 同一次事务更新任务、尝试、租约、并发许可、事件与流程交付记录。 +3. CAS 竞争、数据库时间校验、门控与开始授权串行化。 +4. 对幂等重复请求比较语义摘要,返回原响应或冲突。 +5. 计划当前版本与版本内容原子发布,信号匹配正确的业务操作。 +6. 查询轮次、预算、下一任务创建同事务提交;未知结果按证据核查。 +7. 不可变版本发布前的流程图校验、表达式类型与结构化汇合验证。 + +当前模型方法只校验局部状态或预算,不能被当作已经实现了执行授权和业务幂等。 + +## 6. 迁移与回归 + +入口是 `orbit-migrate up|status`。应用进程只检查迁移状态,不执行 DDL。迁移持有数据库级命名锁,逐语句应用;失败保留 dirty 标记。由于 MySQL DDL 不整体回滚,修复必须先对照语句和实际表结构完成核查,不提供自动清除 dirty 的命令。 + +已应用的迁移不得修改。后续使用新的前向迁移;回归环境使用独立临时数据库。初始迁移尚未在生产部署。 + +命令与验证记录见 [开发计划](08-development-plan.md) 和 [基础阶段验证报告](reports/phase-0.md)。 diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md new file mode 100644 index 0000000..d3389fc --- /dev/null +++ b/docs/08-development-plan.md @@ -0,0 +1,74 @@ +# Orbit 持续开发与回归计划 + +更新:2026-09-17。此计划对应持续开发目标,由开发过程同步维护。 + +## 1. 最终交付目标 + +交付可由多个业务项目接入的 Go + MySQL + HTTP/WebSocket 平台:Pulse 管理任务与调度,Weave 管理持久化流程,业务处理器运行在独立 Worker。研发通过独立 Go SDK 定义和发布流程。 + +完成标准包括服务端、SDK、迁移及运行工具、Markdown 接口手册、可运行场景示例,以及实际正确性/故障/性能验证记录。完成设计、单机运行或展示一条正常路径,均不等同于完成首版交付。 + +## 2. 持续推进方式 + +每个阶段按以下顺序推进: + +1. 根据已确认功能细化状态、协议、数据及完成标准。 +2. 完成一条可执行的业务无关调用链,同步 SDK 和文档。 +3. 运行单元、真实数据库、协议及端到端回归,记录失败与修复。 +4. 验证该阶段异常路径、兼容性、授权边界与可恢复性。 +5. 更新本计划、接口实现状态、示例和验证报告,再进入下一阶段。 + +常规技术选择和缺陷修复自主进行。只有确实缺少外部信息或涉及未获授权的操作时才请求用户介入。不能为了让目标显示完成而隐藏未实现项或跳过故障验收。 + +## 3. 阶段清单 + +| 阶段 | 开发内容 | 回归与完成标准 | 当前状态 | +| --- | --- | --- | --- | +| P0 基础与数据模型 | Go workspace、独立 SDK module、公共/Pulse/Weave 模型、MySQL 迁移、配置、健康检查 | 编译/竞态/静态检查;真实 MySQL 建表、隔离、唯一性、预算、不可变版本、迁移失败防护;SDK 健康调用 | 已完成并通过阶段回归(见 P0 报告) | +| P1 任务受理闭环 | 应用凭据与作用域权限、队列、幂等提交、查询、待执行取消、受理事件;SDK 客户端 | API/SDK 创建查询闭环;重复键与内容冲突、响应丢失后重报、跨命名空间拒绝、参数边界 | 待开发 | +| P2 Worker 执行闭环 | WSS 接入、能力/容量、领取与开始、租约、续租、结果确认、排空与恢复;Worker SDK | 真 Worker 执行;失联/重连/重复确认/旧尝试拒绝;业务效果未知转核查;节点故障接管 | 待开发 | +| P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、并发许可、配额/背压、可靠结果订阅 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 待开发 | +| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 待开发 | +| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 待开发 | +| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 | +| P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 待开发 | + +SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最后才开始补文档。 + +## 4. SDK 与示例交付清单 + +| 场景 | 预期调用链 | 所属阶段 | +| --- | --- | --- | +| 健康检查 | 创建客户端 → 存活/就绪查询 | P0 | +| 文件转换任务 | 提交幂等任务 → Worker 处理 → 查询结果 | P1–P2 | +| 定期数据同步 | 发布计划 → 触发任务 → 暂停/恢复 → 查看触发记录 | P3 | +| 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4 | +| 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4 | +| 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5 | +| 暂停与人工恢复 | 暂停 → 保存结果 → 核查/恢复 → 查看审计 | P5 | +| 故障恢复 | 节点/连接中断 → 相同身份核对 → 恢复或人工核查 | P2–P6 | + +每个正式示例需要:前置配置、完整可运行源码、启动命令、输入、预期输出、失败路径、幂等/清理说明。SDK API 尚未实现时,文档只能标为规划,不能给出伪装可运行的代码。 + +## 5. 回归规则 + +- 每次模型或迁移变化:执行真实 MySQL 集成测试,验证命名空间及唯一约束。 +- 每次状态或协议变化:更新服务端、SDK、兼容说明与异常场景。 +- 每次执行/恢复变化:增加对应断线、超时、重复、迟到、宕机或竞争用例。 +- 每次发布候选:全量回归、示例回归、迁移检查、故障和负载报告。 +- 验证失败先修复再推进,未执行的项目明确记为未验证。 + +## 6. 性能与高可用验收边界 + +不直接用租户总数作为容量结论。P6 先固定硬件、数据库拓扑、消息大小、任务时长、连接数、峰值与积压模型,再记录受理/派发/推进/恢复的吞吐、延迟和资源消耗。 + +本地多实例与故障注入可以验证恢复逻辑;实际生产复制确认、跨故障域部署、备份介质、RPO/RTO 及峰值余量,需要在对应部署环境复测。所有报告分别标注已验证、未验证和当前限制。 + +## 7. 文档入口 + +- [数据模型](07-data-model.md) +- [接口手册](api/README.md) +- [SDK 接入](sdk/README.md) +- [通用场景示例](examples/README.md) +- [生产验收总清单](05-production-acceptance.md) +- [P0 验证记录](reports/phase-0.md) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..13fbfd9 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,82 @@ +# Orbit · 星轨设计文档 + +- 文档版本:0.2 +- 整理日期:2026-09-17 +- 阶段:基础骨架与数据模型已实现,平台完整功能按持续计划开发;尚未完成生产验收 +- 适用范围:Orbit 统一平台,包含 Pulse 任务调度模块、Weave 流程编排模块与公共能力。平台面向多个业务系统复用。 + +## 阅读顺序 + +| 文档 | 内容 | +| --- | --- | +| [01 功能说明](01-functional-spec.md) | 平台职责、Pulse 与公共能力、操作行为与责任边界 | +| [02 技术方案](02-technical-design.md) | 组件、部署、调度与派发、MySQL 使用方式、SDK | +| [03 HTTP 与 WebSocket 协议](03-http-websocket-protocol.md) | 接口草案、消息结构、确认、重连、鉴权与兼容性 | +| [04 可靠性与数据模型](04-reliability-and-data.md) | 状态机、事务、租约、幂等、调度一致性、恢复 | +| [05 生产验收](05-production-acceptance.md) | 故障场景、性能指标、上线门槛与验证证据 | +| [06 Weave 流程设计](06-workflow-spec.md) | 已确认的首版功能、研发定义发布、持久化等待、有界查询、暂停恢复与模块协作 | +| [07 数据模型](07-data-model.md) | 当前 Go 实体、数据库约束、存储约定与实现边界 | +| [08 持续开发计划](08-development-plan.md) | 开发、测试回归、阶段验收与最终交付目标 | +| [接口手册](api/README.md) | 实际接口状态、调用说明与后续接口规划 | +| [SDK 与场景](sdk/README.md) | 可运行示例、接入方法及分阶段 SDK 交付 | + +## 已确认的决策 + +| 项目 | 决策 | +| --- | --- | +| 产品定位 | 通用任务调度与流程编排平台,支持多个业务项目复用 | +| 名称与目录 | Orbit · 星轨,目录 `orbit/` | +| 项目与部署 | 一个统一项目,内部保留 Pulse 与 Weave;平台独立于业务系统部署,业务方通过 SDK 接入 | +| 开发语言 | Go | +| 持久化 | MySQL 为任务、执行、计划、流程及结果的权威存储 | +| 通信方向 | HTTP + WebSocket;生产使用 HTTPS / WSS | +| HTTP 职责 | 任务提交、流程启动与信号、查询、计划管理、配置与管理操作 | +| WS 职责 | Worker 接入、能力与容量上报、任务派发、续租、结果上报、取消通知 | +| 执行位置 | 业务处理器位于业务项目自己的 Worker 中 | +| 外部依赖范围 | 首版以 MySQL 为必需的外部存储依赖;运行不要求 Redis、独立消息队列或独立协调服务 | +| Pulse 与公共功能范围 | 任务管理、定时计划、Worker 与恢复、资源控制、多项目权限、审计可观测、SDK 与运维 | +| Weave 定位与定义方式 | 同一平台内承担流程编排,研发通过 Go SDK 描述并发布版本,业务处理器仍在业务 Worker 中运行 | +| Weave 首版流程能力 | 顺序、条件分支、有限并行与汇合、等待信号、定时等待、有界重复查询 | +| Weave 首版管理能力 | 暂停与继续、取消、人工核查与恢复、版本固定、全程追踪 | + +Orbit 不内置特定行业规则;Pulse 管理执行,Weave 管理步骤依赖、分支和等待等流程语义。二者通过模块接口及共享事务协作,不要求相互通过 HTTP / WS 调用。 + +## 如何理解文档中的约束 + +- **已确认**:上表的平台方向、Pulse 与 Weave 首版功能范围及研发定义发布方式已在讨论中确认。 +- **设计要求**:为实现已确认的生产目标提出的可靠性约束,后续代码与测试应共同遵守。 +- **设计建议 / 草案**:本次整理补充的具体实现选择,仍需协议或架构评审。 +- **待定**:没有足够依据的参数或选择,不代表已批准或已实现。 + +本文中的“必须”描述目标行为,不代表当前已经具备该能力。示例 ID、接口与 JSON 均为设计示例。 + +## 需要继续确认的事项 + +| 事项 | 当前建议 / 处理方式 | 关联文档 | +| --- | --- | --- | +| HTTP / WS 的最终字段、路径、错误码 | 以本稿作为协议草案,评审后冻结 v1 | [协议](03-http-websocket-protocol.md) | +| Weave 技术细则 | 功能范围已确认;SDK/定义 Schema、暂停门控与计时、查询预算、并行失败策略、人工恢复身份仍需评审 | [流程](06-workflow-spec.md) | +| WS 库及后续依赖版本 | 当前基础使用 Go 1.27.1、MySQL 8.4+ 与显式 SQL;WS 库随 P2 实现锁定 | [模型基线](07-data-model.md) | +| 人工重试的任务身份 | 建议创建关联的新任务,自动重试保持原任务 ID | [功能](01-functional-spec.md) | +| Cron、固定间隔与夏令时细节 | 建议五段 Cron、固定间隔首版采用 fixed-rate;夏令时行为需明确 | [功能](01-functional-spec.md) | +| 批量大小、消息上限、心跳、租约、扫描周期 | 均需设置有限上限;数值通过原型与压测确定 | [验收](05-production-acceptance.md) | +| 幂等、事件、审计的保留周期 | 按恢复与业务追溯要求确定,不能仅随任务清理 | [数据](04-reliability-and-data.md) | +| 数据库高可用与恢复目标 | 明确故障模型、复制确认、RPO / RTO 及运维责任 | [验收](05-production-acceptance.md) | +| 容量与可用性目标 | 根据峰值提交、活跃任务、连接数和积压建立负载模型 | [验收](05-production-acceptance.md) | +| 多分区与全局配额细节 | 明确路由与配额一致性范围后实现,首版不承诺跨库原子操作 | [技术](02-technical-design.md) | + +上述事项中影响执行安全、兼容和容量验收的内容,必须在生产发布前关闭。不能将“待定”参数直接作为生产默认值。 + +## 文档维护规则 + +1. 功能文档定义使用者看到的行为;协议文档定义通信;数据文档定义权威状态与不变量,其他文档通过链接引用。 +2. 修改状态、幂等、重试、租约或确认语义时,同时更新协议、恢复场景和验收项。 +3. 改变已确认决策时记录原因、影响与迁移方式,不静默替换旧约定。 +4. 设计建议不应被描述为已验证的性能或生产保证。 +5. 当前已在 Orbit 目录初始化独立 Git 并开始代码实现;服务端、SDK、文档与测试同步维护,不修改目录外的历史项目。 + +## 决策更新 + +2026-09-17,文档更新至 0.2:用户确认研发定义发布流程及 Weave 首版功能,明确纳入有界重复查询和暂停/继续等生产管理能力。技术细则与参数继续按建议或待定标注,未将功能确认等同于实现或验收通过。 + +持续开发目标已建立。当前实现和验收进度以 [开发计划](08-development-plan.md) 与 [验证报告](reports/phase-0.md) 为准;早期设计稿中的“未实现”描述保留其编写阶段语境。 diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..8abef7a --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,34 @@ +# Orbit HTTP / WebSocket 接口手册 + +更新:2026-09-17。接口实现状态随 [开发计划](../08-development-plan.md) 更新。 + +## 当前接口状态 + +| 接口 | 状态 | 手册 | +| --- | --- | --- | +| `GET /health/live` | 已实现 | [健康检查](health.md) | +| `GET /health/ready` | 已实现,仅验证数据库与迁移记录 | [健康检查](health.md) | +| `/v1/tasks` 与任务管理 | 计划 P1–P3,尚未实现 | [任务接口规划](tasks.md) | +| `/v1/worker/connect` | 计划 P2,尚未实现 | [协议草案](../03-http-websocket-protocol.md) | +| `/v1/schedules` 与计划管理 | 计划 P3,尚未实现 | [协议草案](../03-http-websocket-protocol.md) | +| `/v1/events/connect` | 计划 P3,尚未实现 | [协议草案](../03-http-websocket-protocol.md) | +| `/v1/workflows` 与流程管理 | 计划 P4–P5,尚未实现 | [流程接口规划](workflows.md) | + +当前服务对未实现路由返回 404。健康检查就绪不表示任务调度、鉴权或流程引擎已经可用。 + +## 业务接口统一要求 + +下述业务契约尚在实现阶段细化,不能用于声称当前已有业务 API: + +- 生产使用 HTTPS/WSS,凭据通过授权头或受控握手传递,不放入 URL。 +- 应用身份、命名空间和操作权限分别校验;知道资源 ID 不等于拥有权限。 +- 稳定幂等键与请求追踪 ID 分开;超时重报保留原键。 +- 可能超过跨语言整数精度的 ID 和执行代次使用字符串。 +- 时间为 UTC RFC 3339,时长字段带单位;查询和批量操作有上限。 +- 错误码、字段、分页游标和限制值在对应阶段实现及回归后冻结。 + +健康接口目前直接返回状态对象。业务接口计划使用 `request_id` + `data` / `error` 信封,详见 [通信协议](../03-http-websocket-protocol.md)。 + +## 每个正式接口的文档模板 + +实现时必须补齐:用途与权限、HTTP 方法和路径、请求头、字段类型/必填/默认值/限制、完整请求与响应、错误码、幂等与并发规则、超时/重试/取消语义、SDK 对应方法、可运行示例、兼容版本和已覆盖的回归场景。 diff --git a/docs/api/health.md b/docs/api/health.md new file mode 100644 index 0000000..dc3c9e8 --- /dev/null +++ b/docs/api/health.md @@ -0,0 +1,45 @@ +# 健康检查接口 + +状态:已实现,基础阶段。无需鉴权;默认仅监听 `127.0.0.1:8080`。 + +## GET /health/live + +返回进程 HTTP 服务是否存活,不访问数据库。 + +```sh +curl -i http://127.0.0.1:8080/health/live +``` + +HTTP 200,`Content-Type: application/json`: + +```json +{"status":"alive"} +``` + +## GET /health/ready + +检查数据库连接和迁移记录是否与当前二进制的版本、校验和一致且没有 dirty 标记。内部检查最多 2 秒。 + +```sh +curl -i http://127.0.0.1:8080/health/ready +``` + +通过时 HTTP 200: + +```json +{"status":"ready","scope":"database_and_schema"} +``` + +数据库不可用、迁移缺失、记录被修改或存在 dirty 标记时 HTTP 503: + +```json +{"status":"not_ready"} +``` + +响应不包含 DSN、数据库错误文本或凭据。检查迁移记录不能发现所有人工 DDL 漂移,运行账户权限和迁移流程仍需受控。 + +## SDK 与运行限制 + +对应 `Client.Liveness(ctx)` 和 `Client.Readiness(ctx)`,见 [SDK 接入](../sdk/README.md)。HTTP 503 返回 `*orbit.HTTPError`。 + +此阶段的 ready 只表示数据库与迁移基础可用;尚无任务、Worker、流程或业务鉴权服务。SDK 默认请求超时为 10 秒,调用方可通过 context 设置更短期限。 diff --git a/docs/api/tasks.md b/docs/api/tasks.md new file mode 100644 index 0000000..67f2054 --- /dev/null +++ b/docs/api/tasks.md @@ -0,0 +1,16 @@ +# 任务接口规划 + +状态:计划 P1–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 恢复 | + +提交契约需要队列、处理器/参数版本、业务输入或引用、执行时间、时限、恢复安全等级和重试策略。业务参数内容由接入方解释。 + +P1 回归完成前,此页不提供看似可调用的 SDK 方法或 HTTP 请求体。接口实现后同步增加完整请求/响应、错误码及文件转换场景。 diff --git a/docs/api/workflows.md b/docs/api/workflows.md new file mode 100644 index 0000000..17ad248 --- /dev/null +++ b/docs/api/workflows.md @@ -0,0 +1,16 @@ +# 流程接口规划 + +状态:计划 P4–P5,尚未实现。研发通过 Go SDK 描述流程并显式发布,服务端保存不可变定义版本。 + +| 操作 | 候选接口 | 完成要求 | +| --- | --- | --- | +| 发布版本 | `POST /v1/workflow-definitions/{definition_id}/versions` | 图结构、类型、预算校验;同版本同内容幂等,不同内容冲突 | +| 启动实例 | `POST /v1/workflows` | 固定版本并幂等启动,首批任务与实例原子创建 | +| 查询实例 | `GET /v1/workflows/{workflow_id}` | 状态、暂停标记、版本、等待原因、预算摘要 | +| 查询步骤 | `GET /v1/workflows/{workflow_id}/steps` | 步骤、激活、查询轮次、输入/结果引用与任务关联 | +| 提交信号 | `POST /v1/workflows/{workflow_id}/signals` | 幂等、授权、业务操作匹配;受理与消费分别可查 | +| 暂停/继续 | `POST /v1/workflows/{workflow_id}:pause`、`:resume` | 控制版本与门控原子变化,保留进度、预算与审计 | +| 取消 | `POST /v1/workflows/{workflow_id}:cancel` | 停止后续工作,收集在途结果,不伪装业务撤销 | +| 核查恢复 | 具体接口待实现阶段冻结 | 权限、证据、预期版本与稳定操作身份;保留旧激活历史 | + +配套场景:并行生成结果、等待审核信号、外部作业有界查询、暂停与核查恢复。正式示例将在对应 API 和 SDK 通过端到端验证后提供。节点与状态设计见 [Weave 功能与设计](../06-workflow-spec.md)。 diff --git a/docs/examples/README.md b/docs/examples/README.md new file mode 100644 index 0000000..453fcc0 --- /dev/null +++ b/docs/examples/README.md @@ -0,0 +1,24 @@ +# 通用场景示例 + +示例只使用通用的作业、对象、文件、资源与审核信号,不包含接入方项目名称或行业规则。 + +## 已可运行:健康检查 + +- 源码:[SDK 健康检查](../../sdk/go/examples/health/main.go)。 +- 前置:已迁移的 MySQL 与已启动的基础服务。 +- 操作与预期输出:[SDK 接入说明](../sdk/README.md)。 +- 异常:数据库不可用或迁移记录异常时 readiness 返回 HTTP 503,SDK 返回类型化状态错误。 +- 清理:只读调用,无业务资源需要删除。 + +## 计划场景与验收标准 + +| 场景 | 输入与步骤 | 预期结果 | 必测异常 | 状态 | +| --- | --- | --- | --- | --- | +| 文件转换 | 对象引用 → 提交任务 → Worker 转换 → 结果引用 | 同一操作获得一次业务结果,历史可查 | 重复提交、Worker 在结果上报前中断 | P1–P2 待开发 | +| 定期同步 | 发布计划 → 周期触发 → 暂停 → 恢复 | 触发身份唯一,漏跑策略可追溯 | 多节点竞争、停机后补跑上限 | P3 待开发 | +| 并行生成 | 输入对象 → 并行计算两份结果 → 汇合 | 两支满足条件后只创建一份后继工作 | 一支失败、重复结果、汇合竞争 | P4 待开发 | +| 等待审核 | 校验 → 等待信号 → 发布结果 | 只接受授权、匹配当前操作的信号 | 早到、重复、过期与越权信号 | P4 待开发 | +| 外部作业查询 | 提交一次 → 间隔查询 → 最终结果 | 处理中消耗轮次,请求失败消耗尝试 | 回执与查询竞争、预算耗尽、响应未知 | P5 待开发 | +| 暂停与恢复 | 暂停 → 接收在途结果 → 核查 → 继续 | 历史与预算保留,已成功工作不重复 | 暂停/开始竞争、恢复时已过期 | P5 待开发 | + +每个待开发场景最终都需提供独立目录、完整源码、配置模板、启动与验证命令,不能只留下概念流程图。配套回归会先使用可控的模拟处理器或外部服务,不对真实系统产生副作用。 diff --git a/docs/reports/phase-0.md b/docs/reports/phase-0.md new file mode 100644 index 0000000..baba9ff --- /dev/null +++ b/docs/reports/phase-0.md @@ -0,0 +1,51 @@ +# P0 基础阶段验证记录 + +日期:2026-09-17。范围:数据模型、MySQL 迁移、基础 HTTP 与 SDK 健康调用。 + +## 环境 + +- macOS arm64,Go 1.27.1 官方工具链,下载摘要已核对。 +- MySQL 8.4.10,Docker 独立临时容器;随机凭据、回环随机端口,测试结束后删除容器。 +- MySQL 驱动 `github.com/go-sql-driver/mysql v1.10.1`,依赖版本记录于 go.mod/go.sum。 +- 使用真实 MySQL,不以 Mock 代替迁移或数据库约束验证。 + +## 验证结果 + +| 项目 | 结果 | +| --- | --- | +| 服务端编译 | 已通过 | +| 服务端 `go test -race ./...` | 已通过 | +| 服务端 `go vet ./...` | 已通过 | +| 两个迁移调用竞争同一数据库锁 | 已通过 | +| 30 个模型与实际数据库列名一致性 | 已通过 | +| 跨命名空间关联拒绝 | 已通过 | +| 非法任务状态、缺少租约与超尝试预算拒绝 | 已通过 | +| 本地事务回滚 | 已通过 | +| 幂等键作用域与唯一约束 | 已通过 | +| 流程发布版本不可修改 | 已通过 | +| 查询轮次、任务唯一性与预算约束 | 已通过 | +| 状态事件与审计记录不可改写 | 已通过 | +| dirty 迁移与校验和漂移阻断 | 已通过 | +| SDK HTTP 契约与错误边界 | 已通过 | +| 迁移 CLI → 真实服务 → SDK 健康示例 | 已通过 | +| 文档链接、示例与接入方标识清理 | 已通过 | +| 模型与数据库中文注释 | 30 个模型的持久化字段及 30 张表、366 个列注释已补齐,DDL 再次验证通过 | + +数据库集成用例首轮约 7.37 秒,最终复核约 2.12 秒,中文注释补齐后再验证约 1.91 秒;这只是功能测试耗时,不是容量基准。最终端到端输出为 `liveness=alive readiness=ready scope=database_and_schema`。 + +文档检查覆盖基础阶段 17 个 Markdown 文件(随后新增 AGENTS.md 维护中文注释规范)、69 个本地链接和 4 个 JSON 示例,未发现失效链接或接入方定制标识。 + +## 复现 + +```sh +make test +make vet +make build +make integration +``` + +普通 `go test` 会跳过需要数据库的测试;`make integration` 会创建专用数据库、运行实际约束测试,并启动真实服务执行 SDK 示例。显式设置 `ORBIT_TEST_MYSQL_DSN` 直接运行测试时,必须使用可丢弃的独立数据库。 + +## 未验证范围 + +目前没有任务处理器、Worker 协议、调度或流程执行引擎,不宣称这些能力已通过验收。尚未执行生产吞吐、长稳、多节点调度、数据库高可用、备份恢复和真实业务接入验收。这些内容在后续阶段实现并验证。 diff --git a/docs/sdk/README.md b/docs/sdk/README.md new file mode 100644 index 0000000..8f99694 --- /dev/null +++ b/docs/sdk/README.md @@ -0,0 +1,72 @@ +# Go SDK 接入说明 + +当前已实现客户端创建、存活与就绪查询。任务提交、Worker 和流程构建接口按开发计划逐阶段实现。 + +SDK module:`git.yuntuo.hk/k2safe/orbit/sdk/go`。服务端与 SDK 在同一 Git 仓库,使用独立 `go.mod`;SDK 当前仅依赖 Go 标准库。 + +## 1. 本地运行当前示例 + +前置条件:Go 1.27.1、MySQL 8.4+,先按项目根目录 README 迁移数据库并启动服务。 + +在仓库根目录执行: + +```sh +cd sdk/go +ORBIT_URL=http://127.0.0.1:8080 go run ./examples/health +``` + +期望输出: + +```text +liveness=alive readiness=ready scope=database_and_schema +``` + +[完整示例源码](../../sdk/go/examples/health/main.go)。示例 context 最多等待 5 秒;数据库或迁移未就绪时返回错误并以非零状态退出。 + +## 2. 当前真实调用方式 + +```go +client, err := orbit.NewClient("http://127.0.0.1:8080", orbit.Options{}) +if err != nil { + return err +} + +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() + +health, err := client.Readiness(ctx) +if err != nil { + var responseErr *orbit.HTTPError + if errors.As(err, &responseErr) { + // responseErr.StatusCode 是实际 HTTP 状态,例如 503。 + } + return err +} +fmt.Println(health.Status, health.Scope) +``` + +片段需在调用函数中使用并导入标准库包和 `orbit`;完整可运行程序见上方源码。 + +`Options.HTTPClient` 可注入专用 HTTP 客户端。SDK 复制其配置,不修改调用方客户端;禁止自动跟随重定向,限制健康响应大小,不自动重试请求,不将服务端响应正文放进错误。 + +## 3. 外部项目的本地接入 + +仓库尚未发布 SDK 版本。开发期间可使用独立工作区或本地 replace,例如在接入项目中执行: + +```sh +go mod edit -require=git.yuntuo.hk/k2safe/orbit/sdk/go@v0.0.0 +go mod edit -replace=git.yuntuo.hk/k2safe/orbit/sdk/go=/absolute/path/to/orbit/sdk/go +go mod tidy +``` + +示例路径需替换为本机实际目录。不要将本机绝对路径 replace 提交为发布依赖。后续发布 SDK 时使用独立 module 标签与兼容说明,在真实仓库取包路径验证后给出 `go get` 命令;当前不提供不存在的发行版本。 + +## 4. 后续 SDK 交付 + +- P1:凭据配置、幂等提交、查询与待执行取消。 +- P2:处理器注册、Worker 容量、WSS、续租与结果确认、优雅退出。 +- P3:计划管理、可靠结果订阅与队列控制。 +- P4:流程定义、发布、启动、信号与查询。 +- P5:有界查询、暂停继续和人工恢复入口。 + +每阶段同步更新可运行场景、错误处理与兼容测试。业务幂等、外部结果核查与 Outbox 接入责任会与真实代码示例一起说明。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e4e5454 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module git.yuntuo.hk/k2safe/orbit + +go 1.27.1 + +require github.com/go-sql-driver/mysql v1.10.1 + +require filippo.io/edwards25519 v1.2.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a95e9a --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME= +github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= diff --git a/go.work b/go.work new file mode 100644 index 0000000..4ff7073 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.27.1 + +use ( + . + ./sdk/go +) diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4b382aa --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,46 @@ +package config + +import ( + "errors" + "fmt" + "net" + "os" + "strconv" +) + +type Config struct { + HTTPAddr string + MySQLDSN string + MaxOpen int + MaxIdle int +} + +func Load() (Config, error) { return FromEnv(os.Getenv) } +func FromEnv(get func(string) string) (Config, error) { + c := Config{HTTPAddr: get("ORBIT_HTTP_ADDR"), MySQLDSN: get("ORBIT_MYSQL_DSN"), MaxOpen: 20, MaxIdle: 5} + if c.HTTPAddr == "" { + c.HTTPAddr = "127.0.0.1:8080" + } + if _, _, err := net.SplitHostPort(c.HTTPAddr); err != nil { + return Config{}, errors.New("invalid ORBIT_HTTP_ADDR") + } + if c.MySQLDSN == "" { + return Config{}, errors.New("ORBIT_MYSQL_DSN is required") + } + for _, field := range []struct { + name string + dst *int + }{{"ORBIT_DB_MAX_OPEN", &c.MaxOpen}, {"ORBIT_DB_MAX_IDLE", &c.MaxIdle}} { + if raw := get(field.name); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return Config{}, fmt.Errorf("%s must be a nonnegative integer", field.name) + } + *field.dst = n + } + } + if c.MaxOpen <= 0 || c.MaxIdle > c.MaxOpen { + return Config{}, errors.New("database pool requires 0 <= max_idle <= max_open and max_open > 0") + } + return c, nil +} diff --git a/internal/storage/mysql/db.go b/internal/storage/mysql/db.go new file mode 100644 index 0000000..5a15ac9 --- /dev/null +++ b/internal/storage/mysql/db.go @@ -0,0 +1,66 @@ +// Package mysql owns SQL connections, explicit migrations and transaction boundaries. +package mysql + +import ( + "context" + "database/sql" + "errors" + "time" + + driver "github.com/go-sql-driver/mysql" +) + +// Open configures UTC, bounded I/O and an explicit connection pool. It never +// applies migrations and never logs the DSN. TLS is chosen in the secret DSN. +func Open(ctx context.Context, dsn string, maxOpen, maxIdle int) (*sql.DB, error) { + if maxOpen <= 0 || maxIdle < 0 || maxIdle > maxOpen { + return nil, errors.New("invalid database pool configuration") + } + cfg, err := driver.ParseDSN(dsn) + if err != nil { + return nil, errors.New("invalid MySQL DSN") + } + if cfg.DBName == "" { + return nil, errors.New("MySQL database name required") + } + cfg.ParseTime = true + cfg.Loc = time.UTC + cfg.MultiStatements = false + cfg.InterpolateParams = false + cfg.Timeout = 5 * time.Second + cfg.ReadTimeout = 10 * time.Second + cfg.WriteTimeout = 10 * time.Second + if cfg.Params == nil { + cfg.Params = map[string]string{} + } + cfg.Params["time_zone"] = "'+00:00'" + cfg.Params["sql_mode"] = "'STRICT_ALL_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'" + connector, err := driver.NewConnector(cfg) + if err != nil { + return nil, errors.New("invalid MySQL connection configuration") + } + db := sql.OpenDB(connector) + db.SetMaxOpenConns(maxOpen) + db.SetMaxIdleConns(maxIdle) + db.SetConnMaxLifetime(5 * time.Minute) + db.SetConnMaxIdleTime(time.Minute) + if err := db.PingContext(ctx); err != nil { + db.Close() + return nil, errors.New("MySQL connection failed; check endpoint, credentials and TLS configuration") + } + return db, nil +} + +// WithTx does not replay callbacks: a failed commit may have succeeded remotely. +// Callers reconcile by stable operation identity before retrying an ambiguous write. +func WithTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { + tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return err + } + defer tx.Rollback() + if err := fn(tx); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/storage/mysql/integration_test.go b/internal/storage/mysql/integration_test.go new file mode 100644 index 0000000..c5d3065 --- /dev/null +++ b/internal/storage/mysql/integration_test.go @@ -0,0 +1,253 @@ +package mysql + +import ( + "context" + "crypto/sha256" + "database/sql" + "errors" + "os" + "reflect" + "sync" + "testing" + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" + platform "git.yuntuo.hk/k2safe/orbit/platform/model" + pulse "git.yuntuo.hk/k2safe/orbit/pulse/model" + weave "git.yuntuo.hk/k2safe/orbit/weave/model" + mysqldriver "github.com/go-sql-driver/mysql" +) + +func TestIntegrationSchemaAndConstraints(t *testing.T) { + dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("set ORBIT_TEST_MYSQL_DSN to a disposable database, or run make integration") + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + db, err := Open(ctx, dsn, 6, 2) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var version string + if err := db.QueryRowContext(ctx, "SELECT VERSION()").Scan(&version); err != nil { + t.Fatal(err) + } + t.Log("MySQL", version) + var wg sync.WaitGroup + results := make(chan error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { defer wg.Done(); results <- Migrate(ctx, db) }() + } + wg.Wait() + close(results) + for err := range results { + if err != nil { + t.Fatal(err) + } + } + if err := CheckSchema(ctx, db); err != nil { + t.Fatal(err) + } + exec := func(q string, args ...any) { + t.Helper() + if _, err := db.ExecContext(ctx, q, args...); err != nil { + t.Fatal(err) + } + } + id := func() core.ID { + t.Helper() + v, err := core.NewID() + if err != nil { + t.Fatal(err) + } + return v + } + reject := func(code uint16, q string, args ...any) { + t.Helper() + _, err := db.ExecContext(ctx, q, args...) + var me *mysqldriver.MySQLError + if !errors.As(err, &me) || me.Number != code { + t.Fatalf("wanted MySQL %d, got %v", code, err) + } + } + app, nsA, nsB, queueA := id(), id(), id(), id() + exec("INSERT INTO orbit_applications(id,name) VALUES (?,?)", app, string(app)) + for _, ns := range []core.ID{nsA, nsB} { + exec("INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,'test')", ns, app, string(ns)) + } + exec("INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')", nsA, queueA, string(queueA)) + taskSQL := `INSERT INTO pulse_tasks(namespace_id,id,queue_id,handler,handler_version,payload_version,payload,policy_snapshot,available_at,deadline_at,attempt_timeout_ms,max_attempts) + VALUES (?,?,?,'object.prepare','1','1','{}','{}',UTC_TIMESTAMP(6),DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR),5000,3)` + taskA, taskB := id(), id() + exec(taskSQL, nsA, taskA, queueA) + exec(taskSQL, nsA, taskB, queueA) + t.Run("namespace_isolation_and_safe_task_states", func(t *testing.T) { + reject(1452, taskSQL, nsB, id(), queueA) + reject(3819, "UPDATE pulse_tasks SET state='RUNNING' WHERE namespace_id=? AND id=?", nsA, taskA) + reject(3819, "UPDATE pulse_tasks SET state='UNRECOGNIZED' WHERE namespace_id=? AND id=?", nsA, taskA) + reject(3819, "UPDATE pulse_tasks SET state='SUCCEEDED' WHERE namespace_id=? AND id=?", nsA, taskA) + reject(3819, "UPDATE pulse_tasks SET attempt_count=4 WHERE namespace_id=? AND id=?", nsA, taskA) + var scanned core.ID + var available time.Time + if err := db.QueryRowContext(ctx, "SELECT id,available_at FROM pulse_tasks WHERE namespace_id=? AND id=?", nsA, taskA).Scan(&scanned, &available); err != nil || scanned != taskA || available.Location() != time.UTC { + t.Fatalf("binary/time roundtrip: %s %v %v", scanned, available, err) + } + }) + t.Run("transaction_rollback", func(t *testing.T) { + sentinel := errors.New("abort workflow update") + err := WithTx(ctx, db, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, "UPDATE pulse_queues SET paused=TRUE WHERE namespace_id=? AND id=?", nsA, queueA); err != nil { + return err + } + return sentinel + }) + if !errors.Is(err, sentinel) { + t.Fatal(err) + } + var paused bool + if err := db.QueryRowContext(ctx, "SELECT paused FROM pulse_queues WHERE namespace_id=? AND id=?", nsA, queueA).Scan(&paused); err != nil || paused { + t.Fatalf("rollback failed: %v %v", paused, err) + } + }) + hash := core.Digest(sha256.Sum256([]byte("test-operation"))) + t.Run("idempotency_scope", func(t *testing.T) { + q := `INSERT INTO orbit_idempotency_keys(namespace_id,id,operation,scope_key,key_digest,request_digest,resource_kind,resource_id,response,expires_at) VALUES (?,? ,?,'',?,?,'task',?,'{}',DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 DAY))` + exec(q, nsA, id(), "task.submit", hash, hash, taskA) + reject(1062, q, nsA, id(), "task.submit", hash, hash, taskB) + exec(q, nsB, id(), "task.submit", hash, hash, taskB) + exec(q, nsA, id(), "workflow.start", hash, hash, taskB) + }) + definition, defVersion, gate, workflow, step, activation := id(), id(), id(), id(), id(), id() + exec("INSERT INTO weave_definitions(namespace_id,id,name) VALUES (?,?,?)", nsA, definition, string(definition)) + versionSQL := "INSERT INTO weave_definition_versions(namespace_id,id,definition_id,version,schema_version,specification,content_digest,published_by) VALUES (?,?,?,'v1',1,'{}',?,'test')" + exec(versionSQL, nsA, defVersion, definition, hash) + exec("INSERT INTO pulse_execution_gates(namespace_id,id) VALUES (?,?)", nsA, gate) + exec(`INSERT INTO weave_instances(namespace_id,id,definition_id,definition_version_id,gate_id,input,policy_snapshot,deadline_at) VALUES (?,?,?,?,?,'{}','{}',DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR))`, nsA, workflow, definition, defVersion, gate) + exec("INSERT INTO weave_steps(namespace_id,id,workflow_id,`key`,kind) VALUES (?,?,?,'poll','POLL')", nsA, step, workflow) + exec("INSERT INTO weave_activations(namespace_id,id,workflow_id,step_id,sequence,input) VALUES (?,?,?,?,1,'{}')", nsA, activation, workflow, step) + exec("UPDATE weave_steps SET current_activation_id=? WHERE namespace_id=? AND id=?", activation, nsA, step) + t.Run("published_definitions_are_immutable", func(t *testing.T) { + reject(1062, versionSQL, nsA, id(), definition, hash) + reject(1644, "UPDATE weave_definition_versions SET specification='{}' WHERE namespace_id=? AND id=?", nsA, defVersion) + }) + t.Run("poll_identity_and_budget", func(t *testing.T) { + poll := id() + exec("INSERT INTO weave_poll_states(namespace_id,id,workflow_id,activation_id,max_rounds,max_attempts_per_round,interval_ms,deadline_at) VALUES (?,?,?,?,2,3,1000,DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR))", nsA, poll, workflow, activation) + q := "INSERT INTO weave_poll_rounds(namespace_id,id,workflow_id,activation_id,round,task_id) VALUES (?,?,?,?,?,?)" + exec(q, nsA, id(), workflow, activation, 1, taskA) + reject(1062, q, nsA, id(), workflow, activation, 1, taskB) + reject(1062, q, nsA, id(), workflow, activation, 2, taskA) + reject(3819, "UPDATE weave_poll_states SET used_rounds=3 WHERE namespace_id=? AND id=?", nsA, poll) + }) + t.Run("events_and_audit_are_append_only", func(t *testing.T) { + event, audit := id(), id() + q := "INSERT INTO orbit_events(namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at) VALUES (?,?,'task',?,1,'task.accepted','{}',UTC_TIMESTAMP(6))" + exec(q, nsA, event, taskA) + reject(1062, q, nsA, id(), taskA) + reject(1644, "UPDATE orbit_events SET payload='{}' WHERE namespace_id=? AND id=?", nsA, event) + exec("INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason) VALUES (?,?,?,'test','pause','workflow',?,'integration test')", nsA, audit, id(), workflow) + reject(1644, "UPDATE orbit_audit_events SET reason='rewritten' WHERE namespace_id=? AND id=?", nsA, audit) + }) + t.Run("model_columns_match_migrated_database", func(t *testing.T) { checkModelColumns(t, ctx, db) }) + t.Run("dirty_or_modified_migration_refuses_startup", func(t *testing.T) { + exec("UPDATE orbit_schema_migrations SET dirty=TRUE WHERE version=1") + if err := Migrate(ctx, db); err == nil { + t.Fatal("continued dirty migration") + } + if err := CheckSchema(ctx, db); err == nil { + t.Fatal("accepted dirty schema") + } + exec("UPDATE orbit_schema_migrations SET dirty=FALSE,checksum=REPEAT('0',64) WHERE version=1") + if err := Migrate(ctx, db); err == nil { + t.Fatal("accepted modified migration") + } + migrations, err := Migrations() + if err != nil { + t.Fatal(err) + } + exec("UPDATE orbit_schema_migrations SET checksum=? WHERE version=1", migrations[0].Checksum) + if err := CheckSchema(ctx, db); err != nil { + t.Fatal(err) + } + }) +} + +type tableModel interface{ TableName() string } + +func checkModelColumns(t *testing.T, ctx context.Context, db *sql.DB) { + t.Helper() + models := []tableModel{ + platform.Application{}, + platform.Namespace{}, + platform.Credential{}, + platform.IdempotencyKey{}, + platform.Event{}, + platform.Subscription{}, + platform.Delivery{}, + platform.AuditEvent{}, + pulse.Queue{}, + pulse.ExecutionGate{}, + pulse.WorkerSession{}, + pulse.Task{}, + pulse.Attempt{}, + pulse.AttemptReceipt{}, + pulse.ConcurrencyLimit{}, + pulse.Permit{}, + pulse.Schedule{}, + pulse.ScheduleVersion{}, + pulse.Occurrence{}, + weave.Definition{}, + weave.DefinitionVersion{}, + weave.Instance{}, + weave.Step{}, + weave.Activation{}, + weave.Wait{}, + weave.Signal{}, + weave.PollState{}, + weave.PollRound{}, + weave.InboxEvent{}, + weave.PendingAdvance{}, + } + for _, m := range models { + fields := map[string]bool{} + var visit func(reflect.Type) + visit = func(typ reflect.Type) { + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if f.Anonymous { + visit(f.Type) + } else if col := f.Tag.Get("db"); col != "" { + fields[col] = true + } + } + } + visit(reflect.TypeOf(m)) + rows, err := db.QueryContext(ctx, "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", m.TableName()) + if err != nil { + t.Fatal(err) + } + count := 0 + for rows.Next() { + var column string + if err := rows.Scan(&column); err != nil { + t.Fatal(err) + } + count++ + if !fields[column] { + t.Errorf("%s.%s has no Go field", m.TableName(), column) + } + delete(fields, column) + } + err = rows.Err() + rows.Close() + if err != nil { + t.Fatal(err) + } + if count == 0 || len(fields) > 0 { + t.Errorf("%s missing columns: %v", m.TableName(), fields) + } + } +} diff --git a/internal/storage/mysql/migrate.go b/internal/storage/mysql/migrate.go new file mode 100644 index 0000000..ba84bcb --- /dev/null +++ b/internal/storage/mysql/migrate.go @@ -0,0 +1,207 @@ +package mysql + +import ( + "context" + "crypto/sha256" + "database/sql" + "database/sql/driver" + "embed" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "sort" + "strconv" + "strings" + "time" +) + +//go:embed migrations/*.up.sql +var migrationFiles embed.FS + +type Migration struct { + Version uint64 + Name string + Checksum string + SQL string +} +type MigrationStatus struct { + Version uint64 `json:"version"` + Name string `json:"name"` + Checksum string `json:"checksum"` + Dirty bool `json:"dirty"` +} + +func Migrations() ([]Migration, error) { + names, err := fs.Glob(migrationFiles, "migrations/*.up.sql") + if err != nil { + return nil, err + } + result := make([]Migration, 0, len(names)) + seen := map[uint64]bool{} + for _, name := range names { + base := strings.TrimPrefix(name, "migrations/") + version, err := strconv.ParseUint(strings.SplitN(base, "_", 2)[0], 10, 64) + if err != nil || version == 0 || seen[version] { + return nil, fmt.Errorf("invalid or duplicate migration version: %s", base) + } + seen[version] = true + b, err := migrationFiles.ReadFile(name) + if err != nil { + return nil, err + } + digest := sha256.Sum256(b) + result = append(result, Migration{version, base, hex.EncodeToString(digest[:]), string(b)}) + } + sort.Slice(result, func(i, j int) bool { return result[i].Version < result[j].Version }) + return result, nil +} + +// Migrate applies explicit forward migrations while holding one connection-scoped +// advisory lock. MySQL DDL commits implicitly: interruption leaves a dirty record +// and stops further migrations until an operator reconciles the partial schema. +func Migrate(ctx context.Context, db *sql.DB) error { + migrations, err := Migrations() + if err != nil { + return err + } + conn, err := db.Conn(ctx) + if err != nil { + return err + } + defer conn.Close() + var dbName, version string + if err := conn.QueryRowContext(ctx, "SELECT DATABASE(), VERSION()").Scan(&dbName, &version); err != nil { + return err + } + var major, minor int + if _, err := fmt.Sscanf(version, "%d.%d", &major, &minor); err != nil || strings.Contains(strings.ToLower(version), "mariadb") || major < 8 || major == 8 && minor < 4 { + return errors.New("migrations require MySQL 8.4 or newer") + } + digest := sha256.Sum256([]byte(dbName)) + lockName := "orbit_migrate_" + hex.EncodeToString(digest[:20]) + var acquired sql.NullInt64 + if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, 10)", lockName).Scan(&acquired); err != nil { + return err + } + if !acquired.Valid || acquired.Int64 != 1 { + return errors.New("migration lock unavailable") + } + defer func() { + cleanup, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var released sql.NullInt64 + if err := conn.QueryRowContext(cleanup, "SELECT RELEASE_LOCK(?)", lockName).Scan(&released); err != nil || !released.Valid || released.Int64 != 1 { + _ = conn.Raw(func(any) error { return driver.ErrBadConn }) + } + }() + if _, err := conn.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS orbit_schema_migrations ( + version BIGINT UNSIGNED PRIMARY KEY, + name VARCHAR(191) NOT NULL, + checksum CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + dirty BOOLEAN NOT NULL, + applied_at DATETIME(6) NULL + ) ENGINE=InnoDB`); err != nil { + return err + } + rows, err := conn.QueryContext(ctx, "SELECT version, name, checksum, dirty FROM orbit_schema_migrations ORDER BY version") + if err != nil { + return err + } + applied := map[uint64]MigrationStatus{} + for rows.Next() { + var m MigrationStatus + if err := rows.Scan(&m.Version, &m.Name, &m.Checksum, &m.Dirty); err != nil { + rows.Close() + return err + } + applied[m.Version] = m + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + known := map[uint64]Migration{} + for _, m := range migrations { + known[m.Version] = m + } + for v, a := range applied { + m, ok := known[v] + if !ok { + return fmt.Errorf("database migration %d is newer than this binary", v) + } + if a.Dirty { + return fmt.Errorf("migration %d is dirty; reconcile partial DDL before proceeding", v) + } + if a.Checksum != m.Checksum || a.Name != m.Name { + return fmt.Errorf("migration %d checksum/name mismatch", v) + } + } + for _, m := range migrations { + if _, ok := applied[m.Version]; ok { + continue + } + for v := range applied { + if v > m.Version { + return errors.New("out-of-order migration requires operator review") + } + } + statements, err := splitSQL(m.SQL) + if err != nil { + return fmt.Errorf("migration %d: %w", m.Version, err) + } + if _, err := conn.ExecContext(ctx, "INSERT INTO orbit_schema_migrations (version,name,checksum,dirty) VALUES (?,?,?,TRUE)", m.Version, m.Name, m.Checksum); err != nil { + return err + } + for i, statement := range statements { + if _, err := conn.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("migration %d statement %d failed (marked dirty): %w", m.Version, i+1, err) + } + } + if _, err := conn.ExecContext(ctx, "UPDATE orbit_schema_migrations SET dirty=FALSE, applied_at=UTC_TIMESTAMP(6) WHERE version=? AND checksum=?", m.Version, m.Checksum); err != nil { + return err + } + } + return nil +} + +func Status(ctx context.Context, db *sql.DB) ([]MigrationStatus, error) { + rows, err := db.QueryContext(ctx, "SELECT version,name,checksum,dirty FROM orbit_schema_migrations ORDER BY version") + if err != nil { + return nil, err + } + defer rows.Close() + result := []MigrationStatus{} + for rows.Next() { + var m MigrationStatus + if err := rows.Scan(&m.Version, &m.Name, &m.Checksum, &m.Dirty); err != nil { + return nil, err + } + result = append(result, m) + } + return result, rows.Err() +} + +// CheckSchema rejects missing, modified, dirty and unexpected versions. It does +// not execute DDL. A matched migration ledger is not proof against manual DDL drift. +func CheckSchema(ctx context.Context, db *sql.DB) error { + wanted, err := Migrations() + if err != nil { + return err + } + actual, err := Status(ctx, db) + if err != nil { + return errors.New("schema unavailable; run orbit-migrate up with migration credentials") + } + if len(actual) != len(wanted) { + return errors.New("schema version mismatch") + } + for i, m := range wanted { + a := actual[i] + if a.Version != m.Version || a.Name != m.Name || a.Checksum != m.Checksum || a.Dirty { + return errors.New("schema is dirty or does not match this binary") + } + } + return nil +} diff --git a/internal/storage/mysql/migrations/000001_initial.up.sql b/internal/storage/mysql/migrations/000001_initial.up.sql new file mode 100644 index 0000000..59bb955 --- /dev/null +++ b/internal/storage/mysql/migrations/000001_initial.up.sql @@ -0,0 +1,816 @@ +-- Orbit initial schema, MySQL 8.4+. All times are UTC at microsecond precision. +-- Do not edit an applied migration. Add a new forward migration instead. + +CREATE TABLE orbit_applications ( + id BINARY(16) NOT NULL COMMENT '应用标识,全局唯一', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `name` VARCHAR(128) NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束', + `enabled` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用;关闭后拒绝相应的新接入或新操作', + PRIMARY KEY (id), + UNIQUE KEY uq_application_name (name), + CONSTRAINT ck_orbit_applications_0 CHECK (revision > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='接入应用,承载通用应用身份与启停配置'; + +CREATE TABLE orbit_namespaces ( + id BINARY(16) NOT NULL COMMENT '命名空间标识,全局唯一', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `application_id` BINARY(16) NOT NULL COMMENT '所属应用标识', + `name` VARCHAR(128) NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束', + `environment` VARCHAR(32) NOT NULL COMMENT '环境标识,用于区分开发、测试和生产等隔离范围', + `enabled` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用;关闭后拒绝相应的新接入或新操作', + PRIMARY KEY (id), + UNIQUE KEY uq_namespace_name (application_id, environment, name), + CONSTRAINT ck_orbit_namespaces_0 CHECK (revision > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='应用在指定环境中的资源隔离边界'; + +CREATE TABLE orbit_credentials ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `name` VARCHAR(128) NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束', + `secret_digest` BINARY(32) NOT NULL COMMENT '凭据秘密的 SHA-256 摘要,不保存或输出原文', + `permissions` JSON NOT NULL COMMENT '授权范围的 JSON 快照,必须由服务端解释和校验', + `expires_at` DATETIME(6) NULL COMMENT '凭据过期时间,统一 UTC;为空表示没有配置到期时间', + `revoked_at` DATETIME(6) NULL COMMENT '撤销时间;为空表示尚未撤销', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_credential_name (namespace_id, name), + CONSTRAINT ck_orbit_credentials_0 CHECK (revision > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据,只保存秘密摘要和授权范围'; + +CREATE TABLE orbit_idempotency_keys ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `operation` VARCHAR(64) NOT NULL COMMENT '稳定操作类型,用于区分不同接口的幂等作用域', + `scope_key` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '资源作用域标识;空字符串表示当前操作的默认作用域', + `key_digest` BINARY(32) NOT NULL COMMENT '稳定幂等键或资源键的 SHA-256 摘要', + `request_digest` BINARY(32) NOT NULL COMMENT '规范化请求语义的 SHA-256 摘要,用于识别同键不同内容', + `resource_kind` VARCHAR(32) NOT NULL COMMENT '关联资源类型;多态关联需要服务层额外校验', + `resource_id` BINARY(16) NOT NULL COMMENT '关联资源标识,不能单独作为访问授权依据', + `response` JSON NOT NULL COMMENT '首次可靠提交后的响应 JSON,供重复请求补取确认', + `expires_at` DATETIME(6) NOT NULL COMMENT '幂等保留截止时间,不应短于约定的重发与恢复窗口', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_idempotency_scope (namespace_id, operation, scope_key, key_digest), + KEY ix_idempotency_expiry (expires_at, namespace_id, id), + CONSTRAINT ck_orbit_idempotency_keys_0 CHECK (revision > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='请求幂等记录,关联语义摘要与首次受理响应'; + +CREATE TABLE orbit_events ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `aggregate_kind` VARCHAR(32) NOT NULL COMMENT '产生事件的聚合类型,例如任务或流程', + `aggregate_id` BINARY(16) NOT NULL COMMENT '产生事件的聚合标识', + `aggregate_revision` BIGINT UNSIGNED NOT NULL COMMENT '来源聚合的状态版本,用于去重与迟到事件判断', + `type` VARCHAR(64) NOT NULL COMMENT '事件或信号的稳定类型标识', + `payload` JSON NOT NULL COMMENT '版本化输入或事件内容;仅承载通用 JSON,不解释行业规则', + `occurred_at` DATETIME(6) NOT NULL COMMENT '事件发生时间,统一 UTC', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_event_revision (namespace_id, aggregate_kind, aggregate_id, aggregate_revision, type), + CONSTRAINT ck_orbit_events_0 CHECK (revision > 0), + CONSTRAINT ck_orbit_events_1 CHECK (aggregate_revision > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='追加保存的状态事件,供外部订阅或内部流程接续'; + +CREATE TABLE orbit_subscriptions ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `name` VARCHAR(128) NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束', + `credential_id` BINARY(16) NOT NULL COMMENT '关联的命名空间凭据标识', + `filter` JSON NOT NULL COMMENT '订阅过滤规则的 JSON 文档', + `enabled` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用;关闭后拒绝相应的新接入或新操作', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_subscription_name (namespace_id, name), + CONSTRAINT ck_orbit_subscriptions_0 CHECK (revision > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='事件订阅配置,绑定授权凭据及过滤规则'; + +CREATE TABLE orbit_deliveries ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `subscription_id` BINARY(16) NOT NULL COMMENT '目标订阅标识', + `event_id` BINARY(16) NOT NULL COMMENT '来源持久化事件标识,用于消费去重', + `state` VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '当前状态;只允许定义的状态转换并配合事务校验', + `available_at` DATETIME(6) NOT NULL COMMENT '该事件最早允许再次交付的时间,统一 UTC', + `lease_owner` BINARY(16) NULL COMMENT '租约持有者标识;未持有租约时为空', + `lease_epoch` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '递增的执行或消费代次,用于隔离旧授权', + `lease_until` DATETIME(6) NULL COMMENT '交付租约截止时间;没有交付租约时为空', + `attempts` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已产生的交付尝试次数,不等同于业务执行次数', + `acknowledged_at` DATETIME(6) NULL COMMENT '接收方可靠处理后的确认时间;未确认时为空', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_delivery_event (namespace_id, subscription_id, event_id), + KEY ix_delivery_due (namespace_id, state, available_at, id), + CONSTRAINT ck_orbit_deliveries_0 CHECK (revision > 0), + CONSTRAINT ck_orbit_deliveries_1 CHECK (state IN ('PENDING', 'LEASED', 'ACKED', 'EXPIRED')), + CONSTRAINT ck_orbit_deliveries_2 CHECK ((state = 'LEASED' AND lease_owner IS NOT NULL AND lease_until IS NOT NULL) OR (state <> 'LEASED' AND lease_owner IS NULL AND lease_until IS NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='单个事件向一个订阅的可靠交付记录'; + +CREATE TABLE orbit_audit_events ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `operation_id` BINARY(16) NOT NULL COMMENT '管理操作的稳定身份,重复操作必须保留同一身份', + `actor_id` VARCHAR(128) NOT NULL COMMENT '经认证的操作主体标识,不能直接信任请求自报值', + `action` VARCHAR(64) NOT NULL COMMENT '执行的管理动作,例如暂停、取消或核查', + `resource_kind` VARCHAR(32) NOT NULL COMMENT '关联资源类型;多态关联需要服务层额外校验', + `resource_id` BINARY(16) NOT NULL COMMENT '关联资源标识,不能单独作为访问授权依据', + `reason` VARCHAR(1024) NOT NULL COMMENT '操作原因或处理说明,需要保留审计信息', + `before` JSON NULL COMMENT '操作前的受控摘要 JSON,须脱敏', + `after` JSON NULL COMMENT '操作后的受控摘要 JSON,须脱敏', + `evidence_ref` VARCHAR(512) NULL COMMENT '业务核查证据的引用;为空表示没有附加引用', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_audit_operation (namespace_id, operation_id), + KEY ix_audit_resource (namespace_id, resource_kind, resource_id, created_at, id), + CONSTRAINT ck_orbit_audit_events_0 CHECK (revision > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='不可改写的管理审计,保留操作身份、原因和变更摘要'; + +CREATE TABLE pulse_queues ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `name` VARCHAR(128) NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束', + `paused` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '暂停标记;停止新的受控工作,不表示在途请求已停止', + `max_concurrency` INT UNSIGNED NOT NULL COMMENT '允许同时持有的有效执行许可上限,必须大于零', + `rate_per_second` INT UNSIGNED NOT NULL COMMENT '每秒新执行的速率配置;实际限流由共享协调逻辑落实', + `policy` JSON NOT NULL COMMENT '队列资源与执行配置的 JSON 文档', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_queue_name (namespace_id, name), + CONSTRAINT ck_pulse_queues_0 CHECK (revision > 0), + CONSTRAINT ck_pulse_queues_1 CHECK (max_concurrency > 0), + CONSTRAINT ck_pulse_queues_2 CHECK (rate_per_second > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='任务路由与资源控制队列,不要求每个租户单独创建队列'; + +CREATE TABLE pulse_execution_gates ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `paused` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '暂停标记;停止新的受控工作,不表示在途请求已停止', + `control_revision` BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '暂停、继续等控制操作的版本,避免旧操作覆盖新决定', + `reason` VARCHAR(1024) NULL COMMENT '操作原因或处理说明,需要保留审计信息', + PRIMARY KEY (namespace_id, id), + CONSTRAINT ck_pulse_execution_gates_0 CHECK (revision > 0), + CONSTRAINT ck_pulse_execution_gates_1 CHECK (control_revision > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='通用执行门控,协调暂停与新执行授权的竞争'; + +CREATE TABLE pulse_worker_sessions ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `worker_instance_id` BINARY(16) NOT NULL COMMENT 'Worker 进程实例标识,重连保留,进程重启应更换', + `credential_id` BINARY(16) NOT NULL COMMENT '关联的命名空间凭据标识', + `node_id` BINARY(16) NOT NULL COMMENT '承载当前连接的平台节点标识', + `session_epoch` BIGINT UNSIGNED NOT NULL COMMENT '该 Worker 实例的连接代次,用于隔离旧会话', + `capabilities` JSON NOT NULL COMMENT '已登记的处理器版本、队列等能力快照', + `capacity` INT UNSIGNED NOT NULL COMMENT 'Worker 声明的执行容量,仍受平台权限和配额约束', + `capacity_sequence` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '会话内容量消息序号,重复上报不得重复增加额度', + `draining` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否正在排空连接;排空时不接收新任务', + `expires_at` DATETIME(6) NOT NULL COMMENT '过期时间,统一 UTC;到期后的行为由对应资源策略决定', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_worker_epoch (namespace_id, worker_instance_id, session_epoch), + KEY ix_worker_expiry (expires_at, namespace_id, id), + CONSTRAINT ck_pulse_worker_sessions_0 CHECK (revision > 0), + CONSTRAINT ck_pulse_worker_sessions_1 CHECK (session_epoch > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='一次 Worker 连接的持久化身份与能力快照'; + +CREATE TABLE pulse_tasks ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `queue_id` BINARY(16) NOT NULL COMMENT '任务所属队列,必须属于同一命名空间', + `gate_id` BINARY(16) NULL COMMENT '可选执行门控;为空表示没有流程级门控,仍受队列与权限控制', + `state` VARCHAR(32) NOT NULL DEFAULT 'WAITING' COMMENT '任务状态;与执行尝试状态分开,未知业务效果进入待核查', + `handler` VARCHAR(128) NOT NULL COMMENT '业务 Worker 注册的处理器名称', + `handler_version` VARCHAR(64) NOT NULL COMMENT '处理器兼容版本,受理后不得静默替换', + `payload_version` VARCHAR(32) NOT NULL COMMENT '输入参数的解释版本', + `payload` JSON NOT NULL COMMENT '版本化输入或事件内容;仅承载通用 JSON,不解释行业规则', + `result` JSON NULL COMMENT '已保存的结果或结果引用 JSON;未取得结果时为空', + `policy_snapshot` JSON NOT NULL COMMENT '受理时冻结的实际生效策略 JSON', + `recovery_safety` VARCHAR(32) NOT NULL DEFAULT 'RECONCILE_ONLY' COMMENT '恢复安全等级;未知值按需要核查处理,不能默认自动重跑', + `business_key` VARCHAR(191) NULL COMMENT '业务侧关联键,用于检索追踪,不自动提供业务幂等', + `owner_kind` VARCHAR(32) NULL COMMENT '来源资源类型,例如流程激活;为空表示无来源关联', + `owner_id` BINARY(16) NULL COMMENT '来源资源标识,必须与来源类型同时有值或同时为空', + `priority` SMALLINT NOT NULL DEFAULT 0 COMMENT '同一调度范围内的优先级,不承诺跨队列全局顺序', + `available_at` DATETIME(6) NOT NULL COMMENT '最早可被领取或处理的时间,统一 UTC', + `start_deadline_at` DATETIME(6) NULL COMMENT '最晚允许新尝试开始的时间;为空时仅受总时限约束', + `deadline_at` DATETIME(6) NOT NULL COMMENT '总截止时间,统一 UTC;到期不能证明外部业务没有生效', + `attempt_timeout_ms` BIGINT UNSIGNED NOT NULL COMMENT '单次尝试的超时时长,单位毫秒', + `max_attempts` INT UNSIGNED NOT NULL COMMENT '最多执行尝试数,包含首次尝试', + `attempt_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已产生的执行尝试数,恢复时不得重置', + `current_attempt_id` BINARY(16) NULL COMMENT '当前关联的执行尝试,必须属于本任务', + `lease_epoch` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '递增的执行或消费代次,用于隔离旧授权', + `lease_until` DATETIME(6) NULL COMMENT '当前执行授权截止时间;没有有效授权时为空', + `cancel_requested_at` DATETIME(6) NULL COMMENT '取消意图的持久化时间;不等于取消已完成', + `retry_of` BINARY(16) NULL COMMENT '来源记录标识,人工重新发起时用于保留历史关联', + `completed_at` DATETIME(6) NULL COMMENT '终结时间;非终态为空,进入终态时必须写入', + PRIMARY KEY (namespace_id, id), + KEY ix_task_dispatch (namespace_id, queue_id, state, available_at, priority, id), + KEY ix_task_lease_recovery (state, lease_until, namespace_id, id), + KEY ix_task_business (namespace_id, business_key, created_at, id), + CONSTRAINT ck_pulse_tasks_0 CHECK (revision > 0), + CONSTRAINT ck_pulse_tasks_1 CHECK (state IN ('WAITING', 'LEASED', 'RUNNING', 'NEEDS_REVIEW', 'SUCCEEDED', 'FAILED', 'CANCELLED')), + CONSTRAINT ck_pulse_tasks_2 CHECK (recovery_safety IN ('IDEMPOTENT', 'RECONCILE_ONLY')), + CONSTRAINT ck_pulse_tasks_3 CHECK (max_attempts > 0 AND attempt_count <= max_attempts), + CONSTRAINT ck_pulse_tasks_4 CHECK (attempt_timeout_ms > 0), + CONSTRAINT ck_pulse_tasks_5 CHECK (deadline_at > available_at), + CONSTRAINT ck_pulse_tasks_6 CHECK (start_deadline_at IS NULL OR start_deadline_at <= deadline_at), + CONSTRAINT ck_pulse_tasks_7 CHECK (((state IN ('LEASED', 'RUNNING')) AND current_attempt_id IS NOT NULL AND lease_until IS NOT NULL) OR ((state NOT IN ('LEASED', 'RUNNING')) AND lease_until IS NULL)), + CONSTRAINT ck_pulse_tasks_8 CHECK ((state IN ('SUCCEEDED', 'FAILED', 'CANCELLED') AND completed_at IS NOT NULL) OR (state NOT IN ('SUCCEEDED', 'FAILED', 'CANCELLED') AND completed_at IS NULL)), + CONSTRAINT ck_pulse_tasks_9 CHECK ((owner_kind IS NULL) = (owner_id IS NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='任务主记录,执行尝试另存历史,终态不自动回退'; + +CREATE TABLE pulse_attempts ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `task_id` BINARY(16) NOT NULL COMMENT '关联的 Pulse 任务标识', + `epoch` BIGINT UNSIGNED NOT NULL COMMENT '该任务的授权代次,同一任务内唯一且递增', + `session_id` BINARY(16) NOT NULL COMMENT '当前执行归属的 Worker 会话标识', + `token_digest` BINARY(32) NOT NULL COMMENT '执行秘密令牌的 SHA-256 摘要,不输出原令牌', + `state` VARCHAR(32) NOT NULL DEFAULT 'LEASED' COMMENT '当前执行尝试的状态,不能代替整个任务的状态', + `lease_until` DATETIME(6) NOT NULL COMMENT '已确认的租约截止时间,必须用数据库时间核验', + `authorized_at` DATETIME(6) NULL COMMENT '平台允许处理器开始的时间;不能证明业务副作用已经发生', + `ended_at` DATETIME(6) NULL COMMENT '本次尝试结束时间;尚未结束时为空', + `error_code` VARCHAR(64) NULL COMMENT '稳定错误分类,供恢复策略判断;不得写入敏感原始错误', + `result` JSON NULL COMMENT '已保存的结果或结果引用 JSON;未取得结果时为空', + `result_digest` BINARY(32) NULL COMMENT '结果语义摘要,用于识别确认重报与冲突结果', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_attempt_epoch (namespace_id, task_id, epoch), + UNIQUE KEY uq_attempt_task_id (namespace_id, task_id, id), + CONSTRAINT ck_pulse_attempts_0 CHECK (revision > 0), + CONSTRAINT ck_pulse_attempts_1 CHECK (state IN ('LEASED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'EXPIRED', 'UNKNOWN')), + CONSTRAINT ck_pulse_attempts_2 CHECK (epoch > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='一次任务执行尝试,保存租约、授权代次与独立结果'; + +CREATE TABLE pulse_attempt_receipts ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `attempt_id` BINARY(16) NOT NULL COMMENT '关联的独立执行尝试标识', + `operation` VARCHAR(32) NOT NULL COMMENT '稳定操作类型,用于区分不同接口的幂等作用域', + `message_id` BINARY(16) NOT NULL COMMENT '可靠操作消息身份,跨重连重报时保持稳定', + `request_digest` BINARY(32) NOT NULL COMMENT '规范化请求语义的 SHA-256 摘要,用于识别同键不同内容', + `response` JSON NOT NULL COMMENT '首次可靠提交后的响应 JSON,供重复请求补取确认', + `expires_at` DATETIME(6) NOT NULL COMMENT '过期时间,统一 UTC;到期后的行为由对应资源策略决定', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_attempt_receipt (namespace_id, attempt_id, operation, message_id), + CONSTRAINT ck_pulse_attempt_receipts_0 CHECK (revision > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='执行操作的持久化确认,用于断线或确认丢失后的幂等补取'; + +CREATE TABLE pulse_concurrency_limits ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `scope_key` VARCHAR(64) NOT NULL COMMENT '资源作用域标识;空字符串表示当前操作的默认作用域', + `key_digest` BINARY(32) NOT NULL COMMENT '稳定幂等键或资源键的 SHA-256 摘要', + `max_permits` INT UNSIGNED NOT NULL COMMENT '该共享资源允许占用的许可上限', + `used_permits` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已占用的有效许可数,必须与许可记录同事务维护', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_limit_key (namespace_id, scope_key, key_digest), + CONSTRAINT ck_pulse_concurrency_limits_0 CHECK (revision > 0), + CONSTRAINT ck_pulse_concurrency_limits_1 CHECK (max_permits > 0 AND used_permits <= max_permits) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='跨节点共享的并发限制,实际额度更新必须处于事务内'; + +CREATE TABLE pulse_permits ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `limit_id` BINARY(16) NOT NULL COMMENT '关联的并发限制记录标识', + `attempt_id` BINARY(16) NOT NULL COMMENT '关联的独立执行尝试标识', + `lease_until` DATETIME(6) NOT NULL COMMENT '已确认的租约截止时间,必须用数据库时间核验', + `released_at` DATETIME(6) NULL COMMENT '许可释放时间;为空表示尚未记录释放', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_permit_attempt (namespace_id, limit_id, attempt_id), + KEY ix_permit_expiry (released_at, lease_until, namespace_id, id), + CONSTRAINT ck_pulse_permits_0 CHECK (revision > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='一次执行尝试占用的并发许可及释放历史'; + +CREATE TABLE pulse_schedules ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `name` VARCHAR(128) NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束', + `current_version` BIGINT UNSIGNED NOT NULL COMMENT '当前生效的计划版本号,发布时与版本记录原子更新', + `paused` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '暂停标记;停止新的受控工作,不表示在途请求已停止', + `next_fire_at` DATETIME(6) NULL COMMENT '下一计划触发时间;没有后续触发时为空', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_schedule_name (namespace_id, name), + KEY ix_schedule_due (paused, next_fire_at, namespace_id, id), + CONSTRAINT ck_pulse_schedules_0 CHECK (revision > 0), + CONSTRAINT ck_pulse_schedules_1 CHECK (current_version > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='调度计划的当前控制状态,规则内容由不可变版本保存'; + +CREATE TABLE pulse_schedule_versions ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `schedule_id` BINARY(16) NOT NULL COMMENT '所属调度计划标识', + `version` BIGINT UNSIGNED NOT NULL COMMENT '不可变发布版本;同版本不同内容必须拒绝', + `effective_at` DATETIME(6) NOT NULL COMMENT '该计划版本开始生效的时间', + `timezone` VARCHAR(64) NOT NULL COMMENT '计划计算使用的 IANA 时区名称', + `specification` JSON NOT NULL COMMENT '版本化规则定义 JSON;发布后禁止原地修改', + `task_template` JSON NOT NULL COMMENT '每次触发创建任务时采用的输入与策略模板', + `misfire_policy` VARCHAR(16) NOT NULL COMMENT '漏跑策略:跳过、合并或有界补跑', + `overlap_policy` VARCHAR(16) NOT NULL COMMENT '上次未完成时的策略:并行、串行或跳过', + `catchup_limit` INT UNSIGNED NOT NULL COMMENT '单次漏跑补偿上限;补跑策略必须配置正数', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_schedule_version (namespace_id, schedule_id, version), + CONSTRAINT ck_pulse_schedule_versions_0 CHECK (revision > 0), + CONSTRAINT ck_pulse_schedule_versions_1 CHECK (version > 0), + CONSTRAINT ck_pulse_schedule_versions_2 CHECK (misfire_policy IN ('SKIP', 'COALESCE', 'CATCH_UP')), + CONSTRAINT ck_pulse_schedule_versions_3 CHECK (overlap_policy IN ('PARALLEL', 'SERIAL', 'SKIP')), + CONSTRAINT ck_pulse_schedule_versions_4 CHECK (misfire_policy <> 'CATCH_UP' OR catchup_limit > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='已发布的不可变计划版本,记录规则与任务模板'; + +CREATE TABLE pulse_schedule_occurrences ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `schedule_id` BINARY(16) NOT NULL COMMENT '所属调度计划标识', + `schedule_version` BIGINT UNSIGNED NOT NULL COMMENT '该触发点采用的计划版本号', + `scheduled_at` DATETIME(6) NOT NULL COMMENT '原计划触发时间,统一 UTC;参与触发去重', + `decision` VARCHAR(16) NOT NULL COMMENT '触发处理决定:已触发、已跳过或已合并', + `task_id` BINARY(16) NULL COMMENT '关联的 Pulse 任务标识', + `covered_until` DATETIME(6) NULL COMMENT '合并触发覆盖的结束边界;没有合并范围时为空', + `reason` VARCHAR(1024) NULL COMMENT '操作原因或处理说明,需要保留审计信息', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_occurrence_time (namespace_id, schedule_id, scheduled_at), + UNIQUE KEY uq_occurrence_task (namespace_id, task_id), + CONSTRAINT ck_pulse_schedule_occurrences_0 CHECK (revision > 0), + CONSTRAINT ck_pulse_schedule_occurrences_1 CHECK (decision IN ('FIRED', 'SKIPPED', 'COALESCED')), + CONSTRAINT ck_pulse_schedule_occurrences_2 CHECK ((decision = 'SKIPPED' AND task_id IS NULL) OR (decision IN ('FIRED', 'COALESCED') AND task_id IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='一个原计划触发点的处理记录,包含触发、跳过或合并决定'; + +CREATE TABLE weave_definitions ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `name` VARCHAR(128) NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束', + `description` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '定义用途说明,不承载运行状态', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_definition_name (namespace_id, name), + CONSTRAINT ck_weave_definitions_0 CHECK (revision > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='流程定义的稳定身份,不保存可直接执行的业务代码'; + +CREATE TABLE weave_definition_versions ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `definition_id` BINARY(16) NOT NULL COMMENT '稳定流程定义标识', + `version` VARCHAR(64) NOT NULL COMMENT '不可变发布版本;同版本不同内容必须拒绝', + `schema_version` INT UNSIGNED NOT NULL COMMENT '流程定义文档的结构版本', + `specification` JSON NOT NULL COMMENT '版本化规则定义 JSON;发布后禁止原地修改', + `content_digest` BINARY(32) NOT NULL COMMENT '不可变定义内容的 SHA-256 摘要', + `published_by` VARCHAR(128) NOT NULL COMMENT '发布版本的认证主体,用于追溯发布来源', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_definition_version (namespace_id, definition_id, version), + UNIQUE KEY uq_definition_version_id (namespace_id, definition_id, id), + CONSTRAINT ck_weave_definition_versions_0 CHECK (revision > 0), + CONSTRAINT ck_weave_definition_versions_1 CHECK (schema_version > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='已发布的不可变流程定义版本'; + +CREATE TABLE weave_instances ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `definition_id` BINARY(16) NOT NULL COMMENT '稳定流程定义标识', + `definition_version_id` BINARY(16) NOT NULL COMMENT '实例固定使用的定义版本记录,不能随新版本发布而变化', + `gate_id` BINARY(16) NOT NULL COMMENT '执行门控标识,用于检查是否允许新的领取与开始', + `state` VARCHAR(32) NOT NULL DEFAULT 'RUNNING' COMMENT '流程执行状态,与独立的暂停标记同时展示', + `paused` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '暂停标记;停止新的受控工作,不表示在途请求已停止', + `control_revision` BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '暂停、继续等控制操作的版本,避免旧操作覆盖新决定', + `input` JSON NOT NULL COMMENT '本次实例或步骤激活的输入快照 JSON', + `output` JSON NULL COMMENT '已保存的输出 JSON;未完成时可以为空', + `policy_snapshot` JSON NOT NULL COMMENT '受理时冻结的实际生效策略 JSON', + `business_key` VARCHAR(191) NULL COMMENT '业务侧关联键,用于检索追踪,不自动提供业务幂等', + `deadline_at` DATETIME(6) NOT NULL COMMENT '流程总截止时间,暂停默认不延长此业务边界', + `cancel_requested_at` DATETIME(6) NULL COMMENT '取消意图的持久化时间;不等于取消已完成', + `completed_at` DATETIME(6) NULL COMMENT '终结时间;非终态为空,进入终态时必须写入', + `retry_of` BINARY(16) NULL COMMENT '来源记录标识,人工重新发起时用于保留历史关联', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_workflow_gate (namespace_id, gate_id), + KEY ix_workflow_state (namespace_id, state, updated_at, id), + KEY ix_workflow_business (namespace_id, business_key, created_at, id), + CONSTRAINT ck_weave_instances_0 CHECK (revision > 0), + CONSTRAINT ck_weave_instances_1 CHECK (state IN ('RUNNING', 'WAITING', 'CANCELLING', 'NEEDS_REVIEW', 'SUCCEEDED', 'FAILED', 'CANCELLED')), + CONSTRAINT ck_weave_instances_2 CHECK (control_revision > 0), + CONSTRAINT ck_weave_instances_3 CHECK ((state IN ('SUCCEEDED', 'FAILED', 'CANCELLED') AND completed_at IS NOT NULL) OR (state NOT IN ('SUCCEEDED', 'FAILED', 'CANCELLED') AND completed_at IS NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='流程运行实例,固定定义版本,暂停与执行状态分别保存'; + +CREATE TABLE weave_steps ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识', + `key` VARCHAR(128) NOT NULL COMMENT '定义中的稳定步骤键,在同一流程实例内唯一', + `kind` VARCHAR(32) NOT NULL COMMENT '节点或等待类型,决定由哪类推进规则处理', + `state` VARCHAR(32) NOT NULL DEFAULT 'BLOCKED' COMMENT '当前状态;只允许定义的状态转换并配合事务校验', + `current_activation_id` BINARY(16) NULL COMMENT '当前激活记录,必须属于本实例的本步骤', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_step_key (namespace_id, workflow_id, `key`), + UNIQUE KEY uq_step_workflow_id (namespace_id, workflow_id, id), + CONSTRAINT ck_weave_steps_0 CHECK (revision > 0), + CONSTRAINT ck_weave_steps_1 CHECK (state IN ('BLOCKED', 'READY', 'ACTIVE', 'WAITING', 'NEEDS_REVIEW', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'SKIPPED')), + CONSTRAINT ck_weave_steps_2 CHECK (kind IN ('TASK', 'BRANCH', 'PARALLEL', 'JOIN', 'SIGNAL', 'TIMER', 'POLL')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='实例中的逻辑步骤,指向该步骤当前的激活记录'; + +CREATE TABLE weave_activations ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识', + `step_id` BINARY(16) NOT NULL COMMENT '所属逻辑步骤标识', + `sequence` INT UNSIGNED NOT NULL COMMENT '同一步骤的激活序号,人工恢复递增并保留旧记录', + `state` VARCHAR(32) NOT NULL DEFAULT 'READY' COMMENT '当前状态;只允许定义的状态转换并配合事务校验', + `task_id` BINARY(16) NULL COMMENT '关联的 Pulse 任务标识', + `input` JSON NOT NULL COMMENT '本次实例或步骤激活的输入快照 JSON', + `output` JSON NULL COMMENT '已保存的输出 JSON;未完成时可以为空', + `completed_at` DATETIME(6) NULL COMMENT '终结时间;非终态为空,进入终态时必须写入', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_activation_sequence (namespace_id, step_id, sequence), + UNIQUE KEY uq_activation_workflow_id (namespace_id, workflow_id, id), + UNIQUE KEY uq_activation_step_id (namespace_id, workflow_id, step_id, id), + UNIQUE KEY uq_activation_task (namespace_id, task_id), + CONSTRAINT ck_weave_activations_0 CHECK (revision > 0), + CONSTRAINT ck_weave_activations_1 CHECK (state IN ('BLOCKED', 'READY', 'ACTIVE', 'WAITING', 'NEEDS_REVIEW', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'SKIPPED')), + CONSTRAINT ck_weave_activations_2 CHECK (sequence > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='步骤的一次具体激活,人工恢复通过新激活保留历史'; + +CREATE TABLE weave_waits ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识', + `activation_id` BINARY(16) NOT NULL COMMENT '所属步骤激活,区分旧轮次和人工恢复后的新轮次', + `kind` VARCHAR(16) NOT NULL COMMENT '节点或等待类型,决定由哪类推进规则处理', + `state` VARCHAR(32) NOT NULL DEFAULT 'WAITING' COMMENT '当前状态;只允许定义的状态转换并配合事务校验', + `correlation_key` VARCHAR(191) NOT NULL COMMENT '外部业务操作关联键,防止旧信号误用于新工作', + `deadline_at` DATETIME(6) NOT NULL COMMENT '等待截止时间,用可靠受理时间判断信号是否按时到达', + `wake_task_id` BINARY(16) NULL COMMENT '内部定时唤醒任务;无需定时唤醒时为空', + `resolved_at` DATETIME(6) NULL COMMENT '等待或查询节点结清的时间;尚未结清时为空', + PRIMARY KEY (namespace_id, id), + KEY ix_wait_deadline (state, deadline_at, namespace_id, id), + UNIQUE KEY uq_wait_workflow_id (namespace_id, workflow_id, id), + CONSTRAINT ck_weave_waits_0 CHECK (revision > 0), + CONSTRAINT ck_weave_waits_1 CHECK (state IN ('WAITING', 'SATISFIED', 'EXPIRED', 'CANCELLED')), + CONSTRAINT ck_weave_waits_2 CHECK (kind IN ('SIGNAL', 'TIMER', 'POLL')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='可恢复的信号或时间等待,不长期占用业务 Worker'; + +CREATE TABLE weave_signals ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识', + `key_digest` BINARY(32) NOT NULL COMMENT '稳定幂等键或资源键的 SHA-256 摘要', + `request_digest` BINARY(32) NOT NULL COMMENT '规范化请求语义的 SHA-256 摘要,用于识别同键不同内容', + `type` VARCHAR(64) NOT NULL COMMENT '事件或信号的稳定类型标识', + `target_step_key` VARCHAR(128) NOT NULL COMMENT '信号目标步骤键,用于早到信号的后续匹配', + `correlation_key` VARCHAR(191) NOT NULL COMMENT '外部业务操作关联键,防止旧信号误用于新工作', + `payload` JSON NOT NULL COMMENT '版本化输入或事件内容;仅承载通用 JSON,不解释行业规则', + `actor_id` VARCHAR(128) NOT NULL COMMENT '经认证的操作主体标识,不能直接信任请求自报值', + `state` VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '当前状态;只允许定义的状态转换并配合事务校验', + `accepted_at` DATETIME(6) NOT NULL COMMENT '信号可靠受理时间,不能用稍后的消费时间替代', + `expires_at` DATETIME(6) NOT NULL COMMENT '过期时间,统一 UTC;到期后的行为由对应资源策略决定', + `consumed_wait_id` BINARY(16) NULL COMMENT '消费该信号的等待记录;尚未消费时为空', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_signal_identity (namespace_id, workflow_id, key_digest), + KEY ix_signal_match (namespace_id, workflow_id, target_step_key, correlation_key, state, accepted_at), + CONSTRAINT ck_weave_signals_0 CHECK (revision > 0), + CONSTRAINT ck_weave_signals_1 CHECK (state IN ('PENDING', 'CONSUMED', 'EXPIRED', 'REJECTED')), + CONSTRAINT ck_weave_signals_2 CHECK ((state = 'CONSUMED' AND consumed_wait_id IS NOT NULL) OR (state <> 'CONSUMED' AND consumed_wait_id IS NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='已可靠受理的外部信号,受理与消费是两个阶段'; + +CREATE TABLE weave_poll_states ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识', + `activation_id` BINARY(16) NOT NULL COMMENT '所属步骤激活,区分旧轮次和人工恢复后的新轮次', + `max_rounds` INT UNSIGNED NOT NULL COMMENT '业务查询最多轮次,不等于单轮请求重试次数', + `used_rounds` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已创建的查询轮次,重启或暂停继续不得重置', + `max_attempts_per_round` INT UNSIGNED NOT NULL COMMENT '每轮 Pulse 任务最多尝试数,包含该轮首次请求', + `interval_ms` BIGINT UNSIGNED NOT NULL COMMENT '相邻业务查询轮次的等待间隔,单位毫秒', + `deadline_at` DATETIME(6) NOT NULL COMMENT '查询节点总截止时间,轮次和单轮重试都受此边界限制', + `next_poll_at` DATETIME(6) NULL COMMENT '最早允许下一轮查询的时间;为空表示当前不能直接推进', + `current_task_id` BINARY(16) NULL COMMENT '当前在途查询任务;非空时禁止重叠开启下一轮', + `resolved_at` DATETIME(6) NULL COMMENT '等待或查询节点结清的时间;尚未结清时为空', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_poll_activation (namespace_id, workflow_id, activation_id), + CONSTRAINT ck_weave_poll_states_0 CHECK (revision > 0), + CONSTRAINT ck_weave_poll_states_1 CHECK (max_rounds > 0 AND used_rounds <= max_rounds), + CONSTRAINT ck_weave_poll_states_2 CHECK (max_attempts_per_round > 0 AND interval_ms > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='有界查询节点的持久化预算与恢复检查点'; + +CREATE TABLE weave_poll_rounds ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识', + `activation_id` BINARY(16) NOT NULL COMMENT '所属步骤激活,区分旧轮次和人工恢复后的新轮次', + `round` INT UNSIGNED NOT NULL COMMENT '本次激活内的查询轮次编号,从 1 开始', + `task_id` BINARY(16) NOT NULL COMMENT '关联的 Pulse 任务标识', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_poll_round (namespace_id, workflow_id, activation_id, round), + UNIQUE KEY uq_poll_task (namespace_id, task_id), + CONSTRAINT ck_weave_poll_rounds_0 CHECK (revision > 0), + CONSTRAINT ck_weave_poll_rounds_1 CHECK (round > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='一次业务查询轮次,一轮对应一个 Pulse 任务'; + +CREATE TABLE weave_inbox ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识', + `event_id` BINARY(16) NOT NULL COMMENT '来源持久化事件标识,用于消费去重', + `state` VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '当前状态;只允许定义的状态转换并配合事务校验', + `available_at` DATETIME(6) NOT NULL COMMENT '最早可被领取或处理的时间,统一 UTC', + `lease_owner` BINARY(16) NULL COMMENT '租约持有者标识;未持有租约时为空', + `lease_epoch` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '递增的执行或消费代次,用于隔离旧授权', + `lease_until` DATETIME(6) NULL COMMENT '内部消费租约截止时间;没有消费租约时为空', + `processed_at` DATETIME(6) NULL COMMENT '内部事件完成消费的时间;尚未消费时为空', + `error_code` VARCHAR(64) NULL COMMENT '稳定错误分类,供恢复策略判断;不得写入敏感原始错误', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_inbox_event (namespace_id, event_id), + KEY ix_inbox_due (state, available_at, namespace_id, id), + CONSTRAINT ck_weave_inbox_0 CHECK (revision > 0), + CONSTRAINT ck_weave_inbox_1 CHECK (state IN ('PENDING', 'LEASED', 'PROCESSED', 'QUARANTINED')), + CONSTRAINT ck_weave_inbox_2 CHECK ((state = 'LEASED' AND lease_owner IS NOT NULL AND lease_until IS NOT NULL) OR (state <> 'LEASED' AND lease_owner IS NULL AND lease_until IS NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='Weave 内部事件消费记录,和流程推进一起确认消费'; + +CREATE TABLE weave_pending_advances ( + namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', + id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', + revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', + `workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识', + `step_id` BINARY(16) NOT NULL COMMENT '所属逻辑步骤标识', + `expected_step_revision` BIGINT UNSIGNED NOT NULL COMMENT '恢复推进时要求的步骤版本,避免使用过期检查点', + `checkpoint` JSON NOT NULL COMMENT '分批或暂停后继续推进所需的持久化 JSON 检查点', + `available_at` DATETIME(6) NOT NULL COMMENT '最早可被领取或处理的时间,统一 UTC', + PRIMARY KEY (namespace_id, id), + UNIQUE KEY uq_advance_step (namespace_id, workflow_id, step_id), + KEY ix_advance_due (available_at, namespace_id, id), + CONSTRAINT ck_weave_pending_advances_0 CHECK (revision > 0), + CONSTRAINT ck_weave_pending_advances_1 CHECK (expected_step_revision > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='暂停或分批推进后的续接检查点,避免仅依赖内存唤醒'; + +ALTER TABLE orbit_namespaces ADD CONSTRAINT fk_orbit_namespaces_0 + FOREIGN KEY (application_id) REFERENCES orbit_applications (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE orbit_credentials ADD CONSTRAINT fk_orbit_credentials_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE orbit_idempotency_keys ADD CONSTRAINT fk_orbit_idempotency_keys_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE orbit_events ADD CONSTRAINT fk_orbit_events_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE orbit_subscriptions ADD CONSTRAINT fk_orbit_subscriptions_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE orbit_subscriptions ADD CONSTRAINT fk_orbit_subscriptions_1 + FOREIGN KEY (namespace_id, credential_id) REFERENCES orbit_credentials (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE orbit_deliveries ADD CONSTRAINT fk_orbit_deliveries_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE orbit_deliveries ADD CONSTRAINT fk_orbit_deliveries_1 + FOREIGN KEY (namespace_id, subscription_id) REFERENCES orbit_subscriptions (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE orbit_deliveries ADD CONSTRAINT fk_orbit_deliveries_2 + FOREIGN KEY (namespace_id, event_id) REFERENCES orbit_events (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE orbit_audit_events ADD CONSTRAINT fk_orbit_audit_events_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_queues ADD CONSTRAINT fk_pulse_queues_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_execution_gates ADD CONSTRAINT fk_pulse_execution_gates_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_worker_sessions ADD CONSTRAINT fk_pulse_worker_sessions_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_worker_sessions ADD CONSTRAINT fk_pulse_worker_sessions_1 + FOREIGN KEY (namespace_id, credential_id) REFERENCES orbit_credentials (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_tasks ADD CONSTRAINT fk_pulse_tasks_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_tasks ADD CONSTRAINT fk_pulse_tasks_1 + FOREIGN KEY (namespace_id, queue_id) REFERENCES pulse_queues (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_tasks ADD CONSTRAINT fk_pulse_tasks_2 + FOREIGN KEY (namespace_id, gate_id) REFERENCES pulse_execution_gates (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_tasks ADD CONSTRAINT fk_pulse_tasks_3 + FOREIGN KEY (namespace_id, retry_of) REFERENCES pulse_tasks (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_tasks ADD CONSTRAINT fk_pulse_tasks_4 + FOREIGN KEY (namespace_id, id, current_attempt_id) REFERENCES pulse_attempts (namespace_id, task_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_attempts ADD CONSTRAINT fk_pulse_attempts_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_attempts ADD CONSTRAINT fk_pulse_attempts_1 + FOREIGN KEY (namespace_id, task_id) REFERENCES pulse_tasks (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_attempts ADD CONSTRAINT fk_pulse_attempts_2 + FOREIGN KEY (namespace_id, session_id) REFERENCES pulse_worker_sessions (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_attempt_receipts ADD CONSTRAINT fk_pulse_attempt_receipts_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_attempt_receipts ADD CONSTRAINT fk_pulse_attempt_receipts_1 + FOREIGN KEY (namespace_id, attempt_id) REFERENCES pulse_attempts (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_concurrency_limits ADD CONSTRAINT fk_pulse_concurrency_limits_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_permits ADD CONSTRAINT fk_pulse_permits_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_permits ADD CONSTRAINT fk_pulse_permits_1 + FOREIGN KEY (namespace_id, limit_id) REFERENCES pulse_concurrency_limits (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_permits ADD CONSTRAINT fk_pulse_permits_2 + FOREIGN KEY (namespace_id, attempt_id) REFERENCES pulse_attempts (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_schedules ADD CONSTRAINT fk_pulse_schedules_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_schedule_versions ADD CONSTRAINT fk_pulse_schedule_versions_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_schedule_versions ADD CONSTRAINT fk_pulse_schedule_versions_1 + FOREIGN KEY (namespace_id, schedule_id) REFERENCES pulse_schedules (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_schedule_occurrences ADD CONSTRAINT fk_pulse_schedule_occurrences_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_schedule_occurrences ADD CONSTRAINT fk_pulse_schedule_occurrences_1 + FOREIGN KEY (namespace_id, schedule_id, schedule_version) REFERENCES pulse_schedule_versions (namespace_id, schedule_id, version) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE pulse_schedule_occurrences ADD CONSTRAINT fk_pulse_schedule_occurrences_2 + FOREIGN KEY (namespace_id, task_id) REFERENCES pulse_tasks (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_definitions ADD CONSTRAINT fk_weave_definitions_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_definition_versions ADD CONSTRAINT fk_weave_definition_versions_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_definition_versions ADD CONSTRAINT fk_weave_definition_versions_1 + FOREIGN KEY (namespace_id, definition_id) REFERENCES weave_definitions (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_instances ADD CONSTRAINT fk_weave_instances_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_instances ADD CONSTRAINT fk_weave_instances_1 + FOREIGN KEY (namespace_id, definition_id, definition_version_id) REFERENCES weave_definition_versions (namespace_id, definition_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_instances ADD CONSTRAINT fk_weave_instances_2 + FOREIGN KEY (namespace_id, gate_id) REFERENCES pulse_execution_gates (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_instances ADD CONSTRAINT fk_weave_instances_3 + FOREIGN KEY (namespace_id, retry_of) REFERENCES weave_instances (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_steps ADD CONSTRAINT fk_weave_steps_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_steps ADD CONSTRAINT fk_weave_steps_1 + FOREIGN KEY (namespace_id, workflow_id) REFERENCES weave_instances (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_steps ADD CONSTRAINT fk_weave_steps_2 + FOREIGN KEY (namespace_id, workflow_id, id, current_activation_id) REFERENCES weave_activations (namespace_id, workflow_id, step_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_activations ADD CONSTRAINT fk_weave_activations_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_activations ADD CONSTRAINT fk_weave_activations_1 + FOREIGN KEY (namespace_id, workflow_id, step_id) REFERENCES weave_steps (namespace_id, workflow_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_activations ADD CONSTRAINT fk_weave_activations_2 + FOREIGN KEY (namespace_id, task_id) REFERENCES pulse_tasks (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_waits ADD CONSTRAINT fk_weave_waits_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_waits ADD CONSTRAINT fk_weave_waits_1 + FOREIGN KEY (namespace_id, workflow_id, activation_id) REFERENCES weave_activations (namespace_id, workflow_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_waits ADD CONSTRAINT fk_weave_waits_2 + FOREIGN KEY (namespace_id, wake_task_id) REFERENCES pulse_tasks (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_signals ADD CONSTRAINT fk_weave_signals_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_signals ADD CONSTRAINT fk_weave_signals_1 + FOREIGN KEY (namespace_id, workflow_id) REFERENCES weave_instances (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_signals ADD CONSTRAINT fk_weave_signals_2 + FOREIGN KEY (namespace_id, workflow_id, consumed_wait_id) REFERENCES weave_waits (namespace_id, workflow_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_poll_states ADD CONSTRAINT fk_weave_poll_states_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_poll_states ADD CONSTRAINT fk_weave_poll_states_1 + FOREIGN KEY (namespace_id, workflow_id, activation_id) REFERENCES weave_activations (namespace_id, workflow_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_poll_states ADD CONSTRAINT fk_weave_poll_states_2 + FOREIGN KEY (namespace_id, current_task_id) REFERENCES pulse_tasks (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_poll_rounds ADD CONSTRAINT fk_weave_poll_rounds_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_poll_rounds ADD CONSTRAINT fk_weave_poll_rounds_1 + FOREIGN KEY (namespace_id, workflow_id, activation_id) REFERENCES weave_poll_states (namespace_id, workflow_id, activation_id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_poll_rounds ADD CONSTRAINT fk_weave_poll_rounds_2 + FOREIGN KEY (namespace_id, task_id) REFERENCES pulse_tasks (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_inbox ADD CONSTRAINT fk_weave_inbox_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_inbox ADD CONSTRAINT fk_weave_inbox_1 + FOREIGN KEY (namespace_id, workflow_id) REFERENCES weave_instances (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_inbox ADD CONSTRAINT fk_weave_inbox_2 + FOREIGN KEY (namespace_id, event_id) REFERENCES orbit_events (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_pending_advances ADD CONSTRAINT fk_weave_pending_advances_0 + FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE weave_pending_advances ADD CONSTRAINT fk_weave_pending_advances_1 + FOREIGN KEY (namespace_id, workflow_id, step_id) REFERENCES weave_steps (namespace_id, workflow_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT; + +-- Published versions and event history are append-only. Retention deletes require +-- a separate administrative identity and must respect active references. +CREATE TRIGGER trg_definition_version_immutable BEFORE UPDATE ON weave_definition_versions + FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Published workflow versions are immutable'; +CREATE TRIGGER trg_schedule_version_immutable BEFORE UPDATE ON pulse_schedule_versions + FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Published schedule versions are immutable'; +CREATE TRIGGER trg_audit_event_immutable BEFORE UPDATE ON orbit_audit_events + FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Audit events are append-only'; +CREATE TRIGGER trg_event_immutable BEFORE UPDATE ON orbit_events + FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'State events are append-only'; diff --git a/internal/storage/mysql/sqlsplit.go b/internal/storage/mysql/sqlsplit.go new file mode 100644 index 0000000..ed56d51 --- /dev/null +++ b/internal/storage/mysql/sqlsplit.go @@ -0,0 +1,99 @@ +package mysql + +import ( + "errors" + "strings" +) + +// splitSQL supports ordinary migration statements, quoted values and comments. +// Stored routines and DELIMITER scripts are intentionally unsupported. +func splitSQL(src string) ([]string, error) { + var out []string + var b strings.Builder + var quote byte + lineComment, blockComment := false, false + flush := func() error { + s := strings.TrimSpace(b.String()) + b.Reset() + if s == "" { + return nil + } + if strings.HasPrefix(strings.ToUpper(s), "DELIMITER") { + return errors.New("DELIMITER scripts are unsupported") + } + out = append(out, s) + return nil + } + for i := 0; i < len(src); i++ { + c := src[i] + if lineComment { + if c == '\n' { + lineComment = false + b.WriteByte(' ') + } + continue + } + if blockComment { + if c == '*' && i+1 < len(src) && src[i+1] == '/' { + blockComment = false + i++ + b.WriteByte(' ') + } + continue + } + if quote != 0 { + b.WriteByte(c) + if c == '\\' && i+1 < len(src) { + i++ + b.WriteByte(src[i]) + continue + } + if c == quote { + if i+1 < len(src) && src[i+1] == quote { + i++ + b.WriteByte(src[i]) + } else { + quote = 0 + } + } + continue + } + if c == '#' || c == '-' && i+2 < len(src) && src[i+1] == '-' && src[i+2] <= ' ' { + lineComment = true + if c == '-' { + i++ + } + continue + } + if c == '/' && i+1 < len(src) && src[i+1] == '*' { + if i+2 < len(src) && src[i+2] == '!' { + return nil, errors.New("executable SQL comments are unsupported") + } + blockComment = true + i++ + continue + } + if c == '\'' || c == '"' || c == '`' { + quote = c + b.WriteByte(c) + continue + } + if c == ';' { + if err := flush(); err != nil { + return nil, err + } + continue + } + b.WriteByte(c) + } + if quote != 0 || blockComment { + return nil, errors.New("unterminated SQL quote or comment") + } + if err := flush(); err != nil { + return nil, err + } + if len(out) == 0 { + return nil, errors.New("empty migration") + } + return out, nil +} diff --git a/internal/storage/mysql/sqlsplit_test.go b/internal/storage/mysql/sqlsplit_test.go new file mode 100644 index 0000000..0293110 --- /dev/null +++ b/internal/storage/mysql/sqlsplit_test.go @@ -0,0 +1,19 @@ +package mysql + +import "testing" + +func TestSQLSplitterPreservesQuotedSemicolons(t *testing.T) { + sql := "-- comment;\nCREATE TABLE `x;y` (v VARCHAR(10)); INSERT INTO `x;y` VALUES ('a;''b'); /* ignore ; */" + s, err := splitSQL(sql) + if err != nil || len(s) != 2 { + t.Fatalf("split=%v err=%v", s, err) + } + if s[1] != "INSERT INTO `x;y` VALUES ('a;''b')" { + t.Fatal(s[1]) + } + for _, bad := range []string{"SELECT 'broken;", "/*broken", "/*! DROP TABLE x */", "DELIMITER $$", "-- empty"} { + if _, err := splitSQL(bad); err == nil { + t.Fatalf("accepted %q", bad) + } + } +} diff --git a/internal/transport/httpserver/server.go b/internal/transport/httpserver/server.go new file mode 100644 index 0000000..f5aeeff --- /dev/null +++ b/internal/transport/httpserver/server.go @@ -0,0 +1,34 @@ +// Package httpserver exposes bootstrap health endpoints only. Task, workflow, +// authentication and WebSocket APIs will be implemented in subsequent slices. +package httpserver + +import ( + "context" + "encoding/json" + "net/http" + "time" +) + +type Checker func(context.Context) error + +func New(addr string, ready Checker) *http.Server { + mux := http.NewServeMux() + write := func(w http.ResponseWriter, status int, body map[string]string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) + } + mux.HandleFunc("GET /health/live", func(w http.ResponseWriter, r *http.Request) { + write(w, http.StatusOK, map[string]string{"status": "alive"}) + }) + mux.HandleFunc("GET /health/ready", func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if ready == nil || ready(ctx) != nil { + write(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready"}) + return + } + 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} +} diff --git a/internal/transport/httpserver/server_test.go b/internal/transport/httpserver/server_test.go new file mode 100644 index 0000000..1cd36d8 --- /dev/null +++ b/internal/transport/httpserver/server_test.go @@ -0,0 +1,21 @@ +package httpserver + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHealthSeparatesLivenessAndReadiness(t *testing.T) { + server := New("127.0.0.1:0", func(context.Context) error { return errors.New("internal-database-details") }) + for path, want := range map[string]int{"/health/live": 200, "/health/ready": 503, "/v1/tasks": 404} { + rr := httptest.NewRecorder() + server.Handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) + if rr.Code != want || strings.Contains(rr.Body.String(), "internal-database-details") { + t.Fatalf("%s: %d %s", path, rr.Code, rr.Body.String()) + } + } +} diff --git a/model/digest.go b/model/digest.go new file mode 100644 index 0000000..8499af3 --- /dev/null +++ b/model/digest.go @@ -0,0 +1,16 @@ +package model + +import ( + "database/sql/driver" + "errors" +) + +func (d Digest) Value() (driver.Value, error) { return d[:], nil } +func (d *Digest) Scan(src any) error { + b, ok := src.([]byte) + if !ok || len(b) != len(d) { + return errors.New("digest: expected BINARY(32)") + } + copy(d[:], b) + return nil +} diff --git a/model/id.go b/model/id.go new file mode 100644 index 0000000..ea9e4b3 --- /dev/null +++ b/model/id.go @@ -0,0 +1,65 @@ +// Package model 提供 Orbit 各模块共用且不绑定存储实现的基础类型。 +// 服务端模型不直接作为公开 SDK 协议。 +package model + +import ( + "crypto/rand" + "database/sql/driver" + "encoding/hex" + "encoding/json" + "errors" + "fmt" +) + +// ID 是 128 位随机标识,对外使用小写十六进制,MySQL 使用 BINARY(16)。 +// 标识只用于定位资源,不能作为访问凭据。 +type ID string + +func NewID() (ID, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return ID(hex.EncodeToString(b[:])), nil +} + +func (id ID) Validate() error { + if len(id) != 32 { + return errors.New("id must contain 32 lowercase hexadecimal characters") + } + for _, c := range id { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return errors.New("id must contain lowercase hexadecimal characters") + } + } + return nil +} + +func (id ID) Value() (driver.Value, error) { + if err := id.Validate(); err != nil { + return nil, err + } + return hex.DecodeString(string(id)) +} + +func (id *ID) Scan(src any) error { + b, ok := src.([]byte) + if !ok || len(b) != 16 { + return fmt.Errorf("id: expected BINARY(16), got %T", src) + } + *id = ID(hex.EncodeToString(b)) + return nil +} + +func (id *ID) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + candidate := ID(s) + if err := candidate.Validate(); err != nil { + return err + } + *id = candidate + return nil +} diff --git a/model/json.go b/model/json.go new file mode 100644 index 0000000..f54e3bb --- /dev/null +++ b/model/json.go @@ -0,0 +1,58 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" +) + +// JSON 是经过合法性检查的通用文档。输入内容应使用版本化结构或业务侧引用, +// Orbit 不解释行业专用字段。 +type JSON json.RawMessage + +func (j JSON) Value() (driver.Value, error) { + if j == nil { + return nil, nil + } + if !json.Valid(j) { + return nil, errors.New("invalid JSON document") + } + return []byte(j), nil +} +func (j *JSON) Scan(src any) error { + if src == nil { + *j = nil + return nil + } + var b []byte + switch v := src.(type) { + case []byte: + b = v + case string: + b = []byte(v) + default: + return fmt.Errorf("JSON: unsupported source %T", src) + } + if !json.Valid(b) { + return errors.New("invalid JSON document") + } + *j = append((*j)[:0], b...) + return nil +} +func (j JSON) MarshalJSON() ([]byte, error) { + if j == nil { + return []byte("null"), nil + } + if !json.Valid(j) { + return nil, errors.New("invalid JSON document") + } + return []byte(j), nil +} +func (j *JSON) UnmarshalJSON(b []byte) error { + if !json.Valid(b) { + return errors.New("invalid JSON document") + } + *j = append((*j)[:0], b...) + return nil +} diff --git a/model/primitives_test.go b/model/primitives_test.go new file mode 100644 index 0000000..8a98c49 --- /dev/null +++ b/model/primitives_test.go @@ -0,0 +1,51 @@ +package model + +import ( + "encoding/json" + "testing" +) + +func TestIDBinaryAndJSONRoundTrip(t *testing.T) { + id, err := NewID() + if err != nil { + t.Fatal(err) + } + binary, err := id.Value() + if err != nil { + t.Fatal(err) + } + var copy ID + if err := copy.Scan(binary); err != nil || copy != id { + t.Fatalf("binary roundtrip: %s %v", copy, err) + } + b, err := json.Marshal(id) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, ©); err != nil || copy != id { + t.Fatalf("JSON roundtrip: %s %v", copy, err) + } + if err := json.Unmarshal([]byte(`"0123"`), ©); err == nil { + t.Fatal("accepted truncated ID") + } + if _, err := ID("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Value(); err == nil { + t.Fatal("accepted case-folded ID") + } + if err := copy.Scan([]byte{1}); err == nil { + t.Fatal("accepted truncated binary ID") + } +} +func TestJSONScanOwnsBytes(t *testing.T) { + src := []byte(`{"ok":true}`) + var doc JSON + if err := doc.Scan(src); err != nil { + t.Fatal(err) + } + src[2] = 'x' + if string(doc) != `{"ok":true}` { + t.Fatal("document aliases driver buffer") + } + if _, err := JSON(`{broken`).Value(); err == nil { + t.Fatal("accepted invalid JSON") + } +} diff --git a/model/record.go b/model/record.go new file mode 100644 index 0000000..b446ed2 --- /dev/null +++ b/model/record.go @@ -0,0 +1,21 @@ +package model + +import "time" + +// Record 是命名空间内实体的公共记录。Revision 从 1 开始,通过条件更新控制并发。 +// 时间戳只用于追踪,不代替状态版本和租约校验。 +type Record struct { + // NamespaceID 资源所属命名空间,参与主键、授权与关联校验。 + NamespaceID ID `db:"namespace_id"` + // ID 记录标识;与命名空间一起定位运行资源。 + ID ID `db:"id"` + // Revision 乐观并发版本,从 1 开始;更新时必须核对预期版本。 + Revision uint64 `db:"revision"` + // CreatedAt 创建时间,统一 UTC,数据库精度为微秒。 + CreatedAt time.Time `db:"created_at"` + // UpdatedAt 最后写入时间,统一 UTC;不能代替并发版本。 + UpdatedAt time.Time `db:"updated_at"` +} + +// Digest 保存 32 字节 SHA-256 摘要。凭据或执行令牌原文不得进入持久化模型、审计和日志。 +type Digest [32]byte diff --git a/platform/model/application.go b/platform/model/application.go new file mode 100644 index 0000000..3237dd1 --- /dev/null +++ b/platform/model/application.go @@ -0,0 +1,26 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Application 表示接入应用,承载通用应用身份与启停配置。 +// 对应表 orbit_applications;这是服务端持久化模型,不直接作为 API 响应。 +type Application struct { + // ID 应用标识,全局唯一。 + ID core.ID `db:"id"` + // Revision 乐观并发版本,从 1 开始;更新时必须核对预期版本。 + Revision uint64 `db:"revision"` + // CreatedAt 创建时间,统一 UTC,数据库精度为微秒。 + CreatedAt time.Time `db:"created_at"` + // UpdatedAt 最后写入时间,统一 UTC;不能代替并发版本。 + UpdatedAt time.Time `db:"updated_at"` + // Name 可读名称,唯一性范围由对应数据表约束。 + Name string `db:"name"` + // Enabled 是否启用;关闭后拒绝相应的新接入或新操作。 + Enabled bool `db:"enabled"` +} + +func (Application) TableName() string { return "orbit_applications" } diff --git a/platform/model/audit_event.go b/platform/model/audit_event.go new file mode 100644 index 0000000..2faa090 --- /dev/null +++ b/platform/model/audit_event.go @@ -0,0 +1,32 @@ +package model + +import ( + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// AuditEvent 表示不可改写的管理审计,保留操作身份、原因和变更摘要。 +// 对应表 orbit_audit_events;这是服务端持久化模型,不直接作为 API 响应。 +type AuditEvent struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // OperationID 管理操作的稳定身份,重复操作必须保留同一身份。 + OperationID core.ID `db:"operation_id"` + // ActorID 经认证的操作主体标识,不能直接信任请求自报值。 + ActorID string `db:"actor_id"` + // Action 执行的管理动作,例如暂停、取消或核查。 + Action string `db:"action"` + // ResourceKind 关联资源类型;多态关联需要服务层额外校验。 + ResourceKind string `db:"resource_kind"` + // ResourceID 关联资源标识,不能单独作为访问授权依据。 + ResourceID core.ID `db:"resource_id"` + // Reason 操作原因或处理说明,需要保留审计信息。 + Reason string `db:"reason"` + // Before 操作前的受控摘要 JSON,须脱敏。 + Before core.JSON `db:"before"` + // After 操作后的受控摘要 JSON,须脱敏。 + After core.JSON `db:"after"` + // EvidenceRef 业务核查证据的引用;为空表示没有附加引用。 + EvidenceRef *string `db:"evidence_ref"` +} + +func (AuditEvent) TableName() string { return "orbit_audit_events" } diff --git a/platform/model/credential.go b/platform/model/credential.go new file mode 100644 index 0000000..fb6cde1 --- /dev/null +++ b/platform/model/credential.go @@ -0,0 +1,26 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Credential 表示命名空间凭据,只保存秘密摘要和授权范围。 +// 对应表 orbit_credentials;这是服务端持久化模型,不直接作为 API 响应。 +type Credential struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // Name 可读名称,唯一性范围由对应数据表约束。 + Name string `db:"name"` + // SecretDigest 凭据秘密的 SHA-256 摘要,不保存或输出原文。 + SecretDigest core.Digest `db:"secret_digest"` + // Permissions 授权范围的 JSON 快照,必须由服务端解释和校验。 + Permissions core.JSON `db:"permissions"` + // ExpiresAt 凭据过期时间,统一 UTC;为空表示没有配置到期时间。 + ExpiresAt *time.Time `db:"expires_at"` + // RevokedAt 撤销时间;为空表示尚未撤销。 + RevokedAt *time.Time `db:"revoked_at"` +} + +func (Credential) TableName() string { return "orbit_credentials" } diff --git a/platform/model/delivery.go b/platform/model/delivery.go new file mode 100644 index 0000000..483372c --- /dev/null +++ b/platform/model/delivery.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Delivery 表示单个事件向一个订阅的可靠交付记录。 +// 对应表 orbit_deliveries;这是服务端持久化模型,不直接作为 API 响应。 +type Delivery struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // SubscriptionID 目标订阅标识。 + SubscriptionID core.ID `db:"subscription_id"` + // EventID 来源持久化事件标识,用于消费去重。 + EventID core.ID `db:"event_id"` + // State 当前状态;只允许定义的状态转换并配合事务校验。 + State DeliveryState `db:"state"` + // AvailableAt 该事件最早允许再次交付的时间,统一 UTC。 + AvailableAt time.Time `db:"available_at"` + // LeaseOwner 租约持有者标识;未持有租约时为空。 + LeaseOwner *core.ID `db:"lease_owner"` + // LeaseEpoch 递增的执行或消费代次,用于隔离旧授权。 + LeaseEpoch uint64 `db:"lease_epoch"` + // LeaseUntil 交付租约截止时间;没有交付租约时为空。 + LeaseUntil *time.Time `db:"lease_until"` + // Attempts 已产生的交付尝试次数,不等同于业务执行次数。 + Attempts uint32 `db:"attempts"` + // AcknowledgedAt 接收方可靠处理后的确认时间;未确认时为空。 + AcknowledgedAt *time.Time `db:"acknowledged_at"` +} + +func (Delivery) TableName() string { return "orbit_deliveries" } diff --git a/platform/model/event.go b/platform/model/event.go new file mode 100644 index 0000000..3065487 --- /dev/null +++ b/platform/model/event.go @@ -0,0 +1,28 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Event 表示追加保存的状态事件,供外部订阅或内部流程接续。 +// 对应表 orbit_events;这是服务端持久化模型,不直接作为 API 响应。 +type Event struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // AggregateKind 产生事件的聚合类型,例如任务或流程。 + AggregateKind string `db:"aggregate_kind"` + // AggregateID 产生事件的聚合标识。 + AggregateID core.ID `db:"aggregate_id"` + // AggregateRevision 来源聚合的状态版本,用于去重与迟到事件判断。 + AggregateRevision uint64 `db:"aggregate_revision"` + // Type 事件或信号的稳定类型标识。 + Type string `db:"type"` + // Payload 版本化输入或事件内容;仅承载通用 JSON,不解释行业规则。 + Payload core.JSON `db:"payload"` + // OccurredAt 事件发生时间,统一 UTC。 + OccurredAt time.Time `db:"occurred_at"` +} + +func (Event) TableName() string { return "orbit_events" } diff --git a/platform/model/idempotency_key.go b/platform/model/idempotency_key.go new file mode 100644 index 0000000..d9c02f6 --- /dev/null +++ b/platform/model/idempotency_key.go @@ -0,0 +1,32 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// IdempotencyKey 表示请求幂等记录,关联语义摘要与首次受理响应。 +// 对应表 orbit_idempotency_keys;这是服务端持久化模型,不直接作为 API 响应。 +type IdempotencyKey struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // Operation 稳定操作类型,用于区分不同接口的幂等作用域。 + Operation string `db:"operation"` + // ScopeKey 资源作用域标识;空字符串表示当前操作的默认作用域。 + ScopeKey string `db:"scope_key"` + // KeyDigest 稳定幂等键或资源键的 SHA-256 摘要。 + KeyDigest core.Digest `db:"key_digest"` + // RequestDigest 规范化请求语义的 SHA-256 摘要,用于识别同键不同内容。 + RequestDigest core.Digest `db:"request_digest"` + // ResourceKind 关联资源类型;多态关联需要服务层额外校验。 + ResourceKind string `db:"resource_kind"` + // ResourceID 关联资源标识,不能单独作为访问授权依据。 + ResourceID core.ID `db:"resource_id"` + // Response 首次可靠提交后的响应 JSON,供重复请求补取确认。 + Response core.JSON `db:"response"` + // ExpiresAt 幂等保留截止时间,不应短于约定的重发与恢复窗口。 + ExpiresAt time.Time `db:"expires_at"` +} + +func (IdempotencyKey) TableName() string { return "orbit_idempotency_keys" } diff --git a/platform/model/namespace.go b/platform/model/namespace.go new file mode 100644 index 0000000..454f5f3 --- /dev/null +++ b/platform/model/namespace.go @@ -0,0 +1,30 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Namespace 表示应用在指定环境中的资源隔离边界。 +// 对应表 orbit_namespaces;这是服务端持久化模型,不直接作为 API 响应。 +type Namespace struct { + // ID 命名空间标识,全局唯一。 + ID core.ID `db:"id"` + // Revision 乐观并发版本,从 1 开始;更新时必须核对预期版本。 + Revision uint64 `db:"revision"` + // CreatedAt 创建时间,统一 UTC,数据库精度为微秒。 + CreatedAt time.Time `db:"created_at"` + // UpdatedAt 最后写入时间,统一 UTC;不能代替并发版本。 + UpdatedAt time.Time `db:"updated_at"` + // ApplicationID 所属应用标识。 + ApplicationID core.ID `db:"application_id"` + // Name 可读名称,唯一性范围由对应数据表约束。 + Name string `db:"name"` + // Environment 环境标识,用于区分开发、测试和生产等隔离范围。 + Environment string `db:"environment"` + // Enabled 是否启用;关闭后拒绝相应的新接入或新操作。 + Enabled bool `db:"enabled"` +} + +func (Namespace) TableName() string { return "orbit_namespaces" } diff --git a/platform/model/state.go b/platform/model/state.go new file mode 100644 index 0000000..f13de60 --- /dev/null +++ b/platform/model/state.go @@ -0,0 +1,10 @@ +package model + +type DeliveryState string + +const ( + DeliveryPending DeliveryState = "PENDING" + DeliveryLeased DeliveryState = "LEASED" + DeliveryAcked DeliveryState = "ACKED" + DeliveryExpired DeliveryState = "EXPIRED" +) diff --git a/platform/model/subscription.go b/platform/model/subscription.go new file mode 100644 index 0000000..aecd947 --- /dev/null +++ b/platform/model/subscription.go @@ -0,0 +1,22 @@ +package model + +import ( + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Subscription 表示事件订阅配置,绑定授权凭据及过滤规则。 +// 对应表 orbit_subscriptions;这是服务端持久化模型,不直接作为 API 响应。 +type Subscription struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // Name 可读名称,唯一性范围由对应数据表约束。 + Name string `db:"name"` + // CredentialID 关联的命名空间凭据标识。 + CredentialID core.ID `db:"credential_id"` + // Filter 订阅过滤规则的 JSON 文档。 + Filter core.JSON `db:"filter"` + // Enabled 是否启用;关闭后拒绝相应的新接入或新操作。 + Enabled bool `db:"enabled"` +} + +func (Subscription) TableName() string { return "orbit_subscriptions" } diff --git a/pulse/model/attempt.go b/pulse/model/attempt.go new file mode 100644 index 0000000..f68fb4d --- /dev/null +++ b/pulse/model/attempt.go @@ -0,0 +1,38 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Attempt 表示一次任务执行尝试,保存租约、授权代次与独立结果。 +// 对应表 pulse_attempts;这是服务端持久化模型,不直接作为 API 响应。 +type Attempt struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // TaskID 关联的 Pulse 任务标识。 + TaskID core.ID `db:"task_id"` + // Epoch 该任务的授权代次,同一任务内唯一且递增。 + Epoch uint64 `db:"epoch"` + // SessionID 当前执行归属的 Worker 会话标识。 + SessionID core.ID `db:"session_id"` + // TokenDigest 执行秘密令牌的 SHA-256 摘要,不输出原令牌。 + TokenDigest core.Digest `db:"token_digest"` + // State 当前执行尝试的状态,不能代替整个任务的状态。 + State AttemptState `db:"state"` + // LeaseUntil 已确认的租约截止时间,必须用数据库时间核验。 + LeaseUntil time.Time `db:"lease_until"` + // AuthorizedAt 平台允许处理器开始的时间;不能证明业务副作用已经发生;可为空,表示尚未设置该关联或值。 + AuthorizedAt *time.Time `db:"authorized_at"` + // EndedAt 本次尝试结束时间;尚未结束时为空。 + EndedAt *time.Time `db:"ended_at"` + // ErrorCode 稳定错误分类,供恢复策略判断;不得写入敏感原始错误;可为空,表示尚未设置该关联或值。 + ErrorCode *string `db:"error_code"` + // Result 已保存的结果或结果引用 JSON;未取得结果时为空。 + Result core.JSON `db:"result"` + // ResultDigest 结果语义摘要,用于识别确认重报与冲突结果;可为空,表示尚未设置该关联或值。 + ResultDigest *core.Digest `db:"result_digest"` +} + +func (Attempt) TableName() string { return "pulse_attempts" } diff --git a/pulse/model/attempt_receipt.go b/pulse/model/attempt_receipt.go new file mode 100644 index 0000000..8c64542 --- /dev/null +++ b/pulse/model/attempt_receipt.go @@ -0,0 +1,28 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// AttemptReceipt 表示执行操作的持久化确认,用于断线或确认丢失后的幂等补取。 +// 对应表 pulse_attempt_receipts;这是服务端持久化模型,不直接作为 API 响应。 +type AttemptReceipt struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // AttemptID 关联的独立执行尝试标识。 + AttemptID core.ID `db:"attempt_id"` + // Operation 稳定操作类型,用于区分不同接口的幂等作用域。 + Operation string `db:"operation"` + // MessageID 可靠操作消息身份,跨重连重报时保持稳定。 + MessageID core.ID `db:"message_id"` + // RequestDigest 规范化请求语义的 SHA-256 摘要,用于识别同键不同内容。 + RequestDigest core.Digest `db:"request_digest"` + // Response 首次可靠提交后的响应 JSON,供重复请求补取确认。 + Response core.JSON `db:"response"` + // ExpiresAt 过期时间,统一 UTC;到期后的行为由对应资源策略决定。 + ExpiresAt time.Time `db:"expires_at"` +} + +func (AttemptReceipt) TableName() string { return "pulse_attempt_receipts" } diff --git a/pulse/model/concurrency_limit.go b/pulse/model/concurrency_limit.go new file mode 100644 index 0000000..ed8af11 --- /dev/null +++ b/pulse/model/concurrency_limit.go @@ -0,0 +1,22 @@ +package model + +import ( + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// ConcurrencyLimit 表示跨节点共享的并发限制,实际额度更新必须处于事务内。 +// 对应表 pulse_concurrency_limits;这是服务端持久化模型,不直接作为 API 响应。 +type ConcurrencyLimit struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // ScopeKey 资源作用域标识;空字符串表示当前操作的默认作用域。 + ScopeKey string `db:"scope_key"` + // KeyDigest 稳定幂等键或资源键的 SHA-256 摘要。 + KeyDigest core.Digest `db:"key_digest"` + // MaxPermits 该共享资源允许占用的许可上限。 + MaxPermits uint32 `db:"max_permits"` + // UsedPermits 已占用的有效许可数,必须与许可记录同事务维护。 + UsedPermits uint32 `db:"used_permits"` +} + +func (ConcurrencyLimit) TableName() string { return "pulse_concurrency_limits" } diff --git a/pulse/model/execution_gate.go b/pulse/model/execution_gate.go new file mode 100644 index 0000000..2ad607f --- /dev/null +++ b/pulse/model/execution_gate.go @@ -0,0 +1,20 @@ +package model + +import ( + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// ExecutionGate 表示通用执行门控,协调暂停与新执行授权的竞争。 +// 对应表 pulse_execution_gates;这是服务端持久化模型,不直接作为 API 响应。 +type ExecutionGate struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // Paused 暂停标记;停止新的受控工作,不表示在途请求已停止。 + Paused bool `db:"paused"` + // ControlRevision 暂停、继续等控制操作的版本,避免旧操作覆盖新决定。 + ControlRevision uint64 `db:"control_revision"` + // Reason 操作原因或处理说明,需要保留审计信息;可为空,表示尚未设置该关联或值。 + Reason *string `db:"reason"` +} + +func (ExecutionGate) TableName() string { return "pulse_execution_gates" } diff --git a/pulse/model/occurrence.go b/pulse/model/occurrence.go new file mode 100644 index 0000000..daf6456 --- /dev/null +++ b/pulse/model/occurrence.go @@ -0,0 +1,30 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Occurrence 表示一个原计划触发点的处理记录,包含触发、跳过或合并决定。 +// 对应表 pulse_schedule_occurrences;这是服务端持久化模型,不直接作为 API 响应。 +type Occurrence struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // ScheduleID 所属调度计划标识。 + ScheduleID core.ID `db:"schedule_id"` + // ScheduleVersion 该触发点采用的计划版本号。 + ScheduleVersion uint64 `db:"schedule_version"` + // ScheduledAt 原计划触发时间,统一 UTC;参与触发去重。 + ScheduledAt time.Time `db:"scheduled_at"` + // Decision 触发处理决定:已触发、已跳过或已合并。 + Decision string `db:"decision"` + // TaskID 关联的 Pulse 任务标识;可为空,表示尚未设置该关联或值。 + TaskID *core.ID `db:"task_id"` + // CoveredUntil 合并触发覆盖的结束边界;没有合并范围时为空。 + CoveredUntil *time.Time `db:"covered_until"` + // Reason 操作原因或处理说明,需要保留审计信息;可为空,表示尚未设置该关联或值。 + Reason *string `db:"reason"` +} + +func (Occurrence) TableName() string { return "pulse_schedule_occurrences" } diff --git a/pulse/model/permit.go b/pulse/model/permit.go new file mode 100644 index 0000000..7ffe9ef --- /dev/null +++ b/pulse/model/permit.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Permit 表示一次执行尝试占用的并发许可及释放历史。 +// 对应表 pulse_permits;这是服务端持久化模型,不直接作为 API 响应。 +type Permit struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // LimitID 关联的并发限制记录标识。 + LimitID core.ID `db:"limit_id"` + // AttemptID 关联的独立执行尝试标识。 + AttemptID core.ID `db:"attempt_id"` + // LeaseUntil 已确认的租约截止时间,必须用数据库时间核验。 + LeaseUntil time.Time `db:"lease_until"` + // ReleasedAt 许可释放时间;为空表示尚未记录释放。 + ReleasedAt *time.Time `db:"released_at"` +} + +func (Permit) TableName() string { return "pulse_permits" } diff --git a/pulse/model/policy.go b/pulse/model/policy.go new file mode 100644 index 0000000..3db1387 --- /dev/null +++ b/pulse/model/policy.go @@ -0,0 +1,33 @@ +package model + +import ( + "errors" + "time" +) + +// RetryPolicy 保存到受理时冻结的策略快照,时长统一使用毫秒。 +// MaxAttempts 包含首次尝试,不能理解为首次之外的重试次数。 +type RetryPolicy struct { + // MaxAttempts 最多执行尝试数,包含首次尝试。 + MaxAttempts uint32 `json:"max_attempts"` + // InitialDelayMS 首次重试前的等待时长,单位毫秒。 + InitialDelayMS uint64 `json:"initial_delay_ms"` + // MaxDelayMS 重试等待时长上限,单位毫秒。 + MaxDelayMS uint64 `json:"max_delay_ms"` + // AttemptTimeoutMS 单次执行尝试的超时时长,单位毫秒。 + AttemptTimeoutMS uint64 `json:"attempt_timeout_ms"` +} + +func (p RetryPolicy) Validate() error { + maxMS := uint64((1<<63 - 1) / int64(time.Millisecond)) + if p.MaxAttempts == 0 || p.AttemptTimeoutMS == 0 || p.AttemptTimeoutMS > maxMS { + return errors.New("finite attempt count and representable positive timeout required") + } + if p.InitialDelayMS > p.MaxDelayMS || p.MaxDelayMS > maxMS { + return errors.New("invalid retry delay range") + } + if p.MaxAttempts > 1 && p.InitialDelayMS == 0 { + return errors.New("retry delay must be positive") + } + return nil +} diff --git a/pulse/model/queue.go b/pulse/model/queue.go new file mode 100644 index 0000000..ce4087f --- /dev/null +++ b/pulse/model/queue.go @@ -0,0 +1,24 @@ +package model + +import ( + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Queue 表示任务路由与资源控制队列,不要求每个租户单独创建队列。 +// 对应表 pulse_queues;这是服务端持久化模型,不直接作为 API 响应。 +type Queue struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // Name 可读名称,唯一性范围由对应数据表约束。 + Name string `db:"name"` + // Paused 暂停标记;停止新的受控工作,不表示在途请求已停止。 + Paused bool `db:"paused"` + // MaxConcurrency 允许同时持有的有效执行许可上限,必须大于零。 + MaxConcurrency uint32 `db:"max_concurrency"` + // RatePerSecond 每秒新执行的速率配置;实际限流由共享协调逻辑落实。 + RatePerSecond uint32 `db:"rate_per_second"` + // Policy 队列资源与执行配置的 JSON 文档。 + Policy core.JSON `db:"policy"` +} + +func (Queue) TableName() string { return "pulse_queues" } diff --git a/pulse/model/schedule.go b/pulse/model/schedule.go new file mode 100644 index 0000000..d600468 --- /dev/null +++ b/pulse/model/schedule.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Schedule 表示调度计划的当前控制状态,规则内容由不可变版本保存。 +// 对应表 pulse_schedules;这是服务端持久化模型,不直接作为 API 响应。 +type Schedule struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // Name 可读名称,唯一性范围由对应数据表约束。 + Name string `db:"name"` + // CurrentVersion 当前生效的计划版本号,发布时与版本记录原子更新。 + CurrentVersion uint64 `db:"current_version"` + // Paused 暂停标记;停止新的受控工作,不表示在途请求已停止。 + Paused bool `db:"paused"` + // NextFireAt 下一计划触发时间;没有后续触发时为空。 + NextFireAt *time.Time `db:"next_fire_at"` +} + +func (Schedule) TableName() string { return "pulse_schedules" } diff --git a/pulse/model/schedule_version.go b/pulse/model/schedule_version.go new file mode 100644 index 0000000..f753142 --- /dev/null +++ b/pulse/model/schedule_version.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// ScheduleVersion 表示已发布的不可变计划版本,记录规则与任务模板。 +// 对应表 pulse_schedule_versions;这是服务端持久化模型,不直接作为 API 响应。 +type ScheduleVersion struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // ScheduleID 所属调度计划标识。 + ScheduleID core.ID `db:"schedule_id"` + // Version 不可变发布版本;同版本不同内容必须拒绝。 + Version uint64 `db:"version"` + // EffectiveAt 该计划版本开始生效的时间。 + EffectiveAt time.Time `db:"effective_at"` + // Timezone 计划计算使用的 IANA 时区名称。 + Timezone string `db:"timezone"` + // Specification 版本化规则定义 JSON;发布后禁止原地修改。 + Specification core.JSON `db:"specification"` + // TaskTemplate 每次触发创建任务时采用的输入与策略模板。 + TaskTemplate core.JSON `db:"task_template"` + // MisfirePolicy 漏跑策略:跳过、合并或有界补跑。 + MisfirePolicy string `db:"misfire_policy"` + // OverlapPolicy 上次未完成时的策略:并行、串行或跳过。 + OverlapPolicy string `db:"overlap_policy"` + // CatchupLimit 单次漏跑补偿上限;补跑策略必须配置正数。 + CatchupLimit uint32 `db:"catchup_limit"` +} + +func (ScheduleVersion) TableName() string { return "pulse_schedule_versions" } diff --git a/pulse/model/state.go b/pulse/model/state.go new file mode 100644 index 0000000..e98a7a9 --- /dev/null +++ b/pulse/model/state.go @@ -0,0 +1,57 @@ +package model + +// 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" // 取消已确认,不能理解为业务回滚。 +) + +func (s TaskState) Terminal() bool { + return s == TaskSucceeded || s == TaskFailed || s == TaskCancelled +} + +// CanTransitionTo 只检查状态图,不能代替数据库事务中的权限、版本、租约、 +// 暂停门控、副作用证据和重试预算校验。 +func (s TaskState) CanTransitionTo(next TaskState) bool { + switch s { + case TaskWaiting: + return next == TaskLeased || next == TaskCancelled || next == TaskFailed + case TaskLeased: + return next == TaskRunning || next == TaskWaiting || next == TaskCancelled || next == TaskFailed + case TaskRunning: + return next == TaskSucceeded || next == TaskWaiting || next == TaskFailed || next == TaskNeedsReview || next == TaskCancelled + case TaskNeedsReview: + return next == TaskSucceeded || next == TaskFailed + default: + return false + } +} + +type AttemptState string + +const ( + AttemptLeased AttemptState = "LEASED" + AttemptRunning AttemptState = "RUNNING" + AttemptSucceeded AttemptState = "SUCCEEDED" + AttemptFailed AttemptState = "FAILED" + AttemptCancelled AttemptState = "CANCELLED" + AttemptExpired AttemptState = "EXPIRED" + AttemptUnknown AttemptState = "UNKNOWN" +) + +// RecoverySafety 表示恢复安全等级,空值或未知值默认需要核查。 +type RecoverySafety string + +const ( + RecoveryIdempotent RecoverySafety = "IDEMPOTENT" // 接入方明确保证可幂等恢复。 + RecoveryReconcileOnly RecoverySafety = "RECONCILE_ONLY" // 恢复前必须查询结果或人工核查。 +) + +func (s RecoverySafety) CanRecoverAutomatically() bool { return s == RecoveryIdempotent } diff --git a/pulse/model/state_test.go b/pulse/model/state_test.go new file mode 100644 index 0000000..8b621c7 --- /dev/null +++ b/pulse/model/state_test.go @@ -0,0 +1,29 @@ +package model + +import "testing" + +func TestRecoveryDoesNotReopenTerminalOrUnknownWork(t *testing.T) { + for _, state := range []TaskState{TaskSucceeded, TaskFailed, TaskCancelled, TaskNeedsReview, TaskState("new-future-state")} { + if state.CanTransitionTo(TaskWaiting) || state.CanTransitionTo(TaskRunning) { + t.Fatalf("unsafe automatic restart from %s", state) + } + } + for _, safety := range []RecoverySafety{"", RecoveryReconcileOnly, "future"} { + if safety.CanRecoverAutomatically() { + t.Fatalf("unsafe default: %s", safety) + } + } + if !TaskRunning.CanTransitionTo(TaskNeedsReview) { + t.Fatal("unknown effects cannot be preserved") + } +} +func TestRetryPolicyRejectsUnboundedOrOverflowingParameters(t *testing.T) { + for _, p := range []RetryPolicy{{}, {MaxAttempts: 2, AttemptTimeoutMS: 1}, {MaxAttempts: 1, AttemptTimeoutMS: ^uint64(0)}, {MaxAttempts: 2, AttemptTimeoutMS: 1, InitialDelayMS: 100, MaxDelayMS: 1}} { + if p.Validate() == nil { + t.Fatalf("accepted unsafe policy: %+v", p) + } + } + if err := (RetryPolicy{MaxAttempts: 3, AttemptTimeoutMS: 1000, InitialDelayMS: 100, MaxDelayMS: 5000}).Validate(); err != nil { + t.Fatal(err) + } +} diff --git a/pulse/model/task.go b/pulse/model/task.go new file mode 100644 index 0000000..636338a --- /dev/null +++ b/pulse/model/task.go @@ -0,0 +1,68 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Task 表示任务主记录,执行尝试另存历史,终态不自动回退。 +// 对应表 pulse_tasks;这是服务端持久化模型,不直接作为 API 响应。 +type Task struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // QueueID 任务所属队列,必须属于同一命名空间。 + QueueID core.ID `db:"queue_id"` + // GateID 可选执行门控;为空表示没有流程级门控,仍受队列与权限控制。 + GateID *core.ID `db:"gate_id"` + // State 任务状态;与执行尝试状态分开,未知业务效果进入待核查。 + State TaskState `db:"state"` + // Handler 业务 Worker 注册的处理器名称。 + Handler string `db:"handler"` + // HandlerVersion 处理器兼容版本,受理后不得静默替换。 + HandlerVersion string `db:"handler_version"` + // PayloadVersion 输入参数的解释版本。 + PayloadVersion string `db:"payload_version"` + // Payload 版本化输入或事件内容;仅承载通用 JSON,不解释行业规则。 + Payload core.JSON `db:"payload"` + // Result 已保存的结果或结果引用 JSON;未取得结果时为空。 + Result core.JSON `db:"result"` + // PolicySnapshot 受理时冻结的实际生效策略 JSON。 + PolicySnapshot core.JSON `db:"policy_snapshot"` + // RecoverySafety 恢复安全等级;未知值按需要核查处理,不能默认自动重跑。 + RecoverySafety RecoverySafety `db:"recovery_safety"` + // BusinessKey 业务侧关联键,用于检索追踪,不自动提供业务幂等;可为空,表示尚未设置该关联或值。 + BusinessKey *string `db:"business_key"` + // OwnerKind 来源资源类型,例如流程激活;为空表示无来源关联。 + OwnerKind *string `db:"owner_kind"` + // OwnerID 来源资源标识,必须与来源类型同时有值或同时为空。 + OwnerID *core.ID `db:"owner_id"` + // Priority 同一调度范围内的优先级,不承诺跨队列全局顺序。 + Priority int16 `db:"priority"` + // AvailableAt 最早可被领取或处理的时间,统一 UTC。 + AvailableAt time.Time `db:"available_at"` + // StartDeadlineAt 最晚允许新尝试开始的时间;为空时仅受总时限约束。 + StartDeadlineAt *time.Time `db:"start_deadline_at"` + // DeadlineAt 总截止时间,统一 UTC;到期不能证明外部业务没有生效。 + DeadlineAt time.Time `db:"deadline_at"` + // AttemptTimeoutMS 单次尝试的超时时长,单位毫秒。 + AttemptTimeoutMS uint64 `db:"attempt_timeout_ms"` + // MaxAttempts 最多执行尝试数,包含首次尝试。 + MaxAttempts uint32 `db:"max_attempts"` + // AttemptCount 已产生的执行尝试数,恢复时不得重置。 + AttemptCount uint32 `db:"attempt_count"` + // CurrentAttemptID 当前关联的执行尝试,必须属于本任务;可为空,表示尚未设置该关联或值。 + CurrentAttemptID *core.ID `db:"current_attempt_id"` + // LeaseEpoch 递增的执行或消费代次,用于隔离旧授权。 + LeaseEpoch uint64 `db:"lease_epoch"` + // LeaseUntil 当前执行授权截止时间;没有有效授权时为空。 + LeaseUntil *time.Time `db:"lease_until"` + // CancelRequestedAt 取消意图的持久化时间;不等于取消已完成;可为空,表示尚未设置该关联或值。 + CancelRequestedAt *time.Time `db:"cancel_requested_at"` + // RetryOf 来源记录标识,人工重新发起时用于保留历史关联;可为空,表示尚未设置该关联或值。 + RetryOf *core.ID `db:"retry_of"` + // CompletedAt 终结时间;非终态为空,进入终态时必须写入。 + CompletedAt *time.Time `db:"completed_at"` +} + +func (Task) TableName() string { return "pulse_tasks" } diff --git a/pulse/model/worker_session.go b/pulse/model/worker_session.go new file mode 100644 index 0000000..469b23c --- /dev/null +++ b/pulse/model/worker_session.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// WorkerSession 表示一次 Worker 连接的持久化身份与能力快照。 +// 对应表 pulse_worker_sessions;这是服务端持久化模型,不直接作为 API 响应。 +type WorkerSession struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // WorkerInstanceID Worker 进程实例标识,重连保留,进程重启应更换。 + WorkerInstanceID core.ID `db:"worker_instance_id"` + // CredentialID 关联的命名空间凭据标识。 + CredentialID core.ID `db:"credential_id"` + // NodeID 承载当前连接的平台节点标识。 + NodeID core.ID `db:"node_id"` + // SessionEpoch 该 Worker 实例的连接代次,用于隔离旧会话。 + SessionEpoch uint64 `db:"session_epoch"` + // Capabilities 已登记的处理器版本、队列等能力快照。 + Capabilities core.JSON `db:"capabilities"` + // Capacity Worker 声明的执行容量,仍受平台权限和配额约束。 + Capacity uint32 `db:"capacity"` + // CapacitySequence 会话内容量消息序号,重复上报不得重复增加额度。 + CapacitySequence uint64 `db:"capacity_sequence"` + // Draining 是否正在排空连接;排空时不接收新任务。 + Draining bool `db:"draining"` + // ExpiresAt 过期时间,统一 UTC;到期后的行为由对应资源策略决定。 + ExpiresAt time.Time `db:"expires_at"` +} + +func (WorkerSession) TableName() string { return "pulse_worker_sessions" } diff --git a/scripts/test-mysql.sh b/scripts/test-mysql.sh new file mode 100755 index 0000000..6dbd4bb --- /dev/null +++ b/scripts/test-mysql.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Runs against an isolated disposable database, never a developer's existing DB. +set -eu +TASK_DOCKER=${DOCKER:-docker} +TASK_GO=${GO:-go} +TASK_MYSQL_IMAGE=${ORBIT_TEST_MYSQL_IMAGE:-mysql:8.4} +TASK_TEST_NAME="orbit-model-test-$$" +TASK_TEST_DIR=$(mktemp -d "${TMPDIR:-/tmp}/orbit-model-test.XXXXXX") +TASK_SERVER_PID= +cleanup() { + if [ -n "$TASK_SERVER_PID" ]; then kill "$TASK_SERVER_PID" >/dev/null 2>&1 || true; wait "$TASK_SERVER_PID" 2>/dev/null || true; fi + "$TASK_DOCKER" rm -f "$TASK_TEST_NAME" >/dev/null 2>&1 || true + rm -rf "$TASK_TEST_DIR" +} +trap cleanup EXIT HUP INT TERM +umask 077 +TASK_DB_PASSWORD=$(openssl rand -hex 24) +printf 'MYSQL_ROOT_PASSWORD=%s\nMYSQL_DATABASE=orbit_test\n' "$TASK_DB_PASSWORD" > "$TASK_TEST_DIR/mysql.env" +"$TASK_DOCKER" run -d --name "$TASK_TEST_NAME" --env-file "$TASK_TEST_DIR/mysql.env" -p 127.0.0.1::3306 "$TASK_MYSQL_IMAGE" > /dev/null +TASK_READY=0 +for task_attempt in $(seq 1 60); do + if "$TASK_DOCKER" exec "$TASK_TEST_NAME" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=tcp -e "SELECT 1" >/dev/null 2>&1'; then TASK_READY=1; break; fi + sleep 1 +done +if [ "$TASK_READY" -ne 1 ]; then + printf '%s\n' 'Disposable MySQL failed to become ready.' >&2 + "$TASK_DOCKER" logs --tail 30 "$TASK_TEST_NAME" >&2 + exit 1 +fi +TASK_DB_PORT=$("$TASK_DOCKER" port "$TASK_TEST_NAME" 3306/tcp | sed 's/.*://') +export ORBIT_TEST_MYSQL_DSN="root:$TASK_DB_PASSWORD@tcp(127.0.0.1:$TASK_DB_PORT)/orbit_test" +"$TASK_GO" test -race -count=1 -run Integration -v ./internal/storage/mysql + +"$TASK_GO" build -o "$TASK_TEST_DIR/orbit" ./cmd/orbit +"$TASK_GO" build -o "$TASK_TEST_DIR/orbit-migrate" ./cmd/orbit-migrate +ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-migrate" status +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_HTTP_PORT= +for task_attempt in $(seq 1 20); do + TASK_HTTP_PORT=$(sed -n 's/.*"address":"127.0.0.1:\([0-9]*\)".*/\1/p' "$TASK_TEST_DIR/server.log" | head -n 1) + if [ -n "$TASK_HTTP_PORT" ]; then break; fi + if ! kill -0 "$TASK_SERVER_PID" 2>/dev/null; then cat "$TASK_TEST_DIR/server.log" >&2; exit 1; fi + sleep 1 +done +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) diff --git a/sdk/go/client.go b/sdk/go/client.go new file mode 100644 index 0000000..6fdb877 --- /dev/null +++ b/sdk/go/client.go @@ -0,0 +1,80 @@ +package orbit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Options currently configures bootstrap health calls. Task, Worker and workflow +// clients will be added alongside their corresponding server endpoints. +type Options struct{ HTTPClient *http.Client } +type Client struct { + baseURL string + http *http.Client +} +type Health struct { + Status string `json:"status"` + Scope string `json:"scope,omitempty"` +} + +// HTTPError exposes the status code without including sensitive response bodies. +type HTTPError struct{ StatusCode int } + +func (e *HTTPError) Error() string { return fmt.Sprintf("orbit returned HTTP %d", e.StatusCode) } + +func NewClient(baseURL string, opts Options) (*Client, error) { + u, err := url.Parse(baseURL) + 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") + } + client := opts.HTTPClient + if client == nil { + client = &http.Client{Timeout: 10 * time.Second} + } + clone := *client + // Do not silently forward requests to another origin through redirects. + clone.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return &Client{baseURL: strings.TrimRight(u.String(), "/"), http: &clone}, nil +} +func (c *Client) Liveness(ctx context.Context) (Health, error) { return c.health(ctx, "/health/live") } +func (c *Client) Readiness(ctx context.Context) (Health, error) { + return c.health(ctx, "/health/ready") +} +func (c *Client) health(ctx context.Context, path string) (Health, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return Health{}, err + } + req.Header.Set("Accept", "application/json") + response, err := c.http.Do(req) + if err != nil { + return Health{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return Health{}, &HTTPError{StatusCode: response.StatusCode} + } + const maxBody = 16 << 10 + body, err := io.ReadAll(io.LimitReader(response.Body, maxBody+1)) + if err != nil { + return Health{}, err + } + if len(body) > maxBody { + return Health{}, errors.New("Orbit health response exceeds limit") + } + var health Health + if err := json.Unmarshal(body, &health); err != nil { + return Health{}, fmt.Errorf("decode Orbit health response: %w", err) + } + if health.Status == "" { + return Health{}, errors.New("Orbit health response is missing status") + } + return health, nil +} diff --git a/sdk/go/client_test.go b/sdk/go/client_test.go new file mode 100644 index 0000000..b392f78 --- /dev/null +++ b/sdk/go/client_test.go @@ -0,0 +1,70 @@ +package orbit_test + +import ( + "context" + "errors" + orbit "git.yuntuo.hk/k2safe/orbit/sdk/go" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHealthContract(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.Header.Get("Accept") != "application/json" { + t.Error("incorrect request") + } + if r.URL.Path == "/health/live" { + _, _ = w.Write([]byte(`{"status":"alive"}`)) + return + } + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"secret":"must not appear in error"}`)) + })) + defer server.Close() + client, err := orbit.NewClient(server.URL, orbit.Options{}) + if err != nil { + t.Fatal(err) + } + live, err := client.Liveness(context.Background()) + if err != nil || live.Status != "alive" { + t.Fatalf("%+v %v", live, err) + } + _, err = client.Readiness(context.Background()) + var status *orbit.HTTPError + if !errors.As(err, &status) || status.StatusCode != 503 || strings.Contains(err.Error(), "secret") { + t.Fatalf("unsafe status error: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := client.Liveness(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("context ignored: %v", err) + } +} +func TestRejectsUnsafeURLsRedirectsAndOversizedResponses(t *testing.T) { + for _, url := range []string{"", "file:///tmp/x", "http://user:pass@example.org", "http://example.org?token=x"} { + if _, err := orbit.NewClient(url, orbit.Options{}); err == nil { + t.Fatalf("accepted %q", url) + } + } + for _, scenario := range []string{"redirect", "oversize"} { + t.Run(scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if scenario == "redirect" { + http.Redirect(w, r, "/other", http.StatusFound) + return + } + _, _ = w.Write([]byte(strings.Repeat("x", 17000))) + })) + defer server.Close() + client, err := orbit.NewClient(server.URL, orbit.Options{}) + if err != nil { + t.Fatal(err) + } + if _, err := client.Liveness(context.Background()); err == nil { + t.Fatal("accepted unsafe response") + } + }) + } +} diff --git a/sdk/go/doc.go b/sdk/go/doc.go new file mode 100644 index 0000000..4ceff13 --- /dev/null +++ b/sdk/go/doc.go @@ -0,0 +1,4 @@ +// Package orbit is the standalone Go SDK for Orbit. This foundation release +// implements health calls; task, Worker and workflow APIs are planned next. +// The SDK does not import server models, migrations or storage dependencies. +package orbit diff --git a/sdk/go/examples/health/main.go b/sdk/go/examples/health/main.go new file mode 100644 index 0000000..3ca26ef --- /dev/null +++ b/sdk/go/examples/health/main.go @@ -0,0 +1,33 @@ +// Run against a started bootstrap server: ORBIT_URL=http://127.0.0.1:8080 go run ./examples/health +package main + +import ( + "context" + "fmt" + orbit "git.yuntuo.hk/k2safe/orbit/sdk/go" + "log" + "os" + "time" +) + +func main() { + address := os.Getenv("ORBIT_URL") + if address == "" { + address = "http://127.0.0.1:8080" + } + client, err := orbit.NewClient(address, orbit.Options{}) + if err != nil { + log.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + live, err := client.Liveness(ctx) + if err != nil { + log.Fatal(err) + } + ready, err := client.Readiness(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Printf("liveness=%s readiness=%s scope=%s\n", live.Status, ready.Status, ready.Scope) +} diff --git a/sdk/go/go.mod b/sdk/go/go.mod new file mode 100644 index 0000000..a0811c6 --- /dev/null +++ b/sdk/go/go.mod @@ -0,0 +1,3 @@ +module git.yuntuo.hk/k2safe/orbit/sdk/go + +go 1.27.1 diff --git a/weave/model/activation.go b/weave/model/activation.go new file mode 100644 index 0000000..da5b9db --- /dev/null +++ b/weave/model/activation.go @@ -0,0 +1,32 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Activation 表示步骤的一次具体激活,人工恢复通过新激活保留历史。 +// 对应表 weave_activations;这是服务端持久化模型,不直接作为 API 响应。 +type Activation struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // WorkflowID 所属流程实例标识。 + WorkflowID core.ID `db:"workflow_id"` + // StepID 所属逻辑步骤标识。 + StepID core.ID `db:"step_id"` + // Sequence 同一步骤的激活序号,人工恢复递增并保留旧记录。 + Sequence uint32 `db:"sequence"` + // State 当前状态;只允许定义的状态转换并配合事务校验。 + State StepState `db:"state"` + // TaskID 关联的 Pulse 任务标识;可为空,表示尚未设置该关联或值。 + TaskID *core.ID `db:"task_id"` + // Input 本次实例或步骤激活的输入快照 JSON。 + Input core.JSON `db:"input"` + // Output 已保存的输出 JSON;未完成时可以为空。 + Output core.JSON `db:"output"` + // CompletedAt 终结时间;非终态为空,进入终态时必须写入。 + CompletedAt *time.Time `db:"completed_at"` +} + +func (Activation) TableName() string { return "weave_activations" } diff --git a/weave/model/definition.go b/weave/model/definition.go new file mode 100644 index 0000000..825be5a --- /dev/null +++ b/weave/model/definition.go @@ -0,0 +1,18 @@ +package model + +import ( + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Definition 表示流程定义的稳定身份,不保存可直接执行的业务代码。 +// 对应表 weave_definitions;这是服务端持久化模型,不直接作为 API 响应。 +type Definition struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // Name 可读名称,唯一性范围由对应数据表约束。 + Name string `db:"name"` + // Description 定义用途说明,不承载运行状态。 + Description string `db:"description"` +} + +func (Definition) TableName() string { return "weave_definitions" } diff --git a/weave/model/definition_version.go b/weave/model/definition_version.go new file mode 100644 index 0000000..5e98650 --- /dev/null +++ b/weave/model/definition_version.go @@ -0,0 +1,26 @@ +package model + +import ( + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// DefinitionVersion 表示已发布的不可变流程定义版本。 +// 对应表 weave_definition_versions;这是服务端持久化模型,不直接作为 API 响应。 +type DefinitionVersion struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // DefinitionID 稳定流程定义标识。 + DefinitionID core.ID `db:"definition_id"` + // Version 不可变发布版本;同版本不同内容必须拒绝。 + Version string `db:"version"` + // SchemaVersion 流程定义文档的结构版本。 + SchemaVersion uint32 `db:"schema_version"` + // Specification 版本化规则定义 JSON;发布后禁止原地修改。 + Specification core.JSON `db:"specification"` + // ContentDigest 不可变定义内容的 SHA-256 摘要。 + ContentDigest core.Digest `db:"content_digest"` + // PublishedBy 发布版本的认证主体,用于追溯发布来源。 + PublishedBy string `db:"published_by"` +} + +func (DefinitionVersion) TableName() string { return "weave_definition_versions" } diff --git a/weave/model/inbox_event.go b/weave/model/inbox_event.go new file mode 100644 index 0000000..42d06eb --- /dev/null +++ b/weave/model/inbox_event.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// InboxEvent 表示Weave 内部事件消费记录,和流程推进一起确认消费。 +// 对应表 weave_inbox;这是服务端持久化模型,不直接作为 API 响应。 +type InboxEvent struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // WorkflowID 所属流程实例标识。 + WorkflowID core.ID `db:"workflow_id"` + // EventID 来源持久化事件标识,用于消费去重。 + EventID core.ID `db:"event_id"` + // State 当前状态;只允许定义的状态转换并配合事务校验。 + State InboxState `db:"state"` + // AvailableAt 最早可被领取或处理的时间,统一 UTC。 + AvailableAt time.Time `db:"available_at"` + // LeaseOwner 租约持有者标识;未持有租约时为空。 + LeaseOwner *core.ID `db:"lease_owner"` + // LeaseEpoch 递增的执行或消费代次,用于隔离旧授权。 + LeaseEpoch uint64 `db:"lease_epoch"` + // LeaseUntil 内部消费租约截止时间;没有消费租约时为空。 + LeaseUntil *time.Time `db:"lease_until"` + // ProcessedAt 内部事件完成消费的时间;尚未消费时为空。 + ProcessedAt *time.Time `db:"processed_at"` + // ErrorCode 稳定错误分类,供恢复策略判断;不得写入敏感原始错误;可为空,表示尚未设置该关联或值。 + ErrorCode *string `db:"error_code"` +} + +func (InboxEvent) TableName() string { return "weave_inbox" } diff --git a/weave/model/instance.go b/weave/model/instance.go new file mode 100644 index 0000000..71baa4a --- /dev/null +++ b/weave/model/instance.go @@ -0,0 +1,44 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Instance 表示流程运行实例,固定定义版本,暂停与执行状态分别保存。 +// 对应表 weave_instances;这是服务端持久化模型,不直接作为 API 响应。 +type Instance struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // DefinitionID 稳定流程定义标识。 + DefinitionID core.ID `db:"definition_id"` + // DefinitionVersionID 实例固定使用的定义版本记录,不能随新版本发布而变化。 + DefinitionVersionID core.ID `db:"definition_version_id"` + // GateID 执行门控标识,用于检查是否允许新的领取与开始。 + GateID core.ID `db:"gate_id"` + // State 流程执行状态,与独立的暂停标记同时展示。 + State InstanceState `db:"state"` + // Paused 暂停标记;停止新的受控工作,不表示在途请求已停止。 + Paused bool `db:"paused"` + // ControlRevision 暂停、继续等控制操作的版本,避免旧操作覆盖新决定。 + ControlRevision uint64 `db:"control_revision"` + // Input 本次实例或步骤激活的输入快照 JSON。 + Input core.JSON `db:"input"` + // Output 已保存的输出 JSON;未完成时可以为空。 + Output core.JSON `db:"output"` + // PolicySnapshot 受理时冻结的实际生效策略 JSON。 + PolicySnapshot core.JSON `db:"policy_snapshot"` + // BusinessKey 业务侧关联键,用于检索追踪,不自动提供业务幂等;可为空,表示尚未设置该关联或值。 + BusinessKey *string `db:"business_key"` + // DeadlineAt 流程总截止时间,暂停默认不延长此业务边界。 + DeadlineAt time.Time `db:"deadline_at"` + // CancelRequestedAt 取消意图的持久化时间;不等于取消已完成;可为空,表示尚未设置该关联或值。 + CancelRequestedAt *time.Time `db:"cancel_requested_at"` + // CompletedAt 终结时间;非终态为空,进入终态时必须写入。 + CompletedAt *time.Time `db:"completed_at"` + // RetryOf 来源记录标识,人工重新发起时用于保留历史关联;可为空,表示尚未设置该关联或值。 + RetryOf *core.ID `db:"retry_of"` +} + +func (Instance) TableName() string { return "weave_instances" } diff --git a/weave/model/pending_advance.go b/weave/model/pending_advance.go new file mode 100644 index 0000000..0917ca1 --- /dev/null +++ b/weave/model/pending_advance.go @@ -0,0 +1,26 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// PendingAdvance 表示暂停或分批推进后的续接检查点,避免仅依赖内存唤醒。 +// 对应表 weave_pending_advances;这是服务端持久化模型,不直接作为 API 响应。 +type PendingAdvance struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // WorkflowID 所属流程实例标识。 + WorkflowID core.ID `db:"workflow_id"` + // StepID 所属逻辑步骤标识。 + StepID core.ID `db:"step_id"` + // ExpectedStepRevision 恢复推进时要求的步骤版本,避免使用过期检查点。 + ExpectedStepRevision uint64 `db:"expected_step_revision"` + // Checkpoint 分批或暂停后继续推进所需的持久化 JSON 检查点。 + Checkpoint core.JSON `db:"checkpoint"` + // AvailableAt 最早可被领取或处理的时间,统一 UTC。 + AvailableAt time.Time `db:"available_at"` +} + +func (PendingAdvance) TableName() string { return "weave_pending_advances" } diff --git a/weave/model/poll_policy.go b/weave/model/poll_policy.go new file mode 100644 index 0000000..d60785c --- /dev/null +++ b/weave/model/poll_policy.go @@ -0,0 +1,32 @@ +package model + +import ( + "errors" + "time" +) + +var ( + ErrPollExhausted = errors.New("poll budget exhausted; reconciliation required") + ErrPollInFlight = errors.New("a poll round is still in flight") + ErrPollResolved = errors.New("poll already resolved") + ErrPollNotDue = errors.New("poll interval has not elapsed") +) + +// NextRound 用数据库时间检查已锁定的查询快照。调用方还必须核验执行门控, +// 并在同一事务中写入计数、唯一轮次及对应的 Pulse 任务。 +// 下一查询时间为空表示持久化暂挂,不能视为允许立即发起查询。 +func (p PollState) NextRound(now time.Time) (uint32, error) { + if p.ResolvedAt != nil { + return 0, ErrPollResolved + } + if p.MaxRounds == 0 || p.UsedRounds >= p.MaxRounds || !now.Before(p.DeadlineAt) { + return 0, ErrPollExhausted + } + if p.CurrentTaskID != nil { + return 0, ErrPollInFlight + } + if p.NextPollAt == nil || now.Before(*p.NextPollAt) { + return 0, ErrPollNotDue + } + return p.UsedRounds + 1, nil +} diff --git a/weave/model/poll_policy_test.go b/weave/model/poll_policy_test.go new file mode 100644 index 0000000..8493a50 --- /dev/null +++ b/weave/model/poll_policy_test.go @@ -0,0 +1,38 @@ +package model + +import ( + "errors" + core "git.yuntuo.hk/k2safe/orbit/model" + "testing" + "time" +) + +func TestPollRecoveryPreservesBudgetAndDeadline(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + p := PollState{MaxRounds: 3, UsedRounds: 2, DeadlineAt: now.Add(time.Minute), NextPollAt: &now} + n, err := p.NextRound(now) + if err != nil || n != 3 { + t.Fatalf("next round: %d %v", n, err) + } + p.UsedRounds = 3 + if _, err := p.NextRound(now); !errors.Is(err, ErrPollExhausted) { + t.Fatal("counter reset or limit bypass") + } + p.UsedRounds = 2 + if _, err := p.NextRound(p.DeadlineAt); !errors.Is(err, ErrPollExhausted) { + t.Fatal("deadline equality permitted new round") + } + task := core.ID("00000000000000000000000000000001") + p.CurrentTaskID = &task + if _, err := p.NextRound(now); !errors.Is(err, ErrPollInFlight) { + t.Fatal("overlapping poll round permitted") + } +} +func TestResumeDoesNotClearReviewOrCancellation(t *testing.T) { + now := time.Now() + for _, i := range []Instance{{State: InstanceRunning, Paused: true}, {State: InstanceNeedsReview}, {State: InstanceCancelling}, {State: InstanceRunning, CancelRequestedAt: &now}, {State: InstanceSucceeded}, {State: InstanceState("future")}} { + if i.AllowsNewWork() { + t.Fatalf("allowed new work: %+v", i) + } + } +} diff --git a/weave/model/poll_round.go b/weave/model/poll_round.go new file mode 100644 index 0000000..d89a23b --- /dev/null +++ b/weave/model/poll_round.go @@ -0,0 +1,22 @@ +package model + +import ( + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// PollRound 表示一次业务查询轮次,一轮对应一个 Pulse 任务。 +// 对应表 weave_poll_rounds;这是服务端持久化模型,不直接作为 API 响应。 +type PollRound struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // WorkflowID 所属流程实例标识。 + WorkflowID core.ID `db:"workflow_id"` + // ActivationID 所属步骤激活,区分旧轮次和人工恢复后的新轮次。 + ActivationID core.ID `db:"activation_id"` + // Round 本次激活内的查询轮次编号,从 1 开始。 + Round uint32 `db:"round"` + // TaskID 关联的 Pulse 任务标识。 + TaskID core.ID `db:"task_id"` +} + +func (PollRound) TableName() string { return "weave_poll_rounds" } diff --git a/weave/model/poll_state.go b/weave/model/poll_state.go new file mode 100644 index 0000000..5d83012 --- /dev/null +++ b/weave/model/poll_state.go @@ -0,0 +1,36 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// PollState 表示有界查询节点的持久化预算与恢复检查点。 +// 对应表 weave_poll_states;这是服务端持久化模型,不直接作为 API 响应。 +type PollState struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // WorkflowID 所属流程实例标识。 + WorkflowID core.ID `db:"workflow_id"` + // ActivationID 所属步骤激活,区分旧轮次和人工恢复后的新轮次。 + ActivationID core.ID `db:"activation_id"` + // MaxRounds 业务查询最多轮次,不等于单轮请求重试次数。 + MaxRounds uint32 `db:"max_rounds"` + // UsedRounds 已创建的查询轮次,重启或暂停继续不得重置。 + UsedRounds uint32 `db:"used_rounds"` + // MaxAttemptsPerRound 每轮 Pulse 任务最多尝试数,包含该轮首次请求。 + MaxAttemptsPerRound uint32 `db:"max_attempts_per_round"` + // IntervalMS 相邻业务查询轮次的等待间隔,单位毫秒。 + IntervalMS uint64 `db:"interval_ms"` + // DeadlineAt 查询节点总截止时间,轮次和单轮重试都受此边界限制。 + DeadlineAt time.Time `db:"deadline_at"` + // NextPollAt 最早允许下一轮查询的时间;为空表示当前不能直接推进。 + NextPollAt *time.Time `db:"next_poll_at"` + // CurrentTaskID 当前在途查询任务;非空时禁止重叠开启下一轮;可为空,表示尚未设置该关联或值。 + CurrentTaskID *core.ID `db:"current_task_id"` + // ResolvedAt 等待或查询节点结清的时间;尚未结清时为空。 + ResolvedAt *time.Time `db:"resolved_at"` +} + +func (PollState) TableName() string { return "weave_poll_states" } diff --git a/weave/model/signal.go b/weave/model/signal.go new file mode 100644 index 0000000..c151525 --- /dev/null +++ b/weave/model/signal.go @@ -0,0 +1,40 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Signal 表示已可靠受理的外部信号,受理与消费是两个阶段。 +// 对应表 weave_signals;这是服务端持久化模型,不直接作为 API 响应。 +type Signal struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // WorkflowID 所属流程实例标识。 + WorkflowID core.ID `db:"workflow_id"` + // KeyDigest 稳定幂等键或资源键的 SHA-256 摘要。 + KeyDigest core.Digest `db:"key_digest"` + // RequestDigest 规范化请求语义的 SHA-256 摘要,用于识别同键不同内容。 + RequestDigest core.Digest `db:"request_digest"` + // Type 事件或信号的稳定类型标识。 + Type string `db:"type"` + // TargetStepKey 信号目标步骤键,用于早到信号的后续匹配。 + TargetStepKey string `db:"target_step_key"` + // CorrelationKey 外部业务操作关联键,防止旧信号误用于新工作。 + CorrelationKey string `db:"correlation_key"` + // Payload 版本化输入或事件内容;仅承载通用 JSON,不解释行业规则。 + Payload core.JSON `db:"payload"` + // ActorID 经认证的操作主体标识,不能直接信任请求自报值。 + ActorID string `db:"actor_id"` + // State 当前状态;只允许定义的状态转换并配合事务校验。 + State SignalState `db:"state"` + // AcceptedAt 信号可靠受理时间,不能用稍后的消费时间替代。 + AcceptedAt time.Time `db:"accepted_at"` + // ExpiresAt 过期时间,统一 UTC;到期后的行为由对应资源策略决定。 + ExpiresAt time.Time `db:"expires_at"` + // ConsumedWaitID 消费该信号的等待记录;尚未消费时为空。 + ConsumedWaitID *core.ID `db:"consumed_wait_id"` +} + +func (Signal) TableName() string { return "weave_signals" } diff --git a/weave/model/state.go b/weave/model/state.go new file mode 100644 index 0000000..4bc3604 --- /dev/null +++ b/weave/model/state.go @@ -0,0 +1,74 @@ +package model + +// InstanceState 描述流程执行状态,独立的 Paused 标记控制是否允许新工作。 +type InstanceState string + +const ( + InstanceRunning InstanceState = "RUNNING" + InstanceWaiting InstanceState = "WAITING" + InstanceCancelling InstanceState = "CANCELLING" + InstanceNeedsReview InstanceState = "NEEDS_REVIEW" + InstanceSucceeded InstanceState = "SUCCEEDED" + InstanceFailed InstanceState = "FAILED" + InstanceCancelled InstanceState = "CANCELLED" +) + +func (s InstanceState) Terminal() bool { + return s == InstanceSucceeded || s == InstanceFailed || s == InstanceCancelled +} +func (i Instance) AllowsNewWork() bool { + return !i.Paused && i.CancelRequestedAt == nil && (i.State == InstanceRunning || i.State == InstanceWaiting) +} + +type NodeKind string + +const ( + NodeTask NodeKind = "TASK" // 通过 Pulse 执行业务处理器。 + NodeBranch NodeKind = "BRANCH" // 按已保存结果选择条件路径。 + NodeParallel NodeKind = "PARALLEL" // 展开有界并行分支。 + NodeJoin NodeKind = "JOIN" // 按规则汇合并行结果。 + NodeSignal NodeKind = "SIGNAL" // 等待业务信号。 + NodeTimer NodeKind = "TIMER" // 等待指定时间。 + NodePoll NodeKind = "POLL" // 有界重复查询节点。 +) + +type StepState string + +const ( + StepBlocked StepState = "BLOCKED" + StepReady StepState = "READY" + StepActive StepState = "ACTIVE" + StepWaiting StepState = "WAITING" + StepNeedsReview StepState = "NEEDS_REVIEW" + StepSucceeded StepState = "SUCCEEDED" + StepFailed StepState = "FAILED" + StepCancelled StepState = "CANCELLED" + StepSkipped StepState = "SKIPPED" +) + +type WaitState string + +const ( + WaitWaiting WaitState = "WAITING" + WaitSatisfied WaitState = "SATISFIED" + WaitExpired WaitState = "EXPIRED" + WaitCancelled WaitState = "CANCELLED" +) + +type SignalState string + +const ( + SignalPending SignalState = "PENDING" + SignalConsumed SignalState = "CONSUMED" + SignalExpired SignalState = "EXPIRED" + SignalRejected SignalState = "REJECTED" +) + +type InboxState string + +const ( + InboxPending InboxState = "PENDING" + InboxLeased InboxState = "LEASED" + InboxProcessed InboxState = "PROCESSED" + InboxQuarantined InboxState = "QUARANTINED" +) diff --git a/weave/model/step.go b/weave/model/step.go new file mode 100644 index 0000000..8d1d701 --- /dev/null +++ b/weave/model/step.go @@ -0,0 +1,24 @@ +package model + +import ( + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Step 表示实例中的逻辑步骤,指向该步骤当前的激活记录。 +// 对应表 weave_steps;这是服务端持久化模型,不直接作为 API 响应。 +type Step struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // WorkflowID 所属流程实例标识。 + WorkflowID core.ID `db:"workflow_id"` + // Key 定义中的稳定步骤键,在同一流程实例内唯一。 + Key string `db:"key"` + // Kind 节点或等待类型,决定由哪类推进规则处理。 + Kind NodeKind `db:"kind"` + // State 当前状态;只允许定义的状态转换并配合事务校验。 + State StepState `db:"state"` + // CurrentActivationID 当前激活记录,必须属于本实例的本步骤;可为空,表示尚未设置该关联或值。 + CurrentActivationID *core.ID `db:"current_activation_id"` +} + +func (Step) TableName() string { return "weave_steps" } diff --git a/weave/model/wait.go b/weave/model/wait.go new file mode 100644 index 0000000..b1367d9 --- /dev/null +++ b/weave/model/wait.go @@ -0,0 +1,32 @@ +package model + +import ( + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Wait 表示可恢复的信号或时间等待,不长期占用业务 Worker。 +// 对应表 weave_waits;这是服务端持久化模型,不直接作为 API 响应。 +type Wait struct { + // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 + core.Record + // WorkflowID 所属流程实例标识。 + WorkflowID core.ID `db:"workflow_id"` + // ActivationID 所属步骤激活,区分旧轮次和人工恢复后的新轮次。 + ActivationID core.ID `db:"activation_id"` + // Kind 节点或等待类型,决定由哪类推进规则处理。 + Kind string `db:"kind"` + // State 当前状态;只允许定义的状态转换并配合事务校验。 + State WaitState `db:"state"` + // CorrelationKey 外部业务操作关联键,防止旧信号误用于新工作。 + CorrelationKey string `db:"correlation_key"` + // DeadlineAt 等待截止时间,用可靠受理时间判断信号是否按时到达。 + DeadlineAt time.Time `db:"deadline_at"` + // WakeTaskID 内部定时唤醒任务;无需定时唤醒时为空。 + WakeTaskID *core.ID `db:"wake_task_id"` + // ResolvedAt 等待或查询节点结清的时间;尚未结清时为空。 + ResolvedAt *time.Time `db:"resolved_at"` +} + +func (Wait) TableName() string { return "weave_waits" }