test: cover dispatch loss and workflow commit fault boundaries

This commit is contained in:
2026-09-18 10:52:19 +08:00
parent 19cb70368e
commit 1f1d833f69
12 changed files with 697 additions and 38 deletions

View File

@@ -14,7 +14,7 @@
- 结果日志的序号在独立命名空间流行锁下分配,与任务状态及原事件同事务提交;禁止用最大事件ID或时间戳推断提交水位。生产者不得锁订阅行。
- 创建订阅先锁队列再锁流行;消费按订阅→交付持锁,不能再取得任务、队列、额度或流行锁。ACK只验证最近代次及证明,过期未被替代的证明仍可确认;重复确认不能再次扣减pending_count。
- 可靠订阅已完成核心、HTTP/WSS、管理及消费SDK、独立业务Inbox示例、节点及消费者进程故障回归。批量受理已提供逐项幂等与未知结果分类。独立任务核查/人工重试已实现,任务查询已支持业务键、创建时间及直接来源(普通/人工重试/计划固定版本),业务事务Outbox独立示例已实现并通过全量链路回归;P3已完成原功能范围核对与全量阶段回归(docs/reports/phase-3.md),相对延迟及四种重试全停恢复一并验证;继续P4–P7,保留精确A04故障、生产验证和实际文件转换示例等未完成项,不能把P3阶段验收当作全平台交付。
- 可靠订阅已完成核心、HTTP/WSS、管理及消费SDK、独立业务Inbox示例、节点及消费者进程故障回归。批量受理已提供逐项幂等与未知结果分类。独立任务核查/人工重试已实现,任务查询已支持业务键、创建时间及直接来源(普通/人工重试/计划固定版本),业务事务Outbox独立示例已实现并通过全量链路回归;P3已完成原功能范围核对与全量阶段回归(docs/reports/phase-3.md),相对延迟及四种重试全停恢复一并验证;P4/P5功能范围另按阶段报告验收,A04已补精确故障证据;继续P6/P7的生产验证和实际文件转换示例等未完成项,不能把P3阶段验收当作全平台交付。
- 批量受理最多32项、512 KiB;每项复用单项身份与事务,未知结果必须原键核对。任务提交按认证→应用额度→提交幂等身份→队列持锁,禁止恢复为先抢提交唯一键后等待额度锁;原回执仍须先于配额上限检查返回。
@@ -31,7 +31,7 @@
- P4定义发布使用schema_version=1有限结构,词法作用域禁止跨分支/前向读取,任务策略显式冻结;SDK类型独立。发布按认证→应用额度→操作身份→定义→排序队列持锁;原键与同版本去重仍校验当前权限。定义/版本/revision/审计/回执同事务;同版本不同内容不得覆盖。保留精确数字,校验存储原文与结构摘要;不得把发布通过或DSL支持误称为实例执行已实现。
- P4定义子项已通过真实MySQL、HTTP丢回执、独立SDK和全量回归(docs/reports/phase-4-definitions.md)。实例启动/固定版本/首批任务同事务的内部存储已补齐;内部事件消费、顺序与组合推进及根接续已接入;TIMER与后续POLL时钟已接入,SIGNAL及最终查询竞争已接入,内部控制与停止结清已接入,等待恢复已接入,实例公共API/SDK已接入,流程任务来源已接入,已接入有界历史查询,实例分页已接入,已接入状态事件时间线,独立轮询/恢复示例已接入,W06/W09/W14/W15已补实际事务竞争和公共SDK全停回归;P4整体和P5–P7仍未完成。
- P4定义子项已通过真实MySQL、HTTP丢回执、独立SDK和全量回归(docs/reports/phase-4-definitions.md)。实例启动/固定版本/首批任务同事务的内部存储已补齐;内部事件消费、顺序与组合推进及根接续已接入;TIMER与后续POLL时钟已接入,SIGNAL及最终查询竞争已接入,内部控制与停止结清已接入,等待恢复已接入,实例公共API/SDK已接入,流程任务来源已接入,已接入有界历史查询,实例分页已接入,已接入状态事件时间线,独立轮询/恢复示例已接入,W06/W09/W14/W15已补实际事务竞争和公共SDK全停回归;P4/P5平台功能阶段已按原范围验收,见docs/reports/phase-4-5.md;P6/P7及全平台交付仍未完成。
- 内部结果交付使用workflow_activation所有者并固定激活ID,TASK经activation.task_id、POLL经轮次校验归属;weave_inbox来源及独立快照与Pulse结果同事务,先于外部结果流行锁,不在结果路径反锁实例/步骤/订阅。迁移000017隔离缺快照历史,禁止用当前任务重建旧事件。
- weave/engine仅计算有限决策;实例受理、Inbox消费与根接续已接入,等待和停止结清运行器已接入,内部核查/TASK恢复已接入,等待恢复已接入内部服务,公共API/SDK已接入,流程任务来源已接入,有界历史已接入,实例分页已接入,已接入状态事件时间线,独立轮询/恢复示例已接入,W06/W09/W14/W15已补实际事务竞争和公共SDK全停回归。Plan最多32个变更,More必须持久化待续;耗尽变更预算仍须遍历其他已活动分支,不能误判在途工作不可达。冻结分支选择,精确数值,不以PENDING查询结果完成POLL。后续消费确认/当前激活校验/新任务或检查点必须同事务,不能把纯快照往返测试当作真实进程恢复。
@@ -41,7 +41,7 @@
- 结果消费按身份→应用额度→排序队列→实例→门控→步骤/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已接入,流程任务来源已接入,实例列表已接入,状态事件时间线已接入,独立轮询/恢复示例已接入,W01/W13提交切点和W16最终信号竞争已补,P4/P5功能阶段核对见docs/reports/phase-4-5.md;生产数据库故障、容量和运维继续P6/P7。
- 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功能范围核对,恢复与生产负载/运维边界见docs/reports/phase-4-5.md。
- 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流程来源已交付;历史激活/轮次/等待/信号及信号审计已接入;实例列表已接入,状态事件时间线已接入,独立轮询/恢复示例已接入,原功能范围及提交故障已核对,P6/P7生产验收继续推进。
- 恢复请求的匿名嵌入结构按扁平精确JSON字段解析,继承字段也拒绝别名/重复/未知字段,保留显式null例外与必填确认。bootstrap先验证权限集合;工作流执行/控制/信号/恢复/审计分别显式授予,旧workflow-access仅定义发布。
- 任务来源WORKFLOW只返回已授权任务的固定流程/版本/步骤/历史激活及POLL轮次;tasks:read不授予完整流程和其他队列权限。核对正反向关联、固定定义及门控,恢复父任务须为前一激活且POLL同轮。TASK恢复的retry_of位于workflow对象内,后续POLL新轮无父任务。不能改指当前激活、递归展开历史或将来源查询加入派发路径;无外键。
@@ -72,8 +72,11 @@
- 状态时间线只读取已存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和原截止保持。受控内存渠道不是持久生产证据。W06实际版本后继、W09三方竞争、W14暂停竞争和W15暂停全停已补,见docs/reports/phase-4-5-races.md;P4/P5仍不整体宣称完成,P6/P7继续保留。
- 独立workflow-poll示例验证2轮3次请求与2轮耗尽核查;workflow-recovery实际提交后断线,只提交一次,按证据依次ReviewTask/ResolveStep/Continue,再显式RESUME,旧UNKNOWN和原截止保持。受控内存渠道不是持久生产证据。W06实际版本后继、W09三方竞争、W14暂停竞争和W15暂停全停已补,见docs/reports/phase-4-5-races.md;P4/P5功能阶段验收见docs/reports/phase-4-5.md,P6/P7生产及交付门槛继续保留。
- 竞争回归通过performance_schema确认参与者实际进入数据库锁等待,禁止将请求已发出或sleep当作并发证据。W09以原数据库截止自然到达放行;PAUSE以后不得新建任务/尝试或授权,已授权结果仍需收集。公共SDK暂停全停场景须在全部旧节点Wait确认退出后才启动新节点;保留原回执、等待身份、版本、查询轮次与截止。普通SIGNAL竞争不能替代最终查询/回执/下一轮的独立故障边界;COMMIT不明、A04、生产HA与容量等继续按P6/P7验收。
- 竞争回归通过performance_schema确认参与者实际进入数据库锁等待,禁止将请求已发出或sleep当作并发证据。W09以原数据库截止自然到达放行;PAUSE以后不得新建任务/尝试或授权,已授权结果仍需收集。公共SDK暂停全停场景须在全部旧节点Wait确认退出后才启动新节点;保留原回执、等待身份、版本、查询轮次与截止。普通SIGNAL竞争不能替代最终查询/回执/下一轮的独立故障边界;COMMIT不明和A04已补独立故障工具及真实回归;生产HA与容量等继续按P6/P7验收。
- 版本/暂停竞争本批已通过21个真实存储子场景、公共SDK两旧节点全停及两新节点恢复、23个SDK示例/2个业务模块的标准全量回归。证据见docs/reports/phase-4-5-races.md;下一批按docs/08-development-plan.md的P6首批实施顺序先补A04/W01/W13/W16精确故障,不重复将W06/W09/W14/W15列为未做。全平台目标继续保持,不能以本批通过替代观测、凭据轮换、归档/备份、生产拓扑和发布交付。
- 版本/暂停竞争本批已通过21个真实存储子场景、公共SDK两旧节点全停及两新节点恢复、23个SDK示例/2个业务模块的标准全量回归。证据见docs/reports/phase-4-5-races.md;A04/W01/W13/W16精确故障也已补,见docs/reports/phase-6-fault-boundaries.md;下一批按docs/08-development-plan.md推进运行观测,再做凭据轮换,不重复将上述已验证场景列为未做。全平台目标继续保持,不能以本批通过替代观测、凭据轮换、归档/备份、生产拓扑和发布交付。
- A04须截留真实task.assign并独立确认LEASED/未授权,再强杀旧节点、Wait确认退出、新节点等原租约自然到期;不能把已送达未开始替代未送达。COMMIT故障工具只在测试二进制:BEFORE阻止COMMIT,AFTER核验真实MySQL成功包后丢确认;独立连接回读,原身份/候选恢复,禁止在生产增加故障开关或将HTTP丢回执替代数据库提交不明。
- W16下一轮时钟只能在PENDING结果已保存等待后存在,三方竞争中的结果参与者是旧结果重放;新结果/未知/冲突另有在途查询回归。保持原受理时间、事件时间、截止与预算,不能凭有限入队顺序声称穷尽线程交错。

View File

@@ -8,9 +8,9 @@ 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) 继续实现。
已完成 P0–P5 平台功能阶段验收: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/examples/workflow-poll.md)与[恢复](docs/examples/workflow-recovery.md)示例已接入,已补[版本/暂停竞争/全停回归](docs/reports/phase-4-5-races.md),剩余故障见[P4/P5核对](docs/reports/phase-4-5.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)示例已接入,已补[版本/暂停竞争/全停回归](docs/reports/phase-4-5-races.md),功能阶段证据见[P4/P5核对](docs/reports/phase-4-5.md),提交断线及派发未送达见[故障边界](docs/reports/phase-6-fault-boundaries.md);[任务流程来源](docs/api/task-source.md#5-流程固定来源)已接入,P4/P5功能阶段已通过,P6生产验证和P7发布交付继续推进。
数据库禁止外键,关联一致性通过服务端事务校验,索引、唯一约束和 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 @@
# Orbit 生产验收与发布门槛
> 版本 0.2 · 2026-09-17。本文是全平台生产发布门槛。P0–P2 已积累阶段证据,见 [开发计划](08-development-plan.md) 与 [P2 验收](reports/phase-2.md);P3的逐项功能及故障证据边界见[阶段核对](reports/phase-3.md),新增[相对延迟与重试全停恢复](reports/phase-3-delay-recovery.md)。新增[流程版本、暂停竞争与全停恢复](reports/phase-4-5-races.md)覆盖W06/W09/W14/W15。未覆盖的场景继续实施,尚无全平台生产验收或容量结论。
> 版本 0.2 · 2026-09-17。本文是全平台生产发布门槛。P0–P2 已积累阶段证据,见 [开发计划](08-development-plan.md) 与 [P2 验收](reports/phase-2.md);P3的逐项功能及故障证据边界见[阶段核对](reports/phase-3.md),新增[相对延迟与重试全停恢复](reports/phase-3-delay-recovery.md)。新增[流程版本、暂停竞争与全停恢复](reports/phase-4-5-races.md)覆盖W06/W09/W14/W15。[首批P6故障边界](reports/phase-6-fault-boundaries.md)补充A04、W01/W13及W16的实际切点。未覆盖的场景继续实施,尚无全平台生产验收或容量结论。
## 1. 验收对象与证据

View File

@@ -1,6 +1,6 @@
# Weave 流程编排功能与设计
> 版本 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),生产验收仍未完成。
> 版本 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)验收,P6生产验证与P7发布交付仍未完成。
## 1. 职责与边界

View File

@@ -28,16 +28,16 @@
| 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)已接入;独立轮询/恢复示例已接入;[版本/暂停竞争/全停回归](reports/phase-4-5-races.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-races.md)已补;[剩余故障验收](reports/phase-4-5.md)继续推进 |
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 |
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 已完成平台功能阶段验收:[原功能及W01–W17逐项核对](reports/phase-4-5.md)、[版本/暂停/全停回归](reports/phase-4-5-races.md)、[提交故障边界](reports/phase-6-fault-boundaries.md)。研发发布、固定版本、顺序/分支/有限并行、信号/时间等待、公共API/SDK及可运行示例均已交付;生产拓扑与发布兼容继续P6/P7 |
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 已完成平台功能阶段验收:[原功能核对](reports/phase-4-5.md)。有界查询、独立预算、暂停继续/取消、六类恢复、历史/筛选/事件/来源与SDK场景均已交付;真实竞争、COMMIT断线及进程全停保留原身份、截止和预算。运维、容量与首版发布继续P6/P7 |
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 进行中;已补[首批精确故障边界](reports/phase-6-fault-boundaries.md),继续运行观测、凭据轮换及其余生产门槛 |
| 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-5.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 下一步实施顺序
### P4/P5 已交付功能核对
1. 已实现FAIL_AFTER_SETTLED与显式取消的内部分支结清,见[控制报告](reports/phase-5-controls.md):持久取消意图、复用Pulse取消、收集已发出查询/任务结果、等待全部已激活工作结清后才进入失败或取消终态。效果未知仍保留核查。
2. 已实现实例暂停/继续/取消内部操作,以control_revision、操作幂等身份、明确原因及审计保护控制决定;暂停不延长原截止,继续不覆盖核查或取消状态。
@@ -45,9 +45,9 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
4. 实例/当前步骤/信号/控制/六类恢复及流程操作审计HTTP和独立SDK已接入,步骤查询返回实际激活代次;并行执行与信号回执场景已有可运行示例。激活/轮次/信号历史及信号资源审计已接入;实例筛选分页已接入;状态事件时间线已接入;独立轮询及恢复示例已接入,已补[W06/W09/W14/W15专项回归](reports/phase-4-5-races.md);[Pulse任务流程来源](api/task-source.md#5-流程固定来源)已交付。逐项验证原键恢复、权限、版本冲突、竞争与进程全停。
5. 转入P6/P7的生产故障、容量、运维、发布与全部文档核对。内部存储阶段通过不会提前勾选公共接口或生产验收。
公共接入按以下调用链推进;本批已接入启动/当前详情、控制、信号、六类恢复和流程操作审计,并提供独立SDK及基础示例。任务流程来源和有界历史查询也已接入。实例分页和独立查询示例已接入;状态事件时间线已接入;剩余精确故障场景按[P4/P5核对](reports/phase-4-5.md)继续交付:
公共接入按以下调用链推进;本批已接入启动/当前详情、控制、信号、六类恢复和流程操作审计,并提供独立SDK及基础示例。任务流程来源和有界历史查询也已接入。实例分页和独立查询示例已接入;状态事件时间线已接入;P4/P5功能阶段已按[逐项核对](reports/phase-4-5.md)验收;剩余生产门槛继续P6/P7:
| 调用链 | 需要补齐的内容 | 验收重点 |
| 调用链 | 已交付内容 | 验收重点 |
| --- | --- | --- |
| 启动与读取 | 显式固定版本启动、当前实例、步骤及当前激活代次;已补有界历史读取及实例筛选分页 | 原键丢回执恢复、当前权限、读取快照一致性、未激活步骤语义 |
| 控制与信号 | 暂停/继续/取消、固定步骤和代次的信号提交及原受理回执 | control_revision与通用revision区分、迟到/旧代次、暂停期间结果收集 |
@@ -58,7 +58,7 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
### P6 首批实施顺序
1. 统一可复现故障工具:补A04“派发已提交但消息尚未送达”、W01/W13事务提交结果不明,以及W16最终查询结果/回执/下一轮竞争。每个切点必须有实际数据库或进程证据,不能用响应错误推断事务是否已提交,也不能用普通SIGNAL竞争替代最终查询场景。
1. 已补[首批可复现故障工具](reports/phase-6-fault-boundaries.md):A04“派发已提交但消息尚未送达”、W01/W13事务提交结果不明,以及W16最终回执/旧PENDING结果重放/下一轮竞争。每个切点必须有实际数据库或进程证据,不能用响应错误推断事务是否已提交,也不能用普通SIGNAL竞争替代最终查询场景。
2. 实现运行观测:受理/授权/完成及流程推进延迟、重试/核查/恢复、待处理与到期积压、连接/发送队列和数据库连接池。明确采集范围与开销;任务、流程、业务键和凭据不得作为无界指标标签。
3. 补齐凭据签发、轮换、撤销及Worker/订阅安全重绑,保持最小权限、管理审计与未知回执可恢复。
4. 实现保留/归档与受保护记录核对,再执行备份恢复、兼容升级和六类容量负载。未确认交付、有效幂等身份、当前/历史执行证据和未终结流程不能被普通清理破坏。生产HA及RPO/RTO继续按实际拓扑验证,不缩减为本机多进程检查。
@@ -77,7 +77,7 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
| 任务详情来源追踪 | 已实现[独立来源接口](api/task-source.md)、SDK和普通/计划/人工恢复场景,返回计划及触发固定版本;同命名空间归属、当前权限和来源缺失拒绝已通过[全量回归](reports/phase-3-task-source.md),流程来源已按[专项报告](reports/phase-5-task-source.md)接入 |
| 业务到平台的事务Outbox接入 | 已实现[独立业务模块及完整接入](examples/task-outbox.md),覆盖业务变更/待发请求同事务、有限发送恢复、CLI及真实HTTP/MySQL/进程故障,见[报告](reports/phase-3-task-outbox.md);核心SDK保持无ORM/数据库依赖 |
精确A04派发消息未送达时的进程故障、慢消费者/重连风暴、凭据安全重绑、历史清理、全量监控/容量与MySQL故障切换仍按P6执行;Weave的通用执行门控/内部事件接续随P4–P5落实,不将外部结果订阅当作内部流程引擎。
A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专项回归](reports/phase-6-fault-boundaries.md)。慢消费者/重连风暴、凭据安全重绑、历史清理、全量监控/容量与MySQL故障切换继续按P6执行;Weave通用执行门控/内部事件接续已实现,不将外部结果订阅当作内部流程引擎。
### P3 事务Outbox接入的验收范围
@@ -186,8 +186,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/人工恢复示例已验收,提交故障及竞争证据见[专项报告](reports/phase-6-fault-boundaries.md),P6/P7仍未完成。
### P4/P5 原范围核对后的下一批
### P4/P5 阶段结论与下一批
[逐项证据与缺口](reports/phase-4-5.md)已落表,两项独立场景已交付。下一批补:运行中发布不同v2后旧实例的后继执行(W06);信号、真实截止、CANCEL三方并发(W09);PAUSE与开始、创建、领取及重试竞争(W14);暂停状态跨完整节点退出再继续的公共API链路(W15)。原生产故障、观测、容量、凭据、归档、备份恢复与发布门槛保持,P4/P5整体暂不勾选完成。
[逐项证据与生产边界](reports/phase-4-5.md)已落表,P4/P5平台功能阶段验收通过。W06/W09/W14/W15真实竞争和全停、A04未送达、W01/W13提交断线及W16旧结果重放竞争均有对应回归,不重复列为待开发。下一批首先补运行指标与告警依据,再按上方P6顺序推进凭据轮换、保留/归档、容量、备份恢复与生产数据库故障验证;P7负责剩余场景和版本发布。完整首版目标保持进行中。

View File

@@ -2,7 +2,7 @@
状态:下表公共 HTTP 与独立 Go SDK 已接入。定义发布见[定义接口](workflow-definitions.md),人工恢复见[恢复接口](workflow-recovery.md),完整运行例子见[并行作业与回执](../examples/workflow.md)。SDK 尚未发布正式版本,服务端与 SDK 应使用同一开发版本。
实例筛选分页继续开发;[历史激活、轮次、信号和信号资源审计](workflow-history.md)已接入;[Pulse任务流程来源](task-source.md#5-流程固定来源)已接入。旧冻结回执可能不含新增的activation_sequence,应另读当前步骤详情;当前步骤详情只反映当前激活;旧记录保留在数据库,不能把本页的当前视图当作全部历史。
[实例筛选分页](workflow-queries.md)、[历史激活、轮次、信号和信号资源审计](workflow-history.md)、[状态事件时间线](workflow-events.md)和[Pulse任务流程来源](task-source.md#5-流程固定来源)均已接入。旧冻结回执可能不含新增的activation_sequence,应另读当前步骤详情;当前步骤详情只反映当前激活;旧记录保留在数据库,不能把本页的当前视图当作全部历史。
## 1. 路由、权限与 SDK
@@ -11,6 +11,7 @@
| 方法与路径 | 权限 | Go SDK 方法 |
| --- | --- | --- |
| `POST /v1/workflows` | `workflows:start`;有任务队列时还需 `tasks:submit` | StartWorkflow |
| `GET /v1/workflows` | `workflows:read` | ListWorkflows;过滤与游标见[查询手册](workflow-queries.md) |
| `GET /v1/workflows/{id}` | `workflows:read` | GetWorkflow |
| `GET /v1/workflows/{id}/steps` | `workflows:read` | GetWorkflowSteps |
| `GET /v1/workflows/{id}/steps/{key}` | `workflows:read` | GetWorkflowStep |

View File

@@ -1,10 +1,10 @@
# P4/P5 原范围核对与剩余验收
# P4/P5 平台功能阶段验收与生产边界
日期:2026-09-18。本报告按[原流程功能](../06-workflow-spec.md)、[开发计划](../08-development-plan.md)及[生产故障矩阵W01–W17](../05-production-acceptance.md)核对,保留未完成项。功能实现、单个场景通过与整个阶段验收分开记录;P4/P5尚不标为整体完成,P6/P7及持续目标保持进行中。
日期:2026-09-18。本报告按[原流程功能](../06-workflow-spec.md)、[开发计划](../08-development-plan.md)及[生产故障矩阵W01–W17](../05-production-acceptance.md)核对,保留未完成项。原功能均有服务端、公共HTTP/独立SDK、场景及异常回归证据;版本/暂停/全停与提交边界的补强完成后,P4/P5平台功能阶段验收通过。P6/P7及持续目标保持进行中,生产负载、部署拓扑和发布兼容不包含在这一结论中。
新增补强:[版本、暂停竞争与全停恢复回归](phase-4-5-races.md)提供W06/W09/W14/W15的实际事务及公共SDK进程证据。下表随该批更新,原轮询/恢复示例的验证记录保留。
新增补强:[版本、暂停竞争与全停恢复回归](phase-4-5-races.md)提供W06/W09/W14/W15的实际事务及公共SDK进程证据。另有[A04/W01/W13/W16故障边界](phase-6-fault-boundaries.md)的真实派发未送达、COMMIT前后断线和最终信号竞争证据。下表随两批更新,原轮询/恢复示例的验证记录保留。
## 本批交付
## 首批独立场景交付
新增两个独立可运行SDK示例,均只导入公开SDK及示例内部辅助包,不访问Orbit数据库或服务端模型:
@@ -36,7 +36,7 @@
| 编号 | 当前证据 | 仍需补强的具体边界 |
| --- | --- | --- |
| W01 | 受理/推进/后继创建的SQL失败与SAVEPOINT回滚,原结果和额度保持 | COMMIT回执不明与特定步骤写入切点的进程中断需要统一故障工具覆盖 |
| W01 | SQL失败/SAVEPOINT回滚及实例启动、后继创建的真实COMMIT前后断线;原身份恢复且状态/任务/消费确认原子一致,见[故障边界](phase-6-fault-boundaries.md) | MySQL进程/复制故障、存储持久化组合继续P6 |
| W02 | 结果持久化后全控制节点退出,两新节点消费,后继不重复 | 生产数据库切换另按P6,不与应用进程强杀等同 |
| W03 | 早到、重复、迟到、错误关联及代次的实际存储与SDK回归 | 仍须纳入容量模型中的大量信号积压 |
| W04 | TIMER、早到SIGNAL、独立等待恢复均有全停后接续 | 生产长时间等待、保留与清理策略按P6验收 |
@@ -48,13 +48,13 @@
| W10 | 同恢复键竞争、旧激活结果、旧信号代次及恢复后全停 | 滚动版本兼容、长期历史保留另按P6/P7 |
| W11 | 同轮重试与PENDING新轮次的存储、SDK及实际HTTP例子 | 渠道峰值与大批量同刻唤醒按P6 |
| W12 | 最大轮次、截止、原轮尝试耗尽拒绝绕过;新例子保留NEEDS_REVIEW | 不把本地时间预算测试换算成生产延迟指标 |
| W13 | 轮次创建失败回滚、PENDING落库及检查点全停恢复 | 精确COMMIT不确定窗口仍按W01/P6补强 |
| W13 | PENDING结果/等待和下一轮任务/预算分别覆盖COMMIT前回滚、提交后丢确认;新运行器按原候选恢复,原结果计时及查询预算保持 | 生产数据库失联、切换与容量继续P6 |
| W14 | PAUSE与开始/后继/领取/安全重试的真实事务竞争,双向入队、数据库授权时间、暂停收集结果、恢复任务/尝试数及原截止断言 | 不声称穷尽全部线程交错;高负载锁等待及故障切点继续P6 |
| W15 | 公共SDK暂停后提交任务结果/信号,两原节点全部SIGKILL并Wait退出;TIMER/下一查询轮就绪时间过去后两新节点接管,显式RESUME完成四任务/两轮查询 | 单机数据库进程故障证据;生产数据库高可用和长停机恢复另按P6 |
| W16 | 最终信号与查询结果按原受理时间裁决,在途PENDING结清、冲突核查、全停保留 | 普通SIGNAL三方竞争见W09;最终查询/回执/下一轮同时到达的故障切点及热点负载继续P6,不能拿W09替代 |
| W15 | 公共SDK暂停后提交任务结果/信号,两原节点全部SIGKILL并Wait退出;TIMER/下一查询轮就绪时间过去后两新节点接管,显式RESUME完成四任务/两轮查询 | 单机数据库下的应用进程故障证据;生产数据库高可用和长停机恢复另按P6 |
| W16 | 原受理时间裁决、在途PENDING结清/冲突核查/全停保留;另有最终信号、旧PENDING重放、下一轮时钟六种真实事务竞争,后继仅一个 | 新结果冲突与旧结果重放分别核验;不声称穷尽调度交错,热点负载继续P6 |
| W17 | 同版本同内容/不同内容、非法定义、SDK丢发布回执恢复 | 旧SDK/服务版本滚动兼容矩阵按P7发布候选核对 |
## 本批验证记录
## 首批独立场景验证记录(历史)
环境:macOS arm64、Go 1.27.1、Docker MySQL 8.4.10,一次性隔离数据库和真实Orbit进程。新例子先用`go run -race`运行,两个命令均退出0;恢复例子的网络断线由本机HTTP服务器真实注入。
@@ -66,9 +66,9 @@
首次定向脚本省去存储测试后忘记单独执行迁移,bootstrap在发放凭据前拒绝,已修正为先运行orbit-migrate。编译时也纠正了示例误用的来源字段名,正式SDK字段为PollRound。这些修正没有改变平台执行或恢复规则。
## 下一步按顺序完成
## 阶段结论与下一步
1. W06/W09/W14/W15已补真实数据库与公共SDK进程断言,并完成标准全量回归,见[专项回归](phase-4-5-races.md)。原四项缺口已关闭,后续按剩余生产故障推进。
2. 据这些结果再决定P4/P5功能阶段是否可验收,不能把本报告中的部分验证改成全项完成。
3. 进入P6统一观测、凭据轮换、历史保留/归档、故障切点工具、容量负载、备份恢复与滚动升级;继续保留A04精确未派达、MySQL切换、跨故障域等原门槛。
2. W01/W13已补真实COMMIT前后断线,W16已补最终信号/旧PENDING重放/下一轮竞争;本报告原功能表、故障表及两批完整回归共同支持P4/P5平台功能阶段验收。此次完整标准回归结果见[故障边界报告](phase-6-fault-boundaries.md),历史记录不覆盖为新数字。
3. P6已完成上述首批故障工具(包括A04未送达),下一步为统一观测、凭据轮换、历史保留/归档、容量负载、备份恢复与滚动升级;MySQL切换、跨故障域等原门槛继续保留。
4. P7补实际文件转换等仍缺的原场景、SDK版本发布与新接入者全量核对。百万企业支持能力只能根据实际工作负载和目标拓扑验证,当前不作容量承诺。

View File

@@ -0,0 +1,74 @@
# P6 首批:派发未送达与事务提交故障边界
日期:2026-09-18。本批补A04、W01/W13的精确故障切点,并补W16最终回执、旧查询结果与下一轮唤醒的竞争。工具只编译进测试二进制,生产服务、SDK运行代码、接口、模型和迁移不增加故障开关;数据库继续禁止外键。
## A04:派发已提交,Worker尚未收到
[公共SDK进程测试](../../sdk/go/dispatch_process_integration_test.go)使用[真实WS故障网关](../../sdk/go/worker_integration_test.go)。网关已从真实Orbit收到`task.assign`帧,但在转交业务Worker之前截留它,并阻止重连。
测试通过独立HTTP连接确认同一任务已处于LEASED、存在一次持久尝试、authorized_at为空。Worker实际处理器调用数仍为0。随后强杀原Orbit并Wait确认退出;该帧始终没有交给Worker,网关丢弃它。另起真实Orbit节点并按原提交键核对回执。
保持Worker离线,等待原30秒租约自然到期,不修改数据库时钟。新节点将未授权尝试记录为EXPIRED,ended_at不早于原lease_until,原任务回到WAITING且没有未知副作用标记。恢复WS后完成原任务:
- 业务处理器恰好执行1次;总尝试恰好2次,第一次未获授权,第二次SUCCEEDED。
- 沿用原任务、提交回执、总截止和2次尝试预算;RECONCILE_ONLY策略保持。
- 并发上限为1的队列可重新派发,证明旧预占已释放;完成后的未终结任务额度为0。
这条证据针对“未送达业务Worker”。帧已到测试网关,不声称平台从未向网络写过该帧,也不将已送达但未开始的场景混为同一切点。SDK不获得网关保存的旧执行秘密。
## W01/W13:MySQL COMMIT前后真实断线
[MySQL包代理及八个场景](../../internal/storage/mysql/commit_fault_integration_test.go)只允许连接一次性、明文、loopback测试数据库,不修改生产TLS配置,不输出SQL、凭据或业务正文。按MySQL包头逐包转发,识别真实COM_QUERY COMMIT:
| 切点 | 代理实际动作 | 独立数据库连接应观察到的事实 |
| --- | --- | --- |
| BEFORE | 不把COMMIT转交MySQL,关闭连接 | 尚未提交的状态、任务、身份和消费确认全部回滚 |
| AFTER | 收到真实MySQL成功包,核验IN_TRANS已清除;不把确认交给驱动,关闭连接 | 数据已经完整提交,调用方收到连接错误,不能当作回滚后换身份 |
四种事务各覆盖两个切点:启动实例及首批任务、结果消费及创建后继、PENDING结果及建立下一轮等待、下一轮任务及轮次预算递增。故障调用必须返回错误,同时代理必须证明到达所选切点;不能仅因请求报错就推断发生过COMMIT。
通过不经过故障代理的连接读取真实表,检查实例/任务数量、Inbox处理确认、查询等待或轮次原子性。随后使用新运行器处理原候选,或用原启动键重报。最终每个场景恰好两个任务和两次尝试,无待处理Inbox或许可遗留,原版本、原流程/查询截止及尝试预算保持。PENDING等待仍从原结果事件时间计算,不从重报时间重新开始。
这证明数据库连接在提交边界断开的恢复行为,不等于MySQL进程宕机、复制切换、丢失已确认事务或磁盘故障验收。它也不将HTTP回执丢失冒充数据库提交结果不明。
## W16:最终回执、旧查询结果、下一轮同时竞争
[六种入队顺序](../../internal/storage/mysql/workflow_final_signal_race_integration_test.go)使用真实MySQL锁等待确认三个事务都已重叠,再等原下一轮时间自然到达后放行。参与者分别是最终DONE信号受理、上一轮PENDING结果的重复消费、下一轮时钟。
上一轮必须已经保存等待,下一轮才可能到期。因此此处结果参与者是旧结果重放,测试明确保留这个因果关系,不构造不可能同时存在的“未提交PENDING和已到期下一轮”。已授权查询与新结果、冲突/未知结果的处理另由[现有信号运行回归](../../internal/storage/mysql/workflow_signal_runtime_integration_test.go)及真实进程恢复覆盖。
观察到两类合法顺序:最终信号先受理时,不创建第二轮;下一轮先创建时,最终信号取消尚未授权的第二轮查询并收集其结果。两者都只执行第一轮查询和一个后继任务,只完成一个等待。再次投递旧PENDING和旧时钟不能重新打开轮次或多建后继;原信号回执、版本、截止和精确输出保持。
## 复现和验证
```sh
make fmt test vet build
make integration
```
标准脚本的存储组自动执行`TestIntegrationWorkflowCommitFaults`与`TestIntegrationWorkflowFinalSignalRace`;原服务退出后的Worker进程组自动执行`TestIntegrationWorkerProcessUndeliveredAssignment`。无需额外生产依赖或手工开启故障环境变量。
定向环境:macOS arm64、Go 1.27.1、Docker MySQL 8.4.10、独立临时数据库及真实Orbit进程。最终定向存储组14个子场景退出0(21.100秒),A04进程组退出0(34.080秒)。MySQL驱动的预期EOF来自代理确实切断COMMIT连接,不是测试失败。首次编译纠正了测试误用的队列资源方法,改用已公开的GetQueue;未因此增加接口或扩大发放权限。
基础`make fmt test vet build`退出0。最终标准全量回归退出0:23个可运行SDK示例、2个独立业务模块,以及完整存储、协议与真实进程恢复链路均通过。标准脚本使用竞态检测;本批测试源码自启动完整回归后未再修改。
| 标准回归分组 | 结果与测试耗时 |
| --- | --- |
| 存储及迁移全部场景 | 退出0,266.966秒;包含八个COMMIT切点及六个W16入队顺序 |
| 原流程运行器进程全停/接管 | 退出0,49.022秒 |
| 独立业务Inbox | 退出0,35.638秒 |
| 独立业务Outbox | 退出0,34.167秒 |
| Worker/结果订阅进程故障 | 退出0,128.097秒;包含A04自然租约到期场景32.72秒 |
| 公共SDK流程暂停全停/恢复 | 退出0,13.845秒 |
以上耗时仅用于复现核对,不是吞吐或延迟指标。无外键升级回归同时确认:历史70个外键全部移除,323个原索引组成部分及任务数据保留,当前外键为0,重新引入外键的结构漂移被拒绝。模型和中文注释要求没有改变。
本批证据与[前一批竞争/全停回归](phase-4-5-races.md)共同补齐P4/P5功能阶段核对所列的具体缺口;[P4/P5报告](phase-4-5.md)据原功能清单记录阶段验收通过。P6进行中,P7及完整首版交付尚未完成。
最终文档检查:1184个本地链接、68个JSON块,错误0;`git diff --check`通过。
## 保留的生产门槛
本批关闭上述具体切点缺口,不代表故障模型或负载已穷尽。完整首版仍需运行指标/告警、可轮换凭据及安全重绑、历史保留/归档、慢消费者和重连风暴、六类容量负载、MySQL失联/主节点切换、备份恢复、跨故障域部署、SDK发布兼容和完整接入示例。
阶段范围和下一步见[持续计划](../08-development-plan.md)、[P4/P5核对](phase-4-5.md)与[原生产验收门槛](../05-production-acceptance.md)。当前单机数据库证据不能换算成生产HA、RPO/RTO或百万企业容量承诺。

View File

@@ -0,0 +1,341 @@
package mysql
import (
"bytes"
"context"
"io"
"net"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/weave/instance"
driver "github.com/go-sql-driver/mysql"
)
// commitFaultProxy 只存在于测试二进制。代理原始MySQL包,不保存SQL、凭据或业务正文。
// BEFORE在COMMIT发往MySQL前断线;AFTER收到真实MySQL成功包后、转交驱动前断线。
type commitFaultProxy struct {
mode atomic.Int32
reached chan string
mu sync.Mutex
closed bool
active map[net.Conn]struct{}
wg sync.WaitGroup
}
func readFaultPacket(c net.Conn) ([]byte, error) {
var header [4]byte
if _, err := io.ReadFull(c, header[:]); err != nil {
return nil, err
}
size := int(header[0]) | int(header[1])<<8 | int(header[2])<<16
packet := make([]byte, size+4)
copy(packet, header[:])
_, err := io.ReadFull(c, packet[4:])
return packet, err
}
func newCommitFaultProxy(t *testing.T, dsn string) (*commitFaultProxy, string) {
t.Helper()
cfg, err := driver.ParseDSN(dsn)
if err != nil {
t.Fatal("invalid disposable DSN")
}
host, _, err := net.SplitHostPort(cfg.Addr)
if err != nil || cfg.Net != "tcp" || host != "127.0.0.1" || cfg.TLSConfig != "" {
t.Fatal("packet faults require disposable plaintext loopback MySQL")
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
p := &commitFaultProxy{reached: make(chan string, 1), active: map[net.Conn]struct{}{}}
p.wg.Add(1)
t.Cleanup(func() {
listener.Close()
p.mu.Lock()
p.closed = true
for c := range p.active {
c.Close()
}
p.mu.Unlock()
p.wg.Wait()
})
target := cfg.Addr
go func() {
defer p.wg.Done()
for {
down, err := listener.Accept()
if err != nil {
return
}
p.mu.Lock()
if p.closed {
p.mu.Unlock()
down.Close()
return
}
p.active[down] = struct{}{}
p.mu.Unlock()
p.wg.Add(1)
go func() {
defer p.wg.Done()
defer down.Close()
defer func() { p.mu.Lock(); delete(p.active, down); p.mu.Unlock() }()
up, err := net.DialTimeout("tcp", target, 3*time.Second)
if err != nil {
return
}
defer up.Close()
var awaiting atomic.Bool
clientDone := make(chan struct{})
go func() {
defer close(clientDone)
defer up.Close()
for {
packet, err := readFaultPacket(down)
if err != nil {
return
}
if len(packet) > 5 && packet[3] == 0 && packet[4] == 3 && strings.EqualFold(strings.TrimSpace(string(packet[5:])), "COMMIT") {
switch p.mode.Swap(0) {
case 1:
p.reached <- "BEFORE"
return
case 2:
awaiting.Store(true)
}
}
if _, err = io.Copy(up, bytes.NewReader(packet)); err != nil {
return
}
}
}()
for {
packet, err := readFaultPacket(up)
if err != nil {
break
}
if awaiting.Swap(false) {
// COMMIT成功包:序号1、OK、0受影响行、0插入ID,IN_TRANS标志已清除。
if len(packet) >= 11 && packet[3] == 1 && packet[4] == 0 && packet[5] == 0 && packet[6] == 0 && packet[7]&1 == 0 {
p.reached <- "AFTER"
} else {
p.reached <- "UNEXPECTED_COMMIT_RESPONSE"
}
break
}
if _, err = io.Copy(down, bytes.NewReader(packet)); err != nil {
break
}
}
down.Close()
up.Close()
<-clientDone
}()
}
}()
cfg.Addr = listener.Addr().String()
return p, cfg.FormatDSN()
}
func (p *commitFaultProxy) expect(t *testing.T, after bool, err error) {
t.Helper()
if err == nil {
t.Fatal("COMMIT wire cut was reported as success")
}
want := "BEFORE"
if after {
want = "AFTER"
}
select {
case got := <-p.reached:
if got != want {
t.Fatalf("wrong COMMIT boundary: %s", got)
}
case <-time.After(time.Second):
t.Fatal("COMMIT fault not reached")
}
}
func TestIntegrationWorkflowCommitFaults(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 24, 6)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err = Migrate(ctx, db); err != nil {
t.Fatal(err)
}
for _, after := range []bool{false, true} {
mode := "before"
if after {
mode = "after"
}
for _, boundary := range []string{"start", "successor", "poll_wait", "poll_next"} {
t.Run(boundary+"_"+mode+"_commit", func(t *testing.T) {
f := newClockFixture(t, ctx, db)
spec := runtimeSpec(f.resources.QueueID, 2)
if strings.HasPrefix(boundary, "poll_") {
spec = pollingSpec(f.resources.QueueID)
}
grantWorkflowInstance(t, f)
input := publishInstanceSpec(t, f, spec)
key := string(executionID(t))
api := instance.New(NewWorkflowInstanceStore(db))
var original instance.StartResult
var c workflowCandidate
var firstTask core.ID
var originalNext, pollDeadline, expectedNext time.Time
if boundary != "start" {
original, err = api.Start(ctx, f.token, key, input)
if err != nil {
t.Fatal(err)
}
ref := retryStart(t, f)
firstTask = ref.TaskID
if boundary == "successor" {
retryFinish(t, f, ref, "SUCCEEDED", false)
} else {
finishQueryOnly(t, f, ref, "PENDING")
}
c = runtimeInbox(t, f, ref.TaskID)
if strings.HasPrefix(boundary, "poll_") {
if err := db.QueryRowContext(ctx, `SELECT deadline_at FROM weave_poll_states WHERE namespace_id=? AND workflow_id=?`, f.resources.NamespaceID, original.Instance.ID).Scan(&pollDeadline); err != nil {
t.Fatal(err)
}
var occurred time.Time
if err := db.QueryRowContext(ctx, `SELECT e.occurred_at FROM weave_inbox i JOIN orbit_events e ON e.namespace_id=i.namespace_id AND e.id=i.event_id WHERE i.namespace_id=? AND i.id=?`, f.resources.NamespaceID, c.id).Scan(&occurred); err != nil {
t.Fatal(err)
}
expectedNext = minimumTime(occurred.Add(time.Second), pollDeadline)
}
if boundary == "poll_next" {
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
c = clockCandidate(t, f, original.Instance.ID, "query")
if err := db.QueryRowContext(ctx, `SELECT next_poll_at FROM weave_poll_states WHERE namespace_id=? AND workflow_id=?`, f.resources.NamespaceID, original.Instance.ID).Scan(&originalNext); err != nil {
t.Fatal(err)
}
waitClockDue(t, f, c)
}
}
proxy, proxiedDSN := newCommitFaultProxy(t, dsn)
faultDB, err := Open(ctx, proxiedDSN, 2, 1)
if err != nil {
t.Fatal(err)
}
defer faultDB.Close()
if after {
proxy.mode.Store(2)
} else {
proxy.mode.Store(1)
}
if boundary == "start" {
_, err = instance.New(NewWorkflowInstanceStore(faultDB)).Start(ctx, f.token, key, input)
} else {
_, err = newRuntime(t, faultDB).process(ctx, c, false)
}
proxy.expect(t, after, err)
// 回读通过直接连接,与故障连接、其内存回执和连接池完全独立。
if boundary == "start" {
want := 0
if after {
want = 1
}
if f.count("weave_instances", "1=1") != want || f.count("pulse_tasks", "1=1") != want {
t.Fatal("partial start transaction")
}
original, err = api.Start(ctx, f.token, key, input)
if err != nil || original.Deduplicated != after {
t.Fatal("original start identity did not reconcile unknown COMMIT", err)
}
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
} else {
wantTasks := 1
if after && (boundary == "successor" || boundary == "poll_next") {
wantTasks = 2
}
if f.count("pulse_tasks", "1=1") != wantTasks {
t.Fatal("partial or duplicated successor transaction")
}
if boundary != "poll_next" {
want := 0
if after {
want = 1
}
if f.count("weave_inbox", "source_task_id=? AND state='PROCESSED'", firstTask) != want {
t.Fatal("inbox ACK disagrees with COMMIT")
}
}
if boundary == "poll_wait" {
want := 0
if after {
want = 1
}
if f.count("weave_poll_states", "current_task_id IS NULL AND next_poll_at IS NOT NULL AND used_rounds=1") != want {
t.Fatal("PENDING result and wait were not atomic")
}
}
if boundary == "poll_next" {
want := 1
if after {
want = 2
}
if f.count("weave_poll_rounds", "1=1") != want || f.count("weave_poll_states", "used_rounds=?", want) != 1 {
t.Fatal("new round and budget were not atomic")
}
if !after && f.count("weave_poll_states", "next_poll_at=?", originalNext) != 1 {
t.Fatal("rollback changed original next wake")
}
}
if _, err = newRuntime(t, db).process(ctx, c, false); err != nil {
t.Fatal("fresh runtime could not reconcile original candidate", err)
}
if boundary == "poll_wait" {
if f.count("weave_poll_states", "next_poll_at=?", expectedNext) != 1 {
t.Fatal("reconciled wait was timed from retry rather than original result")
}
clock := clockCandidate(t, f, original.Instance.ID, "query")
waitClockDue(t, f, clock)
runClock(t, f, clock)
}
}
last := retryStart(t, f)
if strings.HasPrefix(boundary, "poll_") {
finishPoll(t, f, last, "DONE")
} else {
retryFinish(t, f, last, "SUCCEEDED", false)
runtimeConsume(t, f, newRuntime(t, db), last.TaskID)
}
v := runtimeView(t, f, original.Instance.ID, "SUCCEEDED")
if v.DefinitionVersionID != original.Instance.DefinitionVersionID || !v.DeadlineAt.Equal(original.Instance.DeadlineAt) || f.count("pulse_tasks", "1=1") != 2 || f.count("pulse_attempts", "1=1") != 2 || f.used() != 0 || f.count("weave_inbox", "state='PENDING'") != 0 {
t.Fatal("COMMIT recovery duplicated work or reset state")
}
if strings.HasPrefix(boundary, "poll_") && f.count("weave_poll_states", "used_rounds=2 AND current_task_id IS NULL AND resolved_at IS NOT NULL") != 1 {
t.Fatal("COMMIT recovery lost final query budget")
}
if strings.HasPrefix(boundary, "poll_") && f.count("weave_poll_states", "deadline_at=? AND max_rounds=3 AND max_attempts_per_round=1", pollDeadline) != 1 {
t.Fatal("COMMIT recovery reset original poll deadline or attempt budget")
}
replay, err := api.Start(ctx, f.token, key, input)
if err != nil || !replay.Deduplicated || replay.Instance.ID != original.Instance.ID || !replay.Instance.DeadlineAt.Equal(original.Instance.DeadlineAt) {
t.Fatal("start receipt changed after completion", err)
}
t.Logf("%s: real TCP COMMIT %s cut; same identity/candidate resumed; exactly two tasks/attempts and original deadline", boundary, mode)
})
}
}
}

View File

@@ -0,0 +1,107 @@
package mysql
import (
"context"
"fmt"
"os"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/weave/definition"
"git.yuntuo.hk/k2safe/orbit/weave/signal"
)
// W16:上轮PENDING已保存等待后,其旧结果消费可再次被投递;与最终回执、下一轮同时竞争。
func TestIntegrationWorkflowFinalSignalRace(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 32, 8)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err = Migrate(ctx, db); err != nil {
t.Fatal(err)
}
for _, order := range [][3]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}} {
t.Run(fmt.Sprint(order), func(t *testing.T) {
f := newClockFixture(t, ctx, db)
spec := finalSignalSpec(f.resources.QueueID)
tail := runtimeSpec(f.resources.QueueID, 1)
spec.Body.Steps = append(spec.Body.Steps, tail.Body.Steps...)
spec.Body.OutputType = tail.Body.OutputType
spec.Body.Output = definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: "task0"}}
v := runtimeStart(t, f, spec)
grantWorkflowSignal(f)
first := retryStart(t, f)
finishQueryOnly(t, f, first, "PENDING")
oldResult := runtimeInbox(t, f, first.TaskID)
runtimeConsume(t, f, newRuntime(t, db), first.TaskID)
next := clockCandidate(t, f, v.ID, "query")
r := newRuntime(t, db)
api := signal.New(NewWorkflowSignalStore(db))
key := string(executionID(t))
var accepted signal.Result
ops := []func() error{
func() error {
var err error
accepted, err = api.Send(ctx, f.token, v.ID, key, pollSignalInput(v))
return err
},
func() error { _, err := r.process(ctx, oldResult, false); return err },
func() error { _, err := r.process(ctx, next, false); return err },
}
errs := competeWorkflow(t, f, func() { waitClockDue(t, f, next) }, ops[order[0]], ops[order[1]], ops[order[2]])
for _, err := range errs {
if err != nil {
t.Fatal(err)
}
}
// 消费最终信号;若下一轮已经先创建,只取消尚未授权的查询并收集其真实结果。
for i := 0; i < 20 && stopStep(t, f, v.ID); i++ {
}
if f.count("weave_steps", "`key`='query' AND state='SUCCEEDED'") != 1 || f.count("weave_steps", "`key`='task0' AND state='ACTIVE'") != 1 || f.count("weave_waits", "winner_signal_id=? AND state='SATISFIED'", accepted.Signal.ID) != 1 || f.count("weave_signals", "state='CONSUMED'") != 1 {
t.Fatal("final signal did not resolve exactly one wait and successor")
}
var rounds int
if err := db.QueryRowContext(ctx, `SELECT used_rounds FROM weave_poll_states WHERE namespace_id=? AND workflow_id=? AND current_task_id IS NULL AND resolved_at IS NOT NULL AND next_poll_at IS NULL`, f.resources.NamespaceID, v.ID).Scan(&rounds); err != nil {
t.Fatal(err)
}
if rounds < 1 || rounds > 2 || f.count("weave_poll_rounds", "1=1") != rounds || f.count("pulse_tasks", "state='CANCELLED'") != rounds-1 || f.count("pulse_attempts", "1=1") != 1 {
t.Fatal("race duplicated a query or authorized work after final signal")
}
before := f.count("pulse_tasks", "1=1")
if before != rounds+1 {
t.Fatal("multiple successors")
}
// 再送旧结果与旧时钟,不能重新打开轮次或重复创建汇合后的任务。
for _, candidate := range []workflowCandidate{oldResult, next} {
if _, err := newRuntime(t, db).process(ctx, candidate, false); err != nil {
t.Fatal(err)
}
}
if f.count("pulse_tasks", "1=1") != before {
t.Fatal("stale candidate advanced a settled query")
}
last := retryStart(t, f)
if last.TaskID == first.TaskID {
t.Fatal("old query was replayed")
}
retryFinish(t, f, last, "SUCCEEDED", false)
runtimeConsume(t, f, newRuntime(t, db), last.TaskID)
finished := runtimeView(t, f, v.ID, "SUCCEEDED")
if f.used() != 0 || f.count("pulse_attempts", "1=1") != 2 || !finished.DeadlineAt.Equal(v.DeadlineAt) || finished.DefinitionVersionID != v.DefinitionVersionID || !workflowRaceNumber(finished.Output, "9007199254740993") {
t.Fatal("final-signal race reset state or leaked execution")
}
duplicate, err := api.Send(ctx, f.token, v.ID, key, pollSignalInput(v))
if err != nil || !duplicate.Deduplicated || duplicate.Signal.ID != accepted.Signal.ID || duplicate.Signal.State != accepted.Signal.State {
t.Fatal("final signal receipt changed", err)
}
t.Logf("three overlapping transactions order=%v; query rounds=%d; executed queries=1; successors=1; stale PENDING/clock cannot reopen", order, rounds)
})
}
}

View File

@@ -0,0 +1,116 @@
package orbit
import (
"context"
"encoding/json"
"sync/atomic"
"testing"
"time"
)
// A04:数据库已提交派发,但真实WS帧被网关截留,业务Worker从未收到该尝试。
func TestIntegrationWorkerProcessUndeliveredAssignment(t *testing.T) {
a := startOrbitProcess(t)
_, _, token := integrationWorkerClient(t)
api := processClient(t, a, token)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second)
defer cancel()
name := "dispatch.crash." + workerTestID(t)
queue, err := api.CreateQueue(ctx, name, CreateQueueRequest{Name: name, MaxConcurrency: 1, RatePerSecond: 20, MaxUnfinishedTasks: 2})
if err != nil {
t.Fatal(err)
}
in := SubmitTaskRequest{QueueID: queue.Queue.ID, Handler: name, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"n":9007199254740993}`), TaskTimeoutMS: 45000, Retry: RetryPolicy{MaxAttempts: 2, AttemptTimeoutMS: 5000, InitialDelayMS: 100, MaxDelayMS: 100}, RecoverySafety: RecoveryReconcileOnly}
key := workerTestID(t)
accepted, err := api.SubmitTask(ctx, key, in)
if err != nil {
t.Fatal(err)
}
g := newWorkerFaultGateway(t, a.url)
g.holdAssignment.Store(true)
client, err := NewClient(g.server.URL, Options{Token: token, HTTPClient: g.server.Client()})
if err != nil {
t.Fatal(err)
}
var calls atomic.Int32
w, err := NewWorker(client, WorkerOptions{QueueID: queue.Queue.ID, Capacity: 1, ReconnectMaxDelay: time.Second, Handlers: []WorkerHandler{runtimeHandler(name, func(_ context.Context, e WorkerExecution) (json.RawMessage, error) {
calls.Add(1)
return e.Task.Payload, nil
})}})
if err != nil {
t.Fatal(err)
}
run, stop := context.WithCancel(ctx)
defer stop()
done := make(chan error, 1)
go func() { done <- w.Run(run) }()
var held WorkerAssignment
select {
case held = <-g.assignmentsHeld:
case err := <-done:
t.Fatal("Worker stopped before assignment fault", err)
case <-ctx.Done():
t.Fatal("assignment fault was not reached")
}
if held.Task.ID != accepted.Task.ID || calls.Load() != 0 || w.Stats().Started != 0 {
t.Fatal("fault was not before Worker execution")
}
leased, err := api.GetTask(ctx, held.Task.ID)
if err != nil || leased.State != TaskLeased || leased.AttemptCount != 1 {
t.Fatal("dispatch was not durably committed", err)
}
first, err := api.GetTaskAttempt(ctx, held.Task.ID, held.AttemptID)
if err != nil || first.State != AttemptLeased || first.AuthorizedAt != nil || first.EndedAt != nil {
t.Fatal("intercepted assignment already authorized", err)
}
a.kill(t)
close(g.releaseAssignment)
b := startOrbitProcess(t)
if a.nodeID == b.nodeID {
t.Fatal("node identity reused")
}
api = processClient(t, b, token)
receipt, err := api.SubmitTask(ctx, key, in)
if err != nil || !receipt.Deduplicated || receipt.Task.ID != accepted.Task.ID || !receipt.Task.DeadlineAt.Equal(accepted.Task.DeadlineAt) {
t.Fatal("dispatch crash lost admission receipt", err)
}
// 保持Worker离线,等真实原租约自然过期;不使用SQL改时钟或伪造恢复结果。
recovered := waitRuntimeState(t, ctx, api, held.Task.ID, TaskWaiting)
old, err := api.GetTaskAttempt(ctx, held.Task.ID, held.AttemptID)
if err != nil || old.State != AttemptExpired || old.AuthorizedAt != nil || old.EndedAt == nil || old.EndedAt.Before(first.LeaseUntil) || recovered.EffectUncertain || recovered.AttemptCount != 1 || calls.Load() != 0 {
t.Fatal("unreceived assignment recovery fabricated an execution", err)
}
g.target.Store(b.url)
g.offline.Store(false)
finished := waitRuntimeState(t, ctx, api, held.Task.ID, TaskSucceeded)
for w.Stats().ReportsConfirmed != 1 {
select {
case err := <-done:
t.Fatal(err)
case <-ctx.Done():
t.Fatal("result receipt not recovered")
case <-time.After(20 * time.Millisecond):
}
}
stop()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
t.Fatal("Worker did not drain")
}
history, err := api.ListTaskAttempts(ctx, held.Task.ID, HistoryPageRequest{})
if err != nil || len(history.Items) != 2 || history.NextCursor != "" || history.Items[1].ID != held.AttemptID || history.Items[1].AuthorizedAt != nil || history.Items[1].State != AttemptExpired || history.Items[0].State != AttemptSucceeded || history.Items[0].Epoch != held.Epoch+1 {
t.Fatal("dispatch fault duplicated or replaced history", err)
}
if calls.Load() != 1 || w.Stats().Started != 1 || finished.AttemptCount != 2 || finished.EffectUncertain || !finished.DeadlineAt.Equal(accepted.Task.DeadlineAt) || finished.Retry != accepted.Task.Retry || string(finished.Result) != string(in.Payload) {
t.Fatal("dispatch recovery reset budget or executed twice")
}
resources, err := api.GetQueue(ctx, queue.Queue.ID)
if err != nil || resources.UnfinishedTasks != 0 {
t.Fatal("dispatch recovery leaked unfinished task quota", err)
}
t.Log("A04: committed LEASED frame never forwarded to Worker; original node SIGKILL confirmed; natural lease expiry preserved unauthorized history; new node completed original task with exactly one handler call and two budgeted attempts")
}

View File

@@ -53,6 +53,9 @@ type workerFaultGateway struct {
target atomic.Value
dropOffline atomic.Bool
dropCancel atomic.Bool
holdAssignment atomic.Bool
assignmentsHeld chan WorkerAssignment
releaseAssignment chan struct{}
offline atomic.Bool
mu sync.Mutex
active map[*websocket.Conn]struct{}
@@ -66,7 +69,7 @@ func newWorkerFaultGateway(t *testing.T, upstream string) *workerFaultGateway {
func newProtocolFaultGateway(t *testing.T, upstream, path, protocol string) *workerFaultGateway {
t.Helper()
g := &workerFaultGateway{dropped: make(chan string, 4), active: make(map[*websocket.Conn]struct{})}
g := &workerFaultGateway{dropped: make(chan string, 4), active: make(map[*websocket.Conn]struct{}), assignmentsHeld: make(chan WorkerAssignment, 1), releaseAssignment: make(chan struct{})}
g.drop.Store("")
g.target.Store(upstream)
ctx, cancel := context.WithCancel(context.Background())
@@ -150,6 +153,20 @@ func newProtocolFaultGateway(t *testing.T, upstream, path, protocol string) *wor
g.dropped <- "task.cancel"
return
}
if f.Type == "task.assign" && g.holdAssignment.CompareAndSwap(true, false) {
var assignment WorkerAssignment
if json.Unmarshal(f.Body, &assignment) != nil {
return
}
// 真实派发帧只到隔离网关,不向Worker发送;测试确认节点退出后丢弃。
g.offline.Store(true)
g.assignmentsHeld <- assignment
select {
case <-g.releaseAssignment:
case <-bridge.Done():
}
return
}
if f.Type == "ack" && operation != "" && g.drop.CompareAndSwap(operation, "") {
if g.dropOffline.Load() {
g.offline.Store(true)