33 的 JSON DTO能拒绝缺字段和错误类型,`TaskId` 与 `makeTaskDraft` 还能拒绝空白业务值。把这些校验搬进 HTTP 路由以后,`POST /tasks` 返回 202 是否意味着任务已经成功?如果浏览器离开,谁有权取消已经受理的任务?关键不在返回体措辞,而在**请求、后台 Job 和查询记录是否由同一所有者明确管理**。

本篇仍用 Kotlin 2.2.21、coroutines 1.10.2、serialization 1.9.0、Gradle 8.14、JDK 21,新增 Ktor core、Netty、content-negotiation、serialization-kotlinx-json、test-host 的 JVM 制品 3.3.1。源码 tag 3.3.1 SHA 为 49fb2bfb657cc74af6abadae9e11c765234dafa4。官方 Ktor 服务测试和创建服务文档于 2026-10-10 查阅;这些滚动页面当时已是更新版本,具体路径只能以本机 3.3.1 测试和解析树为证。Ktor 新增传递依赖使 JUnit Platform Launcher 实际解析为 1.13.4,虽然项目仍声明旧版 1.12.2;精确解析见 verification/34/test_dependencies.txt。

HTTP 只提供请求/响应层的词汇。POST 在这里表示创建或提交一条任务;GET 表示读取已知 ID 的当前快照;取消也用 POST,因为它改变服务端状态。状态码同样只解释接口层事实:202 Accepted 是服务已接收并交给后台所有者,200 OK 是查询或重复提交得到可返回结果,400 Bad Request 是请求体不能形成合法任务,404 Not Found 是当前索引没有该 ID,409 Conflict 是同 ID 对应了不同草稿。任何一个码都不等于数据库已提交、外部副作用已完成或客户端断开后任务必然继续。

HTTP 回包只报告受理

Application.taskHttpRoutes(registry) 暴露三条路由:POST /tasks 接收 TaskRequestDto 并先 toDraft() 校验,GET /tasks/{id} 查询内存状态,POST /tasks/{id}/cancel 请求取消。首次合法提交得到 202,回包中的 Queued 是注册时的初始快照,不是客户端收到字节那一刻的最新状态。实际执行可能早已进入 Running,甚至完成;需要再 GET,而不能用 202 推断业务成功。

1
2
3
POST /tasks ── 校验 DTO ──> 注册内存条目 ──> 202 Accepted
│
└─ 服务所属 Job 运行 ──> GET 查询当前状态

测试应用里,工作步骤先通知“已开始”,再停在 CompletableDeferred 闸门。此时 POST 已收到 202,GET 可返回该任务,但闸门尚未释放;放闸并等待 Job 后,查询才出现 Succeeded。原始记录 HTTP testApplication accepted_before_completion=true ... completed_after_gate=true 因而区分了接受请求和执行完成,不用固定 sleep 猜处理时长。

这里的任务没有写入已有的 TaskStore,也没有 JDBC。TaskHttpRegistry 单独维护 id → 草稿/Job/状态 的内存映射,执行步骤是可注入的合成函数;这让“已受理但尚未完成”的窗口可观测,却不提供重启恢复。网页断开后服务端是否继续执行,必须由服务持有者的 Job 生命周期负责,而不是从 HTTP 接口的响应码推测。

重试同一个 ID 会怎样

在同一服务实例的内存映射中,同 ID 的相同有效草稿再次提交返回 200,accepted=false,不启动第二个 Job;同 ID 但文本不同返回 409。测试记录同一个 ID 重试后的实际执行次数为 1。无效 JSON/空白任务为 400,未知 ID 的查询返回 404。重复判断保护的是单进程内已标准化的 TaskDraft,不是原始 JSON 字节级比较:例如当前 DTO 的 note 并未进入草稿,因而更改 note 不构成新的业务任务。这里没有为该字段承诺存储或幂等签名。

路由调用方遇到 202 后应保存 ID,再用 GET 查询;遇到 200 的复用结果也必须看状态,不能假设之前任务已成功。映射在进程退出后消失,两个服务实例之间没有共享去重或数据库唯一约束。原始断言 duplicate=200 conflict=409 invalid=400 只适用于这台服务实例与已测试的顺序请求;真实并发跨进程重试和中间代理恢复均 NOT_RUN。

取消请求返回前等到了什么

POST /tasks/{id}/cancel 对已存在任务执行 job.cancelAndJoin(),等此次合成挂起步骤进入 finally 才返回 Cancelled。另一条独立任务没有被取消,测试还确认它能完成。输出 HTTP cancellation_joined_cleanup=true unrelated_task_succeeded=true 证明的是此处的协作取消与内存任务隔离,不能推出未遵守取消协议的同步 JDBC、网络驱动或外部进程已经停止。任务完成与取消竞争、取消晚到后的幂等语义仍要在真实持久服务中重新审定。

任务由 TaskHttpRegistry 创建的 SupervisorJob + dispatcher 所有,与单个请求协程不是父子关系;测试退出时显式 registry.stop()。这把后台任务从 HTTP 请求的终止中分开了,但也让服务的停止责任不能遗漏:若部署进程不关闭所有者,作业不会因连接关闭自动得到清理。生产级“停止接单—排空/取消—关闭资源”属于 38,不由这个测试服务背书。

testApplication 不是实际监听

testApplication 走 Ktor 的测试环境及内存客户端,能精确放闸,比较 202 与最终状态,测试 200/400/409/404 和取消清理。但它不能单独证明 TCP 客户端能连接。另一用例启动实际 Netty 回环监听,使用 JDK HttpURLConnection 对 127.0.0.1 发出 POST,得到 202;等合成任务完成后,GET 得到 200 和 Succeeded,原始输出 REAL_HTTP loopback_post=202 query=200 state=Succeeded。这是真实端口,不是公网部署。

真实端口上尝试过让后台任务长时间等闸、并在此期间让客户端退出,出现多次请求超时;JDK HttpClient 与 HttpURLConnection 都曾在第一次 POST 等不到响应。独立只跑真实端口用例通过;全量回归恢复无长时间挂起的真实端口路径后通过。该故障链路未完成根因归因,verification/34/build-initial.txt 只保留了失败诊断摘录,最初的完整失败 XML 被后续测试覆盖,不能冒称保留了全部失败原始输出。因此“TCP 连接在请求途中断开后,已提交任务一定继续运行”在本篇仍是 NOT_RUN。接口契约选择由服务所有者管理已受理任务;这里只确证在测试应用里响应早于任务完成及真实端口的普通成功路径。端口来自先取系统空闲端口再绑定的测试方式,取端口与再次绑定之间仍存在竞争窗口,不能拿一次运行代表无竞争的部署方案。

确认点 实测结果 尚不能推断
POST 返回 202 测试应用先响应、工作等闸;真实端口常规请求也返回 202 后台任务已完成或持久化
GET /tasks/{id} 已注册任务可查询,放闸后 Succeeded 进程重启后仍可查
重复/冲突提交 同 ID 同草稿 200,不同草稿 409 跨进程或跨数据库幂等
POST 取消 协作步骤 finally 已运行,其他任务继续 任意阻塞驱动已停止
客户端中途断开 NOT_RUN,真实端口闸门实验超时未归因 不能以测试应用替真实断线背书

练习与答案

练习一:同一个 ID、同一个任务文本、不同 note 再次提交,为什么不是 409?答案:当前草稿没有保存 note,重复判断基于标准化后的业务草稿,不基于原始 JSON 字节。

练习二:把合法提交的 202 改成 200,能否让后台任务变成已完成?答案:不能。状态码只能改变接口表达,后台 Job 的完成点仍由查询和任务所有者管理。

练习三:客户端在收到 202 前断开,能否按本篇证据宣称任务继续运行?答案:不能。这条真实端口断线链路未完成归因,仍是 NOT_RUN;服务设计上选择后台所有者,不等于实验已经覆盖断线。

运行 bash examples/kotlin-language/verification/34.sh 会分别运行普通 test 与隔离测试 JVM 的 loopbackTest,随后检查 36 组累计 JUnit 与 CLI 正反例;两层原始 XML 分别在 TaskHttpRoutesTest.txt 和 TaskHttpLoopbackTest.txt,build-initial.txt 留下先前编译/依赖/真实端口超时失败条件。隔离进程是为了避免已复现的混跑超时,不代表已证明混跑干扰的根因。累计工程归档包含 Wrapper、Ktor 配置和既有证据,解包步骤见同名素材 README。下一篇才将内存索引接到真实关系数据库,用独立连接检查事务提交、回滚及重启读取;现阶段没有任何 JDBC 实验成功声明。