任务还在排队,突然收到“完成”;已经标成取消,又收到“成功”。如果状态只是一串可以任意覆盖的字符串,两次写入都可能看起来合法。类型能帮忙列清有哪些状态,但“允许从哪儿走到哪儿”必须另外定义。本篇为同一任务系统规定顺序执行时的状态转移,保留非法转移和遗漏分支的编译反例;真实取消与完成的同时竞争要到后续有闸门和受管任务树时再验收。

沿用 11 的内存 TaskStore,状态还没有写回 Store,也没有接入旧 submit CLI。当前只用同步纯函数 advanceTask 处理本地状态值。Kotlin/JVM 2.2.21、JDK 21、Gradle Wrapper 8.14 的原始实验记录放在 12 证据目录。

sealed 说明分支,负载也属于状态的一部分

状态不是一个随处拼接的字符串:

1
2
3
4
5
6
7
sealed interface TaskState {
data object Queued : TaskState
data object Running : TaskState
data class Succeeded(val result: String) : TaskState
data class Failed(val reason: String) : TaskState
data object Cancelled : TaskState
}

完成结果与失败理由不是同一种数据;因此 Succeeded、Failed 各携带自己的字段,而无需负载的 Queued、Running、Cancelled 只是对象。Kotlin sealed 与 when 文档允许在已知直接子类型范围内让 when 表达式做穷尽检查。负载本身的合法性——例如结果或失败理由是否允许空串——本章没有检验,应在 13 的领域失败契约里继续收紧。

TaskSignal 同样使用 Start、Success(result)、Failure(reason)、Cancel 四个 sealed 分支。传入状态与信号后,纯函数返回新的状态;不触碰数据库,也不启动协程。仅需要一个粗粒度分组时则用 enum class TaskStage { WAITING, ACTIVE, TERMINAL }:Queued 对应 WAITING,Running 对应 ACTIVE,三种终态对应 TERMINAL。Kotlin enum 声明文档是这里的语法依据。enum 分组不替代携带结果/理由的 sealed 具体状态。

when 的穷尽与业务允许的路径是两件事

advanceTask 为 Queued 只接受 Start、Cancel;Running 接受 Success、Failure、Cancel;任何终态再次收到信号都抛 IllegalStateException。因此 Queued 不能直接成功,Running 不能重复 Start,终态不会被晚到的另一种结果覆盖。测试逐一断言了 Queued → Running → Succeeded("indexed"),从 Running 到 Failed("disk"),以及在 Queued 或 Running 取消的路径。

函数的外层 when(state) 和内层 when(signal) 都显式列出类型分支,不靠兜底 else 静默吞掉新增类型。独立于正常源码集的 verification/12-missing-branch.kt 刻意漏掉 Failed:冻结编译器的 compileKotlin 退出 1,实际诊断为 'when' expression must be exhaustive. Add the 'is Failed' branch or an 'else' branch.。这证明类型覆盖能在编译期提示漏项;它并不能自动裁定“排队时不该收到成功信号”。后者由 advanceTask 的显式拒绝规则和运行断言维护。

两种先后顺序,不是多线程竞争测试

顺序实验先从 Running 收到 Cancel 再收到 Success,第二次被拒绝;换过来先 Success 再 Cancel,后一次也被拒绝。这冻结的是一旦进入终态就不覆写的业务规则;测试依次调用两个函数,没有两个同时运行的任务,也没有 compare-and-set、锁或事务,因此完全不能推出真实并发情况下谁先赢、是否会双写外部副作用。27–29 才有条件用闸门、Job 生命周期和共享状态测试竞争;即便那时取得最终状态,也要将后台任务完成与外部副作用分别记账。

不要把这里的 TaskState.Running 误读为协程 Job 的运行态,更不要把 TaskState.Cancelled 当成底层阻塞操作已停止的证据。当前没有 suspend、Dispatcher、Job、线程或网络,只有业务上选定的一次状态转换。24–28 会分别处理挂起、实际等待、取消请求、清理和任务树完成。

同一 ID 重试怎样处理

顺序提交使用 acceptSubmission(store, id, text):ID 未见过时写入文本并返回真;同 ID 同文本再次提交返回假,表示没有新建;同 ID 不同文本抛 IllegalArgumentException,原内容保持不变。测试对合成 ID "task-1" 和 "alpha" 验证这三条路径。这里的真/假不是 HTTP 应答码或任务完成状态,只说明当前单机内存 Store 对一次调用给出的决定。

尤其要注意 11 的 putIfAbsent 是先查后写,本章在它前面还执行了一次 find。多线程同时到达时这些步骤可能交错;本篇没有并发原子性、重启恢复或数据库唯一约束证据,不应写成“已经实现幂等提交”。真正的重复提交边界要在 34 的 HTTP 契约与 35 的数据库约束下重新验收。

本章如何复核

在仓库根目录执行 cd examples/kotlin-language && bash verification/12.sh。脚本保存 UTC 环境、JDK/Gradle 信息与原始退出码,离线 clean test 退出 0,00–12 十三组各 2/2。TaskStateTest 覆盖合法路径、非法路径、两种顺序完成/取消先后、同 ID 重试;隔离 when 编译失败退出 1。旧 submit alpha CLI 退出 0、缺参数退出 1,但 CLI 仍不调用新的状态模型。对应 XML、编译诊断与断言位于 writing-plans/kotlin/verification/12/。

下载含Wrapper、累计工程与12证据的快照(SHA-256校验)。同名素材目录给出实际解包复跑命令;本章没有虚拟时间或真实线程、HTTP、数据库实验。

练习与答案

练习一:新增 TaskState.Paused 但忘记修改 taskStage 会怎样?答:因为该函数使用 when 表达式且没有 else,编译期需补新分支;本章漏 Failed 的隔离样本已经证明这一诊断。补分支之后仍要决定 Paused 允许哪些信号,编译器不会代写业务图。

练习二:为什么 TaskState.Cancelled 不能证明底层任务已经停止?答:它只是同步转移函数返回的业务值;这里根本没有协程或 I/O。真正停止、清理和外部副作用必须分别由任务树、资源释放和独立观测验证。

练习三:同 ID、同文本的第二次 acceptSubmission 返回假,能否宣布并发接口幂等?答:不能。测试仅顺序调用内存 find 和 putIfAbsent;没有并发闸门或数据库原子约束。此处返回假只表示当前单次调用没有新增内存记录。

下一篇拆开非法输入、业务失败与基础设施异常,不用一个 null 或 Result 把所有失败含混地吞掉。