perf(persona): 重算吞吐四处优化 —— worker pool + 连接池联动 + 少两次往返

生产/测试服实测基线(测试服 12 万患者样本,concurrency=8):
  单患者 p50 139ms / p90 230ms / p99 386ms / max 75,750ms,均值 165ms
  理论上限 8÷0.165 = 2,909 人/分,实际 ~1,600 → **效率只有 55%**

## ① 批次栅栏 → worker pool(src/common/run-pool.ts)

原写法 `for (i += N) await Promise.all(slice)` 是**栅栏**:每批都等本批最慢那个,
快的 worker 干等。缺口正好是 E[N 次抽样最大值](≈p90)与均值之比。
极端例子:那个 75.7 秒的患者把同批 7 个 worker 一起冻了 75 秒。
改成 N 个 worker 各自从共享游标取下一个,谁空谁取 —— 慢任务只拖住自己那条线。

## ② --concurrency 根本没放大连接池(真 bug)

`withCohortDerivedPool` 只认 `PAC_COHORT_CONCURRENCY`(**摄入**的旋钮),
于是 `recompute-persona --concurrency=N` 的旋钮**静默失效**:实测跑 --concurrency=8 时
进程恰好只有 9 条连接(= 4核×2+1 的 Prisma 默认),超过 9 的 worker 全卡在池上排队。

️ 这个 bug **只在小核机器上咬人**,所以一直没被发现:服务器 4 核 → 默认池 9;
   开发机 16 核 → 默认池 33,本地压根复现不出来。

改:池改读通用的 `PAC_DB_CONCURRENCY`,由各 CLI 在 NestFactory **之前**按自己的
--concurrency 设进来(PrismaService 在容器初始化时就定池,晚了没用);
`PAC_COHORT_CONCURRENCY` 保留为向后兼容别名,两者取大。

## ③ 同一张表查两遍 → 一次取,内存分两份

persona.service 对 patient_facts 每患者查两次(同患者、同索引、两次往返):
  ① status IN (active, fulfilled)                    → factsByType
  ② type=appointment_record AND status != superseded → appointmentsAll
② 是 ① 在 appointment 这类上的放宽,故取 `status != 'superseded'` 一把捞,内存切两份,
语义逐字节等价。

## ⑤ 审计日志 2 写 → 1 写

原 create(status='running') + update(终态)。startedAt 可显式赋值,故耗时统计一字不差。
代价:进程被硬杀(OOM/SIGKILL)时不留痕 —— 评估可接受:try/catch 里的异常仍写 'failed' 行;
唯一丢的是"算到一半进程没了",而那种情况原本留下的是一条**永远卡在 running** 的行(没人清理)。
读侧确认无依赖(daily-health-report 只用 findFirst 最近一条 + 按 status 的 groupBy)。
全量回填还顺带少一半日志表写入(35.6 万 → 17.8 万行/轮)。

往返预算:12.7 → 10.7(-16%)。

## 本地实测(13,268 患者,--force,concurrency=8,同为「全 unchanged」稳态)

  旧代码  93.50s
  优化后  76.89s      → 1.22×,省 17.8%

️ 本地测不出 ① 的真实收益:本机 avg 35.9ms / p90 49ms / **max 436ms**,
   而测试服 avg 165ms / p90 230ms / **max 75,750ms**(174× 均值)。本地没有那种能冻住
   整批的长尾,且延迟低到瓶颈更像是 JS 单线程 CPU 而非 DB 往返。
   所以 1.22× 主要是 ③⑤ 的往返削减(机器无关),① 的收益要到服务器上才显现 ——
   **具体多少不预估,等在测试服实跑对照。**

## 正确性

指纹法:优化后跑完 → 切回旧代码再跑 → 旧代码对全部 13,268 人判 `unchanged`,
persona_features 指纹 4de1dcd0ddc6a2c99e222fd43e0755de 不变。
即**旧算法认为新代码的产出与它自己会算出来的逐字节相同**。

## 测试

494 单测通过(36 suites),service tsc 干净。新增 tests/persona-recompute-perf.spec.ts 12 例:
  runPool — 不丢项 / 并发上限严格且用满 / 慢任务不冻住其他 worker / 边界 / 错误上抛
  连接池 — PAC_DB_CONCURRENCY 生效 / COHORT 向后兼容 / 取大 / 封顶 40 / =1 不动 URL /
          已有 connection_limit 不覆盖 / 非标准 URL 不抛

## 未做(单独排)

④ gapSelector 每患者平均 2.66 条 SQL(实测分布 0 组 3.4% / 1 组 19.5% / 2 组 25.7% /
  3 组 24.7% / 4+ 26.7%,max 9)。UNION ALL 合一能再省 ~1.7 次往返,但动的是**召回共享**
  的 gap 真理源,风险最高,要配专门的对账测试。

另注:plan-engine.runAllForHost 有一模一样的批次栅栏写法(PAC_PLAN_BATCH_CONCURRENCY),
同样的 5 行改法适用,本次未动。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Found errors in your .gitlab-ci.yml:

  • jobs:openapi-drift config contains unknown keys: rules
You can also test your .gitlab-ci.yml in the Lint
Status Job ID Name Coverage