feat: expose workflow state events and persist signal transitions
This commit is contained in:
14
AGENTS.md
14
AGENTS.md
@@ -31,17 +31,17 @@
|
||||
|
||||
- P4定义发布使用schema_version=1有限结构,词法作用域禁止跨分支/前向读取,任务策略显式冻结;SDK类型独立。发布按认证→应用额度→操作身份→定义→排序队列持锁;原键与同版本去重仍校验当前权限。定义/版本/revision/审计/回执同事务;同版本不同内容不得覆盖。保留精确数字,校验存储原文与结构摘要;不得把发布通过或DSL支持误称为实例执行已实现。
|
||||
|
||||
- P4定义子项已通过真实MySQL、HTTP丢回执、独立SDK和全量回归(docs/reports/phase-4-definitions.md)。实例启动/固定版本/首批任务同事务的内部存储已补齐;内部事件消费、顺序与组合推进及根接续已接入;TIMER与后续POLL时钟已接入,SIGNAL及最终查询竞争已接入,内部控制与停止结清已接入,等待恢复已接入,实例公共API/SDK已接入,流程任务来源已接入,已接入有界历史查询,实例分页已接入,继续统一事件时间线范围核对;P4整体和P5–P7仍未完成。
|
||||
- P4定义子项已通过真实MySQL、HTTP丢回执、独立SDK和全量回归(docs/reports/phase-4-definitions.md)。实例启动/固定版本/首批任务同事务的内部存储已补齐;内部事件消费、顺序与组合推进及根接续已接入;TIMER与后续POLL时钟已接入,SIGNAL及最终查询竞争已接入,内部控制与停止结清已接入,等待恢复已接入,实例公共API/SDK已接入,流程任务来源已接入,已接入有界历史查询,实例分页已接入,已接入状态事件时间线,继续独立轮询/恢复场景与全范围核对;P4整体和P5–P7仍未完成。
|
||||
|
||||
- 内部结果交付使用workflow_activation所有者并固定激活ID,TASK经activation.task_id、POLL经轮次校验归属;weave_inbox来源及独立快照与Pulse结果同事务,先于外部结果流行锁,不在结果路径反锁实例/步骤/订阅。迁移000017隔离缺快照历史,禁止用当前任务重建旧事件。
|
||||
- weave/engine仅计算有限决策;实例受理、Inbox消费与根接续已接入,等待和停止结清运行器已接入,内部核查/TASK恢复已接入,等待恢复已接入内部服务,公共API/SDK已接入,流程任务来源已接入,有界历史已接入,实例分页已接入,继续事件时间线范围核对。Plan最多32个变更,More必须持久化待续;耗尽变更预算仍须遍历其他已活动分支,不能误判在途工作不可达。冻结分支选择,精确数值,不以PENDING查询结果完成POLL。后续消费确认/当前激活校验/新任务或检查点必须同事务,不能把纯快照往返测试当作真实进程恢复。
|
||||
- weave/engine仅计算有限决策;实例受理、Inbox消费与根接续已接入,等待和停止结清运行器已接入,内部核查/TASK恢复已接入,等待恢复已接入内部服务,公共API/SDK已接入,流程任务来源已接入,有界历史已接入,实例分页已接入,已接入状态事件时间线,继续独立轮询/恢复场景与全范围核对。Plan最多32个变更,More必须持久化待续;耗尽变更预算仍须遍历其他已活动分支,不能误判在途工作不可达。冻结分支选择,精确数值,不以PENDING查询结果完成POLL。后续消费确认/当前激活校验/新任务或检查点必须同事务,不能把纯快照往返测试当作真实进程恢复。
|
||||
|
||||
- 实例内部受理已实现,信号内部运行器已接入,公共实例/信号HTTP及独立SDK已接入;结果消费和根待续扫描已接入后台。Start按认证→应用额度→原身份→排序队列建立门控/固定版本/全部步骤/首批工作,所有失败同事务回滚。首批推进递增revision至2,原键返回冻结回执且仍核验当前权限,不重算截止。
|
||||
- 实例根步骤`$root`/JOIN不属于用户定义,Steps查询排除它;More对应根步骤版本化持久检查点。TASK直接关联激活task_id,POLL通过轮次关联,实际任务截止必须受实例/查询绝对截止限制。Wait名称及契约版本冻结,旧NULL等待须核实,不能盲目解释;新关联512字节、存储兼容历史764字节。
|
||||
|
||||
- 结果消费按身份→应用额度→排序队列→实例→门控→步骤/Inbox持锁,禁止先锁Inbox再锁父资源。来源使用独立冻结快照,旧激活/低版本留痕;数据库故障不归类为业务失败。消费确认、步骤、后继或根待续、事件及审计同事务。
|
||||
- 后继满额用SAVEPOINT回滚整轮新工作,保留已接受结果和待续;恢复必须重读持久图。多次根推进使用独立系统审计操作身份;迟到证据也递增实例历史版本,禁止复用已有workflow.advanced唯一身份。
|
||||
- 本轮消费运行器和真实Orbit强杀/双节点接管已验证,见docs/reports/phase-4-runtime.md。TIMER到期与POLL后续轮次已接入,见docs/13-workflow-clock.md;SIGNAL及最终信号竞争已接入(docs/14-workflow-signals.md);内部FAIL_AFTER_SETTLED分支结清与控制已接入(docs/15-workflow-controls.md);公共控制API和实例SDK已接入,流程任务来源已接入,实例列表已接入,统一事件时间线范围核对与更多场景仍须完成,不得按部分自动运行宣称P4/P5完成。
|
||||
- 本轮消费运行器和真实Orbit强杀/双节点接管已验证,见docs/reports/phase-4-runtime.md。TIMER到期与POLL后续轮次已接入,见docs/13-workflow-clock.md;SIGNAL及最终信号竞争已接入(docs/14-workflow-signals.md);内部FAIL_AFTER_SETTLED分支结清与控制已接入(docs/15-workflow-controls.md);公共控制API和实例SDK已接入,流程任务来源已接入,实例列表已接入,状态事件时间线已接入,更多独立场景仍须完成,不得按部分自动运行宣称P4/P5完成。
|
||||
|
||||
- TIMER/POLL复用weave_pending_advances及到期索引,时钟固定激活ID;迁移000019只回填原等待/查询时钟,不改截止和预算。后续POLL只由已确认POLL_PENDING开启,首轮精确输入/资源键冻结,每轮Pulse尝试独立计数;满额/暂停不消耗轮数。截止遇到已落库结果先等待Inbox,仍在途则关闭门控但保留ACTIVE和当前任务,许可由Pulse结清;不得由时钟假定外部请求已结束。
|
||||
|
||||
@@ -52,16 +52,16 @@
|
||||
- StopKind/StopRequestedAt冻结首次CANCEL或FAILURE目标,后来的用户取消不覆盖原失败目标。结清复用ROOT,每事务最多一个Pulse取消、32项步骤收尾;结果流锁取得后不能再锁另一任务。终态前校验全部历史任务的双向归属、未结束尝试和未释放许可,UNKNOWN不得终结。迁移000021恢复旧停止意图,不添加外键。
|
||||
|
||||
- 人工核查使用显式workflows:recover、当前实例revision、步骤/任务版本及激活身份,必须有原因和证据;执行结论、步骤结论、解除核查分别确认。新决定拒绝PENDING/QUARANTINED Inbox,原键回执仍核验权限。核查回执/额度/双审计/结果流同事务,结果流锁最后取得。
|
||||
- TASK人工重试保留首次绝对截止和精确输入,以新激活/关联任务继续消耗原max_attempts;激活数也受该上限限制,不能无限制造零尝试任务。成功步骤、未知执行、原停止意图或预算耗尽均不得重试。Continue不解除paused、重置预算或撤销停止。POLL跨激活恢复已实现(docs/17-workflow-poll-recovery.md),等待恢复已接入内部服务,公共HTTP/SDK已接入,流程任务来源已接入,实例列表已接入,统一事件时间线范围核对与更多场景仍需补齐,不能据此宣称P5完成。
|
||||
- TASK人工重试保留首次绝对截止和精确输入,以新激活/关联任务继续消耗原max_attempts;激活数也受该上限限制,不能无限制造零尝试任务。成功步骤、未知执行、原停止意图或预算耗尽均不得重试。Continue不解除paused、重置预算或撤销停止。POLL跨激活恢复已实现(docs/17-workflow-poll-recovery.md),等待恢复已接入内部服务,公共HTTP/SDK已接入,流程任务来源已接入,实例列表已接入,状态事件时间线已接入,更多独立场景仍需补齐,不能据此宣称P5完成。
|
||||
|
||||
- POLL人工恢复以新激活重查同一业务轮,FirstRound/FirstRoundAttemptsUsed保留原轮次和已用尝试;实际尝试行、retry_of链和冻结参数必须相符。同轮任务数也受原max_attempts限制,PENDING不得人工重查绕过轮次上限。查询截止按首次激活、请求截止按该轮首任务,不允许重计时;下一业务轮才获得完整单轮策略。
|
||||
- POLL恢复的最终信号等待保留原契约/关联/截止并更换代次,旧winner和已消费信号只留历史;存在当前代次PENDING信号时拒绝重建。迁移000022无外键,新增2个中文注释字段。公共HTTP/SDK已接入,有界历史查询已接入,实例列表已接入,事件时间线范围核对与生产验收继续推进。
|
||||
- POLL恢复的最终信号等待保留原契约/关联/截止并更换代次,旧winner和已消费信号只留历史;存在当前代次PENDING信号时拒绝重建。迁移000022无外键,新增2个中文注释字段。公共HTTP/SDK已接入,有界历史查询已接入,实例列表已接入,状态事件时间线已接入,生产验收继续推进。
|
||||
|
||||
- 核查将UNKNOWN请求确认成成功PENDING时,当前RetryStep仍拒绝重查同轮;ContinuePoll显式恢复原等待,必须核验当前SUCCEEDED/PENDING任务、已处理Inbox及不可变来源。新审计固定来源事件/版本、原间隔与截止,时钟/最终信号核验证明后才推进;不改写旧Inbox处置,ReviewTask不自动解除核查。
|
||||
|
||||
- RearmWait仅重建独立SIGNAL/TIMER,显式wait_verified及最新步骤/激活/实例版本,连续激活上限32。旧WAITING激活/等待关闭,新代次保留首次输入、契约、关联及截止;PENDING信号、已有赢家、停止意图及过期均拒绝。TIMER运行和成功核查按首次激活计时,恢复后仍须Continue;无新持久化字段或外键。
|
||||
|
||||
- 公共流程API包括实例启动/读取、最多128项步骤元数据、单步当前输入输出/预算/等待、控制、信号、六类恢复和流程资源操作审计。当前激活代次同时进入Steps、详情和新恢复回执;旧冻结回执缺字段时只能另读当前详情,不改写历史回执。Pulse流程来源已交付;历史激活/轮次/等待/信号及信号审计已接入;实例列表已接入,统一状态事件时间线范围核对与更多独立示例仍未完成。
|
||||
- 公共流程API包括实例启动/读取、最多128项步骤元数据、单步当前输入输出/预算/等待、控制、信号、六类恢复和流程资源操作审计。当前激活代次同时进入Steps、详情和新恢复回执;旧冻结回执缺字段时只能另读当前详情,不改写历史回执。Pulse流程来源已交付;历史激活/轮次/等待/信号及信号审计已接入;实例列表已接入,状态事件时间线已接入,更多独立示例仍未完成。
|
||||
- 恢复请求的匿名嵌入结构按扁平精确JSON字段解析,继承字段也拒绝别名/重复/未知字段,保留显式null例外与必填确认。bootstrap先验证权限集合;工作流执行/控制/信号/恢复/审计分别显式授予,旧workflow-access仅定义发布。
|
||||
|
||||
- 任务来源WORKFLOW只返回已授权任务的固定流程/版本/步骤/历史激活及POLL轮次;tasks:read不授予完整流程和其他队列权限。核对正反向关联、固定定义及门控,恢复父任务须为前一激活且POLL同轮。TASK恢复的retry_of位于workflow对象内,后续POLL新轮无父任务。不能改指当前激活、递归展开历史或将来源查询加入派发路径;无外键。
|
||||
@@ -69,3 +69,5 @@
|
||||
- 历史读取按固定实例及所有队列授权,明确步骤/激活/信号归属,不依赖外键;摘要分页20/100,游标绑定种类和父资源。指定激活详情的Step是当前视图、Activation是历史视图,不自动改代次;POLL轮次限定单个激活,恢复同轮任务不可与下一业务轮混淆。信号列表保留未核验旧身份,正文单条读取;信号审计用显式workflows:audit:read,不能按全局审计ID越权。000023只增加分页索引;全量回归必须保留零外键与旧数据/索引断言。
|
||||
|
||||
- 实例列表按固定定义全部队列在SQL中筛选,再校验原定义/摘要、范围、策略及关联;不能用已执行任务推断权限。000024增加不可变范围索引与7个列表索引,无外键;发布与范围同事务,旧DSL常量中的queue_id不纳入范围。游标绑定命名空间/全部筛选/规范化队列权限,跨页不承诺快照,limit只限制返回候选量。先排序锁整页队列,不在实例锁之后追加队列锁。
|
||||
|
||||
- 状态时间线只读取已存workflow/workflow_step/workflow_signal事件,排除$root;两条路由均显式workflows:audit:read并核对固定版本全部队列及实际子资源。000025派生只读索引不授予权限、不改历史原文、不补造旧信号转换,无外键;新信号状态和事件同事务,批量拒绝32条一批且失败整笔回滚。时间/ID排序与资源revision均不是提交水位;Pulse可靠消费仍走持久订阅。旧写节点停止后迁移,不能承诺混合版本无历史缺口。
|
||||
|
||||
@@ -10,7 +10,7 @@ Orbit 是面向多个业务项目的通用任务调度与流程编排平台,
|
||||
|
||||
已完成 P0–P3 平台功能阶段验收:36 个数据模型、版本化迁移、凭据及范围校验、队列与任务 API、WSS 执行、独立 SDK、自动 Worker 和 [执行尝试/证据查询](docs/api/execution-history.md)。[Worker Schema](docs/api/worker-schema.md) 与真实进程强杀恢复回归已通过;[安全重试](docs/api/retries.md)、[定时计划](docs/api/schedules.md)、[运行中取消](docs/api/cancellation.md) 、[队列资源控制](docs/api/queues.md) 、[应用共享配额](docs/api/application-resources.md) 、[资源键互斥](docs/api/keyed-concurrency.md) 和 [优先级/节点公平派发](docs/api/priority-and-fairness.md) 已接入服务端和 SDK,[可靠结果订阅](docs/api/subscriptions.md) 已实现持久化核心、HTTP/WSS、管理 SDK 与示例,[消费 SDK](docs/sdk/event-consumer.md) 和 [独立 MySQL Inbox 示例](docs/examples/result-inbox.md) 已实现;[批量任务受理](docs/api/batch-tasks.md) 已提供逐项幂等、失败分类与 SDK 示例;[人工核查与重试](docs/api/task-recovery.md) 已提供带证据的管理接口、关联新任务、审计查询及运行示例;[业务键与时间范围查询](docs/api/task-queries.md) 已提供过滤、分页与SDK场景;[任务来源查询](docs/api/task-source.md) 已提供人工重试父任务、计划触发记录与固定版本;[业务事务Outbox](docs/examples/task-outbox.md) 已提供独立业务库、稳定提交身份、发送恢复与CLI;[相对延迟执行](docs/examples/delayed-task.md)已提供数据库计时及原请求恢复;完整范围核对见[P3验收](docs/reports/phase-3.md),流程引擎与生产验证按 [持续开发计划](docs/08-development-plan.md) 继续实现。
|
||||
|
||||
Weave已提供[定义发布](docs/api/workflow-definitions.md)、[实例启动、步骤详情、暂停/继续/取消、信号和操作审计](docs/api/workflows.md),以及[六类人工核查恢复](docs/api/workflow-recovery.md)的HTTP与独立Go SDK。持久运行器支持顺序、分支、有限并行、TIMER、SIGNAL及有界POLL,恢复沿用原预算与截止。[并行作业与回执示例](docs/examples/workflow.md)提供完整调用链;[历史激活/轮次/信号及信号审计](docs/api/workflow-history.md)已接入;[实例筛选分页](docs/api/workflow-queries.md)已接入,统一事件时间线范围核对和更多独立恢复示例继续交付;[任务流程来源](docs/api/task-source.md#5-流程固定来源)已接入,P4/P5与生产验收尚未整体完成。
|
||||
Weave已提供[定义发布](docs/api/workflow-definitions.md)、[实例启动、步骤详情、暂停/继续/取消、信号和操作审计](docs/api/workflows.md),以及[六类人工核查恢复](docs/api/workflow-recovery.md)的HTTP与独立Go SDK。持久运行器支持顺序、分支、有限并行、TIMER、SIGNAL及有界POLL,恢复沿用原预算与截止。[并行作业与回执示例](docs/examples/workflow.md)提供完整调用链;[历史激活/轮次/信号及信号审计](docs/api/workflow-history.md)已接入;[实例筛选分页](docs/api/workflow-queries.md)已接入,[状态事件时间线](docs/api/workflow-events.md)已接入,更多独立轮询/恢复示例继续交付;[任务流程来源](docs/api/task-source.md#5-流程固定来源)已接入,P4/P5与生产验收尚未整体完成。
|
||||
|
||||
数据库禁止外键,关联一致性通过服务端事务校验,索引、唯一约束和 CHECK 保留;readiness 同时检查迁移及零外键要求。这不表示完整平台已具备生产服务能力。验证证据见 [P0](docs/reports/phase-0.md)、[P1](docs/reports/phase-1.md)、[P2 阶段验收](docs/reports/phase-2.md)、[安全重试](docs/reports/phase-3-safe-retry.md)、[计划调度](docs/reports/phase-3-schedules.md)、[取消与无外键回归](docs/reports/phase-3-cancellation.md) 及 [队列资源回归](docs/reports/phase-3-queue-resources.md)。
|
||||
|
||||
|
||||
@@ -237,3 +237,11 @@ PollState新增FirstRound和FirstRoundAttemptsUsed,记录当前激活的首个
|
||||
VersionScope新增6个中文注释字段:命名空间、定义版本、定义、原摘要、去重队列JSON、索引创建时间;当前36个实体、311个直接持久化字段声明,全部有中文注释。版本范围不可更新,发布与范围同事务,读取摘要还要核验原定义和关联,不以派生索引授予权限。回填仅遍历DSL步骤,忽略常量中相同字段;旧版本及实例原文不修改。
|
||||
|
||||
7个新实例复合索引加现有业务键索引覆盖定义/业务键/状态8种组合,按创建时间与ID分页;全部保留无外键和无级联。范围校验、迁移性能边界和接口见[查询手册](api/workflow-queries.md)。
|
||||
|
||||
## 流程事件派生索引(000025)
|
||||
|
||||
Event.WorkflowID为只读STORED生成列,从原不可变事件派生流程归属,非流程或非法标识为空;应用不得独立写入,也不能将它作为授权依据。索引为(namespace_id,workflow_id,occurred_at,id)。历史原文不变,不补造过去缺失的信号转换;当前36个模型、312个直接持久化字段均有中文注释,无外键及级联。
|
||||
|
||||
新运行器将PENDING信号的消费、拒绝或过期与原状态事件同事务保存,批量拒绝分32条追加但整笔事务原子提交。公开查询核对固定版本全部队列、步骤/信号归属及历史激活。原始事件按数据库记录时间分页,版本与时间均不构成可靠消费水位。详见[状态时间线接口](api/workflow-events.md)与[验证报告](reports/phase-5-events.md)。
|
||||
|
||||
升级先停止旧写入节点,应用000025再启新版本,防止混合节点继续遗漏信号转换;生成列和索引涉及历史行扫描与可能的表重建,需预演维护窗口、磁盘和锁等待。严格SQL模式下先校验十六进制字符再转换,异常历史范围保留NULL,不清理原记录。
|
||||
|
||||
@@ -28,30 +28,30 @@
|
||||
| P1 任务受理闭环 | 应用凭据与作用域权限、队列、幂等提交、查询、待执行取消、受理事件;SDK 客户端 | API/SDK 创建查询闭环;重复键与内容冲突、响应未确认重报、跨命名空间拒绝、参数边界 | 已完成并通过阶段回归(见 P1 报告;真实断路/提交 ACK 丢失注入留后续故障阶段) |
|
||||
| P2 Worker 执行闭环 | WSS 接入、能力/容量、领取与开始、租约、续租、结果确认、排空与恢复;Worker SDK | 真 Worker 执行;失联/重连/重复确认/旧尝试拒绝;业务效果未知转核查;节点故障接管 | 已完成并通过阶段回归:服务端、自动 SDK、历史查询、完整 Schema 与真实进程强杀恢复;测试拓扑和剩余生产故障矩阵见 P2 验收报告 |
|
||||
| P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、运行中取消、并发许可、配额/背压、可靠结果订阅、批量受理、独立任务核查/人工重试、查询筛选与事务Outbox接入 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 已完成平台功能阶段验收:[原范围逐项核对](reports/phase-3.md)、[本轮完整回归](reports/phase-3-delay-recovery.md)。补齐相对延迟、改参数关联示例和四种重试全停恢复场景;20个可运行示例全部通过。全平台生产门槛及SDK首版交付剩余项仍按P6–P7推进 |
|
||||
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 定义格式/词法类型校验、不可变发布、独立SDK及六类节点定义例子已实现,已通过[子项验收](reports/phase-4-definitions.md);已补[有限决策器及内部结果交付](reports/phase-4-engine-core.md);[实例受理及首批事务](reports/phase-4-admission.md)已实现;[结果消费和根接续](reports/phase-4-runtime.md)已接入后台,顺序/分支/并行及真实进程恢复已验证;[TIMER与后续POLL轮次](13-workflow-clock.md)已接入;[信号消费和最终查询竞争](14-workflow-signals.md)已接入内部运行器;[内部控制与停止结清](15-workflow-controls.md)已接入;[内部核查与TASK新激活](16-workflow-recovery.md)已接入;等待恢复已接入内部服务;实例公共API/SDK与基础可运行场景已接入;流程任务来源已接入;历史激活/轮次/信号与信号审计已接入;实例筛选分页已接入;统一事件时间线范围核对与更多场景继续开发 |
|
||||
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 有界查询轮次/尝试独立预算及原截止恢复已接入内部运行器;最终信号竞争及在途查询结清已接入;内部暂停/继续/取消与失败结清已接入;执行核查、步骤结论、解除核查及TASK新激活已接入;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入;等待恢复已接入内部服务;公共API/SDK已接入;流程任务来源已接入;历史激活/轮次/信号与信号审计已接入;实例筛选分页已接入;统一事件时间线范围核对与更多场景继续开发 |
|
||||
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 定义格式/词法类型校验、不可变发布、独立SDK及六类节点定义例子已实现,已通过[子项验收](reports/phase-4-definitions.md);已补[有限决策器及内部结果交付](reports/phase-4-engine-core.md);[实例受理及首批事务](reports/phase-4-admission.md)已实现;[结果消费和根接续](reports/phase-4-runtime.md)已接入后台,顺序/分支/并行及真实进程恢复已验证;[TIMER与后续POLL轮次](13-workflow-clock.md)已接入;[信号消费和最终查询竞争](14-workflow-signals.md)已接入内部运行器;[内部控制与停止结清](15-workflow-controls.md)已接入;[内部核查与TASK新激活](16-workflow-recovery.md)已接入;等待恢复已接入内部服务;实例公共API/SDK与基础可运行场景已接入;流程任务来源已接入;历史激活/轮次/信号与信号审计已接入;实例筛选分页已接入;[状态事件时间线](api/workflow-events.md)已接入;独立轮询/恢复场景及全范围验收继续开发 |
|
||||
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 有界查询轮次/尝试独立预算及原截止恢复已接入内部运行器;最终信号竞争及在途查询结清已接入;内部暂停/继续/取消与失败结清已接入;执行核查、步骤结论、解除核查及TASK新激活已接入;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入;等待恢复已接入内部服务;公共API/SDK已接入;流程任务来源已接入;历史激活/轮次/信号与信号审计已接入;实例筛选分页已接入;[状态事件时间线](api/workflow-events.md)已接入;独立轮询/恢复场景及全范围验收继续开发 |
|
||||
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 |
|
||||
| P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 待开发 |
|
||||
|
||||
SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最后才开始补文档。
|
||||
|
||||
P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](reports/phase-4-clock.md)。外部信号与最终查询竞争见[信号报告](reports/phase-4-signals.md)。公共实例、控制、信号、六类恢复和操作审计已接入HTTP与独立SDK,见[公共接入报告](reports/phase-5-public-api.md)。流程任务来源及[历史查询](api/workflow-history.md)已接入,[实例筛选分页](api/workflow-queries.md)已接入,统一事件时间线及更多恢复示例仍须核对交付。
|
||||
P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](reports/phase-4-clock.md)。外部信号与最终查询竞争见[信号报告](reports/phase-4-signals.md)。公共实例、控制、信号、六类恢复和操作审计已接入HTTP与独立SDK,见[公共接入报告](reports/phase-5-public-api.md)。流程任务来源及[历史查询](api/workflow-history.md)已接入,[实例筛选分页](api/workflow-queries.md)已接入,[状态事件时间线](api/workflow-events.md)已接入,更多恢复示例仍须交付。
|
||||
|
||||
### P4/P5 下一步实施顺序
|
||||
|
||||
1. 已实现FAIL_AFTER_SETTLED与显式取消的内部分支结清,见[控制报告](reports/phase-5-controls.md):持久取消意图、复用Pulse取消、收集已发出查询/任务结果、等待全部已激活工作结清后才进入失败或取消终态。效果未知仍保留核查。
|
||||
2. 已实现实例暂停/继续/取消内部操作,以control_revision、操作幂等身份、明确原因及审计保护控制决定;暂停不延长原截止,继续不覆盖核查或取消状态。
|
||||
3. 已实现内部执行核查、步骤结论、解除核查及TASK新激活和旧激活隔离,见[核查报告](reports/phase-5-recovery.md)。POLL跨激活恢复已实现(见[查询恢复](17-workflow-poll-recovery.md));[核查后PENDING接续和独立等待重建](18-workflow-wait-recovery.md)也已接入内部服务,保留原预算、轮次、结果和证据。
|
||||
4. 实例/当前步骤/信号/控制/六类恢复及流程操作审计HTTP和独立SDK已接入,步骤查询返回实际激活代次;并行执行与信号回执场景已有可运行示例。激活/轮次/信号历史及信号资源审计已接入;实例筛选分页已接入;继续核对统一状态事件时间线,并提供独立轮询及恢复示例;[Pulse任务流程来源](api/task-source.md#5-流程固定来源)已交付。逐项验证原键恢复、权限、版本冲突、竞争与进程全停。
|
||||
4. 实例/当前步骤/信号/控制/六类恢复及流程操作审计HTTP和独立SDK已接入,步骤查询返回实际激活代次;并行执行与信号回执场景已有可运行示例。激活/轮次/信号历史及信号资源审计已接入;实例筛选分页已接入;状态事件时间线已接入;继续提供独立轮询及恢复示例;[Pulse任务流程来源](api/task-source.md#5-流程固定来源)已交付。逐项验证原键恢复、权限、版本冲突、竞争与进程全停。
|
||||
5. 转入P6/P7的生产故障、容量、运维、发布与全部文档核对。内部存储阶段通过不会提前勾选公共接口或生产验收。
|
||||
|
||||
公共接入按以下调用链推进;本批已接入启动/当前详情、控制、信号、六类恢复和流程操作审计,并提供独立SDK及基础示例。任务流程来源和有界历史查询也已接入。实例分页和独立查询示例已接入;以下统一事件时间线范围核对和更多独立场景继续交付:
|
||||
公共接入按以下调用链推进;本批已接入启动/当前详情、控制、信号、六类恢复和流程操作审计,并提供独立SDK及基础示例。任务流程来源和有界历史查询也已接入。实例分页和独立查询示例已接入;状态事件时间线已接入;更多独立场景继续交付:
|
||||
|
||||
| 调用链 | 需要补齐的内容 | 验收重点 |
|
||||
| --- | --- | --- |
|
||||
| 启动与读取 | 显式固定版本启动、当前实例、步骤及当前激活代次;已补有界历史读取及实例筛选分页 | 原键丢回执恢复、当前权限、读取快照一致性、未激活步骤语义 |
|
||||
| 控制与信号 | 暂停/继续/取消、固定步骤和代次的信号提交及原受理回执 | control_revision与通用revision区分、迟到/旧代次、暂停期间结果收集 |
|
||||
| 核查与追踪 | 六类恢复、激活/轮次/等待/信号历史读取及信号管理审计已接入;统一状态事件时间线待核对;Pulse任务流程来源已交付 | 当前实例/步骤/任务版本、证据和显式声明、旧历史保留、有界响应和权限隔离 |
|
||||
| 核查与追踪 | 六类恢复、激活/轮次/等待/信号历史读取及信号管理审计已接入;状态事件时间线已接入;Pulse任务流程来源已交付 | 当前实例/步骤/任务版本、证据和显式声明、旧历史保留、有界响应和权限隔离 |
|
||||
| 通用接入场景 | 并行汇合、外部回执、有限轮询、暂停核查恢复的可运行SDK示例 | 真实Worker执行、完整进程退出后接续、未知效果保留、全链路回归 |
|
||||
|
||||
接口只使用已经落地的内部事务规则;SDK保持独立module,不能引用服务端模型或数据库实现。生产故障及容量结论仍由P6专门验收。
|
||||
@@ -176,3 +176,7 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
|
||||
- [P2 SDK 协议阶段记录](reports/phase-2-sdk-protocol.md)
|
||||
- [P2 自动 Worker 运行器记录](reports/phase-2-worker-runtime.md)
|
||||
- [P2 执行历史与大载荷记录](reports/phase-2-execution-history.md)
|
||||
|
||||
### 状态事件时间线子项
|
||||
|
||||
已接入流程/步骤/信号状态事件的公共HTTP、独立SDK、原元数据详情与完整运行例子。000025仅派生索引,历史原字段保持;新增信号转换与状态同事务,批量失败整体回滚。当前阶段验证见[时间线报告](reports/phase-5-events.md),P4/P5全范围核对、独立POLL/人工恢复示例和P6/P7仍未完成。
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
| [12 Weave结果消费与推进](12-workflow-runtime.md) | 后台消费、根接续、事务与进程恢复,及尚未完成的等待/控制边界 |
|
||||
| [13 Weave定时等待与查询轮次](13-workflow-clock.md) | 原截止、轮次/尝试预算、时钟检查点、升级与全停恢复 |
|
||||
| [流程历史查询](api/workflow-history.md) | 指定历史激活、查询轮次、信号摘要与信号资源审计 |
|
||||
| [流程状态时间线](api/workflow-events.md) | 原始流程/步骤/信号状态事件、分页和历史详情 |
|
||||
| [接口手册](api/README.md) | 实际接口状态、调用说明与后续接口规划 |
|
||||
| [SDK 与场景](sdk/README.md) | 可运行示例、接入方法及分阶段 SDK 交付 |
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
| `/v1/workflow-definitions` 创建、发布与固定版本读取 | P4定义子项已实现,实例另用启动接口 | [定义接口](workflow-definitions.md)、[格式规范](workflow-definition-format.md) |
|
||||
| `/v1/workflows`、步骤详情、控制、信号及操作审计 | 已接入HTTP与独立SDK | [实例接口](workflows.md) |
|
||||
| 历史激活/轮次/信号列表、历史激活详情及信号资源审计 | 已接入有界分页HTTP与独立SDK | [历史查询](workflow-history.md) |
|
||||
| `/v1/workflows/{id}/events` 及单条事件详情 | 已接入不可变状态事件、审计权限及独立SDK | [状态时间线](workflow-events.md) |
|
||||
| `/v1/workflows/{id}/recovery/*` 六类人工恢复 | 已接入有证据恢复及独立SDK | [恢复接口](workflow-recovery.md) |
|
||||
|
||||
未实现路由返回 404。健康检查就绪不表示任务执行、定时调度或流程引擎已经可用。
|
||||
|
||||
158
docs/api/workflow-events.md
Normal file
158
docs/api/workflow-events.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 流程状态事件时间线
|
||||
|
||||
HTTP 与独立 Go SDK 已接入。用于查看流程、用户定义步骤与信号已经保存的状态变化;原事件不会被后续恢复覆盖。运行方法见[并行作业与回执示例](../examples/workflow.md),验证记录见[时间线报告](../reports/phase-5-events.md)。
|
||||
|
||||
## 1. 路由、权限与范围
|
||||
|
||||
| 方法与路径 | 权限 | Go SDK |
|
||||
| --- | --- | --- |
|
||||
| `GET /v1/workflows/{id}/events` | workflows:audit:read | ListWorkflowEvents(ctx, id, page) |
|
||||
| `GET /v1/workflows/{id}/events/{event}` | workflows:audit:read | GetWorkflowEvent(ctx, id, event) |
|
||||
|
||||
成功为200,使用 `Authorization: Bearer <token>`,无需请求体或幂等键。id和event均为32位小写十六进制。两条路由都要求显式审计权限,普通workflows:read不包含此权限;开发初始化用 `--workflow-audit-access`。每次读取都核验当前命名空间及实例固定版本引用的全部队列,包括尚未执行的分支。
|
||||
|
||||
数据库派生的workflow_id只用于缩小查询范围,不能授予权限。服务端继续核对真实实例、步骤、信号及事件引用的历史激活归属;内部`$root`步骤不出现在公开时间线。详情必须同时指定父实例和事件ID,不能用全局事件ID越权。
|
||||
|
||||
本接口提供逻辑状态事件。任务执行次数、原输入结果、POLL业务轮次、等待预算和人工决定通过下列接口关联查看:
|
||||
|
||||
| 要查看的内容 | 接口 |
|
||||
| --- | --- |
|
||||
| 当前步骤、任务引用、等待原因和剩余预算 | [实例与步骤详情](workflows.md) |
|
||||
| 指定历史激活的输入输出、等待、POLL轮次 | [流程历史](workflow-history.md) |
|
||||
| Pulse尝试与迟到/矛盾证据 | [执行历史](execution-history.md) |
|
||||
| 管理操作人、原因、证据及前后摘要 | [流程操作审计](workflows.md#6-操作审计与错误处理)、[信号审计](workflow-history.md) |
|
||||
| 任务的固定版本、步骤和历史激活来源 | [任务来源](task-source.md#5-流程固定来源) |
|
||||
|
||||
事件元数据只保留当时实际写入的引用,例如activation_id或audit_id;旧事件没有的字段不会用当前资源补造。操作审计与Pulse尝试不复制进本接口,避免将不同记录误认为同一种状态转换。
|
||||
|
||||
## 2. 摘要分页
|
||||
|
||||
只接受可选limit和cursor。limit为1–100,省略默认20;SDK的0表示省略。cursor最多1024字节,需进行URL编码。未知、重复、非法参数返回400;详情不接受查询参数。
|
||||
|
||||
```sh
|
||||
curl --get "$ORBIT_URL/v1/workflows/$WORKFLOW_ID/events" \
|
||||
--header "Authorization: Bearer $ORBIT_TOKEN" \
|
||||
--data-urlencode 'limit=20'
|
||||
```
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"data": {
|
||||
"workflow_id": "11111111111111111111111111111111",
|
||||
"revision": "12",
|
||||
"items": [{
|
||||
"id": "22222222222222222222222222222222",
|
||||
"workflow_id": "11111111111111111111111111111111",
|
||||
"aggregate_kind": "workflow_signal",
|
||||
"aggregate_id": "33333333333333333333333333333333",
|
||||
"aggregate_revision": "2",
|
||||
"type": "workflow.signal.changed",
|
||||
"state_scope": "SIGNAL",
|
||||
"state": "CONSUMED",
|
||||
"occurred_at": "2026-09-18T08:00:00.123456Z"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| workflow_id | 本页授权的实例 |
|
||||
| revision | 读取本页时的实例版本,uint64字符串;不是事件消费水位 |
|
||||
| items | 有界摘要;空列表为[],不带payload或业务输入结果 |
|
||||
| next_cursor | 还有更旧记录时返回;没有下一页则省略 |
|
||||
| items[].id | 原不可变事件ID |
|
||||
| aggregate_kind / aggregate_id | 来源类型及身份:workflow、workflow_step、workflow_signal |
|
||||
| aggregate_revision | 来源资源当时的版本,uint64字符串,各资源独立计数 |
|
||||
| type | 当时保存的事件类型 |
|
||||
| state_scope / state | WORKFLOW、STEP或SIGNAL,以及该对象当时保存的状态 |
|
||||
| step_key | 仅步骤事件返回,取自固定定义,不返回内部根步骤 |
|
||||
| occurred_at | 数据库记录事件的UTC微秒时间,RFC3339;不是外部业务发生时间 |
|
||||
|
||||
按occurred_at、id双键倒序,相同微秒由ID稳定定位。游标绑定命名空间、实例和事件查询种类,不能用于其他实例、操作审计或激活列表。跨页不承诺固定快照,新提交或迟到提交的事件可能落在已经翻过的位置;重新从第一页读取可查看更新。
|
||||
|
||||
时间戳及ID均不能表达严格因果或提交顺序。例如同一个启动事务中,首步状态可能先于workflow.started写入;跨资源aggregate_revision不能比较大小。定时退避可能改变资源版本而不改变状态,因此状态事件的版本也可能不连续。需要可靠处理Pulse结果时使用[持久订阅](subscriptions.md),不要将此审计分页当作消息消费协议。
|
||||
|
||||
## 3. 类型与原事件详情
|
||||
|
||||
| type | aggregate_kind | state_scope | 记录内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| workflow.started | workflow | WORKFLOW | 首次启动、固定定义及版本 |
|
||||
| workflow.advanced | workflow | WORKFLOW | 持久推进时保存的实例状态 |
|
||||
| workflow.controlled | workflow | WORKFLOW | 暂停、继续、取消的控制结果 |
|
||||
| workflow.recovered | workflow | WORKFLOW | 人工恢复决定及当时关联 |
|
||||
| workflow.blocked | workflow | WORKFLOW | 进入核查等阻塞状态 |
|
||||
| workflow.stopped | workflow | WORKFLOW | 停止意图结清后的终态 |
|
||||
| workflow.step.changed | workflow_step | STEP | 步骤当时状态和已记录的激活/原因 |
|
||||
| workflow.signal.accepted | workflow | SIGNAL | 保存信号受理记录,state可为PENDING、REJECTED或EXPIRED |
|
||||
| workflow.signal.changed | workflow_signal | SIGNAL | 已有PENDING信号被消费、拒绝或过期 |
|
||||
|
||||
`workflow.signal.accepted`沿用历史格式,aggregate_id和aggregate_revision属于实例;state_scope为SIGNAL,信号ID在payload.signal_id中。受理成功不等于信号被消费,受理时就被拒绝的记录不会再制造一个虚假的PENDING转换。
|
||||
|
||||
详情data在摘要字段外增加payload,为原事件元数据对象,最大64KiB。例如上述信号消费事件的payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"workflow_id": "11111111111111111111111111111111",
|
||||
"signal_id": "33333333333333333333333333333333",
|
||||
"from": "PENDING",
|
||||
"state": "CONSUMED",
|
||||
"disposition": "WAIT_MATCHED",
|
||||
"consumed_wait_id": "44444444444444444444444444444444"
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
curl "$ORBIT_URL/v1/workflows/$WORKFLOW_ID/events/$EVENT_ID" \
|
||||
--header "Authorization: Bearer $ORBIT_TOKEN"
|
||||
```
|
||||
|
||||
信号消费、过期和拒绝,与对应事件在同一数据库事务提交。批量拒绝最多处理单实例既有上限1024条,每32条批量追加事件;任一批失败会回滚整笔转换,重复处理不重复制造事件。该原子性覆盖平台记录,不保证外部系统业务副作用只发生一次。
|
||||
|
||||
## 4. SDK调用
|
||||
|
||||
类型见[workflow_events.go](../../sdk/go/workflow_events.go)。返回revision和aggregate_revision自动解析为uint64;Payload为json.RawMessage,保留精确数字,进一步解析未知元数据时用json.Decoder.UseNumber或明确整数类型。
|
||||
|
||||
```go
|
||||
cursor := ""
|
||||
for {
|
||||
page, err := client.ListWorkflowEvents(ctx, workflowID,
|
||||
orbit.HistoryPageRequest{Limit: 20, Cursor: cursor})
|
||||
if err != nil { return err }
|
||||
for _, event := range page.Items {
|
||||
fmt.Printf("%s %s %s %s\n", event.OccurredAt, event.Type,
|
||||
event.StateScope, event.State)
|
||||
// 仅在需要展开某条事件时读取原元数据。
|
||||
if event.Type == "workflow.signal.changed" {
|
||||
detail, err := client.GetWorkflowEvent(ctx, workflowID, event.ID)
|
||||
if err != nil { return err }
|
||||
fmt.Printf("%s\n", detail.Payload)
|
||||
}
|
||||
}
|
||||
if page.NextCursor == "" { break }
|
||||
cursor = page.NextCursor
|
||||
}
|
||||
```
|
||||
|
||||
完整[workflow示例](../examples/workflow.md)运行真实Worker,保存外部回执后按每页5条读取时间线,核对CONSUMED事件的原PENDING状态及赢家等待ID。
|
||||
|
||||
## 5. 错误、升级与验证边界
|
||||
|
||||
| HTTP / code | 处理 |
|
||||
| --- | --- |
|
||||
| 400 / INVALID_ARGUMENT | 修正非法ID、limit、cursor或参数;不要删除父资源或切换查询种类重试 |
|
||||
| 401 / UNAUTHENTICATED | 检查有效凭据 |
|
||||
| 403 / PERMISSION_DENIED | 显式授权workflows:audit:read |
|
||||
| 404 / NOT_FOUND | 实例/事件不存在、父子错配、范围不可访问;不按全局ID降级查找 |
|
||||
| 503 / UNAVAILABLE | 数据库故障、已选事件归属损坏、未知历史类型或超大元数据;保留现场核查,不能把空页当作成功 |
|
||||
|
||||
只读请求可在原实例与游标下有限重试;数据改变后结果可能不同。取消HTTP只取消本次读取,不控制流程。SDK拒绝父资源不一致、无效摘要、排序不符和超限详情,不自动补造历史。
|
||||
|
||||
迁移000025新增只读派生列及`(namespace_id,workflow_id,occurred_at,id)`索引,无外键和级联。已有事件原字段不变;无合法流程范围的历史行派生为NULL,不进入流程时间线。新读取仍核验实际归属,但本接口不是全库损坏扫描,无法自动找出被错误范围隐藏的历史行。
|
||||
|
||||
旧版本没有保存的信号转换不会回填。部署时先停止旧写入节点,完成迁移再启动新节点,否则混合写入期间可能继续缺少转换事件。STORED生成列和索引需要读取历史数据并可能重建表,生产升级须预演锁等待、时长及磁盘空间;不承诺无停机DDL。旧不可变迁移保持原校验和。
|
||||
|
||||
真实MySQL验证包括升级原文保留、零外键、相同时间分页、授权、跨父资源、消费/拒绝/过期、分批写入失败回滚、重复处理和历史激活恢复;HTTP/SDK及完整例子已接入标准回归。大库索引开销、生产故障域、主从切换与全量运维仍属于P6/P7验收。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 流程激活、查询轮次、信号与信号审计
|
||||
|
||||
状态:HTTP与独立Go SDK已接入;本页查询明确指定的历史激活,不跟随逻辑步骤的当前激活。实例筛选分页、统一状态事件时间线及更多独立恢复场景仍按[开发计划](../08-development-plan.md)核对与交付。测试与范围见[本批报告](../reports/phase-5-history.md)。
|
||||
状态:HTTP与独立Go SDK已接入;本页查询明确指定的历史激活,不跟随逻辑步骤的当前激活。[实例筛选分页](workflow-queries.md)和[状态事件时间线](workflow-events.md)已接入,更多独立恢复场景仍按[开发计划](../08-development-plan.md)交付。测试与范围见[本批报告](../reports/phase-5-history.md)。
|
||||
|
||||
## 1. 路由与调用规则
|
||||
|
||||
|
||||
@@ -153,6 +153,8 @@ name/payload_version/target_step_key必填,分别最多128/32/64个ASCII名称
|
||||
|
||||
## 6. 操作审计与错误处理
|
||||
|
||||
[状态事件时间线](workflow-events.md)另提供流程、步骤和信号的不可变状态变化,以及原事件元数据;同样要求显式审计权限。此处操作审计保留管理决定的操作人、原因、证据及前后摘要,两类记录通过已保存的身份关联,不将当前资源状态反填为历史。
|
||||
|
||||
ListWorkflowOperations接受HistoryPageRequest:limit默认20、范围1–100;cursor最大1024字节,绑定命名空间、流程ID和查询种类,不可跨资源复用。data为 `{items, next_cursor?}`;每项含 id、workflow_id、operation_id、actor_id、action、created_at,按时间及ID倒序。GetWorkflowOperation除这些字段外还返回reason、evidence_ref(可空省略)、before、after JSON摘要。单独读取正文,避免列表聚合大结果。
|
||||
|
||||
该接口当前查询resource_kind=workflow的启动、推进、控制和恢复审计;独立任务核查审计通过恢复回执的task_audit_id关联。信号自身的受理状态通过GetWorkflowSignal读取;[信号资源审计及历史激活查询](workflow-history.md)使用独立路由,统一状态事件时间线仍待核对交付。
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
- [完整命令、输入输出与失败处理](workflow.md)、[独立SDK源码](../../sdk/go/examples/workflow/main.go)。
|
||||
- 两个任务并行处理并汇合,计时等待后接收显式目标代次的信号,再执行后续任务;最终三个任务完成并有界排空。
|
||||
- 启动及信号原键重复不会建立新的实例或信号。
|
||||
- 启动及信号原键重复不会建立新的实例或信号;分页查看原状态事件,核对信号消费及赢家等待。
|
||||
|
||||
## 已可运行:业务事务与任务提交Outbox
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ chmod 700 .local
|
||||
go run ./cmd/orbit-admin bootstrap \
|
||||
--application workflow-example --namespace default --environment development \
|
||||
--queue workflows --workflow-access --workflow-run-access \
|
||||
--workflow-signal-access --worker-access \
|
||||
--workflow-signal-access --workflow-audit-access --worker-access \
|
||||
--output .local/workflow.credentials.json
|
||||
go run ./cmd/orbit
|
||||
```
|
||||
@@ -31,7 +31,7 @@ go run ./examples/workflow
|
||||
|
||||
## 2. 调用链与输出
|
||||
|
||||
CreateWorkflowDefinition → PublishWorkflowVersion(v1) → StartWorkflow → 原键再次StartWorkflow → 自动Worker并行执行 → GetWorkflowStep读取当前信号契约与代次 → SendWorkflowSignal → 原键再次发送 → 后继任务完成 → GetWorkflow → GetWorkflowSteps和GetTaskSource核对三个任务的固定来源 → Worker有界排空。
|
||||
CreateWorkflowDefinition → PublishWorkflowVersion(v1) → StartWorkflow → 原键再次StartWorkflow → 自动Worker并行执行 → GetWorkflowStep读取当前信号契约与代次 → SendWorkflowSignal → 原键再次发送 → 后继任务完成 → GetWorkflow → GetWorkflowSteps和GetTaskSource核对三个任务的固定来源 → 读取历史激活与信号 → 分页读取状态事件并核对消费记录 → Worker有界排空。
|
||||
|
||||
预期输出中的ID每次运行不同:
|
||||
|
||||
@@ -39,6 +39,7 @@ CreateWorkflowDefinition → PublishWorkflowVersion(v1) → StartWorkflow →
|
||||
workflow=<32位实例ID> state=SUCCEEDED result={"text":"HELLO ORBIT"} start_deduplicated=true signal_deduplicated=true
|
||||
workflow_sources_verified=3 fixed_version=v1
|
||||
workflow_history_verified=true signal_state=CONSUMED
|
||||
workflow_timeline_verified=true events=<事件数> signal_transition=PENDING->CONSUMED
|
||||
worker_drained=true started=3 confirmed=3
|
||||
```
|
||||
|
||||
@@ -56,4 +57,8 @@ worker_drained=true started=3 confirmed=3
|
||||
|
||||
[公共SDK集成测试](../../sdk/go/workflow_instances_integration_test.go)还实际运行:启动响应丢失的原键恢复;暂停/继续、定时等待、信号消费和取消;UNKNOWN执行核查、步骤结论及解除核查;剩余尝试内TASK重试与SIGNAL换代;确认PENDING后沿原查询预算继续。使用`make integration`在一次性环境复现。
|
||||
|
||||
历史激活和信号列表已接入,示例核对指定历史等待的winner与信号CONSUMED状态。实例筛选分页、更多独立业务示例及生产验收仍按[计划](../08-development-plan.md)推进。
|
||||
历史激活和信号列表已接入,示例核对指定历史等待的winner与信号CONSUMED状态。[实例筛选分页](workflow-query.md)已接入;更多独立轮询/恢复示例及生产验收仍按[计划](../08-development-plan.md)推进。
|
||||
|
||||
## 5. 状态事件审计
|
||||
|
||||
例子使用显式审计权限,按每页5条调用ListWorkflowEvents,再用GetWorkflowEvent展开已消费信号,核对原状态PENDING和历史等待ID。当前成功流程的事件仍保留早先WAITING等状态。事件数量可能随实际推进次数变化,不将数量写死为业务契约;示例会确认必要消费事件存在。字段、分页限制和旧版本历史缺口见[时间线手册](../api/workflow-events.md)。
|
||||
|
||||
58
docs/reports/phase-5-events.md
Normal file
58
docs/reports/phase-5-events.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# P4/P5 流程状态事件时间线验证记录
|
||||
|
||||
日期:2026-09-18。本批范围是流程状态事件的分页、详情、HTTP、独立SDK,以及信号消费/拒绝/过期的事务事件。P4/P5全范围核对、独立POLL/人工恢复场景和P6/P7仍未完成。
|
||||
|
||||
## 实现与接口
|
||||
|
||||
- `GET /v1/workflows/{id}/events`及`/{event}`,对应ListWorkflowEvents和GetWorkflowEvent。两条路由都要求显式workflows:audit:read,并核对固定版本全部队列。
|
||||
- 原始workflow、workflow_step、workflow_signal事件按数据库记录时间与ID倒序,不展现内部根步骤。摘要默认20、最多100条,原元数据详情不超过64KiB。
|
||||
- 实例、步骤和信号是独立聚合,revision不能跨资源比较;时间/ID不能推断提交水位或可靠消费进度。旧事件只展示当时保存的字段,不用当前激活改写历史。
|
||||
- PENDING信号的新转换与原状态事件同事务提交。批量拒绝每32条插入事件,任一批失败时全部回滚;保留单实例1024条历史信号上限。
|
||||
- 000025增加只读派生列和分页索引,不添加外键或级联,不补造历史缺失的信号转换。索引只用于查找,实际资源归属仍由服务端核验。
|
||||
- 36个持久化模型、312个直接字段均有中文注释;新增列同步中文COMMENT。
|
||||
|
||||
接口、字段及SDK片段见[时间线手册](../api/workflow-events.md)。[workflow完整例子](../examples/workflow.md)在真实Worker任务完成后,按每页5条核对信号原状态与赢家等待ID。原激活/轮次、Pulse尝试及操作审计仍通过各自接口关联,不能把逻辑时间线误称为所有底层记录的单一事件流。
|
||||
|
||||
## 环境与命令
|
||||
|
||||
macOS arm64、Go 1.27.1、Docker MySQL 8.4.10,一次性隔离数据库;真实Orbit进程、HTTP及WSS Worker。未访问生产数据库。
|
||||
|
||||
```sh
|
||||
make fmt test vet build
|
||||
DOCKER=/usr/local/bin/docker make integration
|
||||
```
|
||||
|
||||
本地通过GO参数指定工具链。标准集成入口增加WorkflowEventsSDK,沿用21个SDK例子和2个独立业务模块,原有流程/Worker/订阅进程故障用例继续执行。
|
||||
|
||||
## 验证记录
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| fmt、race单元回归、vet与构建 | 通过 |
|
||||
| 流程及步骤真实推进、列表无payload、详情与摘要一致 | 定向MySQL通过 |
|
||||
| 权限、队列限制、跨实例事件与跨种类游标 | 定向MySQL通过 |
|
||||
| 消费赢家、竞争者拒绝、重复处理 | 定向MySQL通过,状态与事件不重复 |
|
||||
| 单条事件写入失败 | 定向MySQL通过,赢家等待/信号/后继全部回滚 |
|
||||
| 70条待处理信号停止结清,第二批事件写入失败 | 定向MySQL通过,70条信号仍PENDING、已插入批次全部回滚;恢复后70条REJECTED各有事件 |
|
||||
| 同一SQL批次相同微秒时间的分页 | 定向MySQL通过,每页7条覆盖相同时间组,无漏无重 |
|
||||
| 过期、错误派生范围及超大元数据 | 定向MySQL通过;不返回损坏候选的部分页 |
|
||||
| v24→v25升级与重跑 | 通过,旧8个原字段全部相同、精确JSON保留、非法范围NULL、只读生成列不可覆盖、索引列序正确、外键0 |
|
||||
| 真实HTTP独立SDK | 通过,11个原事件分6页,信号CONSUMED与流程CANCELLED保留 |
|
||||
| 完整workflow示例 | 通过,3个Worker任务成功并排空,20条事件包含PENDING→CONSUMED和赢家等待 |
|
||||
| 定义与原五组公共实例SDK回归 | 定向链路通过 |
|
||||
| 新增旧激活恢复后事件读取与损坏归属拒绝 | 全量存储回归通过,恢复后原事件保持,错误历史归属返回UNAVAILABLE |
|
||||
| 完整标准集成脚本 | 通过,退出码0;21个SDK例子、2个独立业务模块、流程/Worker/订阅进程故障全部完成 |
|
||||
|
||||
定向存储12.734秒,SDK组26.530秒,其中时间线SDK2.26秒。这些是测试执行耗时,不是吞吐或延迟指标。当前312个模型字段中文注释检查缺失0;Markdown检查1096个本地链接、68个JSON块,错误0。
|
||||
|
||||
回归修正了两处测试准备错误:尚在等待的错误关联信号会在受理时直接REJECTED,不能作为PENDING批量转换夹具,因此改为前置TIMER尚未结束时收到的早到信号;SDK模拟响应必须提供合法32位request_id。严格SQL模式还揭示UNHEX非法值在生成列DDL中会报错,因此转换前先核验全部字符,真实v24旧库重跑升级通过。修复没有放宽权限或移除归属检查。
|
||||
|
||||
## 生产边界和剩余工作
|
||||
|
||||
- 索引只是查询范围,不是授权真相;被非法范围隐藏的历史行不会由单实例分页自动检出,需要数据库写权限保护与专项核查。
|
||||
- 旧节点未保存的信号转换不会自动出现。先停止旧写入节点、应用迁移,再由新版接管;未实现无协调混合写节点升级。
|
||||
- STORED生成列和索引涉及旧行扫描及可能的表重建。大库DDL时长、空间、锁等待、查询稀疏性及信号批量事务开销仍需P6真实容量验证。
|
||||
- 本地进程故障回归不等于跨故障域高可用、MySQL主从切换或生产备份恢复验收。平台事务原子性也不等于外部业务副作用只执行一次。
|
||||
- 继续独立轮询/人工恢复示例、P4/P5原范围核对和P6/P7;当前持续目标保持进行中。
|
||||
|
||||
完整标准集成脚本退出码0:存储组237.549秒,流程进程恢复48.726秒,最后Worker/订阅进程故障组97.407秒。新时间线6组存储场景与v24升级通过;历史v7→当前迁移再次确认70个外键全部移除、323个原索引组成部分和原任务保留。生产容量与高可用边界仍按上述未验证项处理。
|
||||
@@ -208,3 +208,5 @@ P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md)
|
||||
流程任务同样调用`GetTaskSource`,kind为WORKFLOW时读取workflow中的固定版本、步骤、历史激活代次、查询轮次和直接恢复父任务。该代次不表示当前等待目标;发送信号须另读GetWorkflowStep。完整字段及兼容边界见[来源接口](../api/task-source.md#5-流程固定来源),[workflow示例](../examples/workflow.md)实际核对三个任务来源。
|
||||
|
||||
[ListWorkflows及完整查询示例](../api/workflow-queries.md)已接入,固定筛选沿游标分页,覆盖未执行分支的全部队列权限。
|
||||
|
||||
[ListWorkflowEvents / GetWorkflowEvent](../api/workflow-events.md)按实例读取不可变状态事件,要求显式workflows:audit:read;[完整workflow示例](../examples/workflow.md)核对信号从PENDING到CONSUMED的原事件与赢家等待。游标用于审计翻页,不是可靠订阅水位。
|
||||
|
||||
@@ -64,6 +64,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
|
||||
retryFinish(t, g, finished, "SUCCEEDED", false)
|
||||
assertApplicationCounts(t, f, 4, 2, 1)
|
||||
// 还原后续新增表、字段和索引,得到真实 v9 结构;现有任务内容保持不变。
|
||||
f.exec(`ALTER TABLE orbit_events DROP INDEX ix_event_workflow_history,DROP COLUMN workflow_id`)
|
||||
f.exec(`DROP TABLE weave_version_scopes`)
|
||||
f.exec(`ALTER TABLE weave_instances DROP INDEX ix_workflow_created,DROP INDEX ix_workflow_state_created,DROP INDEX ix_workflow_business_state_created,DROP INDEX ix_workflow_definition_created,DROP INDEX ix_workflow_definition_state_created,DROP INDEX ix_workflow_definition_business_created,DROP INDEX ix_workflow_definition_business_state_created`)
|
||||
f.exec(`ALTER TABLE weave_signals DROP INDEX ix_signal_history`)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 从已有不可变事件派生流程检索范围,不改写历史原文或伪造过去的状态转换。
|
||||
ALTER TABLE orbit_events
|
||||
ADD COLUMN workflow_id BINARY(16) GENERATED ALWAYS AS (
|
||||
CASE
|
||||
WHEN aggregate_kind='workflow' THEN aggregate_id
|
||||
WHEN aggregate_kind IN ('workflow_step','workflow_signal')
|
||||
AND JSON_TYPE(JSON_EXTRACT(payload,'$.workflow_id'))='STRING'
|
||||
AND OCTET_LENGTH(JSON_UNQUOTE(JSON_EXTRACT(payload,'$.workflow_id')))=32
|
||||
-- 严格DDL模式下UNHEX非法值会中止迁移,先确认32个字符全部是十六进制。
|
||||
AND OCTET_LENGTH(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(JSON_UNQUOTE(JSON_EXTRACT(payload,'$.workflow_id'))),'0',''),'1',''),'2',''),'3',''),'4',''),'5',''),'6',''),'7',''),'8',''),'9',''),'a',''),'b',''),'c',''),'d',''),'e',''),'f',''))=0
|
||||
THEN UNHEX(JSON_UNQUOTE(JSON_EXTRACT(payload,'$.workflow_id')))
|
||||
ELSE NULL
|
||||
END
|
||||
) STORED COMMENT '从不可变事件派生的流程查询标识;无范围或非法历史值为空,读取仍核对父资源,不能作为授权依据',
|
||||
ADD KEY ix_event_workflow_history(namespace_id,workflow_id,occurred_at,id);
|
||||
@@ -88,8 +88,8 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
|
||||
}
|
||||
indexes := func() []string {
|
||||
t.Helper()
|
||||
// 排除后续新增/替换索引;原有项在此逐项比较,v24新增项由WorkflowQueryUpgrade核对。
|
||||
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||
// 排除后续新增/替换索引;原有项在此逐项比较,v24/v25新增项由对应Query/Timeline升级测试核对。
|
||||
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ func (r *WorkflowRuntime) settleWorkflow(ctx context.Context, tx *sql.Tx, g *wor
|
||||
}
|
||||
}
|
||||
// 已无平台内有效授权时不逐秒空转;UNKNOWN不证明外部执行已结束,后续核查负责接续。
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE weave_signals SET state='REJECTED',disposition='WORKFLOW_STOPPED_REVIEW',revision=revision+1,updated_at=? WHERE namespace_id=? AND workflow_id=? AND state='PENDING'`, now, g.view.NamespaceID, g.view.ID); err != nil {
|
||||
if err := rejectPendingWorkflowSignals(ctx, tx, g, "", nil, "WORKFLOW_STOPPED_REVIEW", now); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := deleteWorkflowClock(ctx, tx, g, g.steps[workflowRootKey]); err != nil {
|
||||
@@ -212,7 +212,7 @@ func (r *WorkflowRuntime) settleWorkflow(ctx context.Context, tx *sql.Tx, g *wor
|
||||
}
|
||||
changed++
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE weave_signals SET state='REJECTED',disposition='WORKFLOW_STOPPED',revision=revision+1,updated_at=? WHERE namespace_id=? AND workflow_id=? AND state='PENDING'`, now, g.view.NamespaceID, g.view.ID); err != nil {
|
||||
if err := rejectPendingWorkflowSignals(ctx, tx, g, "", nil, "WORKFLOW_STOPPED", now); err != nil {
|
||||
return false, err
|
||||
}
|
||||
next, rootState := weave.InstanceCancelled, weave.StepCancelled
|
||||
|
||||
97
internal/storage/mysql/workflow_signal_events.go
Normal file
97
internal/storage/mysql/workflow_signal_events.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/signal"
|
||||
)
|
||||
|
||||
type signalTransition struct {
|
||||
WorkflowID core.ID `json:"workflow_id"` // WorkflowID 与信号归属同事务保存的流程身份。
|
||||
SignalID core.ID `json:"signal_id"` // SignalID 被修改的信号身份,旧记录保留。
|
||||
From string `json:"from"` // From 本次转换前的真实状态,不以当前状态推测过去。
|
||||
State string `json:"state"` // State 本次转换提交的目标状态。
|
||||
Disposition string `json:"disposition"` // Disposition 消费、过期或拒绝的稳定原因。
|
||||
ConsumedWaitID *core.ID `json:"consumed_wait_id,omitempty"` // ConsumedWaitID 消费赢家等待,其他转换省略。
|
||||
}
|
||||
|
||||
func emitWorkflowSignalTransition(ctx context.Context, tx *sql.Tx, ns, workflow, id core.ID, revision uint64, state, reason string, wait *core.ID) error {
|
||||
return emitEvent(ctx, tx, ns, id, revision, "workflow_signal", "workflow.signal.changed", signalTransition{workflow, id, "PENDING", state, reason, wait})
|
||||
}
|
||||
|
||||
// rejectPendingWorkflowSignals 的filter只由内部固定SQL构造。调用方已持有实例写锁,
|
||||
// 一次读取最多1025条检测破坏上限的存储;更新和每条事件同事务,按32条批量追加事件。
|
||||
func rejectPendingWorkflowSignals(ctx context.Context, tx *sql.Tx, g *workflowGraph, filter string, args []any, reason string, now time.Time) error {
|
||||
where := ` WHERE s.namespace_id=? AND s.workflow_id=? AND s.state='PENDING'` + filter
|
||||
params := append([]any{g.view.NamespaceID, g.view.ID}, args...)
|
||||
rows, err := tx.QueryContext(ctx, `SELECT s.id,s.revision FROM weave_signals s`+where+` ORDER BY s.id LIMIT ? FOR UPDATE`, append(params, signal.MaxPerWorkflow+1)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type entry struct {
|
||||
id core.ID
|
||||
revision uint64
|
||||
}
|
||||
var items []entry
|
||||
for rows.Next() {
|
||||
var v entry
|
||||
if err = rows.Scan(&v.id, &v.revision); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
items = append(items, v)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) > signal.MaxPerWorkflow {
|
||||
return errors.New("workflow signal limit violated")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
update := append([]any{reason, now}, params...)
|
||||
result, err := tx.ExecContext(ctx, `UPDATE weave_signals s SET s.state='REJECTED',s.disposition=?,s.revision=s.revision+1,s.updated_at=?`+where, update...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != int64(len(items)) {
|
||||
return errors.New("workflow signal rejection set changed")
|
||||
}
|
||||
for start := 0; start < len(items); start += 32 {
|
||||
end := start + 32
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
var values []string
|
||||
var insert []any
|
||||
for _, v := range items[start:end] {
|
||||
event, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(signalTransition{WorkflowID: g.view.ID, SignalID: v.id, From: "PENDING", State: "REJECTED", Disposition: reason})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values = append(values, "(?,?,?,?,?,?,?,UTC_TIMESTAMP(6))")
|
||||
insert = append(insert, g.view.NamespaceID, event, "workflow_signal", v.id, v.revision+1, "workflow.signal.changed", string(raw))
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO orbit_events(namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at) VALUES `+strings.Join(values, ","), insert...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func (r *WorkflowRuntime) driveWorkflowSignal(ctx context.Context, tx *sql.Tx, g
|
||||
return false, false, "", err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE weave_signals s JOIN weave_activations a ON a.namespace_id=s.namespace_id AND a.workflow_id=s.workflow_id AND a.sequence=s.target_activation_sequence SET s.state='REJECTED',s.disposition=?,s.revision=s.revision+1,s.updated_at=? WHERE s.namespace_id=? AND s.workflow_id=? AND s.target_step_key=? AND a.id=? AND a.step_id=? AND s.state='PENDING'`, string(invalid), now, g.view.NamespaceID, g.view.ID, row.view.Key, row.view.ActivationID, row.view.ID); err != nil {
|
||||
if err := rejectPendingWorkflowSignals(ctx, tx, g, ` AND s.target_step_key=? AND EXISTS(SELECT 1 FROM weave_activations a WHERE a.namespace_id=s.namespace_id AND a.workflow_id=s.workflow_id AND a.sequence=s.target_activation_sequence AND a.id=? AND a.step_id=?)`, []any{row.view.Key, row.view.ActivationID, row.view.ID}, string(invalid), now); err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
if err := deleteWorkflowClock(ctx, tx, g, row); err != nil {
|
||||
@@ -315,5 +315,8 @@ func (r *WorkflowRuntime) expireWorkflowSignal(ctx context.Context, tx *sql.Tx,
|
||||
if err := exactlyOne(res, err); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := emitWorkflowSignalTransition(ctx, tx, c.ns, c.workflow, v.ID, v.Revision+1, "EXPIRED", "UNMATCHED_DEADLINE", nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, r.audit(ctx, tx, g, c.id, "SIGNAL_EXPIRED", now)
|
||||
}
|
||||
|
||||
@@ -175,9 +175,8 @@ func claimWorkflowSignal(ctx context.Context, tx *sql.Tx, g *workflowGraph, w *w
|
||||
v.Revision++
|
||||
v.Disposition = "WAIT_MATCHED"
|
||||
v.ConsumedWaitID = &w.id
|
||||
return nil
|
||||
return emitWorkflowSignalTransition(ctx, tx, g.view.NamespaceID, g.view.ID, v.ID, v.Revision, string(v.State), v.Disposition, v.ConsumedWaitID)
|
||||
}
|
||||
func rejectOtherWorkflowSignals(ctx context.Context, tx *sql.Tx, g *workflowGraph, row *workflowStep, sequence uint32, reason string, now time.Time) error {
|
||||
_, err := tx.ExecContext(ctx, `UPDATE weave_signals SET state='REJECTED',disposition=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND workflow_id=? AND target_step_key=? AND target_activation_sequence=? AND state='PENDING'`, reason, now, g.view.NamespaceID, g.view.ID, row.view.Key, sequence)
|
||||
return err
|
||||
return rejectPendingWorkflowSignals(ctx, tx, g, ` AND s.target_step_key=? AND s.target_activation_sequence=?`, []any{row.view.Key, sequence}, reason, now)
|
||||
}
|
||||
|
||||
266
internal/storage/mysql/workflow_timeline.go
Normal file
266
internal/storage/mysql/workflow_timeline.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/instance"
|
||||
)
|
||||
|
||||
const workflowEventColumns = `e.id,e.aggregate_kind,e.aggregate_id,e.aggregate_revision,e.type,e.occurred_at,IF(OCTET_LENGTH(CAST(e.payload AS CHAR))<=65536,e.payload,NULL)`
|
||||
const workflowEventTables = ` FROM orbit_events e FORCE INDEX(ix_event_workflow_history)
|
||||
LEFT JOIN weave_steps s ON e.aggregate_kind='workflow_step' AND s.namespace_id=e.namespace_id AND s.id=e.aggregate_id AND s.workflow_id=e.workflow_id
|
||||
WHERE e.namespace_id=? AND e.workflow_id=? AND (e.aggregate_kind<>'workflow_step' OR s.id IS NULL OR s.key<>'$root')`
|
||||
|
||||
type workflowEventParent struct {
|
||||
view instance.View
|
||||
steps map[core.ID]instance.StepView
|
||||
}
|
||||
|
||||
func readWorkflowEventParent(ctx context.Context, tx *sql.Tx, ns, id core.ID, p auth.Permissions) (workflowEventParent, error) {
|
||||
var out workflowEventParent
|
||||
view, gate, ver, err := readWorkflowForQuery(ctx, tx, ns, id, p)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
steps, err := readWorkflowStepViews(ctx, tx, ns, id, gate, ver, "")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.view = view
|
||||
out.steps = map[core.ID]instance.StepView{}
|
||||
for _, step := range steps {
|
||||
out.steps[step.ID] = step
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func scanWorkflowEvent(row scanner) (instance.EventDetail, error) {
|
||||
var out instance.EventDetail
|
||||
var raw []byte
|
||||
err := row.Scan(&out.ID, &out.AggregateKind, &out.AggregateID, &out.AggregateRevision, &out.Type, &out.OccurredAt, &raw)
|
||||
out.Payload = raw
|
||||
return out, err
|
||||
}
|
||||
func eventIDField(fields map[string]json.RawMessage, name string) (core.ID, error) {
|
||||
var id core.ID
|
||||
if json.Unmarshal(fields[name], &id) != nil || id.Validate() != nil {
|
||||
return "", errors.New("invalid workflow event reference")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
func verifyWorkflowEvent(ctx context.Context, tx *sql.Tx, h workflowEventParent, e *instance.EventDetail) error {
|
||||
if e.ID.Validate() != nil || e.AggregateID.Validate() != nil || e.AggregateRevision == 0 || len(e.Payload) == 0 {
|
||||
return errors.New("workflow event metadata invalid or oversized")
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal(e.Payload, &fields) != nil || fields == nil || json.Unmarshal(fields["state"], &e.State) != nil {
|
||||
return errors.New("workflow event state missing")
|
||||
}
|
||||
e.WorkflowID = h.view.ID
|
||||
switch e.AggregateKind {
|
||||
case "workflow":
|
||||
if e.AggregateID != h.view.ID || e.AggregateRevision > h.view.Revision {
|
||||
return errors.New("workflow event aggregate mismatch")
|
||||
}
|
||||
e.StateScope = "WORKFLOW"
|
||||
switch e.Type {
|
||||
case "workflow.started":
|
||||
def, err := eventIDField(fields, "definition_id")
|
||||
if err != nil || def != h.view.DefinitionID {
|
||||
return errors.New("workflow event definition mismatch")
|
||||
}
|
||||
version, err := eventIDField(fields, "definition_version_id")
|
||||
if err != nil || version != h.view.DefinitionVersionID {
|
||||
return errors.New("workflow event fixed version mismatch")
|
||||
}
|
||||
case "workflow.advanced", "workflow.controlled", "workflow.recovered", "workflow.blocked", "workflow.stopped":
|
||||
case "workflow.signal.accepted":
|
||||
e.StateScope = "SIGNAL"
|
||||
signal, err := eventIDField(fields, "signal_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = verifyEventSignal(ctx, tx, h, signal, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
owner, err := eventIDField(fields, "workflow_id")
|
||||
if err != nil || owner != h.view.ID {
|
||||
return errors.New("workflow signal event scope mismatch")
|
||||
}
|
||||
default:
|
||||
return errors.New("unsupported workflow event type")
|
||||
}
|
||||
case "workflow_step":
|
||||
step, ok := h.steps[e.AggregateID]
|
||||
if !ok || e.AggregateRevision > step.Revision || e.Type != "workflow.step.changed" {
|
||||
return errors.New("workflow step event aggregate mismatch")
|
||||
}
|
||||
owner, err := eventIDField(fields, "workflow_id")
|
||||
if err != nil || owner != h.view.ID {
|
||||
return errors.New("workflow step event scope mismatch")
|
||||
}
|
||||
e.StepKey = step.Key
|
||||
e.StateScope = "STEP"
|
||||
if raw, ok := fields["key"]; ok {
|
||||
var key string
|
||||
if json.Unmarshal(raw, &key) != nil || key != step.Key {
|
||||
return errors.New("workflow event step key mismatch")
|
||||
}
|
||||
}
|
||||
if raw, ok := fields["activation_id"]; ok && string(raw) != "null" {
|
||||
activation, err := eventIDField(fields, "activation_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var count int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM weave_activations WHERE namespace_id=? AND workflow_id=? AND step_id=? AND id=?`, h.view.NamespaceID, h.view.ID, step.ID, activation).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return errors.New("workflow event historical activation mismatch")
|
||||
}
|
||||
}
|
||||
case "workflow_signal":
|
||||
e.StateScope = "SIGNAL"
|
||||
if e.Type != "workflow.signal.changed" {
|
||||
return errors.New("unsupported workflow signal event")
|
||||
}
|
||||
owner, err := eventIDField(fields, "workflow_id")
|
||||
if err != nil || owner != h.view.ID {
|
||||
return errors.New("workflow signal event scope mismatch")
|
||||
}
|
||||
signal, err := eventIDField(fields, "signal_id")
|
||||
if err != nil || signal != e.AggregateID {
|
||||
return errors.New("workflow event signal reference mismatch")
|
||||
}
|
||||
if err = verifyEventSignal(ctx, tx, h, signal, e.AggregateRevision); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return errors.New("unsupported workflow event aggregate")
|
||||
}
|
||||
valid := false
|
||||
switch e.StateScope {
|
||||
case "WORKFLOW":
|
||||
switch e.State {
|
||||
case "RUNNING", "WAITING", "CANCELLING", "NEEDS_REVIEW", "SUCCEEDED", "FAILED", "CANCELLED":
|
||||
valid = true
|
||||
}
|
||||
case "STEP":
|
||||
switch e.State {
|
||||
case "BLOCKED", "ACTIVE", "WAITING", "NEEDS_REVIEW", "SUCCEEDED", "FAILED", "CANCELLED", "SKIPPED":
|
||||
valid = true
|
||||
}
|
||||
case "SIGNAL":
|
||||
switch e.State {
|
||||
case "PENDING", "CONSUMED", "EXPIRED", "REJECTED":
|
||||
valid = true
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return errors.New("workflow event recorded state invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func verifyEventSignal(ctx context.Context, tx *sql.Tx, h workflowEventParent, id core.ID, revision uint64) error {
|
||||
var actual uint64
|
||||
err := tx.QueryRowContext(ctx, `SELECT revision FROM weave_signals WHERE namespace_id=? AND workflow_id=? AND id=?`, h.view.NamespaceID, h.view.ID, id).Scan(&actual)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if revision > actual {
|
||||
return errors.New("workflow signal event version mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *WorkflowInstanceStore) ListEvents(ctx context.Context, t auth.Token, id core.ID, in service.HistoryPageInput) (instance.EventPage, error) {
|
||||
out := instance.EventPage{WorkflowID: id, Items: []instance.EventSummary{}}
|
||||
err := s.transaction(ctx, t, instance.AuditAction, func(tx *sql.Tx, p auth.Permissions) error {
|
||||
h, err := readWorkflowEventParent(ctx, tx, t.NamespaceID, id, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Revision = h.view.Revision
|
||||
q := `SELECT ` + workflowEventColumns + workflowEventTables
|
||||
args := []any{t.NamespaceID, id}
|
||||
if in.Cursor != "" {
|
||||
c, err := decodeHistoryCursorKind(in.Cursor, t.NamespaceID, id, "", "workflow-events")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q += ` AND (e.occurred_at<? OR (e.occurred_at=? AND e.id<?))`
|
||||
args = append(args, *c.Created, *c.Created, c.ID)
|
||||
}
|
||||
q += ` ORDER BY e.occurred_at DESC,e.id DESC LIMIT ?`
|
||||
args = append(args, in.Limit+1)
|
||||
rows, err := tx.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var entries []instance.EventDetail
|
||||
for rows.Next() {
|
||||
e, err := scanWorkflowEvent(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range entries {
|
||||
if err = verifyWorkflowEvent(ctx, tx, h, &entries[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
more := len(entries) > in.Limit
|
||||
if more {
|
||||
entries = entries[:in.Limit]
|
||||
}
|
||||
for _, e := range entries {
|
||||
out.Items = append(out.Items, e.EventSummary)
|
||||
}
|
||||
if more {
|
||||
last := out.Items[len(out.Items)-1]
|
||||
out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "workflow-events", Namespace: t.NamespaceID, Task: id, ID: last.ID, Created: &last.OccurredAt})
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return instance.EventPage{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *WorkflowInstanceStore) GetEvent(ctx context.Context, t auth.Token, id, event core.ID) (instance.EventDetail, error) {
|
||||
var out instance.EventDetail
|
||||
err := s.transaction(ctx, t, instance.AuditAction, func(tx *sql.Tx, p auth.Permissions) error {
|
||||
h, err := readWorkflowEventParent(ctx, tx, t.NamespaceID, id, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 明确事件ID后走主键;仍保留流程派生范围和实际归属核验。
|
||||
q := `SELECT ` + workflowEventColumns + strings.Replace(workflowEventTables, "FORCE INDEX(ix_event_workflow_history)", "FORCE INDEX(PRIMARY)", 1) + ` AND e.id=?`
|
||||
out, err = scanWorkflowEvent(tx.QueryRowContext(ctx, q, t.NamespaceID, id, event))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return verifyWorkflowEvent(ctx, tx, h, &out)
|
||||
})
|
||||
if err != nil {
|
||||
return instance.EventDetail{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
319
internal/storage/mysql/workflow_timeline_integration_test.go
Normal file
319
internal/storage/mysql/workflow_timeline_integration_test.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/instance"
|
||||
)
|
||||
|
||||
func grantWorkflowEvents(f *executionFixture) {
|
||||
f.exec(`UPDATE orbit_credentials SET permissions=JSON_ARRAY_APPEND(permissions,'$.actions','workflows:audit:read') WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
|
||||
}
|
||||
func TestIntegrationWorkflowTimeline(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
|
||||
defer cancel()
|
||||
db, err := Open(ctx, dsn, 16, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err = Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
api := instance.New(NewWorkflowInstanceStore(db))
|
||||
t.Run("real_transitions_pagination_scope_and_no_payload_list", func(t *testing.T) {
|
||||
f := newExecutionFixture(t, ctx, db)
|
||||
v := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 2))
|
||||
if _, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{}); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("audit permission missing", err)
|
||||
}
|
||||
grantWorkflowEvents(f)
|
||||
ref := retryStart(t, f)
|
||||
retryFinish(t, f, ref, "SUCCEEDED", false)
|
||||
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
|
||||
first, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 1})
|
||||
if err != nil || len(first.Items) != 1 || first.NextCursor == "" {
|
||||
t.Fatal(first, err)
|
||||
}
|
||||
seen := map[core.ID]bool{}
|
||||
states := map[string]bool{}
|
||||
cursor := ""
|
||||
var last *instance.EventSummary
|
||||
for n := 0; n < 30; n++ {
|
||||
page, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 1, Cursor: cursor})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(page)
|
||||
if strings.Contains(string(raw), `"payload"`) || strings.Contains(string(raw), `9007199254740993`) {
|
||||
t.Fatal("body exposed in list")
|
||||
}
|
||||
for _, e := range page.Items {
|
||||
if seen[e.ID] || e.WorkflowID != v.ID || e.StepKey == workflowRootKey {
|
||||
t.Fatal("event identity", e)
|
||||
}
|
||||
seen[e.ID] = true
|
||||
states[e.Type+":"+e.State] = true
|
||||
if last != nil && (e.OccurredAt.After(last.OccurredAt) || e.OccurredAt.Equal(last.OccurredAt) && e.ID >= last.ID) {
|
||||
t.Fatal("unstable event order")
|
||||
}
|
||||
current := e
|
||||
last = ¤t
|
||||
detail, err := api.GetEvent(ctx, f.token, v.ID, e.ID)
|
||||
if err != nil || detail.EventSummary != e || !json.Valid(detail.Payload) {
|
||||
t.Fatal("event detail mismatch", detail, err)
|
||||
}
|
||||
}
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = page.NextCursor
|
||||
}
|
||||
if len(seen) < 5 || !states["workflow.started:RUNNING"] || !states["workflow.step.changed:SUCCEEDED"] || !states["workflow.step.changed:ACTIVE"] {
|
||||
t.Fatal("missing actual transitions", states)
|
||||
}
|
||||
if _, err := api.ListOperations(ctx, f.token, v.ID, service.HistoryPageInput{Cursor: first.NextCursor}); fault.Code(err) != "INVALID_ARGUMENT" {
|
||||
t.Fatal("cross kind cursor", err)
|
||||
}
|
||||
other := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 1))
|
||||
grantWorkflowEvents(f)
|
||||
if _, err := api.GetEvent(ctx, f.token, other.ID, first.Items[0].ID); fault.Code(err) != "NOT_FOUND" {
|
||||
t.Fatal("cross workflow event", err)
|
||||
}
|
||||
if _, err := api.ListEvents(ctx, f.token, other.ID, service.HistoryPageInput{Cursor: first.NextCursor}); fault.Code(err) != "INVALID_ARGUMENT" {
|
||||
t.Fatal("cross workflow cursor", err)
|
||||
}
|
||||
grantWorkflowInstance(t, f, string(executionID(t)))
|
||||
grantWorkflowEvents(f)
|
||||
if _, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{}); fault.Code(err) != "NOT_FOUND" {
|
||||
t.Fatal("queue bypass", err)
|
||||
}
|
||||
})
|
||||
t.Run("signal_consumption_and_rejection_atomic_events", func(t *testing.T) {
|
||||
f, v, _ := signalStart(t, ctx, db, false)
|
||||
grantWorkflowEvents(f)
|
||||
first := sendSignal(t, f, v, signalInput(v))
|
||||
second := sendSignal(t, f, v, signalInput(v))
|
||||
c := clockCandidate(t, f, v.ID, "signal")
|
||||
f.exec(`CREATE TRIGGER timeline_signal_failure BEFORE INSERT ON orbit_events FOR EACH ROW SET NEW.type=IF(NEW.aggregate_kind='workflow_signal',NULL,NEW.type)`)
|
||||
_, err := newRuntime(t, db).process(ctx, c, false)
|
||||
f.exec(`DROP TRIGGER timeline_signal_failure`)
|
||||
if err == nil || f.count("weave_signals", "state='PENDING'") != 2 || f.count("weave_waits", "winner_signal_id IS NOT NULL") != 0 || f.count("pulse_tasks", "1=1") != 0 || f.count("orbit_events", "aggregate_kind='workflow_signal'") != 0 {
|
||||
t.Fatal("partial signal transition committed", err)
|
||||
}
|
||||
if !runClock(t, f, c) {
|
||||
t.Fatal("retry failed")
|
||||
}
|
||||
page, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed := map[core.ID]string{}
|
||||
for _, e := range page.Items {
|
||||
if e.AggregateKind == "workflow_signal" {
|
||||
changed[e.AggregateID] = e.State
|
||||
if e.StateScope != "SIGNAL" {
|
||||
t.Fatal("wrong state subject")
|
||||
}
|
||||
detail, err := api.GetEvent(ctx, f.token, v.ID, e.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var meta signalTransition
|
||||
if json.Unmarshal(detail.Payload, &meta) != nil || meta.From != "PENDING" {
|
||||
t.Fatal("lost previous state")
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed[first.Signal.ID] != "CONSUMED" || changed[second.Signal.ID] != "REJECTED" || len(changed) != 2 {
|
||||
t.Fatal("signal transitions", changed)
|
||||
}
|
||||
runClock(t, f, c)
|
||||
if f.count("orbit_events", "aggregate_kind='workflow_signal'") != 2 {
|
||||
t.Fatal("duplicate transition")
|
||||
}
|
||||
})
|
||||
t.Run("bulk_stop_preserves_all_signal_events_and_rollback", func(t *testing.T) {
|
||||
f := newExecutionFixture(t, ctx, db)
|
||||
spec := signalSpec(f.resources.QueueID, true)
|
||||
spec.Body.Steps[0] = definition.Step{Key: "before", Kind: "TIMER", Timer: &definition.Timer{DelayMS: 60000}}
|
||||
v := runtimeStart(t, f, spec)
|
||||
grantWorkflowSignal(f)
|
||||
grantWorkflowControl(f)
|
||||
grantWorkflowEvents(f)
|
||||
for n := 0; n < 70; n++ {
|
||||
in := signalInput(v)
|
||||
in.CorrelationKey += "unmatched"
|
||||
if sent := sendSignal(t, f, v, in); sent.Signal.State != "PENDING" {
|
||||
t.Fatal("fixture must retain early pending signals", sent.Signal.State)
|
||||
}
|
||||
}
|
||||
stopped := applyControl(t, f, runtimeView(t, f, v.ID, "WAITING"), "CANCEL").Instance
|
||||
var target core.ID
|
||||
if err := db.QueryRowContext(ctx, `SELECT id FROM weave_signals WHERE namespace_id=? ORDER BY id LIMIT 1 OFFSET 40`, f.resources.NamespaceID).Scan(&target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.exec(`CREATE TRIGGER timeline_batch_failure BEFORE INSERT ON orbit_events FOR EACH ROW SET NEW.type=IF(NEW.aggregate_kind='workflow_signal' AND NEW.aggregate_id=UNHEX('` + string(target) + `'),NULL,NEW.type)`)
|
||||
c := clockCandidate(t, f, v.ID, workflowRootKey)
|
||||
var transitionErr error
|
||||
// 停止会先结清等待,根接续可能退避;推进到实际批量转换事务再注入失败。
|
||||
for n := 0; n < 10; n++ {
|
||||
f.exec(`UPDATE weave_pending_advances SET available_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, c.ns, c.id)
|
||||
_, transitionErr = newRuntime(t, db).process(ctx, c, false)
|
||||
if transitionErr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
f.exec(`DROP TRIGGER timeline_batch_failure`)
|
||||
if transitionErr == nil || f.count("weave_signals", "state='PENDING'") != 70 || f.count("orbit_events", "aggregate_kind='workflow_signal'") != 0 {
|
||||
t.Fatal("batch prefix survived rollback", transitionErr, f.count("weave_signals", "state='PENDING'"), f.count("orbit_events", "aggregate_kind='workflow_signal'"))
|
||||
}
|
||||
drainStop(t, f, stopped.ID, "CANCELLED")
|
||||
if f.count("weave_signals", "state='REJECTED'") != 70 || f.count("orbit_events", "aggregate_kind='workflow_signal' AND type='workflow.signal.changed'") != 70 {
|
||||
t.Fatal("incomplete rejection history")
|
||||
}
|
||||
page, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 100})
|
||||
if err != nil || len(page.Items) != 100 || page.NextCursor == "" {
|
||||
t.Fatal("large timeline", len(page.Items), err)
|
||||
}
|
||||
for _, e := range page.Items {
|
||||
if e.StepKey == workflowRootKey {
|
||||
t.Fatal("internal root exposed")
|
||||
}
|
||||
}
|
||||
// 每批32条事件共享SQL语句时间;小页跨过相同时间,必须按ID无漏无重。
|
||||
seen := map[core.ID]bool{}
|
||||
times := map[time.Time]int{}
|
||||
cursor := ""
|
||||
for n := 0; n < 100; n++ {
|
||||
page, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 7, Cursor: cursor})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range page.Items {
|
||||
if seen[e.ID] {
|
||||
t.Fatal("duplicate tied event", e.ID)
|
||||
}
|
||||
seen[e.ID] = true
|
||||
times[e.OccurredAt]++
|
||||
}
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = page.NextCursor
|
||||
}
|
||||
var expected int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*)`+workflowEventTables, f.resources.NamespaceID, v.ID).Scan(&expected); err != nil || len(seen) != expected {
|
||||
t.Fatal("tied pagination skipped events", len(seen), expected, err)
|
||||
}
|
||||
hasTies := false
|
||||
for _, count := range times {
|
||||
hasTies = hasTies || count > 7
|
||||
}
|
||||
if !hasTies {
|
||||
t.Fatal("fixture did not cross timestamp ties")
|
||||
}
|
||||
})
|
||||
t.Run("expiry_records_original_transition_once", func(t *testing.T) {
|
||||
f, v, _ := signalStart(t, ctx, db, true)
|
||||
grantWorkflowEvents(f)
|
||||
in := signalInput(v)
|
||||
in.CorrelationKey += "unmatched"
|
||||
sig := sendSignal(t, f, v, in)
|
||||
f.exec(`UPDATE weave_signals SET expires_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 SECOND),available_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 SECOND) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, sig.Signal.ID)
|
||||
c := workflowCandidate{kind: "SIGNAL", ns: f.resources.NamespaceID, workflow: v.ID, id: sig.Signal.ID, revision: 1}
|
||||
if _, err := newRuntime(t, db).process(ctx, c, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count := 0
|
||||
for _, e := range page.Items {
|
||||
if e.AggregateKind == "workflow_signal" && e.AggregateID == sig.Signal.ID && e.State == "EXPIRED" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatal("expiry event missing", count)
|
||||
}
|
||||
})
|
||||
t.Run("derived_scope_is_not_authority_and_payload_is_bounded", func(t *testing.T) {
|
||||
f := newExecutionFixture(t, ctx, db)
|
||||
v := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 1))
|
||||
other := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 1))
|
||||
grantWorkflowEvents(f)
|
||||
var step core.ID
|
||||
if err := db.QueryRowContext(ctx, "SELECT id FROM weave_steps WHERE namespace_id=? AND workflow_id=? AND `key`='task0'", f.resources.NamespaceID, other.ID).Scan(&step); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forged := executionID(t)
|
||||
f.exec(`INSERT INTO orbit_events(namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at) VALUES (?,?,'workflow_step',?,1,'workflow.step.changed',JSON_OBJECT('workflow_id',?,'state','ACTIVE'),UTC_TIMESTAMP(6))`, f.resources.NamespaceID, forged, step, string(v.ID))
|
||||
if page, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{}); err == nil || len(page.Items) != 0 {
|
||||
t.Fatal("forged scope returned partial data")
|
||||
}
|
||||
if _, err := api.GetEvent(ctx, f.token, v.ID, forged); err == nil {
|
||||
t.Fatal("forged parent accepted")
|
||||
}
|
||||
huge := executionID(t)
|
||||
f.exec(`INSERT INTO orbit_events(namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at) VALUES (?,?,'workflow',?,1,'workflow.blocked',JSON_OBJECT('state','RUNNING','padding',?),UTC_TIMESTAMP(6))`, f.resources.NamespaceID, huge, other.ID, strings.Repeat("x", 65536))
|
||||
if _, err := api.GetEvent(ctx, f.token, other.ID, huge); err == nil {
|
||||
t.Fatal("oversized detail accepted")
|
||||
}
|
||||
})
|
||||
t.Run("historical_activation_survives_recovery_and_checks_parent", func(t *testing.T) {
|
||||
f, v := waitRecoveryFixture(t, ctx, db, "TIMER", 30000)
|
||||
grantWorkflowEvents(f)
|
||||
old, err := api.Step(ctx, f.token, v.ID, "timer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved instance.EventDetail
|
||||
for _, event := range page.Items {
|
||||
if event.AggregateKind != "workflow_step" || event.AggregateID != old.Step.ID {
|
||||
continue
|
||||
}
|
||||
detail, err := api.GetEvent(ctx, f.token, v.ID, event.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var meta struct {
|
||||
ActivationID core.ID `json:"activation_id"`
|
||||
}
|
||||
if json.Unmarshal(detail.Payload, &meta) == nil && meta.ActivationID == *old.Step.ActivationID {
|
||||
saved = detail
|
||||
break
|
||||
}
|
||||
}
|
||||
if saved.ID == "" {
|
||||
t.Fatal("historical activation event missing")
|
||||
}
|
||||
rearmWorkflowWait(t, f, v.ID, "timer")
|
||||
after, err := api.GetEvent(ctx, f.token, v.ID, saved.ID)
|
||||
if err != nil || after.EventSummary != saved.EventSummary || string(after.Payload) != string(saved.Payload) {
|
||||
t.Fatal("old event replaced by recovery", err)
|
||||
}
|
||||
f.exec(`UPDATE weave_activations SET workflow_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, *old.Step.ActivationID)
|
||||
if _, err := api.GetEvent(ctx, f.token, v.ID, saved.ID); fault.Code(err) != "UNAVAILABLE" {
|
||||
t.Fatal("foreign historical activation accepted", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// 用真实v24事件验证派生范围回填,历史内容不变,不凭空补造信号转换。
|
||||
func TestIntegrationWorkflowTimelineUpgrade(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
admin, err := Open(ctx, dsn, 2, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer admin.Close()
|
||||
name := "orbit_timeline_upgrade_" + string(executionID(t))
|
||||
if _, err := admin.ExecContext(ctx, "CREATE DATABASE `"+name+"`"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
cleanup, stop := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer stop()
|
||||
if _, err := admin.ExecContext(cleanup, "DROP DATABASE `"+name+"`"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
cfg, err := driver.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatal("invalid test DSN")
|
||||
}
|
||||
cfg.DBName = name
|
||||
db, err := Open(ctx, cfg.FormatDSN(), 4, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.ExecContext(ctx, q, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
exec(`CREATE TABLE 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`)
|
||||
migrations, err := Migrations()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range migrations {
|
||||
if m.Version > 24 {
|
||||
break
|
||||
}
|
||||
statements, err := splitSQL(m.SQL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, q := range statements {
|
||||
exec(q)
|
||||
}
|
||||
exec(`INSERT INTO orbit_schema_migrations(version,name,checksum,dirty,applied_at) VALUES (?,?,?,FALSE,UTC_TIMESTAMP(6))`, m.Version, m.Name, m.Checksum)
|
||||
}
|
||||
|
||||
ns, owner := executionID(t), executionID(t)
|
||||
type sample struct {
|
||||
kind, payload string
|
||||
aggregate core.ID
|
||||
want *core.ID
|
||||
}
|
||||
cases := []sample{
|
||||
{"workflow", `{"state":"WAITING"}`, owner, &owner},
|
||||
{"workflow_step", `{"workflow_id":"` + string(owner) + `","state":"ACTIVE","n":9007199254740993}`, executionID(t), &owner},
|
||||
{"workflow_signal", `{"workflow_id":"` + string(owner) + `","state":"CONSUMED"}`, executionID(t), &owner},
|
||||
{"task", `{"workflow_id":"` + string(owner) + `"}`, executionID(t), nil},
|
||||
{"workflow_step", `{"workflow_id":"gggggggggggggggggggggggggggggggg"}`, executionID(t), nil},
|
||||
{"workflow_step", `{"workflow_id":"abc"}`, executionID(t), nil},
|
||||
{"workflow_step", `{"workflow_id":null}`, executionID(t), nil},
|
||||
{"workflow_step", `{"workflow_id":12345678901234567890123456789012}`, executionID(t), nil},
|
||||
{"workflow_step", `{"state":"ACTIVE"}`, executionID(t), nil},
|
||||
}
|
||||
ids := make([]core.ID, len(cases))
|
||||
for i, v := range cases {
|
||||
ids[i] = executionID(t)
|
||||
exec(`INSERT INTO orbit_events(namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at) VALUES (?,?,?,?,7,'legacy.event',?,'2026-01-02 03:04:05.123456')`, ns, ids[i], v.kind, v.aggregate, v.payload)
|
||||
}
|
||||
snapshot := func() [][]sql.NullString {
|
||||
t.Helper()
|
||||
rows, err := db.QueryContext(ctx, `SELECT namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at FROM orbit_events ORDER BY namespace_id,id`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out [][]sql.NullString
|
||||
for rows.Next() {
|
||||
row := make([]sql.NullString, 8)
|
||||
args := make([]any, 8)
|
||||
for i := range row {
|
||||
args[i] = &row[i]
|
||||
}
|
||||
if err := rows.Scan(args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
before := snapshot()
|
||||
if err := Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckSchema(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot()) {
|
||||
t.Fatal("timeline migration changed or invented historical events")
|
||||
}
|
||||
for i, v := range cases {
|
||||
var got *core.ID
|
||||
if err := db.QueryRowContext(ctx, `SELECT workflow_id FROM orbit_events WHERE namespace_id=? AND id=?`, ns, ids[i]).Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(v.want, got) {
|
||||
t.Fatalf("case %d derived owner mismatch: want %v got %v", i, v.want, got)
|
||||
}
|
||||
}
|
||||
var columns string
|
||||
if err := db.QueryRowContext(ctx, `SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orbit_events' AND INDEX_NAME='ix_event_workflow_history'`).Scan(&columns); err != nil || columns != "namespace_id,workflow_id,occurred_at,id" {
|
||||
t.Fatal("timeline index mismatch", columns, err)
|
||||
}
|
||||
var foreignKeys int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&foreignKeys); err != nil || foreignKeys != 0 {
|
||||
t.Fatal("foreign keys forbidden", foreignKeys, err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO orbit_events(namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at,workflow_id) VALUES (?,?,'workflow',?,1,'forbidden.override','{}',UTC_TIMESTAMP(6),?)`, ns, executionID(t), owner, owner); err == nil {
|
||||
t.Fatal("generated scope accepted explicit override")
|
||||
}
|
||||
if err := Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot()) {
|
||||
t.Fatal("repeated migration changed history")
|
||||
}
|
||||
t.Log("v24 -> v25: original fields and precision preserved, invalid scopes remain NULL, index ordered, generated scope protected, foreign keys=0")
|
||||
}
|
||||
27
internal/transport/httpserver/workflow_events.go
Normal file
27
internal/transport/httpserver/workflow_events.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/instance"
|
||||
)
|
||||
|
||||
func registerWorkflowEvents(mux *http.ServeMux, s *instance.Service) {
|
||||
mux.HandleFunc("GET /v1/workflows/{id}/events", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
in, err := historyQuery(r, true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.ListEvents(r.Context(), t, core.ID(r.PathValue("id")), in)
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/workflows/{id}/events/{event}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if _, err := historyQuery(r, false); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.GetEvent(r.Context(), t, core.ID(r.PathValue("id")), core.ID(r.PathValue("event")))
|
||||
return out, 200, err
|
||||
}))
|
||||
}
|
||||
@@ -17,6 +17,7 @@ func RegisterWorkflows(server *http.Server, instances *instance.Service, control
|
||||
mux := server.Handler.(*http.ServeMux)
|
||||
registerWorkflowHistory(mux, instances, signals)
|
||||
registerWorkflowQuery(mux, instances)
|
||||
registerWorkflowEvents(mux, instances)
|
||||
mux.HandleFunc("POST /v1/workflows", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if _, err := historyQuery(r, false); err != nil {
|
||||
return nil, 0, err
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
type Event struct {
|
||||
// Record 包含命名空间、记录标识、并发版本和 UTC 时间。
|
||||
core.Record
|
||||
// WorkflowID 是数据库从不可变事件派生的流程查询索引;不可写,非流程或无有效范围时为空,不能代替归属授权。
|
||||
WorkflowID *core.ID `db:"workflow_id"`
|
||||
// AggregateKind 产生事件的聚合类型,例如任务或流程。
|
||||
AggregateKind string `db:"aggregate_kind"`
|
||||
// AggregateID 产生事件的聚合标识。
|
||||
|
||||
@@ -84,7 +84,7 @@ ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap -
|
||||
|
||||
# 流程发布、执行、控制、信号、核查和审计权限逐项显式签发。
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application sdk-workflow --namespace default --environment test --queue workflow --workflow-access --worker-access --workflow-run-access --workflow-control-access --workflow-signal-access --workflow-recovery-access --workflow-audit-access --output "$TASK_TEST_DIR/workflow.credentials.json"
|
||||
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/workflow.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationWorkflow(Definition|Instances|Query)SDK$' -v .)
|
||||
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/workflow.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationWorkflow(Definition|Instances|Query|Events)SDK$' -v .)
|
||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/workflow.credentials.json" "$TASK_GO" run ./examples/workflow-definition)
|
||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/workflow.credentials.json" "$TASK_GO" run ./examples/workflow)
|
||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/workflow.credentials.json" "$TASK_GO" run ./examples/workflow-query)
|
||||
|
||||
@@ -189,6 +189,39 @@ func run() error {
|
||||
return errors.New("signal history missing")
|
||||
}
|
||||
fmt.Println("workflow_history_verified=true signal_state=CONSUMED")
|
||||
// 时间线用于审计展示,游标只是倒序翻页位置,不作为可靠消息消费水位。
|
||||
cursor, eventCount, consumed := "", 0, false
|
||||
for {
|
||||
page, err := client.ListWorkflowEvents(ctx, accepted.Instance.ID, orbit.HistoryPageRequest{Limit: 5, Cursor: cursor})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, event := range page.Items {
|
||||
eventCount++
|
||||
if event.AggregateKind == "workflow_signal" && event.AggregateID == sent.Signal.ID && event.State == "CONSUMED" {
|
||||
detail, err := client.GetWorkflowEvent(ctx, accepted.Instance.ID, event.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var transition struct {
|
||||
From string `json:"from"`
|
||||
ConsumedWaitID string `json:"consumed_wait_id"`
|
||||
}
|
||||
if json.Unmarshal(detail.Payload, &transition) != nil || transition.From != "PENDING" || transition.ConsumedWaitID != historical.Wait.ID {
|
||||
return errors.New("signal transition evidence mismatch")
|
||||
}
|
||||
consumed = true
|
||||
}
|
||||
}
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = page.NextCursor
|
||||
}
|
||||
if !consumed {
|
||||
return errors.New("consumed signal event missing")
|
||||
}
|
||||
fmt.Printf("workflow_timeline_verified=true events=%d signal_transition=PENDING->CONSUMED\n", eventCount)
|
||||
|
||||
stop()
|
||||
err = <-done
|
||||
|
||||
88
sdk/go/workflow_events.go
Normal file
88
sdk/go/workflow_events.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkflowEventSummary 只表示已保存的事件,不把当前状态或当前激活反填到历史。
|
||||
type WorkflowEventSummary struct {
|
||||
ID string `json:"id"` // ID 原不可变状态事件标识。
|
||||
WorkflowID string `json:"workflow_id"` // WorkflowID 已核验授权的流程归属。
|
||||
AggregateKind string `json:"aggregate_kind"` // AggregateKind workflow、workflow_step或workflow_signal。
|
||||
AggregateID string `json:"aggregate_id"` // AggregateID 产生事件的实例、步骤或信号身份。
|
||||
AggregateRevision uint64 `json:"aggregate_revision,string"` // AggregateRevision 来源资源版本,各资源独立,不是全局序号。
|
||||
Type string `json:"type"` // Type 原始事件类型,不按当前状态改写。
|
||||
StateScope string `json:"state_scope"` // StateScope WORKFLOW、STEP或SIGNAL,说明State记录的是哪类对象。
|
||||
State string `json:"state"` // State 该事件保存的状态,不代表对象当前状态。
|
||||
StepKey string `json:"step_key,omitempty"` // StepKey 步骤事件的固定定义名称;其他事件省略。
|
||||
OccurredAt time.Time `json:"occurred_at"` // OccurredAt 数据库事件记录时间,UTC微秒精度,不是外部业务发生时间或提交水位。
|
||||
}
|
||||
type WorkflowEventPage struct {
|
||||
WorkflowID string `json:"workflow_id"` // WorkflowID 本页授权的实例身份。
|
||||
Revision uint64 `json:"revision,string"` // Revision 本页读取时实例版本,不能作为信号或步骤事件的消费水位。
|
||||
Items []WorkflowEventSummary `json:"items"` // Items 按记录时间和ID倒序的有界摘要,空列表为[]。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例与事件查询种类,不承诺跨页快照。
|
||||
}
|
||||
type WorkflowEventDetail struct {
|
||||
WorkflowEventSummary
|
||||
Payload json.RawMessage `json:"payload"` // Payload 原不可变事件元数据,最多64KiB;嵌套数字保留原文本,输入/结果另走资源详情。
|
||||
}
|
||||
|
||||
// ListWorkflowEvents 读取实例、步骤和信号的不可变状态事件,需要workflows:audit:read。
|
||||
func (c *Client) ListWorkflowEvents(ctx context.Context, id string, in HistoryPageRequest) (WorkflowEventPage, error) {
|
||||
var out WorkflowEventPage
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid workflow id")
|
||||
}
|
||||
query, err := historyPageQuery(in)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = c.call(ctx, "GET", "/v1/workflows/"+id+"/events"+query, "", nil, &out); err != nil {
|
||||
return WorkflowEventPage{}, err
|
||||
}
|
||||
if out.WorkflowID != id || out.Revision == 0 || out.Items == nil || !historyResponseSize(len(out.Items), in) {
|
||||
return WorkflowEventPage{}, errors.New("invalid workflow event page")
|
||||
}
|
||||
for i, e := range out.Items {
|
||||
if !validWorkflowEvent(e, id) || i > 0 && (e.OccurredAt.After(out.Items[i-1].OccurredAt) || e.OccurredAt.Equal(out.Items[i-1].OccurredAt) && e.ID >= out.Items[i-1].ID) {
|
||||
return WorkflowEventPage{}, errors.New("invalid workflow event summary or ordering")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func validWorkflowEvent(e WorkflowEventSummary, id string) bool {
|
||||
if e.WorkflowID != id || !validID(e.ID) || !validID(e.AggregateID) || e.AggregateRevision == 0 || e.Type == "" || e.State == "" || e.OccurredAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
switch e.AggregateKind {
|
||||
case "workflow":
|
||||
if e.AggregateID != id {
|
||||
return false
|
||||
}
|
||||
case "workflow_step":
|
||||
if !workflowVersionName(e.StepKey) {
|
||||
return false
|
||||
}
|
||||
case "workflow_signal":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return e.StateScope == "WORKFLOW" || e.StateScope == "STEP" || e.StateScope == "SIGNAL"
|
||||
}
|
||||
func (c *Client) GetWorkflowEvent(ctx context.Context, id, event string) (WorkflowEventDetail, error) {
|
||||
var out WorkflowEventDetail
|
||||
if !validID(id) || !validID(event) {
|
||||
return out, errors.New("invalid workflow event identity")
|
||||
}
|
||||
if err := c.call(ctx, "GET", "/v1/workflows/"+id+"/events/"+event, "", nil, &out); err != nil {
|
||||
return WorkflowEventDetail{}, err
|
||||
}
|
||||
if out.ID != event || !validWorkflowEvent(out.WorkflowEventSummary, id) || len(out.Payload) == 0 || len(out.Payload) > 65536 || !json.Valid(out.Payload) {
|
||||
return WorkflowEventDetail{}, errors.New("invalid workflow event detail")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
80
sdk/go/workflow_events_integration_test.go
Normal file
80
sdk/go/workflow_events_integration_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIntegrationWorkflowEventsSDK(t *testing.T) {
|
||||
api, queue, _ := integrationWorkerClient(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
operation := workerTestID(t)
|
||||
spec := workflowSDKSpec(queue, "events."+operation)
|
||||
spec.Body = WorkflowBlock{Steps: []WorkflowStep{
|
||||
{Key: "receipt", Kind: "SIGNAL", Signal: &WorkflowSignal{Name: "done", PayloadVersion: "v1", Correlation: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`"` + operation + `"`)}, PayloadType: spec.InputType, TimeoutMS: 60000}},
|
||||
{Key: "delay", Kind: "TIMER", Timer: &WorkflowTimer{DelayMS: 60000}},
|
||||
}, OutputType: WorkflowType{Kind: "NULL"}, Output: WorkflowRef("delay")}
|
||||
v := startWorkflowSDK(t, ctx, api, spec)
|
||||
sent, err := api.SendWorkflowSignal(ctx, v.ID, operation, SendWorkflowSignalRequest{Name: "done", PayloadVersion: "v1", TargetStepKey: "receipt", TargetActivationSequence: 1, CorrelationKey: operation, Payload: workflowExact, Reason: "verify timeline"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitWorkflowStepSDK(t, ctx, api, v.ID, "receipt", func(d WorkflowStepDetail) bool { return d.Step.State == "SUCCEEDED" })
|
||||
current, err := api.GetWorkflow(ctx, v.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = api.ControlWorkflow(ctx, v.ID, operation, WorkflowControlRequest{Operation: "CANCEL", ExpectedControlRevision: current.ControlRevision, Reason: "timeline cleanup"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitWorkflowSDK(t, ctx, api, v.ID, "CANCELLED")
|
||||
seen := map[string]bool{}
|
||||
types := map[string]bool{}
|
||||
cursor := ""
|
||||
pages := 0
|
||||
for {
|
||||
page, err := api.ListWorkflowEvents(ctx, v.ID, HistoryPageRequest{Limit: 2, Cursor: cursor})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pages++
|
||||
raw, _ := json.Marshal(page)
|
||||
if strings.Contains(string(raw), `"payload"`) || strings.Contains(string(raw), `9007199254740993`) {
|
||||
t.Fatal("payload in summaries")
|
||||
}
|
||||
for _, e := range page.Items {
|
||||
if seen[e.ID] || e.WorkflowID != v.ID || e.StepKey == "$root" {
|
||||
t.Fatal("event identity", e)
|
||||
}
|
||||
seen[e.ID] = true
|
||||
types[e.Type] = true
|
||||
detail, err := api.GetWorkflowEvent(ctx, v.ID, e.ID)
|
||||
if err != nil || detail.WorkflowEventSummary != e {
|
||||
t.Fatal("detail", detail, err)
|
||||
}
|
||||
if e.AggregateKind == "workflow_signal" && (e.AggregateID != sent.Signal.ID || e.State != "CONSUMED" || e.StateScope != "SIGNAL") {
|
||||
t.Fatal("signal transition", e)
|
||||
}
|
||||
}
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = page.NextCursor
|
||||
if pages > 20 {
|
||||
t.Fatal("unbounded page repetition")
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{"workflow.started", "workflow.step.changed", "workflow.signal.accepted", "workflow.signal.changed", "workflow.controlled", "workflow.stopped"} {
|
||||
if !types[kind] {
|
||||
t.Fatal("event missing", kind, types)
|
||||
}
|
||||
}
|
||||
if len(seen) < 7 || pages < 4 {
|
||||
t.Fatal("incomplete timeline", len(seen), pages)
|
||||
}
|
||||
t.Logf("real HTTP event timeline: %d immutable events over %d pages; signal transition and cancelled workflow retained", len(seen), pages)
|
||||
}
|
||||
78
sdk/go/workflow_events_test.go
Normal file
78
sdk/go/workflow_events_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWorkflowEventsSDKIdentityPrecisionAndBounds(t *testing.T) {
|
||||
const id = "11111111111111111111111111111111"
|
||||
const event = "22222222222222222222222222222222"
|
||||
var calls atomic.Int32
|
||||
var corrupt atomic.Bool
|
||||
summary := WorkflowEventSummary{ID: event, WorkflowID: id, AggregateKind: "workflow", AggregateID: id, AggregateRevision: 9007199254740993, Type: "workflow.advanced", StateScope: "WORKFLOW", State: "WAITING", OccurredAt: time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC)}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.Method != "GET" || r.Header.Get("Authorization") != "Bearer token" || r.Header.Get("Idempotency-Key") != "" {
|
||||
t.Error("read request invalid")
|
||||
}
|
||||
item := summary
|
||||
if corrupt.Load() {
|
||||
item.WorkflowID = event
|
||||
}
|
||||
var data any
|
||||
if strings.HasSuffix(r.URL.Path, "/events") {
|
||||
if r.URL.Query().Get("limit") != "1" || r.URL.Query().Get("cursor") != "cursor+/=" {
|
||||
t.Error("cursor encoding changed")
|
||||
}
|
||||
data = WorkflowEventPage{WorkflowID: id, Revision: 9007199254740993, Items: []WorkflowEventSummary{item}}
|
||||
} else {
|
||||
data = WorkflowEventDetail{WorkflowEventSummary: item, Payload: json.RawMessage(`{"n":9007199254740993,"state":"WAITING"}`)}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"request_id": event, "data": data})
|
||||
}))
|
||||
defer server.Close()
|
||||
c, err := NewClient(server.URL, Options{Token: "token"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := HistoryPageRequest{Limit: 1, Cursor: "cursor+/="}
|
||||
page, err := c.ListWorkflowEvents(context.Background(), id, input)
|
||||
if err != nil || page.Items[0].AggregateRevision != 9007199254740993 {
|
||||
t.Fatal(page, err)
|
||||
}
|
||||
detail, err := c.GetWorkflowEvent(context.Background(), id, event)
|
||||
if err != nil || !strings.Contains(string(detail.Payload), "9007199254740993") {
|
||||
t.Fatal(detail, err)
|
||||
}
|
||||
before := calls.Load()
|
||||
for _, bad := range []string{"", "../wrong", strings.Repeat("A", 32)} {
|
||||
if _, err := c.ListWorkflowEvents(context.Background(), bad, input); err == nil {
|
||||
t.Fatal("invalid parent")
|
||||
}
|
||||
if _, err := c.GetWorkflowEvent(context.Background(), id, bad); err == nil {
|
||||
t.Fatal("invalid event")
|
||||
}
|
||||
}
|
||||
for _, bad := range []HistoryPageRequest{{Limit: -1}, {Limit: 101}, {Cursor: strings.Repeat("x", 1025)}} {
|
||||
if _, err := c.ListWorkflowEvents(context.Background(), id, bad); err == nil {
|
||||
t.Fatal("invalid page")
|
||||
}
|
||||
}
|
||||
if calls.Load() != before {
|
||||
t.Fatal("invalid request reached HTTP")
|
||||
}
|
||||
corrupt.Store(true)
|
||||
if _, err := c.ListWorkflowEvents(context.Background(), id, input); err == nil {
|
||||
t.Fatal("foreign item accepted")
|
||||
}
|
||||
if _, err := c.GetWorkflowEvent(context.Background(), id, event); err == nil {
|
||||
t.Fatal("foreign detail accepted")
|
||||
}
|
||||
}
|
||||
54
weave/instance/events.go
Normal file
54
weave/instance/events.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
// EventSummary 只表示已保存的事件,不把当前状态或当前激活反填到历史。
|
||||
type EventSummary struct {
|
||||
ID core.ID `json:"id"` // ID 原不可变状态事件标识。
|
||||
WorkflowID core.ID `json:"workflow_id"` // WorkflowID 已核验授权的流程归属。
|
||||
AggregateKind string `json:"aggregate_kind"` // AggregateKind workflow、workflow_step或workflow_signal。
|
||||
AggregateID core.ID `json:"aggregate_id"` // AggregateID 产生事件的实例、步骤或信号身份。
|
||||
AggregateRevision uint64 `json:"aggregate_revision,string"` // AggregateRevision 来源资源版本,各资源独立,不是全局序号。
|
||||
Type string `json:"type"` // Type 原始事件类型,不按当前状态改写。
|
||||
StateScope string `json:"state_scope"` // StateScope WORKFLOW、STEP或SIGNAL,说明State记录的是哪类对象。
|
||||
State string `json:"state"` // State 该事件保存的状态,不代表对象当前状态。
|
||||
StepKey string `json:"step_key,omitempty"` // StepKey 步骤事件的固定定义名称;其他事件省略。
|
||||
OccurredAt time.Time `json:"occurred_at"` // OccurredAt 数据库事件记录时间,UTC微秒精度,不是外部业务发生时间或提交水位。
|
||||
}
|
||||
type EventPage struct {
|
||||
WorkflowID core.ID `json:"workflow_id"` // WorkflowID 本页授权的实例身份。
|
||||
Revision uint64 `json:"revision,string"` // Revision 本页读取时实例版本,不能作为信号或步骤事件的消费水位。
|
||||
Items []EventSummary `json:"items"` // Items 按记录时间和ID倒序的有界摘要,空列表为[]。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例与事件查询种类,不承诺跨页快照。
|
||||
}
|
||||
type EventDetail struct {
|
||||
EventSummary
|
||||
Payload json.RawMessage `json:"payload"` // Payload 原不可变事件元数据,最多64KiB;嵌套数字保留原文本,输入/结果另走资源详情。
|
||||
}
|
||||
|
||||
// ListEvents 要求workflows:audit:read,并核对固定版本全部队列;不能按全局事件ID跨资源读取。
|
||||
func (s *Service) ListEvents(ctx context.Context, t auth.Token, id core.ID, in service.HistoryPageInput) (EventPage, error) {
|
||||
if id.Validate() != nil {
|
||||
return EventPage{}, fault.Invalid("invalid workflow id")
|
||||
}
|
||||
in, err := historyPage(in)
|
||||
if err != nil {
|
||||
return EventPage{}, err
|
||||
}
|
||||
return s.store.ListEvents(ctx, t, id, in)
|
||||
}
|
||||
func (s *Service) GetEvent(ctx context.Context, t auth.Token, id, event core.ID) (EventDetail, error) {
|
||||
if id.Validate() != nil || event.Validate() != nil {
|
||||
return EventDetail{}, fault.Invalid("invalid workflow event identity")
|
||||
}
|
||||
return s.store.GetEvent(ctx, t, id, event)
|
||||
}
|
||||
@@ -72,6 +72,8 @@ type StepsView struct {
|
||||
Steps []StepView `json:"steps"` // Steps 按键排序,最多128个,不含内部根检查点。
|
||||
}
|
||||
type Store interface {
|
||||
ListEvents(context.Context, auth.Token, core.ID, service.HistoryPageInput) (EventPage, error)
|
||||
GetEvent(context.Context, auth.Token, core.ID, core.ID) (EventDetail, error)
|
||||
List(context.Context, auth.Token, ListInput) (Page, error)
|
||||
ListActivations(context.Context, auth.Token, core.ID, string, service.HistoryPageInput) (ActivationPage, error)
|
||||
GetActivation(context.Context, auth.Token, core.ID, string, core.ID) (ActivationDetail, error)
|
||||
|
||||
@@ -89,3 +89,12 @@ func (s *captureStore) List(context.Context, auth.Token, ListInput) (Page, error
|
||||
s.calls++
|
||||
return Page{}, nil
|
||||
}
|
||||
|
||||
func (s *captureStore) ListEvents(context.Context, auth.Token, core.ID, service.HistoryPageInput) (EventPage, error) {
|
||||
s.calls++
|
||||
return EventPage{}, nil
|
||||
}
|
||||
func (s *captureStore) GetEvent(context.Context, auth.Token, core.ID, core.ID) (EventDetail, error) {
|
||||
s.calls++
|
||||
return EventDetail{}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user