feat: add bounded polling and evidence-based workflow recovery examples

This commit is contained in:
2026-09-18 09:52:35 +08:00
parent a4d9900e2c
commit 9f333b5003
14 changed files with 941 additions and 28 deletions

View File

@@ -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已接入,流程任务来源已接入,已接入有界历史查询,实例分页已接入,已接入状态事件时间线,独立轮询/恢复示例已接入,继续W06/W09/W14/W15精确回归;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已接入,流程任务来源已接入,有界历史已接入,实例分页已接入,已接入状态事件时间线,独立轮询/恢复示例已接入,继续W06/W09/W14/W15精确回归。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,7 +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新轮无父任务。不能改指当前激活、递归展开历史或将来源查询加入派发路径;无外键。
@@ -71,3 +71,5 @@
- 实例列表按固定定义全部队列在SQL中筛选,再校验原定义/摘要、范围、策略及关联;不能用已执行任务推断权限。000024增加不可变范围索引与7个列表索引,无外键;发布与范围同事务,旧DSL常量中的queue_id不纳入范围。游标绑定命名空间/全部筛选/规范化队列权限,跨页不承诺快照,limit只限制返回候选量。先排序锁整页队列,不在实例锁之后追加队列锁。
- 状态时间线只读取已存workflow/workflow_step/workflow_signal事件,排除$root;两条路由均显式workflows:audit:read并核对固定版本全部队列及实际子资源。000025派生只读索引不授予权限、不改历史原文、不补造旧信号转换,无外键;新信号状态和事件同事务,批量拒绝32条一批且失败整笔回滚。时间/ID排序与资源revision均不是提交水位;Pulse可靠消费仍走持久订阅。旧写节点停止后迁移,不能承诺混合版本无历史缺口。
- 独立workflow-poll示例验证2轮3次请求与2轮耗尽核查;workflow-recovery实际提交后断线,只提交一次,按证据依次ReviewTask/ResolveStep/Continue,再显式RESUME,旧UNKNOWN和原截止保持。受控内存渠道不是持久生产证据。下一批依docs/reports/phase-4-5.md补W06实际版本后继、W09三方竞争、W14暂停竞争和W15暂停全停;P4/P5仍不整体宣称完成,P6/P7继续保留。

View File

@@ -10,7 +10,7 @@ Orbit 是面向多个业务项目的通用任务调度与流程编排平台,
已完成 P0–P3 平台功能阶段验收:36 个数据模型、版本化迁移、凭据及范围校验、队列与任务 API、WSS 执行、独立 SDK、自动 Worker 和 [执行尝试/证据查询](docs/api/execution-history.md)。[Worker Schema](docs/api/worker-schema.md) 与真实进程强杀恢复回归已通过;[安全重试](docs/api/retries.md)、[定时计划](docs/api/schedules.md)、[运行中取消](docs/api/cancellation.md) 、[队列资源控制](docs/api/queues.md) 、[应用共享配额](docs/api/application-resources.md) 、[资源键互斥](docs/api/keyed-concurrency.md) 和 [优先级/节点公平派发](docs/api/priority-and-fairness.md) 已接入服务端和 SDK,[可靠结果订阅](docs/api/subscriptions.md) 已实现持久化核心、HTTP/WSS、管理 SDK 与示例,[消费 SDK](docs/sdk/event-consumer.md) 和 [独立 MySQL Inbox 示例](docs/examples/result-inbox.md) 已实现;[批量任务受理](docs/api/batch-tasks.md) 已提供逐项幂等、失败分类与 SDK 示例;[人工核查与重试](docs/api/task-recovery.md) 已提供带证据的管理接口、关联新任务、审计查询及运行示例;[业务键与时间范围查询](docs/api/task-queries.md) 已提供过滤、分页与SDK场景;[任务来源查询](docs/api/task-source.md) 已提供人工重试父任务、计划触发记录与固定版本;[业务事务Outbox](docs/examples/task-outbox.md) 已提供独立业务库、稳定提交身份、发送恢复与CLI;[相对延迟执行](docs/examples/delayed-task.md)已提供数据库计时及原请求恢复;完整范围核对见[P3验收](docs/reports/phase-3.md),流程引擎与生产验证按 [持续开发计划](docs/08-development-plan.md) 继续实现。
Weave已提供[定义发布](docs/api/workflow-definitions.md)、[实例启动、步骤详情、暂停/继续/取消、信号和操作审计](docs/api/workflows.md),以及[六类人工核查恢复](docs/api/workflow-recovery.md)的HTTP与独立Go SDK。持久运行器支持顺序、分支、有限并行、TIMER、SIGNAL及有界POLL,恢复沿用原预算与截止。[并行作业与回执示例](docs/examples/workflow.md)提供完整调用链;[历史激活/轮次/信号及信号审计](docs/api/workflow-history.md)已接入;[实例筛选分页](docs/api/workflow-queries.md)已接入,[状态事件时间线](docs/api/workflow-events.md)已接入,更多独立轮询/恢复示例继续交付;[任务流程来源](docs/api/task-source.md#5-流程固定来源)已接入,P4/P5与生产验收尚未整体完成。
Weave已提供[定义发布](docs/api/workflow-definitions.md)、[实例启动、步骤详情、暂停/继续/取消、信号和操作审计](docs/api/workflows.md),以及[六类人工核查恢复](docs/api/workflow-recovery.md)的HTTP与独立Go SDK。持久运行器支持顺序、分支、有限并行、TIMER、SIGNAL及有界POLL,恢复沿用原预算与截止。[并行作业与回执示例](docs/examples/workflow.md)提供完整调用链;[历史激活/轮次/信号及信号审计](docs/api/workflow-history.md)已接入;[实例筛选分页](docs/api/workflow-queries.md)已接入,[状态事件时间线](docs/api/workflow-events.md)已接入,独立[轮询](docs/examples/workflow-poll.md)与[恢复](docs/examples/workflow-recovery.md)示例已接入,剩余故障见[P4/P5核对](docs/reports/phase-4-5.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)。

View File

@@ -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.11 · 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)已接入;流程任务来源已接入;历史与状态事件已接入;独立[轮询](examples/workflow-poll.md)/[恢复](examples/workflow-recovery.md)示例已交付,剩余故障见[P4/P5核对](reports/phase-4-5.md),生产验收仍未完成。
## 1. 职责与边界
@@ -49,11 +49,11 @@ Weave 不执行特定业务逻辑,也不把整段业务代码放入平台进
| `poll_round` | 同一次查询节点激活内的查询轮次;一轮关联一个 Pulse 任务,可包含多次执行尝试 |
| `signal_id` / 关联键 | 信号去重及匹配指定实例、步骤激活或业务操作 |
启动时绑定明确的定义版本、参数版本、处理器兼容要求和策略快照。若允许以“当前版本”启动,首次受理时解析并固定;相同幂等键重报仍返回首次绑定的实例。
启动时绑定明确的定义版本、参数版本、处理器兼容要求和策略快照。当前公共API只接受显式发布版本,不解析latest别名;相同幂等键重报仍返回首次绑定的实例。
初次启动的幂等键至少在命名空间和操作类型内唯一。同键不同语义输入必须冲突;不因新定义发布就生成另一份流程。
## 4. 建议状态模型
## 4. 状态模型
流程状态与任务状态分别管理,不能用“最后一个任务成功”替代整个流程的成功条件。
@@ -69,7 +69,7 @@ Weave 不执行特定业务逻辑,也不把整段业务代码放入平台进
存在未知副作用时不能进入一个暗示“全部已结清”的终态。建议异常核查优先显示为 `NEEDS_REVIEW`,同时保留取消意图及每个分支的真实状态;状态汇总优先级在协议评审中固定。
暂停建议用独立持久化标记 `paused` 与控制版本表达,不覆盖上述执行状态:实例可以同时显示“已暂停”和“有任务执行中”或“待核查”。恢复仅解除暂停,不自动解除核查或撤销取消。具体字段与汇总展示规则仍待协议评审。
暂停建议用独立持久化标记 `paused` 与控制版本表达,不覆盖上述执行状态:实例可以同时显示“已暂停”和“有任务执行中”或“待核查”。恢复仅解除暂停,不自动解除核查或撤销取消。具体字段见[实例接口](api/workflows.md),暂停与核查分别解除。
步骤单独保存等待前置条件、等待任务、等待信号/时间、完成、失败、跳过或待核查等阶段及状态版本。步骤任务排队不代表业务已经运行;详情应展示 Pulse 的实际执行阶段。
@@ -84,7 +84,7 @@ Weave 不执行特定业务逻辑,也不把整段业务代码放入平台进
| 启动流程 | 启动幂等记录、实例与首批激活步骤、对应 Pulse 任务、状态事件 |
| 保存任务结果 | Pulse 任务/尝试结果、额度释放、结果事件及内部交付记录 |
| 接续任务结果 | 核对实例与步骤激活、消费去重、步骤变化、流程推进、后续任务或等待记录、内部事件处理确认 |
| 建立定时等待 | 步骤等待条件、截止时间、对应的 Pulse 内部定时任务 |
| 建立定时等待 | 步骤等待条件、截止时间、weave_pending_advances持久检查点 |
| 接收信号 | 幂等记录、信号内容与身份、持久化待处理记录 |
| 消费信号 / 定时唤醒 | 校验等待条件与状态版本、消费标记、流程推进、后续任务创建 |
| 查询轮次推进 | 校验暂停/取消、轮次/时间预算,创建唯一轮次记录与对应任务,或保存下一次唤醒 |
@@ -113,9 +113,9 @@ Weave 不执行特定业务逻辑,也不把整段业务代码放入平台进
- 信号关联必须能区分具体业务操作,不能只按“审核通过”等类型匹配,避免旧业务批次、旧步骤或旧恢复轮次的信号误用。
- 信号受理成功只表示已可靠保存,消费与步骤推进状态可另外查询。
定时等待使用已保存的截止时间和内部定时任务。定时唤醒由平台执行角色处理,不需要业务 Worker 长期占用连接槽位。任务结果与后续等待消费仍遵守幂等和事务规则。
定时等待使用已保存的截止时间和weave_pending_advances检查点。定时唤醒由平台执行角色处理,不需要业务 Worker 长期占用连接槽位。任务结果与后续等待消费仍遵守幂等和事务规则。
信号、超时与取消竞争时,按等待记录的状态版本原子决定结果。采用信号持久化受理时间与等待截止时间判断是否按时到达,不能因消费积压把已经按时受理的信号误判为超时;截止点的包含规则待协议冻结。取消另一条唤醒路径后,迟到事件只留痕,不再次推进。
信号、超时与取消竞争时,按等待记录的状态版本原子决定结果。采用信号持久化受理时间与等待截止时间判断是否按时到达,不能因消费积压把已经按时受理的信号误判为超时;当前规则要求受理时间严格早于截止,等于截止不视为按时到达。取消另一条唤醒路径后,迟到事件只留痕,不再次推进。
未匹配信号数量、参数大小、保留时间与等待时限必须有上限。到期清理需要可观测且有明确规则,不能悄悄丢掉仍有效的等待依据。
@@ -123,14 +123,14 @@ Weave 不执行特定业务逻辑,也不把整段业务代码放入平台进
**功能已确认**:查询成功但外部业务仍在处理中,由 Weave 等待后发起下一轮;查询请求失败,由 Pulse 在当前轮次内按安全策略重试。达到轮次或总时限上限后转人工核查,不直接认定外部业务失败。
以下为实现建议,具体字段与边界待技术评审:
以下约束已按[定义格式](api/workflow-definition-format.md)和[时钟约定](13-workflow-clock.md)冻结:
| 内容 | 建议约束 |
| 内容 | 当前约束 |
| --- | --- |
| 查询目标 | 固定查询处理器、版本和外部业务操作标识;恢复查询不能重新调用提交接口 |
| 轮次上限 | `max_rounds` 为有限正整数;创建轮次记录与查询任务时原子计数,不因重复事件或重启重复增加 |
| 总时限 | 从节点激活时固定 `deadline_at`;建议同时设置轮次与时间上限,先到者阻止新一轮 |
| 查询间隔 | 建议从上一轮完成且结果仍为处理中时计算下一次时间,可设有上限的退避与抖动;不按错过的间隔集中补跑 |
| 查询间隔 | 从上一轮PENDING结果的可靠事件时间计算固定间隔;业务轮间不支持抖动或变长退避,不按错过的间隔集中补跑 |
| 单轮执行重试 | Pulse 使用独立且有限的尝试预算,自动重试保持该轮任务 ID;受节点剩余时限约束 |
| 结果映射 | 明确区分处理中、业务确认成功、业务确认失败和需要核查;未知值不能默认为成功或继续循环 |
@@ -152,13 +152,13 @@ Weave 不执行特定业务逻辑,也不把整段业务代码放入平台进
**功能已确认**:暂停阻止新步骤推进,保留已完成进度,继续接收在途结果与外部回执;继续操作从持久化进度恢复。暂停不表示已经停止外部业务操作。
以下细则已按[控制运行约定](15-workflow-controls.md)冻结并实现内部链路,公共HTTP/SDK仍待交付:
以下细则已按[控制运行约定](15-workflow-controls.md)冻结并实现内部链路,[公共HTTP/SDK](api/workflows.md)也已接入:
- 暂停与继续带预期控制版本、稳定操作身份、操作人及原因;状态与审计一起提交。新步骤创建必须原子检查控制版本。
- 建议暂停还阻止关联任务的新领取、开始授权和执行重试,包含已经创建但尚未开始的任务。由 Pulse 的通用执行门控与 Weave 控制状态同事务更新,领取/开始时读取权威门控,避免仅暂停流程循环却继续派发旧队列任务。
- 暂停前已获得开始授权的尝试仍可运行、续租和上报,保留时限与安全约束。暂停与开始竞争按事务顺序决定;“暂停成功”只表示门控已持久化,不表示所有在途请求已停止。
- 在暂停期间可靠保存任务结果、信号和到期事实,必要时进入核查或结清已有工作;不创建新的业务步骤或查询轮次。即使内部事件已确认消费,也要保存待续推进状态,避免恢复后无事件可推进。
- 建议暂停不延长原业务截止时间,查询节点、任务时限与信号有效期继续计时。恢复时先核对已受理结果、信号和原截止时间,不能把过期查询重新激活;时间策略在协议冻结前确认。
- 建议暂停不延长原业务截止时间,查询节点、任务时限与信号有效期继续计时。恢复时先核对已受理结果、信号和原截止时间,不能把过期查询重新激活;当前等待、查询和任务都保留原截止。
- 恢复按配额和推进预算处理积压,不重置已用查询轮次和执行尝试,不补发每一个错过的查询时间点。恢复不改变原流程版本,不清除取消或待核查条件。
### 7.2 执行失败与人工恢复
@@ -202,7 +202,7 @@ Weave 不执行特定业务逻辑,也不把整段业务代码放入平台进
旧定义、参数解释与处理器版本在仍有运行实例时必须保持可用,或明确进入可观测的版本不兼容等待;升级不能悄悄把旧实例转到新逻辑。
功能范围已确认。实施前仍需评审:SDK 构建 API、定义 Schema、分支表达式、并行失败策略、暂停门控及计时规则、轮询预算与截止边界、人工恢复身份模型、协议字段、资源上限与保留周期。验收覆盖 [生产验收](05-production-acceptance.md) 的流程场景,尤其是暂停竞争、轮次恢复、查询与回执竞争及版本发布。
功能范围已确认。SDK构建类型、定义Schema、分支表达式、并行失败策略、暂停门控、轮询预算、恢复身份和协议限制已在对应实现手册中冻结。保留/归档、生产参数、精确故障矩阵及容量仍需P6/P7交付,不能由已有正常路径反推完成。验收覆盖 [生产验收](05-production-acceptance.md) 的流程场景,尤其是暂停竞争、轮次恢复、查询与回执竞争及版本发布。
## 10. 决策记录

View File

@@ -28,24 +28,24 @@
| 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与基础可运行场景已接入;流程任务来源已接入;历史激活/轮次/信号与信号审计已接入;实例筛选分页已接入;[状态事件时间线](api/workflow-events.md)已接入;独立轮询/恢复场景及全范围验收继续开发 |
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 有界查询轮次/尝试独立预算及原截止恢复已接入内部运行器;最终信号竞争及在途查询结清已接入;内部暂停/继续/取消与失败结清已接入;执行核查、步骤结论、解除核查及TASK新激活已接入;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入;等待恢复已接入内部服务;公共API/SDK已接入;流程任务来源已接入;历史激活/轮次/信号与信号审计已接入;实例筛选分页已接入;[状态事件时间线](api/workflow-events.md)已接入;独立轮询/恢复场景及全范围验收继续开发 |
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 定义格式/词法类型校验、不可变发布、独立SDK及六类节点定义例子已实现,已通过[子项验收](reports/phase-4-definitions.md);已补[有限决策器及内部结果交付](reports/phase-4-engine-core.md);[实例受理及首批事务](reports/phase-4-admission.md)已实现;[结果消费和根接续](reports/phase-4-runtime.md)已接入后台,顺序/分支/并行及真实进程恢复已验证;[TIMER与后续POLL轮次](13-workflow-clock.md)已接入;[信号消费和最终查询竞争](14-workflow-signals.md)已接入内部运行器;[内部控制与停止结清](15-workflow-controls.md)已接入;[内部核查与TASK新激活](16-workflow-recovery.md)已接入;等待恢复已接入内部服务;实例公共API/SDK与基础可运行场景已接入;流程任务来源已接入;历史激活/轮次/信号与信号审计已接入;实例筛选分页已接入;[状态事件时间线](api/workflow-events.md)已接入;独立轮询/恢复示例已接入;[精确故障验收](reports/phase-4-5.md)继续开发 |
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 有界查询轮次/尝试独立预算及原截止恢复已接入内部运行器;最终信号竞争及在途查询结清已接入;内部暂停/继续/取消与失败结清已接入;执行核查、步骤结论、解除核查及TASK新激活已接入;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入;等待恢复已接入内部服务;公共API/SDK已接入;流程任务来源已接入;历史激活/轮次/信号与信号审计已接入;实例筛选分页已接入;[状态事件时间线](api/workflow-events.md)已接入;独立轮询/恢复示例已接入;[精确故障验收](reports/phase-4-5.md)继续开发 |
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 |
| P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 待开发 |
SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最后才开始补文档。
P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](reports/phase-4-clock.md)。外部信号与最终查询竞争见[信号报告](reports/phase-4-signals.md)。公共实例、控制、信号、六类恢复和操作审计已接入HTTP与独立SDK,见[公共接入报告](reports/phase-5-public-api.md)。流程任务来源及[历史查询](api/workflow-history.md)已接入,[实例筛选分页](api/workflow-queries.md)已接入,[状态事件时间线](api/workflow-events.md)已接入,更多恢复示例仍须交付。
P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](reports/phase-4-clock.md)。外部信号与最终查询竞争见[信号报告](reports/phase-4-signals.md)。公共实例、控制、信号、六类恢复和操作审计已接入HTTP与独立SDK,见[公共接入报告](reports/phase-5-public-api.md)。流程任务来源及[历史查询](api/workflow-history.md)已接入,[实例筛选分页](api/workflow-queries.md)已接入,[状态事件时间线](api/workflow-events.md)已接入,独立轮询/恢复示例已接入,剩余故障见[P4/P5核对](reports/phase-4-5.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已接入,步骤查询返回实际激活代次;并行执行与信号回执场景已有可运行示例。激活/轮次/信号历史及信号资源审计已接入;实例筛选分页已接入;状态事件时间线已接入;独立轮询及恢复示例已接入,继续补W06/W09/W14/W15精确回归;[Pulse任务流程来源](api/task-source.md#5-流程固定来源)已交付。逐项验证原键恢复、权限、版本冲突、竞争与进程全停。
5. 转入P6/P7的生产故障、容量、运维、发布与全部文档核对。内部存储阶段通过不会提前勾选公共接口或生产验收。
公共接入按以下调用链推进;本批已接入启动/当前详情、控制、信号、六类恢复和流程操作审计,并提供独立SDK及基础示例。任务流程来源和有界历史查询也已接入。实例分页和独立查询示例已接入;状态事件时间线已接入;更多独立场景继续交付:
公共接入按以下调用链推进;本批已接入启动/当前详情、控制、信号、六类恢复和流程操作审计,并提供独立SDK及基础示例。任务流程来源和有界历史查询也已接入。实例分页和独立查询示例已接入;状态事件时间线已接入;剩余精确故障场景按[P4/P5核对](reports/phase-4-5.md)继续交付:
| 调用链 | 需要补齐的内容 | 验收重点 |
| --- | --- | --- |
@@ -107,8 +107,8 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
| 研发定义发布 | 六类节点描述 → 校验/发布 → 原键确认 → 新版本 → 读取旧版本 | P4定义子项,[已实现示例](examples/workflow-definition.md) |
| 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4,[已实现示例](examples/workflow.md) |
| 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4,[已实现模拟外部回执示例](examples/workflow.md);实际外部系统由接入方对接 |
| 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5,HTTP/SDK恢复场景已回归,独立可运行示例待补 |
| 暂停与人工恢复 | 暂停 → 保存结果 → 核查/恢复 → 查看审计 | P5,HTTP/SDK及审计场景已回归,独立可运行示例待补 |
| 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5,[已实现轮询与耗尽示例](examples/workflow-poll.md) |
| 暂停与人工恢复 | 暂停 → 保存结果 → 核查/恢复 → 查看审计 | P5,[已实现断线后的核查与暂停恢复示例](examples/workflow-recovery.md) |
| 故障恢复 | 节点/连接中断 → 相同身份核对 → 恢复或人工核查 | P2–P6 |
| 执行历史核对 | 读取尝试 → 查看冲突/迟到证据 → 对照权威结果 | P2,已实现完整示例 |
@@ -179,4 +179,8 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
### 状态事件时间线子项
已接入流程/步骤/信号状态事件的公共HTTP、独立SDK、原元数据详情与完整运行例子。000025仅派生索引,历史原字段保持;新增信号转换与状态同事务,批量失败整体回滚。当前阶段验证见[时间线报告](reports/phase-5-events.md),P4/P5全范围核对、独立POLL/人工恢复示例和P6/P7仍未完成。
已接入流程/步骤/信号状态事件的公共HTTP、独立SDK、原元数据详情与完整运行例子。000025仅派生索引,历史原字段保持;新增信号转换与状态同事务,批量失败整体回滚。当前阶段验证见[时间线报告](reports/phase-5-events.md),P4/P5全范围核对、独立POLL/人工恢复示例已交付,精确故障验收和P6/P7仍未完成。
### P4/P5 原范围核对后的下一批
[逐项证据与缺口](reports/phase-4-5.md)已落表,两项独立场景已交付。下一批补:运行中发布不同v2后旧实例的后继执行(W06);信号、真实截止、CANCEL三方并发(W09);PAUSE与开始、创建、领取及重试竞争(W14);暂停状态跨完整节点退出再继续的公共API链路(W15)。原生产故障、观测、容量、凭据、归档、备份恢复与发布门槛保持,P4/P5整体暂不勾选完成。

View File

@@ -143,4 +143,4 @@ fmt.Println(audit.Action, receipt.Instance.State)
新路由仅在本开发版本及之后可用;旧服务返回404时不能降级为普通任务重试来绕过Weave所有权。SDK类型独立、版本字段保留uint64精度。规则及数据模型未增加表、字段、外键或级联。
本批尚不提供完整历史激活/轮次遍历、实例筛选列表或信号资源审计列表;当前输入结果、当前查询预算、单条信号、流程管理审计及[任务历史激活来源](task-source.md#5-流程固定来源)可读。生产数据库切换、容量及恢复目标继续按P6验证,见[阶段报告](../reports/phase-5-public-api.md)。
[历史激活/轮次/信号及审计](workflow-history.md)、[实例筛选](workflow-queries.md)、[状态时间线](workflow-events.md)和[任务历史激活来源](task-source.md#5-流程固定来源)均已接入。独立[提交后断线与人工恢复示例](../examples/workflow-recovery.md)提供完整启动、故障注入、三步核查、暂停门控与审计核对。生产数据库切换、容量及恢复目标继续按P6验证,原范围剩余故障见[P4/P5核对](../reports/phase-4-5.md)。

View File

@@ -14,6 +14,10 @@
- 两个任务并行处理并汇合,计时等待后接收显式目标代次的信号,再执行后续任务;最终三个任务完成并有界排空。
- 启动及信号原键重复不会建立新的实例或信号;分页查看原状态事件,核对信号消费及赢家等待。
## 已可运行:有界轮询与证据恢复
独立的流程场景另见[有界轮询](workflow-poll.md)与[证据恢复](workflow-recovery.md):前者区分请求重试与业务轮次,后者实际注入外部提交后HTTP断线,保留UNKNOWN历史和暂停门控。本机渠道是受控夹具,不能代替持久业务证据。
## 已可运行:业务事务与任务提交Outbox
- 独立模块:[task-outbox](../../examples/task-outbox/README.md),[完整前置、命令、Go方法及故障处理](task-outbox.md)。

View File

@@ -0,0 +1,85 @@
# 有界轮询:请求重试与业务轮次分别计数
[完整源码](../../sdk/go/examples/workflow-poll/main.go)通过公共SDK发布POLL定义,启动真实Worker,并请求一个本机HTTP渠道夹具。流程先由TASK向本机渠道提交一次作业,受理回执提供固定operation_id,随后POLL只查询这个作业;平台没有集成任何具体业务渠道。
同一命令依次运行两个流程:
| 场景 | 渠道响应 | 平台结果 |
| --- | --- | --- |
| success | 提交一次;首次查询503,重试PENDING,下一轮DONE | 2个业务轮、3次查询尝试,SUCCEEDED |
| exhausted | 提交一次;两次查询都成功返回PENDING | 2个业务轮、2次查询尝试,NEEDS_REVIEW |
## 1. 环境与启动
需要Go 1.27.1、MySQL 8.4+,设置专用`ORBIT_MYSQL_DSN`。从仓库根目录初始化:
```sh
go run ./cmd/orbit-migrate up
mkdir -p .local
chmod 700 .local
go run ./cmd/orbit-admin bootstrap \
--application poll-example --namespace default --environment development \
--queue workflows --workflow-access --workflow-run-access --worker-access \
--output .local/poll.credentials.json
go run ./cmd/orbit
```
bootstrap只执行一次,凭据文件存在时不会覆盖。另开终端,从仓库根目录运行:
```sh
cd sdk/go
ORBIT_URL=http://127.0.0.1:8080 \
ORBIT_CREDENTIALS_FILE=../../.local/poll.credentials.json \
go run ./examples/workflow-poll
```
程序自行创建并关闭本机HTTP夹具,不需要启动第三方系统,不向公网提交操作。正式接入使用HTTPS/WSS和分角色凭据,将只读查询处理器替换为自己的渠道调用。
## 2. 定义与调用链
CreateWorkflowDefinition → PublishWorkflowVersion(v1) → StartWorkflow → 自动Worker提交一次 → 按受理回执中的身份查询 → GetWorkflow/GetWorkflowStep → ListWorkflowPollRounds → GetTaskSource/ListTaskAttempts → Worker排空。
输入为`{"operation_id":"本次示例的固定操作身份"}`。submit任务只允许一次尝试并使用RECONCILE_ONLY,提交结果未知时必须核查;query通过WorkflowRef("submit")引用已保存受理回执,每轮及同轮重试保持这个身份。结果契约是STRING枚举PENDING/DONE。定义中设置:
```go
poll := orbit.WorkflowPoll{
Task: orbit.WorkflowTask{
// 处理器、输入/输出契约和队列等完整字段见可运行源码。
Retry: orbit.RetryPolicy{
MaxAttempts: 2, InitialDelayMS: 1000,
MaxDelayMS: 1000, AttemptTimeoutMS: 5000,
},
TimeoutMS: 20000,
RecoverySafety: orbit.RecoveryIdempotent,
},
MaxRounds: 2, TimeoutMS: 30000, IntervalMS: 1000,
Pending: []string{"PENDING"}, Succeeded: []string{"DONE"},
}
```
本片段说明预算配置,不是可单独发布的完整定义;源码包含所有必填字段。流程总时限45秒,查询总时限30秒,轮次包含首次;实际任务还受流程及节点原截止限制。
受控渠道的503表示明确拒绝只读查询,因此返回`WorkerFailure{Code:"QUERY_TEMPORARY", Retryable:true}`。传输错误仍返回普通error,保留未知结果,由冻结的安全策略决定后续。只有明确知道请求失败才可声明WorkerFailure,不能把任意网络超时归类为明确失败。
HTTP 200的PENDING直接作为成功结果返回给Orbit,不由Worker自己循环或sleep。Weave保存轮间等待、释放Worker槽位,并在原间隔后创建下一轮任务。轮次耗尽不假定外部作业失败,也不重置预算或再次提交外部作业。
## 3. 预期输出及验证
前面的scenario、operation、workflow字段每次不同。两个最终结果为:
```text
poll_scenario=success state=SUCCEEDED submissions=1 rounds=2 attempts=3 requests=3 deadline_preserved=true worker_drained=true
poll_scenario=exhausted state=NEEDS_REVIEW submissions=1 rounds=2 attempts=2 requests=2 deadline_preserved=true worker_drained=true
```
程序从实际历史读取两轮,每页1条;成功场景第一轮包含FAILED和SUCCEEDED两次尝试,第二轮只有一次SUCCEEDED。耗尽场景两轮请求都成功,逻辑步骤仍进入核查,没有current_task_id或next_poll_at继续运行。每轮任务来源必须指向原实例与轮次,原查询/实例截止保持不变,每个流程只有一次POST提交;Worker实际执行与确认总数包含该提交,所以成功场景共4次、耗尽场景共3次,其中查询请求分别为3次和2次。
## 4. 失败、重跑与清理
程序总运行期限60秒,每个Worker排空期限5秒。发生意外状态、权限错误、响应损坏或预算不符会非零退出,不自动换版本、续期或解除核查。
每次重新运行会创建新的独立示例身份,不代表对上一次未知响应的恢复。生产调用方应持久保存幂等键及完整命令;响应不明时原命令原键核对,不能重新执行此示例来替代恢复。
正常结束关闭本机渠道与Worker,保留两个流程和所有历史,其中exhausted流程有意保留NEEDS_REVIEW供查看。不要将该示例放进生产定时任务无限创建待核查记录。真实渠道状态和证据需由接入方核实,再通过[人工恢复API](../api/workflow-recovery.md)决定后续;示例不会伪造最终成功。
回归入口是`make integration`。SDK预算与字段见[定义格式](../api/workflow-definition-format.md),原截止与查询语义见[时钟约定](../13-workflow-clock.md),阶段证据见[P4/P5核对](../reports/phase-4-5.md)。

View File

@@ -0,0 +1,90 @@
# 提交后回执丢失:核查、暂停与有证据恢复
[完整源码](../../sdk/go/examples/workflow-recovery/main.go)使用真实Orbit、自动Worker和本机HTTP渠道夹具。夹具先保存外部提交结果,再直接关闭连接,确保Worker实际遇到传输错误。流程进入NEEDS_REVIEW后,程序在受控夹具中模拟一个已经核实事实的管理员,逐项提交恢复决定。
这是一份可运行的SDK接入与故障示例。夹具数据仅在本次进程内保存;真实系统需要持久证据和真实授权,不能把下面的演示核查逻辑接成任意业务的自动放行。
## 1. 启动
需要Go 1.27.1、MySQL 8.4+,设置专用`ORBIT_MYSQL_DSN`。仓库根目录执行:
```sh
go run ./cmd/orbit-migrate up
mkdir -p .local
chmod 700 .local
go run ./cmd/orbit-admin bootstrap \
--application workflow-recovery-example --namespace default --environment development \
--queue workflows --workflow-access --workflow-run-access --worker-access \
--workflow-control-access --workflow-recovery-access --workflow-audit-access \
--output .local/workflow-recovery.credentials.json
go run ./cmd/orbit
```
初始化一次;原凭据文件不会被覆盖。另开终端,从仓库根目录运行:
```sh
cd sdk/go
ORBIT_URL=http://127.0.0.1:8080 \
ORBIT_CREDENTIALS_FILE=../../.local/workflow-recovery.credentials.json \
go run ./examples/workflow-recovery
```
开发凭据包含定义、启动、Worker、控制、恢复及审计权限,便于单命令演示。正式接入应使用HTTPS/WSS,并为业务Worker与管理员分别签发所需权限;Worker执行权不自动授予恢复权。
## 2. 故障与完整调用顺序
流程包含publish和record两个顺序任务。输入为原操作身份及精确序号9007199254740993;publish只允许一次请求尝试,安全策略为RECONCILE_ONLY。record只在前一步被核实成功并解除控制门控后处理已确认回执。
1. 创建定义、发布v1并启动;Worker向本机渠道POST一次。渠道保存结果,然后关闭连接,不返回HTTP响应。
2. 处理器返回普通error。Orbit保留UNKNOWN尝试,任务和流程进入NEEDS_REVIEW,不重发POST。
3. 查询当前步骤、任务及尝试,确认唯一尝试已结束、Worker处理器已退出且结果报告已确认,再显式PAUSE流程。
4. 用原操作身份GET渠道证据,核对operation_id、状态CONFIRMED及精确序号。夹具能证明这是唯一一次提交,且提交处理已经结束;真实系统必须另行证明历史执行已结清,仅有一次GET成功不够。
5. `ReviewWorkflowTask`提交执行结论:当前实例版本、任务版本、execution_settled=true、结果及证据引用。使用原键原命令再调用一次,确认同一审计回执。
6. 等待核查结果被内部消费,重新读取步骤及实例版本;`ResolveWorkflowStep`确认逻辑步骤成功,保留原激活。
7. `ContinueWorkflow`声明核查事项已解决。此时仍为paused,record步骤保持BLOCKED,没有任务被提前建立。
8. 查询三条管理审计,核对操作人、原因和同一证据引用;再单独调用`ControlWorkflow(RESUME)`释放暂停。
9. Worker完成record,流程成功;查询原尝试仍是UNKNOWN,原实例截止未变,最后排空Worker。
每个管理决定都有独立稳定操作键。核查使用实例revision,控制使用control_revision,两者不可互换。读到新事实后重新核查再形成下一条命令;不能自动把STATE_CONFLICT改为最新版本重报。
## 3. SDK关键字段
完整程序使用[六类恢复API中的三个方法](../api/workflow-recovery.md)。执行核查示意:
```go
command := orbit.ReviewWorkflowTaskRequest{
WorkflowRecoveryDecision: orbit.WorkflowRecoveryDecision{
ExpectedRevision: currentStep.Revision,
Reason: "已核实原操作、结果和所有相关执行已结束",
EvidenceRef: evidenceRef,
},
TaskID: task.ID, ExpectedTaskRevision: task.Revision,
ExecutionSettled: true,
Outcome: orbit.TaskSucceeded, Result: verifiedResult,
}
receipt, err := client.ReviewWorkflowTask(ctx, workflowID, reviewKey, command)
if err != nil { return err }
// receipt是管理事务的冻结回执,下一项决定前应读取新的当前事实。
```
示例证据引用形如`evidence:local-fixture/<operation>`,明确表示本地受控夹具,不能冒充持久业务证据。平台只保存受控引用及审计,不请求该地址。真实证据应由接入方按权限、保留及审计要求持久保存。
## 4. 输出与历史保护
程序先打印可查询的操作身份和workflow ID,最终应输出:
```text
recovery_verified=true state=SUCCEEDED submissions=1 unknown_history_preserved=true review_deduplicated=true pause_preserved=true audits=3 sequence=9007199254740993 worker_drained=true
```
这些标志来自实际API和Worker统计:只发生1次外部POST;同键核查返回同一audit_id;task-review、step-resolve、continue三条审计具有原因、主体和证据;恢复没有解除PAUSE;旧UNKNOWN尝试和结束时间没有改写;两项业务任务均已结束并确认。
最终任务的权威结果可以是SUCCEEDED,同时原尝试保留UNKNOWN,两者并不矛盾。核查增加新的决定和证据,不能抹去当时无法确认结果的事实。
## 5. 失败及重跑边界
本次运行最多45秒,Worker排空最多5秒。任何身份、证据、结果或版本不符都会非零退出;失败不会自动调用RetryWorkflowStep、换键重发外部POST或解除核查。
重新运行命令创建新的独立演示流程,不是对上一流程的恢复。生产接入方必须持久保存每条命令和幂等键;响应未知时使用原命令原键核对,旧版本冲突需要重新核查。示例退出时关闭本机HTTP夹具与Worker,平台流程和历史保留;若中途失败,夹具停止后不能再用其内存结果作为后续人工恢复依据。
`make integration`运行此示例以及独立SDK中的六类恢复回归;另有节点强杀、恢复代次和原预算验证。记录见[P4/P5核对](../reports/phase-4-5.md)。这不替代真实业务的幂等、权限、证据和生产故障恢复验收。

72
docs/reports/phase-4-5.md Normal file
View File

@@ -0,0 +1,72 @@
# P4/P5 原范围核对与剩余验收
日期:2026-09-18。本报告按[原流程功能](../06-workflow-spec.md)、[开发计划](../08-development-plan.md)及[生产故障矩阵W01–W17](../05-production-acceptance.md)核对,保留未完成项。功能实现、单个场景通过与整个阶段验收分开记录;P4/P5尚不标为整体完成,P6/P7及持续目标保持进行中。
## 本批交付
新增两个独立可运行SDK示例,均只导入公开SDK及示例内部辅助包,不访问Orbit数据库或服务端模型:
- [有界轮询](../examples/workflow-poll.md):先提交一次,实际HTTP查询503→PENDING→DONE,2轮3次尝试;另一流程两轮PENDING后转核查,2轮2次尝试。历史分页、任务来源、实际HTTP次数、原截止和Worker排空逐项断言。
- [带证据恢复](../examples/workflow-recovery.md):本地渠道保存提交结果后直接关闭HTTP连接,Worker报UNKNOWN;暂停、查询原操作证据、执行核查、步骤结论、解除核查、显式恢复暂停,再完成后继任务。断线实际发生,外部提交只发生1次,精确序号9007199254740993、旧UNKNOWN尝试、同键审计及暂停标记均保留。
外部系统为进程内受控HTTP夹具,不是持久业务渠道。恢复示例能证明唯一提交已经结束,并据此模拟管理员的明确决定;不能据此把真实业务核查自动化。平台事务与外部业务副作用的责任边界没有改变。
没有新增模型字段、数据库迁移、外键或级联。平台仍为36个模型、312个直接持久化字段;中文注释保持。标准回归加入这两个命令,目前共23个可运行SDK示例和2个独立业务模块;examples/internal/flowdemo是共享辅助包,不计为可运行示例。
## 原功能逐项对应
| 已确认能力 | 实现与可运行入口 | 已有直接验证证据 |
| --- | --- | --- |
| 研发定义发布、类型和资源约束 | [定义API](../api/workflow-definitions.md)、[结构格式](../api/workflow-definition-format.md)、workflow-definition例子 | [定义存储回归](../../internal/storage/mysql/workflow_definitions_integration_test.go):并发同键/同版本、精确数字、非法/损坏定义、权限撤销、事务失败;[SDK](../../sdk/go/workflow_definitions_integration_test.go)提交后丢回执原键恢复 |
| 顺序、条件分支、有限并行汇合 | weave/engine与持久运行器;[workflow例子](../examples/workflow.md)实际并行后汇合 | [决策器测试](../../weave/engine/plan_test.go)精确条件、分支选择冻结与预算;[实例](../../internal/storage/mysql/workflow_instances_integration_test.go)覆盖六种初始节点;[推进](../../internal/storage/mysql/workflow_runtime_integration_test.go)重复消费、并行结果只建一个后继、满额回滚和根待续 |
| 固定版本及输入 | StartWorkflow显式version;GetWorkflowVersion及实例来源 | 实例并发受理固定版本、发布v2后原键回执保留;[实例查询](../../internal/storage/mysql/workflow_query_integration_test.go)继续按v1全部队列授权。运行中发布v2后继续执行后继步骤的专门场景仍需补强 |
| SIGNAL等待、早到和旧代次隔离 | [信号约定](../14-workflow-signals.md)、SendWorkflowSignal及workflow例子 | [受理](../../internal/storage/mysql/workflow_signals_integration_test.go)身份/契约/范围/事务;[消费](../../internal/storage/mysql/workflow_signal_runtime_integration_test.go)并发赢家、真实到期、及时受理晚消费、篡改及最终查询结清 |
| TIMER持久等待、原截止 | [时钟约定](../13-workflow-clock.md)、workflow例子 | [时钟存储](../../internal/storage/mysql/workflow_clock_integration_test.go)自然到期、重复消费、实例截止截断;[真实进程](../../internal/storage/mysql/workflow_runtime_process_integration_test.go)全停、两新节点接续 |
| 有界查询与独立尝试预算 | [workflow-poll例子](../examples/workflow-poll.md)、POLL定义与历史轮次API | 时钟存储验证同轮重试、下一轮、满额/暂停不耗轮次、截止与在途结果;新例子分别验证成功和耗尽后的实际请求次数 |
| 暂停/继续、取消、分支失败结清 | [公共控制](../api/workflows.md)、[停止规则](../15-workflow-controls.md) | [控制回归](../../internal/storage/mysql/workflow_controls_integration_test.go)原control_revision、原键/并发/撤权、开始与取消竞争、并行FAIL_AFTER_SETTLED、UNKNOWN不终结、超过32步待续;精确暂停同时竞争仍有下面列出的缺口 |
| 人工核查、TASK/POLL重试及等待恢复 | [六类恢复API](../api/workflow-recovery.md)、workflow-recovery例子 | [TASK恢复](../../internal/storage/mysql/workflow_recovery_integration_test.go)、[POLL恢复](../../internal/storage/mysql/workflow_poll_recovery_integration_test.go)、[等待恢复](../../internal/storage/mysql/workflow_wait_recovery_integration_test.go):旧激活、尝试/轮次/原截止、证据、失败回滚、当前版本和停止意图 |
| 完整查询与审计入口 | 当前详情、[历史](../api/workflow-history.md)、[实例筛选](../api/workflow-queries.md)、[状态事件](../api/workflow-events.md)、[任务来源](../api/task-source.md) | 对应存储回归验证当前与历史激活区分、分页、归属损坏、全部固定队列权限;[独立SDK](../../sdk/go/workflow_instances_integration_test.go)覆盖六类恢复和读取,时间线另有SDK与批量事件回滚验证 |
| 不依赖内存的接续 | MySQL检查点、内部Inbox、额度、门控与同事务事件 | 真实流程进程测试先确认原进程SIGKILL退出,再启动两个新节点;结果、TIMER/POLL、早到/最终信号、停止意图、TASK/POLL人工恢复及等待换代均保留 |
上述“已有证据”是相应断言的范围,不等于任意并发交错、数据库故障拓扑或业务渠道已验收。API字段与限制以当前接口手册为准;旧阶段报告保留当时状态,不回写成后来全部完成。
## W01–W17 故障矩阵核对
| 编号 | 当前证据 | 仍需补强的具体边界 |
| --- | --- | --- |
| W01 | 受理/推进/后继创建的SQL失败与SAVEPOINT回滚,原结果和额度保持 | COMMIT回执不明与特定步骤写入切点的进程中断需要统一故障工具覆盖 |
| W02 | 结果持久化后全控制节点退出,两新节点消费,后继不重复 | 生产数据库切换另按P6,不与应用进程强杀等同 |
| W03 | 早到、重复、迟到、错误关联及代次的实际存储与SDK回归 | 仍须纳入容量模型中的大量信号积压 |
| W04 | TIMER、早到SIGNAL、独立等待恢复均有全停后接续 | 生产长时间等待、保留与清理策略按P6验收 |
| W05 | 并行已知失败结清、未知效果保留核查,成功步骤保持 | 业务补偿不属于已确认首版,不冒充平台回滚外部效果 |
| W06 | 不可变版本、原键固定实例、v1范围授权已验证 | 补“v1等待中发布语义不同v2,再由旧实例继续后继”的完整执行断言 |
| W07 | 已授权任务取消后收集SUCCEEDED/FAILED/CANCELLED/UNKNOWN,含进程全停 | 大量在途取消、外部系统执行失联仍需生产负载和接入方证明 |
| W08 | 同一实例并行结果竞争,后继与汇合仅一次 | 高负载热点实例锁等待在P6测量 |
| W09 | 及时受理后过截止再消费、等待取消、停止前后信号及并发赢家已验证 | 补信号受理、真实截止唤醒、CANCEL三方同时执行的专项回归;顺序用例不能替代 |
| W10 | 同恢复键竞争、旧激活结果、旧信号代次及恢复后全停 | 滚动版本兼容、长期历史保留另按P6/P7 |
| W11 | 同轮重试与PENDING新轮次的存储、SDK及实际HTTP例子 | 渠道峰值与大批量同刻唤醒按P6 |
| W12 | 最大轮次、截止、原轮尝试耗尽拒绝绕过;新例子保留NEEDS_REVIEW | 不把本地时间预算测试换算成生产延迟指标 |
| W13 | 轮次创建失败回滚、PENDING落库及检查点全停恢复 | 精确COMMIT不确定窗口仍按W01/P6补强 |
| W14 | 已暂停拒绝LEASED开始、开始/取消竞争、暂停期间结果收集及原预算 | 补PAUSE与开始/后继创建/领取/重试的实际并发交错;CANCEL竞争不能代替PAUSE竞争 |
| W15 | 暂停期间任务/信号/到期处理,Continue保留paused,原时限不增加 | 补暂停状态跨全节点退出后解除暂停的公共API完整场景 |
| W16 | 最终信号与查询结果按原受理时间裁决,在途PENDING结清、冲突核查、全停保留 | 三方同时触发及热点负载按W09和P6补强 |
| W17 | 同版本同内容/不同内容、非法定义、SDK丢发布回执恢复 | 旧SDK/服务版本滚动兼容矩阵按P7发布候选核对 |
## 本批验证记录
环境:macOS arm64、Go 1.27.1、Docker MySQL 8.4.10,一次性隔离数据库和真实Orbit进程。新例子先用`go run -race`运行,两个命令均退出0;恢复例子的网络断线由本机HTTP服务器真实注入。
标准命令为`make fmt test vet build`及`make integration`。完整标准脚本退出0,23个SDK示例、2个独立业务模块和原进程故障链路均通过。存储组239.651秒、流程进程组48.687秒、Inbox35.613秒、Outbox34.499秒,最后Worker/订阅进程组95.453秒;这些是测试耗时,不是性能指标。
收尾按原计划给轮询例子补入首次提交TASK,该修改晚于上述全量脚本的示例执行。最终两个例子另在新隔离数据库、真实Orbit及`go run -race`下完整重跑,退出0,两个轮询场景均验证submissions=1;随后取得基础fmt/race单元/vet/build退出0。平台服务端和迁移未改动;不将较早的全量日志冒充包含最后这项示例修改。
最终文档检查1162个本地链接、68个JSON块,错误0。数据库升级回归继续确认70个历史外键移除、323个原索引组成部分和任务保留,最终结构外键0。
首次定向脚本省去存储测试后忘记单独执行迁移,bootstrap在发放凭据前拒绝,已修正为先运行orbit-migrate。编译时也纠正了示例误用的来源字段名,正式SDK字段为PollRound。这些修正没有改变平台执行或恢复规则。
## 下一步按顺序完成
1. 补W06的版本变更后实际后继执行、W09三方竞争、W14暂停竞争与W15暂停全停接续,保留可复现的真实数据库/进程断言。
2. 据这些结果再决定P4/P5功能阶段是否可验收,不能把本报告中的部分验证改成全项完成。
3. 进入P6统一观测、凭据轮换、历史保留/归档、故障切点工具、容量负载、备份恢复与滚动升级;继续保留A04精确未派达、MySQL切换、跨故障域等原门槛。
4. P7补实际文件转换等仍缺的原场景、SDK版本发布与新接入者全量核对。百万企业支持能力只能根据实际工作负载和目标拓扑验证,当前不作容量承诺。

View File

@@ -210,3 +210,5 @@ P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md)
[ListWorkflows及完整查询示例](../api/workflow-queries.md)已接入,固定筛选沿游标分页,覆盖未执行分支的全部队列权限。
[ListWorkflowEvents / GetWorkflowEvent](../api/workflow-events.md)按实例读取不可变状态事件,要求显式workflows:audit:read;[完整workflow示例](../examples/workflow.md)核对信号从PENDING到CONSUMED的原事件与赢家等待。游标用于审计翻页,不是可靠订阅水位。
独立[有界轮询例子](../examples/workflow-poll.md)区分请求失败重试与PENDING业务轮次;[人工恢复例子](../examples/workflow-recovery.md)从真实HTTP回执丢失开始,依次展示证据核查、逻辑结论、解除核查、显式恢复暂停和原历史保留。

View File

@@ -88,6 +88,8 @@ ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap -
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/workflow.credentials.json" "$TASK_GO" run ./examples/workflow-definition)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/workflow.credentials.json" "$TASK_GO" run ./examples/workflow)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/workflow.credentials.json" "$TASK_GO" run ./examples/workflow-query)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/workflow.credentials.json" "$TASK_GO" run ./examples/workflow-poll)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/workflow.credentials.json" "$TASK_GO" run ./examples/workflow-recovery)
# 接收方业务数据独立建库;数据库驱动仅属于此示例模块,不加入核心SDK。
"$TASK_DOCKER" exec "$TASK_TEST_NAME" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=tcp -e "CREATE DATABASE orbit_receiver_test CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"'

View File

@@ -0,0 +1,119 @@
// Package flowdemo 仅为可运行流程示例提供初始化和有界等待,不属于公开SDK或业务恢复策略。
package flowdemo
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func Client() (*orbit.Client, string, string, error) {
raw, err := os.ReadFile(os.Getenv("ORBIT_CREDENTIALS_FILE"))
if err != nil {
return nil, "", "", err
}
var credentials struct {
Token string `json:"token"`
QueueID string `json:"queue_id"`
}
if json.Unmarshal(raw, &credentials) != nil || credentials.Token == "" || credentials.QueueID == "" {
return nil, "", "", errors.New("invalid private credentials file")
}
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: credentials.Token})
if err != nil {
return nil, "", "", err
}
operation, err := orbit.NewWorkerMessageID()
return client, credentials.QueueID, operation, err
}
// PublishStart 每次示例运行创建独立身份;不将重新运行命令当作未知响应的原键恢复。
func PublishStart(ctx context.Context, api *orbit.Client, operation, name string, spec orbit.WorkflowSpecification, input json.RawMessage) (orbit.WorkflowInstance, error) {
var empty orbit.WorkflowInstance
fmt.Printf("scenario=%s operation=%s\n", name, operation)
created, err := api.CreateWorkflowDefinition(ctx, operation, orbit.CreateWorkflowDefinitionRequest{Name: name + "." + operation, Description: "controlled standalone workflow example"})
if err != nil {
return empty, err
}
if _, err = api.PublishWorkflowVersion(ctx, created.Definition.ID, operation, orbit.PublishWorkflowVersionRequest{Version: "v1", Specification: spec, Reason: "publish controlled SDK example"}); err != nil {
return empty, err
}
result, err := api.StartWorkflow(ctx, operation, orbit.StartWorkflowRequest{DefinitionID: created.Definition.ID, Version: "v1", BusinessKey: operation, Input: input})
if err != nil {
return empty, err
}
fmt.Printf("workflow=%s version=%s\n", result.Instance.ID, result.Instance.Version)
return result.Instance, nil
}
// Runner 持有一个真实Worker及其结束通知,等待时同时检测Worker异常退出。
type Runner struct {
Worker *orbit.Worker
stop context.CancelFunc
done chan struct{}
err error // err 仅在done关闭后读取,关闭通道建立同步关系。
}
func StartWorker(ctx context.Context, api *orbit.Client, queue string, handlers ...orbit.WorkerHandler) (*Runner, error) {
w, err := orbit.NewWorker(api, orbit.WorkerOptions{QueueID: queue, Capacity: 1, ShutdownTimeout: 5 * time.Second, Handlers: handlers})
if err != nil {
return nil, err
}
workerCtx, stop := context.WithCancel(ctx)
r := &Runner{Worker: w, stop: stop, done: make(chan struct{})}
go func() { r.err = w.Run(workerCtx); close(r.done) }()
return r, nil
}
func (r *Runner) Close() error {
r.stop()
<-r.done
return r.err
}
func (r *Runner) Wait(ctx context.Context, check func() (bool, error)) error {
for {
ready, err := check()
if err != nil || ready {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-r.done:
return fmt.Errorf("example Worker stopped before expected state: %v", r.err)
case <-time.After(100 * time.Millisecond):
}
}
}
func (r *Runner) Step(ctx context.Context, api *orbit.Client, workflow, key string, predicate func(orbit.WorkflowStepDetail) bool) (orbit.WorkflowStepDetail, error) {
var out orbit.WorkflowStepDetail
err := r.Wait(ctx, func() (bool, error) {
var err error
out, err = api.GetWorkflowStep(ctx, workflow, key)
return err == nil && predicate(out), err
})
return out, err
}
func (r *Runner) Workflow(ctx context.Context, api *orbit.Client, workflow, state string) (orbit.WorkflowInstance, error) {
var out orbit.WorkflowInstance
err := r.Wait(ctx, func() (bool, error) {
var err error
out, err = api.GetWorkflow(ctx, workflow)
if err != nil || out.State == state {
return err == nil, err
}
if out.State == "NEEDS_REVIEW" || out.State == "FAILED" || out.State == "CANCELLED" || out.State == "SUCCEEDED" {
return false, fmt.Errorf("unexpected workflow state %s; wanted %s", out.State, state)
}
return false, nil
})
return out, err
}

View File

@@ -0,0 +1,249 @@
// workflow-poll 区分请求失败重试与成功PENDING后的业务轮次,并演示轮次耗尽进入核查。
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
"git.yuntuo.hk/k2safe/orbit/sdk/go/examples/internal/flowdemo"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
api, queue, operation, err := flowdemo.Client()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
for _, exhausted := range []bool{false, true} {
if err := scenario(ctx, api, queue, operation, exhausted); err != nil {
return err
}
}
return nil
}
func scenario(ctx context.Context, api *orbit.Client, queue, operation string, exhausted bool) error {
mode, expected := "success", "SUCCEEDED"
if exhausted {
mode, expected = "exhausted", "NEEDS_REVIEW"
}
operation += "." + mode
var requests, submissions atomic.Int32
// 这是本地外部渠道夹具。真实业务应查询已存在的外部作业,不能在轮询里重新提交作业。
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/jobs/"+operation {
http.NotFound(w, r)
return
}
if r.Method == "POST" {
raw, err := io.ReadAll(io.LimitReader(r.Body, 1025))
var in struct {
OperationID string `json:"operation_id"`
}
if err != nil || len(raw) > 1024 || json.Unmarshal(raw, &in) != nil || in.OperationID != operation {
http.Error(w, "invalid operation", 400)
return
}
if submissions.Add(1) != 1 {
http.Error(w, "unexpected repeated submission", 409)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(in)
return
}
if r.Method != "GET" || submissions.Load() != 1 {
http.NotFound(w, r)
return
}
n := requests.Add(1)
if !exhausted && n == 1 {
http.Error(w, "temporary query failure", http.StatusServiceUnavailable)
return
}
state := "PENDING"
if !exhausted && n >= 3 {
state = "DONE"
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(state)
}))
defer remote.Close()
input, _ := json.Marshal(struct {
OperationID string `json:"operation_id"`
}{operation})
typ := orbit.WorkflowType{Kind: "OBJECT", Fields: map[string]orbit.WorkflowType{"operation_id": {Kind: "STRING"}}}
status := orbit.WorkflowType{Kind: "STRING", Enum: []string{"PENDING", "DONE"}}
handler := "job.query." + operation
task := orbit.WorkflowTask{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", InputType: typ, Input: orbit.WorkflowRef("input"), ResultType: status, TimeoutMS: 20000, Retry: orbit.RetryPolicy{MaxAttempts: 2, InitialDelayMS: 1000, MaxDelayMS: 1000, AttemptTimeoutMS: 5000}, RecoverySafety: orbit.RecoveryIdempotent}
submit := task
submit.Handler, submit.ResultType, submit.RecoverySafety = "job.submit."+operation, typ, orbit.RecoveryReconcileOnly
submit.Retry = orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 5000}
task.Input = orbit.WorkflowRef("submit")
spec := orbit.WorkflowSpecification{SchemaVersion: 1, InputType: typ, TimeoutMS: 45000, FailurePolicy: "REVIEW", Body: orbit.WorkflowBlock{Steps: []orbit.WorkflowStep{{Key: "submit", Kind: "TASK", Task: &submit}, {Key: "query", Kind: "POLL", Poll: &orbit.WorkflowPoll{Task: task, MaxRounds: 2, TimeoutMS: 30000, IntervalMS: 1000, Pending: []string{"PENDING"}, Succeeded: []string{"DONE"}}}}, OutputType: status, Output: orbit.WorkflowRef("query")}}
v, err := flowdemo.PublishStart(ctx, api, operation, "poll."+mode, spec, input)
if err != nil {
return err
}
httpClient := remote.Client()
httpClient.Timeout = 3 * time.Second
runner, err := flowdemo.StartWorker(ctx, api, queue, orbit.WorkerHandler{Capability: orbit.WorkerCapability{Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: func(ctx context.Context, e orbit.WorkerExecution) (json.RawMessage, error) {
var in struct {
OperationID string `json:"operation_id"`
}
if json.Unmarshal(e.Task.Payload, &in) != nil || in.OperationID != operation {
return nil, &orbit.WorkerFailure{Code: "INVALID_JOB"}
}
req, err := http.NewRequestWithContext(ctx, "GET", remote.URL+"/jobs/"+in.OperationID, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
} // 未知传输错误保留UNKNOWN,由冻结安全策略处理。
defer resp.Body.Close()
if resp.StatusCode == http.StatusServiceUnavailable {
// 本例渠道明确拒绝只读查询,可以声明请求失败且允许Pulse重试。
return nil, &orbit.WorkerFailure{Code: "QUERY_TEMPORARY", Retryable: true}
}
if resp.StatusCode != http.StatusOK {
return nil, errors.New("unexpected query response")
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1025))
if err != nil {
return nil, err
}
var state string
if len(raw) > 1024 || json.Unmarshal(raw, &state) != nil || state != "PENDING" && state != "DONE" {
return nil, errors.New("invalid query result")
}
return json.RawMessage(raw), nil // PENDING是成功查询结果,不能转为可重试错误。
}}, orbit.WorkerHandler{Capability: orbit.WorkerCapability{Handler: submit.Handler, HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: func(ctx context.Context, e orbit.WorkerExecution) (json.RawMessage, error) {
req, err := http.NewRequestWithContext(ctx, "POST", remote.URL+"/jobs/"+operation, bytes.NewReader(e.Task.Payload))
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
} // 提交结果未知必须核查,不能交给POLL重新提交。
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
return nil, errors.New("submission receipt unavailable")
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1025))
if err != nil {
return nil, err
}
var accepted struct {
OperationID string `json:"operation_id"`
}
if len(raw) > 1024 || json.Unmarshal(raw, &accepted) != nil || accepted.OperationID != operation {
return nil, errors.New("submission identity mismatch")
}
return json.RawMessage(raw), nil
}})
if err != nil {
return err
}
defer runner.Close()
initial, err := runner.Step(ctx, api, v.ID, "query", func(d orbit.WorkflowStepDetail) bool { return d.Poll != nil && d.Step.ActivationID != nil })
if err != nil {
return err
}
final, err := runner.Workflow(ctx, api, v.ID, expected)
if err != nil {
return err
}
step, err := api.GetWorkflowStep(ctx, v.ID, "query")
if err != nil {
return err
}
if step.Poll == nil || step.Step.ActivationID == nil || step.Poll.UsedRounds != 2 || step.Poll.MaxAttemptsPerRound != 2 || !step.Poll.DeadlineAt.Equal(initial.Poll.DeadlineAt) || !final.DeadlineAt.Equal(v.DeadlineAt) || *step.Step.ActivationID != *initial.Step.ActivationID {
return errors.New("poll budget, deadline or activation changed")
}
// 历史按业务轮倒序;每轮再独立读取Pulse尝试,二者不可混为同一个计数。
cursor, rounds, totalAttempts := "", 0, 0
for {
page, err := api.ListWorkflowPollRounds(ctx, v.ID, "query", *step.Step.ActivationID, orbit.HistoryPageRequest{Limit: 1, Cursor: cursor})
if err != nil {
return err
}
for _, round := range page.Items {
rounds++
source, err := api.GetTaskSource(ctx, round.TaskID)
if err != nil {
return err
}
if source.Workflow == nil || source.Workflow.WorkflowID != v.ID || source.Workflow.PollRound != round.Round {
return errors.New("query round source mismatch")
}
attempts, err := api.ListTaskAttempts(ctx, round.TaskID, orbit.HistoryPageRequest{Limit: 10})
if err != nil {
return err
}
want := 1
if !exhausted && round.Round == 1 {
want = 2
}
if len(attempts.Items) != want || attempts.NextCursor != "" {
return errors.New("request retries consumed wrong business rounds")
}
for i, a := range attempts.Items {
expect := "SUCCEEDED"
if want == 2 && i == 1 {
expect = "FAILED"
}
if string(a.State) != expect || a.EndedAt == nil {
return errors.New("unexpected request attempt history")
}
}
totalAttempts += len(attempts.Items)
}
if page.NextCursor == "" {
break
}
cursor = page.NextCursor
}
expectedRequests := 3
if exhausted {
expectedRequests = 2
}
if rounds != 2 || totalAttempts != expectedRequests || int(requests.Load()) != expectedRequests || submissions.Load() != 1 {
return errors.New("unexpected query count")
}
if !exhausted && string(final.Output) != `"DONE"` {
return errors.New("final business result missing")
}
if exhausted && (step.Step.State != "NEEDS_REVIEW" || step.Poll.CurrentTaskID != nil || step.Poll.NextPollAt != nil) {
return errors.New("exhausted poll remained runnable")
}
if err := runner.Close(); err != nil {
return err
}
stats := runner.Worker.Stats()
if int(stats.Started) != expectedRequests+1 || int(stats.ReportsConfirmed) != expectedRequests+1 || stats.Running != 0 {
return errors.New("Worker did not drain all requests")
}
fmt.Printf("poll_scenario=%s state=%s submissions=%d rounds=%d attempts=%d requests=%d deadline_preserved=true worker_drained=true\n", mode, final.State, submissions.Load(), rounds, totalAttempts, requests.Load())
return nil
}

View File

@@ -0,0 +1,284 @@
// workflow-recovery 注入本地渠道提交后断线,使用明确证据恢复,保留UNKNOWN尝试和暂停门控。
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"sync"
"sync/atomic"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
"git.yuntuo.hk/k2safe/orbit/sdk/go/examples/internal/flowdemo"
)
const exactSequence uint64 = 9007199254740993
type request struct {
OperationID string `json:"operation_id"` // OperationID 原外部操作身份,核查时保持不变。
Sequence uint64 `json:"sequence"` // Sequence 精确业务序号,不使用float64解析。
}
type receipt struct {
OperationID string `json:"operation_id"` // OperationID 已完成操作的稳定身份。
Sequence uint64 `json:"sequence"` // Sequence 渠道已保存的精确序号。
State string `json:"state"` // State 本夹具唯一可核实的成功状态CONFIRMED。
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
api, queue, operation, err := flowdemo.Client()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
var mu sync.Mutex
var committed json.RawMessage
var submissions atomic.Int32
var dropped atomic.Bool
// 本地夹具代表唯一受控外部系统:先保存结果,再关闭HTTP连接而不发送回执。
// 真实人工核查需要持久业务证据及执行已结束的证明,不能只凭GET成功推断所有执行已停止。
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/objects/"+operation {
http.NotFound(w, r)
return
}
if r.Method == "GET" {
mu.Lock()
raw := append([]byte(nil), committed...)
mu.Unlock()
if len(raw) == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(raw)
return
}
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
raw, err := io.ReadAll(io.LimitReader(r.Body, 4097))
var in request
if err != nil || len(raw) > 4096 || json.Unmarshal(raw, &in) != nil || in.OperationID != operation || in.Sequence != exactSequence {
http.Error(w, "invalid command", 400)
return
}
if submissions.Add(1) != 1 {
http.Error(w, "unexpected resubmission", 409)
return
}
evidence, _ := json.Marshal(receipt{operation, exactSequence, "CONFIRMED"})
mu.Lock()
committed = evidence
mu.Unlock()
conn, _, err := w.(http.Hijacker).Hijack()
if err == nil {
dropped.Store(true)
_ = conn.Close()
}
}))
defer remote.Close()
httpClient := remote.Client()
httpClient.Timeout = 3 * time.Second
requestType := orbit.WorkflowType{Kind: "OBJECT", Fields: map[string]orbit.WorkflowType{"operation_id": {Kind: "STRING"}, "sequence": {Kind: "NUMBER"}}}
receiptType := orbit.WorkflowType{Kind: "OBJECT", Fields: map[string]orbit.WorkflowType{"operation_id": {Kind: "STRING"}, "sequence": {Kind: "NUMBER"}, "state": {Kind: "STRING", Enum: []string{"CONFIRMED"}}}}
publishHandler, recordHandler := "object.publish."+operation, "object.record."+operation
publish := orbit.WorkflowTask{QueueID: queue, Handler: publishHandler, HandlerVersion: "v1", PayloadVersion: "v1", InputType: requestType, Input: orbit.WorkflowRef("input"), ResultType: receiptType, TimeoutMS: 30000, Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 5000}, RecoverySafety: orbit.RecoveryReconcileOnly}
record := publish
record.Handler = recordHandler
record.InputType = receiptType
record.Input = orbit.WorkflowRef("publish")
record.RecoverySafety = orbit.RecoveryIdempotent
spec := orbit.WorkflowSpecification{SchemaVersion: 1, InputType: requestType, TimeoutMS: 60000, FailurePolicy: "REVIEW", Body: orbit.WorkflowBlock{Steps: []orbit.WorkflowStep{{Key: "publish", Kind: "TASK", Task: &publish}, {Key: "record", Kind: "TASK", Task: &record}}, OutputType: receiptType, Output: orbit.WorkflowRef("record")}}
input, _ := json.Marshal(request{operation, exactSequence})
v, err := flowdemo.PublishStart(ctx, api, operation, "recovery", spec, input)
if err != nil {
return err
}
runner, err := flowdemo.StartWorker(ctx, api, queue,
orbit.WorkerHandler{Capability: orbit.WorkerCapability{Handler: publishHandler, HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: func(ctx context.Context, e orbit.WorkerExecution) (json.RawMessage, error) {
req, err := http.NewRequestWithContext(ctx, "POST", remote.URL+"/objects/"+operation, bytes.NewReader(e.Task.Payload))
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
} // 回执丢失只能报UNKNOWN,不能声明明确失败以触发重发。
resp.Body.Close()
return nil, errors.New("fixture expected a lost response; inspect remote operation")
}},
orbit.WorkerHandler{Capability: orbit.WorkerCapability{Handler: recordHandler, HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: func(ctx context.Context, e orbit.WorkerExecution) (json.RawMessage, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := verifyReceipt(e.Task.Payload, operation); err != nil {
return nil, &orbit.WorkerFailure{Code: "INVALID_RECEIPT"}
}
return e.Task.Payload, nil
}})
if err != nil {
return err
}
defer runner.Close()
step, err := runner.Step(ctx, api, v.ID, "publish", func(d orbit.WorkflowStepDetail) bool { return d.Step.State == "NEEDS_REVIEW" })
if err != nil {
return err
}
if step.Step.TaskID == nil || step.Step.ActivationID == nil {
return errors.New("review source missing")
}
taskID, activation := *step.Step.TaskID, *step.Step.ActivationID
task, err := api.GetTask(ctx, taskID)
if err != nil {
return err
}
attempts, err := api.ListTaskAttempts(ctx, taskID, orbit.HistoryPageRequest{Limit: 2})
if err != nil {
return err
}
if task.State != orbit.TaskNeedsReview || len(attempts.Items) != 1 || attempts.Items[0].State != "UNKNOWN" || attempts.Items[0].EndedAt == nil || submissions.Load() != 1 || !dropped.Load() {
return errors.New("lost response did not retain one UNKNOWN execution")
}
if err := runner.Wait(ctx, func() (bool, error) {
s := runner.Worker.Stats()
return s.Running == 0 && s.ReportsConfirmed == 1, nil
}); err != nil {
return err
}
current, err := api.GetWorkflow(ctx, v.ID)
if err != nil {
return err
}
if _, err = api.ControlWorkflow(ctx, v.ID, operation+".pause", orbit.WorkflowControlRequest{Operation: "PAUSE", ExpectedControlRevision: current.ControlRevision, Reason: "hold successor while operator verifies evidence"}); err != nil {
return err
}
// 这里只在受控夹具证明“唯一提交已经结束、操作身份和结果一致”后模拟管理员决定。
req, err := http.NewRequestWithContext(ctx, "GET", remote.URL+"/objects/"+operation, nil)
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
evidence, readErr := io.ReadAll(io.LimitReader(resp.Body, 4097))
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode != 200 || len(evidence) > 4096 {
return errors.New("verified external evidence unavailable")
}
if err := verifyReceipt(evidence, operation); err != nil {
return err
}
step, err = api.GetWorkflowStep(ctx, v.ID, "publish")
if err != nil {
return err
}
evidenceRef := "evidence:local-fixture/" + operation
decision := orbit.WorkflowRecoveryDecision{ExpectedRevision: step.Revision, Reason: "verified fixture commit, original operation and settled execution", EvidenceRef: evidenceRef}
command := orbit.ReviewWorkflowTaskRequest{WorkflowRecoveryDecision: decision, TaskID: taskID, ExpectedTaskRevision: task.Revision, ExecutionSettled: true, Outcome: orbit.TaskSucceeded, Result: evidence}
reviewed, err := api.ReviewWorkflowTask(ctx, v.ID, operation+".review", command)
if err != nil {
return err
}
again, err := api.ReviewWorkflowTask(ctx, v.ID, operation+".review", command)
if err != nil {
return err
}
if !again.Deduplicated || again.AuditID != reviewed.AuditID || reviewed.Task == nil || reviewed.TaskAuditID == nil {
return errors.New("review identity or dual audit missing")
}
step, err = runner.Step(ctx, api, v.ID, "publish", func(d orbit.WorkflowStepDetail) bool { return d.Revision > reviewed.Instance.Revision })
if err != nil {
return err
}
if step.Step.ActivationID == nil || *step.Step.ActivationID != activation {
return errors.New("review changed activation")
}
decision.ExpectedRevision = step.Revision
resolved, err := api.ResolveWorkflowStep(ctx, v.ID, operation+".resolve", orbit.ResolveWorkflowStepRequest{WorkflowStepDecision: orbit.WorkflowStepDecision{WorkflowRecoveryDecision: decision, StepKey: "publish", ActivationID: activation, ExpectedStepRevision: step.Step.Revision}, ExecutionSettled: true, Outcome: "SUCCEEDED", Result: evidence})
if err != nil {
return err
}
current, err = api.GetWorkflow(ctx, v.ID)
if err != nil {
return err
}
decision.ExpectedRevision = current.Revision
continued, err := api.ContinueWorkflow(ctx, v.ID, operation+".continue", orbit.ContinueWorkflowRequest{WorkflowRecoveryDecision: decision, ReviewResolved: true})
if err != nil {
return err
}
next, err := api.GetWorkflowStep(ctx, v.ID, "record")
if err != nil {
return err
}
if !continued.Instance.Paused || next.Step.ActivationID != nil || next.Step.TaskID != nil || next.Step.State != "BLOCKED" {
return errors.New("manual recovery bypassed pause gate")
}
for action, id := range map[string]string{"workflow.task.review": reviewed.AuditID, "workflow.step.resolve": resolved.AuditID, "workflow.review.continue": continued.AuditID} {
audit, err := api.GetWorkflowOperation(ctx, v.ID, id)
if err != nil {
return err
}
if audit.Action != action || audit.EvidenceRef != evidenceRef || audit.Reason != decision.Reason || audit.ActorID == "" {
return errors.New("management evidence or actor missing")
}
}
current, err = api.GetWorkflow(ctx, v.ID)
if err != nil {
return err
}
if _, err = api.ControlWorkflow(ctx, v.ID, operation+".resume", orbit.WorkflowControlRequest{Operation: "RESUME", ExpectedControlRevision: current.ControlRevision, Reason: "verified review complete; explicitly release pause"}); err != nil {
return err
}
final, err := runner.Workflow(ctx, api, v.ID, "SUCCEEDED")
if err != nil {
return err
}
if err := verifyReceipt(final.Output, operation); err != nil {
return err
}
old, err := api.GetTaskAttempt(ctx, taskID, attempts.Items[0].ID)
if err != nil {
return err
}
if old.State != "UNKNOWN" || old.EndedAt == nil || !old.EndedAt.Equal(*attempts.Items[0].EndedAt) || submissions.Load() != 1 || !final.DeadlineAt.Equal(v.DeadlineAt) {
return errors.New("recovery rewrote history, repeated submission or extended deadline")
}
if err := runner.Close(); err != nil {
return err
}
stats := runner.Worker.Stats()
if stats.Started != 2 || stats.ReportsConfirmed != 2 || stats.Running != 0 {
return errors.New("unexpected executions or incomplete drain")
}
fmt.Printf("recovery_verified=true state=%s submissions=%d unknown_history_preserved=true review_deduplicated=true pause_preserved=true audits=3 sequence=%d worker_drained=true\n", final.State, submissions.Load(), exactSequence)
return nil
}
func verifyReceipt(raw json.RawMessage, operation string) error {
var out receipt
if json.Unmarshal(raw, &out) != nil || out.OperationID != operation || out.Sequence != exactSequence || out.State != "CONFIRMED" {
return errors.New("external receipt identity or exact sequence mismatch")
}
return nil
}