feat: expose bounded workflow activation and signal history
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,18 @@
|
||||
- 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新轮无父任务。不能改指当前激活、递归展开历史或将来源查询加入派发路径;无外键。
|
||||
|
||||
- 历史读取按固定实例及所有队列授权,明确步骤/激活/信号归属,不依赖外键;摘要分页20/100,游标绑定种类和父资源。指定激活详情的Step是当前视图、Activation是历史视图,不自动改代次;POLL轮次限定单个激活,恢复同轮任务不可与下一业务轮混淆。信号列表保留未核验旧身份,正文单条读取;信号审计用显式workflows:audit:read,不能按全局审计ID越权。000023只增加分页索引;全量回归必须保留零外键与旧数据/索引断言。
|
||||
|
||||
@@ -10,7 +10,7 @@ Orbit 是面向多个业务项目的通用任务调度与流程编排平台,
|
||||
|
||||
已完成 P0–P3 平台功能阶段验收:35 个数据模型、版本化迁移、凭据及范围校验、队列与任务 API、WSS 执行、独立 SDK、自动 Worker 和 [执行尝试/证据查询](docs/api/execution-history.md)。[Worker Schema](docs/api/worker-schema.md) 与真实进程强杀恢复回归已通过;[安全重试](docs/api/retries.md)、[定时计划](docs/api/schedules.md)、[运行中取消](docs/api/cancellation.md) 、[队列资源控制](docs/api/queues.md) 、[应用共享配额](docs/api/application-resources.md) 、[资源键互斥](docs/api/keyed-concurrency.md) 和 [优先级/节点公平派发](docs/api/priority-and-fairness.md) 已接入服务端和 SDK,[可靠结果订阅](docs/api/subscriptions.md) 已实现持久化核心、HTTP/WSS、管理 SDK 与示例,[消费 SDK](docs/sdk/event-consumer.md) 和 [独立 MySQL Inbox 示例](docs/examples/result-inbox.md) 已实现;[批量任务受理](docs/api/batch-tasks.md) 已提供逐项幂等、失败分类与 SDK 示例;[人工核查与重试](docs/api/task-recovery.md) 已提供带证据的管理接口、关联新任务、审计查询及运行示例;[业务键与时间范围查询](docs/api/task-queries.md) 已提供过滤、分页与SDK场景;[任务来源查询](docs/api/task-source.md) 已提供人工重试父任务、计划触发记录与固定版本;[业务事务Outbox](docs/examples/task-outbox.md) 已提供独立业务库、稳定提交身份、发送恢复与CLI;[相对延迟执行](docs/examples/delayed-task.md)已提供数据库计时及原请求恢复;完整范围核对见[P3验收](docs/reports/phase-3.md),流程引擎与生产验证按 [持续开发计划](docs/08-development-plan.md) 继续实现。
|
||||
|
||||
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/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/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)。
|
||||
|
||||
|
||||
@@ -226,3 +226,7 @@ PollState新增FirstRound和FirstRoundAttemptsUsed,记录当前激活的首个
|
||||
## 流程任务直接来源
|
||||
|
||||
[WORKFLOW来源](api/task-source.md#5-流程固定来源)复用实例固定版本、步骤、激活、查询轮次及Task.RetryOf;不增加迁移、表、持久化字段或外键。响应模型和独立SDK新增字段均有中文注释,仍为35个持久化实体/305个直接字段声明。沿已有任务唯一索引和步骤代次唯一键核验正反向归属,只返回直接父任务;人工恢复不改写旧任务的历史激活来源。
|
||||
|
||||
## 流程历史查询索引
|
||||
|
||||
000023_workflow_signal_history只为weave_signals增加(namespace_id,workflow_id,accepted_at,id)分页索引,保留全部原信号数据和状态。仍为35个持久化实体/305个直接字段声明,无新增字段或外键;HTTP/SDK的摘要和详情字段均有中文注释。[历史查询](api/workflow-history.md)明确区分当前逻辑步骤与指定历史激活,不以当前代次替换旧来源。基础测试禁止000008之后再次引入数据库引用约束,真实MySQL升级及readiness验证外键为0。
|
||||
|
||||
@@ -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与基础可运行场景已接入;流程任务来源已接入;历史激活/轮次/信号与信号审计已接入;实例列表、统一事件时间线范围核对与更多场景继续开发 |
|
||||
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 有界查询轮次/尝试独立预算及原截止恢复已接入内部运行器;最终信号竞争及在途查询结清已接入;内部暂停/继续/取消与失败结清已接入;执行核查、步骤结论、解除核查及TASK新激活已接入;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入;等待恢复已接入内部服务;公共API/SDK已接入;流程任务来源已接入;历史激活/轮次/信号与信号审计已接入;实例列表、统一事件时间线范围核对与更多场景继续开发 |
|
||||
| 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)。流程任务来源已接入,完整历史仍须完成。
|
||||
P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](reports/phase-4-clock.md)。外部信号与最终查询竞争见[信号报告](reports/phase-4-signals.md)。公共实例、控制、信号、六类恢复和操作审计已接入HTTP与独立SDK,见[公共接入报告](reports/phase-5-public-api.md)。流程任务来源及[历史查询](api/workflow-history.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专门验收。
|
||||
@@ -130,6 +130,8 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
|
||||
|
||||
## 7. 文档入口
|
||||
|
||||
- [流程历史查询与SDK](api/workflow-history.md)
|
||||
- [历史查询验证记录](reports/phase-5-history.md)
|
||||
- [流程实例公共API](api/workflows.md)
|
||||
- [流程恢复API及SDK](api/workflow-recovery.md)
|
||||
- [并行作业与回执示例](examples/workflow.md)
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
| [11 Weave实例受理](11-workflow-admission.md) | 固定版本、初始图和首批任务事务 |
|
||||
| [12 Weave结果消费与推进](12-workflow-runtime.md) | 后台消费、根接续、事务与进程恢复,及尚未完成的等待/控制边界 |
|
||||
| [13 Weave定时等待与查询轮次](13-workflow-clock.md) | 原截止、轮次/尝试预算、时钟检查点、升级与全停恢复 |
|
||||
| [流程历史查询](api/workflow-history.md) | 指定历史激活、查询轮次、信号摘要与信号资源审计 |
|
||||
| [接口手册](api/README.md) | 实际接口状态、调用说明与后续接口规划 |
|
||||
| [SDK 与场景](sdk/README.md) | 可运行示例、接入方法及分阶段 SDK 交付 |
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
| `/v1/events/connect` | 已实现 WSS、消息 Schema、连接 SDK 和自动消费器 | [消息协议](subscriptions.md#5-websocket-建连与边界) / [消费 SDK](../sdk/event-consumer.md) |
|
||||
| `/v1/workflow-definitions` 创建、发布与固定版本读取 | P4定义子项已实现,实例另用启动接口 | [定义接口](workflow-definitions.md)、[格式规范](workflow-definition-format.md) |
|
||||
| `/v1/workflows`、步骤详情、控制、信号及操作审计 | 已接入HTTP与独立SDK | [实例接口](workflows.md) |
|
||||
| 历史激活/轮次/信号列表、历史激活详情及信号资源审计 | 已接入有界分页HTTP与独立SDK | [历史查询](workflow-history.md) |
|
||||
| `/v1/workflows/{id}/recovery/*` 六类人工恢复 | 已接入有证据恢复及独立SDK | [恢复接口](workflow-recovery.md) |
|
||||
|
||||
未实现路由返回 404。健康检查就绪不表示任务执行、定时调度或流程引擎已经可用。
|
||||
|
||||
164
docs/api/workflow-history.md
Normal file
164
docs/api/workflow-history.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# 流程激活、查询轮次、信号与信号审计
|
||||
|
||||
状态:HTTP与独立Go SDK已接入;本页查询明确指定的历史激活,不跟随逻辑步骤的当前激活。实例筛选分页、统一状态事件时间线及更多独立恢复场景仍按[开发计划](../08-development-plan.md)核对与交付。测试与范围见[本批报告](../reports/phase-5-history.md)。
|
||||
|
||||
## 1. 路由与调用规则
|
||||
|
||||
所有接口为GET,成功200,使用`Authorization: Bearer <token>`,不需要请求体或幂等键。`{id}`是流程实例ID,`{activation}`是激活ID,`{signal}`是信号ID,`{operation}`是审计记录ID;均为32位小写十六进制。`{key}`是固定定义中的步骤键,1–64个ASCII字符,首位字母或数字,之后允许字母、数字、`_ . -`,禁止内部`$root`。操作审计的operation_id不是查询路径中的审计ID。
|
||||
|
||||
| 路径 | 权限 | SDK方法 |
|
||||
| --- | --- | --- |
|
||||
| `/v1/workflows/{id}/steps/{key}/activations` | workflows:read | ListWorkflowActivations(ctx, id, key, page) |
|
||||
| `/v1/workflows/{id}/steps/{key}/activations/{activation}` | workflows:read | GetWorkflowActivation(ctx, id, key, activation) |
|
||||
| `/v1/workflows/{id}/steps/{key}/activations/{activation}/rounds` | workflows:read | ListWorkflowPollRounds(ctx, id, key, activation, page) |
|
||||
| `/v1/workflows/{id}/signals` | workflows:read | ListWorkflowSignals(ctx, id, page) |
|
||||
| `/v1/workflows/{id}/signals/{signal}/operations` | workflows:audit:read | ListWorkflowSignalOperations(ctx, id, signal, page) |
|
||||
| `/v1/workflows/{id}/signals/{signal}/operations/{operation}` | workflows:audit:read | GetWorkflowSignalOperation(ctx, id, signal, operation) |
|
||||
|
||||
服务端先校验命名空间和固定流程引用的全部队列,再读取子资源。普通流程读取权限不包含审计权限;bootstrap用`--workflow-audit-access`显式签发审计权限。知道激活、任务、信号或审计ID不能绕过父资源权限。
|
||||
|
||||
四个列表只接受可选`limit`、`cursor`。limit为十进制整数1–100,省略默认20;SDK的`HistoryPageRequest.Limit=0`表示省略。cursor最多1024字节,按URL查询参数编码,必须来自相同查询种类、实例以及对应步骤/激活/信号。未知、重复或非法参数返回400;详情不接受查询参数。
|
||||
|
||||
每页最多读取limit+1条判断后续,空列表返回`items: []`,没有下一页时省略next_cursor。游标不是授权凭据;改权限后继续分页仍重新授权。翻页之间不是固定数据库快照,状态可能变化、新记录可能产生;要看最新记录需重新从第一页开始,不能把游标用于执行或确认操作。
|
||||
|
||||
## 2. 激活列表和单次详情
|
||||
|
||||
列表按sequence倒序,不聚合input、output、查询预算或等待正文。返回类型是[WorkflowActivationPage](../../sdk/go/workflow_history_types.go):
|
||||
|
||||
| 字段 | 类型与含义 |
|
||||
| --- | --- |
|
||||
| instance_id、step_id | 字符串,已验证父实例与逻辑步骤 |
|
||||
| revision | 十进制字符串,本页读取时的实例版本;SDK为uint64 |
|
||||
| current_activation_id | 可选字符串,本页读取时的当前激活;尚未激活时省略 |
|
||||
| items | 激活摘要数组,未激活步骤为[] |
|
||||
| next_cursor | 可选字符串,后续位置 |
|
||||
|
||||
每项含id、step_id、sequence(正uint32)、state、revision(uint64字符串)、created_at、updated_at;task_id仅用于TASK直接任务,completed_at仅已保存结束时间时返回。时间均为UTC RFC3339。state反映这条激活当前保存的状态,不代表整个实例状态;核查可能改变记录状态,列表不是每次状态变化的事件流水。
|
||||
|
||||
下面是恢复后的第二页,当前激活已经是第2代,本页仍读取第1代:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"data": {
|
||||
"instance_id": "11111111111111111111111111111111",
|
||||
"revision": "12",
|
||||
"step_id": "22222222222222222222222222222222",
|
||||
"current_activation_id": "44444444444444444444444444444444",
|
||||
"items": [{
|
||||
"id": "33333333333333333333333333333333",
|
||||
"step_id": "22222222222222222222222222222222",
|
||||
"sequence": 1,
|
||||
"state": "FAILED",
|
||||
"revision": "3",
|
||||
"task_id": "55555555555555555555555555555555",
|
||||
"created_at": "2026-09-18T08:00:00Z",
|
||||
"updated_at": "2026-09-18T08:00:01Z",
|
||||
"completed_at": "2026-09-18T08:00:01Z"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
详情的data为`{instance_id, revision, step, activation, input, output?, poll?, wait?}`:
|
||||
|
||||
- `step`是[逻辑步骤当前视图](workflows.md#3-步骤及当前激活),`activation`是明确请求的历史激活摘要,两者的激活ID和代次允许不同。
|
||||
- `input/output`只属于指定激活。缺失output与显式null不同,精确JSON数字由SDK的json.RawMessage保留;不会展开其他激活大正文。
|
||||
- POLL的`poll`返回这一激活的first_round、first_round_attempts_used、used_rounds、冻结上限、原截止和最后任务。字段与[当前查询详情](workflows.md#3-步骤及当前激活)一致,范围改为指定激活。
|
||||
- SIGNAL、TIMER和有最终信号的POLL返回该激活的wait,包含原等待ID、原截止、状态和已有赢家。恢复后旧等待仍可读,新激活不能延长原截止或替换旧winner。
|
||||
- TASK尝试、未知结果和迟到证据通过task_id继续查询[执行历史](execution-history.md)。人工决定的前后摘要通过[流程操作审计](workflows.md#6-操作审计与错误处理)读取。
|
||||
|
||||
## 3. POLL轮次列表
|
||||
|
||||
data为`{instance_id, revision, activation_id, items, next_cursor?}`,items按业务轮round倒序。每项为`{id, activation_id, round, task_id, created_at}`;round是正uint32,其他身份为字符串。
|
||||
|
||||
必须明确指定一个POLL激活;TASK或其他节点调用返回400。人工恢复同一业务轮会新建激活和任务,因此“第1代的业务第1轮”和“第2代的业务第1轮”可以同时存在。先遍历激活,再遍历每个激活的轮次,不能把激活序号、业务轮次和Pulse尝试epoch混为一谈。
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"data": {
|
||||
"instance_id": "11111111111111111111111111111111",
|
||||
"revision": "20",
|
||||
"activation_id": "44444444444444444444444444444444",
|
||||
"items": [{
|
||||
"id": "66666666666666666666666666666666",
|
||||
"activation_id": "44444444444444444444444444444444",
|
||||
"round": 1,
|
||||
"task_id": "77777777777777777777777777777777",
|
||||
"created_at": "2026-09-18T08:00:02Z"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
列表显式验证每轮任务属于该命名空间、激活、固定队列和门控。关联损坏不返回部分成功,也不利用外键推断归属。GetTaskSource可以反向核对[固定来源](task-source.md#5-流程固定来源)。
|
||||
|
||||
## 4. 信号与信号资源审计
|
||||
|
||||
信号列表data为`{workflow_id, revision, items, next_cursor?}`,按accepted_at和id倒序。每项含id、workflow_id、revision、name、payload_version、target_step_key、target_activation_sequence、state、accepted_at、expires_at,有处置原因时增加disposition。列表不携带payload、correlation_key、actor_id或evidence_ref;完整原信号使用[GetWorkflowSignal](workflows.md)读取。
|
||||
|
||||
早期未核验历史可能payload_version为空、target_activation_sequence为0,列表保留这些事实,不补造可执行身份。CONSUMED表示该信号已消费,单条详情的consumed_wait_id与指定历史激活的wait.winner_signal_id可以交叉核对。
|
||||
|
||||
信号操作列表data为`{items, next_cursor?}`,按created_at和id倒序,每项含id、workflow_id、signal_id、operation_id、actor_id、action、created_at。当前受理动作action为`workflow.signal`。操作详情的data直接是该摘要,再增加reason、可选evidence_ref、before和after:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"data": {
|
||||
"id": "88888888888888888888888888888888",
|
||||
"workflow_id": "11111111111111111111111111111111",
|
||||
"signal_id": "99999999999999999999999999999999",
|
||||
"operation_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"actor_id": "cccccccccccccccccccccccccccccccc",
|
||||
"action": "workflow.signal",
|
||||
"created_at": "2026-09-18T08:00:02Z",
|
||||
"reason": "verified external completion",
|
||||
"evidence_ref": "proof:event-1",
|
||||
"before": null,
|
||||
"after": {"signal_id": "99999999999999999999999999999999", "state": "PENDING"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
上例after省略其他受理身份字段,完整内容以原操作保存的JSON为准。after是原受理审计,之后信号已消费也不能把其中PENDING改成CONSUMED。此接口只查`workflow_signal`资源,流程推进/控制/恢复操作在流程审计中。普通信号消费状态通过Get/List查看,不能将本接口解释成所有内部状态事件的统一时间线。
|
||||
|
||||
## 5. SDK调用场景
|
||||
|
||||
以下代码接在已配置client与ctx、已知workflowID及stepKey的调用方中;page使用SDK类型,与服务端模型无依赖:
|
||||
|
||||
```go
|
||||
cursor := ""
|
||||
for {
|
||||
page, err := client.ListWorkflowActivations(ctx, workflowID, stepKey,
|
||||
orbit.HistoryPageRequest{Limit: 20, Cursor: cursor})
|
||||
if err != nil { return err }
|
||||
for _, activation := range page.Items {
|
||||
detail, err := client.GetWorkflowActivation(ctx, workflowID, stepKey, activation.ID)
|
||||
if err != nil { return err }
|
||||
fmt.Printf("activation=%s sequence=%d state=%s\n",
|
||||
detail.Activation.ID, detail.Activation.Sequence, detail.Activation.State)
|
||||
// 按需读取单次结果;禁止拿当前step的代次冒充历史激活代次。
|
||||
}
|
||||
if page.NextCursor == "" { break }
|
||||
cursor = page.NextCursor
|
||||
}
|
||||
```
|
||||
|
||||
POLL继续用ListWorkflowPollRounds(ctx, workflowID, stepKey, activation.ID, page)分页获取该次激活的任务,再按任务查询尝试和证据。信号场景用ListWorkflowSignals定位旧代次和当前代次的信号;具备审计权限后用ListWorkflowSignalOperations和GetWorkflowSignalOperation读取原因与原受理证据。
|
||||
|
||||
[可运行并行作业示例](../examples/workflow.md)会核对已完成等待的历史赢家与CONSUMED信号。[SDK真实链路测试](../../sdk/go/workflow_instances_integration_test.go)另外覆盖TASK恢复、SIGNAL换代、POLL同轮恢复及下一业务轮分页;入口为`make integration`。调用方法见[SDK源码](../../sdk/go/workflow_history.go),各字段中文注释见[独立类型](../../sdk/go/workflow_history_types.go)。
|
||||
|
||||
## 6. 错误、部署与兼容
|
||||
|
||||
| HTTP / 错误码 | 含义与处理 |
|
||||
| --- | --- |
|
||||
| 400 INVALID_ARGUMENT | 身份、步骤键、分页参数或游标范围错误;或非POLL读取轮次。修正查询,不能丢掉筛选继续 |
|
||||
| 401 UNAUTHENTICATED | 凭据无效或过期,按[认证协议](authentication.md)处理 |
|
||||
| 403 PERMISSION_DENIED | 缺少读取或显式审计权限 |
|
||||
| 404 NOT_FOUND | 父/子资源不属于该作用域、不存在或队列不可访问;不跨资源查找 |
|
||||
| 503 UNAVAILABLE | 关联损坏或数据库暂不可用;保留资源身份并检查服务记录,不能将空列表当作成功 |
|
||||
|
||||
所有查询只读,不重算截止、重置预算、消耗轮次或触发恢复。SDK拒绝非法路径/分页及响应父资源不匹配;不自动创建新身份。读操作网络失败可以按原资源重读,状态可能已经前进。
|
||||
|
||||
服务端需先应用`000023_workflow_signal_history`,只增加(namespace_id,workflow_id,accepted_at,id)索引;原数据、迁移历史、模型字段和零外键要求保持。维护窗口和DDL锁需按目标数据库演练。当前SDK仍为开发版,使用与服务端匹配的开发提交;P4/P5整体和生产验收尚未完成。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
状态:下表公共 HTTP 与独立 Go SDK 已接入。定义发布见[定义接口](workflow-definitions.md),人工恢复见[恢复接口](workflow-recovery.md),完整运行例子见[并行作业与回执](../examples/workflow.md)。SDK 尚未发布正式版本,服务端与 SDK 应使用同一开发版本。
|
||||
|
||||
实例筛选分页及历史激活/轮次/信号列表继续开发;[Pulse任务流程来源](task-source.md#5-流程固定来源)已接入。旧冻结回执可能不含新增的activation_sequence,应另读当前步骤详情;当前步骤详情只反映当前激活;旧记录保留在数据库,不能把本页的当前视图当作全部历史。
|
||||
实例筛选分页继续开发;[历史激活、轮次、信号和信号资源审计](workflow-history.md)已接入;[Pulse任务流程来源](task-source.md#5-流程固定来源)已接入。旧冻结回执可能不含新增的activation_sequence,应另读当前步骤详情;当前步骤详情只反映当前激活;旧记录保留在数据库,不能把本页的当前视图当作全部历史。
|
||||
|
||||
## 1. 路由、权限与 SDK
|
||||
|
||||
@@ -155,7 +155,7 @@ name/payload_version/target_step_key必填,分别最多128/32/64个ASCII名称
|
||||
|
||||
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读取,信号资源审计列表及完整激活历史继续开发。
|
||||
该接口当前查询resource_kind=workflow的启动、推进、控制和恢复审计;独立任务核查审计通过恢复回执的task_audit_id关联。信号自身的受理状态通过GetWorkflowSignal读取;[信号资源审计及历史激活查询](workflow-history.md)使用独立路由,统一状态事件时间线仍待核对交付。
|
||||
|
||||
| HTTP/错误码 | 处理方式 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -185,5 +185,5 @@
|
||||
- [来源接口与SDK](../api/task-source.md)随现有tasks、schedules、recovery和workflow四个示例运行,无额外服务或数据库表。
|
||||
- 步骤:取得任务ID → GetTaskSource → 区分普通提交/人工重试/计划触发 → 按权限读取父任务或固定计划版本。
|
||||
- 预期:source_kind分别为DIRECT、MANUAL_RETRY、SCHEDULE;人工重试关联原任务,计划任务保留实际触发版本。
|
||||
- workflow示例逐项核对三个任务所属流程、固定版本、逻辑步骤及创建激活,输出workflow_sources_verified=3;恢复旧激活和POLL轮次由SDK集成测试覆盖。
|
||||
- workflow示例逐项核对三个任务所属流程、固定版本、逻辑步骤及创建激活,输出workflow_sources_verified=3;示例同时查询历史等待赢家与已消费信号;恢复旧激活、原等待截止和POLL轮次分页由SDK集成测试覆盖。
|
||||
- 异常:来源缺失或归属不匹配返回503,不能降级为DIRECT;新版本不覆盖历史版本,引用查询不授予完整计划或流程读取权限。
|
||||
|
||||
@@ -38,6 +38,7 @@ CreateWorkflowDefinition → PublishWorkflowVersion(v1) → StartWorkflow →
|
||||
```text
|
||||
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
|
||||
worker_drained=true started=3 confirmed=3
|
||||
```
|
||||
|
||||
@@ -55,4 +56,4 @@ worker_drained=true started=3 confirmed=3
|
||||
|
||||
[公共SDK集成测试](../../sdk/go/workflow_instances_integration_test.go)还实际运行:启动响应丢失的原键恢复;暂停/继续、定时等待、信号消费和取消;UNKNOWN执行核查、步骤结论及解除核查;剩余尝试内TASK重试与SIGNAL换代;确认PENDING后沿原查询预算继续。使用`make integration`在一次性环境复现。
|
||||
|
||||
历史激活列表、更多独立业务示例及生产验收仍按[计划](../08-development-plan.md)推进。
|
||||
历史激活和信号列表已接入,示例核对指定历史等待的winner与信号CONSUMED状态。实例筛选分页、更多独立业务示例及生产验收仍按[计划](../08-development-plan.md)推进。
|
||||
|
||||
34
docs/reports/phase-5-history.md
Normal file
34
docs/reports/phase-5-history.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# P4/P5 流程历史查询回归
|
||||
|
||||
日期:2026-09-18。本批交付指定历史激活、POLL轮次、信号摘要及信号资源操作审计的HTTP与独立SDK;不将这些列表等同于统一状态事件时间线,P4/P5仍保留实例筛选分页和独立恢复场景等剩余项。
|
||||
|
||||
## 实现与验证边界
|
||||
|
||||
- 六条只读路由:激活列表/详情、单激活的查询轮次列表、信号列表、信号操作审计列表/详情。所有父资源按命名空间与固定流程全部队列授权,审计使用显式workflows:audit:read。
|
||||
- 列表默认20、最多100,游标绑定查询类型及父资源,数据库limit+1判断下一页;不聚合输入/结果/信号正文。单条详情保留精确JSON,区分当前逻辑步骤和明确请求的历史激活。
|
||||
- 人工恢复后的旧TASK、POLL和SIGNAL/TIMER等待可读,原截止、轮次与尝试预算不重算;POLL同轮恢复跨激活,后续新业务轮仍单独编号。旧等待赢家须对应原消费信号和原代次。
|
||||
- 信号列表保留早期未核验身份,不补造契约或激活序号。信号受理审计的原PENDING快照与后来CONSUMED状态分开。没有新增状态推进、恢复授权或业务副作用。
|
||||
- 迁移000023只添加信号历史分页索引,无新模型字段、表或外键。历史迁移不回改;基础测试禁止000008之后重新引入引用约束,readiness继续拒绝实际数据库中的外键。
|
||||
|
||||
## 验证记录
|
||||
|
||||
环境:darwin/arm64、Go 1.27.1、Docker MySQL 8.4.10;一次性隔离数据库、真实Orbit服务与Worker协议,SDK不引用服务端代码。
|
||||
|
||||
| 验证 | 实际结果 |
|
||||
| --- | --- |
|
||||
| 基础检查 | make fmt/test/vet/build通过,覆盖主模块、独立SDK及两个业务模块 |
|
||||
| 存储专项 | 17.546秒通过;包括五组历史场景、v22索引升级、历史70外键移除及无外键应用层归属校验 |
|
||||
| HTTP与SDK专项 | 包24.637秒通过;五组实例场景23.23秒,覆盖六条新增历史路由及原有六类恢复 |
|
||||
| 可运行示例 | 流程SUCCEEDED,workflow_sources_verified=3、workflow_history_verified=true、signal_state=CONSUMED,三个任务确认并排空 |
|
||||
| 全量隔离回归 | scripts/test-mysql.sh退出0;存储223.344秒、流程进程恢复49.047秒、流程SDK23.868秒(五组实例22.52秒)、最终进程故障组95.473秒;20个SDK示例及2个独立业务模块全部通过,一次性环境已清理 |
|
||||
| 模型与文档检查 | 305个直接持久化字段和122个新增协议字段均有中文注释;实际MySQL为v23,外键0、业务字段缺COMMENT为0;1045个本地链接、65段JSON及git diff --check通过 |
|
||||
|
||||
存储测试覆盖TASK第2代→第1代分页、原输入/缺失输出、未激活空页、不同步骤/种类游标拒绝、不存在历史身份、TASK误读POLL轮次拒绝、旧任务所有者漂移后不返回部分页;POLL恢复第1轮→第2轮分页与旧预算;SIGNAL/TIMER第1代等待保留原截止;同一受理时刻信号按ID稳定排序、200 KiB正文不进入摘要、旧NULL契约保留、信号资源与操作审计隔离。真实SDK覆盖历史等待winner、恢复前后激活、同轮恢复与下一轮的任务身份,以及原信号审计原因。SDK单元检查非法路径/分页不发送请求及响应父资源不匹配。
|
||||
|
||||
v22→当前迁移逐字段比较信号、精确数字、旧身份与时间,检查新增索引及重复迁移;v7→当前验证70外键全部移除、323个既有索引组成项保留、原任务不变,手工注入违规外键会被CheckSchema拒绝。初次历史专项发现两处测试断言不匹配既有契约(缺失output不能作为JSON比较、权限错误码为PERMISSION_DENIED),修正断言后重跑通过。首轮全量发现v9额度升级测试的人工降级夹具未移除本轮新增索引,导致再次升级时报重复索引;已补齐夹具的结构还原,不改正式迁移。最终全量结果在上表记录。
|
||||
|
||||
源码:[历史存储](../../internal/storage/mysql/workflow_history.go)、[信号历史与审计](../../internal/storage/mysql/workflow_signal_history.go)、[专项回归](../../internal/storage/mysql/workflow_history_integration_test.go)、[索引升级](../../internal/storage/mysql/workflow_history_upgrade_integration_test.go)、[零外键升级](../../internal/storage/mysql/no_foreign_keys_integration_test.go)、[迁移约束检查](../../internal/storage/mysql/no_foreign_keys_test.go)、[HTTP路由](../../internal/transport/httpserver/workflow_history.go)、[独立SDK](../../sdk/go/workflow_history.go)、[真实SDK链路](../../sdk/go/workflow_instances_integration_test.go)。完整复现入口为`make integration`,见[脚本](../../scripts/test-mysql.sh)。
|
||||
|
||||
## 继续交付
|
||||
|
||||
实例筛选分页、统一状态事件时间线的原范围核对,以及独立POLL、核查和暂停恢复示例继续推进;再逐项核对P4/P5原功能与异常矩阵,开展P6/P7监控、凭据轮换、运维、容量及生产故障验收。本地回归没有证明生产复制、跨故障域部署、目标容量或实际RPO/RTO;列表跨页也不提供固定数据库快照。
|
||||
@@ -6,6 +6,8 @@ module:`git.yuntuo.hk/k2safe/orbit/sdk/go`。SDK 使用独立 go.mod,依赖
|
||||
|
||||
任务相对延迟使用SubmitTaskRequest.DelayMS,由数据库首次受理时间计算,原键重报不重新计时;[完整延迟示例](../examples/delayed-task.md)包含Worker执行和历史核对。非零DelayMS不能与AvailableAt同用,旧服务须先升级。
|
||||
|
||||
[流程历史查询](../api/workflow-history.md)已提供激活/轮次/信号有界分页、历史等待与预算详情及显式权限的信号操作审计;当前视图与指定历史激活分开。
|
||||
|
||||
## 1. 从初始化到运行示例
|
||||
|
||||
需要 Go 1.27.1、MySQL 8.4+。在仓库根目录,通过环境设置专用 ORBIT_MYSQL_DSN:
|
||||
|
||||
@@ -63,7 +63,8 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
|
||||
finished := retryStart(t, g)
|
||||
retryFinish(t, g, finished, "SUCCEEDED", false)
|
||||
assertApplicationCounts(t, f, 4, 2, 1)
|
||||
// 还原后续新增表和字段,得到真实 v9 结构;现有任务内容保持不变。
|
||||
// 还原后续新增表、字段和索引,得到真实 v9 结构;现有任务内容保持不变。
|
||||
f.exec(`ALTER TABLE weave_signals DROP INDEX ix_signal_history`)
|
||||
f.exec(`ALTER TABLE weave_poll_states DROP CHECK ck_poll_recovery_budget, DROP COLUMN first_round, DROP COLUMN first_round_attempts_used`)
|
||||
f.exec(`ALTER TABLE pulse_tasks DROP INDEX ix_task_workflow_owner`)
|
||||
f.exec(`ALTER TABLE weave_instances DROP CHECK ck_workflow_stop_intent, DROP COLUMN stop_kind, DROP COLUMN stop_requested_at`)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 信号按实例及原受理时间分页;不改变历史内容、截止、幂等或消费状态,不增加外键。
|
||||
ALTER TABLE weave_signals
|
||||
ADD KEY ix_signal_history (namespace_id,workflow_id,accepted_at,id);
|
||||
@@ -89,7 +89,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
|
||||
indexes := func() []string {
|
||||
t.Helper()
|
||||
// v15替换retry_of索引、v17增加内部结果唯一索引、v20增加信号索引,下方单独验证定义。
|
||||
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') 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') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||
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') 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') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -180,6 +180,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
|
||||
var collation string
|
||||
for _, index := range []struct{ table, name, want string }{
|
||||
{"pulse_tasks", "ix_task_workflow_owner", "namespace_id:1,owner_kind:1,owner_id:1,gate_id:1,id:1"},
|
||||
{"weave_signals", "ix_signal_history", "namespace_id:1,workflow_id:1,accepted_at:1,id:1"},
|
||||
{"weave_signals", "ix_signal_expiry", "state:1,available_at:1,namespace_id:1,id:1"},
|
||||
{"weave_signals", "ix_signal_activation", "namespace_id:1,workflow_id:1,target_step_key:1,target_activation_sequence:1,state:1,accepted_at:1,id:1"},
|
||||
{"weave_waits", "uq_wait_winner_signal", "namespace_id:0,winner_signal_id:0"},
|
||||
|
||||
29
internal/storage/mysql/no_foreign_keys_test.go
Normal file
29
internal/storage/mysql/no_foreign_keys_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 已应用的历史文件不改写;移除迁移之后的DDL不能再引入数据库引用约束。
|
||||
func TestMigrationsAfterForeignKeyRemovalDoNotReintroduceReferences(t *testing.T) {
|
||||
migrations, err := Migrations()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forbidden := regexp.MustCompile(`(?i)\b(FOREIGN\s+KEY|REFERENCES)\b`)
|
||||
for _, m := range migrations {
|
||||
if m.Version <= 8 {
|
||||
continue
|
||||
}
|
||||
statements, err := splitSQL(m.SQL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if forbidden.MatchString(statement) {
|
||||
t.Errorf("migration %d introduces forbidden reference DDL", m.Version)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,8 @@ func decodeHistoryCursorKind(value string, ns, task, attempt core.ID, kind strin
|
||||
if d.Decode(&c) != nil || c.Version != 1 || c.Namespace != ns || c.Task != task || c.Attempt != attempt {
|
||||
return c, fault.Invalid("cursor does not match this history query")
|
||||
}
|
||||
if kind == "attempts" {
|
||||
if c.Kind != "attempts" || c.Epoch == 0 || c.ID != "" || c.Created != nil {
|
||||
if kind == "attempts" || kind == "workflow-activations" || kind == "workflow-rounds" {
|
||||
if c.Kind != kind || c.Epoch == 0 || kind != "attempts" && c.Epoch > 1<<32-1 || c.ID != "" || c.Created != nil {
|
||||
return c, fault.Invalid("invalid attempts cursor")
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"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/weave/definition"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/engine"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/instance"
|
||||
)
|
||||
@@ -37,43 +38,51 @@ func (s *WorkflowInstanceStore) Step(ctx context.Context, t auth.Token, id core.
|
||||
return err
|
||||
}
|
||||
out.Input, out.Output = input, output
|
||||
program, err := engine.Compile(version.Specification)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node, _ := program.Step(key)
|
||||
if node.Poll != nil {
|
||||
q := &instance.PollView{}
|
||||
var owned, queue *core.ID
|
||||
err = tx.QueryRowContext(ctx, "SELECT p.used_rounds,p.first_round,p.first_round_attempts_used,p.max_rounds,p.max_attempts_per_round,p.interval_ms,p.deadline_at,p.next_poll_at,p.current_task_id,r.task_id,p.resolved_at,t.id,t.queue_id FROM weave_poll_states p JOIN weave_poll_rounds r ON r.namespace_id=p.namespace_id AND r.workflow_id=p.workflow_id AND r.activation_id=p.activation_id AND r.`round`=p.used_rounds LEFT JOIN pulse_tasks t ON t.namespace_id=r.namespace_id AND t.id=r.task_id AND t.owner_kind='workflow_activation' AND t.owner_id=r.activation_id AND t.gate_id=? WHERE p.namespace_id=? AND p.workflow_id=? AND p.activation_id=?", gate, t.NamespaceID, id, a).Scan(&q.UsedRounds, &q.FirstRound, &q.FirstRoundAttemptsUsed, &q.MaxRounds, &q.MaxAttemptsPerRound, &q.IntervalMS, &q.DeadlineAt, &q.NextPollAt, &q.CurrentTaskID, &q.LastTaskID, &q.ResolvedAt, &owned, &queue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owned == nil || *owned != q.LastTaskID || queue == nil || *queue != node.Poll.Task.QueueID || q.CurrentTaskID != nil && *q.CurrentTaskID != q.LastTaskID || q.MaxRounds != node.Poll.MaxRounds || q.MaxAttemptsPerRound != node.Poll.Task.Retry.MaxAttempts || q.IntervalMS != node.Poll.IntervalMS {
|
||||
return errors.New("workflow poll detail ownership or policy invalid")
|
||||
}
|
||||
out.Poll = q
|
||||
}
|
||||
if node.Signal != nil || node.Timer != nil || node.Poll != nil && node.Poll.FinalSignal != nil {
|
||||
w := &instance.WaitView{}
|
||||
if err = tx.QueryRowContext(ctx, `SELECT id,kind,state,signal_name,payload_version,correlation_key,deadline_at,winner_signal_id,resolved_at FROM weave_waits WHERE namespace_id=? AND workflow_id=? AND activation_id=?`, t.NamespaceID, id, a).Scan(&w.ID, &w.Kind, &w.State, &w.Name, &w.PayloadVersion, &w.CorrelationKey, &w.DeadlineAt, &w.WinnerSignalID, &w.ResolvedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if w.Kind != string(out.Step.Kind) {
|
||||
return errors.New("workflow wait kind mismatch")
|
||||
}
|
||||
if w.WinnerSignalID != nil {
|
||||
var n int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM weave_signals WHERE namespace_id=? AND workflow_id=? AND id=? AND consumed_wait_id=? AND target_step_key=? AND target_activation_sequence=? AND state='CONSUMED'`, t.NamespaceID, id, w.WinnerSignalID, w.ID, key, out.Step.ActivationSequence).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 1 {
|
||||
return errors.New("workflow wait winner association invalid")
|
||||
}
|
||||
}
|
||||
out.Wait = w
|
||||
}
|
||||
return nil
|
||||
out.Poll, out.Wait, err = readWorkflowActivationAttachments(ctx, tx, t.NamespaceID, id, gate, key, string(out.Step.Kind), *a, out.Step.ActivationSequence, version)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// readWorkflowActivationAttachments 查询指定激活的等待与预算,不替换为当前激活。
|
||||
func readWorkflowActivationAttachments(ctx context.Context, tx *sql.Tx, ns, workflow, gate core.ID, key, kind string, activation core.ID, sequence uint32, version definition.VersionView) (*instance.PollView, *instance.WaitView, error) {
|
||||
var poll *instance.PollView
|
||||
var wait *instance.WaitView
|
||||
program, err := engine.Compile(version.Specification)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
node, _ := program.Step(key)
|
||||
if node.Poll != nil {
|
||||
q := &instance.PollView{}
|
||||
var owned, queue *core.ID
|
||||
err = tx.QueryRowContext(ctx, "SELECT p.used_rounds,p.first_round,p.first_round_attempts_used,p.max_rounds,p.max_attempts_per_round,p.interval_ms,p.deadline_at,p.next_poll_at,p.current_task_id,r.task_id,p.resolved_at,t.id,t.queue_id FROM weave_poll_states p JOIN weave_poll_rounds r ON r.namespace_id=p.namespace_id AND r.workflow_id=p.workflow_id AND r.activation_id=p.activation_id AND r.`round`=p.used_rounds LEFT JOIN pulse_tasks t ON t.namespace_id=r.namespace_id AND t.id=r.task_id AND t.owner_kind='workflow_activation' AND t.owner_id=r.activation_id AND t.gate_id=? WHERE p.namespace_id=? AND p.workflow_id=? AND p.activation_id=?", gate, ns, workflow, activation).Scan(&q.UsedRounds, &q.FirstRound, &q.FirstRoundAttemptsUsed, &q.MaxRounds, &q.MaxAttemptsPerRound, &q.IntervalMS, &q.DeadlineAt, &q.NextPollAt, &q.CurrentTaskID, &q.LastTaskID, &q.ResolvedAt, &owned, &queue)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if owned == nil || *owned != q.LastTaskID || queue == nil || *queue != node.Poll.Task.QueueID || q.CurrentTaskID != nil && *q.CurrentTaskID != q.LastTaskID || q.MaxRounds != node.Poll.MaxRounds || q.MaxAttemptsPerRound != node.Poll.Task.Retry.MaxAttempts || q.IntervalMS != node.Poll.IntervalMS {
|
||||
return nil, nil, errors.New("workflow poll detail ownership or policy invalid")
|
||||
}
|
||||
poll = q
|
||||
}
|
||||
if node.Signal != nil || node.Timer != nil || node.Poll != nil && node.Poll.FinalSignal != nil {
|
||||
w := &instance.WaitView{}
|
||||
if err = tx.QueryRowContext(ctx, `SELECT id,kind,state,signal_name,payload_version,correlation_key,deadline_at,winner_signal_id,resolved_at FROM weave_waits WHERE namespace_id=? AND workflow_id=? AND activation_id=?`, ns, workflow, activation).Scan(&w.ID, &w.Kind, &w.State, &w.Name, &w.PayloadVersion, &w.CorrelationKey, &w.DeadlineAt, &w.WinnerSignalID, &w.ResolvedAt); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if w.Kind != kind {
|
||||
return nil, nil, errors.New("workflow wait kind mismatch")
|
||||
}
|
||||
if w.WinnerSignalID != nil {
|
||||
var n int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM weave_signals WHERE namespace_id=? AND workflow_id=? AND id=? AND consumed_wait_id=? AND target_step_key=? AND target_activation_sequence=? AND state='CONSUMED'`, ns, workflow, w.WinnerSignalID, w.ID, key, sequence).Scan(&n); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if n != 1 {
|
||||
return nil, nil, errors.New("workflow wait winner association invalid")
|
||||
}
|
||||
}
|
||||
wait = w
|
||||
}
|
||||
return poll, wait, nil
|
||||
}
|
||||
|
||||
206
internal/storage/mysql/workflow_history.go
Normal file
206
internal/storage/mysql/workflow_history.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"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/definition"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/engine"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/instance"
|
||||
)
|
||||
|
||||
type workflowHistoryParent struct {
|
||||
view instance.View
|
||||
gate core.ID
|
||||
version definition.VersionView
|
||||
step instance.StepView
|
||||
node definition.Step
|
||||
}
|
||||
|
||||
func readWorkflowHistoryParent(ctx context.Context, tx *sql.Tx, ns, id core.ID, key string, p auth.Permissions) (workflowHistoryParent, error) {
|
||||
var h workflowHistoryParent
|
||||
var err error
|
||||
h.view, h.gate, h.version, err = readWorkflowForQuery(ctx, tx, ns, id, p)
|
||||
if err != nil {
|
||||
return h, err
|
||||
}
|
||||
steps, err := readWorkflowStepViews(ctx, tx, ns, id, h.gate, h.version, key)
|
||||
if err != nil {
|
||||
return h, err
|
||||
}
|
||||
if len(steps) != 1 {
|
||||
return h, fault.NotFound
|
||||
}
|
||||
h.step = steps[0]
|
||||
program, err := engine.Compile(h.version.Specification)
|
||||
if err != nil {
|
||||
return h, err
|
||||
}
|
||||
h.node, _ = program.Step(key)
|
||||
return h, nil
|
||||
}
|
||||
|
||||
const activationHistorySelect = `SELECT a.id,a.step_id,a.sequence,a.state,a.revision,a.task_id,a.created_at,a.updated_at,a.completed_at,t.id,t.queue_id FROM weave_activations a LEFT JOIN pulse_tasks t ON t.namespace_id=a.namespace_id AND t.id=a.task_id AND t.owner_kind='workflow_activation' AND t.owner_id=a.id AND t.gate_id=? WHERE a.namespace_id=? AND a.workflow_id=? AND a.step_id=?`
|
||||
|
||||
func scanWorkflowActivation(row scanner, h workflowHistoryParent) (instance.ActivationSummary, error) {
|
||||
var a instance.ActivationSummary
|
||||
var task, queue *core.ID
|
||||
err := row.Scan(&a.ID, &a.StepID, &a.Sequence, &a.State, &a.Revision, &a.TaskID, &a.CreatedAt, &a.UpdatedAt, &a.CompletedAt, &task, &queue)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if a.StepID != h.step.ID || a.Sequence == 0 || (h.node.Task != nil) != (a.TaskID != nil) {
|
||||
return a, fault.Unavailable
|
||||
}
|
||||
if a.TaskID != nil && (task == nil || queue == nil || *task != *a.TaskID || *queue != h.node.Task.QueueID) {
|
||||
return a, fault.Unavailable
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
func readHistoryActivation(ctx context.Context, tx *sql.Tx, h workflowHistoryParent, activation core.ID) (instance.ActivationSummary, error) {
|
||||
a, err := scanWorkflowActivation(tx.QueryRowContext(ctx, activationHistorySelect+` AND a.id=?`, h.gate, h.view.NamespaceID, h.view.ID, h.step.ID, activation), h)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return a, fault.NotFound
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
func (s *WorkflowInstanceStore) ListActivations(ctx context.Context, t auth.Token, id core.ID, key string, in service.HistoryPageInput) (instance.ActivationPage, error) {
|
||||
out := instance.ActivationPage{InstanceID: id, Items: []instance.ActivationSummary{}}
|
||||
err := s.transaction(ctx, t, instance.ReadAction, func(tx *sql.Tx, p auth.Permissions) error {
|
||||
h, err := readWorkflowHistoryParent(ctx, tx, t.NamespaceID, id, key, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Revision, out.StepID, out.CurrentActivationID = h.view.Revision, h.step.ID, h.step.ActivationID
|
||||
q := activationHistorySelect
|
||||
args := []any{h.gate, t.NamespaceID, id, h.step.ID}
|
||||
if in.Cursor != "" {
|
||||
c, err := decodeHistoryCursorKind(in.Cursor, t.NamespaceID, id, h.step.ID, "workflow-activations")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q += ` AND a.sequence<?`
|
||||
args = append(args, c.Epoch)
|
||||
}
|
||||
q += ` ORDER BY a.sequence DESC LIMIT ?`
|
||||
args = append(args, in.Limit+1)
|
||||
rows, err := tx.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
a, err := scanWorkflowActivation(rows, h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, a)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(out.Items) > in.Limit {
|
||||
out.Items = out.Items[:in.Limit]
|
||||
last := out.Items[len(out.Items)-1]
|
||||
out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "workflow-activations", Namespace: t.NamespaceID, Task: id, Attempt: h.step.ID, Epoch: uint64(last.Sequence)})
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return instance.ActivationPage{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *WorkflowInstanceStore) GetActivation(ctx context.Context, t auth.Token, id core.ID, key string, activation core.ID) (instance.ActivationDetail, error) {
|
||||
out := instance.ActivationDetail{InstanceID: id}
|
||||
err := s.transaction(ctx, t, instance.ReadAction, func(tx *sql.Tx, p auth.Permissions) error {
|
||||
h, err := readWorkflowHistoryParent(ctx, tx, t.NamespaceID, id, key, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Revision, out.Step = h.view.Revision, h.step
|
||||
out.Activation, err = readHistoryActivation(ctx, tx, h, activation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var input, output []byte
|
||||
if err = tx.QueryRowContext(ctx, `SELECT input,output FROM weave_activations WHERE namespace_id=? AND workflow_id=? AND step_id=? AND id=?`, t.NamespaceID, id, h.step.ID, activation).Scan(&input, &output); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Input, out.Output = input, output
|
||||
out.Poll, out.Wait, err = readWorkflowActivationAttachments(ctx, tx, t.NamespaceID, id, h.gate, key, string(h.step.Kind), activation, out.Activation.Sequence, h.version)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return instance.ActivationDetail{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *WorkflowInstanceStore) ListPollRounds(ctx context.Context, t auth.Token, id core.ID, key string, activation core.ID, in service.HistoryPageInput) (instance.PollRoundPage, error) {
|
||||
out := instance.PollRoundPage{InstanceID: id, ActivationID: activation, Items: []instance.PollRoundSummary{}}
|
||||
err := s.transaction(ctx, t, instance.ReadAction, func(tx *sql.Tx, p auth.Permissions) error {
|
||||
h, err := readWorkflowHistoryParent(ctx, tx, t.NamespaceID, id, key, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a, err := readHistoryActivation(ctx, tx, h, activation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if h.node.Poll == nil {
|
||||
return fault.Invalid("round history requires a POLL activation")
|
||||
}
|
||||
out.Revision = h.view.Revision
|
||||
poll, _, err := readWorkflowActivationAttachments(ctx, tx, t.NamespaceID, id, h.gate, key, string(h.step.Kind), activation, a.Sequence, h.version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := "SELECT r.id,r.activation_id,r.`round`,r.task_id,r.created_at,t.id,t.queue_id FROM weave_poll_rounds r LEFT JOIN pulse_tasks t ON t.namespace_id=r.namespace_id AND t.id=r.task_id AND t.owner_kind='workflow_activation' AND t.owner_id=r.activation_id AND t.gate_id=? WHERE r.namespace_id=? AND r.workflow_id=? AND r.activation_id=?"
|
||||
args := []any{h.gate, t.NamespaceID, id, activation}
|
||||
if in.Cursor != "" {
|
||||
c, err := decodeHistoryCursorKind(in.Cursor, t.NamespaceID, id, activation, "workflow-rounds")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q += " AND r.`round`<?"
|
||||
args = append(args, c.Epoch)
|
||||
}
|
||||
q += " ORDER BY r.`round` DESC LIMIT ?"
|
||||
args = append(args, in.Limit+1)
|
||||
rows, err := tx.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var round instance.PollRoundSummary
|
||||
var task, queue *core.ID
|
||||
if err = rows.Scan(&round.ID, &round.ActivationID, &round.Round, &round.TaskID, &round.CreatedAt, &task, &queue); err != nil {
|
||||
return err
|
||||
}
|
||||
if task == nil || queue == nil || *task != round.TaskID || *queue != h.node.Poll.Task.QueueID || round.Round < poll.FirstRound || round.Round > poll.UsedRounds {
|
||||
return fault.Unavailable
|
||||
}
|
||||
out.Items = append(out.Items, round)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(out.Items) > in.Limit {
|
||||
out.Items = out.Items[:in.Limit]
|
||||
last := out.Items[len(out.Items)-1]
|
||||
out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "workflow-rounds", Namespace: t.NamespaceID, Task: id, Attempt: activation, Epoch: uint64(last.Round)})
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return instance.PollRoundPage{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
251
internal/storage/mysql/workflow_history_integration_test.go
Normal file
251
internal/storage/mysql/workflow_history_integration_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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/instance"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/recovery"
|
||||
)
|
||||
|
||||
func TestIntegrationWorkflowHistory(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
db, err := Open(ctx, dsn, 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("task_generations_input_pagination_and_no_foreign_key_corruption", func(t *testing.T) {
|
||||
f := newExecutionFixture(t, ctx, db)
|
||||
spec := runtimeSpec(f.resources.QueueID, 2)
|
||||
spec.Body.Steps[0].Task.Retry.MaxAttempts = 2
|
||||
spec.Body.Steps[0].Task.Retry.InitialDelayMS = 1000
|
||||
spec.Body.Steps[0].Task.Retry.MaxDelayMS = 1000
|
||||
v := runtimeStart(t, f, spec)
|
||||
grantWorkflowRecovery(f)
|
||||
empty, err := api.ListActivations(ctx, f.token, v.ID, "task1", service.HistoryPageInput{})
|
||||
if err != nil || empty.Items == nil || len(empty.Items) != 0 || empty.CurrentActivationID != nil {
|
||||
t.Fatal("unactivated history", err)
|
||||
}
|
||||
ref := retryStart(t, f)
|
||||
retryFinish(t, f, ref, "FAILED", false)
|
||||
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
|
||||
old := recoveryStep(t, f, v.ID, "task0")
|
||||
before, err := api.GetActivation(ctx, f.token, v.ID, "task0", old.ActivationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child, err := recovery.New(NewWorkflowRecoveryStore(db)).RetryStep(ctx, f.token, v.ID, string(executionID(t)), recovery.RetryStepInput{StepDecision: old, SafeToRetry: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := api.ListActivations(ctx, f.token, v.ID, "task0", service.HistoryPageInput{Limit: 1})
|
||||
if err != nil || len(page.Items) != 1 || page.Items[0].Sequence != 2 || page.NextCursor == "" || *page.CurrentActivationID != *child.Step.ActivationID {
|
||||
t.Fatal("latest page", err)
|
||||
}
|
||||
older, err := api.ListActivations(ctx, f.token, v.ID, "task0", service.HistoryPageInput{Limit: 1, Cursor: page.NextCursor})
|
||||
if err != nil || len(older.Items) != 1 || older.Items[0].ID != old.ActivationID || older.NextCursor != "" {
|
||||
t.Fatal("historical page", err)
|
||||
}
|
||||
detail, err := api.GetActivation(ctx, f.token, v.ID, "task0", old.ActivationID)
|
||||
if err != nil || detail.Activation.ID != old.ActivationID || detail.Activation.Sequence != 1 || detail.Step.ActivationSequence != 2 || *detail.Activation.TaskID != ref.TaskID || !sameWorkflowJSON(detail.Input, before.Input) || !bytes.Equal(detail.Output, before.Output) {
|
||||
t.Fatal("history replaced by current activation", err)
|
||||
}
|
||||
_, err = api.ListActivations(ctx, f.token, v.ID, "task1", service.HistoryPageInput{Cursor: page.NextCursor})
|
||||
expectWorkerCode(t, err, "INVALID_ARGUMENT")
|
||||
_, err = api.GetActivation(ctx, f.token, v.ID, "task1", old.ActivationID)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
_, err = api.GetActivation(ctx, f.token, v.ID, "task0", executionID(t))
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
_, err = api.ListPollRounds(ctx, f.token, v.ID, "task0", old.ActivationID, service.HistoryPageInput{})
|
||||
expectWorkerCode(t, err, "INVALID_ARGUMENT")
|
||||
for _, in := range []service.HistoryPageInput{{Limit: -1}, {Limit: 101}, {Cursor: strings.Repeat("x", 1025)}} {
|
||||
_, err = api.ListActivations(ctx, f.token, v.ID, "task0", in)
|
||||
expectWorkerCode(t, err, "INVALID_ARGUMENT")
|
||||
}
|
||||
_, err = api.ListActivations(ctx, f.token, v.ID, "$root", service.HistoryPageInput{})
|
||||
expectWorkerCode(t, err, "INVALID_ARGUMENT")
|
||||
// 无外键时模拟旧任务归属漂移:历史读取必须失败且不返回部分页。
|
||||
f.exec(`UPDATE pulse_tasks SET owner_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, ref.TaskID)
|
||||
bad, err := api.ListActivations(ctx, f.token, v.ID, "task0", service.HistoryPageInput{})
|
||||
if fault.Code(err) != "UNAVAILABLE" || bad.Items != nil {
|
||||
t.Fatal("foreign historical task leaked", err)
|
||||
}
|
||||
if _, err = api.GetActivation(ctx, f.token, v.ID, "task0", old.ActivationID); fault.Code(err) != "UNAVAILABLE" {
|
||||
t.Fatal("foreign activation accepted", err)
|
||||
}
|
||||
f.exec(`UPDATE pulse_tasks SET owner_id=? WHERE namespace_id=? AND id=?`, old.ActivationID, f.resources.NamespaceID, ref.TaskID)
|
||||
f.exec(`UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.queues',JSON_ARRAY(?)) WHERE namespace_id=? AND id=?`, string(executionID(t)), f.resources.NamespaceID, f.resources.CredentialID)
|
||||
_, err = api.ListActivations(ctx, f.token, v.ID, "task0", service.HistoryPageInput{})
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
})
|
||||
t.Run("poll_same_round_recovery_then_two_round_pages", func(t *testing.T) {
|
||||
f, v, oldTask := failedPoll(t, ctx, db, false)
|
||||
old := recoveryStep(t, f, v.ID, "query")
|
||||
before, err := api.GetActivation(ctx, f.token, v.ID, "query", old.ActivationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := retryPollStep(t, f, v.ID)
|
||||
continueWorkflow(t, f, v.ID)
|
||||
ref := retryStart(t, f)
|
||||
finishPoll(t, f, ref, "PENDING")
|
||||
c := clockCandidate(t, f, v.ID, "query")
|
||||
waitClockDue(t, f, c)
|
||||
runClock(t, f, c)
|
||||
last := retryStart(t, f)
|
||||
finishPoll(t, f, last, "DONE")
|
||||
runtimeView(t, f, v.ID, "SUCCEEDED")
|
||||
prior, err := api.ListPollRounds(ctx, f.token, v.ID, "query", old.ActivationID, service.HistoryPageInput{})
|
||||
if err != nil || len(prior.Items) != 1 || prior.Items[0].Round != 1 || prior.Items[0].TaskID != oldTask.TaskID {
|
||||
t.Fatal("old round lost", err)
|
||||
}
|
||||
page, err := api.ListPollRounds(ctx, f.token, v.ID, "query", *child.Step.ActivationID, service.HistoryPageInput{Limit: 1})
|
||||
if err != nil || len(page.Items) != 1 || page.Items[0].Round != 2 || page.Items[0].TaskID != last.TaskID || page.NextCursor == "" {
|
||||
t.Fatal("latest round", err)
|
||||
}
|
||||
older, err := api.ListPollRounds(ctx, f.token, v.ID, "query", *child.Step.ActivationID, service.HistoryPageInput{Limit: 1, Cursor: page.NextCursor})
|
||||
if err != nil || len(older.Items) != 1 || older.Items[0].Round != 1 || older.Items[0].TaskID != child.Task.ID || older.NextCursor != "" {
|
||||
t.Fatal("recovered round", err)
|
||||
}
|
||||
_, err = api.ListPollRounds(ctx, f.token, v.ID, "query", old.ActivationID, service.HistoryPageInput{Cursor: page.NextCursor})
|
||||
expectWorkerCode(t, err, "INVALID_ARGUMENT")
|
||||
_, err = api.ListActivations(ctx, f.token, v.ID, "query", service.HistoryPageInput{Cursor: page.NextCursor})
|
||||
expectWorkerCode(t, err, "INVALID_ARGUMENT")
|
||||
after, err := api.GetActivation(ctx, f.token, v.ID, "query", old.ActivationID)
|
||||
if err != nil || after.Poll.UsedRounds != 1 || after.Poll.DeadlineAt != before.Poll.DeadlineAt || after.Poll.LastTaskID != oldTask.TaskID || after.Activation.Sequence != 1 || after.Step.ActivationSequence != 2 {
|
||||
t.Fatal("old poll replaced", err)
|
||||
}
|
||||
// 破坏非末轮的任务所有者,必须由轮次列表本身发现。
|
||||
f.exec(`UPDATE pulse_tasks SET owner_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, child.Task.ID)
|
||||
bad, err := api.ListPollRounds(ctx, f.token, v.ID, "query", *child.Step.ActivationID, service.HistoryPageInput{})
|
||||
if fault.Code(err) != "UNAVAILABLE" || bad.Items != nil {
|
||||
t.Fatal("round ownership bypassed", err)
|
||||
}
|
||||
})
|
||||
for _, kind := range []string{"SIGNAL", "TIMER"} {
|
||||
t.Run("historical_wait_"+kind, func(t *testing.T) {
|
||||
f, v := waitRecoveryFixture(t, ctx, db, kind, 30000)
|
||||
key := "signal"
|
||||
if kind == "TIMER" {
|
||||
key = "timer"
|
||||
}
|
||||
old, err := api.Step(ctx, f.token, v.ID, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rearmWorkflowWait(t, f, v.ID, key)
|
||||
detail, err := api.GetActivation(ctx, f.token, v.ID, key, *old.Step.ActivationID)
|
||||
if err != nil || detail.Wait.ID != old.Wait.ID || detail.Wait.DeadlineAt != old.Wait.DeadlineAt || detail.Step.ActivationSequence != 2 || detail.Activation.Sequence != 1 || detail.Activation.State != "CANCELLED" {
|
||||
t.Fatal("wait history lost", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("signal_pages_legacy_summary_and_resource_audit", func(t *testing.T) {
|
||||
f, v, signals := signalStart(t, ctx, db, true)
|
||||
empty, err := signals.List(ctx, f.token, v.ID, service.HistoryPageInput{})
|
||||
if err != nil || empty.Items == nil || len(empty.Items) != 0 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := make([]core.ID, 0, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
out, err := signals.Send(ctx, f.token, v.ID, string(executionID(t)), signalInput(v))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent = append(sent, out.Signal.ID)
|
||||
}
|
||||
_, err = signals.ListOperations(ctx, f.token, v.ID, sent[0], service.HistoryPageInput{})
|
||||
expectWorkerCode(t, err, "PERMISSION_DENIED")
|
||||
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)
|
||||
audits, err := signals.ListOperations(ctx, f.token, v.ID, sent[0], service.HistoryPageInput{Limit: 1})
|
||||
if err != nil || len(audits.Items) != 1 || audits.Items[0].Action != "workflow.signal" {
|
||||
t.Fatal("signal audit list", err)
|
||||
}
|
||||
audit, err := signals.GetOperation(ctx, f.token, v.ID, sent[0], audits.Items[0].ID)
|
||||
if err != nil || audit.SignalID != sent[0] || audit.WorkflowID != v.ID || audit.Reason != signalInput(v).Reason || audit.EvidenceRef != signalInput(v).EvidenceRef {
|
||||
t.Fatal("signal audit detail", err)
|
||||
}
|
||||
_, err = signals.GetOperation(ctx, f.token, v.ID, sent[1], audit.ID)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
// 构造相同受理时间、历史未核验身份和大正文,验证有界摘要及稳定二级排序;不将夹具作为合法信号送入运行器。
|
||||
f.exec(`UPDATE weave_signals SET accepted_at='2026-01-01 00:00:00.000000',state='REJECTED',disposition='LEGACY_UNVERIFIED',payload_version=NULL,target_activation_sequence=NULL,payload=? WHERE namespace_id=? AND workflow_id=?`, `"`+strings.Repeat("p", 200<<10)+`"`, f.resources.NamespaceID, v.ID)
|
||||
seen := map[core.ID]bool{}
|
||||
cursor := ""
|
||||
last := core.ID("")
|
||||
for {
|
||||
page, err := signals.List(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 1, Cursor: cursor})
|
||||
if err != nil || len(page.Items) != 1 {
|
||||
t.Fatal("signal page", err)
|
||||
}
|
||||
raw, _ := json.Marshal(page)
|
||||
item := page.Items[0]
|
||||
if len(raw) > 2048 || seen[item.ID] || last != "" && item.ID >= last || item.TargetActivationSequence != 0 || item.PayloadVersion != "" {
|
||||
t.Fatal("signal summary lost bounds or identity")
|
||||
}
|
||||
seen[item.ID] = true
|
||||
last = item.ID
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = page.NextCursor
|
||||
}
|
||||
if len(seen) != 3 {
|
||||
t.Fatal("signal omitted")
|
||||
}
|
||||
_, err = signals.ListOperations(ctx, f.token, v.ID, sent[0], service.HistoryPageInput{Cursor: cursor})
|
||||
expectWorkerCode(t, err, "INVALID_ARGUMENT")
|
||||
other := runtimeStart(t, f, signalSpec(f.resources.QueueID, true))
|
||||
grantWorkflowSignal(f)
|
||||
_, err = signals.List(ctx, f.token, other.ID, service.HistoryPageInput{Cursor: cursor})
|
||||
expectWorkerCode(t, err, "INVALID_ARGUMENT")
|
||||
_, err = signals.List(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 101})
|
||||
expectWorkerCode(t, err, "INVALID_ARGUMENT")
|
||||
_, err = signals.ListOperations(ctx, f.token, v.ID, executionID(t), service.HistoryPageInput{})
|
||||
expectWorkerCode(t, err, "PERMISSION_DENIED") // runtimeStart重签普通权限,审计权限不能随读取继承。
|
||||
|
||||
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)
|
||||
_, err = signals.ListOperations(ctx, f.token, other.ID, sent[0], service.HistoryPageInput{})
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
_, err = signals.GetOperation(ctx, f.token, other.ID, sent[0], audit.ID)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkflowHistoryCursorScopeAndWidth(t *testing.T) {
|
||||
ns, id, step := executionID(t), executionID(t), executionID(t)
|
||||
for _, kind := range []string{"workflow-activations", "workflow-rounds", "attempts"} {
|
||||
cursor := historyCursor{Version: 1, Kind: kind, Namespace: ns, Task: id, Attempt: step, Epoch: 1<<32 - 1}
|
||||
raw, _ := encodeHistoryCursor(cursor)
|
||||
if _, err := decodeHistoryCursorKind(raw, ns, id, step, kind); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := decodeHistoryCursorKind(raw, ns, id, executionID(t), kind); err == nil {
|
||||
t.Fatal("cursor crossed child")
|
||||
}
|
||||
cursor.Epoch = 1 << 32
|
||||
raw, _ = encodeHistoryCursor(cursor)
|
||||
_, err := decodeHistoryCursorKind(raw, ns, id, step, kind)
|
||||
if (err == nil) != (kind == "attempts") {
|
||||
t.Fatal("sequence width or legacy epoch changed", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// 用真实v22结构验证历史索引升级,逐字段核对信号未被改写。
|
||||
func TestIntegrationWorkflowHistoryUpgrade(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_history_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 > 22 {
|
||||
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, workflow, id := executionID(t), executionID(t), executionID(t)
|
||||
exec(`INSERT INTO weave_signals(namespace_id,id,workflow_id,key_digest,request_digest,type,target_step_key,correlation_key,payload,actor_id,state,accepted_at,expires_at,available_at,disposition) VALUES (?,?,?,UNHEX(SHA2('k',256)),UNHEX(SHA2('r',256)),'external.done','signal','关联尾部 ','{"n":9007199254740993}','legacy','REJECTED',UTC_TIMESTAMP(6),DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR),UTC_TIMESTAMP(6),'LEGACY_UNVERIFIED')`, ns, id, workflow)
|
||||
snapshot := func() []sql.NullString {
|
||||
t.Helper()
|
||||
rows, err := db.QueryContext(ctx, `SELECT * FROM weave_signals WHERE namespace_id=? AND id=?`, ns, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rows.Next() {
|
||||
t.Fatal("signal missing")
|
||||
}
|
||||
out := make([]sql.NullString, len(cols))
|
||||
args := make([]any, len(cols))
|
||||
for i := range out {
|
||||
args[i] = &out[i]
|
||||
}
|
||||
if err = rows.Scan(args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
before := snapshot()
|
||||
if err = Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot()) {
|
||||
t.Fatal("history index changed signal fields")
|
||||
}
|
||||
if err = CheckSchema(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var index 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='weave_signals' AND INDEX_NAME='ix_signal_history' AND NON_UNIQUE=1`).Scan(&index); err != nil || index != "namespace_id,workflow_id,accepted_at,id" {
|
||||
t.Fatal("history index", index, err)
|
||||
}
|
||||
var fks int
|
||||
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&fks); err != nil || fks != 0 {
|
||||
t.Fatal("foreign keys", fks, err)
|
||||
}
|
||||
if err = Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Log("v22 upgraded; signal values, precision, legacy identity and timestamps unchanged; history index installed; foreign keys=0")
|
||||
}
|
||||
141
internal/storage/mysql/workflow_signal_history.go
Normal file
141
internal/storage/mysql/workflow_signal_history.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"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/signal"
|
||||
)
|
||||
|
||||
func (s *WorkflowSignalStore) List(ctx context.Context, t auth.Token, workflow core.ID, in service.HistoryPageInput) (signal.Page, error) {
|
||||
out := signal.Page{WorkflowID: workflow, Items: []signal.Summary{}}
|
||||
err := s.transaction(ctx, t, signal.ReadAction, func(tx *sql.Tx, p auth.Permissions) error {
|
||||
v, _, _, err := readWorkflowForQuery(ctx, tx, t.NamespaceID, workflow, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Revision = v.Revision
|
||||
q := `SELECT id,workflow_id,revision,type,COALESCE(payload_version,''),target_step_key,COALESCE(target_activation_sequence,0),state,COALESCE(disposition,''),accepted_at,expires_at FROM weave_signals WHERE namespace_id=? AND workflow_id=?`
|
||||
args := []any{t.NamespaceID, workflow}
|
||||
if in.Cursor != "" {
|
||||
c, err := decodeHistoryCursorKind(in.Cursor, t.NamespaceID, workflow, "", "workflow-signals")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q += ` AND (accepted_at<? OR (accepted_at=? AND id<?))`
|
||||
args = append(args, *c.Created, *c.Created, c.ID)
|
||||
}
|
||||
q += ` ORDER BY accepted_at DESC,id DESC LIMIT ?`
|
||||
args = append(args, in.Limit+1)
|
||||
rows, err := tx.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var v signal.Summary
|
||||
if err = rows.Scan(&v.ID, &v.WorkflowID, &v.Revision, &v.Name, &v.PayloadVersion, &v.TargetStepKey, &v.TargetActivationSequence, &v.State, &v.Disposition, &v.AcceptedAt, &v.ExpiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, v)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(out.Items) > in.Limit {
|
||||
out.Items = out.Items[:in.Limit]
|
||||
last := out.Items[len(out.Items)-1]
|
||||
out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "workflow-signals", Namespace: t.NamespaceID, Task: workflow, Created: &last.AcceptedAt, ID: last.ID})
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return signal.Page{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// authorizeSignalAudit 先授权固定流程全部队列,再验证信号属于该实例,不通过审计ID跨资源查找。
|
||||
func authorizeSignalAudit(ctx context.Context, tx *sql.Tx, ns, workflow, id core.ID, p auth.Permissions) error {
|
||||
if _, _, _, err := readWorkflowForQuery(ctx, tx, ns, workflow, p); err != nil {
|
||||
return err
|
||||
}
|
||||
var found core.ID
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM weave_signals WHERE namespace_id=? AND workflow_id=? AND id=?`, ns, workflow, id).Scan(&found)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
const signalOperationColumns = `id,resource_id,operation_id,actor_id,action,created_at`
|
||||
|
||||
func (s *WorkflowSignalStore) ListOperations(ctx context.Context, t auth.Token, workflow, id core.ID, in service.HistoryPageInput) (signal.OperationPage, error) {
|
||||
out := signal.OperationPage{Items: []signal.OperationSummary{}}
|
||||
err := s.transaction(ctx, t, signal.AuditAction, func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if err := authorizeSignalAudit(ctx, tx, t.NamespaceID, workflow, id, p); err != nil {
|
||||
return err
|
||||
}
|
||||
q := `SELECT ` + signalOperationColumns + ` FROM orbit_audit_events WHERE namespace_id=? AND resource_kind='workflow_signal' AND resource_id=?`
|
||||
args := []any{t.NamespaceID, id}
|
||||
if in.Cursor != "" {
|
||||
c, err := decodeHistoryCursorKind(in.Cursor, t.NamespaceID, workflow, id, "signal-operations")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q += ` AND (created_at<? OR (created_at=? AND id<?))`
|
||||
args = append(args, *c.Created, *c.Created, c.ID)
|
||||
}
|
||||
q += ` ORDER BY created_at DESC,id DESC LIMIT ?`
|
||||
args = append(args, in.Limit+1)
|
||||
rows, err := tx.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
v := signal.OperationSummary{WorkflowID: workflow}
|
||||
if err = rows.Scan(&v.ID, &v.SignalID, &v.OperationID, &v.ActorID, &v.Action, &v.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, v)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(out.Items) > in.Limit {
|
||||
out.Items = out.Items[:in.Limit]
|
||||
last := out.Items[len(out.Items)-1]
|
||||
out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "signal-operations", Namespace: t.NamespaceID, Task: workflow, Attempt: id, Created: &last.CreatedAt, ID: last.ID})
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return signal.OperationPage{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *WorkflowSignalStore) GetOperation(ctx context.Context, t auth.Token, workflow, id, operation core.ID) (signal.OperationView, error) {
|
||||
out := signal.OperationView{OperationSummary: signal.OperationSummary{WorkflowID: workflow}}
|
||||
err := s.transaction(ctx, t, signal.AuditAction, func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if err := authorizeSignalAudit(ctx, tx, t.NamespaceID, workflow, id, p); err != nil {
|
||||
return err
|
||||
}
|
||||
var before, after []byte
|
||||
err := tx.QueryRowContext(ctx, `SELECT `+signalOperationColumns+",reason,COALESCE(evidence_ref,''),`before`,`after` FROM orbit_audit_events WHERE namespace_id=? AND resource_kind='workflow_signal' AND resource_id=? AND id=?", t.NamespaceID, id, operation).Scan(&out.ID, &out.SignalID, &out.OperationID, &out.ActorID, &out.Action, &out.CreatedAt, &out.Reason, &out.EvidenceRef, &before, &after)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
out.Before, out.After = before, after
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return signal.OperationView{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
58
internal/transport/httpserver/workflow_history.go
Normal file
58
internal/transport/httpserver/workflow_history.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/instance"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/signal"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func registerWorkflowHistory(mux *http.ServeMux, instances *instance.Service, signals *signal.Service) {
|
||||
mux.HandleFunc("GET /v1/workflows/{id}/steps/{key}/activations", 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 := instances.ListActivations(r.Context(), t, core.ID(r.PathValue("id")), r.PathValue("key"), in)
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/workflows/{id}/steps/{key}/activations/{activation}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if _, err := historyQuery(r, false); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := instances.GetActivation(r.Context(), t, core.ID(r.PathValue("id")), r.PathValue("key"), core.ID(r.PathValue("activation")))
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/workflows/{id}/steps/{key}/activations/{activation}/rounds", 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 := instances.ListPollRounds(r.Context(), t, core.ID(r.PathValue("id")), r.PathValue("key"), core.ID(r.PathValue("activation")), in)
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/workflows/{id}/signals", 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 := signals.List(r.Context(), t, core.ID(r.PathValue("id")), in)
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/workflows/{id}/signals/{signal}/operations", 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 := signals.ListOperations(r.Context(), t, core.ID(r.PathValue("id")), core.ID(r.PathValue("signal")), in)
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/workflows/{id}/signals/{signal}/operations/{operation}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if _, err := historyQuery(r, false); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := signals.GetOperation(r.Context(), t, core.ID(r.PathValue("id")), core.ID(r.PathValue("signal")), core.ID(r.PathValue("operation")))
|
||||
return out, 200, err
|
||||
}))
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
// RegisterWorkflows 只适配协议,实际授权、并发版本、原键回执及审计由事务服务校验。
|
||||
func RegisterWorkflows(server *http.Server, instances *instance.Service, controls *control.Service, signals *signal.Service, reviews *recovery.Service) {
|
||||
mux := server.Handler.(*http.ServeMux)
|
||||
registerWorkflowHistory(mux, instances, signals)
|
||||
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
|
||||
|
||||
@@ -167,6 +167,29 @@ func run() error {
|
||||
return errors.New("incomplete workflow task sources")
|
||||
}
|
||||
fmt.Printf("workflow_sources_verified=%d fixed_version=%s\n", verified, accepted.Instance.Version)
|
||||
history, err := client.ListWorkflowActivations(ctx, accepted.Instance.ID, "receipt", orbit.HistoryPageRequest{Limit: 20})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(history.Items) != 1 {
|
||||
return errors.New("unexpected receipt activation history")
|
||||
}
|
||||
historical, err := client.GetWorkflowActivation(ctx, accepted.Instance.ID, "receipt", history.Items[0].ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if historical.Wait == nil || historical.Wait.WinnerSignalID == nil || *historical.Wait.WinnerSignalID != sent.Signal.ID {
|
||||
return errors.New("historical signal winner missing")
|
||||
}
|
||||
signals, err := client.ListWorkflowSignals(ctx, accepted.Instance.ID, orbit.HistoryPageRequest{Limit: 20})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(signals.Items) != 1 || signals.Items[0].State != "CONSUMED" || signals.Items[0].ID != sent.Signal.ID {
|
||||
return errors.New("signal history missing")
|
||||
}
|
||||
fmt.Println("workflow_history_verified=true signal_state=CONSUMED")
|
||||
|
||||
stop()
|
||||
err = <-done
|
||||
waited = true
|
||||
|
||||
173
sdk/go/workflow_history.go
Normal file
173
sdk/go/workflow_history.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func workflowActivationPath(id, key string, activation ...string) (string, error) {
|
||||
if !validID(id) || !workflowVersionName(key) {
|
||||
return "", errors.New("invalid workflow step identity")
|
||||
}
|
||||
path := "/v1/workflows/" + id + "/steps/" + key + "/activations"
|
||||
for _, v := range activation {
|
||||
if !validID(v) {
|
||||
return "", errors.New("invalid activation id")
|
||||
}
|
||||
path += "/" + v
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
func workflowSignalPath(id string, signal ...string) (string, error) {
|
||||
if !validID(id) {
|
||||
return "", errors.New("invalid workflow id")
|
||||
}
|
||||
path := "/v1/workflows/" + id + "/signals"
|
||||
for _, v := range signal {
|
||||
if !validID(v) {
|
||||
return "", errors.New("invalid signal id")
|
||||
}
|
||||
path += "/" + v
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
func historyResponseSize(n int, in HistoryPageRequest) bool {
|
||||
limit := in.Limit
|
||||
if limit == 0 {
|
||||
limit = 20
|
||||
}
|
||||
return n <= limit
|
||||
}
|
||||
|
||||
// ListWorkflowActivations 返回指定逻辑步骤的各次激活摘要,代次倒序;跨页状态可继续变化。
|
||||
func (c *Client) ListWorkflowActivations(ctx context.Context, id, key string, in HistoryPageRequest) (WorkflowActivationPage, error) {
|
||||
var out WorkflowActivationPage
|
||||
path, err := workflowActivationPath(id, key)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
query, err := historyPageQuery(in)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = c.call(ctx, "GET", path+query, "", nil, &out); err != nil {
|
||||
return WorkflowActivationPage{}, err
|
||||
}
|
||||
if out.InstanceID != id || !validID(out.StepID) || out.Revision == 0 || out.Items == nil || !historyResponseSize(len(out.Items), in) {
|
||||
return WorkflowActivationPage{}, errors.New("invalid activation page")
|
||||
}
|
||||
for i, a := range out.Items {
|
||||
if !validID(a.ID) || a.StepID != out.StepID || a.Sequence == 0 || a.Revision == 0 || i > 0 && a.Sequence >= out.Items[i-1].Sequence {
|
||||
return WorkflowActivationPage{}, errors.New("invalid activation summary")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetWorkflowActivation 明确读取给定历史激活,Step仍为逻辑步骤当前视图,不能用它替代历史Activation。
|
||||
func (c *Client) GetWorkflowActivation(ctx context.Context, id, key, activation string) (WorkflowActivationDetail, error) {
|
||||
var out WorkflowActivationDetail
|
||||
path, err := workflowActivationPath(id, key, activation)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = c.call(ctx, "GET", path, "", nil, &out); err != nil {
|
||||
return WorkflowActivationDetail{}, err
|
||||
}
|
||||
if out.InstanceID != id || out.Step.Key != key || out.Activation.ID != activation || !validID(out.Step.ID) || out.Activation.StepID != out.Step.ID || out.Activation.Sequence == 0 || out.Revision == 0 {
|
||||
return WorkflowActivationDetail{}, errors.New("activation detail identity mismatch")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListWorkflowPollRounds 只读取一个激活内的查询轮次;人工恢复的同一业务轮可分属不同激活。
|
||||
func (c *Client) ListWorkflowPollRounds(ctx context.Context, id, key, activation string, in HistoryPageRequest) (WorkflowPollRoundPage, error) {
|
||||
var out WorkflowPollRoundPage
|
||||
path, err := workflowActivationPath(id, key, activation)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
query, err := historyPageQuery(in)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = c.call(ctx, "GET", path+"/rounds"+query, "", nil, &out); err != nil {
|
||||
return WorkflowPollRoundPage{}, err
|
||||
}
|
||||
if out.InstanceID != id || out.ActivationID != activation || out.Revision == 0 || out.Items == nil || !historyResponseSize(len(out.Items), in) {
|
||||
return WorkflowPollRoundPage{}, errors.New("invalid poll round page")
|
||||
}
|
||||
for i, r := range out.Items {
|
||||
if !validID(r.ID) || !validID(r.TaskID) || r.ActivationID != activation || r.Round == 0 || i > 0 && r.Round >= out.Items[i-1].Round {
|
||||
return WorkflowPollRoundPage{}, errors.New("invalid poll round summary")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListWorkflowSignals 不携带信号正文;单条原始内容用GetWorkflowSignal读取。
|
||||
func (c *Client) ListWorkflowSignals(ctx context.Context, id string, in HistoryPageRequest) (WorkflowSignalPage, error) {
|
||||
var out WorkflowSignalPage
|
||||
path, err := workflowSignalPath(id)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
query, err := historyPageQuery(in)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = c.call(ctx, "GET", path+query, "", nil, &out); err != nil {
|
||||
return WorkflowSignalPage{}, err
|
||||
}
|
||||
if out.WorkflowID != id || out.Revision == 0 || out.Items == nil || !historyResponseSize(len(out.Items), in) {
|
||||
return WorkflowSignalPage{}, errors.New("invalid signal page")
|
||||
}
|
||||
for _, v := range out.Items {
|
||||
if !validID(v.ID) || v.WorkflowID != id {
|
||||
return WorkflowSignalPage{}, errors.New("signal summary identity mismatch")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListWorkflowSignalOperations 需要独立workflows:audit:read权限,只返回信号资源的操作审计摘要。
|
||||
func (c *Client) ListWorkflowSignalOperations(ctx context.Context, id, signal string, in HistoryPageRequest) (WorkflowSignalOperationPage, error) {
|
||||
var out WorkflowSignalOperationPage
|
||||
path, err := workflowSignalPath(id, signal)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
query, err := historyPageQuery(in)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = c.call(ctx, "GET", path+"/operations"+query, "", nil, &out); err != nil {
|
||||
return WorkflowSignalOperationPage{}, err
|
||||
}
|
||||
if out.Items == nil || !historyResponseSize(len(out.Items), in) {
|
||||
return WorkflowSignalOperationPage{}, errors.New("invalid signal operation page")
|
||||
}
|
||||
for _, v := range out.Items {
|
||||
if !validID(v.ID) || v.WorkflowID != id || v.SignalID != signal {
|
||||
return WorkflowSignalOperationPage{}, errors.New("signal operation identity mismatch")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (c *Client) GetWorkflowSignalOperation(ctx context.Context, id, signal, operation string) (WorkflowSignalOperation, error) {
|
||||
var out WorkflowSignalOperation
|
||||
path, err := workflowSignalPath(id, signal)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if !validID(operation) {
|
||||
return out, errors.New("invalid signal operation id")
|
||||
}
|
||||
if err = c.call(ctx, "GET", path+"/operations/"+operation, "", nil, &out); err != nil {
|
||||
return WorkflowSignalOperation{}, err
|
||||
}
|
||||
if out.ID != operation || out.WorkflowID != id || out.SignalID != signal {
|
||||
return WorkflowSignalOperation{}, errors.New("signal operation detail identity mismatch")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
89
sdk/go/workflow_history_test.go
Normal file
89
sdk/go/workflow_history_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkflowHistoryIdentityBoundsAndResponseValidation(t *testing.T) {
|
||||
const id = "11111111111111111111111111111111"
|
||||
const child = "22222222222222222222222222222222"
|
||||
ctx := context.Background()
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.Method != "GET" || r.Header.Get("Idempotency-Key") != "" || r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
t.Error("invalid read request")
|
||||
}
|
||||
if r.URL.Query().Get("cursor") != "opaque+cursor/=" || r.URL.Query().Get("limit") != "1" {
|
||||
t.Error("query changed")
|
||||
}
|
||||
// 合法响应信封中的错误父资源,SDK不得将其作为请求资源的数据返回。
|
||||
json.NewEncoder(w).Encode(map[string]any{"request_id": id, "data": map[string]any{"instance_id": child, "workflow_id": child, "step_id": child, "revision": "1", "items": []any{}}})
|
||||
}))
|
||||
defer server.Close()
|
||||
c, err := NewClient(server.URL, Options{Token: "test-token"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, bad := range []string{"../other", strings.Repeat("A", 32), "a?x=b"} {
|
||||
if _, err = c.ListWorkflowActivations(ctx, bad, "work", HistoryPageRequest{}); err == nil {
|
||||
t.Fatal("bad workflow accepted")
|
||||
}
|
||||
if _, err = c.GetWorkflowActivation(ctx, id, "work", bad); err == nil {
|
||||
t.Fatal("bad activation accepted")
|
||||
}
|
||||
if _, err = c.ListWorkflowPollRounds(ctx, id, "work", bad, HistoryPageRequest{}); err == nil {
|
||||
t.Fatal("bad round scope accepted")
|
||||
}
|
||||
if _, err = c.ListWorkflowSignals(ctx, bad, HistoryPageRequest{}); err == nil {
|
||||
t.Fatal("bad signal parent accepted")
|
||||
}
|
||||
if _, err = c.ListWorkflowSignalOperations(ctx, id, bad, HistoryPageRequest{}); err == nil {
|
||||
t.Fatal("bad signal accepted")
|
||||
}
|
||||
if _, err = c.GetWorkflowSignalOperation(ctx, id, child, bad); err == nil {
|
||||
t.Fatal("bad operation accepted")
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"$root", "../other", "", strings.Repeat("x", 65)} {
|
||||
if _, err = c.ListWorkflowActivations(ctx, id, key, HistoryPageRequest{}); err == nil {
|
||||
t.Fatal("bad key accepted")
|
||||
}
|
||||
}
|
||||
for _, in := range []HistoryPageRequest{{Limit: -1}, {Limit: 101}, {Cursor: strings.Repeat("x", 1025)}} {
|
||||
if _, err = c.ListWorkflowActivations(ctx, id, "work", in); err == nil {
|
||||
t.Fatal("bad activation page")
|
||||
}
|
||||
if _, err = c.ListWorkflowPollRounds(ctx, id, "work", child, in); err == nil {
|
||||
t.Fatal("bad round page")
|
||||
}
|
||||
if _, err = c.ListWorkflowSignals(ctx, id, in); err == nil {
|
||||
t.Fatal("bad signal page")
|
||||
}
|
||||
if _, err = c.ListWorkflowSignalOperations(ctx, id, child, in); err == nil {
|
||||
t.Fatal("bad operation page")
|
||||
}
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatal("invalid arguments reached transport")
|
||||
}
|
||||
page := HistoryPageRequest{Limit: 1, Cursor: "opaque+cursor/="}
|
||||
if _, err = c.ListWorkflowActivations(ctx, id, "work", page); err == nil {
|
||||
t.Fatal("foreign activation page accepted")
|
||||
}
|
||||
if _, err = c.ListWorkflowPollRounds(ctx, id, "work", child, page); err == nil {
|
||||
t.Fatal("foreign round page accepted")
|
||||
}
|
||||
if _, err = c.ListWorkflowSignals(ctx, id, page); err == nil {
|
||||
t.Fatal("foreign signal page accepted")
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatal("unexpected automatic read retry")
|
||||
}
|
||||
}
|
||||
92
sdk/go/workflow_history_types.go
Normal file
92
sdk/go/workflow_history_types.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkflowActivationSummary 描述指定历史激活的当前记录,不携带大输入/结果或执行凭据。
|
||||
type WorkflowActivationSummary struct {
|
||||
ID string `json:"id"` // ID 激活身份,人工恢复时新建且保留原记录。
|
||||
StepID string `json:"step_id"` // StepID 本激活所属逻辑步骤。
|
||||
Sequence uint32 `json:"sequence"` // Sequence 该步骤的激活代次,和POLL业务轮次分开。
|
||||
State string `json:"state"` // State 此激活状态,不代表实例当前状态。
|
||||
Revision uint64 `json:"revision,string"` // Revision 此激活记录的版本,保留整数精度。
|
||||
TaskID *string `json:"task_id,omitempty"` // TaskID TASK的直接任务;POLL从轮次列表查询。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 激活创建时间,不代表恢复获得新截止。
|
||||
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 此记录最后变更时间。
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"` // CompletedAt 此激活已保存的结束时间;未记录时省略。
|
||||
}
|
||||
type WorkflowActivationPage struct {
|
||||
InstanceID string `json:"instance_id"` // InstanceID 已授权的所属实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本;跨页不保证同一快照。
|
||||
StepID string `json:"step_id"` // StepID 已核验的逻辑步骤。
|
||||
CurrentActivationID *string `json:"current_activation_id,omitempty"` // CurrentActivationID 本页读取时的当前激活,未激活时省略。
|
||||
Items []WorkflowActivationSummary `json:"items"` // Items 按代次倒序的有界摘要,空页为[]。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、步骤和查询类型的后续位置。
|
||||
}
|
||||
type WorkflowActivationDetail struct {
|
||||
InstanceID string `json:"instance_id"` // InstanceID 所属实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 读取详情时的实例版本。
|
||||
Step WorkflowStepStatus `json:"step"` // Step 逻辑步骤的当前视图,不能替代Activation中的历史代次。
|
||||
Activation WorkflowActivationSummary `json:"activation"` // Activation 指定的历史激活,不自动改为当前激活。
|
||||
Input json.RawMessage `json:"input"` // Input 此激活原输入,显式null保留。
|
||||
Output json.RawMessage `json:"output,omitempty"` // Output 此激活当前保存的结果,缺失与显式null不同。
|
||||
Poll *WorkflowPollStatus `json:"poll,omitempty"` // Poll 此激活的查询预算及最后任务,只用于POLL。
|
||||
Wait *WorkflowWaitStatus `json:"wait,omitempty"` // Wait 此激活的等待记录,恢复不修改旧记录的截止。
|
||||
}
|
||||
type WorkflowPollRoundSummary struct {
|
||||
ID string `json:"id"` // ID 该任务唯一对应的轮次记录。
|
||||
ActivationID string `json:"activation_id"` // ActivationID 产生本次查询的激活。
|
||||
Round uint32 `json:"round"` // Round 从首次激活累计的业务轮编号;恢复同轮不重置。
|
||||
TaskID string `json:"task_id"` // TaskID 该轮Pulse任务,尝试和结果由任务接口读取。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 本次轮次记录创建时间。
|
||||
}
|
||||
type WorkflowPollRoundPage struct {
|
||||
InstanceID string `json:"instance_id"` // InstanceID 所属实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本。
|
||||
ActivationID string `json:"activation_id"` // ActivationID 明确指定的历史激活。
|
||||
Items []WorkflowPollRoundSummary `json:"items"` // Items 本激活内按业务轮倒序的摘要,空页为[]。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、激活及查询类型的后续位置。
|
||||
}
|
||||
|
||||
// WorkflowSignalSummary 不聚合信号正文、关联键或证据;单条Get用于读取完整原始信号。
|
||||
type WorkflowSignalSummary struct {
|
||||
ID string `json:"id"` // ID 持久信号身份。
|
||||
WorkflowID string `json:"workflow_id"` // WorkflowID 所属实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 该信号当前记录版本。
|
||||
Name string `json:"name"` // Name 原信号类型名称。
|
||||
PayloadVersion string `json:"payload_version"` // PayloadVersion 固定内容契约;未核验旧数据可能为空。
|
||||
TargetStepKey string `json:"target_step_key"` // TargetStepKey 原目标步骤名称,不跟随当前步骤变更。
|
||||
TargetActivationSequence uint32 `json:"target_activation_sequence"` // TargetActivationSequence 原指定代次,未核验旧数据可能为0。
|
||||
State string `json:"state"` // State 当前PENDING、CONSUMED、EXPIRED或REJECTED。
|
||||
Disposition string `json:"disposition,omitempty"` // Disposition 已记录的消费或拒绝原因。
|
||||
AcceptedAt time.Time `json:"accepted_at"` // AcceptedAt 原受理时间,也是分页排序依据。
|
||||
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 原固定截止,不因读取或重报延长。
|
||||
}
|
||||
type WorkflowSignalPage struct {
|
||||
WorkflowID string `json:"workflow_id"` // WorkflowID 已授权实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本;跨页状态可变化。
|
||||
Items []WorkflowSignalSummary `json:"items"` // Items 按受理时间及ID倒序的摘要,空页为[]。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定命名空间及实例的后续位置。
|
||||
}
|
||||
type WorkflowSignalOperationSummary struct {
|
||||
ID string `json:"id"` // ID 不可改写的操作审计身份。
|
||||
WorkflowID string `json:"workflow_id"` // WorkflowID 经信号归属验证的实例。
|
||||
SignalID string `json:"signal_id"` // SignalID 本次操作对应的信号资源。
|
||||
OperationID string `json:"operation_id"` // OperationID 服务端操作身份,不是原始幂等键。
|
||||
ActorID string `json:"actor_id"` // ActorID 已认证的实际操作主体。
|
||||
Action string `json:"action"` // Action 操作种类,如workflow.signal。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 原操作创建时间。
|
||||
}
|
||||
type WorkflowSignalOperation struct {
|
||||
WorkflowSignalOperationSummary
|
||||
Reason string `json:"reason"` // Reason 原操作原因。
|
||||
EvidenceRef string `json:"evidence_ref,omitempty"` // EvidenceRef 原证据引用,不保存外部秘密。
|
||||
Before json.RawMessage `json:"before"` // Before 操作前摘要,首次受理可为null。
|
||||
After json.RawMessage `json:"after"` // After 原受理身份、摘要、时间和当时状态,不替代当前消费状态。
|
||||
}
|
||||
type WorkflowSignalOperationPage struct {
|
||||
Items []WorkflowSignalOperationSummary `json:"items"` // Items 按创建时间和ID倒序的有界审计摘要。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、信号及查询类型的后续位置。
|
||||
}
|
||||
@@ -202,6 +202,13 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
t.Fatal("start receipt", err)
|
||||
}
|
||||
id := started.Instance.ID
|
||||
|
||||
// 所有新增历史路由必须拒绝未声明参数,不能悄悄扩大读取范围。
|
||||
for _, suffix := range []string{"/steps/work/activations", "/steps/work/activations/" + id, "/steps/work/activations/" + id + "/rounds", "/signals", "/signals/" + id + "/operations", "/signals/" + id + "/operations/" + id} {
|
||||
var unused any
|
||||
err := api.call(ctx, "GET", "/v1/workflows/"+id+suffix+"?unknown=1", "", nil, &unused)
|
||||
workflowAPIError(t, err, "INVALID_ARGUMENT")
|
||||
}
|
||||
before, err := api.GetWorkflowSteps(ctx, id)
|
||||
if err != nil || len(before.Steps) != 4 {
|
||||
t.Fatal(err)
|
||||
@@ -256,6 +263,27 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
if err != nil || receipt.State != "CONSUMED" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
signals, err := api.ListWorkflowSignals(ctx, id, HistoryPageRequest{Limit: 1})
|
||||
if err != nil || len(signals.Items) != 1 || signals.Items[0].ID != accepted.Signal.ID || signals.Items[0].State != "CONSUMED" {
|
||||
t.Fatal("signal history", err)
|
||||
}
|
||||
operations, err := api.ListWorkflowSignalOperations(ctx, id, accepted.Signal.ID, HistoryPageRequest{Limit: 1})
|
||||
if err != nil || len(operations.Items) != 1 {
|
||||
t.Fatal("signal operations", err)
|
||||
}
|
||||
operation, err := api.GetWorkflowSignalOperation(ctx, id, accepted.Signal.ID, operations.Items[0].ID)
|
||||
if err != nil || operation.Reason != signal.Reason || operation.Action != "workflow.signal" {
|
||||
t.Fatal("signal operation detail", err)
|
||||
}
|
||||
historical, err := api.GetWorkflowStep(ctx, id, "receipt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitHistory, err := api.GetWorkflowActivation(ctx, id, "receipt", *historical.Step.ActivationID)
|
||||
if err != nil || waitHistory.Wait.WinnerSignalID == nil || *waitHistory.Wait.WinnerSignalID != accepted.Signal.ID {
|
||||
t.Fatal("historical winner", err)
|
||||
}
|
||||
old, err := api.StartWorkflow(ctx, key, in)
|
||||
if err != nil || old.Instance.Revision != started.Instance.Revision || old.Instance.DeadlineAt != started.Instance.DeadlineAt {
|
||||
t.Fatal("start receipt changed", err)
|
||||
@@ -344,6 +372,12 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
if err != nil || current.Wait.DeadlineAt != old.Wait.DeadlineAt {
|
||||
t.Fatal("wait extended", err)
|
||||
}
|
||||
|
||||
priorWait, err := api.GetWorkflowActivation(ctx, v.ID, "receipt", *old.Step.ActivationID)
|
||||
if err != nil || priorWait.Activation.Sequence != 1 || priorWait.Step.ActivationSequence != 2 || priorWait.Wait.DeadlineAt != old.Wait.DeadlineAt || priorWait.Wait.ID != old.Wait.ID {
|
||||
t.Fatal("wait history retargeted", err)
|
||||
}
|
||||
assertTwoActivationPagesSDK(t, ctx, api, v.ID, "receipt", *old.Step.ActivationID, *restored.Step.ActivationID)
|
||||
stale, err := api.SendWorkflowSignal(ctx, v.ID, workerTestID(t), SendWorkflowSignalRequest{Name: "done", PayloadVersion: "v1", TargetStepKey: "receipt", TargetActivationSequence: 1, CorrelationKey: "shared", Payload: workflowExact, Reason: "late prior generation"})
|
||||
if err != nil || stale.Signal.State != "REJECTED" {
|
||||
t.Fatal("old signal not fenced", err)
|
||||
@@ -362,6 +396,12 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
if err != nil || !repeated.Deduplicated || repeated.Task.ID != child.Task.ID {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertTwoActivationPagesSDK(t, ctx, api, v.ID, "work", *step.Step.ActivationID, *child.Step.ActivationID)
|
||||
oldTask, err := api.GetWorkflowActivation(ctx, v.ID, "work", *step.Step.ActivationID)
|
||||
if err != nil || oldTask.Activation.TaskID == nil || *oldTask.Activation.TaskID != *child.Task.RetryOf || oldTask.Activation.State != "FAILED" {
|
||||
t.Fatal("old task activation", err)
|
||||
}
|
||||
assertWorkflowSourceSDK(t, ctx, api, v, child.Task.ID, 2, 0, *child.Task.RetryOf)
|
||||
assertWorkflowSourceSDK(t, ctx, api, v, *child.Task.RetryOf, 1, 0, "")
|
||||
continueWorkflowSDK(t, ctx, api, v.ID)
|
||||
@@ -414,6 +454,15 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
if err != nil || final.Poll.UsedRounds != 2 || final.Poll.DeadlineAt != step.Poll.DeadlineAt {
|
||||
t.Fatal("poll budget changed", err)
|
||||
}
|
||||
roundPage, err := api.ListWorkflowPollRounds(ctx, v.ID, "query", *final.Step.ActivationID, HistoryPageRequest{Limit: 1})
|
||||
if err != nil || len(roundPage.Items) != 1 || roundPage.Items[0].Round != 2 || roundPage.Items[0].TaskID != last.Task.ID || roundPage.NextCursor == "" {
|
||||
t.Fatal("round page", err)
|
||||
}
|
||||
priorRound, err := api.ListWorkflowPollRounds(ctx, v.ID, "query", *final.Step.ActivationID, HistoryPageRequest{Limit: 1, Cursor: roundPage.NextCursor})
|
||||
if err != nil || len(priorRound.Items) != 1 || priorRound.Items[0].Round != 1 || priorRound.Items[0].TaskID != source.ID || priorRound.NextCursor != "" {
|
||||
t.Fatal("prior round", err)
|
||||
}
|
||||
|
||||
})
|
||||
t.Run("poll_recovery_source_keeps_business_round", func(t *testing.T) {
|
||||
spec := workflowSDKSpec(queue, handler)
|
||||
@@ -434,9 +483,31 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
continueWorkflowSDK(t, ctx, api, v.ID)
|
||||
finishWorkflowSDK(t, ctx, w, WorkerSucceeded, json.RawMessage(`"DONE"`))
|
||||
waitWorkflowSDK(t, ctx, api, v.ID, "SUCCEEDED")
|
||||
|
||||
assertTwoActivationPagesSDK(t, ctx, api, v.ID, "query", *step.Step.ActivationID, *child.Step.ActivationID)
|
||||
for _, item := range []struct{ activation, task string }{{*step.Step.ActivationID, old.Task.ID}, {*child.Step.ActivationID, child.Task.ID}} {
|
||||
rounds, err := api.ListWorkflowPollRounds(ctx, v.ID, "query", item.activation, HistoryPageRequest{})
|
||||
if err != nil || len(rounds.Items) != 1 || rounds.Items[0].Round != 1 || rounds.Items[0].TaskID != item.task {
|
||||
t.Fatal("recovery same business round", err)
|
||||
}
|
||||
}
|
||||
assertWorkflowSourceSDK(t, ctx, api, v, child.Task.ID, 2, 1, old.Task.ID)
|
||||
})
|
||||
if !t.Failed() {
|
||||
t.Log("public workflow start/query/control/signal, all six recovery commands, audit and real Worker execution verified without server imports")
|
||||
}
|
||||
}
|
||||
|
||||
func assertTwoActivationPagesSDK(t *testing.T, ctx context.Context, api *Client, id, key, old, current string) {
|
||||
t.Helper()
|
||||
first, err := api.ListWorkflowActivations(ctx, id, key, HistoryPageRequest{Limit: 1})
|
||||
if err != nil || len(first.Items) != 1 || first.Items[0].ID != current || first.Items[0].Sequence != 2 || first.NextCursor == "" {
|
||||
t.Fatal("current activation page", err)
|
||||
}
|
||||
second, err := api.ListWorkflowActivations(ctx, id, key, HistoryPageRequest{Limit: 1, Cursor: first.NextCursor})
|
||||
if err != nil || len(second.Items) != 1 || second.Items[0].ID != old || second.Items[0].Sequence != 1 || second.NextCursor != "" {
|
||||
t.Fatal("old activation page", err)
|
||||
}
|
||||
_, err = api.ListWorkflowSignals(ctx, id, HistoryPageRequest{Cursor: first.NextCursor})
|
||||
workflowAPIError(t, err, "INVALID_ARGUMENT")
|
||||
}
|
||||
|
||||
93
weave/instance/history.go
Normal file
93
weave/instance/history.go
Normal file
@@ -0,0 +1,93 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// ActivationSummary 描述指定历史激活的当前记录,不携带大输入/结果或执行凭据。
|
||||
type ActivationSummary struct {
|
||||
ID core.ID `json:"id"` // ID 激活身份,人工恢复时新建且保留原记录。
|
||||
StepID core.ID `json:"step_id"` // StepID 本激活所属逻辑步骤。
|
||||
Sequence uint32 `json:"sequence"` // Sequence 该步骤的激活代次,和POLL业务轮次分开。
|
||||
State string `json:"state"` // State 此激活状态,不代表实例当前状态。
|
||||
Revision uint64 `json:"revision,string"` // Revision 此激活记录的版本,保留整数精度。
|
||||
TaskID *core.ID `json:"task_id,omitempty"` // TaskID TASK的直接任务;POLL从轮次列表查询。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 激活创建时间,不代表恢复获得新截止。
|
||||
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 此记录最后变更时间。
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"` // CompletedAt 此激活已保存的结束时间;未记录时省略。
|
||||
}
|
||||
type ActivationPage struct {
|
||||
InstanceID core.ID `json:"instance_id"` // InstanceID 已授权的所属实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本;跨页不保证同一快照。
|
||||
StepID core.ID `json:"step_id"` // StepID 已核验的逻辑步骤。
|
||||
CurrentActivationID *core.ID `json:"current_activation_id,omitempty"` // CurrentActivationID 本页读取时的当前激活,未激活时省略。
|
||||
Items []ActivationSummary `json:"items"` // Items 按代次倒序的有界摘要,空页为[]。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、步骤和查询类型的后续位置。
|
||||
}
|
||||
type ActivationDetail struct {
|
||||
InstanceID core.ID `json:"instance_id"` // InstanceID 所属实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 读取详情时的实例版本。
|
||||
Step StepView `json:"step"` // Step 逻辑步骤的当前视图,不能替代Activation中的历史代次。
|
||||
Activation ActivationSummary `json:"activation"` // Activation 指定的历史激活,不自动改为当前激活。
|
||||
Input json.RawMessage `json:"input"` // Input 此激活原输入,显式null保留。
|
||||
Output json.RawMessage `json:"output,omitempty"` // Output 此激活当前保存的结果,缺失与显式null不同。
|
||||
Poll *PollView `json:"poll,omitempty"` // Poll 此激活的查询预算及最后任务,只用于POLL。
|
||||
Wait *WaitView `json:"wait,omitempty"` // Wait 此激活的等待记录,恢复不修改旧记录的截止。
|
||||
}
|
||||
type PollRoundSummary struct {
|
||||
ID core.ID `json:"id"` // ID 该任务唯一对应的轮次记录。
|
||||
ActivationID core.ID `json:"activation_id"` // ActivationID 产生本次查询的激活。
|
||||
Round uint32 `json:"round"` // Round 从首次激活累计的业务轮编号;恢复同轮不重置。
|
||||
TaskID core.ID `json:"task_id"` // TaskID 该轮Pulse任务,尝试和结果由任务接口读取。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 本次轮次记录创建时间。
|
||||
}
|
||||
type PollRoundPage struct {
|
||||
InstanceID core.ID `json:"instance_id"` // InstanceID 所属实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本。
|
||||
ActivationID core.ID `json:"activation_id"` // ActivationID 明确指定的历史激活。
|
||||
Items []PollRoundSummary `json:"items"` // Items 本激活内按业务轮倒序的摘要,空页为[]。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、激活及查询类型的后续位置。
|
||||
}
|
||||
|
||||
func historyPage(in service.HistoryPageInput) (service.HistoryPageInput, error) {
|
||||
if in.Limit == 0 {
|
||||
in.Limit = 20
|
||||
}
|
||||
if in.Limit < 1 || in.Limit > 100 || len(in.Cursor) > 1024 {
|
||||
return in, fault.Invalid("invalid workflow history page")
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
func (s *Service) ListActivations(ctx context.Context, t auth.Token, id core.ID, key string, in service.HistoryPageInput) (ActivationPage, error) {
|
||||
if id.Validate() != nil || !versionName(key) {
|
||||
return ActivationPage{}, fault.Invalid("invalid workflow step identity")
|
||||
}
|
||||
in, err := historyPage(in)
|
||||
if err != nil {
|
||||
return ActivationPage{}, err
|
||||
}
|
||||
return s.store.ListActivations(ctx, t, id, key, in)
|
||||
}
|
||||
func (s *Service) GetActivation(ctx context.Context, t auth.Token, id core.ID, key string, activation core.ID) (ActivationDetail, error) {
|
||||
if id.Validate() != nil || !versionName(key) || activation.Validate() != nil {
|
||||
return ActivationDetail{}, fault.Invalid("invalid workflow activation identity")
|
||||
}
|
||||
return s.store.GetActivation(ctx, t, id, key, activation)
|
||||
}
|
||||
func (s *Service) ListPollRounds(ctx context.Context, t auth.Token, id core.ID, key string, activation core.ID, in service.HistoryPageInput) (PollRoundPage, error) {
|
||||
if id.Validate() != nil || !versionName(key) || activation.Validate() != nil {
|
||||
return PollRoundPage{}, fault.Invalid("invalid workflow activation identity")
|
||||
}
|
||||
in, err := historyPage(in)
|
||||
if err != nil {
|
||||
return PollRoundPage{}, err
|
||||
}
|
||||
return s.store.ListPollRounds(ctx, t, id, key, activation, in)
|
||||
}
|
||||
@@ -72,6 +72,9 @@ type StepsView struct {
|
||||
Steps []StepView `json:"steps"` // Steps 按键排序,最多128个,不含内部根检查点。
|
||||
}
|
||||
type Store interface {
|
||||
ListActivations(context.Context, auth.Token, core.ID, string, service.HistoryPageInput) (ActivationPage, error)
|
||||
GetActivation(context.Context, auth.Token, core.ID, string, core.ID) (ActivationDetail, error)
|
||||
ListPollRounds(context.Context, auth.Token, core.ID, string, core.ID, service.HistoryPageInput) (PollRoundPage, error)
|
||||
ListOperations(context.Context, auth.Token, core.ID, service.HistoryPageInput) (OperationPage, error)
|
||||
GetOperation(context.Context, auth.Token, core.ID, core.ID) (OperationView, error)
|
||||
Start(context.Context, auth.Token, string, StartInput) (StartResult, error)
|
||||
|
||||
@@ -71,3 +71,16 @@ func (s *captureStore) GetOperation(context.Context, auth.Token, core.ID, core.I
|
||||
s.calls++
|
||||
return OperationView{}, nil
|
||||
}
|
||||
|
||||
func (s *captureStore) ListActivations(context.Context, auth.Token, core.ID, string, service.HistoryPageInput) (ActivationPage, error) {
|
||||
s.calls++
|
||||
return ActivationPage{}, nil
|
||||
}
|
||||
func (s *captureStore) GetActivation(context.Context, auth.Token, core.ID, string, core.ID) (ActivationDetail, error) {
|
||||
s.calls++
|
||||
return ActivationDetail{}, nil
|
||||
}
|
||||
func (s *captureStore) ListPollRounds(context.Context, auth.Token, core.ID, string, core.ID, service.HistoryPageInput) (PollRoundPage, error) {
|
||||
s.calls++
|
||||
return PollRoundPage{}, nil
|
||||
}
|
||||
|
||||
91
weave/signal/history.go
Normal file
91
weave/signal/history.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package signal
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
const AuditAction = "workflows:audit:read"
|
||||
|
||||
// Summary 不聚合信号正文、关联键或证据;单条Get用于读取完整原始信号。
|
||||
type Summary struct {
|
||||
ID core.ID `json:"id"` // ID 持久信号身份。
|
||||
WorkflowID core.ID `json:"workflow_id"` // WorkflowID 所属实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 该信号当前记录版本。
|
||||
Name string `json:"name"` // Name 原信号类型名称。
|
||||
PayloadVersion string `json:"payload_version"` // PayloadVersion 固定内容契约;未核验旧数据可能为空。
|
||||
TargetStepKey string `json:"target_step_key"` // TargetStepKey 原目标步骤名称,不跟随当前步骤变更。
|
||||
TargetActivationSequence uint32 `json:"target_activation_sequence"` // TargetActivationSequence 原指定代次,未核验旧数据可能为0。
|
||||
State string `json:"state"` // State 当前PENDING、CONSUMED、EXPIRED或REJECTED。
|
||||
Disposition string `json:"disposition,omitempty"` // Disposition 已记录的消费或拒绝原因。
|
||||
AcceptedAt time.Time `json:"accepted_at"` // AcceptedAt 原受理时间,也是分页排序依据。
|
||||
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 原固定截止,不因读取或重报延长。
|
||||
}
|
||||
type Page struct {
|
||||
WorkflowID core.ID `json:"workflow_id"` // WorkflowID 已授权实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本;跨页状态可变化。
|
||||
Items []Summary `json:"items"` // Items 按受理时间及ID倒序的摘要,空页为[]。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定命名空间及实例的后续位置。
|
||||
}
|
||||
type OperationSummary struct {
|
||||
ID core.ID `json:"id"` // ID 不可改写的操作审计身份。
|
||||
WorkflowID core.ID `json:"workflow_id"` // WorkflowID 经信号归属验证的实例。
|
||||
SignalID core.ID `json:"signal_id"` // SignalID 本次操作对应的信号资源。
|
||||
OperationID core.ID `json:"operation_id"` // OperationID 服务端操作身份,不是原始幂等键。
|
||||
ActorID string `json:"actor_id"` // ActorID 已认证的实际操作主体。
|
||||
Action string `json:"action"` // Action 操作种类,如workflow.signal。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 原操作创建时间。
|
||||
}
|
||||
type OperationView struct {
|
||||
OperationSummary
|
||||
Reason string `json:"reason"` // Reason 原操作原因。
|
||||
EvidenceRef string `json:"evidence_ref,omitempty"` // EvidenceRef 原证据引用,不保存外部秘密。
|
||||
Before json.RawMessage `json:"before"` // Before 操作前摘要,首次受理可为null。
|
||||
After json.RawMessage `json:"after"` // After 原受理身份、摘要、时间和当时状态,不替代当前消费状态。
|
||||
}
|
||||
type OperationPage struct {
|
||||
Items []OperationSummary `json:"items"` // Items 按创建时间和ID倒序的有界审计摘要。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、信号及查询类型的后续位置。
|
||||
}
|
||||
|
||||
func historyPage(in service.HistoryPageInput) (service.HistoryPageInput, error) {
|
||||
if in.Limit == 0 {
|
||||
in.Limit = 20
|
||||
}
|
||||
if in.Limit < 1 || in.Limit > 100 || len(in.Cursor) > 1024 {
|
||||
return in, fault.Invalid("invalid workflow signal history page")
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
func (s *Service) List(ctx context.Context, t auth.Token, workflow core.ID, in service.HistoryPageInput) (Page, error) {
|
||||
if workflow.Validate() != nil {
|
||||
return Page{}, fault.Invalid("invalid workflow id")
|
||||
}
|
||||
in, err := historyPage(in)
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
return s.store.List(ctx, t, workflow, in)
|
||||
}
|
||||
func (s *Service) ListOperations(ctx context.Context, t auth.Token, workflow, id core.ID, in service.HistoryPageInput) (OperationPage, error) {
|
||||
if workflow.Validate() != nil || id.Validate() != nil {
|
||||
return OperationPage{}, fault.Invalid("invalid workflow signal identity")
|
||||
}
|
||||
in, err := historyPage(in)
|
||||
if err != nil {
|
||||
return OperationPage{}, err
|
||||
}
|
||||
return s.store.ListOperations(ctx, t, workflow, id, in)
|
||||
}
|
||||
func (s *Service) GetOperation(ctx context.Context, t auth.Token, workflow, id, operation core.ID) (OperationView, error) {
|
||||
if workflow.Validate() != nil || id.Validate() != nil || operation.Validate() != nil {
|
||||
return OperationView{}, fault.Invalid("invalid signal operation identity")
|
||||
}
|
||||
return s.store.GetOperation(ctx, t, workflow, id, operation)
|
||||
}
|
||||
@@ -56,6 +56,9 @@ type Result struct {
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 原身份恢复,不重报新事件或重新计时。
|
||||
}
|
||||
type Store interface {
|
||||
List(context.Context, auth.Token, core.ID, service.HistoryPageInput) (Page, error)
|
||||
ListOperations(context.Context, auth.Token, core.ID, core.ID, service.HistoryPageInput) (OperationPage, error)
|
||||
GetOperation(context.Context, auth.Token, core.ID, core.ID, core.ID) (OperationView, error)
|
||||
Send(context.Context, auth.Token, core.ID, string, Input) (Result, error)
|
||||
Get(context.Context, auth.Token, core.ID, core.ID) (View, error)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
type captureStore struct {
|
||||
@@ -53,3 +54,16 @@ func TestSignalInputBoundary(t *testing.T) {
|
||||
t.Fatal("missing identity accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *captureStore) List(context.Context, auth.Token, core.ID, service.HistoryPageInput) (Page, error) {
|
||||
s.calls++
|
||||
return Page{}, nil
|
||||
}
|
||||
func (s *captureStore) ListOperations(context.Context, auth.Token, core.ID, core.ID, service.HistoryPageInput) (OperationPage, error) {
|
||||
s.calls++
|
||||
return OperationPage{}, nil
|
||||
}
|
||||
func (s *captureStore) GetOperation(context.Context, auth.Token, core.ID, core.ID, core.ID) (OperationView, error) {
|
||||
s.calls++
|
||||
return OperationView{}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user