# [LRN-20260427-001] 共享测试容器被并发 job 杀掉时，集成测试要 fail-fast

**日期**: 2026-04-27
**触发**: PR #156 (`Merge branch 'production' into staging`) backend-integration 失败，
            run_id=1208 / job=3601，4 suites failed / 48 tests failed，跑了 469s
**严重度**: 中（不阻塞合并，但每次 CI infra 抖动浪费 ~6 分钟 + 错误日志严重失真）

## 现象

- 测试开始 12:08:25，DB 在 12:09:16 就绪并强制重建 schema，前几个 suite 12:11:01 前都正常通过。
- **12:11:15 突然出现 `prisma:error ... E57P01 / "terminating connection due to administrator command"`**——Postgres 被外部命令（`pg_ctl stop` / `docker restart` / SIGTERM）强制关掉。
- 之后剩下 ~18 个 suite 全部因 `Server has closed the connection` 失败，job 一直跑到 12:17:28 才退出。**故障发生 → job 结束差 6 分 13 秒，全在白跑**。
- 失败日志被几百条 `PrismaClientKnownRequestError` 和 `Cannot read properties of undefined (reading '$executeRawUnsafe')` 淹没，真正的根因 `E57P01 admin_shutdown` 要翻到第 4426 行才看见。

## 根因

`ffoa-test-postgres` 是 **共享容器**（启动日志写"复用已有测试数据库容器"）。同一 `uat-runner` 上有另一个并发任务在 12:11:15 左右执行了"强制重建测试库 / docker restart"，把容器干掉了。最可能的并发源：

1. 同时间另一个 PR 的 backend-integration 也在跑，两个 job 抢同一个 `ffoa-test-postgres`，后到的执行 `DROP DATABASE` / `docker restart` 把先到的连接踢飞。
2. UAT 服务器上的定时清理/备份任务撞上。
3. `run_attempt=2` 重跑时与某次残留的 attempt 1 时间重叠。

按 CLAUDE.md 的"L1 必须连接独立测试数据库"的精神，**复用同一个容器在并发场景下本来就违反了"独立"的原则**，并发 job 互相打架是迟早的事。

## 经验

### 1. 区分"基础设施故障" vs "业务测试失败"，前者立即 abort

业务断言失败要看全（多个失败有助于诊断 bug，所以不用 `--bail`），但 **DB 被杀**这种基础设施死亡——继续跑没有意义，每个 suite 都会 timeout 18s。

判定模式（命中其一即视为 infra 故障）：
- `E57P01`（admin_shutdown）
- `administrator command`
- `Server has closed the connection`
- `Can't reach database server`
- `ECONNREFUSED.*543\d`
- `Connection terminated unexpectedly`

命中后用 `process.exit(2)` 立刻退出（**退出码 2** 区别于 jest 默认的 1，CI 一眼能分辨"infra 挂了"还是"代码 bug"）。

### 2. 每个 suite 入口加 `SELECT 1` 健康检查

成本：每 suite ~几 ms；
收益：DB 死时单 suite 失败时间从 ~18s（NestJS bootstrap 超时）降到 ~1s。

放在 `createTestApp()` 的最前面，触发 abort 即终止整轮。

### 3. 不要用 `--bail`

`--bail=N` 对业务 bug 也一刀切，会损失诊断信息（5 个 suite 都失败时只能看到前 N 个）。
正确做法是按错误**性质**区分（infra vs 业务），不是按数量。

## 修复方式

新增 `testing/backend/helpers/db-fail-fast.ts`：
- `pingTestDb()`：suite 入口跑 `SELECT 1`，DB 不通 → abort。
- `checkAndAbortIfFatal(err, origin)`：错误命中 fatal 模式 → abort。
- 进程级 `unhandledRejection` / `uncaughtException` 兜底：异步任务里漏 await 的 prisma 报错也能被捕获。

接入点：
- `testing/backend/helpers/app.helper.ts::createTestApp()`：开头 `pingTestDb()`，`app.init()` 用 try/catch 包，失败时调 `checkAndAbortIfFatal`。
- `testing/backend/helpers/cleanup.helper.ts::cleanupAllData()`：`exec` 内部 catch 调 `checkAndAbortIfFatal`，避免 cleanup 一路 catch 掉 connection-closed 错误后还继续往下跑。

## 治本方向（未做）

- CI workflow 里 backend-integration job 加 **concurrency group**（按 runner 维度），同 runner 不并发跑测试。
- 或：每个 job 起独立容器（`ffoa-test-postgres-${{ github.run_id }}`），物理隔离。

本次只做"失败时快速逃生"，治本待后续单独提 PR。

## 适用范围

任何依赖共享 docker 服务（postgres/redis/mq）跑集成测试的 CI 场景；判定模式可按服务扩展（redis 的 `READONLY`、`LOADING`、connection refused 等）。
