feat: recover workflow waits within original deadlines

This commit is contained in:
2026-09-18 06:10:04 +08:00
parent 56b9a2ccfe
commit af1ae4cb21
25 changed files with 941 additions and 52 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及最终查询竞争已接入,内部控制与停止结清已接入,继续独立SIGNAL/TIMER等待重建及实例公共SDK;P4整体和P5–P7仍未完成。
- P4定义子项已通过真实MySQL、HTTP丢回执、独立SDK和全量回归(docs/reports/phase-4-definitions.md)。实例启动/固定版本/首批任务同事务的内部存储已补齐;内部事件消费、顺序与组合推进及根接续已接入;TIMER与后续POLL时钟已接入,SIGNAL及最终查询竞争已接入,内部控制与停止结清已接入,等待恢复已接入,继续实例公共SDK;P4整体和P5–P7仍未完成。
- 内部结果交付使用workflow_activation所有者并固定激活ID,TASK经activation.task_id、POLL经轮次校验归属;weave_inbox来源及独立快照与Pulse结果同事务,先于外部结果流行锁,不在结果路径反锁实例/步骤/订阅。迁移000017隔离缺快照历史,禁止用当前任务重建旧事件。
- weave/engine仅计算有限决策;实例受理、Inbox消费与根接续已接入,等待和停止结清运行器已接入,内部核查/TASK恢复已接入,独立SIGNAL/TIMER等待重建继续开发。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);独立SIGNAL/TIMER等待重建、公共控制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,9 +52,11 @@
- 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),独立SIGNAL/TIMER等待重建尚未实现,公共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个中文注释字段。独立SIGNAL/TIMER等待重建、公共HTTP/SDK与生产验收继续推进。
- POLL恢复的最终信号等待保留原契约/关联/截止并更换代次,旧winner和已消费信号只留历史;存在当前代次PENDING信号时拒绝重建。迁移000022无外键,新增2个中文注释字段。公共HTTP/SDK与生产验收继续推进。
- 核查将UNKNOWN请求确认成成功PENDING时,当前RetryStep仍拒绝重查同轮;显式恢复等待/后续业务轮的管理操作尚待实现,必须保留原间隔、轮次与截止并使用新的审计决定,不能由ReviewTask自动解除核查。
- 核查将UNKNOWN请求确认成成功PENDING时,当前RetryStep仍拒绝重查同轮;ContinuePoll显式恢复原等待,必须核验当前SUCCEEDED/PENDING任务、已处理Inbox及不可变来源。新审计固定来源事件/版本、原间隔与截止,时钟/最终信号核验证明后才推进;不改写旧Inbox处置,ReviewTask不自动解除核查。
- RearmWait仅重建独立SIGNAL/TIMER,显式wait_verified及最新步骤/激活/实例版本,连续激活上限32。旧WAITING激活/等待关闭,新代次保留首次输入、契约、关联及截止;PENDING信号、已有赢家、停止意图及过期均拒绝。TIMER运行和成功核查按首次激活计时,恢复后仍须Continue;无新持久化字段或外键。

View File

@@ -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) 继续实现。
P4已实现[流程定义与不可变版本发布](docs/api/workflow-definitions.md)、[结构版本1](docs/api/workflow-definition-format.md)及[SDK发布例子](docs/examples/workflow-definition.md)。[有限步骤决策器与内部结果交付](docs/10-workflow-engine-core.md)已实现;已补[实例受理及首批工作存储](docs/11-workflow-admission.md)。[结果消费、顺序/分支/并行推进及根检查点接续](docs/12-workflow-runtime.md)已接入后台运行器。[定时等待、后续查询轮次及原预算恢复](docs/13-workflow-clock.md)已接入;[外部信号和查询最终信号竞争](docs/14-workflow-signals.md)已接入内部运行器;[暂停/继续/取消与失败结清](docs/15-workflow-controls.md)已接入内部运行器;[人工核查、解除核查与TASK新激活](docs/16-workflow-recovery.md)已接入内部服务;[POLL跨激活恢复](docs/17-workflow-poll-recovery.md)已接入;独立SIGNAL/TIMER等待重建及实例公共API/SDK继续开发,发布定义不会启动任务。
P4已实现[流程定义与不可变版本发布](docs/api/workflow-definitions.md)、[结构版本1](docs/api/workflow-definition-format.md)及[SDK发布例子](docs/examples/workflow-definition.md)。[有限步骤决策器与内部结果交付](docs/10-workflow-engine-core.md)已实现;已补[实例受理及首批工作存储](docs/11-workflow-admission.md)。[结果消费、顺序/分支/并行推进及根检查点接续](docs/12-workflow-runtime.md)已接入后台运行器。[定时等待、后续查询轮次及原预算恢复](docs/13-workflow-clock.md)已接入;[外部信号和查询最终信号竞争](docs/14-workflow-signals.md)已接入内部运行器;[暂停/继续/取消与失败结清](docs/15-workflow-controls.md)已接入内部运行器;[人工核查、解除核查与TASK新激活](docs/16-workflow-recovery.md)已接入内部服务;[POLL跨激活恢复](docs/17-workflow-poll-recovery.md)已接入;[PENDING接续和独立等待恢复](docs/18-workflow-wait-recovery.md)已接入内部服务;实例公共API/SDK继续开发,发布定义不会启动任务。
数据库禁止外键,关联一致性通过服务端事务校验,索引、唯一约束和 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.8 · 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)已接入;独立SIGNAL/TIMER等待重建及实例公共API/SDK仍待P4–P5实现验收。
> 版本 0.9 · 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仍待P4–P5实现验收。
## 1. 职责与边界
@@ -174,7 +174,7 @@ Weave 不执行特定业务逻辑,也不把整段业务代码放入平台进
3. 在一个事务内记录管理操作,创建新的步骤激活及关联 Pulse 任务,并固定关联关系。
4. 保留旧激活与旧任务结果,沿用恰当的业务幂等身份。
执行核查、步骤结论、解除核查及TASK新激活已按[人工恢复约定](16-workflow-recovery.md)实现内部服务,原预算和绝对截止不重置;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入,独立SIGNAL/TIMER等待重建仍需补齐,公共API另行交付。普通任务人工重试入口应拒绝绕过流程管理的关联任务请求。已经确认成功的步骤不能通过“重试流程”随意再执行一次。
执行核查、步骤结论、解除核查及TASK新激活已按[人工恢复约定](16-workflow-recovery.md)实现内部服务,原预算和绝对截止不重置;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入,等待恢复已接入内部服务,公共API另行交付。普通任务人工重试入口应拒绝绕过流程管理的关联任务请求。已经确认成功的步骤不能通过“重试流程”随意再执行一次。
取消和补偿是不同业务操作。若未来增加补偿,补偿本身也必须是有权限、可审计、可重试或可核查的业务步骤;不能宣称撤销流程就能撤回已经生效的外部业务操作。

View File

@@ -211,9 +211,14 @@ Instance新增StopKind和StopRequestedAt,冻结首次用户取消或失败结
## 流程人工核查与TASK新激活
复用现有激活sequence、任务retry_of、审计和幂等回执,不增加表、字段或外键。TASK人工重试以新激活保留旧执行,原尝试预算跨激活扣除,原绝对截止不改变;执行核查和步骤逻辑结论分别保存。35个实体、303个中文注释字段。当前内部支持范围与独立SIGNAL/TIMER等待重建剩余项见[核查约定](16-workflow-recovery.md)。
复用现有激活sequence、任务retry_of、审计和幂等回执,不增加表、字段或外键。TASK人工重试以新激活保留旧执行,原尝试预算跨激活扣除,原绝对截止不改变;执行核查和步骤逻辑结论分别保存。此阶段为35个实体、303个中文注释字段;后续POLL恢复增加2个预算字段,见下节。核查操作见[核查约定](16-workflow-recovery.md),独立等待恢复见[等待恢复](18-workflow-wait-recovery.md)。
### 000022:跨激活保留查询轮次与尝试
PollState新增FirstRound和FirstRoundAttemptsUsed,记录当前激活的首个业务轮及该轮在旧激活已消耗的尝试。UsedRounds继续使用首次激活起的业务轮编号;原单轮尝试和查询/请求绝对截止均不重置。新业务轮恢复原单轮策略,人工恢复同轮只分配剩余尝试。当前35个实体、305个直接持久化字段声明,Go/SQL均有中文注释,无外键。v21升级保留旧数据、时间和预算,详情见[查询恢复](17-workflow-poll-recovery.md)。
### 等待恢复与PENDING接续
复用现有激活、等待、查询状态和不可变审计,不新增迁移或字段。RearmWait以新代次保留首次等待截止;ContinuePoll保留当前代次和原轮次,以权威已处理结果及管理审计恢复间隔。仍为35个实体/305个中文注释字段,无外键,见[等待恢复](18-workflow-wait-recovery.md)。

View File

@@ -28,8 +28,8 @@
| 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)已接入;独立SIGNAL/TIMER等待重建和实例公共API/SDK继续开发 |
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 有界查询轮次/尝试独立预算及原截止恢复已接入内部运行器;最终信号竞争及在途查询结清已接入;内部暂停/继续/取消与失败结清已接入;执行核查、步骤结论、解除核查及TASK新激活已接入;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入;独立SIGNAL/TIMER等待重建及公共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 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 待开发 |
@@ -41,10 +41,21 @@ 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的有界等待接续及独立SIGNAL/TIMER等待重建,保留原预算、轮次、结果和证据,不能通过换激活/新键绕过未知效果或执行上限。
3. 已实现内部执行核查、步骤结论、解除核查及TASK新激活和旧激活隔离,见[核查报告](reports/phase-5-recovery.md)。POLL跨激活恢复已实现(见[查询恢复](17-workflow-poll-recovery.md));[核查后PENDING接续和独立等待重建](18-workflow-wait-recovery.md)也已接入内部服务,保留原预算、轮次、结果和证据。
4. 同步开放实例/步骤/信号/来源/审计HTTP、独立SDK和端到端场景,覆盖原键恢复、权限、版本冲突、竞争与进程全停;公共步骤/激活查询须返回目标激活代次,调用方据此发送恢复后的信号,不能要求调用方自行猜测sequence。公共能力逐项记录实际验收结果。
5. 转入P6/P7的生产故障、容量、运维、发布与全部文档核对。内部存储阶段通过不会提前勾选公共接口或生产验收。
公共接入下一批按以下调用链交付,逐项补齐服务端、独立SDK、Markdown手册和真实HTTP回归:
| 调用链 | 需要补齐的内容 | 验收重点 |
| --- | --- | --- |
| 启动与读取 | 显式固定版本启动、当前实例、步骤及当前激活代次;后续补筛选分页和单次有界历史读取 | 原键丢回执恢复、当前权限、读取快照一致性、未激活步骤语义 |
| 控制与信号 | 暂停/继续/取消、固定步骤和代次的信号提交及原受理回执 | control_revision与通用revision区分、迟到/旧代次、暂停期间结果收集 |
| 核查与追踪 | 六类人工恢复操作、激活/轮次/等待/信号及管理审计读取、Pulse任务流程来源 | 当前实例/步骤/任务版本、证据和显式声明、旧历史保留、有界响应和权限隔离 |
| 通用接入场景 | 并行汇合、外部回执、有限轮询、暂停核查恢复的可运行SDK示例 | 真实Worker执行、完整进程退出后接续、未知效果保留、全链路回归 |
接口只使用已经落地的内部事务规则;SDK保持独立module,不能引用服务端模型或数据库实现。生产故障及容量结论仍由P6专门验收。
### P3 功能范围核对
可靠订阅链路完成后,重新对照 [原功能范围](01-functional-spec.md) 和 [事务衔接设计](04-reliability-and-data.md),以下原定首版能力已按核对结果补齐;进一步发现的相对延迟与改参数关联示例也已交付。[P3验收](reports/phase-3.md)逐项保留实现证据及尚待P4–P7完成的原范围,不按已有模块反推全平台完成。
@@ -119,6 +130,8 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
## 7. 文档入口
- [等待恢复](18-workflow-wait-recovery.md)
- [等待恢复验证记录](reports/phase-5-wait-recovery.md)
- [有界查询恢复](17-workflow-poll-recovery.md)
- [POLL恢复验证记录](reports/phase-5-poll-recovery.md)
- [数据模型](07-data-model.md)

View File

@@ -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)已接入;独立SIGNAL/TIMER等待重建、接口和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. 消费者领取内部事件后核对命名空间、来源任务版本、激活/轮次及当前步骤版本;消费确认、步骤变化、后续任务或持久化待续记录同事务提交。

View File

@@ -1,6 +1,6 @@
# Weave 实例受理与首批工作
状态:内部Go服务与MySQL存储适配已实现实例Start/Get/Steps、固定版本、首批决策落库和可靠结果交付。[后台Inbox消费和根待续扫描](12-workflow-runtime.md)已接通。**尚未开放实例HTTP路由、启动SDK或命令行入口;[内部控制和结清](15-workflow-controls.md)已接入,[内部核查及TASK恢复](16-workflow-recovery.md)已接入,独立SIGNAL/TIMER等待重建继续开发;[信号运行器](14-workflow-signals.md)已接入。** Weave相关公开SDK目前仅支持定义发布。[TIMER和后续POLL轮次](13-workflow-clock.md)已接入,TASK及组合节点已可自动推进,不能将此解释成全部等待/控制能力已实现。
状态:内部Go服务与MySQL存储适配已实现实例Start/Get/Steps、固定版本、首批决策落库和可靠结果交付。[后台Inbox消费和根待续扫描](12-workflow-runtime.md)已接通。**尚未开放实例HTTP路由、启动SDK或命令行入口;[内部控制和结清](15-workflow-controls.md)已接入,[内部核查及TASK恢复](16-workflow-recovery.md)已接入,等待恢复已接入内部服务,公共API/SDK继续开发;[信号运行器](14-workflow-signals.md)已接入。** Weave相关公开SDK目前仅支持定义发布。[TIMER和后续POLL轮次](13-workflow-clock.md)已接入,TASK及组合节点已可自动推进,不能将此解释成全部等待/控制能力已实现。
## 1. 受理事务
@@ -49,7 +49,7 @@ Get返回当前实例视图;Start重报返回受理时冻结的原始视图,
| 活跃实例 | 每个namespace最多10000个RUNNING/WAITING/CANCELLING/NEEDS_REVIEW实例;终态不计入;满额仍可恢复原受理回执 |
| 信号关联 | 新受理最多512字节;数据库兼容旧191字符最长764字节,升级不截断历史 |
| 截止 | 数据库时间生成;任务、查询与等待均不超过实例总截止 |
| 自动运行 | Inbox消费、根待续、TIMER到期及后续POLL轮次已接入;SIGNAL与最终查询竞争已接入,内部控制与结清已接入,内部核查及TASK恢复已接入,独立SIGNAL/TIMER等待重建仍待实现 |
| 自动运行 | Inbox消费、根待续、TIMER到期及后续POLL轮次已接入;SIGNAL与最终查询竞争已接入,内部控制与结清已接入,内部核查及TASK恢复已接入,等待恢复已接入内部服务,公共API/SDK仍待实现 |
迁移000018为Wait增加signal_name和payload_version两个带中文注释的可空字段;NULL仅表示TIMER或尚未核实的历史元数据,未来消费者不得把历史NULL自动解释为有效的新信号等待。correlation_key转为精确字节存储;实例business_key改为utf8mb4_0900_bin。该迁移时为35个实体、含公共Record的295个直接持久化字段声明,全部有中文注释,继续保持零外键。

View File

@@ -1,6 +1,6 @@
# Weave 结果消费与自动推进
状态:内部实例的TASK结果消费、顺序、分支、有限并行汇合和根检查点接续已接入Orbit后台运行器。[定时等待和后续查询轮次](13-workflow-clock.md)也已接入。[信号等待与最终查询竞争](14-workflow-signals.md)已接入。实例公共HTTP/SDK仍未开放,[失败后的分支取消结清与内部控制](15-workflow-controls.md)已接入,[内部核查及TASK恢复](16-workflow-recovery.md)已接入,独立SIGNAL/TIMER等待重建继续开发,不能将本阶段等同于完整Weave交付。
状态:内部实例的TASK结果消费、顺序、分支、有限并行汇合和根检查点接续已接入Orbit后台运行器。[定时等待和后续查询轮次](13-workflow-clock.md)也已接入。[信号等待与最终查询竞争](14-workflow-signals.md)已接入。实例公共HTTP/SDK仍未开放,[失败后的分支取消结清与内部控制](15-workflow-controls.md)已接入,[内部核查及TASK恢复](16-workflow-recovery.md)已接入,等待恢复已接入内部服务,公共API/SDK继续开发,不能将本阶段等同于完整Weave交付。
## 1. 后台运行和事务边界
@@ -64,4 +64,4 @@ flowchart LR
进程回归由`make integration`构建二进制后显式执行。缺少一次性数据库或`ORBIT_TEST_SERVER_BINARY`时测试会跳过;跳过不构成进程恢复证据。验证记录见[本阶段报告](reports/phase-4-runtime.md)。
持久化信号等待及查询最终信号竞争已按[信号运行约定](14-workflow-signals.md)实现。内部控制及停止结清也已接入。下一步仍包括独立SIGNAL/TIMER等待重建、实例/来源公共API、独立SDK和通用端到端场景。P4整体及P5–P7继续按[完整计划](08-development-plan.md)执行。
持久化信号等待及查询最终信号竞争已按[信号运行约定](14-workflow-signals.md)实现。内部控制及停止结清也已接入。下一步仍包括实例/来源公共API、独立SDK和通用端到端场景。P4整体及P5–P7继续按[完整计划](08-development-plan.md)执行。

View File

@@ -65,4 +65,4 @@
[受理回归](../internal/storage/mysql/workflow_signals_integration_test.go)、[消费回归](../internal/storage/mysql/workflow_signal_runtime_integration_test.go)、[真实v19升级](../internal/storage/mysql/workflow_signal_upgrade_integration_test.go)和[实际进程恢复](../internal/storage/mysql/workflow_runtime_process_integration_test.go)对应受理/并发/故障/升级边界,实际运行结果记录在[阶段报告](reports/phase-4-signals.md)。
内部失败分支取消结清、实例暂停/继续/取消已接入。人工核查及[POLL新激活的最终信号换代](17-workflow-poll-recovery.md)已接入;下一步完成独立等待重建,再交付实例与信号HTTP、独立SDK及完整场景。当前内部存储测试不替代尚未开放的公共接口验收,也不替代生产MySQL切换与容量测试。
内部失败分支取消结清、实例暂停/继续/取消已接入。人工核查及[POLL新激活的最终信号换代](17-workflow-poll-recovery.md)已接入;[PENDING接续与独立等待重建](18-workflow-wait-recovery.md)已接入内部服务,下一步交付实例与信号HTTP、独立SDK及完整场景。当前内部存储测试不替代尚未开放的公共接口验收,也不替代生产MySQL切换与容量测试。

View File

@@ -1,6 +1,6 @@
# Weave 控制与停止结清
状态:内部Go暂停、继续、取消服务及后台失败/取消结清运行器已接入。**公共实例/控制HTTP与独立SDK尚未开放,[内部核查及TASK新激活](16-workflow-recovery.md)已接入,独立SIGNAL/TIMER等待重建仍在后续交付范围**。本文说明已实现的持久化规则,不把内部存储回归当作全部Weave或生产验收。
状态:内部Go暂停、继续、取消服务及后台失败/取消结清运行器已接入。**公共实例/控制HTTP与独立SDK尚未开放,[内部核查及TASK新激活](16-workflow-recovery.md)已接入,等待恢复已接入内部服务,公共入口仍在后续交付范围**。本文说明已实现的持久化规则,不把内部存储回归当作全部Weave或生产验收。
## 1. 控制操作
@@ -60,4 +60,4 @@ CANCELLED仅表示本次流程停止已结清,不撤销先前成功的业务
[控制及结清回归](../internal/storage/mysql/workflow_controls_integration_test.go)覆盖控制版本、权限重报、开始竞争、失败/未知、审计回滚、信号与查询及超过32步骤接续;[真实v20升级](../internal/storage/mysql/workflow_stop_upgrade_integration_test.go)验证兼容回填;[实际Orbit进程回归](../internal/storage/mysql/workflow_runtime_process_integration_test.go)覆盖停止意图和结果在全停期间保存后由两个新进程接续。实际结果见[阶段报告](reports/phase-5-controls.md)。
下一步仍需完成独立SIGNAL/TIMER等待重建、公共实例/控制/信号/审计接口、独立SDK及可运行场景,再完成P6–P7生产故障、性能、运维和发布验收。当前测试不替代生产MySQL复制、故障切换、备份恢复或RPO/RTO验证。
下一步仍需完成公共实例/控制/信号/审计接口、独立SDK及可运行场景,再完成P6–P7生产故障、性能、运维和发布验收。当前测试不替代生产MySQL复制、故障切换、备份恢复或RPO/RTO验证。

View File

@@ -1,15 +1,17 @@
# Weave 人工核查与接续
状态:内部Go服务已提供执行核查、步骤结论确认、解除核查,以及TASK/POLL新激活重试。POLL跨激活预算与附属信号换代见[查询恢复](17-workflow-poll-recovery.md)。公共HTTP、独立SDK和通用接入示例尚未交付;独立SIGNAL/TIMER等待重建继续实现。完整交付继续按[开发计划](08-development-plan.md)推进。
状态:内部Go服务已提供执行核查、步骤结论确认、解除核查,以及TASK/POLL新激活重试。POLL跨激活预算与附属信号换代见[查询恢复](17-workflow-poll-recovery.md)。公共HTTP、独立SDK和通用接入示例尚未交付;[PENDING接续及独立等待重建](18-workflow-wait-recovery.md)已接入内部服务。完整交付继续按[开发计划](08-development-plan.md)推进。
## 1. 四类操作
## 1. 六类操作
[内部服务](../weave/recovery/service.go)要求显式`workflows:recover`权限,普通Worker、任务核查和流程暂停权限均不自动包含它。四个方法都接收认证身份、实例ID、稳定幂等键和命令,命名空间与操作者从认证取得。
[内部服务](../weave/recovery/service.go)要求显式`workflows:recover`权限,普通Worker、任务核查和流程暂停权限均不自动包含它。六个方法都接收认证身份、实例ID、稳定幂等键和命令,命名空间与操作者从认证取得。
| 方法 | 作用 | 不会自动发生的事情 |
| --- | --- | --- |
| ReviewTask | 核实本实例历史激活拥有的NEEDS_REVIEW任务,写入SUCCEEDED/FAILED、明确执行已结束和证据 | 不把查询请求成功解释成业务成功,不解除步骤或实例核查 |
| ResolveStep | 将当前待核查/失败/取消叶子步骤核实为SUCCEEDED或FAILED,保存符合固定契约的逻辑结果 | 不重新执行旧任务,不清除实例核查或原停止意图 |
| ContinuePoll | 根据已处理的权威PENDING结果恢复原等待,保留轮次和间隔 | 不重查同轮,不改写原Inbox处置,不解除实例核查 |
| RearmWait | 在原截止内为独立SIGNAL/TIMER建立新等待代次 | 不延长原截止,不丢弃未消费信号,不重跑成功步骤 |
| Continue | 在当前证据和步骤状态允许时解除实例核查,保存根接续 | 不解除显式paused,不延长截止,不撤销取消或失败结清 |
| RetryStep | 为TASK或POLL建立新的激活和关联任务,继续消耗原任务或原业务轮尝试预算 | 不重跑成功步骤,不改变输入/业务关联,POLL恢复不另起业务轮次 |
@@ -27,7 +29,7 @@
ReviewTask还要求`task_id`、`expected_task_revision`、`execution_settled=true`、SUCCEEDED/FAILED结论和非null JSON结果。声明表示全部历史外部执行已结束且不会继续产生效果;租约失效和UNKNOWN本身不能证明这一点。
ResolveStep和RetryStep都要求固定`step_key`、当前`activation_id`及`expected_step_revision`。前者要求`execution_settled=true`,后者要求`safe_to_retry=true`。Continue要求`review_resolved=true`,服务端仍独立检查当前图、历史任务与来源事件,不把声明作为绕过检查的开关。
ResolveStep和RetryStep都要求固定`step_key`、当前`activation_id`及`expected_step_revision`。前者要求`execution_settled=true`,后者要求`safe_to_retry=true`。ContinuePoll要求`pending_confirmed=true`,RearmWait要求`wait_verified=true`;这两项还要求StepDecision。Continue要求`review_resolved=true`,服务端仍独立检查当前图、历史任务与来源事件,不把声明作为绕过检查的开关。
同键相同命令返回首次冻结回执;同键不同内容冲突。回执恢复仍检查当前凭据和固定定义引用的全部队列权限。后台新增事实使原实例revision失效,新键携带旧版本会冲突;原键可继续核对已提交决定。每个成功人工操作同时递增revision和control_revision,防止旧暂停/继续命令覆盖人工恢复后的控制状态。
@@ -54,7 +56,7 @@ TASK恢复规则如下。要求没有取消或失败停止意图,实例和原
创建新任务后实例仍处于核查,门控保持关闭。操作者确认其他事项已处理后调用Continue,才允许新任务取得执行授权;原paused仍为true时还需正常RESUME。
POLL跨激活恢复已实现,保留原业务轮、同轮剩余尝试、查询总截止和该轮原任务截止;成功返回PENDING的轮次不能人工重查。附属最终信号等待沿用原契约/关联/截止并更换代次,详见[查询恢复](17-workflow-poll-recovery.md)。核查后已确认PENDING的显式等待接续仍需补齐;独立SIGNAL/TIMER逻辑结论可核查,等待重建仍待实现。
POLL跨激活恢复已实现,保留原业务轮、同轮剩余尝试、查询总截止和该轮原任务截止;成功返回PENDING的轮次不能人工重查。附属最终信号等待沿用原契约/关联/截止并更换代次,详见[查询恢复](17-workflow-poll-recovery.md)。核查后已确认PENDING的显式等待接续与独立SIGNAL/TIMER等待重建已实现,见[等待恢复](18-workflow-wait-recovery.md)。
## 5. 停止与到期

View File

@@ -1,6 +1,6 @@
# Weave 有界查询的人工恢复
状态:内部 `RetryStep` 已支持 POLL 新激活,保留跨激活的业务轮次、同轮剩余尝试和绝对截止;附属最终信号等待随新激活换代。核查后已确认 PENDING 的显式等待接续、独立 SIGNAL/TIMER 的等待重建仍待实现,公共实例/恢复 HTTP、独立 SDK 和可运行接入示例继续按[开发计划](08-development-plan.md)交付。验证范围见[阶段报告](reports/phase-5-poll-recovery.md)。
状态:内部 `RetryStep` 已支持 POLL 新激活,保留跨激活的业务轮次、同轮剩余尝试和绝对截止;附属最终信号等待随新激活换代。[核查后PENDING接续和独立等待重建](18-workflow-wait-recovery.md)已接入内部服务,公共实例/恢复 HTTP、独立 SDK 和可运行接入示例继续按[开发计划](08-development-plan.md)交付。验证范围见[阶段报告](reports/phase-5-poll-recovery.md)。
## 1. 业务轮次与请求尝试
@@ -15,7 +15,7 @@
| 恢复请求成功返回 PENDING,间隔到达 | 2 | 2 | 3 | 新业务轮的任务截止受原查询总截止截短 |
| 第二轮请求失败,已用 1 次,再人工恢复 | 3 | 2 | 2 | 第二轮第一次任务的绝对截止、原查询总截止 |
恢复前核验真实尝试行数、任务计数、原处理器/版本/策略/输入、业务键、互斥键、所有者、连续激活及 `retry_of` 链。每一业务轮的任务总数也不超过原单轮尝试上限,防止反复取消未开始任务而无限制造激活。一次成功查询已返回 PENDING 的轮次不能人工重试;轮次耗尽须核查最终事实或取消。若 UNKNOWN 经 ReviewTask 被核实为成功 PENDING,现有操作不会自动恢复等待;原预算内显式接续后续业务轮的操作仍需实现,不能借同轮 RetryStep 绕过间隔或次数。
恢复前核验真实尝试行数、任务计数、原处理器/版本/策略/输入、业务键、互斥键、所有者、连续激活及 `retry_of` 链。每一业务轮的任务总数也不超过原单轮尝试上限,防止反复取消未开始任务而无限制造激活。一次成功查询已返回 PENDING 的轮次不能人工重试;轮次耗尽须核查最终事实或取消。若 UNKNOWN 经 ReviewTask 被核实为成功 PENDING,须等待结果消费,再通过[ContinuePoll](18-workflow-wait-recovery.md)显式恢复原等待;不能借同轮 RetryStep 绕过间隔或次数。
## 2. 恢复前提和事务
@@ -50,7 +50,7 @@ POLL 带最终信号时,恢复创建同契约、同精确关联键、同原截
当前代次仍有 PENDING 信号时,先消费这些事实,恢复拒绝直接丢弃。已有赢家时核对其受理证明与关联后保留历史。最终信号原截止已过时不重新开始等待。
这些规则仅覆盖 POLL 附属等待,不表示独立 SIGNAL/TIMER 重建已经开放。
这些规则覆盖 POLL 附属等待;独立 SIGNAL/TIMER 使用[RearmWait](18-workflow-wait-recovery.md),公共接口仍待交付。
## 5. 内部调用场景

View File

@@ -0,0 +1,55 @@
# Weave 等待核查与恢复
状态:内部服务已实现 `ContinuePoll` 和 `RearmWait`,均使用[人工恢复的权限、版本、证据及原键回执规则](16-workflow-recovery.md)。公共流程 HTTP、独立 SDK、通用可运行场景与生产验收继续按[开发计划](08-development-plan.md)交付;本页不是公共 SDK 调用示例。
## 1. 两类操作
| 方法 | 适用事实 | 持久化结果 |
| --- | --- | --- |
| ContinuePoll | 当前最后查询任务已确定成功、结果为 PENDING,且对应权威 Inbox 已处理 | 保留当前激活和原业务轮,恢复 WAITING;到原间隔后才允许下一轮 |
| RearmWait | 独立 SIGNAL/TIMER 仍在原截止内,需要重建等待代次 | 关闭旧等待,创建新激活和新等待;保留首次输入、契约、关联键与截止 |
这两项操作都只针对 NEEDS_REVIEW 实例,不解除实例核查、显式暂停或执行门控。完成必要的其他核查后再调用 Continue;原 paused 为 true 时仍需 RESUME。已有取消或失败停止目标时拒绝恢复等待。
共同要求:显式 `workflows:recover`、最新实例 revision、步骤 revision、当前 activation_id、稳定操作键、原因和证据;拒绝未消费或隔离 Inbox,检查历史任务归属及已结束执行。兄弟分支可以继续收集在途结果,但恢复操作不能把其未知事实解释为完成。相同键返回原冻结回执,仍检查当前凭据与队列权限。
## 2. 已确认 PENDING 的接续
`ContinuePollInput` 在 StepDecision 基础上要求 `pending_confirmed=true`。该声明不能制造查询结果:服务端读取当前最后任务,要求 SUCCEEDED、无未知效果,并找到同版本、同激活的 PROCESSED Inbox。重新核验快照、原事件、任务/步骤/固定队列/尝试归属,结果还必须与当前权威任务结果一致并通过固定 PENDING 映射。
典型场景是:请求 UNKNOWN → ReviewTask 核实为 SUCCEEDED / PENDING → 内部结果消费 → ContinuePoll → Continue → 按原间隔开启下一业务轮。ReviewTask 本身不会清除步骤核查;不能通过 RetryStep 重查已经成功返回 PENDING 的同一轮。
接续保留:
- 原激活、已用业务轮数、查询总截止及实例总截止。
- 原任务、实际尝试、UNKNOWN 证据、原事件和原 Inbox 处置。
- 下一查询时间为 `min(该权威 PENDING 结果事件时间 + 原 interval_ms, 原查询截止)`。人工核查产生的新结果使用其确认事件时间,不能伪装为更早的外部时间。
- 不创建任务或消耗新轮次。时钟到期且门控/配额允许后才创建下一轮,该轮获得固定定义的完整单轮尝试策略。
已用轮次达到 max_rounds、查询或实例截止已到、原结果不为 PENDING、有未结清执行时拒绝。原请求尝试预算已经耗尽但结果后来被确认为 PENDING,并不消耗额外同轮请求;仍可在剩余业务轮次与查询期限内接续。
接续审计保存来源事件/版本、当前激活、轮次、步骤版本、原结清状态、下一查询时间与固定截止。时钟及最终信号运行器接受这条不可变证明,不改写原 Inbox 的 STEP_ALREADY_SETTLED 等历史处置。当前记录与证明不符时停止推进。
## 3. POLL 的附属最终信号
ContinuePoll 恢复同一个查询激活,因此信号目标代次不变;已拒绝或已消费的旧信号不会重新变为 PENDING。当前等待必须尚未到原截止、没有已选中的最终赢家,且没有当前代次未消费的信号。WAITING 或因先前查询核查而关闭的 CANCELLED 等待可恢复 WAITING,原状态写入管理审计。
如果已有最终赢家,不能用“仍在处理”声明覆盖它,应核查冲突事实。真正重查原请求的 RetryStep 则创建新激活并更换信号代次,两条路径见[查询重试约定](17-workflow-poll-recovery.md)。
## 4. SIGNAL/TIMER 的新代次
`RearmWaitInput` 在 StepDecision 基础上要求 `wait_verified=true`。当前步骤仅允许 WAITING、NEEDS_REVIEW、FAILED 或 CANCELLED;成功步骤不能重新等待。服务端核验完整连续激活历史,最多32个等待激活,包含首次等待;新激活不提供新的时间预算。
- 新 sequence = 旧 sequence + 1;首次输入、信号契约、精确关联键和原等待截止不变。
- 旧 WAITING 激活关闭为 CANCELLED,旧等待也关闭;已有失败/核查激活保留原状态及输出。它们继续由审计和历史关联追踪,不能误解为整个流程取消。
- 当前代次存在 PENDING 信号时拒绝重建,先处理已有事实。已匹配赢家不能被新代次绕过。
- 新等待没有赢家;旧代次后续信号被记录为 STALE_ACTIVATION,调用方须读取新代次后发送新信号。
- TIMER 始终以首次激活时间加固定 delay_ms 计时,重建和人工成功核查都不能提前满足时长或从新创建时间重算。等待或实例截止已到时拒绝重建。
新激活、旧等待结清、新等待、检查点、步骤版本、管理审计和原键回执同事务。审计失败全部回滚;本操作不创建 Pulse 任务,不占业务执行许可。恢复后仍等待显式 Continue 解除核查。
## 5. 实现与验收边界
复用现有激活、等待、查询状态、Inbox 和不可变审计表,没有新增迁移或持久化字段。仍为35个实体、305个带中文注释的直接持久化字段,无外键和级联删除。
实现见[等待重建](../internal/storage/mysql/workflow_wait_rearm.go)、[PENDING 接续与审计证明](../internal/storage/mysql/workflow_poll_continue.go)和[恢复服务](../weave/recovery/service.go)。验证范围见[本阶段报告](reports/phase-5-wait-recovery.md)。本机单 MySQL 与多控制进程回归不能替代生产数据库切换、备份恢复、容量及 RPO/RTO 验收。

View File

@@ -0,0 +1,39 @@
# P5 等待核查与恢复回归
日期:2026-09-18。范围为内部 ContinuePoll 和 RearmWait,以及它们与原时钟、最终信号、人工核查和节点接管的衔接。实例公共 HTTP/SDK、完整接入场景与 P6–P7 生产验收继续实施,本报告不作为整个平台交付结论。
## 实现与边界
[等待恢复约定](../18-workflow-wait-recovery.md)区分两类操作:查询已被核实为成功 PENDING 时,保留当前激活,按权威结果事件时间和原间隔接续;独立 SIGNAL/TIMER 重建等待时,创建新代次并保留首次输入、契约、关联和截止。
继续查询需要当前权威任务、已处理 Inbox、原事件和固定结果契约相符,并写入来源事件、版本、轮次、步骤版本和时限的不可变审计证明。运行器核验证明后才能继续,原 Inbox 处置不被改写。等待换代最多32个激活,包含首次等待;TIMER 的自动运行和人工成功核查均使用首次激活时间。所有操作均需明确权限、原因、证据和当前版本;原键返回冻结回执。已到期、停止意图、信号赢家及未消费信号不能通过恢复绕过。
新等待、旧等待结清、步骤、检查点、审计及回执同事务;两类恢复均不自动解除实例核查,也不自行取得执行许可。复用已有结构,无新增迁移或持久化字段,保持35个实体、305个直接持久化字段声明、无数据库外键。
## 环境与证据
本机 darwin/arm64、Go 1.27.1、Docker MySQL 8.4.10。MySQL 均由脚本创建为隔离的一次性测试库。
| 检查 | 结果 |
| --- | --- |
| 存储专项 | 等待恢复10组及既有查询恢复、人工核查、时钟和信号回归通过,`-race`,62.023秒 |
| 实际进程专项 | 新增等待恢复与实际 Orbit 进程用例通过,`-race`,64.299秒;其中实际进程48.61秒、等待恢复14.39秒 |
| 基础回归 | `make fmt test vet build`通过,退出0,包含独立SDK和两个业务示例模块 |
| 完整隔离回归 | 整条脚本通过,退出0;存储全量214.576秒,实际流程进程包48.875秒(进程用例47.51秒),Worker SDK 92.595秒,最终SDK进程故障组93.095秒;19个SDK示例及2个独立业务示例全部通过 |
| 模型注释 | Go AST核对305个带db标签的直接字段声明,缺中文注释0 |
| 实际数据库结构 | v22测试库外键0、业务表无COMMENT字段0;迁移工具自身的5个元数据列单独计数,不包含在模型字段数中 |
| 文档静态检查 | 最终957个本地链接、53段JSON通过;`git diff --check`通过 |
10组专项覆盖:权威 PENDING 来源与原间隔、最终信号优先完成、接续审计失败全部回滚、假 PENDING 和轮次耗尽拒绝、时钟与审计证明不符拒绝、信号换代保留原截止及隔离旧信号、TIMER 使用首次计时、八路同键并发与旧目标拒绝及32代上限、未消费信号不得丢弃、等待重建审计回滚与截止耗尽。
实际进程用例确认所有旧控制进程退出后:
1. UNKNOWN 查询经人工核查确认为成功 PENDING,由两个新节点处理结果;再次全部退出后,显式接续原等待,再由两个全新节点按原间隔只创建下一业务轮。
2. 独立 SIGNAL 重建为第二代等待,旧截止不变;节点重新启动后接收第二代信号并推进后继任务。
3. 独立 TIMER 重建为第二代等待,节点重新启动后按第一次的绝对时间到期,后继任务最终完成。
该进程用例同时覆盖此前 TASK、POLL 同轮重试、停止结清、早到信号和查询竞争场景,累计22个任务,最终执行许可为0。
证据实现见[等待恢复专项](../../internal/storage/mysql/workflow_wait_recovery_integration_test.go)、[实际进程专项](../../internal/storage/mysql/workflow_runtime_process_integration_test.go)及[服务输入校验](../../weave/recovery/service_test.go)。完整复现入口为[隔离 MySQL 脚本](../../scripts/test-mysql.sh)。缺少一次性 DSN 或实际二进制而跳过的用例不计作通过;本机单数据库和多进程验证不替代生产复制、切换、备份恢复、容量或 RPO/RTO 验收。
完整回归开始后没有继续修改执行代码,仅补充阶段状态、接口交付顺序及验证记录。回归结束后脚本已清理本轮一次性测试容器。

View File

@@ -61,10 +61,6 @@ func (r *WorkflowRuntime) advanceClock(ctx context.Context, tx *sql.Tx, g *workf
}
return false, deleteWorkflowClock(ctx, tx, g, row)
}
var activated time.Time
if err := tx.QueryRowContext(ctx, `SELECT created_at FROM weave_activations WHERE namespace_id=? AND id=? AND workflow_id=? AND step_id=?`, c.ns, checkpoint.ActivationID, c.workflow, step).Scan(&activated); err != nil {
return false, err
}
if checkpoint.Kind == "POLL" {
node, _ := g.program.Step(row.view.Key)
if node.Poll.FinalSignal != nil {
@@ -103,7 +99,11 @@ func (r *WorkflowRuntime) advanceClock(ctx context.Context, tx *sql.Tx, g *workf
return false, err
}
node, _ := g.program.Step(row.view.Key)
intended := activated.Add(time.Duration(node.Timer.DelayMS) * time.Millisecond)
origin, err := workflowActivationOrigin(ctx, tx, g, row)
if err != nil {
return false, err
}
intended := origin.started.Add(time.Duration(node.Timer.DelayMS) * time.Millisecond)
if kind != "TIMER" || state != "WAITING" || name.Valid || version.Valid || len(correlation) != 0 || !deadline.Equal(minimumTime(intended, g.view.DeadlineAt)) {
return false, errors.New("workflow timer wait invalid")
}

View File

@@ -31,6 +31,11 @@ type workflowResult struct {
}
func readWorkflowResult(ctx context.Context, tx *sql.Tx, g *workflowGraph, c workflowCandidate, now time.Time) (*workflowResult, error) {
return readWorkflowResultState(ctx, tx, g, c, now, "PENDING")
}
// readWorkflowResultState 复用不可变来源核验;管理恢复只读取已处理快照,不改写原Inbox处置。
func readWorkflowResultState(ctx context.Context, tx *sql.Tx, g *workflowGraph, c workflowCandidate, now time.Time, required string) (*workflowResult, error) {
var state string
var due time.Time
var raw []byte
@@ -42,7 +47,7 @@ func readWorkflowResult(ctx context.Context, tx *sql.Tx, g *workflowGraph, c wor
if err != nil {
return nil, err
}
if state != "PENDING" || due.After(now) {
if state != required || required == "PENDING" && due.After(now) {
return nil, nil
}
if _, err := jsonvalue.Canonical(raw); err != nil {

View File

@@ -159,13 +159,9 @@ func (r *WorkflowRuntime) advancePoll(ctx context.Context, tx *sql.Tx, g *workfl
return false, err
}
// 只有上一轮成功查询并已分类为PENDING才可以消耗新轮次,不能用时间推进绕过失败预算。
var disposition string
if err := tx.QueryRowContext(ctx, `SELECT error_code FROM weave_inbox WHERE namespace_id=? AND workflow_id=? AND source_task_id=? AND source_activation_id=? AND state='PROCESSED' ORDER BY source_revision DESC LIMIT 1`, c.ns, c.workflow, lastTask, row.view.ActivationID).Scan(&disposition); err != nil {
if err := verifyWorkflowPollPending(ctx, tx, g, row, p, lastTask, now); err != nil {
return false, err
}
if disposition != "POLL_PENDING" {
return false, errors.New("poll previous round was not pending")
}
in, err := pollTaskInput(ctx, tx, g, row, policy.Task)
if err != nil {
return false, err

View File

@@ -0,0 +1,159 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"git.yuntuo.hk/k2safe/orbit/weave/engine"
weave "git.yuntuo.hk/k2safe/orbit/weave/model"
"git.yuntuo.hk/k2safe/orbit/weave/recovery"
)
// processedWorkflowPollResult 只接受当前最后任务的已处理权威结果,不把人工声明当作PENDING事实。
func processedWorkflowPollResult(ctx context.Context, tx *sql.Tx, g *workflowGraph, row *workflowStep, last core.ID, now time.Time) (*workflowResult, error) {
task, err := readTask(ctx, tx, g.view.NamespaceID, last, false)
if err != nil {
return nil, err
}
if task.State != "SUCCEEDED" || task.EffectUncertain {
return nil, fault.Conflict
}
var id core.ID
if err := tx.QueryRowContext(ctx, `SELECT id FROM weave_inbox WHERE namespace_id=? AND workflow_id=? AND source_task_id=? AND source_activation_id=? AND source_revision=? AND state='PROCESSED'`, g.view.NamespaceID, g.view.ID, last, row.view.ActivationID, task.Revision).Scan(&id); err != nil {
return nil, err
}
source, err := readWorkflowResultState(ctx, tx, g, workflowCandidate{ns: g.view.NamespaceID, workflow: g.view.ID, id: id}, now, "PROCESSED")
if err != nil {
return nil, err
}
if source == nil || source.task != task.ID || source.activation != *row.view.ActivationID || source.step != row || source.revision != task.Revision || source.snapshot.State != "SUCCEEDED" || source.snapshot.EffectUncertain || !sameWorkflowJSON(source.snapshot.Result, task.Result) {
return nil, errors.New("reviewed poll result proof mismatch")
}
kind, err := g.program.ClassifyPoll(row.view.Key, source.snapshot.Result)
if err != nil || kind != engine.PollPending {
return nil, fault.Conflict
}
return source, nil
}
func noPendingWorkflowSignals(ctx context.Context, tx *sql.Tx, g *workflowGraph, row *workflowStep, sequence uint32) error {
var pending int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM weave_signals WHERE namespace_id=? AND workflow_id=? AND target_step_key=? AND target_activation_sequence=? AND state='PENDING'`, g.view.NamespaceID, g.view.ID, row.view.Key, sequence).Scan(&pending); err != nil {
return err
}
if pending != 0 {
return fault.Conflict
}
return nil
}
// ContinuePoll 为已核实的PENDING恢复原等待,不重查同轮、不重置尝试,也不自动解除实例核查。
func (s *WorkflowRecoveryStore) ContinuePoll(ctx context.Context, t auth.Token, id core.ID, key string, in recovery.ContinuePollInput) (recovery.Result, error) {
return s.mutate(ctx, t, id, key, "workflow.poll.continue", in, in.Decision, func(tx *sql.Tx, g *workflowGraph, _ core.ID, now time.Time, out *recovery.Result) (map[string]any, error) {
row, err := workflowRecoveryStep(g, in.StepDecision)
if err != nil {
return nil, err
}
node, ok := g.program.Step(row.view.Key)
if !ok || node.Poll == nil {
return nil, fault.Invalid("continue poll requires a POLL step")
}
if g.view.StopKind != nil || g.view.CancelRequestedAt != nil || !now.Before(g.view.DeadlineAt) {
return nil, fault.Conflict
}
if err := workflowStepSettled(ctx, tx, g, row); err != nil {
return nil, err
}
p, last, err := readWorkflowPollBudget(ctx, tx, g, row)
if err != nil {
return nil, err
}
if p.CurrentTaskID != nil || p.UsedRounds >= p.MaxRounds || !now.Before(p.DeadlineAt) {
return nil, fault.Conflict
}
if _, err := readWorkflowPollRoundHistory(ctx, tx, g, row, p, p.UsedRounds); err != nil {
return nil, err
}
source, err := processedWorkflowPollResult(ctx, tx, g, row, last, now)
if err != nil {
return nil, err
}
if !source.occurred.Before(p.DeadlineAt) {
return nil, fault.Conflict
}
next := minimumTime(source.occurred.Add(time.Duration(p.IntervalMS)*time.Millisecond), p.DeadlineAt)
meta := map[string]any{"step_id": row.view.ID, "activation_id": row.view.ActivationID, "previous_state": row.view.State, "previous_output": json.RawMessage(row.output), "previous_resolved_at": p.ResolvedAt, "task_id": last, "source_event_id": source.event, "source_revision": source.revision, "round": p.UsedRounds, "next_poll_at": next, "deadline_at": p.DeadlineAt, "pending_confirmed": true}
if node.Poll.FinalSignal != nil {
w, err := readWorkflowSignalWait(ctx, tx, g, row)
if err != nil {
return nil, err
}
if w.winner != nil || w.state != "WAITING" && w.state != "CANCELLED" || !now.Before(w.deadline) {
return nil, fault.Conflict
}
if err := noPendingWorkflowSignals(ctx, tx, g, row, w.sequence); err != nil {
return nil, err
}
meta["wait_id"], meta["previous_wait_state"] = w.id, w.state
result, err := tx.ExecContext(ctx, `UPDATE weave_waits SET state='WAITING',resolved_at=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND state=? AND winner_signal_id IS NULL`, now, t.NamespaceID, w.id, w.state)
if err := exactlyOne(result, err); err != nil {
return nil, err
}
}
result, err := tx.ExecContext(ctx, `UPDATE weave_poll_states SET next_poll_at=?,resolved_at=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=? AND current_task_id IS NULL`, next, now, t.NamespaceID, p.ID, p.Revision)
if err := exactlyOne(result, err); err != nil {
return nil, err
}
if err := saveWorkflowResultStep(ctx, tx, g, row, weave.StepWaiting, nil, now); err != nil {
return nil, err
}
if err := saveWorkflowClock(ctx, tx, g, row, "POLL", row.view.Revision, next); err != nil {
return nil, err
}
meta["step_revision"] = row.view.Revision
out.Step = &row.view
return meta, nil
})
}
// verifyWorkflowPollPending 保留原Inbox处置;人工接续必须有匹配当前快照的不可变审计证明。
func verifyWorkflowPollPending(ctx context.Context, tx *sql.Tx, g *workflowGraph, row *workflowStep, p weave.PollState, last core.ID, now time.Time) error {
var disposition string
if err := tx.QueryRowContext(ctx, `SELECT error_code FROM weave_inbox WHERE namespace_id=? AND workflow_id=? AND source_task_id=? AND source_activation_id=? AND state='PROCESSED' ORDER BY source_revision DESC LIMIT 1`, g.view.NamespaceID, g.view.ID, last, row.view.ActivationID).Scan(&disposition); err != nil {
return err
}
if disposition == "POLL_PENDING" {
return nil
}
source, err := processedWorkflowPollResult(ctx, tx, g, row, last, now)
if err != nil {
return err
}
var raw []byte
if err := tx.QueryRowContext(ctx, "SELECT `after` FROM orbit_audit_events WHERE namespace_id=? AND resource_kind='workflow' AND resource_id=? AND action='workflow.poll.continue' AND JSON_UNQUOTE(JSON_EXTRACT(`after`,'$.step_id'))=? AND JSON_UNQUOTE(JSON_EXTRACT(`after`,'$.activation_id'))=? AND JSON_UNQUOTE(JSON_EXTRACT(`after`,'$.task_id'))=? ORDER BY created_at DESC,id DESC LIMIT 1", g.view.NamespaceID, g.view.ID, string(row.view.ID), string(*row.view.ActivationID), string(last)).Scan(&raw); err != nil {
return err
}
var proof struct {
StepID core.ID `json:"step_id"`
ActivationID core.ID `json:"activation_id"`
TaskID core.ID `json:"task_id"`
SourceEventID core.ID `json:"source_event_id"`
SourceRevision uint64 `json:"source_revision"`
StepRevision uint64 `json:"step_revision"`
Round uint32 `json:"round"`
NextPollAt time.Time `json:"next_poll_at"`
DeadlineAt time.Time `json:"deadline_at"`
PendingConfirmed bool `json:"pending_confirmed"`
}
expected := minimumTime(source.occurred.Add(time.Duration(p.IntervalMS)*time.Millisecond), p.DeadlineAt)
if json.Unmarshal(raw, &proof) != nil || proof.StepID != row.view.ID || proof.ActivationID != *row.view.ActivationID || proof.TaskID != last || proof.SourceEventID != source.event || proof.SourceRevision != source.revision || proof.StepRevision != row.view.Revision || proof.Round != p.UsedRounds || !proof.PendingConfirmed || !proof.DeadlineAt.Equal(p.DeadlineAt) || p.NextPollAt == nil || !proof.NextPollAt.Equal(*p.NextPollAt) || !proof.NextPollAt.Equal(expected) {
return errors.New("poll continuation audit proof invalid")
}
return nil
}

View File

@@ -155,6 +155,17 @@ func (s *WorkflowRecoveryStore) ReviewTask(ctx context.Context, t auth.Token, id
}
func workflowRecoveryStep(g *workflowGraph, in recovery.StepDecision) (*workflowStep, error) {
row, err := workflowRecoveryTarget(g, in)
if err != nil {
return nil, err
}
if row.view.State != weave.StepNeedsReview && row.view.State != weave.StepFailed && row.view.State != weave.StepCancelled {
return nil, fault.Conflict
}
return row, nil
}
func workflowRecoveryTarget(g *workflowGraph, in recovery.StepDecision) (*workflowStep, error) {
row := g.steps[in.StepKey]
if row == nil || in.StepKey == workflowRootKey {
return nil, fault.NotFound
@@ -162,9 +173,6 @@ func workflowRecoveryStep(g *workflowGraph, in recovery.StepDecision) (*workflow
if row.view.ActivationID == nil || *row.view.ActivationID != in.ActivationID || row.view.Revision != in.ExpectedStepRevision {
return nil, fault.Conflict
}
if row.view.State != weave.StepNeedsReview && row.view.State != weave.StepFailed && row.view.State != weave.StepCancelled {
return nil, fault.Conflict
}
return row, nil
}
@@ -217,11 +225,11 @@ func (s *WorkflowRecoveryStore) ResolveStep(ctx context.Context, t auth.Token, i
}
typ = node.Poll.Task.ResultType
case node.Timer != nil:
var activated time.Time
if err := tx.QueryRowContext(ctx, `SELECT created_at FROM weave_activations WHERE namespace_id=? AND id=?`, t.NamespaceID, row.view.ActivationID).Scan(&activated); err != nil {
origin, err := workflowActivationOrigin(ctx, tx, g, row)
if err != nil {
return nil, err
}
if now.Before(activated.Add(time.Duration(node.Timer.DelayMS) * time.Millisecond)) {
if now.Before(origin.started.Add(time.Duration(node.Timer.DelayMS) * time.Millisecond)) {
return nil, fault.Conflict
}
typ = definition.Type{Kind: "NULL"}

View File

@@ -408,4 +408,89 @@ func TestIntegrationWorkflowRuntimeProcess(t *testing.T) {
}
t.Log("POLL manual same-round recovery survived all-stop and two fresh controllers; reduced request attempts, original deadline, old activation and subsequent business-round progression preserved")
for _, p := range processes {
stop(p)
}
pending := runtimeStart(t, f, recoveryPollSpec(f.resources.QueueID, true))
grantWorkflowRecovery(f)
grantWorkflowSignal(f)
ref = retryStart(t, f)
retryFinish(t, f, ref, "UNKNOWN", false)
start("pending-review-a")
start("pending-review-b")
wait(func() bool { return f.count("weave_instances", "id=? AND state='NEEDS_REVIEW'", pending.ID) == 1 }, "query unknown review")
for _, p := range processes {
stop(p)
}
task := f.get(ref.TaskID)
_, err = recovery.New(NewWorkflowRecoveryStore(db)).ReviewTask(ctx, f.token, pending.ID, string(executionID(t)), recovery.ReviewTaskInput{Decision: recoveryDecision(t, f, pending.ID), TaskID: task.ID, ExpectedTaskRevision: task.Revision, ExecutionSettled: true, Outcome: "SUCCEEDED", Result: json.RawMessage(`"PENDING"`)})
if err != nil {
t.Fatal(err)
}
start("pending-proof-a")
start("pending-proof-b")
wait(func() bool { return f.count("weave_inbox", "workflow_id=? AND state='PENDING'", pending.ID) == 0 }, "reviewed pending evidence consumed")
for _, p := range processes {
stop(p)
}
continueReviewedPoll(t, f, pending.ID)
continueWorkflow(t, f, pending.ID)
start("pending-continue-a")
start("pending-continue-b")
wait(func() bool {
return f.count("weave_poll_states", "workflow_id=? AND used_rounds=2 AND current_task_id IS NOT NULL", pending.ID) == 1
}, "reviewed pending resumes next round across all-stop")
ref = retryStart(t, f)
finishQueryOnly(t, f, ref, "DONE")
wait(func() bool { return f.count("weave_instances", "id=? AND state='SUCCEEDED'", pending.ID) == 1 }, "continued poll completion")
for _, kind := range []string{"SIGNAL", "TIMER"} {
for _, p := range processes {
stop(p)
}
delay := uint64(30000)
key := "signal"
if kind == "TIMER" {
delay = 3000
key = "timer"
}
waiting := runtimeStart(t, f, waitingParallelSpec(f.resources.QueueID, kind, delay))
grantWorkflowRecovery(f)
grantWorkflowSignal(f)
ref = retryStart(t, f)
retryFinish(t, f, ref, "FAILED", false)
start("wait-review-" + kind + "-a")
start("wait-review-" + kind + "-b")
wait(func() bool { return f.count("weave_instances", "id=? AND state='NEEDS_REVIEW'", waiting.ID) == 1 }, "parallel failure leaves waiting sibling")
for _, p := range processes {
stop(p)
}
var due time.Time
if err := db.QueryRowContext(ctx, `SELECT deadline_at FROM weave_waits WHERE namespace_id=? AND workflow_id=?`, f.resources.NamespaceID, waiting.ID).Scan(&due); err != nil {
t.Fatal(err)
}
rearmWorkflowWait(t, f, waiting.ID, key)
if f.count("weave_waits", "workflow_id=? AND deadline_at=?", waiting.ID, due) != 2 {
t.Fatal("wait deadline reset after all-stop")
}
resolveWorkflowStep(t, f, waiting.ID, "left", "SUCCEEDED", `{"n":9007199254740993}`)
continueWorkflow(t, f, waiting.ID)
start("wait-rearm-" + kind + "-a")
start("wait-rearm-" + kind + "-b")
if kind == "SIGNAL" {
input := signalInput(waiting)
input.TargetActivationSequence = 2
sendSignal(t, f, waiting, input)
}
wait(func() bool {
return f.count("weave_steps", "workflow_id=? AND `key`='task0' AND state='ACTIVE'", waiting.ID) == 1
}, "rearmed wait completed and started one successor")
ref = retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
wait(func() bool { return f.count("weave_instances", "id=? AND state='SUCCEEDED'", waiting.ID) == 1 }, "rearmed wait workflow completed")
}
if f.count("pulse_tasks", "1=1") != 22 || f.used() != 0 {
t.Fatal("wait recovery duplicated task or leaked permits")
}
t.Log("reviewed PENDING continuation plus SIGNAL/TIMER new generations survived full controller shutdown and two fresh nodes; original intervals/deadlines and durable proof preserved, exactly 22 tasks overall")
}

View File

@@ -98,13 +98,9 @@ func (r *WorkflowRuntime) applyWorkflowSignal(ctx context.Context, tx *sql.Tx, g
if row.view.State != weave.StepWaiting || poll.NextPollAt == nil {
return false, false, "", errors.New("signal poll continuation missing")
}
var disposition string
if err := tx.QueryRowContext(ctx, `SELECT error_code FROM weave_inbox WHERE namespace_id=? AND workflow_id=? AND source_task_id=? AND source_activation_id=? AND state='PROCESSED' ORDER BY source_revision DESC LIMIT 1`, g.view.NamespaceID, g.view.ID, last, row.view.ActivationID).Scan(&disposition); err != nil {
if err := verifyWorkflowPollPending(ctx, tx, g, row, poll, last, now); err != nil {
return false, false, "", err
}
if disposition != "POLL_PENDING" {
return false, false, "", errors.New("signal poll previous round not pending")
}
}
}
v, err := selectedWorkflowSignal(ctx, tx, g, row, w, now)

View File

@@ -0,0 +1,159 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
weave "git.yuntuo.hk/k2safe/orbit/weave/model"
"git.yuntuo.hk/k2safe/orbit/weave/recovery"
)
// RearmWait 仅对独立SIGNAL/TIMER换代,固定首次等待的预算;到期事实不能通过新建等待清除。
func (s *WorkflowRecoveryStore) RearmWait(ctx context.Context, t auth.Token, id core.ID, key string, in recovery.RearmWaitInput) (recovery.Result, error) {
return s.mutate(ctx, t, id, key, "workflow.wait.rearm", in, in.Decision, func(tx *sql.Tx, g *workflowGraph, _ core.ID, now time.Time, out *recovery.Result) (map[string]any, error) {
row, err := workflowRecoveryTarget(g, in.StepDecision)
if err != nil {
return nil, err
}
node, ok := g.program.Step(row.view.Key)
if !ok || node.Signal == nil && node.Timer == nil {
return nil, fault.Invalid("wait rearm requires a SIGNAL or TIMER step")
}
if row.view.State != weave.StepWaiting && row.view.State != weave.StepNeedsReview && row.view.State != weave.StepFailed && row.view.State != weave.StepCancelled {
return nil, fault.Conflict
}
if g.view.StopKind != nil || g.view.CancelRequestedAt != nil || !now.Before(g.view.DeadlineAt) {
return nil, fault.Conflict
}
if err := workflowStepSettled(ctx, tx, g, row); err != nil {
return nil, err
}
origin, err := workflowActivationOrigin(ctx, tx, g, row)
if err != nil {
return nil, err
}
if origin.sequence >= recovery.MaxWaitActivations {
return nil, fault.Conflict
}
if err := verifyWorkflowWaitHistory(ctx, tx, g, row, origin); err != nil {
return nil, err
}
var w workflowSignalWait
if node.Signal != nil {
w, err = readWorkflowSignalWait(ctx, tx, g, row)
if err != nil {
return nil, err
}
if err := noPendingWorkflowSignals(ctx, tx, g, row, w.sequence); err != nil {
return nil, err
}
} else {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM weave_waits WHERE namespace_id=? AND workflow_id=? AND activation_id=?`, t.NamespaceID, id, row.view.ActivationID).Scan(&count); err != nil {
return nil, err
}
if count != 1 {
return nil, errors.New("timer rearm wait set invalid")
}
var kind string
var name, version *string
var wake *core.ID
if err := tx.QueryRowContext(ctx, `SELECT id,kind,state,deadline_at,signal_name,payload_version,correlation_key,winner_signal_id,wake_task_id FROM weave_waits WHERE namespace_id=? AND workflow_id=? AND activation_id=? FOR UPDATE`, t.NamespaceID, id, row.view.ActivationID).Scan(&w.id, &kind, &w.state, &w.deadline, &name, &version, &w.correlation, &w.winner, &wake); err != nil {
return nil, err
}
expectedInput, _ := json.Marshal(map[string]uint64{"delay_ms": node.Timer.DelayMS})
if kind != "TIMER" || name != nil || version != nil || w.correlation != "" || wake != nil || !sameWorkflowJSON(row.input, expectedInput) || !w.deadline.Equal(minimumTime(origin.started.Add(time.Duration(node.Timer.DelayMS)*time.Millisecond), g.view.DeadlineAt)) {
return nil, errors.New("timer rearm contract mismatch")
}
}
if w.winner != nil || w.state != "WAITING" && w.state != "CANCELLED" || !now.Before(w.deadline) {
return nil, fault.Conflict
}
activation, err := core.NewID()
if err != nil {
return nil, err
}
wait, err := core.NewID()
if err != nil {
return nil, err
}
beforeState := row.view.State
if row.view.State == weave.StepWaiting {
result, err := tx.ExecContext(ctx, `UPDATE weave_activations SET state='CANCELLED',completed_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND workflow_id=? AND step_id=? AND state='WAITING'`, now, now, t.NamespaceID, in.ActivationID, id, row.view.ID)
if err := exactlyOne(result, err); err != nil {
return nil, err
}
}
if _, err := tx.ExecContext(ctx, `UPDATE weave_waits SET state='CANCELLED',resolved_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND state='WAITING'`, now, now, t.NamespaceID, w.id); err != nil {
return nil, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO weave_activations(namespace_id,id,workflow_id,step_id,sequence,state,input,created_at,updated_at) VALUES (?,?,?,?,?,'WAITING',?,?,?)`, t.NamespaceID, activation, id, row.view.ID, origin.sequence+1, string(origin.input), now, now); err != nil {
return nil, err
}
var name, version *string
if w.contract != nil {
name = &w.contract.Name
version = &w.contract.PayloadVersion
}
if _, err := tx.ExecContext(ctx, `INSERT INTO weave_waits(namespace_id,id,workflow_id,activation_id,kind,correlation_key,deadline_at,signal_name,payload_version,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, t.NamespaceID, wait, id, activation, row.view.Kind, []byte(w.correlation), w.deadline, name, version, now, now); err != nil {
return nil, err
}
result, err := tx.ExecContext(ctx, `UPDATE weave_steps SET state='WAITING',current_activation_id=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=? AND current_activation_id=?`, activation, now, t.NamespaceID, row.view.ID, row.view.Revision, in.ActivationID)
if err := exactlyOne(result, err); err != nil {
return nil, err
}
row.view.State, row.view.ActivationID = weave.StepWaiting, &activation
row.view.Revision++
row.output = nil
due := w.deadline
if node.Signal != nil {
due = now
}
if err := saveWorkflowClock(ctx, tx, g, row, string(row.view.Kind), row.view.Revision, due); err != nil {
return nil, err
}
if err := emitEvent(ctx, tx, t.NamespaceID, row.view.ID, row.view.Revision, "workflow_step", "workflow.step.changed", map[string]any{"workflow_id": id, "state": row.view.State, "activation_id": activation}); err != nil {
return nil, err
}
out.Step = &row.view
return map[string]any{"step_id": row.view.ID, "previous_activation_id": in.ActivationID, "activation_id": activation, "sequence": origin.sequence + 1, "previous_state": beforeState, "previous_wait_id": w.id, "previous_wait_state": w.state, "wait_id": wait, "deadline_at": w.deadline, "wait_verified": true}, nil
})
}
func verifyWorkflowWaitHistory(ctx context.Context, tx *sql.Tx, g *workflowGraph, row *workflowStep, origin workflowActivationBase) error {
rows, err := tx.QueryContext(ctx, `SELECT id,sequence,state,input,task_id FROM weave_activations WHERE namespace_id=? AND workflow_id=? AND step_id=? ORDER BY sequence LIMIT 33`, g.view.NamespaceID, g.view.ID, row.view.ID)
if err != nil {
return err
}
defer rows.Close()
var count uint32
var last core.ID
for rows.Next() {
var id core.ID
var sequence uint32
var state string
var input []byte
var task *core.ID
if err := rows.Scan(&id, &sequence, &state, &input, &task); err != nil {
return err
}
count++
if sequence != count || task != nil || !sameWorkflowJSON(input, origin.input) || id != *row.view.ActivationID && state != "NEEDS_REVIEW" && state != "FAILED" && state != "CANCELLED" {
return errors.New("wait activation history invalid")
}
last = id
}
if err := rows.Err(); err != nil {
return err
}
if count != origin.sequence || last != *row.view.ActivationID {
return errors.New("wait activation history incomplete")
}
return nil
}

View File

@@ -0,0 +1,305 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"os"
"sync"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/weave/definition"
"git.yuntuo.hk/k2safe/orbit/weave/instance"
"git.yuntuo.hk/k2safe/orbit/weave/recovery"
)
func waitingParallelSpec(queue core.ID, kind string, delay uint64) definition.Specification {
spec := runtimeSpec(queue, 1)
tail := spec.Body.Steps[0]
join := runtimeParallel(spec)
var wait definition.Step
var typ definition.Type
if kind == "SIGNAL" {
wait = signalSpec(queue, false).Body.Steps[0]
wait.Signal.TimeoutMS = delay
typ = spec.InputType
} else {
wait = timerSpec(queue, delay, false).Body.Steps[0]
typ = definition.Type{Kind: "NULL"}
}
join.Parallel.Branches[1].Body = definition.Block{Steps: []definition.Step{wait}, OutputType: typ, Output: definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: wait.Key}}}
spec.Body.Steps = []definition.Step{join, tail}
return spec
}
func waitRecoveryFixture(t *testing.T, ctx context.Context, db *sql.DB, kind string, delay uint64) (*executionFixture, instance.View) {
t.Helper()
f := newExecutionFixture(t, ctx, db)
v := runtimeStart(t, f, waitingParallelSpec(f.resources.QueueID, kind, delay))
grantWorkflowRecovery(f)
grantWorkflowSignal(f)
ref := retryStart(t, f)
retryFinish(t, f, ref, "FAILED", false)
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
return f, v
}
func rearmWorkflowWait(t *testing.T, f *executionFixture, v core.ID, key string) recovery.Result {
t.Helper()
out, err := recovery.New(NewWorkflowRecoveryStore(f.db)).RearmWait(f.ctx, f.token, v, string(executionID(t)), recovery.RearmWaitInput{StepDecision: recoveryStep(t, f, v, key), WaitVerified: true})
if err != nil {
t.Fatal(err)
}
return out
}
func reviewedPendingPoll(t *testing.T, ctx context.Context, db *sql.DB, final bool) (*executionFixture, instance.View, core.ID) {
t.Helper()
f := newClockFixture(t, ctx, db)
v := runtimeStart(t, f, recoveryPollSpec(f.resources.QueueID, final))
grantWorkflowRecovery(f)
grantWorkflowSignal(f)
ref := retryStart(t, f)
retryFinish(t, f, ref, "UNKNOWN", false)
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
task := f.get(ref.TaskID)
_, err := recovery.New(NewWorkflowRecoveryStore(db)).ReviewTask(ctx, f.token, v.ID, string(executionID(t)), recovery.ReviewTaskInput{Decision: recoveryDecision(t, f, v.ID), TaskID: task.ID, ExpectedTaskRevision: task.Revision, ExecutionSettled: true, Outcome: "SUCCEEDED", Result: json.RawMessage(`"PENDING"`)})
if err != nil {
t.Fatal(err)
}
runtimeConsume(t, f, newRuntime(t, db), task.ID)
return f, v, task.ID
}
func continueReviewedPoll(t *testing.T, f *executionFixture, v core.ID) recovery.Result {
t.Helper()
out, err := recovery.New(NewWorkflowRecoveryStore(f.db)).ContinuePoll(f.ctx, f.token, v, string(executionID(t)), recovery.ContinuePollInput{StepDecision: recoveryStep(t, f, v, "query"), PendingConfirmed: true})
if err != nil {
t.Fatal(err)
}
return out
}
func TestIntegrationWorkflowWaitRecovery(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, stop := context.WithTimeout(context.Background(), 180*time.Second)
defer stop()
db, err := Open(ctx, dsn, 32, 8)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
api := recovery.New(NewWorkflowRecoveryStore(db))
t.Run("reviewed_pending_keeps_proof_and_interval_then_new_business_round", func(t *testing.T) {
f, v, task := reviewedPendingPoll(t, ctx, db, false)
before := recoveryStep(t, f, v.ID, "query")
out := continueReviewedPoll(t, f, v.ID)
if out.Instance.State != "NEEDS_REVIEW" || out.Step.State != "WAITING" || *out.Step.ActivationID != before.ActivationID || f.count("pulse_tasks", "1=1") != 1 || f.count("weave_inbox", "source_task_id=? AND error_code='STEP_ALREADY_SETTLED'", task) != 1 {
t.Fatal("continuation restarted request or changed old source")
}
var next, occurred time.Time
if err := db.QueryRowContext(ctx, `SELECT p.next_poll_at,e.occurred_at FROM weave_poll_states p JOIN pulse_tasks t ON t.namespace_id=p.namespace_id AND t.id=? JOIN weave_inbox i ON i.namespace_id=t.namespace_id AND i.source_task_id=t.id AND i.source_revision=t.revision JOIN orbit_events e ON e.namespace_id=i.namespace_id AND e.id=i.event_id WHERE p.namespace_id=? AND p.workflow_id=?`, task, f.resources.NamespaceID, v.ID).Scan(&next, &occurred); err != nil || !next.Equal(occurred.Add(time.Second)) {
t.Fatal("original interval lost", err)
}
c := clockCandidate(t, f, v.ID, "query")
runClock(t, f, c)
if f.count("pulse_tasks", "1=1") != 1 {
t.Fatal("review gate or interval bypassed")
}
continueWorkflow(t, f, v.ID)
c = clockCandidate(t, f, v.ID, "query")
waitClockDue(t, f, c)
runClock(t, f, c)
ref := retryStart(t, f)
if f.get(ref.TaskID).Retry.MaxAttempts != 3 {
t.Fatal("next round inherited old request budget")
}
finishPoll(t, f, ref, "DONE")
runtimeView(t, f, v.ID, "SUCCEEDED")
if f.count("pulse_tasks", "1=1") != 2 || f.count("pulse_attempts", "state='UNKNOWN'") != 1 || f.used() != 0 {
t.Fatal("request duplicated or history lost")
}
})
t.Run("reviewed_pending_final_signal_still_wins_without_new_round", func(t *testing.T) {
f, v, _ := reviewedPendingPoll(t, ctx, db, true)
continueReviewedPoll(t, f, v.ID)
continueWorkflow(t, f, v.ID)
sendSignal(t, f, v, pollSignalInput(v))
runClock(t, f, clockCandidate(t, f, v.ID, "query"))
runtimeView(t, f, v.ID, "SUCCEEDED")
if f.count("pulse_tasks", "1=1") != 1 || f.count("weave_waits", "state='SATISFIED' AND winner_signal_id IS NOT NULL") != 1 {
t.Fatal("continued final wait lost signal")
}
})
t.Run("pending_continuation_audit_failure_is_atomic", func(t *testing.T) {
f, v, _ := reviewedPendingPoll(t, ctx, db, false)
in := recovery.ContinuePollInput{StepDecision: recoveryStep(t, f, v.ID, "query"), PendingConfirmed: true}
key := string(executionID(t))
trigger := "reject_poll_continue_" + string(executionID(t))
f.exec("CREATE TRIGGER " + trigger + " BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='controlled continuation audit failure'")
_, err := api.ContinuePoll(ctx, f.token, v.ID, key, in)
f.exec("DROP TRIGGER " + trigger)
if err == nil || f.count("weave_poll_states", "resolved_at IS NOT NULL AND next_poll_at IS NULL") != 1 || f.count("weave_pending_advances", "1=1") != 0 || recoveryDecision(t, f, v.ID).ExpectedRevision != in.ExpectedRevision {
t.Fatal("partial continuation committed", err)
}
first, err := api.ContinuePoll(ctx, f.token, v.ID, key, in)
if err != nil {
t.Fatal(err)
}
again, err := api.ContinuePoll(ctx, f.token, v.ID, key, in)
if err != nil || !again.Deduplicated || again.AuditID != first.AuditID {
t.Fatal("continuation receipt changed", err)
}
})
t.Run("continuation_rejects_forged_pending_and_exhausted_rounds", func(t *testing.T) {
f, v, _ := failedPoll(t, ctx, db, false)
if _, err := api.ContinuePoll(ctx, f.token, v.ID, string(executionID(t)), recovery.ContinuePollInput{StepDecision: recoveryStep(t, f, v.ID, "query"), PendingConfirmed: true}); fault.Code(err) != "STATE_CONFLICT" {
t.Fatal("failure treated as pending", err)
}
f = newClockFixture(t, ctx, db)
spec := recoveryPollSpec(f.resources.QueueID, false)
spec.Body.Steps[0].Poll.MaxRounds = 1
v = runtimeStart(t, f, spec)
grantWorkflowRecovery(f)
ref := retryStart(t, f)
finishPoll(t, f, ref, "PENDING")
if _, err := api.ContinuePoll(ctx, f.token, v.ID, string(executionID(t)), recovery.ContinuePollInput{StepDecision: recoveryStep(t, f, v.ID, "query"), PendingConfirmed: true}); fault.Code(err) != "STATE_CONFLICT" || f.count("pulse_tasks", "1=1") != 1 {
t.Fatal("round limit reset", err)
}
})
t.Run("continuation_clock_rejects_changed_due_without_matching_audit", func(t *testing.T) {
f, v, _ := reviewedPendingPoll(t, ctx, db, false)
continueReviewedPoll(t, f, v.ID)
continueWorkflow(t, f, v.ID)
c := clockCandidate(t, f, v.ID, "query")
waitClockDue(t, f, c)
f.exec(`UPDATE weave_poll_states SET next_poll_at=DATE_SUB(next_poll_at,INTERVAL 1 MICROSECOND) WHERE namespace_id=?`, f.resources.NamespaceID)
_, err := newRuntime(t, db).process(ctx, c, false)
f.exec(`UPDATE weave_poll_states SET next_poll_at=DATE_ADD(next_poll_at,INTERVAL 1 MICROSECOND) WHERE namespace_id=?`, f.resources.NamespaceID)
if err == nil || f.count("pulse_tasks", "1=1") != 1 {
t.Fatal("changed checkpoint bypassed audit proof")
}
})
t.Run("signal_rearm_changes_generation_without_changing_deadline", func(t *testing.T) {
f, v := waitRecoveryFixture(t, ctx, db, "SIGNAL", 30000)
before := recoveryStep(t, f, v.ID, "signal")
var deadline time.Time
if err := db.QueryRowContext(ctx, `SELECT deadline_at FROM weave_waits WHERE namespace_id=?`, f.resources.NamespaceID).Scan(&deadline); err != nil {
t.Fatal(err)
}
out := rearmWorkflowWait(t, f, v.ID, "signal")
if *out.Step.ActivationID == before.ActivationID || f.count("weave_waits", "deadline_at=?", deadline) != 2 || f.count("weave_activations", "id=? AND state='CANCELLED'", before.ActivationID) != 1 {
t.Fatal("wait history or original deadline lost")
}
old := sendSignal(t, f, v, signalInput(v))
if old.Signal.Disposition != "STALE_ACTIVATION" {
t.Fatal("old wait signal accepted")
}
resolveWorkflowStep(t, f, v.ID, "left", "SUCCEEDED", `{"n":9007199254740993}`)
continueWorkflow(t, f, v.ID)
in := signalInput(v)
in.TargetActivationSequence = 2
sendSignal(t, f, v, in)
runClock(t, f, clockCandidate(t, f, v.ID, "signal"))
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
runtimeView(t, f, v.ID, "SUCCEEDED")
})
t.Run("timer_rearm_uses_first_activation_elapsed_time", func(t *testing.T) {
f, v := waitRecoveryFixture(t, ctx, db, "TIMER", 2000)
var original time.Time
if err := db.QueryRowContext(ctx, `SELECT deadline_at FROM weave_waits WHERE namespace_id=?`, f.resources.NamespaceID).Scan(&original); err != nil {
t.Fatal(err)
}
time.Sleep(300 * time.Millisecond)
out := rearmWorkflowWait(t, f, v.ID, "timer")
if f.count("weave_waits", "deadline_at=?", original) != 2 {
t.Fatal("timer rearm reset original delay")
}
resolveWorkflowStep(t, f, v.ID, "left", "SUCCEEDED", `{"n":9007199254740993}`)
continueWorkflow(t, f, v.ID)
c := clockCandidate(t, f, v.ID, "timer")
waitClockDue(t, f, c)
runClock(t, f, c)
if f.count("weave_activations", "id=? AND state='SUCCEEDED'", out.Step.ActivationID) != 1 {
t.Fatal("timer expected new activation delay")
}
})
t.Run("wait_rearm_bound_same_key_race_and_stale_target", func(t *testing.T) {
f, v := waitRecoveryFixture(t, ctx, db, "SIGNAL", 60000)
in := recovery.RearmWaitInput{StepDecision: recoveryStep(t, f, v.ID, "signal"), WaitVerified: true}
key := string(executionID(t))
var wg sync.WaitGroup
errs := make(chan error, 8)
results := make(chan recovery.Result, 8)
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
out, err := api.RearmWait(ctx, f.token, v.ID, key, in)
errs <- err
results <- out
}()
}
wg.Wait()
close(errs)
close(results)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
fresh := 0
for out := range results {
if !out.Deduplicated {
fresh++
}
}
if fresh != 1 {
t.Fatal("duplicate wait generations")
}
stale := in
stale.ExpectedRevision = recoveryDecision(t, f, v.ID).ExpectedRevision
if _, err := api.RearmWait(ctx, f.token, v.ID, string(executionID(t)), stale); fault.Code(err) != "STATE_CONFLICT" {
t.Fatal("old activation rearmed latest wait", err)
}
for sequence := uint32(2); sequence < recovery.MaxWaitActivations; sequence++ {
rearmWorkflowWait(t, f, v.ID, "signal")
}
if _, err := api.RearmWait(ctx, f.token, v.ID, string(executionID(t)), recovery.RearmWaitInput{StepDecision: recoveryStep(t, f, v.ID, "signal"), WaitVerified: true}); fault.Code(err) != "STATE_CONFLICT" || f.count("weave_waits", "1=1") != int(recovery.MaxWaitActivations) {
t.Fatal("wait rearm unbounded", err)
}
})
t.Run("pending_signal_cannot_be_discarded_by_rearm", func(t *testing.T) {
f, v := waitRecoveryFixture(t, ctx, db, "SIGNAL", 30000)
sendSignal(t, f, v, signalInput(v))
_, err := api.RearmWait(ctx, f.token, v.ID, string(executionID(t)), recovery.RearmWaitInput{StepDecision: recoveryStep(t, f, v.ID, "signal"), WaitVerified: true})
if fault.Code(err) != "STATE_CONFLICT" || f.count("weave_signals", "state='PENDING'") != 1 || f.count("weave_waits", "1=1") != 1 {
t.Fatal("pending evidence discarded", err)
}
})
t.Run("wait_rearm_audit_failure_and_expired_deadline", func(t *testing.T) {
f, v := waitRecoveryFixture(t, ctx, db, "TIMER", 1500)
in := recovery.RearmWaitInput{StepDecision: recoveryStep(t, f, v.ID, "timer"), WaitVerified: true}
trigger := "reject_wait_rearm_" + string(executionID(t))
f.exec("CREATE TRIGGER " + trigger + " BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='controlled rearm audit failure'")
_, err := api.RearmWait(ctx, f.token, v.ID, string(executionID(t)), in)
f.exec("DROP TRIGGER " + trigger)
if err == nil || f.count("weave_waits", "state='WAITING'") != 1 || f.count("weave_activations", "id=? AND state='WAITING'", in.ActivationID) != 1 || recoveryDecision(t, f, v.ID).ExpectedRevision != in.ExpectedRevision {
t.Fatal("partial wait rearm committed", err)
}
var due time.Time
if err := db.QueryRowContext(ctx, `SELECT deadline_at FROM weave_waits WHERE namespace_id=?`, f.resources.NamespaceID).Scan(&due); err != nil {
t.Fatal(err)
}
time.Sleep(time.Until(due.Add(20 * time.Millisecond)))
if _, err := api.RearmWait(ctx, f.token, v.ID, string(executionID(t)), in); fault.Code(err) != "STATE_CONFLICT" || f.count("weave_waits", "1=1") != 1 {
t.Fatal("expired timer was restarted", err)
}
})
}

View File

@@ -19,6 +19,9 @@ import (
const Action = "workflows:recover"
// MaxWaitActivations 包含首次等待,约束人工换代次数,防止无执行尝试的无限重建。
const MaxWaitActivations uint32 = 32
// Decision 核查基于整个实例的最新证据版本;后台新事实会使旧决定失效,原键仍可恢复回执。
type Decision struct {
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 当前实例版本,不能使用control_revision代替。
@@ -59,6 +62,16 @@ type RetryStepInput struct {
SafeToRetry bool `json:"safe_to_retry" required:"true"` // SafeToRetry 确认旧执行已结束,并按原业务身份再次执行安全;不能只依据未收到响应。
}
type ContinuePollInput struct {
StepDecision
PendingConfirmed bool `json:"pending_confirmed" required:"true"` // PendingConfirmed 确认权威查询结果为处理中,按原间隔与剩余业务轮次接续。
}
type RearmWaitInput struct {
StepDecision
WaitVerified bool `json:"wait_verified" required:"true"` // WaitVerified 已核验当前等待及旧回执,按原契约/截止开启新等待代次。
}
type Result struct {
Instance instance.View `json:"instance"` // Instance 操作提交时的冻结实例回执;核查步骤不自动解除实例核查。
AuditID core.ID `json:"audit_id"` // AuditID 本次流程管理操作的不可变审计标识。
@@ -69,6 +82,8 @@ type Result struct {
}
type Store interface {
ContinuePoll(context.Context, auth.Token, core.ID, string, ContinuePollInput) (Result, error)
RearmWait(context.Context, auth.Token, core.ID, string, RearmWaitInput) (Result, error)
ReviewTask(context.Context, auth.Token, core.ID, string, ReviewTaskInput) (Result, error)
ResolveStep(context.Context, auth.Token, core.ID, string, ResolveStepInput) (Result, error)
Continue(context.Context, auth.Token, core.ID, string, ContinueInput) (Result, error)
@@ -160,3 +175,28 @@ func (s *Service) RetryStep(ctx context.Context, t auth.Token, id core.ID, key s
}
return s.store.RetryStep(ctx, t, id, key, in)
}
func (s *Service) ContinuePoll(ctx context.Context, t auth.Token, id core.ID, key string, in ContinuePollInput) (Result, error) {
if err := validateDecision(id, key, in.Decision); err != nil {
return Result{}, err
}
if err := validateStep(in.StepDecision); err != nil {
return Result{}, err
}
if !in.PendingConfirmed {
return Result{}, fault.Invalid("pending_confirmed=true required")
}
return s.store.ContinuePoll(ctx, t, id, key, in)
}
func (s *Service) RearmWait(ctx context.Context, t auth.Token, id core.ID, key string, in RearmWaitInput) (Result, error) {
if err := validateDecision(id, key, in.Decision); err != nil {
return Result{}, err
}
if err := validateStep(in.StepDecision); err != nil {
return Result{}, err
}
if !in.WaitVerified {
return Result{}, fault.Invalid("wait_verified=true required")
}
return s.store.RearmWait(ctx, t, id, key, in)
}

View File

@@ -28,6 +28,14 @@ func (s *captureStore) RetryStep(context.Context, auth.Token, core.ID, string, R
s.calls++
return Result{}, nil
}
func (s *captureStore) ContinuePoll(context.Context, auth.Token, core.ID, string, ContinuePollInput) (Result, error) {
s.calls++
return Result{}, nil
}
func (s *captureStore) RearmWait(context.Context, auth.Token, core.ID, string, RearmWaitInput) (Result, error) {
s.calls++
return Result{}, nil
}
func TestRecoveryValidation(t *testing.T) {
id := core.ID(strings.Repeat("a", 32))
d := Decision{ExpectedRevision: 2, Reason: "核实外部结果", EvidenceRef: "evidence://case/1"}
@@ -71,7 +79,19 @@ func TestRecoveryValidation(t *testing.T) {
if _, err := s.RetryStep(ctx, token, id, "operation", RetryStepInput{StepDecision: step}); err == nil {
t.Fatal("unsafe retry accepted")
}
if store.calls != 4 {
if _, err := s.ContinuePoll(ctx, token, id, "operation", ContinuePollInput{StepDecision: step}); err == nil {
t.Fatal("unconfirmed pending accepted")
}
if _, err := s.RearmWait(ctx, token, id, "operation", RearmWaitInput{StepDecision: step}); err == nil {
t.Fatal("unverified wait accepted")
}
if _, err := s.ContinuePoll(ctx, token, id, "operation", ContinuePollInput{StepDecision: step, PendingConfirmed: true}); err != nil {
t.Fatal(err)
}
if _, err := s.RearmWait(ctx, token, id, "operation", RearmWaitInput{StepDecision: step, WaitVerified: true}); err != nil {
t.Fatal(err)
}
if store.calls != 6 {
t.Fatal("invalid inputs reached storage", store.calls)
}
}