同一个 submit 调用有三种不同的坏消息:文本为空,任务尚未创建;任务已经开始,执行结果是失败;提交时存储接口抛了异常,调用方甚至不知道本次写入是否成功。如果一律返回 null,调用方既不能给出准确提示,也无法决定能否重试。本篇沿用 12 的同步任务状态,只处理提交决策,不把已经运行的任务状态塞进提交结果。

实验仍是 Kotlin/JVM 2.2.21、JDK 21、Gradle Wrapper 8.14,内存 TaskStore 和一个注入故障的替身。runCatching 的宽捕获范围尤其值得留意:这里没有协程,不能把这段同步包装照搬到取消路径。

提交结果描述调用方能采取的动作

先看一条具体路径:任务 ID task-1、文本 alpha 已存在。再次提交相同文本,调用方可解释为“已有相同内容”;换成 other,则要明确拒绝,不能假称请求已被复用。如果 ID 或文本全为空白,也应该在接触存储之前返回拒绝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sealed interface SubmissionDecision {
data object Accepted : SubmissionDecision
data object Reused : SubmissionDecision
data class Rejected(val reason: String) : SubmissionDecision
}

fun submitValidated(store: TaskStore, id: String, text: String): Result<SubmissionDecision> =
runCatching {
if (id.isBlank() || text.isBlank()) {
return@runCatching SubmissionDecision.Rejected("Task ID and text must not be blank")
}
val existing = store.find(id)
when {
existing == text -> SubmissionDecision.Reused
existing != null -> SubmissionDecision.Rejected("Task ID already belongs to another text")
store.putIfAbsent(id, text) -> SubmissionDecision.Accepted
store.find(id) == text -> SubmissionDecision.Reused
else -> SubmissionDecision.Rejected("Task ID already belongs to another text")
}
}

第一层 Result 回答“提交调用能否正常得到决策”,第二层 SubmissionDecision 回答“正常处理后接受、复用还是拒绝”。Rejected 是预期的业务结果,属于 Result.success;调用方若把所有 Result.success 都当成已创建,仍会误报。这个划分不是 Kotlin 强制的设计,而是本任务接口的约定;Result API 只提供成功值与失败异常的容器,并不知道任务 ID 冲突的业务含义。

写入失败时,复用结论还成立吗

putIfAbsent 返回 false 只能表明本次未插入;若另一条路径在第一次 find 与写入之间放了不同文本,无条件返回 Reused 就是错的。测试替身让第一次读取为空、写入报告失败、第二次读取到 other,断言结果为 Rejected。这是一条人为编排的顺序脚本,能抓住“失败就复用”的判断错误,不能证明真实并发安全。

更重要的是,当前 TaskStore.putIfAbsent 默认实现只是 find 后调用 put,并非一个原子操作;就算补了事后读取,也不能保证“先查后写”不会覆盖另一请求,也无法保证两次读取之间不再变化。到数据库篇须用条件写入/事务重新定义原子性与重试策略。此处只保证顺序场景的返回解释,没有宣称任务写入持久化或线程安全。

执行失败与提交异常不共享一次生命周期

TaskState.Failed("worker rejected task") 来自上一篇 advanceTask(TaskState.Running, TaskSignal.Failure(...)) 的运行后状态转移,属于某个任务的终态。SubmissionDecision.Rejected 则发生在提交阶段,本例未创建可运行任务。两者都可能呈现“失败”字样,但定位和恢复动作不同:输入冲突应修正 ID 或文本;运行失败需要查对应任务的处理结果;提交时存储异常须保留原因和本次提交是否落地的未知性,不能建议无条件重试。

第三种情况使用 UnavailableTaskStore:find 直接抛出预先创建的 IllegalStateException("synthetic store unavailable")。测试断言 result.exceptionOrNull() 与原异常是同一对象,再断言 getOrThrow() 抛出的仍是它,而不是补一个笼统 null。这证实异常能沿接口传递,并不证明外部存储真的断线。runCatching 官方 API 指明捕获任何 Throwable;它只是包装,既不会修复存储,也不会回滚已经发生的外部副作用。日后引入挂起函数时,应另行处理取消传播;不能让通用包装吞掉 CancellationException 再告诉上层“任务正常结束”。

实际验收与复现

在工程目录运行 bash verification/13.sh;脚本执行离线 clean test、逐组读取 JUnit XML、对现有 CLI 的 submit alpha 和缺参数负例留存输出及退出码。SubmissionDecisionTest 两组覆盖非法输入、重复/冲突、顺序任务终态、注入异常同一性与冲突伪竞争。CLI 仍未接入 submitValidated,所以旧 CLI 的正反结果仅用于累计回归,不能证明提交决策已在命令行生效。来源与原始输出位于仓库 writing-plans/kotlin/research/13.md、writing-plans/kotlin/verification/13/;下载含Wrapper、累计工程和原始证据的快照(SHA-256校验),本文同名素材目录有复跑命令。

练习:若存储故障发生在 put 写入成功之后、返回之前,收到异常是否可以改判 Rejected?不能。异常说明调用没有正常得到决策,不说明写入必然没发生;需要明确的幂等键、原子写入/查询约定和持久化证据才能判断。若任务已经 Failed,重新提交同一 ID 是否自动让它变成 Queued?当前只做 ID 与文本比较,状态转移函数拒绝终态出边,两者之间没有自动重启通道。

[PATTERN] 为每种结果标出发生阶段、谁负责恢复以及未知项:输入拒绝是提交决策;运行失败是任务状态;基础设施异常是调用失败。保留这三个维度,比一个泛化的 null 更容易追踪错误边界。

观察 当前动作 本章未证明
Result.success(Rejected(...)) 修改输入/冲突项 已创建后台任务
TaskState.Failed(reason) 查任务终态及失败原因 提交失败或自动重试
Result.failure(cause) 留存异常,先核对写入结果 原子写入、回滚或外部操作停止

下一篇用扩展与作用域函数格式化这些结果,检验接收者的静态类型怎样影响调用目标。当前服务、协程与数据库均未运行。