fix: 自带 PG 的 /dev/shm 提到 1g + 画像版本号并发竞态(测试服全量回填暴露的两个问题)

昨夜测试服跑优化后的全量回填(35.7 万患者,2.2 小时跑完、2,694 人/分)暴露两个**既有**问题,
都不是优化本身造成的。

## ① /dev/shm 64MB → plan 全量重算当场崩

  ERROR: could not resize shared memory segment "/PostgreSQL.xxx" to 12615680 bytes:
         No space left on device

Docker 给 /dev/shm 的默认只有 64MB(inspect 确认 ShmSize=67108864),而 PG 的并行查询
worker 用共享内存段交换中间结果。召回的 selectHits 把 **10 个子场景 Promise.all 并行跑**,
每个都是 2500 万 patient_facts 上带 LATERAL 的重查询、各自还起 parallel worker,
并发的段远超 64MB。

崩在 selectHits → runSubScenario,那处 Promise.all 本次一个字没动 —— 与 worker pool 无关。
compose 里给 postgres 加 `shm_size: 1g`(PG 容器常规配法)。
️ 生产用阿里云托管 RDS、不启本服务,不受此问题影响,也不受本改动影响。

## ② CLI 与 stale-scan cron 抢版本号 → Unique constraint

全量回填跨过了 02:00 的 `PAC_STALE_SCAN_CRON`,两个**独立进程**同时给同一批患者建新版本:
各自在事务外读到同一个 latest.version、算出同一个 nextVersion →
`Unique constraint failed on the fields: (patient_id, version)`。
实测那一轮 CLI 挂 688 个、**定时任务自己挂 1,604 个**(它伤得更重)。

BullMQ processor 内部按 patient_id 串行,挡得住自己人,挡不住外面另起的 CLI 进程。
上一轮旧代码没撞,只是因为它在 2 点前就被我杀了 —— 运气,不是设计。

修法:两条写路径进事务先拿**按患者的 advisory 事务锁**
(`pg_advisory_xact_lock(hashtext(patientId))`,随事务自动释放、跨进程有效);
建版本那支再**在锁内重读**最高版本 —— 迟到者看到已经变大的版本号,顺序叠上去。
就地刷新那支也拿同一把锁,否则可能写到刚被并发写者 supersede 的版本上。

## 验证

本地真库对照:取同 300 个患者、删掉其画像(逼两个进程都算出 version=1)、两进程并发 --force

  旧代码   A: success 18  failed 216  |  B: success 282 failed 18   → **234 次 unique 冲突**
  新代码   A: success 186 failed 0    |  B: success 300 failed 0    → **0 次冲突**

498 单测通过(36 suites),tsc 干净。persona-watermark.spec 新增 4 例锁住:
建版本前必拿锁 / 版本号锁内重读 / 就地刷新那支也拿锁 / noop 不白拿锁。

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