此前的实验分开验证了 JSON 领域校验、Ktor 回环、协程任务树与真实 PostgreSQL:34的 HTTP registry 仍在内存里,35的 JDBC Store 没有接到这个路由。把两篇测试的 PASS 拼在一起,不能推出“一条 HTTP 请求提交后重启仍能查询”。39 改为同一组隔离 PostgreSQL 数据目录和两个先后启动的真实 Netty JVM,逐项询问:202 出现前是否已提交、Job 失败留下什么、数据库断线怎样返回、重启后 GET 读到的是状态还是正在运行的任务。

本轮保持 Kotlin 2.2.21、Gradle 8.14、JDK 21、coroutines 1.10.2、Ktor 3.3.1、JDBC 42.7.7 与专用 PostgreSQL 16.15。2026-10-10 核对 Ktor 服务测试、PostgreSQL 16 INSERT/UPDATE 与 JDK 21 Connection;精确运行版本和原始响应见 writing-plans/kotlin/verification/39/。只使用本地合成请求,测试初始化自己的数据库进程,不访问默认集群或其他会话的数据库。

202 在数据库提交的哪一边

发行包的 HTTP 服务现在可通过显式 TASK_DB_URL 接入 TaskJdbcStore;没设置该变量时仍是 38 的纯内存示例,不会悄悄连上某个本机数据库。POST /tasks 先把 JSON 解码并构造成领域 TaskDraft,再通过专用单线程 JDBC dispatcher 执行 INSERT、提交和连接关闭,成功后才让 registry 受理并返回 202。新路径用协程 Mutex 序列化同一进程内的持久提交,数据库主键拒绝跨进程冲突;旧的 CLI submit alpha 仍只做输入校验,不启动服务或落库。

测试的 success-39 经真实回环 POST 得 202,随后从另一个数据库连接读取刚提交的记录,再轮询 GET 直至 Succeeded;SQL state 为 Succeeded:completed:success-39。工作完成时的状态更新是独立的条件更新:只有数据库行仍为 Running 才能写终态。这样“事务提交 Queued”和“任务后来完成”不会被伪装成一个跨整个 Job 的长事务。输出里的 committed_before_202=true 同时有代码执行顺序和返回后独立读取断言,不只凭 202 猜测 SQL 已成功。

如果 INSERT 已提交,而进程在注册内存 Job 或返回 202 之前崩溃,数据库可能留下 Queued,客户端却没收到成功。受理请求的协程若在 INSERT 的返回派发阶段被取消,也可能留下相同窗口;后台任务终态竞争的测试不覆盖这个请求受理窗口。受控杀进程和该受理取消实验仍标 NOT_RUN。当前 SQL 只存合成任务文本和简化状态,未保存全部 labels、失败详情或外部副作用;Succeeded: 的字符串拼接是本实验的最小编码,不是完整的持久化 schema。数据库状态与执行状态并不天然同构。

每次只改变一个条件

同一隔离数据库和 HTTP 路由运行下面的条件切换;各项断言在 TaskEndToEndTest 的 JUnit XML 与后台进程原始日志中,不能将测试替身当网络端口:

条件 HTTP 与数据库的观察 没有据此证明的事
空白任务文本 400;查不到 bad-39 行 所有外部客户端都安全校验
正常提交 202,马上能独立读取;最终 GET/SQL 为 Succeeded 202 就是 Job 已完成
同一进程内同 ID 同草稿 / 文本冲突 200 复用 / 409 冲突;没有重跑旧 Job 跨进程恰好一次;重启后自动复用旧请求
执行步骤显式抛错 先 202,后 GET 与 SQL 为 Failed 错误消息已完整持久化
任务挂起后发取消 / 已完成后发取消 前者 Cancelled;后者保持 Succeeded,SQL 不被改写 所有纳秒级竞态与外部副作用都原子化
停止数据库进程 新 POST、已有任务 GET 均 503;没有 outage-39 行 进程外数据库故障自动重试成功
数据库与 JVM 先后重启 新进程 GET 能读到成功、失败、取消终态;再提交旧 ID 为 409 自动恢复执行中 Job

39 原始快照的取消行只验证两个受控先后顺序:wait 步骤实际挂起时取消,完成后再次取消。写终态的 SQL 带 WHERE state = 'Running',取消可把 Queued/Running 转 Cancelled,让数据库终态不会被后来一次无条件覆盖。最新累计工程另外用 JDBC 工作块内的同步点检查 SQL 已写入终态、协程尚未从 withContext 返回的取消竞争;下面单独说明这个窗口。提交成功后、响应到达客户端前 的网络断开和跨客户端 exactly-once 仍不在这些实验的保证内。

数据库故障不是事后手工修改错误状态:测试真实停掉该临时 PostgreSQL 进程,服务器仍活着,新提交无法先落库,返回 503;先前状态查询同样 503。pg_ctl 启动后,再从独立连接读到原来的行,outage-39 不存在。数据库关闭时如果正在执行阻塞 JDBC,这里的取消与超时行为仍不能推广到任意驱动/故障方式。35 的 Statement.cancel() 只证明一个受控长 SQL 的驱动能力。

SQL 成功与协程成功为什么可能分开

withContext(jdbcDispatcher) 把同步数据库工作交给另一条线程。SQL 成功并提交后,协程仍要把结果派发回调用方;如果调用方 Job 在这一步已经取消,返回阶段可以抛 CancellationException,即使数据库操作已成功。取消不会撤销已经提交的数据库事务。withContext 的返回取消规则与 JDBC 的提交语义是两个不同的完成点。

若此时取消处理直接把内存状态写成 Cancelled,数据库可能保留 Succeeded,而关闭日志输出 Cancelled。最新实现先在 NonCancellable 清理块中尝试条件取消,只允许 Queued/Running 转成 Cancelled,再从数据库读取实际终态来同步内存。已经提交的 Succeeded/Failed 保留原值;Job 的完成回调也只把非终态改为取消。Job 可以以取消结束,但业务行仍表示工作已经成功完成。

竞争测试的同步点必须在 JDBC executeUpdate() 成功之后、退出 withContext 之前。测试暂停这条线程,确认实际 Job 已收到取消,再放行结果返回;随后分别核对 SQL 行、registry 内存、取消响应、GET 和关闭后的快照。如果只等固定的 100ms,或者把闸门放到 withContext 已返回之后,就不能证明覆盖了上述窗口。这个同步点验证的是一个确定交错,并不证明所有数据库故障或所有网络竞争都已消失。

在同一受控交错下,将取消分支恢复成无条件写内存 Cancelled 的反例实现,测试实际失败为 expected Succeeded(result=done), actual Cancelled;当前分支通过。最新真实进程实验还让 shutdown-39 保持 Running,发送终止请求后检查 req-39-shutdown、同一任务 ID、TASK_FINAL ... state=Cancelled 和独立 JDBC 查询的 Cancelled,再确认工作执行器、JDBC 执行器与引擎退出。原始 XML 和子进程日志见仓库的 writing-plans/kotlin/completion-evidence/。

有界受理与有界线程分别限制什么

最新 task-server 将活跃任务受理上限设为 16;工作与 JDBC 执行器各为一条线程、64 个排队位置。任务取得受理许可后才能 INSERT 和创建后台 Job,许可直到 Job 完成才释放;容量满时返回 503,新任务没有数据库行,也没有 registry 条目。重复请求和文本冲突仍走各自的 200/409 规则,不能为躲过容量上限而另建同 ID 的工作。

这三项限制分别限制活跃 Job、等待工作线程的 Runnable、等待 JDBC 线程的 Runnable。它们不是数据库连接池,也不限制所有已保存终态记录或所有 HTTP 请求的数量。asCoroutineDispatcher() 遇到执行器拒绝时会取消对应 Job,并可能把清理交给 Dispatchers.IO;因此不能把队列长度当成“所有清理永远只在专用线程执行”的证明。拒绝路径、许可释放与新请求不落库需要分别断言,生产流量的吞吐与内存上限仍需独立压测。

真实 JDBC 取消也须分三次观察:取消 Job 后查询是否还在 PostgreSQL 中执行,连接是否已关闭,Job 是否已完成;再调用驱动 Statement.cancel(),观察 SQLSTATE 57014、活跃查询消失和 use 关闭连接。取消信号本身不能替代这三条证据,NonCancellable 也不会自动终止同步 SQL。

练习:按完成点判断终态

让 finish 的 SQL 已将任务写为 Succeeded,但 JDBC 线程仍停在返回前闸门。此时取消 Job,再放行线程:Job 与业务状态各应是什么?如果将取消 SQL 改为没有条件的 UPDATE,会损失什么信息?将容量设为 1,先让一个任务挂起,再提交另一个 ID,第二个请求应留下哪些记录?

答案:Job 可以已取消,业务行、内存快照和查询结果仍应保留 Succeeded;已提交的结果不能由后到的取消覆盖。无条件 UPDATE 会把成功终态改写成取消,丢失“工作已提交完成”的事实。容量为 1 且第一个 Job 尚未完成时,第二个请求应得到 503,不 INSERT、不创建后台 Job;释放首个任务后许可恢复。202 仍只表示已经受理,不是最终成功回执。

重启恢复的是数据库行,不是 Job

服务的 Job 所有者属于进程内 SupervisorJob。外部测试在所有受控任务落终态后停第一个真实 JVM,再对同一个数据库目录执行一次实际停启,启动第二个干净的服务进程。新进程内没有旧的 Job,但 GET 会从 JDBC 读已保存的 Succeeded、Failed、Cancelled;重发旧 ID 得 409,避免偷偷启动第二个后台任务。无终态的 Queued/Running 行若在异常中断后留下,现有 GET 可能仍返回这个存储值,并不代表新进程有工作协程;自动重试/补偿/人工对账流程尚 NOT_RUN。必须先定义如何处理它们,才谈得上可用的恢复服务。

37 的 JMH/JFR提供同一台 JVM 的局部计算与分配样本,不是这条 HTTP+SQL 路径的请求耗时。真实外网、持久队列、数据库断网中途事务不确定性、服务全链路压测与公网部署均 **NOT_RUN**。这份结课证据说明的是“固定环境下的一条合成任务怎样从请求到已提交终态”,并把无法仅靠语言类型或单次测试保证的部分留作明确边界,而不是将内存演示包装为可靠作业平台。

原 39 的 章节快照 与 发行 ZIP 保留当时实现,配套 verification/39.sh 属于该快照。最新源码改用 完整累计工程、任务服务发行包 与 SHA-256 校验清单;解包后在 kotlin-language 内运行 bash verification/local.sh,使用 JDK 21,并将 KOTLIN_PG_BIN 指向自有 PostgreSQL 工具目录。测试自行创建隔离数据库,普通启动不会替使用者建表或初始化集群。

两个 ZIP 的 HTTP+SQL 模式都需要显式 TASK_DB_URL;未设 URL 时仍是可受理但不持久化的内存示例。源码包还包含 ABI、JS/Wasm、Android 和 Native 的独立入口,必须按指定目标复跑,不能将一次 JVM 通过写成全部平台通过。