# Gitea Actions concurrency 失效用 flock 替代：共享测试容器真串行

> **日期**: 2026-05-09
> **场景**: `quality-gates::backend-integration` 和 `deploy-uat::post-deploy-regression` 共用 `ffoa-test-postgres` / `ffoa-test-redis` 全局容器，并发跑会互相 `docker rm -f` 干掉对方
> **关联**: [ERR-20260506-001](ERRORS/ERR-20260506-001.md) / 工单 #264

## 问题

两个 job 都跑在 `uat-with-docker` runner（capacity=2），第一步都是 `docker rm -f ffoa-test-{postgres,redis}` 清理上轮残留 → 后入的 job 把先入 job 正在用的容器干掉 → 先入 job 报 `unexpected postmaster exit (E57P01)`，exit 2。

历史"修复"是给两个 job 都加：
```yaml
concurrency:
  group: shared-test-containers
  cancel-in-progress: false
```

但 **Gitea 1.25.x 不支持 GitHub Actions 的 `concurrency:` 关键字，会被 yaml parser 静默忽略**——支持是在 [Gitea PR #32751](https://github.com/go-gitea/gitea/pull/32751) 引入，**1.26.0 才合**（2026-04-18 release）。

也就是说写了三个月的 concurrency block 一直是装饰，互踩从未真正消除——只是发生频率被 capacity 限制掩盖了，复发是迟早的事。

## 元教训：CI 平台的"同名关键字 ≠ 同行为"

GitHub Actions 与 Gitea Actions 的 yaml schema 大体兼容，但**版本差异要检查 release notes**。`concurrency:` 这种"自带平台支持"的关键字一旦失效就是**静默失效**——yaml parser 不报错、CI runner 不警告。诊断只能靠"事故复发后回头查"。

→ **凡是依赖 CI 平台特性做安全保证的，务必在第一次启用时手动验证生效**（人为制造冲突看是否串行）。无法验证的就不要依赖，改用平台无关的机制（文件锁、环境变量、外部协调服务）。

## 修复：在 yaml 层用 flock 做真串行

不依赖任何 Gitea 版本特性，runner 上的 `flock(1)` 就够了：

```bash
exec 200>/tmp/ffoa-test-containers.lock
if ! flock -x -w 1800 200; then
  echo "❌ 1800s 内未取到 ffoa-test 容器锁，疑似前序 job 卡死"
  echo "--- 诊断 ---"
  lsof /tmp/ffoa-test-containers.lock 2>&1 | head -20 || true
  docker ps -a --filter 'name=ffoa-test' --format 'table {{.Names}}\t{{.Status}}' 2>&1 || true
  echo "如需手动恢复：rm -f /tmp/ffoa-test-containers.lock && docker rm -f ffoa-test-postgres ffoa-test-redis"
  exit 1
fi
echo "✅ 已取得 ffoa-test 容器锁"

# ... 整段容器使用代码 ...

# 锁随 step 退出由 fd 200 关闭自动释放
```

### 关键设计决策

| 选择 | 理由 |
|---|---|
| `/tmp/<name>.lock` 而非 repo 内 | runner 重启 `/tmp` 自清，无残留风险 |
| `fd 200` | `flock(1)` man page 惯例，不与 step shell 占用的低位 fd 冲突 |
| `-x` 排他锁 | 只允许一个 job 持锁，正是要的语义 |
| `-w 1800` 30 min 超时 | 实测 jest 全量 ~5-10 min，3× headroom 防卡死 |
| **锁随 step 退出自动释放**（不显式 unlock）| `exec 200>/tmp/...` 把 fd 绑到 shell 进程，shell 退出 fd 关闭即释放——更可靠（不用担心异常路径忘 unlock） |
| 取锁失败时打印 `lsof` + `docker ps` | oncall 看 CI log 直接拿到诊断信息，不用 ssh 上 runner 再查 |

### 关键约束：合并 step

GitHub/Gitea Actions 每个 step 是独立 shell process——`exec 200>...` 的 fd 不能跨 step 持有。所以 lock 内的所有逻辑必须**塞进同一个 step 内**。

`quality-gates::backend-integration` 因此把原本独立的 3 个 step（Reset / Detect+run / L0c）合并成一个大 step。trade-off：失去了 Gitea UI 的分段计时显示，弥补做法是**step 内 echo `▶ Phase X: ...`**，让 CI log 仍能看到阶段切换。

不合并不行——拆开后两个 step 之间的空隙仍会被对方 `docker rm -f` 干掉容器（jest 还在跑）。

### 锁外做什么

`npm install` / `prisma generate` 不抢容器 → **保持在 lock 之外**，可与其他 job 并发，节省 ~1-2 min 等锁。

## 防同款再发：脚本头加 grep-able 警示

`testing/scripts/run-backend-integration.sh` 头部加注释：

```bash
# CI 警示：本脚本在 Gitea Actions 多个 job 中调用…必须先持有
# /tmp/ffoa-test-containers.lock 互斥锁（exec 200>...; flock -x -w 1800 200）。
```

未来谁要在新 workflow 调用 `run-backend-integration.sh`，grep `ffoa-test-containers.lock` 立刻能找到这条规则。把"必须 flock"从 reviewer 心智约束升级成**脚本自描述的硬约定**。

## 验证

本地 2 进程 race：
- A 占锁 3s → B 等 2.70s 后获取（实测）
- A 占锁 3s + B 0.5s 超时 → B 准确超时退出（实测）

CI 端实际验证留 PR merge 后 babysit 几个并发 PR 看：
- 两个 PR 同时进 backend-integration → 应排队（一个 "已取得锁"，另一个等）
- 不再出现 E57P01 unexpected postmaster exit

## Trade-off：PR throughput

**修复前**：两个 PR 并发跑 backend-integration，但互踩 fail，需要重跑——表面"并发"实际"互毁"。
**修复后**：两个 PR 串行，第二个 PR 等 ~5-10 min。throughput 名义上降，但**有效完成率上升**（不再 fail-retry）。

更彻底的修复：让两个 job 用独立容器名（`ffoa-test-postgres-${RUN_ID}`），但需要改 `lib-test-db.sh` 容器名硬编码 + 影响面大。flock 是改动最小的方案，留待 dedicated-runner-1 装 docker 后再考虑彻底重构（详见工单 #264 的"长期方案"段）。

## 顺手发现的隐患（未在本 PR 修）

- `npx ts-node` 在 quality-gates `Run L0c` 仍存在（CI runner 上 npx 已 cache，行为稳定，但跟 PR-A `pre-commit/pre-push 改本地 ts-node` 的精神冲突）。建议未来一并改为 `testing/node_modules/.bin/ts-node`。
- `dedicated-runner-1` 闲置 + 没装 docker，是工单 #264 长期方案——把 backend-integration 迁过去能从根上消除互踩。需要 ssh 改服务器配置，不在本 PR 范围。
