feat: trace task sources to fixed workflow activations and rounds
This commit is contained in:
12
AGENTS.md
12
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,7 +52,7 @@
|
||||
- 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已接入,完整历史与生产验收继续推进。
|
||||
@@ -61,5 +61,7 @@
|
||||
|
||||
- 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新轮无父任务。不能改指当前激活、递归展开历史或将来源查询加入派发路径;无外键。
|
||||
|
||||
@@ -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)提供完整调用链;实例筛选分页、历史激活/轮次/信号列表、流程任务来源及更多独立恢复示例继续交付,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/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)。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Weave 流程编排功能与设计
|
||||
|
||||
> 版本 0.10 · 2026-09-18。首版功能范围已确认。定义结构、发布校验与不可变版本已实现,见[定义格式](api/workflow-definition-format.md)和[发布API](api/workflow-definitions.md);[实例受理](11-workflow-admission.md)、[结果消费与根推进](12-workflow-runtime.md)、[TIMER与POLL时钟](13-workflow-clock.md)已接入。[信号等待及最终查询竞争](14-workflow-signals.md)已接入内部运行器;[暂停/继续/取消与停止结清](15-workflow-controls.md)已实现内部链路;[人工核查与TASK恢复](16-workflow-recovery.md)已接入内部服务;[POLL跨激活恢复](17-workflow-poll-recovery.md)和[等待恢复](18-workflow-wait-recovery.md)已接入;[实例公共API/SDK](api/workflows.md)已接入;完整历史、来源及生产验收仍需交付。
|
||||
> 版本 0.10 · 2026-09-18。首版功能范围已确认。定义结构、发布校验与不可变版本已实现,见[定义格式](api/workflow-definition-format.md)和[发布API](api/workflow-definitions.md);[实例受理](11-workflow-admission.md)、[结果消费与根推进](12-workflow-runtime.md)、[TIMER与POLL时钟](13-workflow-clock.md)已接入。[信号等待及最终查询竞争](14-workflow-signals.md)已接入内部运行器;[暂停/继续/取消与停止结清](15-workflow-controls.md)已实现内部链路;[人工核查与TASK恢复](16-workflow-recovery.md)已接入内部服务;[POLL跨激活恢复](17-workflow-poll-recovery.md)和[等待恢复](18-workflow-wait-recovery.md)已接入;[实例公共API/SDK](api/workflows.md)已接入;流程任务来源已接入;完整历史及生产验收仍需交付。
|
||||
|
||||
## 1. 职责与边界
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ HTTP/SDK创建时间范围使用包含下界、不包含上界;v2游标以固
|
||||
|
||||
### 任务直接来源视图
|
||||
|
||||
[来源查询](api/task-source.md)复用Task.OwnerKind/OwnerID/RetryOf、Schedule、ScheduleVersion与ScheduleOccurrence,沿已有(namespace_id,task_id)唯一索引及计划版本键定位,不增加表、持久化字段、索引或迁移。只读响应模型的每个字段也有中文注释。任务、计划、触发版本与队列归属在认证事务中显式验证,缺失关联不被默认为普通任务;流程来源随Weave实现。
|
||||
[来源查询](api/task-source.md)复用Task.OwnerKind/OwnerID/RetryOf、Schedule、ScheduleVersion与ScheduleOccurrence,沿已有(namespace_id,task_id)唯一索引及计划版本键定位,不增加表、持久化字段、索引或迁移。只读响应模型的每个字段也有中文注释。任务、计划、触发版本与队列归属在认证事务中显式验证,缺失关联不被默认为普通任务;流程来源已实现,见本页末尾。
|
||||
|
||||
### 业务事务Outbox示例模型
|
||||
|
||||
@@ -222,3 +222,7 @@ PollState新增FirstRound和FirstRoundAttemptsUsed,记录当前激活的首个
|
||||
### 等待恢复与PENDING接续
|
||||
|
||||
复用现有激活、等待、查询状态和不可变审计,不新增迁移或字段。RearmWait以新代次保留首次等待截止;ContinuePoll保留当前代次和原轮次,以权威已处理结果及管理审计恢复间隔。仍为35个实体/305个中文注释字段,无外键,见[等待恢复](18-workflow-wait-recovery.md)。
|
||||
|
||||
## 流程任务直接来源
|
||||
|
||||
[WORKFLOW来源](api/task-source.md#5-流程固定来源)复用实例固定版本、步骤、激活、查询轮次及Task.RetryOf;不增加迁移、表、持久化字段或外键。响应模型和独立SDK新增字段均有中文注释,仍为35个持久化实体/305个直接字段声明。沿已有任务唯一索引和步骤代次唯一键核验正反向归属,只返回直接父任务;人工恢复不改写旧任务的历史激活来源。
|
||||
|
||||
@@ -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)。流程任务来源已接入,完整历史仍须完成。
|
||||
|
||||
### 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任务流程来源,并提供独立轮询及恢复示例。逐项验证原键恢复、权限、版本冲突、竞争与进程全停。
|
||||
4. 实例/当前步骤/信号/控制/六类恢复及流程操作审计HTTP和独立SDK已接入,步骤查询返回实际激活代次;并行执行与信号回执场景已有可运行示例。继续补齐实例筛选分页、完整激活/轮次/信号历史及信号资源审计,并提供独立轮询及恢复示例;[Pulse任务流程来源](api/task-source.md#5-流程固定来源)已交付。逐项验证原键恢复、权限、版本冲突、竞争与进程全停。
|
||||
5. 转入P6/P7的生产故障、容量、运维、发布与全部文档核对。内部存储阶段通过不会提前勾选公共接口或生产验收。
|
||||
|
||||
公共接入按以下调用链推进;本批已接入启动/当前详情、控制、信号、六类恢复和流程操作审计,并提供独立SDK及基础示例。以下历史、来源、分页和更多独立场景继续交付:
|
||||
公共接入按以下调用链推进;本批已接入启动/当前详情、控制、信号、六类恢复和流程操作审计,并提供独立SDK及基础示例。任务流程来源也已接入。以下完整历史、分页和更多独立场景继续交付:
|
||||
|
||||
| 调用链 | 需要补齐的内容 | 验收重点 |
|
||||
| --- | --- | --- |
|
||||
| 启动与读取 | 显式固定版本启动、当前实例、步骤及当前激活代次;后续补筛选分页和单次有界历史读取 | 原键丢回执恢复、当前权限、读取快照一致性、未激活步骤语义 |
|
||||
| 控制与信号 | 暂停/继续/取消、固定步骤和代次的信号提交及原受理回执 | control_revision与通用revision区分、迟到/旧代次、暂停期间结果收集 |
|
||||
| 核查与追踪 | 六类人工恢复操作、激活/轮次/等待/信号及管理审计读取、Pulse任务流程来源 | 当前实例/步骤/任务版本、证据和显式声明、旧历史保留、有界响应和权限隔离 |
|
||||
| 核查与追踪 | 六类人工恢复操作、完整激活/轮次/等待/信号历史读取及信号管理审计;Pulse任务流程来源已交付 | 当前实例/步骤/任务版本、证据和显式声明、旧历史保留、有界响应和权限隔离 |
|
||||
| 通用接入场景 | 并行汇合、外部回执、有限轮询、暂停核查恢复的可运行SDK示例 | 真实Worker执行、完整进程退出后接续、未知效果保留、全链路回归 |
|
||||
|
||||
接口只使用已经落地的内部事务规则;SDK保持独立module,不能引用服务端模型或数据库实现。生产故障及容量结论仍由P6专门验收。
|
||||
@@ -67,7 +67,7 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
|
||||
| 批量任务受理 | 已实现[批量API](api/batch-tasks.md)、独立SDK及[补交示例](examples/batch.md);真实MySQL部分失败与HTTP响应丢失用例已通过,同键并发20轮及全量回归已通过,见[报告](reports/phase-3-batch-tasks.md) |
|
||||
| 独立任务人工核查与重试 | 已实现[管理API及SDK](api/task-recovery.md)、专门权限、显式安全声明、证据/版本/审计和关联新任务;[完整场景](examples/recovery.md)及[回归报告](reports/phase-3-task-recovery.md)同步交付 |
|
||||
| 任务查询筛选 | 已实现[业务键及创建时间查询](api/task-queries.md),v2游标绑定全部筛选、旧游标受限兼容、四种索引路径与[完整场景](examples/task-query.md);[回归报告](reports/phase-3-task-queries.md)同步记录 |
|
||||
| 任务详情来源追踪 | 已实现[独立来源接口](api/task-source.md)、SDK和普通/计划/人工恢复场景,返回计划及触发固定版本;同命名空间归属、当前权限和来源缺失拒绝已通过[全量回归](reports/phase-3-task-source.md),流程来源随Weave接入 |
|
||||
| 任务详情来源追踪 | 已实现[独立来源接口](api/task-source.md)、SDK和普通/计划/人工恢复场景,返回计划及触发固定版本;同命名空间归属、当前权限和来源缺失拒绝已通过[全量回归](reports/phase-3-task-source.md),流程来源已按[专项报告](reports/phase-5-task-source.md)接入 |
|
||||
| 业务到平台的事务Outbox接入 | 已实现[独立业务模块及完整接入](examples/task-outbox.md),覆盖业务变更/待发请求同事务、有限发送恢复、CLI及真实HTTP/MySQL/进程故障,见[报告](reports/phase-3-task-outbox.md);核心SDK保持无ORM/数据库依赖 |
|
||||
|
||||
精确A04派发消息未送达时的进程故障、慢消费者/重连风暴、凭据安全重绑、历史清理、全量监控/容量与MySQL故障切换仍按P6执行;Weave的通用执行门控/内部事件接续随P4–P5落实,不将外部结果订阅当作内部流程引擎。
|
||||
|
||||
@@ -74,7 +74,7 @@ v16及以前的未处理记录缺少冻结来源,升级后统一标为QUARANTI
|
||||
|
||||
## 4. 后续存储适配必须满足的条件
|
||||
|
||||
以下保留存储运行器的完整要求。第1项已按[受理报告](reports/phase-4-admission.md)验证;第2–4项结果消费和根接续已接入[实际运行器](12-workflow-runtime.md),第5项的TIMER/POLL已接入[时钟运行器](13-workflow-clock.md),第5项的信号等待已接入[信号运行器](14-workflow-signals.md),内部控制与结清已接入[控制运行器](15-workflow-controls.md),[核查与TASK新激活](16-workflow-recovery.md)已接入;公共接口和SDK已接入,完整历史及来源继续实现:
|
||||
以下保留存储运行器的完整要求。第1项已按[受理报告](reports/phase-4-admission.md)验证;第2–4项结果消费和根接续已接入[实际运行器](12-workflow-runtime.md),第5项的TIMER/POLL已接入[时钟运行器](13-workflow-clock.md),第5项的信号等待已接入[信号运行器](14-workflow-signals.md),内部控制与结清已接入[控制运行器](15-workflow-controls.md),[核查与TASK新激活](16-workflow-recovery.md)已接入;公共接口和SDK已接入,流程任务来源已接入,完整历史继续实现:
|
||||
|
||||
1. 启动时认证、固定不可变版本/输入和策略、校验全部队列、计算实例截止,并在同事务创建实例、门控、首批激活/任务及审计回执。相同身份返回原实例。
|
||||
2. 消费者领取内部事件后核对命名空间、来源任务版本、激活/轮次及当前步骤版本;消费确认、步骤变化、后续任务或持久化待续记录同事务提交。
|
||||
|
||||
@@ -64,4 +64,4 @@ flowchart LR
|
||||
|
||||
进程回归由`make integration`构建二进制后显式执行。缺少一次性数据库或`ORBIT_TEST_SERVER_BINARY`时测试会跳过;跳过不构成进程恢复证据。验证记录见[本阶段报告](reports/phase-4-runtime.md)。
|
||||
|
||||
持久化信号等待及查询最终信号竞争已按[信号运行约定](14-workflow-signals.md)实现。内部控制及停止结清也已接入。实例公共API与独立SDK已接入,下一步包括完整历史、来源公共API及更多通用场景。P4整体及P5–P7继续按[完整计划](08-development-plan.md)执行。
|
||||
持久化信号等待及查询最终信号竞争已按[信号运行约定](14-workflow-signals.md)实现。内部控制及停止结清也已接入。实例公共API与独立SDK已接入,下一步包括完整历史、实例筛选分页及更多通用场景;流程任务来源已接入。P4整体及P5–P7继续按[完整计划](08-development-plan.md)执行。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| `GET /health/live` | 已实现 | [健康检查](health.md) |
|
||||
| `GET /health/ready` | 已实现,验证数据库、迁移记录及零外键要求 | [健康检查](health.md) |
|
||||
| `/v1/tasks` 提交、查询、分页与待执行取消 | 已实现,P3扩展业务键与创建时间筛选 | [任务接口](tasks.md) / [查询契约](task-queries.md) |
|
||||
| `GET /v1/tasks/{task_id}/source` | P3已实现普通、人工重试与计划固定版本来源 | [来源查询](task-source.md) |
|
||||
| `GET /v1/tasks/{task_id}/source` | 已实现普通、人工重试、计划固定版本及流程历史激活/轮次来源 | [来源查询](task-source.md) |
|
||||
| `POST /v1/tasks:batch` | P3 已实现逐项受理、独立幂等、部分结果与补交 | [批量任务](batch-tasks.md) |
|
||||
| `/v1/tasks/{task_id}:review` / `:retry` 与 `/operations` | P3 已实现带证据核查、关联新任务与操作审计 | [人工恢复](task-recovery.md) |
|
||||
| `/v1/tasks/{task_id}/attempts` 与尝试详情 | P2 已实现 | [执行历史](execution-history.md) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 任务直接来源查询
|
||||
|
||||
状态:P3已实现服务端、独立SDK及普通任务、计划、人工恢复示例。接口只读,使用已有模型和索引,不增加数据库外键或迁移。验证证据见[回归报告](../reports/phase-3-task-source.md)。
|
||||
状态:已实现普通任务、人工重试、计划和流程任务来源的服务端、独立SDK及示例。接口只读,使用已有模型和索引,不增加数据库外键或迁移。证据见[P3报告](../reports/phase-3-task-source.md)和[流程来源报告](../reports/phase-5-task-source.md)。
|
||||
|
||||
## 1. 请求与权限
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
请求头:`Authorization: Bearer <token>`。不需要请求体、Content-Type或Idempotency-Key,不接受任何查询参数。task_id为32位小写十六进制ID;认证决定命名空间,URL不能覆盖它。
|
||||
|
||||
权限为`tasks:read`及任务所属队列范围。每次请求验证当前凭据;不存在、跨命名空间或不在队列范围的任务均返回404。只获得来源引用,不因此获得计划定义、模板、管理或执行权限;读取完整计划及固定版本仍需`schedules:read`,见[计划手册](schedules.md)。
|
||||
权限为`tasks:read`及任务所属队列范围。每次请求验证当前凭据;不存在、跨命名空间或不在队列范围的任务均返回404。只获得来源引用,不因此获得计划/流程定义、其他步骤、管理或执行权限。读取完整计划及固定版本仍需`schedules:read`;读取流程需`workflows:read`及全部固定队列范围,读取流程定义需`workflows:definitions:read`。只拥有一个队列任务权限时,仍能读取该任务的有限流程来源。
|
||||
|
||||
```sh
|
||||
curl "$ORBIT_URL/v1/tasks/$TASK_ID/source" \
|
||||
@@ -22,7 +22,7 @@ HTTP 200,使用`request_id`和`data`信封;X-Request-ID与正文一致。下
|
||||
| 字段 | 类型 / 必填 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| task_id | string / 是 | 已授权查询的任务ID |
|
||||
| kind | string / 是 | DIRECT、MANUAL_RETRY或SCHEDULE |
|
||||
| kind | string / 是 | DIRECT、MANUAL_RETRY、SCHEDULE或WORKFLOW |
|
||||
| retry_of | string / 仅MANUAL_RETRY | 同命名空间、同队列的直接原任务ID |
|
||||
| schedule | object / 仅SCHEDULE | 触发该任务的固定关联,字段如下 |
|
||||
| schedule.schedule_id | string / 是 | 来源计划ID |
|
||||
@@ -31,6 +31,7 @@ HTTP 200,使用`request_id`和`data`信封;X-Request-ID与正文一致。下
|
||||
| schedule.scheduled_at | UTC RFC3339 / 是 | 原计划时间,最多微秒精度,与实际受理/执行时间分开 |
|
||||
| schedule.decision | string / 是 | FIRED单点触发或COALESCED合并触发 |
|
||||
| schedule.covered_until | UTC RFC3339 / 仅COALESCED | 合并范围的排除上界,严格晚于scheduled_at |
|
||||
| workflow | object / 仅WORKFLOW | 此任务创建时的流程来源,字段见第5节 |
|
||||
|
||||
DIRECT表示普通任务受理,包括单项和批量受理;任务内部的自动安全重试仍属于同一个任务,不改变来源。MANUAL_RETRY表示[人工重试](task-recovery.md)创建的新任务,只返回直接父任务,不递归遍历整条重试链。父任务继续保留原历史。
|
||||
|
||||
@@ -85,7 +86,9 @@ SCHEDULE中的版本来自触发记录,不能用计划最新版本替换。COA
|
||||
|
||||
## 3. 关联校验与错误
|
||||
|
||||
平台采用无外键设计,本接口在认证事务内显式核对命名空间、任务、队列、计划、不可变版本与触发记录。人工重试父任务须为同队列中已失败/取消的独立任务。无所有者但存在触发记录、悬空父任务、缺失版本、错误队列、未知所有者或当前未支持的门控均拒绝,不能把关联损坏降级成DIRECT。
|
||||
平台采用无外键设计,本接口在认证事务内显式核对命名空间、任务、队列、计划或流程、不可变版本和创建记录。人工重试父任务须为同队列中已失败/取消的独立任务。流程来源核验实例门控、版本摘要与冻结策略、固定步骤处理器契约、任务与激活或轮次的双向关系;恢复父任务还须属于同一步骤的前一激活,POLL须为同一业务轮。
|
||||
|
||||
无所有者但存在触发/激活/轮次反向记录、同时关联不同来源种类、悬空父任务、缺失版本、错误队列或未知所有者均拒绝,不能把关联损坏降级成DIRECT。查询沿现有唯一索引读取当前任务及至多一个直接父任务,不递归遍历恢复历史,不锁运行器父资源,也不进入派发路径。
|
||||
|
||||
| HTTP / code | 处理 |
|
||||
| --- | --- |
|
||||
@@ -117,6 +120,11 @@ case "SCHEDULE":
|
||||
fmt.Println("来源计划", source.Schedule.ScheduleID,
|
||||
"固定版本", source.Schedule.ScheduleVersion,
|
||||
"触发记录", source.Schedule.OccurrenceID)
|
||||
case "WORKFLOW":
|
||||
fmt.Println("所属流程", source.Workflow.WorkflowID,
|
||||
"步骤", source.Workflow.StepKey,
|
||||
"激活代次", source.Workflow.ActivationSequence,
|
||||
"业务查询轮次", source.Workflow.PollRound)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -127,5 +135,53 @@ SDK的ScheduleVersion为uint64,JSON仍为字符串;时间使用time.Time。S
|
||||
| 单项受理、取消后读取来源 | [tasks](../../sdk/go/examples/tasks/main.go) | [SDK初始化](../sdk/README.md),source_kind=DIRECT |
|
||||
| 按计划找到触发任务,再反查来源 | [schedules](../../sdk/go/examples/schedules/main.go) | [计划场景](schedules.md#6-sdk-与完整示例),source_kind=SCHEDULE、fixed_version=1 |
|
||||
| 人工核查后创建关联新任务 | [recovery](../../sdk/go/examples/recovery/main.go) | [恢复场景](../examples/recovery.md),source_kind=MANUAL_RETRY、parent=原任务ID |
|
||||
| 并行流程完成后核对每项任务来源 | [workflow](../../sdk/go/examples/workflow/main.go) | [流程场景](../examples/workflow.md),workflow_sources_verified=3、fixed_version=v1 |
|
||||
|
||||
上述示例均运行在隔离回归中;新发布计划版本后老任务仍指向旧版本,由真实数据库与SDK集成测试验证。Weave流程来源随P4–P5实现,目前没有WORKFLOW响应。此接口为新增只读路由,不更改Task/TaskSummary或Worker消息结构,不增加权限配置;新客户端调用旧节点的404不能理解为DIRECT。
|
||||
上述示例均运行在隔离回归中;新发布计划/流程版本后老任务仍指向原版本,人工恢复后旧激活仍可追溯。WORKFLOW是新增来源种类,旧SDK可能按未知种类返回错误;应升级SDK,不能降级为DIRECT。不更改Task/TaskSummary或Worker消息结构,不增加权限配置;新客户端调用旧节点的404/503同样不能理解为DIRECT。
|
||||
|
||||
## 5. 流程固定来源
|
||||
|
||||
以下字段位于`data.workflow`,只适用于TASK和POLL产生的业务任务:
|
||||
|
||||
| 字段 | 类型 / 必填 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| workflow_id | string / 是 | 所属流程实例 |
|
||||
| definition_id | string / 是 | 稳定定义身份 |
|
||||
| definition_version_id | string / 是 | 实例固定的版本记录身份 |
|
||||
| version | string / 是 | 固定发布版本名,不解析最新版本 |
|
||||
| step_id / step_key | string / 是 | 逻辑步骤身份及固定名称 |
|
||||
| step_kind | string / 是 | TASK或POLL |
|
||||
| activation_id | string / 是 | 创建此任务的历史激活身份 |
|
||||
| activation_sequence | uint32 / 是 | 步骤激活代次,从1开始;并非当前激活代次 |
|
||||
| poll_round | uint32 / 仅POLL | 首次查询开始累计的业务轮编号;人工恢复同轮不重置 |
|
||||
| poll_round_id | string / 仅POLL | 唯一关联该任务的轮次记录身份 |
|
||||
| retry_of | string / 恢复任务可有 | 同步骤、前一激活的直接旧任务;POLL还要求同一业务轮 |
|
||||
|
||||
TASK第二代及之后必有workflow.retry_of;POLL只在恢复激活的首轮任务上带此关联,后续正常业务轮没有retry_of。顶层retry_of仍只用于MANUAL_RETRY;流程恢复任务的kind一直是WORKFLOW。自动尝试重试不创建新任务,也不改变来源。
|
||||
|
||||
第二代激活重查原第2业务轮的完整响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id":"44444444444444444444444444444444",
|
||||
"data":{
|
||||
"task_id":"11111111111111111111111111111111",
|
||||
"kind":"WORKFLOW",
|
||||
"workflow":{
|
||||
"workflow_id":"22222222222222222222222222222222",
|
||||
"definition_id":"33333333333333333333333333333333",
|
||||
"definition_version_id":"55555555555555555555555555555555",
|
||||
"version":"v1",
|
||||
"step_id":"66666666666666666666666666666666",
|
||||
"step_key":"query","step_kind":"POLL",
|
||||
"activation_id":"77777777777777777777777777777777",
|
||||
"activation_sequence":2,
|
||||
"poll_round":2,
|
||||
"poll_round_id":"88888888888888888888888888888888",
|
||||
"retry_of":"99999999999999999999999999999999"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
流程暂停、结束、发布新版本或后续人工恢复,都不会把旧任务的来源改指向新激活。要取得当前步骤需另调[GetWorkflowStep](workflows.md),不要用来源中的历史代次向当前等待发送信号。完整激活/轮次历史分页仍按开发计划推进,本接口一次只查一个任务的直接来源。
|
||||
|
||||
@@ -123,7 +123,7 @@ curl "$ORBIT_URL/v1/tasks/$TASK_ID" -H "Authorization: Bearer $ORBIT_TOKEN"
|
||||
|
||||
SDK:`Client.GetTask(ctx, taskID)`。
|
||||
|
||||
任务直接来源独立通过`GET /v1/tasks/{task_id}/source`读取,SDK为`GetTaskSource(ctx, taskID)`;可定位普通受理、人工重试父任务、来源计划/触发记录/固定版本。使用相同的tasks:read和队列范围,详情见[来源查询](task-source.md)。来源未嵌入Task或列表摘要,读取完整计划仍需计划权限。
|
||||
任务直接来源独立通过`GET /v1/tasks/{task_id}/source`读取,SDK为`GetTaskSource(ctx, taskID)`;可定位普通受理、人工重试父任务、来源计划/触发记录,以及流程固定版本/步骤/激活代次/查询轮次。使用相同的tasks:read和队列范围,详情见[来源查询](task-source.md)。来源未嵌入Task或列表摘要,读取完整计划或流程仍需各自权限。
|
||||
|
||||
## GET /v1/tasks
|
||||
|
||||
|
||||
@@ -143,4 +143,4 @@ fmt.Println(audit.Action, receipt.Instance.State)
|
||||
|
||||
新路由仅在本开发版本及之后可用;旧服务返回404时不能降级为普通任务重试来绕过Weave所有权。SDK类型独立、版本字段保留uint64精度。规则及数据模型未增加表、字段、外键或级联。
|
||||
|
||||
本批尚不提供完整历史激活/轮次遍历、实例筛选列表或信号资源审计列表;当前输入结果、当前查询预算、单条信号和流程管理审计可读。生产数据库切换、容量及恢复目标继续按P6验证,见[阶段报告](../reports/phase-5-public-api.md)。
|
||||
本批尚不提供完整历史激活/轮次遍历、实例筛选列表或信号资源审计列表;当前输入结果、当前查询预算、单条信号、流程管理审计及[任务历史激活来源](task-source.md#5-流程固定来源)可读。生产数据库切换、容量及恢复目标继续按P6验证,见[阶段报告](../reports/phase-5-public-api.md)。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
状态:下表公共 HTTP 与独立 Go SDK 已接入。定义发布见[定义接口](workflow-definitions.md),人工恢复见[恢复接口](workflow-recovery.md),完整运行例子见[并行作业与回执](../examples/workflow.md)。SDK 尚未发布正式版本,服务端与 SDK 应使用同一开发版本。
|
||||
|
||||
实例筛选分页、历史激活/轮次/信号列表及 Pulse 任务的流程来源查询继续开发。旧冻结回执可能不含新增的activation_sequence,应另读当前步骤详情;当前步骤详情只反映当前激活;旧记录保留在数据库,不能把本页的当前视图当作全部历史。
|
||||
实例筛选分页及历史激活/轮次/信号列表继续开发;[Pulse任务流程来源](task-source.md#5-流程固定来源)已接入。旧冻结回执可能不含新增的activation_sequence,应另读当前步骤详情;当前步骤详情只反映当前激活;旧记录保留在数据库,不能把本页的当前视图当作全部历史。
|
||||
|
||||
## 1. 路由、权限与 SDK
|
||||
|
||||
|
||||
@@ -182,7 +182,8 @@
|
||||
|
||||
## 已可运行:从任务定位来源
|
||||
|
||||
- [来源接口与SDK](../api/task-source.md)随现有tasks、schedules、recovery三个示例运行,无额外服务或数据库表。
|
||||
- [来源接口与SDK](../api/task-source.md)随现有tasks、schedules、recovery和workflow四个示例运行,无额外服务或数据库表。
|
||||
- 步骤:取得任务ID → GetTaskSource → 区分普通提交/人工重试/计划触发 → 按权限读取父任务或固定计划版本。
|
||||
- 预期:source_kind分别为DIRECT、MANUAL_RETRY、SCHEDULE;人工重试关联原任务,计划任务保留实际触发版本。
|
||||
- 异常:来源缺失或归属不匹配返回503,不能降级为DIRECT;新计划版本不覆盖历史版本,引用查询不授予计划定义读取权限。
|
||||
- workflow示例逐项核对三个任务所属流程、固定版本、逻辑步骤及创建激活,输出workflow_sources_verified=3;恢复旧激活和POLL轮次由SDK集成测试覆盖。
|
||||
- 异常:来源缺失或归属不匹配返回503,不能降级为DIRECT;新版本不覆盖历史版本,引用查询不授予完整计划或流程读取权限。
|
||||
|
||||
@@ -31,12 +31,13 @@ go run ./examples/workflow
|
||||
|
||||
## 2. 调用链与输出
|
||||
|
||||
CreateWorkflowDefinition → PublishWorkflowVersion(v1) → StartWorkflow → 原键再次StartWorkflow → 自动Worker并行执行 → GetWorkflowStep读取当前信号契约与代次 → SendWorkflowSignal → 原键再次发送 → 后继任务完成 → GetWorkflow → Worker有界排空。
|
||||
CreateWorkflowDefinition → PublishWorkflowVersion(v1) → StartWorkflow → 原键再次StartWorkflow → 自动Worker并行执行 → GetWorkflowStep读取当前信号契约与代次 → SendWorkflowSignal → 原键再次发送 → 后继任务完成 → GetWorkflow → GetWorkflowSteps和GetTaskSource核对三个任务的固定来源 → Worker有界排空。
|
||||
|
||||
预期输出中的ID每次运行不同:
|
||||
|
||||
```text
|
||||
workflow=<32位实例ID> state=SUCCEEDED result={"text":"HELLO ORBIT"} start_deduplicated=true signal_deduplicated=true
|
||||
workflow_sources_verified=3 fixed_version=v1
|
||||
worker_drained=true started=3 confirmed=3
|
||||
```
|
||||
|
||||
@@ -54,4 +55,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)推进。
|
||||
历史激活列表、更多独立业务示例及生产验收仍按[计划](../08-development-plan.md)推进。
|
||||
|
||||
32
docs/reports/phase-5-task-source.md
Normal file
32
docs/reports/phase-5-task-source.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# P4/P5 流程任务来源追踪回归
|
||||
|
||||
日期:2026-09-18。本批补齐既有GetTaskSource路由和独立SDK的WORKFLOW种类,保留P4/P5完整历史、分页、信号审计和独立恢复示例等剩余范围。
|
||||
|
||||
## 实现与边界
|
||||
|
||||
- 任务可追溯到所属流程、固定定义版本、逻辑步骤、创建时的激活身份及代次;POLL同时返回业务轮编号和该任务对应的轮次记录。后续发布、流程结束或人工恢复不改指旧任务来源。
|
||||
- TASK/POLL人工恢复任务保持WORKFLOW种类,workflow.retry_of只返回直接旧任务。恢复父任务须为同一步骤的前一激活,POLL还须属于同一业务轮;新一业务轮没有恢复父任务。
|
||||
- tasks:read和任务队列范围只授予有限来源引用,不扩展成整个流程/其他队列/定义/管理权限。来源不返回业务输入、结果或完整定义。
|
||||
- 查询显式核对命名空间、门控、固定定义摘要/策略、步骤处理器契约、正反向任务归属及直接父任务。所有者丢失、跨来源种类或缺失关联返回错误,不推测DIRECT;独立人工重试的父任务也校验反向关联。
|
||||
- 所有查询沿已有主键、任务唯一键及步骤代次唯一键定位;不递归展开历史,不引入运行器父资源锁,不改变状态、额度或审计,不进入派发路径。新增SDK及响应字段均有中文注释,无新增持久化字段、迁移或外键。
|
||||
|
||||
## 验证
|
||||
|
||||
环境:darwin/arm64、Go 1.27.1、Docker MySQL 8.4.10;一次性隔离数据库、真实Orbit二进制与独立SDK。
|
||||
|
||||
| 验证 | 实际结果 |
|
||||
| --- | --- |
|
||||
| 基础检查 | make fmt/test/vet/build通过,包含竞态、静态检查和各独立模块构建 |
|
||||
| 来源与恢复专项 | 存储包33.366秒通过;普通/计划/独立人工重试来源、TASK/POLL跨激活恢复及新来源损坏/权限场景 |
|
||||
| HTTP与独立SDK专项 | 包30.638秒通过;五组实例场景29.23秒,含TASK恢复、POLL原间隔下一轮和人工恢复同一业务轮的来源读取 |
|
||||
| 可运行示例 | workflow例子SUCCEEDED;workflow_sources_verified=3、fixed_version=v1,三个任务全部确认并排空 |
|
||||
| 最终完整隔离回归 | scripts/test-mysql.sh退出0;存储210.939秒、流程进程恢复48.982秒、流程SDK24.214秒(五组实例22.69秒)、最终进程故障组97.183秒;20个SDK示例及2个业务模块全部通过,包含独立人工重试父任务反向关联拒绝;一次性环境已清理 |
|
||||
| 模型与文档 | 305个直接持久化字段中文注释完整;实际MySQL为v22,外键0、业务字段缺COMMENT为0;1011个本地链接及62个JSON示例检查通过 |
|
||||
|
||||
专项验证固定版本发布后不变、仅有单队列tasks:read也能查当前任务来源、读取完整流程/定义仍拒绝、跨命名空间/队列拒绝;逐项注入错误所有者、门控、处理器版本、激活所属流程/步骤、任务反向引用、步骤类型、固定版本及策略错误;测试同时出现TASK/POLL关联、POLL父任务错误轮次及所有者丢失。真实恢复链验证业务第1轮→恢复第1轮→第2轮→再次恢复第2轮,激活与业务轮分别计数,原历史可读。
|
||||
|
||||
源码:[来源存储](../../internal/storage/mysql/task_workflow_source.go)、[关联损坏及权限测试](../../internal/storage/mysql/task_workflow_source_integration_test.go)、[TASK恢复](../../internal/storage/mysql/workflow_recovery_integration_test.go)、[POLL恢复](../../internal/storage/mysql/workflow_poll_recovery_integration_test.go)、[SDK协议验证](../../sdk/go/task_source_test.go)、[真实SDK链路](../../sdk/go/workflow_instances_integration_test.go)、[运行例子](../../sdk/go/examples/workflow/main.go)。完整复现入口:`make integration`,见[隔离脚本](../../scripts/test-mysql.sh)。
|
||||
|
||||
## 继续交付
|
||||
|
||||
本接口是单任务固定来源,不等同于流程完整历史浏览。实例筛选分页、历史激活/轮次/信号读取、信号资源审计和更多独立恢复示例继续按[计划](../08-development-plan.md)推进;随后核对P4/P5原功能范围,开展P6/P7监控、运维、容量及生产故障验收。本机回归不证明生产MySQL复制、跨故障域可用性或实际RPO/RTO。
|
||||
@@ -124,7 +124,7 @@ fmt.Println(cancelled.Task.State)
|
||||
| Liveness / Readiness | Health | [健康](../api/health.md) |
|
||||
| SubmitTask | SubmitTaskRequest / SubmitTaskResult | [任务](../api/tasks.md) |
|
||||
| GetTask | taskID / Task | [任务](../api/tasks.md) |
|
||||
| GetTaskSource | taskID / TaskSource,直接来源及计划固定版本 | [来源查询](../api/task-source.md) |
|
||||
| GetTaskSource | taskID / TaskSource,普通/恢复/计划及流程固定版本、历史激活与轮次 | [来源查询](../api/task-source.md) |
|
||||
| ListTasks | ListTasksRequest / TaskPage,业务键及创建时间范围 | [查询](../api/task-queries.md) |
|
||||
| CancelTask | taskID、key、CancelTaskRequest / CancelTaskResult | [任务](../api/tasks.md) |
|
||||
| CreateQueue | key、CreateQueueRequest / CreateQueueResult | [队列](../api/queues.md) |
|
||||
@@ -202,3 +202,5 @@ P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md)
|
||||
## 流程定义发布
|
||||
|
||||
已提供独立WorkflowSpecification类型及CreateWorkflowDefinition、GetWorkflowDefinition、PublishWorkflowVersion、GetWorkflowVersion四个HTTP方法。业务侧研发显式发布,服务端冻结版本;实例执行使用[StartWorkflow等方法](../api/workflows.md),完整运行见[示例](../examples/workflow.md)。见[接口与字段](../api/workflow-definitions.md)、[结构版本1](../api/workflow-definition-format.md)、[完整可运行场景](../examples/workflow-definition.md)。
|
||||
|
||||
流程任务同样调用`GetTaskSource`,kind为WORKFLOW时读取workflow中的固定版本、步骤、历史激活代次、查询轮次和直接恢复父任务。该代次不表示当前等待目标;发送信号须另读GetWorkflowStep。完整字段及兼容边界见[来源接口](../api/task-source.md#5-流程固定来源),[workflow示例](../examples/workflow.md)实际核对三个任务来源。
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
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/engine"
|
||||
)
|
||||
|
||||
func (s *TaskStore) GetTaskSource(ctx context.Context, t auth.Token, id core.ID) (service.TaskSourceView, error) {
|
||||
@@ -18,7 +19,12 @@ func (s *TaskStore) GetTaskSource(ctx context.Context, t auth.Token, id core.ID)
|
||||
var queue core.ID
|
||||
var owner sql.NullString
|
||||
var ownerID, gate, retry *core.ID
|
||||
err := tx.QueryRowContext(ctx, `SELECT queue_id,owner_kind,owner_id,gate_id,retry_of FROM pulse_tasks WHERE namespace_id=? AND id=?`, t.NamespaceID, id).Scan(&queue, &owner, &ownerID, &gate, &retry)
|
||||
var scheduled, activated, polled bool
|
||||
err := tx.QueryRowContext(ctx, `SELECT queue_id,owner_kind,owner_id,gate_id,retry_of,
|
||||
EXISTS(SELECT 1 FROM pulse_schedule_occurrences o WHERE o.namespace_id=t.namespace_id AND o.task_id=t.id),
|
||||
EXISTS(SELECT 1 FROM weave_activations a WHERE a.namespace_id=t.namespace_id AND a.task_id=t.id),
|
||||
EXISTS(SELECT 1 FROM weave_poll_rounds r WHERE r.namespace_id=t.namespace_id AND r.task_id=t.id)
|
||||
FROM pulse_tasks t WHERE namespace_id=? AND id=?`, t.NamespaceID, id).Scan(&queue, &owner, &ownerID, &gate, &retry, &scheduled, &activated, &polled)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
@@ -28,8 +34,22 @@ func (s *TaskStore) GetTaskSource(ctx context.Context, t auth.Token, id core.ID)
|
||||
if !p.AllowsQueue(queue) {
|
||||
return fault.NotFound
|
||||
}
|
||||
// 来源字段由创建事务固定;当前未实现的门控/所有者不能被误判为普通受理。
|
||||
if gate != nil || owner.Valid != (ownerID != nil) || owner.Valid && retry != nil {
|
||||
// 反向关联同样必须存在且种类唯一;丢失所有者不能把流程任务降级为普通受理。
|
||||
if owner.Valid != (ownerID != nil) {
|
||||
return fault.Unavailable
|
||||
}
|
||||
if owner.Valid && owner.String == engine.TaskOwnerKind {
|
||||
if gate == nil || scheduled || activated == polled {
|
||||
return fault.Unavailable
|
||||
}
|
||||
source, err := readTaskWorkflowSource(ctx, tx, t.NamespaceID, id, queue, *ownerID, *gate, activated)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Kind, out.Workflow = "WORKFLOW", &source
|
||||
return nil
|
||||
}
|
||||
if gate != nil || activated || polled || owner.Valid && retry != nil {
|
||||
return fault.Unavailable
|
||||
}
|
||||
if owner.Valid {
|
||||
@@ -56,11 +76,7 @@ func (s *TaskStore) GetTaskSource(ctx context.Context, t auth.Token, id core.ID)
|
||||
out.Schedule = &source
|
||||
return nil
|
||||
}
|
||||
var occurrences int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM pulse_schedule_occurrences WHERE namespace_id=? AND task_id=?`, t.NamespaceID, id).Scan(&occurrences); err != nil {
|
||||
return err
|
||||
}
|
||||
if occurrences != 0 {
|
||||
if scheduled {
|
||||
return fault.Unavailable
|
||||
}
|
||||
if retry == nil {
|
||||
@@ -68,7 +84,10 @@ func (s *TaskStore) GetTaskSource(ctx context.Context, t auth.Token, id core.ID)
|
||||
return nil
|
||||
}
|
||||
var parent core.ID
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM pulse_tasks WHERE namespace_id=? AND id=? AND id<>? AND queue_id=? AND owner_kind IS NULL AND owner_id IS NULL AND gate_id IS NULL AND state IN ('FAILED','CANCELLED')`, t.NamespaceID, *retry, id, queue).Scan(&parent)
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM pulse_tasks t WHERE namespace_id=? AND id=? AND id<>? AND queue_id=? AND owner_kind IS NULL AND owner_id IS NULL AND gate_id IS NULL AND state IN ('FAILED','CANCELLED')
|
||||
AND NOT EXISTS(SELECT 1 FROM pulse_schedule_occurrences o WHERE o.namespace_id=t.namespace_id AND o.task_id=t.id)
|
||||
AND NOT EXISTS(SELECT 1 FROM weave_activations a WHERE a.namespace_id=t.namespace_id AND a.task_id=t.id)
|
||||
AND NOT EXISTS(SELECT 1 FROM weave_poll_rounds r WHERE r.namespace_id=t.namespace_id AND r.task_id=t.id)`, t.NamespaceID, *retry, id, queue).Scan(&parent)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.Unavailable
|
||||
}
|
||||
|
||||
@@ -70,6 +70,12 @@ func TestIntegrationTaskSource(t *testing.T) {
|
||||
expectWorkerCode(t, err, "UNAVAILABLE")
|
||||
}
|
||||
f.exec(`UPDATE pulse_tasks SET retry_of=? WHERE namespace_id=? AND id=?`, original.ID, f.resources.NamespaceID, child.Task.ID)
|
||||
// 没有外键时,父任务即使丢失owner,反向流程关联仍能揭示其不是独立任务。
|
||||
activation := executionID(t)
|
||||
f.exec(`INSERT INTO weave_activations(namespace_id,id,workflow_id,step_id,sequence,task_id,input) VALUES (?,?,?,?,1,?,'{}')`, f.resources.NamespaceID, activation, executionID(t), executionID(t), original.ID)
|
||||
_, err = f.tasks.GetTaskSource(ctx, f.token, child.Task.ID)
|
||||
expectWorkerCode(t, err, "UNAVAILABLE")
|
||||
f.exec(`DELETE FROM weave_activations WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, activation)
|
||||
f.exec(`UPDATE orbit_credentials SET revoked_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
|
||||
_, err = f.tasks.GetTaskSource(ctx, f.token, child.Task.ID)
|
||||
expectWorkerCode(t, err, "UNAUTHENTICATED")
|
||||
|
||||
99
internal/storage/mysql/task_workflow_source.go
Normal file
99
internal/storage/mysql/task_workflow_source.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"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/engine"
|
||||
)
|
||||
|
||||
// readTaskWorkflowSource 只核验创建时固定的来源,不按当前激活替换历史,也不取得运行器锁。
|
||||
// 这里只向已授权任务暴露有限引用;完整流程及定义继续使用各自权限。
|
||||
func readTaskWorkflowSource(ctx context.Context, tx *sql.Tx, ns, task, queue, activation, gate core.ID, direct bool) (service.TaskWorkflowSource, error) {
|
||||
var out service.TaskWorkflowSource
|
||||
var activationTask *core.ID
|
||||
var policyRaw []byte
|
||||
var handler, handlerVersion, payloadVersion string
|
||||
err := tx.QueryRowContext(ctx, "SELECT a.workflow_id,i.definition_id,i.definition_version_id,v.version,s.id,s.`key`,s.kind,a.id,a.sequence,a.task_id,i.policy_snapshot,t.retry_of,t.handler,t.handler_version,t.payload_version FROM weave_activations a JOIN weave_steps s ON s.namespace_id=a.namespace_id AND s.workflow_id=a.workflow_id AND s.id=a.step_id JOIN weave_instances i ON i.namespace_id=a.namespace_id AND i.id=a.workflow_id AND i.gate_id=? JOIN pulse_execution_gates g ON g.namespace_id=i.namespace_id AND g.id=i.gate_id JOIN weave_definitions d ON d.namespace_id=i.namespace_id AND d.id=i.definition_id JOIN weave_definition_versions v ON v.namespace_id=i.namespace_id AND v.definition_id=i.definition_id AND v.id=i.definition_version_id JOIN pulse_tasks t ON t.namespace_id=a.namespace_id AND t.id=? AND t.owner_kind='workflow_activation' AND t.owner_id=a.id AND t.gate_id=i.gate_id AND t.queue_id=? JOIN pulse_queues q ON q.namespace_id=t.namespace_id AND q.id=t.queue_id WHERE a.namespace_id=? AND a.id=?", gate, task, queue, ns, activation).Scan(&out.WorkflowID, &out.DefinitionID, &out.DefinitionVersionID, &out.Version, &out.StepID, &out.StepKey, &out.StepKind, &out.ActivationID, &out.ActivationSequence, &activationTask, &policyRaw, &out.RetryOf, &handler, &handlerVersion, &payloadVersion)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return out, fault.Unavailable
|
||||
}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
version, err := readWorkflowVersion(ctx, tx, ns, out.DefinitionID, out.Version)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
var policy workflowPolicy
|
||||
if version.ID != out.DefinitionVersionID || json.Unmarshal(policyRaw, &policy) != nil || policy != (workflowPolicy{1, version.ID, version.ContentDigest, version.Specification.FailurePolicy, version.Analysis.MaxTaskAttempts}) {
|
||||
return out, fault.Unavailable
|
||||
}
|
||||
program, err := engine.Compile(version.Specification)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
node, ok := program.Step(out.StepKey)
|
||||
if !ok || node.Kind != out.StepKind || out.ActivationSequence == 0 {
|
||||
return out, fault.Unavailable
|
||||
}
|
||||
contract := node.Task
|
||||
wantsParent := out.ActivationSequence > 1
|
||||
switch out.StepKind {
|
||||
case "TASK":
|
||||
if !direct || activationTask == nil || *activationTask != task || contract == nil || out.ActivationSequence > contract.Retry.MaxAttempts {
|
||||
return out, fault.Unavailable
|
||||
}
|
||||
case "POLL":
|
||||
if direct || activationTask != nil || node.Poll == nil {
|
||||
return out, fault.Unavailable
|
||||
}
|
||||
contract = &node.Poll.Task
|
||||
var first, used, maximum uint32
|
||||
var roundID core.ID
|
||||
err = tx.QueryRowContext(ctx, "SELECT r.id,r.`round`,p.first_round,p.used_rounds,p.max_rounds FROM weave_poll_rounds r JOIN weave_poll_states p ON p.namespace_id=r.namespace_id AND p.workflow_id=r.workflow_id AND p.activation_id=r.activation_id WHERE r.namespace_id=? AND r.workflow_id=? AND r.activation_id=? AND r.task_id=?", ns, out.WorkflowID, activation, task).Scan(&roundID, &out.PollRound, &first, &used, &maximum)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return out, fault.Unavailable
|
||||
}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if first == 0 || first > out.PollRound || out.PollRound > used || used > maximum || maximum != node.Poll.MaxRounds || out.ActivationSequence == 1 && first != 1 || out.ActivationSequence > maximum*contract.Retry.MaxAttempts {
|
||||
return out, fault.Unavailable
|
||||
}
|
||||
out.PollRoundID = &roundID
|
||||
wantsParent = wantsParent && out.PollRound == first
|
||||
default:
|
||||
return out, fault.Unavailable
|
||||
}
|
||||
if contract.QueueID != queue || contract.Handler != handler || contract.HandlerVersion != handlerVersion || contract.PayloadVersion != payloadVersion || wantsParent != (out.RetryOf != nil) {
|
||||
return out, fault.Unavailable
|
||||
}
|
||||
if wantsParent {
|
||||
if *out.RetryOf == task {
|
||||
return out, fault.Unavailable
|
||||
}
|
||||
// 只读直接父任务,使用步骤/代次唯一索引;不扫描或递归整个恢复历史。
|
||||
q := `SELECT COUNT(*) FROM weave_activations a JOIN pulse_tasks t ON t.namespace_id=a.namespace_id AND t.owner_kind='workflow_activation' AND t.owner_id=a.id AND t.gate_id=? AND t.queue_id=? AND t.id=? WHERE a.namespace_id=? AND a.workflow_id=? AND a.step_id=? AND a.sequence=? AND t.effect_uncertain=FALSE`
|
||||
args := []any{gate, queue, *out.RetryOf, ns, out.WorkflowID, out.StepID, out.ActivationSequence - 1}
|
||||
if out.StepKind == "TASK" {
|
||||
q += ` AND a.task_id=t.id AND t.state IN ('FAILED','CANCELLED')`
|
||||
} else {
|
||||
q += " AND a.task_id IS NULL AND t.state IN ('SUCCEEDED','FAILED','CANCELLED') AND EXISTS(SELECT 1 FROM weave_poll_rounds r JOIN weave_poll_states p ON p.namespace_id=r.namespace_id AND p.workflow_id=r.workflow_id AND p.activation_id=r.activation_id WHERE r.namespace_id=a.namespace_id AND r.workflow_id=a.workflow_id AND r.activation_id=a.id AND r.task_id=t.id AND r.`round`=? AND p.used_rounds=r.`round` AND p.first_round<=r.`round`)"
|
||||
args = append(args, out.PollRound)
|
||||
}
|
||||
var n int
|
||||
if err = tx.QueryRowContext(ctx, q, args...).Scan(&n); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if n != 1 {
|
||||
return out, fault.Unavailable
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
172
internal/storage/mysql/task_workflow_source_integration_test.go
Normal file
172
internal/storage/mysql/task_workflow_source_integration_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/instance"
|
||||
)
|
||||
|
||||
func assertWorkflowTaskSource(t *testing.T, f *executionFixture, v instance.View, task core.ID, sequence, round uint32, parent *core.ID) service.TaskSourceView {
|
||||
t.Helper()
|
||||
out, err := f.tasks.GetTaskSource(f.ctx, f.token, task)
|
||||
if err != nil || out.Kind != "WORKFLOW" || out.Workflow == nil || out.RetryOf != nil || out.Schedule != nil {
|
||||
t.Fatal("workflow source missing", out, err)
|
||||
}
|
||||
w := out.Workflow
|
||||
if w.WorkflowID != v.ID || w.DefinitionID != v.DefinitionID || w.DefinitionVersionID != v.DefinitionVersionID || w.Version != v.Version || w.ActivationSequence != sequence || w.PollRound != round || !reflect.DeepEqual(w.RetryOf, parent) {
|
||||
t.Fatal("wrong fixed source", w)
|
||||
}
|
||||
if round == 0 && (w.StepKind != "TASK" || w.PollRoundID != nil) || round > 0 && (w.StepKind != "POLL" || w.PollRoundID == nil) {
|
||||
t.Fatal("wrong step source kind", w)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestIntegrationTaskWorkflowSource(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
db, err := Open(ctx, dsn, 16, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err = Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Run("fixed_version_and_task_only_authorization", func(t *testing.T) {
|
||||
f := newExecutionFixture(t, ctx, db)
|
||||
otherQueue := executionID(t)
|
||||
f.exec(`INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) SELECT namespace_id,?,?,max_concurrency,rate_per_second,policy FROM pulse_queues WHERE namespace_id=? AND id=?`, otherQueue, string(otherQueue), f.resources.NamespaceID, f.resources.QueueID)
|
||||
spec := runtimeSpec(f.resources.QueueID, 2)
|
||||
spec.Body.Steps[1].Task.QueueID = otherQueue
|
||||
v := runtimeStart(t, f, spec)
|
||||
steps, err := instance.New(NewWorkflowInstanceStore(db)).Steps(ctx, f.token, v.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := *steps.Steps[0].TaskID
|
||||
before := f.get(task)
|
||||
original := assertWorkflowTaskSource(t, f, v, task, 1, 0, nil)
|
||||
published := spec
|
||||
published.TimeoutMS++
|
||||
if _, err = definition.New(NewWorkflowDefinitionStore(db)).Publish(ctx, f.token, v.DefinitionID, "v2", definition.PublishInput{Version: "v2", Specification: published, Reason: "new publication must not retarget source"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.exec(`UPDATE orbit_credentials SET permissions=JSON_OBJECT('actions',JSON_ARRAY('tasks:read'),'queues',JSON_ARRAY(?)) WHERE namespace_id=? AND id=?`, string(f.resources.QueueID), f.resources.NamespaceID, f.resources.CredentialID)
|
||||
after := assertWorkflowTaskSource(t, f, v, task, 1, 0, nil)
|
||||
if !reflect.DeepEqual(original, after) || !reflect.DeepEqual(before, f.get(task)) {
|
||||
t.Fatal("source read or publication changed original task")
|
||||
}
|
||||
if _, err = instance.New(NewWorkflowInstanceStore(db)).Get(ctx, f.token, v.ID); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("source granted full workflow read", err)
|
||||
}
|
||||
if _, err = definition.New(NewWorkflowDefinitionStore(db)).Version(ctx, f.token, v.DefinitionID, "v1"); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("source granted definition read", err)
|
||||
}
|
||||
other := newExecutionFixture(t, ctx, db)
|
||||
if _, err = other.tasks.GetTaskSource(ctx, other.token, task); fault.Code(err) != "NOT_FOUND" {
|
||||
t.Fatal("cross namespace source", err)
|
||||
}
|
||||
f.exec(`UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.queues',JSON_ARRAY(?)) WHERE namespace_id=? AND id=?`, string(otherQueue), f.resources.NamespaceID, f.resources.CredentialID)
|
||||
if _, err = f.tasks.GetTaskSource(ctx, f.token, task); fault.Code(err) != "NOT_FOUND" {
|
||||
t.Fatal("wrong queue source", err)
|
||||
}
|
||||
})
|
||||
t.Run("damaged_associations_never_become_direct_sources", func(t *testing.T) {
|
||||
f := newExecutionFixture(t, ctx, db)
|
||||
v := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 1))
|
||||
steps, err := instance.New(NewWorkflowInstanceStore(db)).Steps(ctx, f.token, v.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
step := steps.Steps[0]
|
||||
task, activation := *step.TaskID, *step.ActivationID
|
||||
original := assertWorkflowTaskSource(t, f, v, task, 1, 0, nil)
|
||||
var gate core.ID
|
||||
var policy string
|
||||
if err = db.QueryRowContext(ctx, `SELECT gate_id,policy_snapshot FROM weave_instances WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, v.ID).Scan(&gate, &policy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
broken := func() {
|
||||
t.Helper()
|
||||
out, err := f.tasks.GetTaskSource(ctx, f.token, task)
|
||||
if fault.Code(err) != "UNAVAILABLE" || !reflect.DeepEqual(out, service.TaskSourceView{}) {
|
||||
t.Fatal("invalid source leaked", out, err)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
table, field string
|
||||
value, restore any
|
||||
}{
|
||||
{"pulse_tasks", "owner_id", executionID(t), activation},
|
||||
{"pulse_tasks", "gate_id", executionID(t), gate},
|
||||
{"pulse_tasks", "handler_version", "mismatched", f.get(task).HandlerVersion},
|
||||
{"weave_activations", "workflow_id", executionID(t), v.ID},
|
||||
{"weave_activations", "step_id", executionID(t), step.ID},
|
||||
{"weave_activations", "task_id", nil, task},
|
||||
{"weave_steps", "kind", "SIGNAL", "TASK"},
|
||||
{"weave_instances", "definition_version_id", executionID(t), v.DefinitionVersionID},
|
||||
{"weave_instances", "policy_snapshot", `{}`, policy},
|
||||
} {
|
||||
t.Run(tc.table+"/"+tc.field, func(t *testing.T) {
|
||||
id := map[string]core.ID{"pulse_tasks": task, "weave_activations": activation, "weave_steps": step.ID, "weave_instances": v.ID}[tc.table]
|
||||
q := "UPDATE " + tc.table + " SET " + tc.field + "=? WHERE namespace_id=? AND id=?"
|
||||
f.exec(q, tc.value, f.resources.NamespaceID, id)
|
||||
broken()
|
||||
f.exec(q, tc.restore, f.resources.NamespaceID, id)
|
||||
})
|
||||
}
|
||||
f.exec(`UPDATE pulse_tasks SET owner_kind=NULL,owner_id=NULL,gate_id=NULL WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, task)
|
||||
broken()
|
||||
f.exec(`UPDATE pulse_tasks SET owner_kind='workflow_activation',owner_id=?,gate_id=? WHERE namespace_id=? AND id=?`, activation, gate, f.resources.NamespaceID, task)
|
||||
round := executionID(t)
|
||||
f.exec("INSERT INTO weave_poll_rounds(namespace_id,id,workflow_id,activation_id,`round`,task_id) VALUES (?,?,?,?,1,?)", f.resources.NamespaceID, round, v.ID, activation, task)
|
||||
broken()
|
||||
f.exec(`DELETE FROM weave_poll_rounds WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, round)
|
||||
if after := assertWorkflowTaskSource(t, f, v, task, 1, 0, nil); !reflect.DeepEqual(original, after) {
|
||||
t.Fatal("restored source changed")
|
||||
}
|
||||
})
|
||||
t.Run("recovered_poll_parent_must_be_same_step_and_round", func(t *testing.T) {
|
||||
f, v, ref := failedPoll(t, ctx, db, false)
|
||||
old := assertWorkflowTaskSource(t, f, v, ref.TaskID, 1, 1, nil)
|
||||
child := retryPollStep(t, f, v.ID)
|
||||
current := assertWorkflowTaskSource(t, f, v, child.Task.ID, 2, 1, &ref.TaskID)
|
||||
if current.Workflow.ActivationID == old.Workflow.ActivationID {
|
||||
t.Fatal("recovery source collapsed activation history")
|
||||
}
|
||||
if after := assertWorkflowTaskSource(t, f, v, ref.TaskID, 1, 1, nil); !reflect.DeepEqual(old, after) {
|
||||
t.Fatal("old source retargeted")
|
||||
}
|
||||
stranger := executionID(t)
|
||||
f.exec(`UPDATE pulse_tasks SET retry_of=? WHERE namespace_id=? AND id=?`, stranger, f.resources.NamespaceID, child.Task.ID)
|
||||
if _, err = f.tasks.GetTaskSource(ctx, f.token, child.Task.ID); fault.Code(err) != "UNAVAILABLE" {
|
||||
t.Fatal("unrelated retry parent accepted", err)
|
||||
}
|
||||
f.exec(`UPDATE pulse_tasks SET retry_of=? WHERE namespace_id=? AND id=?`, ref.TaskID, f.resources.NamespaceID, child.Task.ID)
|
||||
f.exec("UPDATE weave_poll_rounds SET `round`=2 WHERE namespace_id=? AND task_id=?", f.resources.NamespaceID, ref.TaskID)
|
||||
if _, err = f.tasks.GetTaskSource(ctx, f.token, child.Task.ID); fault.Code(err) != "UNAVAILABLE" {
|
||||
t.Fatal("different parent round accepted", err)
|
||||
}
|
||||
f.exec("UPDATE weave_poll_rounds SET `round`=1 WHERE namespace_id=? AND task_id=?", f.resources.NamespaceID, ref.TaskID)
|
||||
f.exec(`UPDATE pulse_tasks SET owner_kind=NULL,owner_id=NULL,gate_id=NULL,retry_of=NULL WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, child.Task.ID)
|
||||
if _, err = f.tasks.GetTaskSource(ctx, f.token, child.Task.ID); fault.Code(err) != "UNAVAILABLE" {
|
||||
t.Fatal("poll downgraded to direct", err)
|
||||
}
|
||||
// 明确保持数据库无外键;这里只回滚测试注入,绝不改写生产历史。
|
||||
f.exec(`UPDATE pulse_tasks SET owner_kind='workflow_activation',owner_id=?,gate_id=(SELECT gate_id FROM weave_instances WHERE namespace_id=? AND id=?),retry_of=? WHERE namespace_id=? AND id=?`, current.Workflow.ActivationID, f.resources.NamespaceID, v.ID, ref.TaskID, f.resources.NamespaceID, child.Task.ID)
|
||||
assertWorkflowTaskSource(t, f, v, child.Task.ID, 2, 1, &ref.TaskID)
|
||||
})
|
||||
}
|
||||
@@ -70,8 +70,10 @@ func TestIntegrationWorkflowPollRecovery(t *testing.T) {
|
||||
t.Run("retry_same_round_then_next_round_and_retry_again", func(t *testing.T) {
|
||||
f, v, ref := failedPoll(t, ctx, db, false)
|
||||
first := f.get(ref.TaskID)
|
||||
assertWorkflowTaskSource(t, f, v, first.ID, 1, 1, nil)
|
||||
original := recoveryStep(t, f, v.ID, "query")
|
||||
out := retryPollStep(t, f, v.ID)
|
||||
assertWorkflowTaskSource(t, f, v, out.Task.ID, 2, 1, &first.ID)
|
||||
if out.Task.Retry.MaxAttempts != 2 || !out.Task.DeadlineAt.Equal(first.DeadlineAt) || *out.Task.RetryOf != first.ID || *out.Step.ActivationID == original.ActivationID || out.Step.TaskID != nil || !sameWorkflowJSON(out.Task.Payload, first.Payload) {
|
||||
t.Fatal("recovery reset original identity, task deadline or budget")
|
||||
}
|
||||
@@ -91,12 +93,14 @@ func TestIntegrationWorkflowPollRecovery(t *testing.T) {
|
||||
runClock(t, f, c)
|
||||
ref = retryStart(t, f)
|
||||
second := f.get(ref.TaskID)
|
||||
assertWorkflowTaskSource(t, f, v, second.ID, 2, 2, nil)
|
||||
if second.Retry.MaxAttempts != 3 || second.RetryOf != nil || second.BusinessKey != first.BusinessKey || second.ConcurrencyKey != first.ConcurrencyKey {
|
||||
t.Fatal("next business round did not receive independent original policy")
|
||||
}
|
||||
retryFinish(t, f, ref, "FAILED", false)
|
||||
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
|
||||
out = retryPollStep(t, f, v.ID)
|
||||
assertWorkflowTaskSource(t, f, v, out.Task.ID, 3, 2, &second.ID)
|
||||
if out.Task.Retry.MaxAttempts != 2 || !out.Task.DeadlineAt.Equal(second.DeadlineAt) || *out.Task.RetryOf != second.ID || f.count("weave_poll_states", "first_round=2 AND used_rounds=2 AND first_round_attempts_used=1") != 1 {
|
||||
t.Fatal("second round recovery reset logical round")
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ func TestIntegrationWorkflowRecovery(t *testing.T) {
|
||||
retryFinish(t, f, ref, "FAILED", false)
|
||||
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
|
||||
oldTask, _ := f.tasks.GetTask(ctx, f.token, ref.TaskID)
|
||||
oldSource := assertWorkflowTaskSource(t, f, v, ref.TaskID, 1, 0, nil)
|
||||
in := recovery.RetryStepInput{StepDecision: recoveryStep(t, f, v.ID, "task0"), SafeToRetry: true}
|
||||
key := string(executionID(t))
|
||||
out, err := api.RetryStep(ctx, f.token, v.ID, key, in)
|
||||
@@ -141,6 +142,11 @@ func TestIntegrationWorkflowRecovery(t *testing.T) {
|
||||
if out.Task.ID == ref.TaskID || out.Task.RetryOf == nil || *out.Task.RetryOf != ref.TaskID || out.Task.Retry.MaxAttempts != oldTask.Retry.MaxAttempts-1 || !out.Task.DeadlineAt.Equal(oldTask.DeadlineAt) || !sameWorkflowJSON(out.Task.Payload, oldTask.Payload) || *out.Step.ActivationID == in.ActivationID {
|
||||
t.Fatal("retry replaced identity or reset budget")
|
||||
}
|
||||
newSource := assertWorkflowTaskSource(t, f, v, out.Task.ID, 2, 0, &ref.TaskID)
|
||||
if newSource.Workflow.ActivationID == oldSource.Workflow.ActivationID {
|
||||
t.Fatal("retry source lost generation")
|
||||
}
|
||||
assertWorkflowTaskSource(t, f, v, ref.TaskID, 1, 0, nil)
|
||||
if f.count("weave_activations", "id=? AND state='FAILED'", in.ActivationID) != 1 {
|
||||
t.Fatal("old activation rewritten")
|
||||
}
|
||||
|
||||
@@ -11,12 +11,30 @@ import (
|
||||
type TaskSourceView struct {
|
||||
// TaskID 本次已授权查询的任务标识。
|
||||
TaskID core.ID `json:"task_id"`
|
||||
// Kind 当前支持DIRECT、MANUAL_RETRY、SCHEDULE;分别是普通受理、人工派生、计划触发。
|
||||
// Kind DIRECT、MANUAL_RETRY、SCHEDULE或WORKFLOW;分别是普通受理、人工派生、计划触发或流程步骤。
|
||||
Kind string `json:"kind"`
|
||||
// RetryOf 人工重试的直接原任务,限同一命名空间及队列;其他种类省略。
|
||||
RetryOf *core.ID `json:"retry_of,omitempty"`
|
||||
// Schedule 计划触发的固定来源,仅SCHEDULE返回,不包含任务模板或计划配置。
|
||||
Schedule *TaskScheduleSource `json:"schedule,omitempty"`
|
||||
// Workflow 流程任务的固定来源,不返回其他步骤、业务输入或完整定义。
|
||||
Workflow *TaskWorkflowSource `json:"workflow,omitempty"`
|
||||
}
|
||||
|
||||
// TaskWorkflowSource 指向任务自己的历史激活;人工恢复不会把旧任务改指向当前激活。
|
||||
type TaskWorkflowSource struct {
|
||||
WorkflowID core.ID `json:"workflow_id"` // WorkflowID 所属流程实例。
|
||||
DefinitionID core.ID `json:"definition_id"` // DefinitionID 稳定定义身份。
|
||||
DefinitionVersionID core.ID `json:"definition_version_id"` // DefinitionVersionID 实例固定的不可变版本记录。
|
||||
Version string `json:"version"` // Version 固定发布版本名,不跟随新发布版本。
|
||||
StepID core.ID `json:"step_id"` // StepID 实例内逻辑步骤身份。
|
||||
StepKey string `json:"step_key"` // StepKey 固定定义中的步骤名称。
|
||||
StepKind string `json:"step_kind"` // StepKind TASK或POLL,不返回内部根或等待节点。
|
||||
ActivationID core.ID `json:"activation_id"` // ActivationID 创建此任务的激活身份,可能已经是历史代次。
|
||||
ActivationSequence uint32 `json:"activation_sequence"` // ActivationSequence 此步骤的激活代次,从1开始。
|
||||
PollRound uint32 `json:"poll_round,omitempty"` // PollRound 原业务查询轮编号,跨激活恢复不重置;TASK省略。
|
||||
PollRoundID *core.ID `json:"poll_round_id,omitempty"` // PollRoundID 当前任务对应的唯一轮次记录;TASK省略。
|
||||
RetryOf *core.ID `json:"retry_of,omitempty"` // RetryOf 人工恢复所关联的直接旧任务,限同一流程/步骤及业务轮次,不递归展开。
|
||||
}
|
||||
|
||||
// TaskScheduleSource 关联已生成任务的触发记录及当时的不可变计划版本。
|
||||
|
||||
@@ -145,6 +145,28 @@ func run() error {
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
steps, err := client.GetWorkflowSteps(ctx, accepted.Instance.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified := 0
|
||||
for _, step := range steps.Steps {
|
||||
if step.TaskID == nil {
|
||||
continue
|
||||
}
|
||||
source, err := client.GetTaskSource(ctx, *step.TaskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if source.Workflow == nil || source.Workflow.WorkflowID != accepted.Instance.ID || source.Workflow.DefinitionVersionID != accepted.Instance.DefinitionVersionID || source.Workflow.StepID != step.ID || source.Workflow.ActivationID != *step.ActivationID || source.Workflow.ActivationSequence != 1 {
|
||||
return errors.New("workflow task source mismatch")
|
||||
}
|
||||
verified++
|
||||
}
|
||||
if verified != 3 {
|
||||
return errors.New("incomplete workflow task sources")
|
||||
}
|
||||
fmt.Printf("workflow_sources_verified=%d fixed_version=%s\n", verified, accepted.Instance.Version)
|
||||
stop()
|
||||
err = <-done
|
||||
waited = true
|
||||
|
||||
@@ -10,12 +10,30 @@ import (
|
||||
type TaskSource struct {
|
||||
// TaskID 本次已授权查询的任务标识。
|
||||
TaskID string `json:"task_id"`
|
||||
// Kind 当前支持DIRECT、MANUAL_RETRY、SCHEDULE;分别是普通受理、人工派生、计划触发。
|
||||
// Kind DIRECT、MANUAL_RETRY、SCHEDULE或WORKFLOW;分别是普通受理、人工派生、计划触发或流程步骤。
|
||||
Kind string `json:"kind"`
|
||||
// RetryOf 人工重试的直接原任务,限同一命名空间及队列;其他种类省略。
|
||||
RetryOf *string `json:"retry_of,omitempty"`
|
||||
// Schedule 计划触发的固定来源,仅SCHEDULE返回,不包含任务模板或计划配置。
|
||||
Schedule *TaskScheduleSource `json:"schedule,omitempty"`
|
||||
// Workflow 流程任务的固定来源,不返回其他步骤、业务输入或完整定义。
|
||||
Workflow *TaskWorkflowSource `json:"workflow,omitempty"`
|
||||
}
|
||||
|
||||
// TaskWorkflowSource 指向任务自己的历史激活;人工恢复不会把旧任务改指向当前激活。
|
||||
type TaskWorkflowSource struct {
|
||||
WorkflowID string `json:"workflow_id"` // WorkflowID 所属流程实例。
|
||||
DefinitionID string `json:"definition_id"` // DefinitionID 稳定定义身份。
|
||||
DefinitionVersionID string `json:"definition_version_id"` // DefinitionVersionID 实例固定的不可变版本记录。
|
||||
Version string `json:"version"` // Version 固定发布版本名,不跟随新发布版本。
|
||||
StepID string `json:"step_id"` // StepID 实例内逻辑步骤身份。
|
||||
StepKey string `json:"step_key"` // StepKey 固定定义中的步骤名称。
|
||||
StepKind string `json:"step_kind"` // StepKind TASK或POLL,不返回内部根或等待节点。
|
||||
ActivationID string `json:"activation_id"` // ActivationID 创建此任务的激活身份,可能已经是历史代次。
|
||||
ActivationSequence uint32 `json:"activation_sequence"` // ActivationSequence 此步骤的激活代次,从1开始。
|
||||
PollRound uint32 `json:"poll_round,omitempty"` // PollRound 原业务查询轮编号,跨激活恢复不重置;TASK省略。
|
||||
PollRoundID *string `json:"poll_round_id,omitempty"` // PollRoundID 当前任务对应的唯一轮次记录;TASK省略。
|
||||
RetryOf *string `json:"retry_of,omitempty"` // RetryOf 人工恢复所关联的直接旧任务,限同一流程/步骤及业务轮次,不递归展开。
|
||||
}
|
||||
|
||||
// TaskScheduleSource 关联已生成任务的触发记录及当时的不可变计划版本。
|
||||
@@ -34,7 +52,7 @@ type TaskScheduleSource struct {
|
||||
CoveredUntil *time.Time `json:"covered_until,omitempty"`
|
||||
}
|
||||
|
||||
// GetTaskSource 查询直接来源;不会授予来源计划的读取/管理或任务执行权限。
|
||||
// GetTaskSource 查询直接来源;不会授予来源计划/流程的读取、管理或执行权限。
|
||||
func (c *Client) GetTaskSource(ctx context.Context, id string) (TaskSource, error) {
|
||||
var out TaskSource
|
||||
if !validID(id) {
|
||||
@@ -49,14 +67,24 @@ func (c *Client) GetTaskSource(ctx context.Context, id string) (TaskSource, erro
|
||||
valid := false
|
||||
switch out.Kind {
|
||||
case "DIRECT":
|
||||
valid = out.RetryOf == nil && out.Schedule == nil
|
||||
valid = out.RetryOf == nil && out.Schedule == nil && out.Workflow == nil
|
||||
case "MANUAL_RETRY":
|
||||
valid = out.RetryOf != nil && validID(*out.RetryOf) && *out.RetryOf != id && out.Schedule == nil
|
||||
valid = out.RetryOf != nil && validID(*out.RetryOf) && *out.RetryOf != id && out.Schedule == nil && out.Workflow == nil
|
||||
case "SCHEDULE":
|
||||
s := out.Schedule
|
||||
if s != nil && out.RetryOf == nil && validID(s.ScheduleID) && validID(s.OccurrenceID) && s.ScheduleVersion > 0 && validSourceTime(s.ScheduledAt) {
|
||||
if s != nil && out.RetryOf == nil && out.Workflow == nil && validID(s.ScheduleID) && validID(s.OccurrenceID) && s.ScheduleVersion > 0 && validSourceTime(s.ScheduledAt) {
|
||||
valid = s.Decision == "FIRED" && s.CoveredUntil == nil || s.Decision == "COALESCED" && s.CoveredUntil != nil && validSourceTime(*s.CoveredUntil) && s.CoveredUntil.After(s.ScheduledAt)
|
||||
}
|
||||
case "WORKFLOW":
|
||||
w := out.Workflow
|
||||
if w != nil && out.RetryOf == nil && out.Schedule == nil && validID(w.WorkflowID) && validID(w.DefinitionID) && validID(w.DefinitionVersionID) && workflowVersionName(w.Version) && validID(w.StepID) && workflowVersionName(w.StepKey) && validID(w.ActivationID) && w.ActivationSequence > 0 {
|
||||
valid = w.StepKind == "TASK" && w.PollRound == 0 && w.PollRoundID == nil || w.StepKind == "POLL" && w.PollRound > 0 && w.PollRoundID != nil && validID(*w.PollRoundID)
|
||||
if w.RetryOf != nil {
|
||||
valid = valid && w.ActivationSequence > 1 && validID(*w.RetryOf) && *w.RetryOf != id
|
||||
} else if w.StepKind == "TASK" && w.ActivationSequence > 1 {
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return TaskSource{}, errors.New("invalid or unsupported task source response")
|
||||
|
||||
@@ -39,8 +39,43 @@ func TestTaskSourceValidationAndPrecision(t *testing.T) {
|
||||
t.Fatal("version precision lost")
|
||||
}
|
||||
}
|
||||
workflow := TaskWorkflowSource{WorkflowID: parent, DefinitionID: parent, DefinitionVersionID: occurrence, Version: "v1", StepID: parent, StepKey: "query", StepKind: "TASK", ActivationID: occurrence, ActivationSequence: 1}
|
||||
for _, kind := range []string{"TASK", "POLL"} {
|
||||
for _, sequence := range []uint32{1, 2} {
|
||||
w := workflow
|
||||
w.StepKind, w.ActivationSequence = kind, sequence
|
||||
if sequence == 2 {
|
||||
w.RetryOf = &p
|
||||
}
|
||||
if kind == "POLL" {
|
||||
w.PollRound, w.PollRoundID = 2, &p
|
||||
}
|
||||
reply.Store(TaskSource{TaskID: task, Kind: "WORKFLOW", Workflow: &w})
|
||||
if _, err := client.GetTaskSource(context.Background(), task); err != nil {
|
||||
t.Fatal("valid workflow source", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
self := task
|
||||
invalid := []TaskSource{{TaskID: parent, Kind: "DIRECT"}, {TaskID: task, Kind: "unknown"}, {TaskID: task, Kind: "DIRECT", RetryOf: &p}, {TaskID: task, Kind: "MANUAL_RETRY"}, {TaskID: task, Kind: "MANUAL_RETRY", RetryOf: &self}, {TaskID: task, Kind: "SCHEDULE"}}
|
||||
invalid = append(invalid, TaskSource{TaskID: task, Kind: "WORKFLOW"}, TaskSource{TaskID: task, Kind: "DIRECT", Workflow: &workflow}, TaskSource{TaskID: task, Kind: "WORKFLOW", Workflow: &workflow, RetryOf: &p})
|
||||
for _, change := range []func(*TaskWorkflowSource){
|
||||
func(w *TaskWorkflowSource) { w.WorkflowID = "invalid" },
|
||||
func(w *TaskWorkflowSource) { w.Version = "../version" },
|
||||
func(w *TaskWorkflowSource) { w.StepKey = "$root" },
|
||||
func(w *TaskWorkflowSource) { w.StepKind = "SIGNAL" },
|
||||
func(w *TaskWorkflowSource) { w.ActivationSequence = 0 },
|
||||
func(w *TaskWorkflowSource) { w.ActivationSequence = 2 },
|
||||
func(w *TaskWorkflowSource) { w.RetryOf = &p },
|
||||
func(w *TaskWorkflowSource) { w.ActivationSequence = 2; w.RetryOf = &self },
|
||||
func(w *TaskWorkflowSource) { w.PollRound = 1 },
|
||||
func(w *TaskWorkflowSource) { w.PollRoundID = &p },
|
||||
func(w *TaskWorkflowSource) { w.StepKind = "POLL"; w.PollRound = 1 },
|
||||
} {
|
||||
w := workflow
|
||||
change(&w)
|
||||
invalid = append(invalid, TaskSource{TaskID: task, Kind: "WORKFLOW", Workflow: &w})
|
||||
}
|
||||
for _, change := range []func(*TaskScheduleSource){func(v *TaskScheduleSource) { v.ScheduleVersion = 0 }, func(v *TaskScheduleSource) { v.Decision = "SKIPPED" }, func(v *TaskScheduleSource) { v.Decision = "COALESCED" }, func(v *TaskScheduleSource) { v.CoveredUntil = &until }, func(v *TaskScheduleSource) { v.ScheduledAt = time.Time{} }, func(v *TaskScheduleSource) { v.OccurrenceID = "invalid" }} {
|
||||
v := valid[2]
|
||||
s := *v.Schedule
|
||||
|
||||
@@ -137,6 +137,18 @@ func workflowAPIError(t *testing.T, err error, code string) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertWorkflowSourceSDK(t *testing.T, ctx context.Context, api *Client, v WorkflowInstance, task string, sequence, round uint32, parent string) {
|
||||
t.Helper()
|
||||
source, err := api.GetTaskSource(ctx, task)
|
||||
if err != nil || source.Workflow == nil || source.Kind != "WORKFLOW" {
|
||||
t.Fatal("workflow source", err)
|
||||
}
|
||||
w := source.Workflow
|
||||
if w.WorkflowID != v.ID || w.DefinitionID != v.DefinitionID || w.DefinitionVersionID != v.DefinitionVersionID || w.Version != v.Version || w.ActivationSequence != sequence || w.PollRound != round || parent == "" && w.RetryOf != nil || parent != "" && (w.RetryOf == nil || *w.RetryOf != parent) {
|
||||
t.Fatal("fixed source mismatch", w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
api, queue, token := integrationWorkerClient(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
@@ -212,6 +224,7 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
w := workflowWorkerSDK(t, ctx, api, queue, handler)
|
||||
defer w.Close()
|
||||
first := finishWorkflowSDK(t, ctx, w, WorkerSucceeded, workflowExact)
|
||||
assertWorkflowSourceSDK(t, ctx, api, started.Instance, first.Task.ID, 1, 0, "")
|
||||
work, err := api.GetWorkflowStep(ctx, id, "work")
|
||||
if err != nil || work.Step.TaskID == nil || *work.Step.TaskID != first.Task.ID || work.Step.ActivationSequence != 1 {
|
||||
t.Fatal("step metadata", err)
|
||||
@@ -349,6 +362,8 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
if err != nil || !repeated.Deduplicated || repeated.Task.ID != child.Task.ID {
|
||||
t.Fatal(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)
|
||||
a := finishWorkflowSDK(t, ctx, w, WorkerSucceeded, workflowExact)
|
||||
if a.Task.ID != child.Task.ID {
|
||||
@@ -374,6 +389,7 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertWorkflowSourceSDK(t, ctx, api, v, source.ID, 1, 1, "")
|
||||
reviewed, err := api.ReviewWorkflowTask(ctx, v.ID, workerTestID(t), ReviewWorkflowTaskRequest{WorkflowRecoveryDecision: workflowDecision(step).WorkflowRecoveryDecision, TaskID: source.ID, ExpectedTaskRevision: source.Revision, ExecutionSettled: true, Outcome: TaskSucceeded, Result: json.RawMessage(`"PENDING"`)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -390,13 +406,36 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continueWorkflowSDK(t, ctx, api, v.ID)
|
||||
finishWorkflowSDK(t, ctx, w, WorkerSucceeded, json.RawMessage(`"DONE"`))
|
||||
last := finishWorkflowSDK(t, ctx, w, WorkerSucceeded, json.RawMessage(`"DONE"`))
|
||||
assertWorkflowSourceSDK(t, ctx, api, v, last.Task.ID, 1, 2, "")
|
||||
assertWorkflowSourceSDK(t, ctx, api, v, source.ID, 1, 1, "")
|
||||
waitWorkflowSDK(t, ctx, api, v.ID, "SUCCEEDED")
|
||||
final, err := api.GetWorkflowStep(ctx, v.ID, "query")
|
||||
if err != nil || final.Poll.UsedRounds != 2 || final.Poll.DeadlineAt != step.Poll.DeadlineAt {
|
||||
t.Fatal("poll budget changed", err)
|
||||
}
|
||||
})
|
||||
t.Run("poll_recovery_source_keeps_business_round", func(t *testing.T) {
|
||||
spec := workflowSDKSpec(queue, handler)
|
||||
task := *spec.Body.Steps[0].Task
|
||||
task.ResultType = WorkflowType{Kind: "STRING", Enum: []string{"PENDING", "DONE"}}
|
||||
spec.Body = WorkflowBlock{Steps: []WorkflowStep{{Key: "query", Kind: "POLL", Poll: &WorkflowPoll{Task: task, MaxRounds: 3, TimeoutMS: 60000, IntervalMS: 1000, Pending: []string{"PENDING"}, Succeeded: []string{"DONE"}}}}, OutputType: task.ResultType, Output: WorkflowRef("query")}
|
||||
v := startWorkflowSDK(t, ctx, api, spec)
|
||||
w := workflowWorkerSDK(t, ctx, api, queue, handler)
|
||||
defer w.Close()
|
||||
old := finishWorkflowSDK(t, ctx, w, WorkerFailed, json.RawMessage(`"failure"`))
|
||||
step := waitWorkflowStepSDK(t, ctx, api, v.ID, "query", func(v WorkflowStepDetail) bool { return v.Step.State == "NEEDS_REVIEW" })
|
||||
child, err := api.RetryWorkflowStep(ctx, v.ID, workerTestID(t), RetryWorkflowStepRequest{WorkflowStepDecision: workflowDecision(step), SafeToRetry: true})
|
||||
if err != nil || child.Task == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertWorkflowSourceSDK(t, ctx, api, v, child.Task.ID, 2, 1, old.Task.ID)
|
||||
assertWorkflowSourceSDK(t, ctx, api, v, old.Task.ID, 1, 1, "")
|
||||
continueWorkflowSDK(t, ctx, api, v.ID)
|
||||
finishWorkflowSDK(t, ctx, w, WorkerSucceeded, json.RawMessage(`"DONE"`))
|
||||
waitWorkflowSDK(t, ctx, api, v.ID, "SUCCEEDED")
|
||||
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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user