测试进程收到 SIGTERM 时,PostgreSQL 中仍有一个 PgSleep 查询。应用先拒绝新任务,等待已经接受的任务提交,再关闭执行器、连接池和文件。两个故意失败的 stop hook 被记录下来,后面的清理仍然执行。

退出码 143 只说明进程因 SIGTERM 结束。任务是否提交、线程是否终止、文件和池是否关闭,需要各自的终态证据。本实验同时保留生命周期日志和停止后由新连接读取的数据库结果。

自建资源需要明确的持有者

app/lifecycle/ManagedDatabaseJob.java 是单例的有限实验任务持有者。它按需创建一个 scheduler、一个 worker、一个最多两个连接的数据库池和一个 FileChannel,每次只允许一个任务在途。

scheduler 负责安排任务开始,worker 执行真正的同步 JDBC。两者分开后,任务执行不会占住定时器线程,但这个结构仍然需要容量、拒绝和关闭规则;创建两个执行器本身不提供可靠任务系统。

flowchart LR
    Start[接受提交] --> Scheduler[有限调度器]
    Scheduler --> Worker[JDBC worker]
    Worker --> DB[事务与唯一业务键]
    Stop[开始停止] --> Reject[拒绝新提交]
    Reject --> Drain[等待已接受任务]
    Drain --> Close[关闭池与文件]

数据库功能默认关闭时,持有者不会为普通 health 请求创建数据库池。资源初始化失败时也要关闭已经创建的部分;只有完全初始化后才注册依赖这些资源的清理逻辑。

初始化和销毁构成同一个所有权问题:谁创建资源,谁记录它是否已创建,并在错误和正常退出路径都尝试关闭。把清理散落在某个 HTTP controller 的 finally 中,会遗漏没有走到该 controller 的应用停止路径。

stop hook 逆序执行,并等待每个返回值

固定 Play 3.0.6 源码提交 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6 的 ApplicationLifecycle.scala 使用双端队列 push 注册 hook,再逐项 poll 执行。因此后注册的 hook 先运行。

执行过程把同步调用 hook 的异常转换为失败 Future,并对返回 Future 的失败记录日志后继续。下一个 hook 接在前一个的完成之后;一个永不完成的 Stage 仍可能阻止后续 hook,异常会被记录不等于无限等待会自动解除。

实验的资源 hook 按下面顺序注册:最终关闭、异步失败、同步抛出、停止调度与等待在途任务。运行顺序则是等待在途任务、同步失败、异步失败、最终关闭。构造器还注册停止标记 hook,覆盖资源从未初始化的情况。

下面的完整 Java 类只演示注册与观察,不创建任何外部资源。把它注册到应用的 ApplicationLifecycle 后,停止应用可读取事件列表;它本身不负责发起应用关闭。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import play.inject.ApplicationLifecycle;

public final class LifecycleRegistration {
private final List<String> events = new CopyOnWriteArrayList<>();

public LifecycleRegistration(ApplicationLifecycle lifecycle) {
lifecycle.addStopHook(() -> {
events.add("cleanup");
return CompletableFuture.completedFuture(null);
});
lifecycle.addStopHook(() -> {
events.add("async-failure");
return CompletableFuture.failedFuture(
new IllegalStateException("synthetic failed stage"));
});
lifecycle.addStopHook(() -> {
events.add("sync-failure");
throw new IllegalStateException("synthetic thrown hook");
});
lifecycle.addStopHook(() -> {
events.add("drain");
return CompletableFuture.completedFuture(null);
});
}

public List<String> snapshot() {
return List.copyOf(events);
}
}

该类只表达顺序,真实资源验证由 ManagedDatabaseJob 执行。它的 drain hook 返回真实等待结果,最终关闭 hook 则在池关闭失败时仍进入文件关闭的 finally。不能把记录一个 cleanup 字符串当成资源已关闭。

停止标记与提交必须共享边界

只在最后关闭 scheduler 会留下竞态:新请求先把 inFlight 从 0 改为 1,随后 scheduler 拒绝 schedule,计数却没有恢复。此时没有任务真正开始,状态却永久显示一个在途任务。

实现让 start 与 beginStop 共享 synchronized 边界。停止开始先设置 stopping,再等待旧任务;新提交在访问或创建资源之前检查这个标记。已经跨过接收边界的任务属于等待范围,后来的任务得到拒绝。

1
2
3
start:     检查 stopping → 初始化 → 预留 inFlight → schedule
stop: 设置 stopping → 关闭调度入口 → 等待 worker → 关闭依赖
reject: 还原 inFlight → 记录 rejected 终态 → 释放等待者

除了 schedule 本身,scheduler 向 worker 提交任务也可能失败。实现分别处理两处拒绝,恢复 inFlight 并释放开始等待的门闩,避免把提交失败伪装成长期运行中的业务任务。

Java 回归测试先接受一个 700 ms 的真实 SQL 任务,再并发停止应用。观察到 stopping 后,新提交被拒绝;应用停止后再次提交也被拒绝。旧任务最终状态为 committed-inserted-1,inFlight 回到 0。

controller 将调度拒绝映射为 503 DB_STOPPING,但上述竞争由 Java 持有者的直接调用测试。它没有证明已经关闭监听端口的服务器还能接收 HTTP 请求,更不能把连接拒绝与应用返回 503 混为一谈。

在途数据库任务遇到真实 SIGTERM

HTTP 驱动通过 /db/background/start?key=...&millis=1500 提交任务,并使用独立 PostgreSQL 查询确认 PgSleep 正在执行,然后才向该 stage 进程发送 SIGTERM。睡眠范围限制为 0–2000 ms,所有等待均有期限。

实际日志顺序如下,业务键每次运行都独立生成:

1
2
3
4
5
6
7
8
9
initialized: resources-open
task-start: <business-key>
drain-start: inFlight=1
task-terminal: <business-key>:committed-inserted-1
drain-end: inFlight=0,terminal=committed-inserted-1
sync-failure: synthetic
async-failure: synthetic
final-cleanup: fileClosed=true,workerTerminated=true,
schedulerTerminated=true,poolClosed=true

应用进程随后退出 143,新数据库连接查到该业务键对应的一条已提交记录。事务成功不是由 shutdown 日志推测出来的;外部查询补上了数据库终态。

停止提交、等待任务与逆序清理

这个结果只覆盖本次协作停止和有限 SQL。SIGKILL、机器掉电、数据库断网或超过停止预算的任务没有同样的保证;stop hook 无法在进程已经被强制终止后继续执行。需要持久任务恢复的业务,应另外设计任务记录、重试和幂等协议。

清理失败仍应继续释放其他资源

close(scheduler); close(worker); 放在同一个 finally 中仍然不够。第一步在被中断时可能抛出异常,后面的 worker 关闭就被跳过。池和文件也存在相同的串联失败风险。

实验对每层独立资源使用嵌套 finally:关闭 scheduler 失败仍尝试关闭 worker,关闭池失败仍尝试关闭 file。主操作已经失败时,把清理错误作为 suppressed 保留;临时清除中断以执行必要清理后恢复中断位,避免无声吞掉停止信号。

第 22 篇的实际中断场景还执行了 SELECT 1 后再触发清理中断,观察到主异常保留、suppressed=1、中断位为 true、池关闭和执行器终止。它验证了累计实现采用的清理结构,不能据此声称所有驱动都能在任意中断状态下立即关闭。

停止路径的等待驱动使用独立线程,不能让它排队到正在被等待、已经堵住的工作池内。否则任务和关闭过程可能互相等待,有限停止预算也难以按设计兑现。

两个实例重复调度同一业务键

另一个场景启动两个真实 stage 进程,各自拥有 scheduler、worker 和连接池,提交相同 business_key。数据库表以 business_key 为主键,事务使用 INSERT ... ON CONFLICT DO NOTHING。

两个任务都执行到了数据库,一次记录 committed-inserted-1,另一次记录 committed-inserted-0,最终只有一行。进程内的单例只限制一个应用实例,跨进程的重复效果由数据库唯一约束处理。

这组结果证明了合成写入的幂等效果,没有证明任务只执行一次。两边都消耗了线程和 SQL 时间;若在插入前发送邮件或调用外部支付接口,唯一键不能撤销已经发生的外部副作用。

同样,使用 Actor 或定时器不会自动保存业务任务。崩溃后是否重投、重试是否有界、任务何时算完成,需要持久化状态和恢复协议;本实验没有实现 Actor Persistence 或 exactly-once 调度。

复现和检查终态

从仓库根目录配置 JDK 21 与专用 PostgreSQL 17.6。驱动 42.7.5、HikariCP 5.0.1 来自实际 stage jar;公开合成口令和 loopback 地址只服务于本地实验。

1
2
3
export JAVA_HOME="$(/usr/libexec/java_home -v 21)"
PLAY_LAB_DB_ENABLED=true PLAY_LAB_JDBC_URL='jdbc:postgresql://127.0.0.1:5432/play_lab?connectTimeout=3&socketTimeout=5' bash examples/play-lab/sbtw test stage
python3 examples/play-lab/lab/database_checks.py --container play-db-local --jdbc-url 'jdbc:postgresql://127.0.0.1:5432/play_lab?connectTimeout=3&socketTimeout=5' --evidence examples/play-lab/evidence/local-lifecycle-run

脚本只终止自己启动并记录 PID 的应用进程。实验结束后,资源持有者、进程退出状态和新连接数据库查询都需要检查;数据库容器的销毁属于建立该 fixture 的流程,不应使用会影响其他容器的批量停止命令。

examples/play-lab/evidence/batch22-25/isolated/http-04/database-main.jsonl 保留真实 SIGTERM 顺序,background-first.jsonl 和 background-second.jsonl 保留双实例终态。summary.json 的 sigterm 与 twoBackgroundInstances 提供对应数据库行数。

历史隔离38项JUnit及37次HTTP观察保留;共享累计工程数据库启用后46项JUnit、stage和37次HTTP观察通过。停止竞态在shared-junit/TEST-DatabaseLabTest.xml,SIGTERM143、drain与清理顺序、独立数据库终态在shared-http/。关闭DB时原有39项实际执行、7个数据库方法跳过;接口产生重复通知的原始53条XML记录按方法归并,不能把Passed46当作46个无数据库实验。单一测试通过不能替代进程与SQL终态。

两个改动练习

  1. 把最终关闭 hook 的注册位置移到 drain 后面,在独立合成 fixture 观察逆序执行如何改变关闭时点。恢复正确顺序后,断言在途任务终态、池关闭和文件关闭,不只断言 stop 返回。
  2. 让两个实例在数据库插入前分别增加一个独立的尝试计数,再提交相同业务键。验收尝试次数为两次而业务行数为一,并据此说明需要外部副作用时还缺少哪些原子性约束。

上一篇:Evolutions 与模式升级 · 下一篇:认证与授权 · 系列起点:最小应用