订单创建返回 200,响应中却包含 notification=BUDGET_FAILED。数据库订单已经提交,固定本地下游超过了 120ms 请求预算;这两个结果同时成立。把通知超时转换成“订单失败”,会诱导调用方换一个幂等键重试,产生第二笔合法订单。

结课工程把这种边界放进一个真实服务:PostgreSQL 保存订单和库存,Play 签名 session 提供合成身份,对象策略同时约束 tenant 与 subject;SSE 查询订单状态,CSV 通过文件源下载。每项结论都有对应输入、数据库或资源终态,以及可重跑的有限实验。

工程范围与可复核产物

结课服务已接入第00篇的累计源码包。共享55项JUnit全部执行,112个应用及生成class与stage jar字节一致;生产重放再次验证订单、库存、幂等、导出、订阅与在途停机,见 evidence/batch30-35/shared-capstone-http。实验仅删除自己的UUID schema,保留共享PostgreSQL实例。

本篇固定 Play 3.0.6、JDK 21.0.11、PostgreSQL 17.6。Play 源码锚定完整提交 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6,四个引用文件已与该提交的官方原始文件逐字节核对。实验通过 stage 启动独立 Prod 模式进程,仅监听 127.0.0.1。

Prod 是运行模式。capstone.conf 仍是专项实验配置,显式开放公开合成登录、就绪检查、撤权和状态接口,首次初始化还会创建自己的 capstone_* schema。基础应用默认未启用该模块,登录开关缺省为 false。这个实验配置不能直接作为生产身份认证或数据库迁移方案。

受控提交、数据库事务和流终态三个观测位置

flowchart LR
  H[HTTP Cookie 与请求体] --> A[签名身份与对象权限]
  A --> Q[4 个工作线程<br/>16 个等待任务]
  Q --> DB[(PostgreSQL<br/>订单与库存)]
  DB --> C[事务提交后返回订单]
  C --> W[固定本地 WS 下游<br/>120ms 预算]
  W --> R[HTTP Result]
  DB --> S[SSE 每事件再查权限]
  DB --> F[有限 CSV 文件]
  S --> T[流终止记录]
  F --> T

代码位于累计工程 examples/play-lab。app/capstone/OrderRepository.java 负责同步 JDBC 事务,CapstoneService.java 拥有线程池、数据库池、WS 客户端、临时文件和关闭记录,Controller 负责请求形状与 HTTP 错误契约。已有 labdb.DbAccess 被直接复用;订单算法沿用事务实验的唯一键占位思路,但表与权限键改为结课模块自己的 schema。生命周期顺序沿用后台任务实验的先停止接收、再排空、最后关闭资源;没有调用旧实验中故意失败的 stop hook。

实际验收是 2 项针对性 JUnit 加完整真实网络矩阵。它没有执行共享工程的累计 suite,不能把先前章节测试数相加写成本次通过数。stage-manifest.json 保存 246 个生产阶段文件的哈希,artifacts/ 保存实际应用、assets 和业务模块 jar;源文件集成清单与这些二进制产物分开记录。

身份坐标进入每一条对象查询

Security.AuthenticatedAction 与默认 Authenticator 先读取 session.username。存在用户名时执行 delegate,缺失时返回 401。这个默认 Action 没有订单所有者的知识,也不会从用户名自动推出 tenant。

实验登录把 tenant 与 username 写入框架签名 session。Controller 将它们转换为不可变的 Principal;客户端提交的 X-Username、X-Tenant 不参加计算,JSON 中额外提交 tenant 字段会得到 400。对象访问继续进入数据库事务,先检查 principals.revoked,再用 id、tenant、subject 同时限定订单。

1
2
3
4
5
SELECT revoked FROM capstone_example.principals
WHERE tenant = ? AND subject = ? FOR SHARE;

SELECT quantity, status FROM capstone_example.orders
WHERE id = ? AND tenant = ? AND subject = ?;

这两条 SQL 是 OrderRepository 的查询形状;实际 schema 由每次运行生成,名称经过固定正则校验,身份与订单值使用参数绑定。列表同样带 tenant、subject 条件,按 id 排序,最多返回 100 行。这个上限只限制本次结果集,服务尚未提供翻页游标。

FOR SHARE 使授权检查所在事务持有权限行的共享锁。撤权更新与已开始的事务按数据库锁规则协调,允许当前已授权事务结束,再让后续检查读到 revoked。它没有追溯取消之前已提交的订单,也没有清空 TCP 接收端已经取得的事件。

实际请求 结果 证明的边界
无 Cookie、仅伪造身份头、修改真实 Cookie 签名字节 分别 401 网络验签与默认认证入口
有 session,缺 JSON/Form CSRF token,或使用旧 bypass 头 分别 403 专项配置清除了早期实验的 CSRF bypass
t1/alice 与 t1/bob 列表各自只有自己的订单 同一租户内的 subject 隔离
t1/alice 与 t2/alice 访问对方订单 403 相同 subject 不能跨 tenant
对方 detail、cancel、SSE、CSV 三组用户各四项均 403 策略覆盖普通与流式入口

只验证 detail 接口会遗漏导出与订阅。流在完成授权前不能分配出口;这里初次 SQL 检查通过后才申请流许可证。拒绝访问因此不会消耗四个有限流槽位,也不会创建导出文件。

唯一键、payload 和库存处在同一个事务

幂等键的数据库范围是 (tenant, subject, idem_key)。相同字符串由不同主体使用时可以产生不同订单,符合该实验的业务契约。请求中的 quantity 范围为 1–3,note 限定字符与 80 字符长度,规范化 payload 为 quantity + ":" + note;幂等键不只是一个“见过没有”的集合。

flowchart TD
  I[校验身份与有限输入] --> P[插入订单唯一键占位]
  P -->|插入一行| U[条件扣减库存]
  U -->|库存足够| C[同事务提交]
  U -->|更新零行| X[抛出冲突并回滚占位]
  P -->|唯一键冲突| L[下一条 SELECT 读取已有订单]
  L --> E{payload 相等}
  E -->|是| R[返回已有 id 与当前状态]
  E -->|否| N[409 PAYLOAD_CONFLICT]

占位使用 INSERT ... ON CONFLICT(tenant,subject,idem_key) DO NOTHING。插入成功后,执行 UPDATE stock SET remaining=remaining-? WHERE tenant=? AND remaining>=?。只有库存更新影响一行才允许事务返回;不足时抛出 Conflict,此前插入的订单随事务回滚。

PostgreSQL 17 的 Read Committed 说明指出,ON CONFLICT DO NOTHING 可能因当前语句快照不可见的并发结果而放弃插入;下一条语句使用新快照。因此这里在冲突后另发 SELECT,读取已提交的行并比较 payload。实验记录数据库默认隔离级别为 read committed。改变隔离级别后需要重新测试冲突与重试路径,不能照搬这次观察。

Play 的 Scala withTransaction 在同步 block 返回后调用 commit,随后外层 finally 关闭连接;本工程的 Java 业务异常进入 rollback 分支。源码中的 Scala ControlThrowable 是单独提交再抛出的分支,不能把整个 catch 描述为“任意 throwable 都回滚”。

事务 block 返回的是 JSON 订单值,异步通知在 block 外组合。若把尚未完成的 CompletionStage 直接作为 block 的返回值,Play 只会看到 block 已返回,随后提交并关闭连接;它不会自动等待这个 Stage 里的数据库操作。

真实矩阵对相同新键并发发送四次 quantity=2,得到同一个 UUID、一项 CREATED、三项 REPLAY,库存从 20 降为 18。随后四个不同 payload 请求全部 409。另一个全新键同时竞争 quantity=1 与 2,各发送两次,结果是两项 200、两项 409;成功组内部仍为一次创建和一次重放。胜出的 payload 取决于竞争顺序,断言没有预设它必须是哪一个。

库存不足也经过实际事务。t2/bob 连续提交七笔 quantity=3,其中六笔成功,第七笔 409;独立 psql 查询确认失败键 stock-6 没有残留订单。只检查 409 会遗漏“订单插入成功、库存扣减失败、却忘记回滚”的错误实现。

取消只对一次状态迁移恢复库存

取消事务先按对象权限 SELECT ... FOR UPDATE 锁定订单。CREATED 分支把状态更新成 CANCELLED,并在同一事务增加库存;已经 CANCELLED 的分支返回 REPLAY_CANCEL,不再写库存。两个并发取消请求得到一次 CANCELLED、一次 REPLAY_CANCEL。

1
2
3
CREATED --持锁更新订单 + 恢复库存并提交--> CANCELLED
CANCELLED --重复取消--> CANCELLED(库存不变)
相同创建 key + 相同 payload --重放--> 已有 CANCELLED 订单

唯一键记录没有在取消时删除,所以旧创建请求再次到达时仍返回原 id 与 CANCELLED 状态。若取消后需要重新购买,业务协议应要求新的幂等键;静默删除旧键会让延迟重试变成新订单。

每份独立数据库快照都断言 remaining = 20 - sum(CREATED.quantity),按 tenant 分别计算。这个守恒关系把创建、取消和失败回滚连接起来,比只统计接口成功次数更容易发现多扣或多还。它仍属于当前单库业务约束,没有包含支付、物流或其他系统。

提交结果与通知预算使用不同字段

服务将同步 JDBC 放入四个专用工作线程,等待队列长度为 16,数据库池上限同为 4。借连接期限为 1000ms,语句设置 5 秒查询超时,JDBC URL 另有连接与 socket 期限。线程数与连接数相同并不能消除事务锁等待,队列长度也不等于请求延迟上限。

事务返回后记录 transaction-returned。请求 notifySlow=true 才调用配置中的固定 http://127.0.0.1:<port>/slow,请求数据不能提供任意 URL,重定向关闭,专有 WS 客户端的连接上限为 2。notifySlow 和实验用 delayMillis 不属于订单 payload;重放请求可能再次尝试通知,订单幂等不等于通知幂等。

sequenceDiagram
  participant C as HTTP客户端
  participant P as Play工作线程
  participant D as PostgreSQL
  participant W as 固定慢下游
  C->>P: 创建订单,notifySlow=true
  P->>D: BEGIN / 占位 / 扣库存
  D-->>P: COMMIT 成功
  P->>P: transaction-returned
  P->>W: HTTP请求,预算120ms
  P->>P: notification-terminal = BUDGET_FAILED
  P-->>C: 200,订单id + 通知失败字段
  Note over W: 本轮约700ms后写响应

最终样本中,下游记录确实进入 /slow,稍后记录 written。WS 预算先失败,Play 返回已提交订单及 BUDGET_FAILED,独立 SQL 查询仍存在该订单。下游 write 返回只能表明本机写调用没有报错,不能证明超时后的 WS 调用方消费了响应。

BUDGET_FAILED 是本教学实现对 WS 异常的合并分类,代码没有把所有异常逐类拆开。它在这次受控慢响应中由有限请求预算触发;面对连接拒绝、TLS 或其他异常时,需要增加分类,不能仅凭这个字段断言网络一定慢。

JDK 21 CompletionStage提供依赖阶段的组合契约。以下完整示例只验证这部分行为:事务失败时不调用通知;通知失败时保留提交标识。它使用合成函数,没有连接数据库,实际事务与网络结论仍由前述独立进程矩阵证明。record 自 Java 16 提供,failedFuture 自 Java 9 提供。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.function.Supplier;

// JDK 21 example; record requires Java 16, failedFuture requires Java 9.
public final class CommitThenNotify {
record Receipt(String orderId, String notification) {}

static CompletionStage<Receipt> submit(
Supplier<String> synchronousTransaction,
Function<String, CompletionStage<String>> notification,
Executor executor) {
return CompletableFuture.supplyAsync(synchronousTransaction, executor)
.thenCompose(orderId -> {
CompletionStage<String> attempt;
try {
attempt = notification.apply(orderId);
} catch (RuntimeException failure) {
attempt = CompletableFuture.failedFuture(failure);
}
return attempt.handle((status, failure) -> new Receipt(
orderId, failure == null ? status : "BUDGET_FAILED"));
});
}

public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
AtomicInteger notifications = new AtomicInteger();
try {
Receipt receipt = submit(() -> "committed-order", orderId -> {
notifications.incrementAndGet();
return CompletableFuture.failedFuture(
new java.util.concurrent.TimeoutException("synthetic budget"));
}, executor).toCompletableFuture().get(2, TimeUnit.SECONDS);
if (!receipt.equals(new Receipt("committed-order", "BUDGET_FAILED"))) {
throw new AssertionError(receipt);
}
try {
submit(() -> { throw new IllegalStateException("transaction failed"); },
orderId -> {
notifications.incrementAndGet();
return CompletableFuture.completedFuture("unexpected");
}, executor).toCompletableFuture().get(2, TimeUnit.SECONDS);
throw new AssertionError("failure expected");
} catch (java.util.concurrent.ExecutionException expected) {
if (!(expected.getCause() instanceof IllegalStateException)) throw expected;
}
if (notifications.get() != 1) throw new AssertionError(notifications);
System.out.println("PASS committed identity retained; failed transaction skipped notification");
} finally {
executor.shutdownNow();
if (!executor.awaitTermination(2, TimeUnit.SECONDS)) {
throw new AssertionError("executor did not terminate");
}
}
}
}

当调用方未收到响应时,服务端可能已经提交。现有唯一键使同键查询与重试能够查到既有结果;数据库提交确认丢失时怎样分类错误,尚未在本轮故障注入中执行。工程没有实现 outbox、持久通知重试或跨服务 exactly-once。

SSE 订阅复查权限,文件下载观察源关闭

SSE 使用有限 Source.range(1,20),每 150ms 最多推进一个元素,通过 mapAsync(1, ...) 查询当前订单。每次查询重新执行权限 SQL,再交给 EventSource 编码;整个流额外限制在 5 秒内。四个流槽位由许可证控制,记录表最多保存 128 项,淘汰已关闭项,不无限保留连接历史。

flowchart LR
  N[下一个有限 tick] --> G[提交数据库任务]
  G --> A{主体仍授权?}
  A -->|是| O[读取该主体订单状态]
  O --> E[EventSource 编码]
  E --> B[有界流与网络缓冲]
  A -->|否| X[流失败]
  B -->|实际客户端断开| X
  X --> T[watchTermination<br/>closeCount=1 / 归还槽位]

真实客户端先收到 CREATED,再通过另一个请求取消同一订单,原 SSE 连接随后收到 CANCELLED。另一个场景在收到首事件后撤权,客户端出现 ConnectionResetError,服务端 ledger 记录 failure=Denied 和 closeCount=1;后续 list、detail、create、cancel、SSE、CSV 均返回 403。

这个实现发布的是“定期查询到的状态”,没有历史事件表,也没有 Last-Event-ID 重放。两个 tick 之间发生的多次变化可能只剩最后状态;检查权限之后已进入缓冲的事件,也可能与撤权提交并发。应用不能根据“每事件查一次”推导出撤权瞬间远端绝不会再看到旧数据。

CSV 首先校验对象权限,写有限临时文件,再通过 sendPath 返回。文件名由服务器生成,导出内容只含 id、status、quantity。普通导出为一个订单行;压力参数 copies 可把同一授权行重复最多 524288 次,用于生成足够大的有限文件,不代表数据库拥有这么多订单。

Scala Results.sendPath 使用 FileIO 文件源,并在物化结果完成时调用 onClose。应用回调删除临时文件、记录 closeCount 并归还槽位;Java 层没有用 readAllBytes 把文件整体装入内存。

本轮取消下载的 Content-Length 为 24641555,客户端实际读取 16384 字节就关闭连接;随后观察 closeCount=1、临时目录为空。文件存在与否单独不能证明资源释放,因此验收同时保存源关闭回调和实际读取长度。这个回调也不证明对端收齐 24641555 字节。

流容量实验同时保持四条真实 SSE 连接,第五条得到 503。关闭前四条连接后,每条记录只关闭一次,许可证恢复为 4。这个结果证明当前有限输入下容量拒绝与释放成立,没有测量长期常量内存或持续负载吞吐。

SIGTERM 先关闭提交入口,再等待已有 SQL

关闭入口需要早于资源释放。CapstoneService 在 PhaseBeforeServiceUnbind 将 stopping 置为 true;提交函数在同一个同步区检查该状态并增加 inFlight,避免停止标志与入队之间留下独立检查窗口。已接收任务继续执行,后续提交得到 RejectedExecutionException,Controller 可映射为 503。

Play Pekko HTTP 后端的关闭注册 将取消监听、等待请求、应用 stop hook 放在不同阶段。Pekko Coordinated Shutdown 文档将任务按阶段组织;相同阶段内的任务不能当作严格串行顺序使用;应用这里只依赖阶段先后,资源关闭留在后续 ApplicationLifecycle hook。

sequenceDiagram
  participant T as 验证脚本
  participant P as Play服务
  participant D as PostgreSQL
  T->>P: 创建订单,有限pg_sleep(2)
  P->>D: 执行事务内SQL
  T->>D: pg_stat_activity确认PgSleep
  T->>P: SIGTERM
  P->>P: 停止接收新任务,inFlight=1
  P->>P: 取消HTTP监听
  D-->>P: SQL返回与COMMIT
  P-->>T: 原请求200
  P->>P: 排空executor,关闭WS与DB池
  P->>P: 关闭journal文件,记录终态

脚本没有用“等待两秒”猜测任务已经开始。它通过独立 psql 查询看到 pg_stat_activity 的 PgSleep,再发送 SIGTERM。最终原请求得到 200,独立查询存在一条 sigterm 订单;关闭提交入口时 inFlight=1,内部提交探针确实被拒绝,新建网络连接实际得到 ConnectionRefusedError。

503 与拒绝连接属于不同位置的结果。服务仍能处理已建立连接上的新请求时,应用拒绝分支可以产生 503;本轮观察到监听已取消后的拒绝连接,不能把它改写成网络收到 503。inFlight 只统计该服务接收的 JDBC 工作,不能解释为全部 HTTP、通知或流的数量。

两个进程都以 143 退出。退出记录同时满足 fileClosed/poolClosed/clientClosed/executorTerminated=true、inFlight=0、filesLeft=0、streamPermits=4,没有 cleanupFailure。退出码本身不能替代这些记录。正常排空样本只有一个在途 2000ms SQL,尚未证明满队列下的关闭期限。

配置将 play.server.terminationTimeout 与 pekko.coordinated-shutdown.phases.service-requests-done.timeout 都设为 12 秒。只增大前者会超过阶段期限;历史首轮日志保存了这一警告,最终重跑已消除。资源关闭代码会分别尝试客户端、数据库池和 journal 关闭,并把后续失败附加到原始异常上,不能因一个 close 抛错就跳过其余资源。

重跑入口与证据范围

源码包与累计工程入口见第00篇最小应用。准备 JDK 21 的 JAVA_HOME,并按数据库章节准备 PostgreSQL 17.6 fixture;下列命令在 examples/play-lab 执行,输出目录须不存在。

1
2
bash sbtw 'testOnly CapstoneTest' stage
python3 lab/capstone_checks.py --out evidence/batch35/replay --container play-db-lab-20261003-pipeline-43 --pg-port 35711

脚本只创建和删除自己的随机 capstone schema,保留数据库容器。HTTP 服务与慢下游使用临时 loopback 端口,由 finally 清理自有进程与线程。运行时 secret 随机注入环境,最终两份日志与 246 个 stage 文件均扫描未发现其字面量;公开 fixture 身份不应被误认为真实认证凭证。

读者证据根为 examples/play-lab/evidence/batch35/isolated/。build3.log 与 TEST-CapstoneTest.xml 是编译和两个单元测试;run-final/http.json 保留逐请求状态、响应头及订单响应,Cookie 与 CSRF token 被省略;sql.json 保留独立数据库查询和输出;observations.json 串联场景断言。两份 *-journal.jsonl 与服务器日志用于交叉核对流终态和关闭顺序。

尚未覆盖的故障 缺失的实验 当前结论不能扩大到
COMMIT 已完成但确认丢失 数据库连接断开注入 任意500都代表未提交
SIGKILL、数据库崩溃、故障转移 强制终止及恢复运行 有序 stop hook 保证崩溃清理
多实例同时写同一业务空间 第二个并发服务实例 本轮线程竞争等同分布式全覆盖
文件系统满或删除被拒绝 权限/容量故障注入 closeCount1总能删除文件
通知可靠投递 outbox、重试与消费去重 订单幂等包含通知幂等
长流恢复与长期负载 SSE游标、满队列停机、持续压测 有限样本证明事件无丢失或恒定内存

改动练习一:只在隔离副本删除取消路径的 FOR UPDATE,给状态读取与更新之间增加一个有期限的双请求屏障,再并发取消同一订单。保留数据库快照,断言库存守恒是否失败;恢复行锁后重跑相同输入。这个练习尚未执行,不能预填失败次数。

改动练习二:在同一数据库事务插入通知 outbox 行,另用有界工作器投递;让下游在返回前中断连接,并使用持久通知 id 去重。验收要同时记录订单提交、outbox 状态、下游实际副作用次数和重启后的处理结果,不能把 HTTP 重试次数当作副作用次数。这属于工程扩展,当前证据没有实现或覆盖。

上一篇:34 故障诊断。下一篇:E01 Scala API对照。