Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
ee1ecaa7
Commit
ee1ecaa7
authored
Oct 06, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: sync main into host writeback development base
parents
b6cee831
9f7ec1a4
Show whitespace changes
Inline
Side-by-side
Showing
35 changed files
with
2144 additions
and
113 deletions
+2144
-113
apps/pac-docs/content/docs/integration/friday-push-payload.mdx
+52
-6
apps/pac-service/.env.example
+13
-0
apps/pac-service/data/friday/assemblers/patient_return_visit.yaml
+75
-0
apps/pac-service/data/friday/export.sh
+12
-0
apps/pac-service/data/friday/manifest.yaml
+29
-0
apps/pac-service/data/jvs-dw/manifest.yaml
+6
-0
apps/pac-service/src/cli/refresh-clinic-names.cli.ts
+22
-16
apps/pac-service/src/modules/plan/engine/plan-engine.service.ts
+170
-29
apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
+13
-1
apps/pac-service/src/modules/plan/plan-assignment.service.ts
+24
-3
apps/pac-service/src/modules/sync/cold-import/clinic-directory.ts
+67
-0
apps/pac-service/src/modules/sync/cold-import/cold-import.service.ts
+35
-5
apps/pac-service/src/modules/sync/cold-import/manifest.schema.ts
+8
-0
apps/pac-service/src/modules/sync/transforms/operators/derive.op.ts
+51
-0
apps/pac-service/src/modules/sync/transforms/transforms.schema.ts
+11
-0
apps/pac-service/src/prisma/prisma.service.ts
+29
-9
apps/pac-service/src/queues/daily-health-report.service.ts
+1
-1
apps/pac-service/src/queues/sync-incremental.scheduler.ts
+137
-11
apps/pac-service/tests/canonical-fact-layer.spec.ts
+5
-0
apps/pac-service/tests/clinic-directory-latest-name.spec.ts
+124
-0
apps/pac-service/tests/fixtures/reparse-bind-limit/assemblers/diagnosis.yaml
+13
-0
apps/pac-service/tests/fixtures/reparse-bind-limit/manifest.yaml
+21
-0
apps/pac-service/tests/friday-return-visit.spec.ts
+187
-0
apps/pac-service/tests/null-array-pg-cast.spec.ts
+89
-0
apps/pac-service/tests/persona-recompute-perf.spec.ts
+38
-8
apps/pac-service/tests/plan-engine-batch.spec.ts
+118
-1
apps/pac-service/tests/reparse-dry-run-bind-limit.spec.ts
+114
-0
apps/pac-service/tests/scheduler-kill-switch.spec.ts
+8
-2
apps/pac-service/tests/scheduler-reentrancy-guard.spec.ts
+4
-0
apps/pac-service/tests/sync-lock-reap.spec.ts
+214
-11
deploy/README.md
+20
-0
deploy/deploy-docs.sh
+102
-0
deploy/deploy-prod.sh
+20
-6
deploy/health-watch.sh
+207
-0
docker-compose.prod.yml
+105
-4
No files found.
apps/pac-docs/content/docs/integration/friday-push-payload.mdx
View file @
ee1ecaa7
---
title: FRIDAY 推送数据契约
description: FRIDAY SaaS 按形态 A 推送的
9
个 source 及字段定义;宿主推自洽的业务表(自己的引用 inline 进相关行),PAC 做跨宿主临床归一。
description: FRIDAY SaaS 按形态 A 推送的
10
个 source 及字段定义;宿主推自洽的业务表(自己的引用 inline 进相关行),PAC 做跨宿主临床归一。
icon: FileJson
---
...
...
@@ -290,11 +290,57 @@ icon: FileJson
---
## 7. 对接自测(预检 + 推送记录)
## 7. 回访(1 个 source)
### `customer_return_visit` — 客户回访(任务属性已 inline)
> **回访任务 `customer_task` 不再单独推**——它是**统一任务表**,按 `task_type` 分流
> (`1`咨询 → `customer_consult`、`2`回访 → 本表、`4`预约备注、`10`…),PAC 只要回访那一支的
> 「排程日期 + 任务状态」两个属性,任务头本身不是 PAC 实体。宿主把 `customer_task` 的
> 相关列 **inline 进每条回访记录**即可(关联:`customer_return_visit.task_id = customer_task.id`,
> 实测 2552/2552 全中、零空值)。这与计划行 inline 计划头属性是同一形态。
>
> ⚠️ **`task_date` 含未来排程**——界面「设回访」就是设在未来日期。PAC 据此区分
> 「已发生的回访」与「排了但还没做的」:详情页按它倒序展示,召回话术**只把已发生的**
> 算作"联系过"。**不要为了让数据好看而把未来日期改成当天**。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | ✅ | 回访记录主键(= PAC externalId,幂等键)|
| `tenant_id` | string | ✅ | 品牌 GUID |
| `organization_id` | string | ✅ | 诊所 |
| `customer_id` | string | ✅ | → 患者 id |
| `task_id` | string | | → `customer_task.id`(留作溯源)|
| `task_date` | string(date) | | **任务日期(宿主 inline,取自 `customer_task`)——含未来排程** |
| `task_status` | string/number | | **任务状态(宿主 inline)**——`1`进行中 `2`已完成 `3`未完成 `4`已预约 `5`创建新回访 `6`未回访 |
| `return_visit_type` | string/number | | 回访类型——`1`咨询回访 `2`术后回访 `3`常规回访 `4`自定义/事件驱动(含「取消预约回访」)`5`系统诊断召回。⚠️ 官方注释只写了 1-3,**4/5 是实测存在的**(占 12%) |
| `return_visit_status` | string/number | | 回访状态——`1`已回访 `2`未回访 |
| `treatment_items` | string | | 关联治疗项(大类,如「种植」)|
| `treatment_items_two` | string | | 关联治疗项(子项,如「单颗种植」)。⚠️ 与上一列**成对出现**,PAC 合并成单字段展示 |
| `follow_content` | string | | 回访任务内容。⚠️ **可能含 HTML 富文本**(界面是富文本编辑器),PAC 摄入时剥标签 |
| `return_visit_result` | string | | 回访结果 |
| `task_director_id` / `task_director` | string | | **执行本次回访的人**。⚠️ 见下方说明 |
| `return_visit_method` | string/number | | 回访方式——`1`打电话 `2`发微信 `3`发短信 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | 创建 / 末次修改时间 |
**建议一并 inline 的 `customer_task` 列**(PAC 当前不映射,原样存进 `raw_payload` 备用,
将来要用不必再找宿主改):`suggested_return_person`(建议回访人)、`actual_return_person` /
`actual_return_time`(实际回访人/时间)、`customer_status`(客户状态)、`is_first`(初诊/复诊)。
> **`task_director` 是「该任务当时派给谁」,不是患者的专属客服。**
> PAC 用它**按诊所反推客服名册**供主管指派(宿主不提供「客服归属诊所」字段,只能按行为反推)。
> 参照宿主 jvs-dw 实测:近 12 月 367 万对回访,「任务负责人」与「患者主档专属客服」仅 **23.1%** 相同。
>
> ⚠️ **`task_director` 可能是 `System`**(系统自动生成的回访,实测 158 条、全部是
> `return_visit_type=5` 的诊断召回)。PAC 侧**不会把 `System` 计入客服名册** —— 它不是人。
> 宿主照推即可,不需要过滤。
## 8. 对接自测(预检 + 推送记录)
对接期不必盲推 —— PAC 提供两个自助工具,**测试环境** `https://pac.jarvismedical.asia`:
###
7
.1 预检:`?dryRun=1`(强烈建议先跑)
###
8
.1 预检:`?dryRun=1`(强烈建议先跑)
```
POST /pac/v1/push/rows?dryRun=1
...
...
@@ -315,7 +361,7 @@ POST /pac/v1/push/rows?dryRun=1
**自测通过标准**:`failed=0` 且 `mappingMisses=0` 且 `suspectFields=0`,再看 `samples` 里的字段值对不对 —— 都 OK 才去掉 `?dryRun=1` 正式推。
###
7
.2 推送记录:事后可查(两种查法)
###
8
.2 推送记录:事后可查(两种查法)
**① 免登录 —— 用已有 push 凭据验签(联调推荐)**
```
...
...
@@ -335,7 +381,7 @@ GET /pac/v1/admin/host/self/push-logs?limit=50
`source / status / dryRun / fetched / transactionsWritten / duplicates / failed / errorMessage`。
当时的同步响应没留存也能事后查:**哪批错了、错在哪**。预检批次由 `dryRun:true` 标识,不与正式推送混淆。
###
7
.3 其他可用面
###
8
.3 其他可用面
| 用途 | 地址 |
|---|---|
...
...
@@ -350,7 +396,7 @@ GET /pac/v1/admin/host/self/push-logs?limit=50
---
##
8
. 语义澄清记录(2026-07,源码 + 数据双证)
##
9
. 语义澄清记录(2026-07,源码 + 数据双证)
以下曾是开放问题,现已定案 —— 推送方无需再确认,列此备查:
...
...
apps/pac-service/.env.example
View file @
ee1ecaa7
...
...
@@ -42,6 +42,16 @@ POSTGRES_DB=pac
#
# staging: postgresql://pac:<staging-pwd>@<staging-pg-host>:5532/pac?schema=public
# production: postgresql://pac:<prod-pwd>@<prod-pg-host>:5532/pac?schema=public
#
# ⚠️ 连接池:URL 里**不写** connection_limit 时,PrismaService 会按并发旋钮自动放大
# (PAC_DB_CONCURRENCY / PAC_COHORT_CONCURRENCY / PAC_RECALL_SUBSCENARIO_CONCURRENCY 取最大,
# 池 = N×6+5 封顶 40);全都 ≤1 才落回 Prisma 默认池。
# Prisma 默认池是「**物理核**×2+1」,不是逻辑核 —— 生产 nproc=4 但物理核只有 2,默认只有 **5** 条。
# URL 里一旦显式写了 connection_limit,自动放大**整个失效**(逃生口,写了就得自己负责够用)。
# ⛔ 但 compose 模式下**在本文件里写 connection_limit 是没用的** —— compose 的 environment:
# 段会整条覆盖 DATABASE_URL。要确认到底生效的是什么,只能看
# `docker inspect <容器> --format '{{range .Config.Env}}{{println .}}{{end}}' | grep DATABASE_URL`。
# 2026-09-02 生产事故就出在这:开了召回并发却没算进池,批次占满 5 条,每日健康日报连续三天超时。
DATABASE_URL=postgresql://pac:pac@localhost:5532/pac?schema=public
# staging+prod: redis://<host>:6479 (BullMQ 队列用,丢了会丢未消费的 plan-asset-generate 任务)
...
...
@@ -214,6 +224,9 @@ SENTRY_RELEASE=
# 瓶颈是**延迟**(逐次索引探查在等 page 返回)不是磁盘吞吐,所以并发 2 就超线性(×1.50)。
# ⚠️ 判据只能看 `[plan] 阶段耗时` 的**墙钟** —— 并发下单条查询的 sql= 会被争抢拉长,
# 看单条会误判成"变慢了"。详见 treatment-initiation-recall.scenario.ts 里 conc 处注释。
# ⚠️ **这个值会放大数据库连接池**(见上面 DATABASE_URL 处):批次会长时间占住这么多条连接,
# 不放大池的话常驻服务的其他活(每日健康日报、API)就抢不到 —— 2026-09-02 实际发生过。
# URL 里显式写了 connection_limit 的机器不吃这套自动放大,得自己保证池够大。
PAC_RECALL_SUBSCENARIO_CONCURRENCY=4
# gap 计算形态:legacy(默认,逐行相关子查询)/ setbased(集合式)。
...
...
apps/pac-service/data/friday/assemblers/patient_return_visit.yaml
0 → 100644
View file @
ee1ecaa7
# patient_return_visit — FRIDAY 客户回访(customer_return_visit → patient_return_visits 独立表)
#
# upsert 资源(无 emits,同 patient/patient_relation):落 PatientReturnVisit 表,
# **不进 transaction / fact、不参与召回**。用途:患者详情页「回访记录」展示 + 按诊所反推客服名册。
#
# ── 源表关系 ──
# 宿主是「统一任务表 + 分类型记录表」结构:
# customer_task(task_type: 1咨询 / 2回访 / 4预约备注 / 10…)
# ├─ task_type=1 → customer_consult (咨询,PAC 已单独摄入)
# └─ task_type=2 → customer_return_visit (回访,本 assembler)
# 关联 customer_return_visit.task_id = customer_task.id,实测 2552/2552 全中、零空值。
# 任务头不是 PAC 实体,故 **不单独摄入** —— 宿主把 task_date / task_status 等 inline 进回访行
# (同 customer_treat_plan_item inline 计划头的 organization_id/plan_name)。
#
# ── 枚举归一到中文,与 jvs-dw 对齐 ──
# jvs-dw 的 DW 侧本就是 *_name 中文列(常规回访/已回访/已完成…),FRIDAY 是数字码。
# 这里翻成**同样的中文**,否则同一张 PatientReturnVisit 表里两个宿主的值对不齐,
# 前端展示和按类型筛选都会分叉。
canonical
:
patient_return_visit
primary
:
table
:
customer_return_visit
key
:
id
dedup_by
:
id
field_mapping
:
externalId
:
id
patientExternalId
:
customer_id
clinicId
:
organization_id
# 排程日期(宿主 inline 自 customer_task)—— **含未来**,界面「设回访」就是设未来日期。
# PAC 据此区分「已发生」与「排了没做」:详情页倒序展示、召回话术只算已发生的。
# ⚠️ 客服名册判「在岗」用 sourceCreatedAt 而非本字段,正因为本字段含未来排程。
taskDate
:
task_date
taskStatus
:
task_status
type
:
return_visit_type
status
:
return_visit_status
# 大类·子项两列合并成单字段(同 jvs-dw)。实测:两列都有 499、只一级 257、只二级 0、都空 1796
# —— 与 jvs-dw「总是成对」同构;只有一级时合并结果尾部的分隔符由 normalizeMergedItems 归一掉。
treatmentItems
:
treatment_items_full
followContent
:
follow_content_text
# 已剥 HTML(transforms;界面是富文本编辑器)
result
:
return_visit_result
# 执行本次回访的人(≠ 患者主档专属客服)。用于按诊所反推客服名册供主管指派。
# ⚠️ 可能是 'System'(系统自动生成的诊断召回,实测 158 条全是 return_visit_type=5),
# 不是真人 —— 名册侧已排除,见 transforms 说明。
taskDirectorId
:
task_director_id
taskDirectorName
:
task_director
# 宿主侧业务时间(≠ PAC 入库时间 created_at/updated_at,后者重摄会被刷新)
sourceCreatedAt
:
created_gmt_at
sourceUpdatedAt
:
updated_gmt_at
# 数字码 → 中文,与 jvs-dw 的 DW *_name 列取值逐字对齐
enum_mapping
:
type
:
'
1'
:
咨询回访
'
2'
:
术后回访
'
3'
:
常规回访
# ⚠️ 官方注释只写了 1-3,但实测存在 4/5(占 12%,311/2552),不能落 _default 丢掉语义:
# 4 → 100% 带治疗项,取值含「取消预约回访」「自定义」→ 事件驱动的自定义回访
# 5 → 100% 带治疗项且全是**诊断名**(残根/龋齿/缺失牙/根尖周炎),task_director 全为 System
# → 系统按诊断自动生成的召回任务
'
4'
:
自定义回访
'
5'
:
系统召回
_default
:
'
'
status
:
'
1'
:
已回访
'
2'
:
未回访
_default
:
'
'
taskStatus
:
'
1'
:
进行中
'
2'
:
已完成
'
3'
:
未完成
'
4'
:
已预约
'
5'
:
创建新回访
'
6'
:
未回访
_default
:
'
'
apps/pac-service/data/friday/export.sh
View file @
ee1ecaa7
...
...
@@ -146,6 +146,18 @@ mysql_csv "SELECT uuid,tenant_id,organization_id,patient_id,doctor_id,status,rec
# 结算明细原表(全量含 is_refund,PAC 侧切退费明细)。JOIN 结算头:① 挂靠限定 cohort
# ② 反填真 patient_id(spec.patient_id 部分品牌是诊所本地 id,不可信 → 取结算头 ps.patient_id inline)
mysql_csv
"SELECT sp.id,sp.tenant_id,sp.organization_id,ps.patient_id,sp.settlement_id,sp.cure_name,sp.service_project_name,sp.receivable_this,sp.net_receipts_this,sp.is_refund,sp.created_gmt_at,sp.updated_gmt_at FROM
\`
arrail-settlement-server
\`
.patient_settlement_spec sp JOIN
\`
arrail-settlement-server
\`
.patient_settlement ps ON ps.uuid=sp.settlement_id
$(
pf ps.patient_id
)
"
patient_settlement_spec.csv
# 客户回访 + inline 任务属性(取自统一任务表 customer_task 的 task_type=2 那一支;任务头不单独导)
# 关联 rv.task_id = t.id,实测 2552/2552 全中。task_date 含未来排程(界面「设回访」设未来日期),
# 不要在导出侧过滤掉未来行 —— PAC 靠它区分「已发生」与「排了没做」。
# 末尾几列 PAC 当前不映射,原样带出存进 raw_payload 备用(宿主多给无妨,PAC 选择性摄入)。
mysql_csv
"SELECT rv.id,rv.tenant_id,rv.organization_id,rv.customer_id,rv.task_id,
t.task_date,t.task_status,
rv.return_visit_type,rv.return_visit_status,rv.treatment_items,rv.treatment_items_two,
rv.follow_content,rv.return_visit_result,rv.task_director_id,rv.task_director,
rv.return_visit_method,rv.created_gmt_at,rv.updated_gmt_at,
t.suggested_return_person,t.actual_return_person,t.actual_return_time,t.customer_status,t.is_first
FROM customer.customer_return_visit rv
LEFT JOIN customer.customer_task t ON t.id=rv.task_id AND t.task_type=2
$(
pf rv.customer_id
)
"
customer_return_visit.csv
# 转介绍圈(患者-患者关系边,双向成对存;referee_relationship 为宿主字典 id,PAC 侧统计推断解码)
mysql_csv
"SELECT id,tenant_id,organization_id,customer_id,referee_patient_id,referee_relationship,type,created_gmt_at,updated_gmt_at FROM customer.customer_referee_circle
$(
pf customer_id
)
"
customer_referee_circle.csv
# 咨询(意向 potential_treatment/未成交原因;测试库仅 45 行,结构真实)
...
...
apps/pac-service/data/friday/manifest.yaml
View file @
ee1ecaa7
...
...
@@ -41,6 +41,8 @@ clinic_directory:
table
:
med_emr_info
id_field
:
organization_id
name_field
:
organization_name
# 按时间取最新(同 jvs-dw 的理由:诊所改名后旧名仍留在历史行里)。
time_field
:
updated_gmt_at
# ── 增量水位声明(2026-07 与 jvs-dw 统一;file 源放 manifest 顶层)──
...
...
@@ -58,6 +60,7 @@ incremental:
customer_treat_plan_item
:
{
cursor_column
:
updated_gmt_at
}
customer_referee_circle
:
{
cursor_column
:
updated_gmt_at
}
customer_consult
:
{
cursor_column
:
updated_gmt_at
}
customer_return_visit
:
{
cursor_column
:
updated_gmt_at
}
# ⭐ 宿主推「自洽的业务表」:私有字典码 / 1:N 派生 / 头-行属性 都由宿主 within-host join 后 inline 进相关行
# (契约见 docs/integration/friday-push-payload)。故不再摄入纯字典表(std_diag/std_check_class)、
...
...
@@ -76,6 +79,9 @@ tables:
-
{
table
:
patient_settlement_spec
,
file
:
patient_settlement_spec.csv
}
# 结算明细原表(全量,含 is_refund)
-
{
table
:
customer_referee_circle
,
file
:
customer_referee_circle.csv
}
# 转介绍圈(患者-患者边)
-
{
table
:
customer_consult
,
file
:
customer_consult.csv
}
# 咨询(意向/未成交原因)
# 回访:宿主「统一任务表 customer_task」按 task_type 分流,2=回访 → 本表(1=咨询已由 customer_consult 接)。
# 任务头不单独摄入,task_date/task_status 等由宿主 inline 进回访行(同计划行 inline 计划头)。
-
{
table
:
customer_return_visit
,
file
:
customer_return_visit.csv
}
# 客户回访(含 inline task_date/task_status)
transforms
:
# ── 患者 phone:宿主已按「默认号→本人→最早」挑好、inline 进 customer_basic_info 的
...
...
@@ -386,6 +392,28 @@ transforms:
op
:
concat
parts
:
[
'
${referee_relationship}'
,
'
|'
,
'
${referee_sex}'
]
# ═══════════ 回访(customer_return_visit → patient_return_visits 独立表)═══════════
# J.1 治疗项两列合并成单字段(同 jvs-dw:大类「种植」+ 子项「单颗种植」)。
# 实测 2552 行:两列都有 499、只一级 257、只二级 0、都空 1796 —— 与 jvs-dw「总是成对」同构。
# 只有一级时合并出 "种植 · ",尾部分隔符由落库前的 normalizeMergedItems 归一掉;
# 两列全空合并出 " · ",同样被归一成 null(回访常无治疗项,占 70%)。
-
kind
:
derive
input
:
customer_return_visit
output
:
customer_return_visit
fields
:
treatment_items_full
:
op
:
concat
parts
:
[
'
${treatment_items}'
,
'
·
'
,
'
${treatment_items_two}'
]
# J.2 回访内容剥富文本标签 —— 宿主界面是富文本编辑器,实测 794/2552(31%)带 <p>/<ol>/<li>。
# PAC 侧该字段是纯文本语义(详情页直接展示 + 喂召回话术 LLM),带标签会原样显示成 "<p>xxx</p>"。
-
kind
:
derive
input
:
customer_return_visit
output
:
customer_return_visit
fields
:
follow_content_text
:
op
:
strip_html
from
:
follow_content
assemblers
:
-
{
file
:
assemblers/patient.yaml
}
-
{
file
:
assemblers/appointment.yaml
}
...
...
@@ -402,3 +430,4 @@ assemblers:
-
{
file
:
assemblers/refund_item.yaml
}
-
{
file
:
assemblers/patient_relation.yaml
}
-
{
file
:
assemblers/consult.yaml
}
-
{
file
:
assemblers/patient_return_visit.yaml
}
# 客户回访(展示 + 客服名册)→ PatientReturnVisit upsert
apps/pac-service/data/jvs-dw/manifest.yaml
View file @
ee1ecaa7
...
...
@@ -64,6 +64,12 @@ clinic_directory:
table
:
fact_emr_treatment_out
id_field
:
organization_id
name_field
:
organization_name
# ⚠️ 必须按时间取最新 —— 诊所改过名,源表里同一个 id 存着历次名字,且**旧名往往行数更多**:
# 273393ed… 「正大诊所」91,053 行(2016-07~2022-08,早停用)
# 「花旗医院」3,096 行(2023-06~2023-09,过渡)
# 「瑞尔齿科上海花旗诊所」42,646 行(2023-09~至今,现名)
# 66 家里 53 家都有多个名字。取 any()/最高频都会拿到旧名。
time_field
:
updated_date
amount_unit
:
yuan
timezone
:
Asia/Shanghai
...
...
apps/pac-service/src/cli/refresh-clinic-names.cli.ts
View file @
ee1ecaa7
...
...
@@ -25,6 +25,11 @@ import { createClient } from '@clickhouse/client';
import
{
AppModule
}
from
'../app.module'
;
import
{
PrismaService
}
from
'../prisma/prisma.service'
;
import
{
ColdImportManifestSchema
}
from
'../modules/sync/cold-import/manifest.schema'
;
import
{
buildClinicNameQuery
,
pickLatestNames
,
type
ClinicDirectoryConfig
,
}
from
'../modules/sync/cold-import/clinic-directory'
;
import
{
disableSchedulersForCli
}
from
'./bootstrap-flags'
;
// CLI 是短命进程,不需要 org-tree 启动预热(且会在 app.close() 时跟后台 warmAll 抢连接报噪音)。
...
...
@@ -37,7 +42,7 @@ function flag(name: string): string | undefined {
async
function
deriveFromClickhouse
(
source
:
NonNullable
<
ReturnType
<
typeof
ColdImportManifestSchema
.
parse
>
[
'sql_source'
]
>
,
dir
:
{
table
:
string
;
id_field
:
string
;
name_field
:
string
}
,
dir
:
ClinicDirectoryConfig
,
logger
:
Logger
,
):
Promise
<
Record
<
string
,
string
>>
{
const
url
=
process
.
env
.
DW_CLICKHOUSE_URL
?.
trim
()
||
source
.
connection
.
url
;
...
...
@@ -51,12 +56,15 @@ async function deriveFromClickhouse(
const
realTable
=
(()
=>
{
const
sql
=
source
.
queries
[
dir
.
table
];
const
m
=
sql
?.
match
(
/FROM
\s
+
([\w
.
]
+
)
/i
);
return
m
?
m
[
1
]
:
dir
.
table
;
return
m
?.[
1
]
??
dir
.
table
;
})();
const
q
=
`SELECT
${
dir
.
id_field
}
AS id, any(
${
dir
.
name_field
}
) AS name `
+
`FROM
${
realTable
}
WHERE notEmpty(
${
dir
.
id_field
}
) AND notEmpty(
${
dir
.
name_field
}
) `
+
`GROUP BY
${
dir
.
id_field
}
`
;
if
(
!
dir
.
time_field
)
{
logger
.
warn
(
`clinic_directory 未声明 time_field —— 无法判断哪个名字是现名,改名过的诊所会拿到旧名。`
+
`请在 manifest 补 time_field(如 updated_date)。`
,
);
}
const
q
=
buildClinicNameQuery
(
realTable
,
dir
);
logger
.
log
(
`CH 派生:
${
q
}
`
);
const
rs
=
await
client
.
query
({
query
:
q
,
format
:
'JSONEachRow'
});
const
rows
=
(
await
rs
.
json
())
as
Array
<
{
id
:
string
;
name
:
string
}
>
;
...
...
@@ -71,7 +79,8 @@ async function deriveFromClickhouse(
function
deriveFromFile
(
manifestDir
:
string
,
tables
:
Array
<
{
table
:
string
;
file
:
string
;
format
?:
'csv'
|
'json'
}
>
,
dir
:
{
table
:
string
;
id_field
:
string
;
name_field
:
string
},
dir
:
ClinicDirectoryConfig
,
logger
?:
Logger
,
):
Record
<
string
,
string
>
{
const
t
=
tables
.
find
((
x
)
=>
x
.
table
===
dir
.
table
);
if
(
!
t
)
throw
new
Error
(
`directory.table=
${
dir
.
table
}
不在 tables[] 里`
);
...
...
@@ -80,15 +89,12 @@ function deriveFromFile(
const
rows
:
Array
<
Record
<
string
,
unknown
>>
=
t
.
file
.
toLowerCase
().
endsWith
(
'.json'
)
?
JSON
.
parse
(
raw
)
:
parseCsv
(
raw
,
{
columns
:
true
,
skip_empty_lines
:
true
,
trim
:
true
});
const
map
:
Record
<
string
,
string
>
=
{};
for
(
const
r
of
rows
)
{
const
id
=
r
[
dir
.
id_field
];
const
name
=
r
[
dir
.
name_field
];
if
(
id
!=
null
&&
String
(
id
)
!==
''
&&
name
!=
null
&&
String
(
name
)
!==
''
)
{
map
[
String
(
id
)]
=
String
(
name
);
}
if
(
!
dir
.
time_field
)
{
logger
?.
warn
(
`clinic_directory 未声明 time_field —— 无法判断哪个名字是现名,改名过的诊所会拿到旧名。`
,
);
}
return
map
;
return
pickLatestNames
(
rows
,
dir
)
;
}
async
function
main
():
Promise
<
void
>
{
...
...
@@ -111,7 +117,7 @@ async function main(): Promise<void> {
const
clinicMap
=
manifest
.
sql_source
?
await
deriveFromClickhouse
(
manifest
.
sql_source
,
cd
,
logger
)
:
deriveFromFile
(
manifestDir
,
manifest
.
tables
??
[],
cd
);
:
deriveFromFile
(
manifestDir
,
manifest
.
tables
??
[],
cd
,
logger
);
const
nc
=
Object
.
keys
(
clinicMap
).
length
;
logger
.
log
(
`派生诊所名
${
nc
}
家`
);
if
(
nc
===
0
)
{
...
...
apps/pac-service/src/modules/plan/engine/plan-engine.service.ts
View file @
ee1ecaa7
...
...
@@ -26,6 +26,73 @@ import { PlanLabelService } from '../plan-label.service';
* - 有 active plan + hits 不同 → supersede 旧 + 创建新版本
* - 已有 assigned plan → 不动(避免抢正在跟进的客服)— v1 用 simple skip
*/
/**
* plan 写入段的**端到端批大小**(按患者切)。
*
* 🔴 2026-09-04 生产挂死 ~11.8 小时(20:06 → 次日 07:52 硬重启)后加的。
*
* ⚠️ **本注释 09-05 全面更正过一次,更正掉的东西比留下的多,值得先读这段。**
* 初版写的是「内核 OOM killer 杀了 node(anon-rss 6.03GB)→ 整机 swap 抖死」。
* 事后用 `journalctl -b -1` 核查,三条全错:
* ① 当晚**内核一条 OOM 都没打**。VNC 上看到的那条 `Killed process 945388 (node)`
* 是 **2026-08-29 07:26:17** 的,pid/anon-rss/pgtables 与内核日志逐字段相同 ——
* 内核 cmdline 带 `console=tty0`,它在 framebuffer 上留了 6 天没被刷掉。
* ② **这台机没有 swap**(`Swap: 0B`、`vm.swappiness=0`),「swap 抖死」不成立。
* ③ 没有崩溃循环:内核 `eth0: renamed from` 按天计数 Sep 03=0 / Sep 04=0,
* 当晚一次容器启动都没有,dockerd 整晚无日志。
* 真正的形态是**无 swap 下的 page-cache refault 活锁**:匿名页吃满后内核只能回收
* 文件页,把程序正文/mmap 刷掉、下一条指令又缺页读回来(监控上那条 286MB/s 满速读、
* 写≈0、CPU~15%)。因为**始终有文件页可回收,`out_of_memory()` 从未被调用** ——
* 没人被杀,也就没有任何自愈动作,一路挂到人工硬重启。
* ⇒ 对照 08-29:同一台机、同一水位、同样吃到 6GB,那次走 OOM kill,机器 10 分钟量级自愈;
* 这次走活锁,挂了 11.8 小时。**失败形态比内存本身更决定后果。**
*
* 死点(用生产库写入痕迹重建,应用日志已随部署 --force-recreate 永久丢失):
* 18:50:42 开跑 → 场景+预取 4,110s → 写入段 **仅 43s**(550,011 行落库)
* → 第 3 步 stale-close 分片事务最后一次提交 **20:05:49**(全库最后一次写)
* → 收尾 backfillMissing 一行没写,`[plan] 阶段耗时` 从未打出。
* ⇒ 死在第 3 步刚提交完的那几秒。场景/预取/写入三段都跑完了。
*
* 堆账(550,011 命中患者 / 约 96 万条 hit,按 V8 对象布局建模,**未经 heap snapshot 实测**):
* latestByPatient 1.93GB + hitsByPatient 1.06GB + persona/visit 0.14GB
* + activePlans 0.09GB + logRows 0.07GB ≈ **活跃保留 3.3GB**。
*
* ⚠️ **本改动治什么、不治什么**(别把它当这次事故的直接对策):
* 治:把上面那堆「同时在堆」的产物拆成单批驻留 → 降低峰值 → 降低再次触发活锁的概率。
* 测试机实测 45 批 heapUsed 单调下降 462→107MB,零累积。
* ⛔ 不治:① selectHits → hitsByPatient 的交接仍同时持有**两份**完整 hit 载荷(≈2.1GB,
* 见下方批循环上游 `arr.push({...h})` 那处),这是现存最大的未处理项;
* ② 第 3 步仍一次拉整池(事故当轮 549,926 行);
* ③ 真正的兜底缺口不在这个文件:容器 `MemLimit=0` + 无 swap ⇒ cgroup OOM 与内核 OOM
* 两道兜底全被绕过,失败才会呈现为「活锁」而不是「杀掉重启」。
*
* 病根不是"取数没分块"(取数早就是 2000 一块),是**产物全程不释放**:
* runPool 的闭包(见下方批循环)把 latest/persona/visit/logRows 全部 context-allocate,
* V8 要到 runAllForHost 整个 frame 结束才可能回收 → 第 3 步扫全池时它们全还在,
* 那一下就是峰值。所以要把「取数分块」升格成「端到端分批」:每批取数→写库→释放。
*
* ⚠️ 上限 20000 是硬约束不是调优:latest/persona 两条走 Prisma `patientId: { in: ids }`,
* 每个 id 占一个 bind,PG 单条 prepared statement 上限 32767。
*
* 🔴 **这里没有 single-shot 回退档,别再加回来。**
* 初版曾照 cold-import 的 resolveCohortBatchSize 做成「显式传 0 = 不分批」当回滚开关。
* 2026-09-04 在测试机实跑当场炸:`PAC_PLAN_BATCH_SIZE=0` + 88,728 命中患者 →
* `too many bind variables in prepared statement, expected maximum of 32767, received 32769`
* 两个错都在那一档上:
* ① **它绕过了上面那条 20000 的硬上限** —— 昨天刚给 reparse 夹住同一堵墙(reparseBatchSize),
* 今天在这儿开了个后门让它从另一侧长回来。
* ② **它压根不等价于改造前。** 改造前 prefetchForBatch 内部有 CHUNK=2000 循环,
* 取数从来没有一次超过 2000 个 id;把内循环删掉后,「一批 = 全部患者」是旧代码
* 从未有过的行为。拿它当对照组,量出来的东西没有意义。
* ⇒ 真正的回退手段是 revert 这个提交,不是拧旋钮。旋钮只调批大小,不调"分不分批"。
*
* ⛔ 别把 snooze 预取塞进批循环(见 prefetchSnoozeAnchors 的注释):那是已经被消掉的
* O(患者数) 项,塞回去到 200 万患者就是 1 次 vs 1000 次。
*/
export
function
resolvePlanBatchSize
(
env
:
NodeJS
.
ProcessEnv
=
process
.
env
):
number
{
return
Math
.
min
(
20
_000
,
Math
.
max
(
1
,
Number
(
env
.
PAC_PLAN_BATCH_SIZE
)
||
2000
));
}
@
Injectable
()
export
class
PlanEngineService
{
private
readonly
logger
=
new
Logger
(
PlanEngineService
.
name
);
...
...
@@ -272,20 +339,56 @@ export class PlanEngineService {
// 注:selectHits 的全表扫不在本优化内(时间规则随时可改,每轮必须全量重选才正确)。
const
selectMs
=
Date
.
now
()
-
tSelect
;
const
patientIds
=
[...
hitsByPatient
.
keys
()];
/**
* ⭐ 第 3 步(stale-close)是整条链**唯一**的硬全局依赖:它拿本轮命中集对整个召回池取补集
* (`!hitsByPatient.has(pid)` → supersede)。分批之后 hitsByPatient 会被逐批清空,
* 所以这里先把「本轮命中过谁」单独留成一个 Set —— 只存 id,不存 hit 载荷。
* 🔴 别把第 3 步搬进批循环:每批都会把**其他批**的患者判成"信号消失",
* 结果是整池 supersede + 每条认领单一条 auto_release,**而且不报错** ——
* 下面那行「本轮关闭 N 条无信号 plan」的日志反而会把它解释成「这不是 bug」。
* 第 3 步必须等所有批跑完再跑一次。
*/
const
hitPatientIds
=
new
Set
(
patientIds
);
const
tPrefetch
=
Date
.
now
();
const
{
latestByPatient
,
snoozedByPatient
,
personaByPatient
,
lastVisitClinicByPatient
,
touchedPlanIds
}
=
await
this
.
prefetchForBatch
(
scope
,
patientIds
,
now
);
cons
t
prefetchMs
=
Date
.
now
()
-
tPrefetch
;
// snooze 整轮一次(见 prefetchSnoozeAnchors);其余三条按批取。
const
snoozedByPatient
=
await
this
.
prefetchSnoozeAnchors
(
scope
,
now
);
le
t
prefetchMs
=
Date
.
now
()
-
tPrefetch
;
const
tWrite
=
Date
.
now
();
// 只累计真正的写库段(runPool + createMany),不含批内预取 —— 否则与 预取= 重叠
let
writeMs
=
0
;
const
EMPTY_SNOOZE
=
new
Map
<
string
,
Date
>
();
const
logRows
:
Prisma
.
PlanGenerationLogCreateManyInput
[]
=
[];
// ⭐ 2026-07-26:写操作从「每 N 个一批 await Promise.all」的**栅栏**改成连续调度的
// worker pool(见 common/run-pool.ts)。栅栏每批都要等本批最慢那个,快的 worker 干等。
// 同一处改法在 recompute-persona 上实测过(测试服 35.7 万患者,concurrency=8):
// 并发效率 44% → 94%,吞吐 1,454 → 2,694 人/分。
// plan 侧单患者耗时的离散度只会更大(upsertPlan 里 reason 数量差异明显),栅栏的浪费同理。
const
concurrency
=
Math
.
max
(
1
,
Number
(
process
.
env
.
PAC_PLAN_BATCH_CONCURRENCY
)
||
8
);
const
entries
=
[...
hitsByPatient
.
entries
()];
/**
* ⭐ 端到端批循环(2026-09-04)。每批:预取 → 写库 → **释放**。
*
* ⛔ **循环体里不要加 try/catch**。今天的语义是「selectHits 抛错发生在任何写之前 → 整轮中止,
* 第 3 步不跑」;分批后前 N 批已经落库了,若在这里吞掉异常"把剩下的批跑完",
* 第 3 步就会拿着**残缺**的命中集去关池子 —— 后果同上面 hitPatientIds 那段注释。
* 逐患者的 try/catch(下面 runPool 回调里那个)原样保留,那个是有意的。
*/
const
batchSize
=
resolvePlanBatchSize
();
const
step
=
batchSize
;
let
batches
=
0
;
for
(
let
off
=
0
;
off
<
patientIds
.
length
;
off
+=
step
)
{
const
ids
=
patientIds
.
slice
(
off
,
off
+
step
);
batches
+=
1
;
const
tb
=
Date
.
now
();
const
{
latestByPatient
,
personaByPatient
,
lastVisitClinicByPatient
,
touchedPlanIds
}
=
await
this
.
prefetchOneBatch
(
scope
,
ids
);
prefetchMs
+=
Date
.
now
()
-
tb
;
const
tw
=
Date
.
now
();
const
logRows
:
Prisma
.
PlanGenerationLogCreateManyInput
[]
=
[];
// hit 的所有权交给本批的局部数组,随即从 Map 里删掉 —— 批末连同 latest/persona/visit/logRows
// 一起出作用域被回收。这是本次改造真正省内存的那一下(hitsByPatient 满载约 1.06GB)。
const
entries
=
ids
.
map
(
(
pid
)
=>
[
pid
,
hitsByPatient
.
get
(
pid
)
??
[]]
as
[
string
,
ScenarioHitWithKey
[]],
);
for
(
const
pid
of
ids
)
hitsByPatient
.
delete
(
pid
);
await
runPool
(
entries
,
concurrency
,
async
([
patientId
,
hits
])
=>
{
try
{
const
result
=
await
this
.
upsertPlan
({
...
...
@@ -336,6 +439,19 @@ export class PlanEngineService {
for
(
let
i
=
0
;
i
<
logRows
.
length
;
i
+=
5000
)
{
await
this
.
prisma
.
planGenerationLog
.
createMany
({
data
:
logRows
.
slice
(
i
,
i
+
5000
)
});
}
writeMs
+=
Date
.
now
()
-
tw
;
// 内存水位:沿用 cold-import 的 `rss=` 字样便于跟历史并排 grep;额外带 heapUsed ——
// 判 8G 堆上限还剩多少余量只能看 heapUsed,rss 含 Prisma Rust 引擎与碎片,不够精确。
// 每批一行太吵:每 5 批 + 末批各打一次(同 cold-import 的做法)。
if
(
batches
%
5
===
0
||
off
+
step
>=
patientIds
.
length
)
{
const
mu
=
process
.
memoryUsage
();
const
mb
=
(
n
:
number
)
=>
Math
.
round
(
n
/
1024
/
1024
);
this
.
logger
.
log
(
`[plan] 批
${
batches
}
患者=
${
ids
.
length
}
累计=
${
off
+
ids
.
length
}
/
${
patientIds
.
length
}
`
+
`rss=
${
mb
(
mu
.
rss
)}
MB heapUsed=
${
mb
(
mu
.
heapUsed
)}
MB heapTotal=
${
mb
(
mu
.
heapTotal
)}
MB`
,
);
}
}
// 3. ⭐ 缺口1 修复:关闭"有 active plan 但本轮 0 命中"的患者的遗留 plan。
// 旧版只遍历 hitsByPatient(有命中的患者)→ 治完疗变 0 命中的患者,其 plan 永远残留召回池。
...
...
@@ -359,8 +475,11 @@ export class PlanEngineService {
// ⭐ 子集模式(--clinics)必须**同步收窄**:本轮只评估了子集,子集外的患者天然 0 命中,
// 不收窄就会把整个 host 的召回池当成"信号全消失"清空,认领中的单还各记一条 auto_release。
// 用内存 Set 过滤而非 SQL `patientId: { in: ids }` —— 子集动辄数万,会撞 PG 32767 bind 上限。
// ⚠️ 2026-09-04:判据从 `hitsByPatient.has` 换成 `hitPatientIds.has` —— 分批之后
// hitsByPatient 已被逐批清空(hit 载荷交给各批局部数组以便回收),只有那个 id Set 还在。
// 两者在改造前后是同一个集合(Set 由 hitsByPatient.keys() 构造且此后只读)。
const
staleRows
=
activePlans
.
filter
(
(
pl
)
=>
!
hit
sByPatient
.
has
(
pl
.
patientId
)
&&
(
!
scopedPatientIds
||
scopedPatientIds
.
has
(
pl
.
patientId
)),
(
pl
)
=>
!
hit
PatientIds
.
has
(
pl
.
patientId
)
&&
(
!
scopedPatientIds
||
scopedPatientIds
.
has
(
pl
.
patientId
)),
);
if
(
staleRows
.
length
>
0
)
{
// ⚠️ 必须分片:原实现是一条 `id: { in: staleIds }`,PG bind 变量上限 32767 —— 池子上了
...
...
@@ -422,13 +541,22 @@ export class PlanEngineService {
this
.
logger
.
warn
(
`[标签] 补齐失败(不阻断生成,夜间刷新会兜住):
${
e
instanceof
Error
?
e
.
message
:
e
}
`
);
}
// ⚠️ 分段口径(2026-09-04 修正):「预取=」是 N 批预取之和 + 整轮一次的 snooze,
// 「写入=」只算真正写库那段(runPool + createMany),**两者不重叠**,
// 场景+预取+写入 ≈ 总计。初版把「写入=」写成整个批循环的墙钟(把预取又算了一遍),
// 实测那轮显示 写入=1,243,665ms 里有 983,448ms 其实是预取 —— 误导性的度量比没有度量更糟,
// 而这几个数正是判断本次改造成败的依据。
// `批=` 便于跟历史对照;判据仍以整轮墙钟为准。
void
tWrite
;
this
.
logger
.
log
(
`[plan] 阶段耗时 场景=
${
selectMs
}
ms 预取=
${
prefetchMs
}
ms 写入=
${
Date
.
now
()
-
tWrite
}
ms `
+
`命中患者=
${
patientIds
.
length
}
总计=
${
Date
.
now
()
-
startedAt
.
getTime
()}
ms`
,
`[plan] 阶段耗时 场景=
${
selectMs
}
ms 预取=
${
prefetchMs
}
ms 写入=
${
writeMs
}
ms `
+
`批=
${
batches
}
(size=
${
batchSize
}
) `
+
`命中患者=
${
hitPatientIds
.
size
}
总计=
${
Date
.
now
()
-
startedAt
.
getTime
()}
ms`
,
);
return
{
scenariosRun
:
this
.
scenarios
.
length
,
patientsHit
:
hitsByPatient
.
size
,
// ⚠️ 不能用 hitsByPatient.size —— 分批后它已被清空,恒为 0。
patientsHit
:
hitPatientIds
.
size
,
plansCreated
,
plansSuperseded
,
plansUnchanged
,
...
...
@@ -913,23 +1041,17 @@ export class PlanEngineService {
* 取数口径与 upsertPlan(findFirst orderBy version desc)/ fetchSnoozedSignalKeys(终态+未到期)
* 严格对齐;IN 列表分块防参数超限。
*/
private async prefetchForBatch(
/**
* snooze 抑制集 —— **整轮查一次,绝不能进批循环**。
* 它的代价跟「终态且冷静期未到期的计划数」走,跟患者数无关(生产实测全库 106 行)。
* 2026-09-04 把 prefetchForBatch 拆成「整轮一次」+「每批一次」两半时单独抽出来,
* 就是为了让这条纪律在类型上也成立:它压根拿不到 patientIds,想塞进批里也塞不了。
*/
private async prefetchSnoozeAnchors(
scope: ScenarioScope,
patientIds: string[],
now: Date,
): Promise<{
latestByPatient: Map<string, PlanWithReasons>;
snoozedByPatient: Map<string, Map<string, Date>>;
personaByPatient: Map<string, string>;
lastVisitClinicByPatient: Map<string, string>;
/// 「客服碰过」的 planId 集合 —— ⚠️ **只覆盖带 assignment_id 的单**:
/// 没进过批次的单本来就没有归因可继承,为它们查 view 事件是白扫全表。
touchedPlanIds: Set<string>;
}> {
const latestByPatient = new Map<string, PlanWithReasons>();
): Promise<Map<string, Map<string, Date>>> {
const snoozedByPatient = new Map<string, Map<string, Date>>();
const personaByPatient = new Map<string, string>();
const lastVisitClinicByPatient = new Map<string, string>();
// ⭐ snooze 抑制集**提到循环外一次查完** —— 它的代价跟「终态且冷静期未到期的计划数」走,
// 跟患者数无关。2026-08-30 生产实测:全库符合条件的只有 **106 行**,
...
...
@@ -965,10 +1087,31 @@ export class PlanEngineService {
snoozedByPatient.set(pid, buildSnoozeAnchors(plans));
}
}
return snoozedByPatient;
}
const CHUNK = 2000;
for (let i = 0; i < patientIds.length; i += CHUNK) {
const ids = patientIds.slice(i, i + CHUNK);
/**
* **一批**患者的预取(不含 snooze —— 那个整轮一次,见 prefetchSnoozeAnchors)。
*
* 2026-09-04 从 prefetchForBatch 里拆出来:原先这里有个内部 `
CHUNK
=
2000
` 循环,
* 结果**累积**在跨全量存活的几个 Map 里 —— 取数是分块的,内存不是。
* 现在把切批权交给调用方,本方法只管一批,返回的 Map 生命周期 = 该批,批末即可回收。
*/
private async prefetchOneBatch(
scope: ScenarioScope,
ids: string[],
): Promise<{
latestByPatient: Map<string, PlanWithReasons>;
personaByPatient: Map<string, string>;
lastVisitClinicByPatient: Map<string, string>;
/// 「客服碰过」的 planId 集合 —— ⚠️ **只覆盖带 assignment_id 的单**:
/// 没进过批次的单本来就没有归因可继承,为它们查 view 事件是白扫全表。
touchedPlanIds: Set<string>;
}> {
const latestByPatient = new Map<string, PlanWithReasons>();
const personaByPatient = new Map<string, string>();
const lastVisitClinicByPatient = new Map<string, string>();
{
// ⭐ 三条彼此独立,**并行发** —— 原来是串行,每 chunk 的墙钟 = 三条之和;
// 并行后 = 最慢那条。三条都只读、无共享状态,并行不改任何口径。
// 并发度就是 3(不随患者数涨),不会挤爆连接池(Prisma 默认池 = 核数×2+1)。
...
...
@@ -1029,9 +1172,7 @@ export class PlanEngineService {
for (const r of rows) touchedPlanIds.add(r.planId);
}
return {
latestByPatient, snoozedByPatient, personaByPatient, lastVisitClinicByPatient, touchedPlanIds,
};
return { latestByPatient, personaByPatient, lastVisitClinicByPatient, touchedPlanIds };
}
/// 单刷路径:解析该患者最后一次到诊(encounter/emr)所在诊所(批量路径走 prefetchForBatch)。
...
...
apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
View file @
ee1ecaa7
...
...
@@ -251,6 +251,10 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ⚠️ **读数注意**:并发下**单条**查询的 sql= 会被争抢拉长(生产 caries 23.8→27.7 分,
// perio 12.6→16.9 分),看单条会误以为变慢了。**判据只能是阶段墙钟**,
// 不能看各条耗时之和 —— 上面那次误判有一半就栽在这。
// ⚠️ 这个旋钮**同时是连接池的输入** —— 批次会长时间占住 conc 条连接。
// withCohortDerivedPool() 已把它算进池(N×6+5);2026-09-02 之前没算,生产池只有 5,
// 批次占 4 条 → 每日 09:00 健康日报的并发 count 抢不到连接,连续三天超时。
// ⛔ 调大它之前先确认该机 DATABASE_URL 没有显式 connection_limit(写了就绕过自动放大)。
const
conc
=
Math
.
max
(
1
,
Number
(
process
.
env
.
PAC_RECALL_SUBSCENARIO_CONCURRENCY
)
||
1
);
// ⚠️ 不能 hits.push(...subHits):spread 把每个元素当实参压栈,V8 实参上限 ~6.5万;
// host 患者到 ~28 万后单子场景命中可超限 → RangeError: Maximum call stack size exceeded
...
...
@@ -623,7 +627,15 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ⛔ 必须用事务级 SET LOCAL,**不能全局调**:work_mem 是「每个排序/哈希节点」的上限,
// 不是每连接。生产 RDS 只有约 7GB 内存(shared_buffers 1.83GB 反推),全局设大值
// 遇上几十个并发连接同时排序会把内存吃穿。SET LOCAL 出了事务自动还原,
// Web API 的连接完全不受影响;且场景查询是串行的,同一时刻只有一条在跑,峰值可控。
// Web API 的连接完全不受影响。
//
// 🔴 **原文这里还有一句「且场景查询是串行的,同一时刻只有一条在跑,峰值可控」——
// 那句自 2026-08-30 起就是错的**,已删。生产从那天起设了
// PAC_RECALL_SUBSCENARIO_CONCURRENCY=4(见上方 conc 处注释),于是 RDS 上
// **同时有 4 个带 SET LOCAL work_mem='256MB' 的事务**。
// ⇒ DB 侧峰值内存 = 256MB × 并发数 × 该查询的排序/哈希节点数,不再"可控"。
// 生产 RDS 约 7GB,4 路已是能承受的上限;⛔ 谁要再调大 conc 或 work_mem,
// 必须先算这笔账,别只看场景段墙钟。(2026-09-05 事故复盘时发现这处注释失真。)
//
// ⛔ 别再去加「信号码部分索引」——(content->>'code') 上的部分索引在测试机和生产
// 都实测过,生产上 −5% 落在噪声里(同配置两次测量本身差 6%),不值得引入
...
...
apps/pac-service/src/modules/plan/plan-assignment.service.ts
View file @
ee1ecaa7
...
...
@@ -368,7 +368,11 @@ export class PlanAssignmentService {
const
aExpires
:
Date
[]
=
[];
const
aStrategy
:
string
[]
=
[];
const
aDedicatedCs
:
(
string
|
null
)[]
=
[];
const
aDedicatedCsLastVisit
:
(
Date
|
null
)[]
=
[];
/**
* ⚠️ 存 **ISO 文本**而不是 Date —— 见下方 `::text[]::timestamptz[]` 的长注释。
* 一句话:整个数组全是 null 时,Prisma 推断不出元素类型,会退化成 integer[]。
*/
const
aDedicatedCsLastVisit
:
(
string
|
null
)[]
=
[];
const
aPriorityScore
:
number
[]
=
[];
const
aSourceConfidence
:
(
number
|
null
)[]
=
[];
const
aSelectionMode
:
string
[]
=
[];
...
...
@@ -383,12 +387,29 @@ export class PlanAssignmentService {
);
aStrategy
.
push
(
item
.
assignStrategy
);
aDedicatedCs
.
push
(
dcs
);
aDedicatedCsLastVisit
.
push
(
dcs
?
(
lastVisitByCs
.
get
(
dcs
)
??
null
)
:
null
);
aDedicatedCsLastVisit
.
push
(
dcs
?
(
lastVisitByCs
.
get
(
dcs
)
?.
toISOString
()
??
null
)
:
null
);
aPriorityScore
.
push
(
row
.
priorityScore
);
aSourceConfidence
.
push
(
confidenceByPlan
.
get
(
row
.
id
)
??
null
);
aSelectionMode
.
push
(
item
.
selectionMode
??
'rank'
);
}
/**
* 🔴 `aDedicatedCsLastVisit` 走 `::text[]::timestamptz[]`,**别改回 `::timestamptz[]`** ——
* 那样写在「本批所有患者都没有专属客服」时必 500(2026-09-21 FRIDAY 宿主实证)。
*
* 机理:Prisma 从**值**推断数组元素类型。整个数组全是 null 时推不出来,退化成
* `integer[]`,撞上显式 `::timestamptz[]` → PG 报
* `42846 cannot cast type integer[] to timestamp with time zone[]`。
* ⚠️ 空数组 `[]` 反而没事(压根走不到转换);**只有「非空且全 null」才炸**。
*
* 实测哪些目标类型会塌(2026-09-21 测试机):
* ::timestamptz[] ❌ ::uuid[] ❌
* ::text[] ✅ ::double precision[] ✅ ::int[] ✅
* ⇒ 同段里 `aDedicatedCs`(::text[])、`aSourceConfidence`(::double precision[])
* 虽然也可空,但 integer 到这些类型有转换路径,**不受影响**,别顺手一起改。
*
* ⛔ 别把这段注释写进下面的模板串里 —— 那里面的 `${…}` 会被当插值求值(踩过)。
*/
const
appliedRows
=
await
tx
.
$queryRaw
<
Array
<
{
id
:
string
}
>>
(
Prisma
.
sql
`
UPDATE followup_plans AS fp SET
status = 'assigned',
...
...
@@ -413,7 +434,7 @@ export class PlanAssignmentService {
${
aExpires
}
::timestamptz[],
${
aStrategy
}
::text[],
${
aDedicatedCs
}
::text[],
${
aDedicatedCsLastVisit
}
::timestamptz[],
${
aDedicatedCsLastVisit
}
::t
ext[]::t
imestamptz[],
${
aPriorityScore
}
::double precision[],
${
aSourceConfidence
}
::double precision[],
${
aSelectionMode
}
::text[]
...
...
apps/pac-service/src/modules/sync/cold-import/clinic-directory.ts
0 → 100644
View file @
ee1ecaa7
/**
* 诊所名字典派生 —— 从摄入源的 (id, name) 里挑出**现名**。
*
* 🔴 这里唯一要解决的问题:**诊所会改名,源表里同一个 id 存着历次名字。**
* 2026-09-03 生产实测(jvs-dw fact_emr_treatment_out,66 家诊所):
* · **53 家有多个名字**(80%),不是个案
* · 273393ed… 「正大诊所」91,053 行(2016-07~2022-08,早停用)
* 「花旗医院」3,096 行(2023-06~2023-09,过渡)
* 「瑞尔齿科上海花旗诊所」42,646 行(2023-09~至今,**现名**)
* · c59bfc52… 6 个名字,其中两个带前导 `\t`
*
* ⛔ `any(name)`(原实现)任取一个 —— 不确定,且大概率给旧名。
* ⛔ "取最高频"同样错 —— 旧名用了 6 年,行数往往碾压现名。
* ✅ 只能按时间取最新。
*
* 缺 `time_field` 时退化成"字典序最大":唯一的好处是**确定性**(同样输入同样输出,
* 不会跑一次一个名字),但**不保证是现名** —— 调用方必须告警。
*/
export
interface
ClinicDirectoryConfig
{
table
:
string
;
id_field
:
string
;
name_field
:
string
;
/// 判定"哪个名字是现名"的时间列(越大越新);字符串日期也可(字典序即时序)
time_field
?:
string
;
}
/**
* ClickHouse 派生 SQL。
* - `trimBoth` 清掉源里带前导 `\t` 的脏值(华贸诊所实测有)
* - `argMax(name, time)` 取时间最大那行的名字
* - `ifNull(toString(time), '')` 兼容 Nullable 时间列(jvs-dw 的 updated_date 是 Nullable(String))
*/
export
function
buildClinicNameQuery
(
realTable
:
string
,
dir
:
ClinicDirectoryConfig
):
string
{
const
nameExpr
=
`trimBoth(
${
dir
.
name_field
}
)`
;
const
pick
=
dir
.
time_field
?
`argMax(
${
nameExpr
}
, ifNull(toString(
${
dir
.
time_field
}
), ''))`
:
`argMax(
${
nameExpr
}
,
${
nameExpr
}
)`
;
return
(
`SELECT
${
dir
.
id_field
}
AS id,
${
pick
}
AS name `
+
`FROM
${
realTable
}
WHERE notEmpty(
${
dir
.
id_field
}
) AND notEmpty(
${
nameExpr
}
) `
+
`GROUP BY
${
dir
.
id_field
}
`
);
}
/**
* 文件源派生 —— 与 ClickHouse 分支**同口径**。
* ⛔ 不能写成"遍历行、后者覆盖前者":行序是文件顺序,等于随机取名。
*/
export
function
pickLatestNames
(
rows
:
Array
<
Record
<
string
,
unknown
>>
,
dir
:
ClinicDirectoryConfig
,
):
Record
<
string
,
string
>
{
const
best
=
new
Map
<
string
,
{
name
:
string
;
at
:
string
}
>
();
for
(
const
r
of
rows
)
{
const
id
=
r
[
dir
.
id_field
];
if
(
id
==
null
||
String
(
id
)
===
''
)
continue
;
const
nm
=
r
[
dir
.
name_field
]
==
null
?
''
:
String
(
r
[
dir
.
name_field
]).
trim
();
if
(
!
nm
)
continue
;
// 无 time_field → 拿名字自身当序(字典序最大);有则按时间列
const
at
=
dir
.
time_field
?
String
(
r
[
dir
.
time_field
]
??
''
)
:
nm
;
const
cur
=
best
.
get
(
String
(
id
));
if
(
!
cur
||
at
>=
cur
.
at
)
best
.
set
(
String
(
id
),
{
name
:
nm
,
at
});
}
const
out
:
Record
<
string
,
string
>
=
{};
for
(
const
[
id
,
v
]
of
best
)
out
[
id
]
=
v
.
name
;
return
out
;
}
apps/pac-service/src/modules/sync/cold-import/cold-import.service.ts
View file @
ee1ecaa7
...
...
@@ -46,6 +46,36 @@ import type { FactReject } from '../pipeline/fact-writer.service';
* 给几条足够定位;全量在 PAC 服务端日志里(每条都有 [schema-violation] 行)。
*/
const
FACT_REJECT_SAMPLE_CAP
=
10
;
/**
* PG 单条 prepared statement 的 bind 变量上限。
* reparse 的每条查询都带 `patientId IN (...)` —— **每个 id 占一个 bind**,
* 所以"一批多少患者"同时是内存旋钮和一堵硬墙。
*/
const
PG_MAX_BIND_VARS
=
32
_767
;
/** 患者分批粒度的硬上限:留出 where 里 hostId / subjectType 等其余 bind 的余量。 */
const
REPARSE_MAX_BATCH
=
PG_MAX_BIND_VARS
-
100
;
/** 默认批大小(内存口径:每批只把这批患者的 rawPayload 拉进内存)。 */
const
REPARSE_DEFAULT_BATCH
=
3000
;
/**
* reparse 的患者分批粒度 —— **dryRun 与实跑共用同一个值**。
*
* ⚠️ 2026-08-28 测试服实测:`--patients-file`(3.2 万+ 清单)+ `--dry-run` 直接炸
* `too many bind variables in prepared statement, expected maximum of 32767, received 62899`
* —— 当时只有 dryRun 分支把**全部** patientIds 一次塞进 `IN`,实跑分支早就分批了。
* 讽刺的是 `--patients-file` 的文档正写着「按受影响患者收窄是 reparse 最有效的提速手段
* (实测可达 250 倍)」:清单越大越该先 dry-run 探一探,而那恰恰是它唯一不工作的场景。
*
* 🔴 2026-09-03 补上另一半:dryRun 那侧当时修好了(分块),**实跑那侧的旋钮却没有上限** ——
* `PAC_REPARSE_BATCH` 之前是 `Math.max(1, …)`,只夹下限。设成 50000 就会让同一堵墙
* 从实跑那侧长回来。这里统一夹住,两侧共用。
* ⛔ 别在任何分支把整份 patientIds 一次性塞进 `IN`;也别绕过本函数直接读 env。
*/
export
function
reparseBatchSize
(
env
:
NodeJS
.
ProcessEnv
=
process
.
env
):
number
{
const
raw
=
Number
(
env
.
PAC_REPARSE_BATCH
)
||
REPARSE_DEFAULT_BATCH
;
return
Math
.
min
(
REPARSE_MAX_BATCH
,
Math
.
max
(
1
,
raw
));
}
import
type
{
TransformOp
}
from
'../transforms/transforms.schema'
;
import
{
buildPushLookupFallbackRows
,
...
...
@@ -185,10 +215,10 @@ export class ColdImportService {
// 2a. dryRun:报 scope(患者数 + 各资源 txn 数),不写、不分批装配(便宜)。
if (opts.dryRun) {
// ⛔ 同样分块 —— 早先这里也是整份清单塞 in,>3.2 万患者时 dry-run 直接崩
,
//
而 --patients-file 的文档恰恰说「按受影响患者收窄是最有效的提速手段(可达 250 倍)」:
//
最需要先 dry-run 探一探的大清单场景,正好是它唯一不工作的场景
。
const DRY_CHUNK =
3000
;
// ⛔ 同样分块 —— 早先这里也是整份清单塞 in,>3.2 万患者时 dry-run 直接崩
。
//
与实跑共用 reparseBatchSize()(见其注释:两侧用同一个夹过上限的值,
//
否则改一侧另一侧的墙还在)
。
const DRY_CHUNK =
reparseBatchSize()
;
for (const cfg of reparseableCfgs) {
let n = 0;
const chunks: Array<string[] | null> = opts.patientIds?.length
...
...
@@ -209,7 +239,7 @@ export class ColdImportService {
// 2b. 分批实跑:每批患者 → 重建源表(只这批 distinct rawPayload)→ transform → processSubject
// (transaction 幂等命中已存 → parser 重衍生 fact,版本流 supersede)。
const BATCH =
Math.max(1, Number(process.env.PAC_REPARSE_BATCH) || 3000
);
const BATCH =
reparseBatchSize(
);
const runStart = new Date();
const aggByResource = new Map<string, PerResourceStats>();
const seenTenants = new Set<string>();
...
...
apps/pac-service/src/modules/sync/cold-import/manifest.schema.ts
View file @
ee1ecaa7
...
...
@@ -231,11 +231,19 @@ export const ColdImportManifestSchema = z
/// `refresh-clinic-names` CLI 据此 SELECT DISTINCT 出 id→名,写 host.clinicNames;
/// /auth/session 再合并进 dictionary.clinics 下发,前端显示名字不依赖登录传。
/// sql_source 模式:table 是 CH 表名(或 queries 的 key);文件模式:table 对应 tables[].table。
/// 诊所名字典来源(id → 中文名),供 refresh-clinic-names 派生 host.clinicNames。
/// ⚠️ `time_field` 不是可有可无的:诊所会改名,同一个 id 在源表里存着**历次**名字。
/// 实测 jvs-dw 66 家里 **53 家有多个名字**(如 273393ed… 同时有「正大诊所」91,053 行
/// 〔2016~2022 旧名〕/「花旗医院」3,096 行 /「瑞尔齿科上海花旗诊所」42,646 行〔当前〕)。
/// 不按时间取最新,就会把早已停用的旧名当成现名 —— 而且旧名往往行数更多,
/// "取最高频"同样错。缺 time_field 时只能退化成"最高频"并告警,结果不可信。
clinic_directory
:
z
.
object
({
table
:
z
.
string
().
min
(
1
),
id_field
:
z
.
string
().
min
(
1
),
name_field
:
z
.
string
().
min
(
1
),
/// 判定"哪个名字是现名"的时间列(越大越新)。字符串日期也可(字典序即时序)。
time_field
:
z
.
string
().
min
(
1
).
optional
(),
})
.
optional
(),
...
...
apps/pac-service/src/modules/sync/transforms/operators/derive.op.ts
View file @
ee1ecaa7
...
...
@@ -97,6 +97,11 @@ function evalExpr(expr: DeriveExpr, row: Row): unknown {
if
(
typeof
v
!==
'string'
)
return
v
??
null
;
return
v
.
trim
().
replace
(
TRAILING_NOISE
,
''
).
trim
();
}
case
'strip_html'
:
{
const
v
=
row
[
expr
.
from
];
if
(
typeof
v
!==
'string'
)
return
v
??
null
;
return
stripHtml
(
v
);
}
case
'normalize'
:
{
const
v
=
row
[
expr
.
from
];
if
(
typeof
v
!==
'string'
)
return
v
??
null
;
...
...
@@ -116,3 +121,49 @@ function evalExpr(expr: DeriveExpr, row: Row): unknown {
}
}
}
/**
* 剥 HTML 富文本标签 → 纯文本。
*
* 宿主用富文本编辑器录入的字段(FRIDAY 回访内容实测 31% 带 <p>/<ol>/<li>/<em>)进 PAC 后
* 是**纯文本语义** —— 详情页直接展示、喂 LLM 生成话术。带标签会被原样显示成 "<p>xxx</p>",
* 也污染 LLM 上下文,所以在摄入层剥掉,而不是留给每个消费方各自处理。
*
* 口径:
* - 块级标签(</p> </li> </div> <br>)→ 换行,否则多段文字会黏成一行
* - 其余标签直接去掉
* - 常见 HTML 实体还原( & < > " ')
* - 收尾清理连续空行/首尾空白;全部剥完只剩空白 → 返回 null(空壳不入库)
*/
export
function
stripHtml
(
input
:
string
):
string
|
null
{
// 单轮:剥标签 → 还原实体。实体还原可能**再露出字面标签** —— 宿主实测存在双重编码:
// '<p><span…><p>阿斯蒂芬撒的发生</p></span></p>'
// 外层是真标签、内层是被转义的标签,剥一轮后 <p> 还原成 <p> 留在结果里。
// 故做**有界多轮**:还原后若又出现标签则再剥一轮,最多 2 轮 —— 不做无界循环,
// 避免构造出的畸形输入(每轮都产生新标签)把摄入卡死。
const
once
=
(
v
:
string
):
string
=>
v
// 块级结束/换行标签先转成换行,保住段落边界
.
replace
(
/<
\s
*br
\s
*
\/?\s
*>/gi
,
'
\
n'
)
.
replace
(
/<
\/\s
*
(
p|div|li|tr|h
[
1-6
]
|blockquote
)\s
*>/gi
,
'
\
n'
)
// 其余标签一律剥掉
.
replace
(
/<
[^
>
]
*>/g
,
''
)
// 实体还原(& 放最后,避免 &lt; 被二次解码成 <)
.
replace
(
/ /gi
,
' '
)
.
replace
(
/</gi
,
'<'
)
.
replace
(
/>/gi
,
'>'
)
.
replace
(
/"/gi
,
'"'
)
.
replace
(
/'/g
,
"'"
)
.
replace
(
/&/gi
,
'&'
);
let
text
=
once
(
input
);
// 第 2 轮只在确实又露出标签时才跑(绝大多数行一轮即净,不做无谓开销)
if
(
/<
[^
>
]
+>/
.
test
(
text
))
text
=
once
(
text
);
text
=
text
// 连续空行压成一个,首尾清干净
.
replace
(
/
[
\t]
+
\n
/g
,
'
\
n'
)
.
replace
(
/
\n{2,}
/g
,
'
\
n'
)
.
trim
();
return
text
===
''
?
null
:
text
;
}
apps/pac-service/src/modules/sync/transforms/transforms.schema.ts
View file @
ee1ecaa7
...
...
@@ -125,6 +125,7 @@ export type SplitJsonArrayOp = z.infer<typeof SplitJsonArrayOpSchema>;
* - normalize: { op: normalize, from: field } — trim 升级版 + CJK→ASCII 中段标点
* (中括号()/中逗号,/中分号;/中尖括号 < >)统一 ASCII,中文宿主 enum 匹配必备
* - coalesce: { op: coalesce, from: [f1, f2, ...] } — 取第一个非空字段值(诊断码兜底:stdCode→message)
* - strip_html: { op: strip_html, from: field } — 剥富文本标签取纯文本(宿主用富文本编辑器时)
*
* concat parts 里 `${fieldName}` 是字段引用,其他视为字面量。
* 限定不允许任意表达式(canonical-fact-layer.md 原则 1)。
...
...
@@ -162,6 +163,15 @@ export const DeriveTrimSchema = z.object({
from
:
z
.
string
().
min
(
1
),
});
/// 剥 HTML 富文本标签 → 纯文本。宿主侧用富文本编辑器录入时,原值会带 <p>/<ol>/<li>/<em> 等
/// (FRIDAY 回访内容实测 794/2552 = 31% 含标签)。PAC 的这些字段是**纯文本语义**
/// (详情页直接展示、喂 LLM 生成话术),带标签进来会被原样显示成 "<p>xxx</p>",
/// 也会污染 LLM 上下文。故在摄入层剥掉,而不是留给每个消费方各自处理。
export
const
DeriveStripHtmlSchema
=
z
.
object
({
op
:
z
.
literal
(
'strip_html'
),
from
:
z
.
string
().
min
(
1
),
});
/// W4 末加:CJK→ASCII 标点 + trim 一站式归一(给中文宿主 enum_mapping 字符串匹配用)
/// trim 是子集(只去首尾噪音);normalize 还做"中文括号/逗号/分号 → ASCII"中段替换
/// 适用任何中文宿主的脏字段归一(不是 jvs-dw 特化),所以是通用 op
...
...
@@ -191,6 +201,7 @@ export const DeriveExprSchema = z.discriminatedUnion('op', [
DeriveUpperSchema
,
DeriveDefaultSchema
,
DeriveTrimSchema
,
DeriveStripHtmlSchema
,
DeriveNormalizeSchema
,
DeriveCoalesceSchema
,
DeriveRecodeSchema
,
...
...
apps/pac-service/src/prisma/prisma.service.ts
View file @
ee1ecaa7
...
...
@@ -14,16 +14,28 @@ import { tenantGuardExtension } from './tenant-guard.extension';
*
* ⭐ 2026-07-26 修:原来只认 `PAC_COHORT_CONCURRENCY`(**摄入**的旋钮),于是
* `recompute-persona --concurrency=N` 这类 CLI 的旋钮**不会放大池**。
* 改成读**通用**的 PAC_DB_CONCURRENCY,由各 CLI 在 Nest 启动前按自己的 --concurrency 设进来;
* PAC_COHORT_CONCURRENCY 保留为向后兼容的别名,取最大者。
*
*
⚠️ 这是**潜在**缺陷,咬不咬人取决于该机 .env 有没有显式写 connection_limit(核对过两台):
*
测试服 47.251.104.47 URL 里显式 `connection_limit=30` → 本函数提前返回,池=30,不是瓶颈
*
(那里观测到的 9 条连接 = 8 个 worker + 1 空闲,不是池上限 —— 别再据此推断)
*
生产 47.99.62.30 URL **没写** → 走 Prisma 默认 `核数×2+1`,4 核机 = **9**
*
→ `--concurrency` 超过 8 就会被卡在 9,多出来的 worker 排队等连接
*
也就是说:按 --concurrency=8 跑生产,9 条刚好够,本修复不产生收益;它的价值是**解锁更高并发**
。
*
🔴 2026-09-02 再修:**常驻服务自己也有并发旋钮,一样没被算进来** —— 生产事故。
*
`PAC_RECALL_SUBSCENARIO_CONCURRENCY=4`(召回子场景并行,2026-08-30 在生产开启)让批次
*
长时间占住 4 条连接,而常驻进程走的是 Prisma 默认池。结果:每日 09:00 的健康日报
*
(6 个并发 count)抢不到连接,**连续三天在 09:00:10 整点超时**,企微收不到日报;
*
同一根因还打掉过一条 plan upsert(`Unable to start a transaction in the given time`)。
*
本函数开头那句"一调并发就得手动调池"说的就是这个病,只是当时没把常驻服务的旋钮算进来
。
*
* 改成读**通用**的 PAC_DB_CONCURRENCY,由各 CLI 在 Nest 启动前按自己的 --concurrency 设进来;
* PAC_COHORT_CONCURRENCY 保留为向后兼容的别名,两者取大。
* ⚠️ **Prisma 默认池是「物理核」×2+1,不是逻辑核** —— 这里以前写错过,导致低估了生产的池:
* 生产 47.99.62.30:`nproc`=4 但 `Core(s) per socket`=2 / `Thread(s) per core`=2,
* 物理核只有 2 → 默认池 = 2×2+1 = **5**(不是按 4 核算的 9)。5 条连接扛不住 4 路并发 + 日报。
* ⛔ **别再说「测试服 .env 里写了 connection_limit=30 所以不受影响」** —— 这话流传了很久,是错的:
* compose 会在 `environment:` 段覆盖 .env 里的 DATABASE_URL,容器里实际拿到的是
* `postgresql://…@postgres:5432/pac?schema=public`,**没有 connection_limit**
* (2026-09-02 `docker inspect` 实测两台都如此)。测试服同样是默认池,
* 09-01 那天它的健康日报也报了同一句 `connection limit: 5`。
* 判断逃生口生不生效,要看**容器实际环境变量**,不是看 .env 文件。
*
* 生产 RDS `max_connections=820`(2026-09-02 实测,当时全库仅 23 条在用),
* 封顶 40 对它绰绰有余;封顶注释里那个 100 是早年自建 PG 的值,保守留着。
*/
export
function
withCohortDerivedPool
(
rawUrl
:
string
|
undefined
):
string
|
undefined
{
if
(
!
rawUrl
)
return
rawUrl
;
...
...
@@ -35,7 +47,15 @@ export function withCohortDerivedPool(rawUrl: string | undefined): string | unde
}
if
(
url
.
searchParams
.
has
(
'connection_limit'
))
return
rawUrl
;
const
num
=
(
v
:
string
|
undefined
)
=>
Math
.
max
(
1
,
parseInt
(
v
??
'1'
,
10
)
||
1
);
const
n
=
Math
.
max
(
num
(
process
.
env
.
PAC_DB_CONCURRENCY
),
num
(
process
.
env
.
PAC_COHORT_CONCURRENCY
));
// 三个旋钮取最大:
// PAC_DB_CONCURRENCY 通用(各 CLI 按自己的 --concurrency 在 Nest 启动前设)
// PAC_COHORT_CONCURRENCY 摄入的历史别名,向后兼容
// PAC_RECALL_SUBSCENARIO_CONCURRENCY **常驻服务**的召回并行度(批次会长时间占住这么多条)
const
n
=
Math
.
max
(
num
(
process
.
env
.
PAC_DB_CONCURRENCY
),
num
(
process
.
env
.
PAC_COHORT_CONCURRENCY
),
num
(
process
.
env
.
PAC_RECALL_SUBSCENARIO_CONCURRENCY
),
);
if
(
n
<=
1
)
return
rawUrl
;
url
.
searchParams
.
set
(
'connection_limit'
,
String
(
Math
.
min
(
n
*
6
+
5
,
40
)));
return
url
.
toString
();
...
...
apps/pac-service/src/queues/daily-health-report.service.ts
View file @
ee1ecaa7
...
...
@@ -253,7 +253,7 @@ export class DailyHealthReportService {
}
if
(
pac
.
kind
===
'return_visit'
)
{
const
r
=
await
this
.
prisma
.
$queryRaw
<
Array
<
{
c
:
bigint
}
>>
`
SELECT count(distinct patient_id) AS c FROM patient_return_visit WHERE host_id =
${
hostId
}
::uuid`
;
SELECT count(distinct patient_id) AS c FROM patient_return_visit
s
WHERE host_id =
${
hostId
}
::uuid`
;
return
Number
(
r
[
0
]?.
c
??
0
);
}
if
(
pac
.
kind
===
'image_ai'
)
{
...
...
apps/pac-service/src/queues/sync-incremental.scheduler.ts
View file @
ee1ecaa7
...
...
@@ -4,6 +4,7 @@ import { CronJob } from 'cron';
import
*
as
path
from
'path'
;
import
{
SyncStatus
}
from
'@pac/types'
;
import
{
PrismaService
}
from
'../prisma/prisma.service'
;
import
{
AlertService
}
from
'../common/alerting/alert.service'
;
import
{
ColdImportService
,
SyncAlreadyRunningError
,
...
...
@@ -40,9 +41,28 @@ import { schedulerDisabled } from './scheduler-switch';
/**
* 僵尸锁的最小年龄。比「一轮同步的正常耗时」留足余量 —— 生产实测单轮摄入 28~52 分钟,
* 取 3 小时:真崩溃留下的锁必然远超此值,而正常在跑的绝不会。
*
* 🔴 2026-09-04 测试机事故:**这条阈值单独用在启动回收上,对 deploy 孤儿锁 100% 失效。**
* deploy 孤儿锁的年龄 = T_重启 − T_起跑,而那轮在重启时**尚未跑完** ⇒ 年龄恒 < 一轮耗时;
* 本阈值又被刻意取成 > 一轮耗时。两式相与 ⇒ 任何 deploy 期间在飞的 sync 留下的锁,
* 在新进程启动那一刻必然"太年轻",必然逃过回收 —— 不是概率,是结构性必然。
* 实测:锁 14:15:00 起,容器 14:30:24 重建,回收在 14:30:31 跑了、查到 0 行、静默 return;
* 锁当时 15.5 分钟大,离 180 分钟阈值差 164.5 分钟。此后每轮 cron 全被并发锁拦下,
* 游标 24.7 小时不动直到人工重启。
* ⇒ 修法不是调阈值(调多小都会被"一轮耗时"从上方封住),而是**给回收增加时机**:
* 见 reapStaleRunningLocks 的周期态调用。
*/
const
REAP_MIN_AGE_MS
=
3
*
60
*
60
*
1000
;
/**
* 周期回收只碰 **cron 增量自己**留下的锁(triggered_by = `sync:<host>:<runId>`)。
*
* ⛔ 不能扩到全部前缀:`full:` 是手工全量 / 按诊所补摄,建国门实测 import 段就跑了 2h25m,
* 离 3 小时阈值只剩 35 分钟;`patient_refresh:` 是客服点「刷新」的前台请求。
* 周期回收每轮都跑,把它们纳入 = 把 2026-08-30 那个「误杀正在跑的活儿」换个入口放回来。
*/
const
REAP_PERIODIC_PREFIX
=
'sync:'
;
@
Injectable
()
export
class
SyncIncrementalSchedulerService
implements
OnModuleInit
{
private
readonly
logger
=
new
Logger
(
SyncIncrementalSchedulerService
.
name
);
...
...
@@ -72,8 +92,27 @@ export class SyncIncrementalSchedulerService implements OnModuleInit {
private
readonly
persona
:
PersonaService
,
private
readonly
planEngine
:
PlanEngineService
,
private
readonly
scheduler
:
SchedulerRegistry
,
private
readonly
alerter
:
AlertService
,
)
{}
/**
* 告警的统一出口。⛔ 必须吞异常:推送失败**绝不能**反过来打断同步 ——
* 告警是观测,不是业务。同理 alerter 未注入(老单测按位置构造)时也只记日志。
*
* 🔴 为什么这个文件到 2026-09-05 才有告警:此前整条增量链路**一次都不推送**,
* `runHostSafe` 只 `logger.error` 就吞掉。轮次失败的唯一出口是每天 09:07 的
* 每日健康报告 —— 最坏晚 24 小时。09-04 生产宕 11.8h 全程零告警有这一份。
* 加了 compose 的 mem_limit(8g)之后这个洞更要命:容器被 SIGKILL → 轮次失败 →
* HTTP 30 秒内恢复 → 外部探针一路绿 → **系统在稳定地失败,而所有指示灯是绿的**。
*/
private
async
alertSafe
(
level
:
'warning'
|
'critical'
,
title
:
string
,
body
:
string
):
Promise
<
void
>
{
try
{
await
this
.
alerter
.
send
({
level
,
title
,
body
});
}
catch
(
err
)
{
this
.
logger
.
error
(
`sync-incremental: 告警推送失败(不影响同步):
${(
err
as
Error
).
message
}
`
);
}
}
async
onModuleInit
():
Promise
<
void
>
{
// ⭐ 写侧总闸:PAC_SCHEDULER_DISABLED=1 → 本实例不跑任何**会改数据**的定时任务。
// 用于「新旧实例并存」的窗口(服务器迁移 / 蓝绿 / 备用实例):新实例要能起来接流量、
...
...
@@ -97,7 +136,7 @@ export class SyncIncrementalSchedulerService implements OnModuleInit {
// 启动即回收上一个进程留下的僵尸锁(部署/崩溃重启时,running 行来不及标终态,
// 会永久占住 sync_logs 的 partial UNIQUE(host_id) WHERE status='running',
// 导致之后每次 cron 增量都被并发锁 skip → 游标不动 → DW 滞后告警)。
await
this
.
reapStaleRunningLocks
();
await
this
.
reapStaleRunningLocks
(
PROCESS_STARTED_AT
);
const
globalCron
=
process
.
env
.
PAC_INCREMENTAL_CRON
;
const
dataDir
=
this
.
dataDir
();
...
...
@@ -171,23 +210,47 @@ export class SyncIncrementalSchedulerService implements OnModuleInit {
* @param processStartedAt 回收判据的时间界(默认本进程启动时刻)。显式传入是为了让判据可测 ——
* 测试不必靠真实时钟凑时间差(高负载下事件循环被拖慢会越过阈值边界,产生间歇性假失败)。
*/
private
async
reapStaleRunningLocks
(
processStartedAt
:
Date
=
PROCESS_STARTED_AT
):
Promise
<
void
>
{
try
{
// 双条件取更早的那个界:既要早于本进程启动,又要已经躺够 REAP_MIN_AGE_MS。
private
staleCutoff
(
processStartedAt
?:
Date
):
Date
{
const
ageCutoff
=
new
Date
(
Date
.
now
()
-
REAP_MIN_AGE_MS
);
const
cutoff
=
ageCutoff
<
processStartedAt
?
ageCutoff
:
processStartedAt
;
// 周期态(不传 processStartedAt):**只用年龄闸**。
// 本进程是长驻的,带上 processStartedAt 会让 min() 恒取它 → 对"本进程启动之后
// 才产生的孤儿锁"永远为 no-op,周期回收等于没加。
if
(
!
processStartedAt
)
return
ageCutoff
;
// 启动态:双条件取更早的那个界(保住 2026-08-30 的 CLI 误杀修复)。
return
ageCutoff
<
processStartedAt
?
ageCutoff
:
processStartedAt
;
}
/**
* ⛔ **这里不能写默认参数** `= PROCESS_STARTED_AT`。JS 的默认值对显式传入的 `undefined`
* 同样生效,于是周期态调用 `reap(undefined, opts)` 会被悄悄补成启动态 —— 判据变回
* min(age, 进程启动),对长驻进程恒 no-op,周期回收等于没加、还不报错。
* 2026-09-04 写这个修复时就踩了一次,是 sync-lock-reap.spec 的远期基准用例咬出来的。
* 启动态由调用方显式传 PROCESS_STARTED_AT。
*/
private
async
reapStaleRunningLocks
(
processStartedAt
?:
Date
,
opts
?:
{
triggerPrefix
?:
string
;
reason
?:
string
},
):
Promise
<
number
>
{
try
{
const
cutoff
=
this
.
staleCutoff
(
processStartedAt
);
const
where
=
{
status
:
SyncStatus
.
RUNNING
,
startedAt
:
{
lt
:
cutoff
},
...(
opts
?.
triggerPrefix
?
{
triggeredBy
:
{
startsWith
:
opts
.
triggerPrefix
}
}
:
{}),
};
const
stale
=
await
this
.
prisma
.
syncLog
.
findMany
({
where
:
{
status
:
SyncStatus
.
RUNNING
,
startedAt
:
{
lt
:
cutoff
}
}
,
where
,
select
:
{
id
:
true
,
hostId
:
true
,
startedAt
:
true
,
triggeredBy
:
true
},
});
if
(
stale
.
length
===
0
)
return
;
if
(
stale
.
length
===
0
)
return
0
;
const
{
count
}
=
await
this
.
prisma
.
syncLog
.
updateMany
({
where
:
{
status
:
SyncStatus
.
RUNNING
,
startedAt
:
{
lt
:
cutoff
}
}
,
where
,
data
:
{
status
:
SyncStatus
.
FAILED
,
endedAt
:
new
Date
(),
errorMessage
:
'进程重启前未完成,启动时回收僵尸锁(reapStaleRunningLocks)'
,
errorMessage
:
opts
?.
reason
??
'进程重启前未完成,启动时回收僵尸锁(reapStaleRunningLocks)'
,
},
});
for
(
const
s
of
stale
)
{
...
...
@@ -196,10 +259,35 @@ export class SyncIncrementalSchedulerService implements OnModuleInit {
`trigger=
${
s
.
triggeredBy
}
`
,
);
}
this
.
logger
.
log
(
`sync-incremental: 启动回收
${
count
}
个僵尸同步锁`
);
this
.
logger
.
log
(
`sync-incremental: 回收
${
count
}
个僵尸同步锁(
${
processStartedAt
?
'启动态'
:
'周期态'
}
)`
,
);
// ⭐ 回收本身就是「上一轮没正常终结」的**唯一确定性信号**,必须推出去。
// 它比"轮次失败"更早、更准:失败可能只是一次网络抖动,而留下僵尸锁意味着
// 进程是被**外力打断**的(SIGKILL / 部署 / 卡死后重启),没走到 finally。
await
this
.
alertSafe
(
'warning'
,
`增量回收了
${
count
}
个僵尸同步锁`
,
[
`上一轮**没有正常终结**(
${
processStartedAt
?
'启动态'
:
'周期态'
}
回收)。`
,
...
stale
.
map
(
(
x
)
=>
`· host=
${
x
.
hostId
}
起跑=
${
x
.
startedAt
.
toISOString
()}
trigger=
${
x
.
triggeredBy
}
`
,
),
''
,
'按概率排查:'
,
'① 容器撞 mem_limit(8g)被 SIGKILL 后重启 —— 查 RestartCount 与 `dmesg -T | grep CONSTRAINT_MEMCG`'
,
' ⚠️ 别看 docker inspect 的 ExitCode/OOMKilled,restart:always 下那是重启后那条命'
,
'② 部署/人工重启打断了在飞的轮次(预期内,不用处理)'
,
'③ 进程卡死后被外力重启'
,
''
,
'数据没丢:游标未推进,下一轮靠 48h 回看窗补齐。'
,
].
join
(
'
\
n'
),
);
return
count
;
}
catch
(
err
)
{
// 回收失败不阻断启动(cron 仍注册);最坏退回人工清理,不比现状糟
this
.
logger
.
error
(
`sync-incremental: 僵尸锁回收失败:
${(
err
as
Error
).
message
}
`
);
return
0
;
}
}
...
...
@@ -211,16 +299,54 @@ export class SyncIncrementalSchedulerService implements OnModuleInit {
`sync-incremental: host=
${
host
}
**跳过本轮** —— 上一轮仍在运行(防套圈)。`
+
`连续出现说明单轮已撑不下 cron 间隔,需要查 plan 段耗时。`
,
);
// 套圈是 2026-08-29 雪崩的前兆信号,也是 09-04 那次的中间态 —— 当时整整 11.8 小时
// 每轮都在这里 return,而没有任何人知道。防套圈闸救了系统,却也**吞掉了症状**。
await
this
.
alertSafe
(
'warning'
,
`增量跳过本轮(上一轮还在跑):
${
host
}
`
,
[
'上一轮已超过一个 cron 间隔仍未结束,本轮跳过(防套圈)。'
,
''
,
'⚠️ **连续出现 = 系统在恶化**,不是稳态:'
,
'· 查 plan 段耗时 —— `docker logs pac-pac-service-1 | grep "
\\
[plan
\\
] 阶段耗时"`'
,
'· 查内存水位 —— 同一批日志里的 `heapUsed=`'
,
'· 若上一轮其实是死的,下一轮开跑前的僵尸锁回收会另行告警'
,
].
join
(
'
\
n'
),
);
return
;
}
this
.
runningHosts
.
add
(
host
);
try
{
// ⭐ 2026-09-04:**回收的第二次机会**。启动态回收挡不住 deploy 孤儿锁
// (见 REAP_MIN_AGE_MS 注释:那种锁在重启那一刻必然"太年轻"),而回收原本
// 只有 onModuleInit 一次机会 → 逃过就永久卡死,实测把测试机的增量停了 24.7 小时。
// 这里每轮开跑前再收一次:判据只用年龄闸(周期态),且**只碰本 host 的 cron 增量锁**。
// 走到这一行说明 runningHosts 里没有本 host —— 即本进程没有在跑它,
// 那么一把躺满 3 小时的 `sync:<host>:` 锁必定是死进程留下的。
// 最坏情况:锁比阈值年轻 → 本轮照旧被并发锁拦下,下轮(2 小时后)再收,
// 自愈时延上限 = REAP_MIN_AGE_MS + 一个 cron 间隔,不再需要人工重启。
await
this
.
reapStaleRunningLocks
(
undefined
,
{
triggerPrefix
:
`
${
REAP_PERIODIC_PREFIX
}${
host
}
:`
,
reason
:
'上一轮未正常终结,下一轮开跑前回收僵尸锁(周期态 reapStaleRunningLocks)'
,
});
await
this
.
runOne
(
path
.
join
(
this
.
dataDir
(),
host
));
}
catch
(
err
)
{
if
(
err
instanceof
SyncAlreadyRunningError
)
{
this
.
logger
.
warn
(
`sync-incremental: host=
${
host
}
skip(并发锁拦截):
${
err
.
message
}
`
);
}
else
{
this
.
logger
.
error
(
`sync-incremental: host=
${
host
}
failed:
${(
err
as
Error
).
message
}
`
);
const
msg
=
(
err
as
Error
).
message
;
this
.
logger
.
error
(
`sync-incremental: host=
${
host
}
failed:
${
msg
}
`
);
await
this
.
alertSafe
(
'critical'
,
`增量轮次失败:
${
host
}
`
,
[
msg
,
''
,
'游标未推进,下一轮会按同一水位 catchup(48h 回看窗)——'
,
'**单次失败通常不用人工介入,连续失败才要查**。'
,
'连续多轮同一错误 → 看 `docker logs --tail=300 pac-pac-service-1`。'
,
].
join
(
'
\
n'
),
);
}
}
finally
{
// ⚠️ 必须在 finally —— 抛异常时不释放会把该 host 永久锁死到进程重启
...
...
apps/pac-service/tests/canonical-fact-layer.spec.ts
View file @
ee1ecaa7
...
...
@@ -179,6 +179,11 @@ describe('canonical-fact-layer | 闸 4:yaml enum_mapping 目标 ∈ canonical-co
for
(
const
{
host
,
file
,
config
}
of
yamls
)
{
const
mappings
=
config
.
enum_mapping
??
{};
for
(
const
[
canonicalField
,
valueMap
]
of
Object
.
entries
(
mappings
))
{
// patient_return_visit 特例:它的 status/type 是**展示用自由文本**,不是受控码。
// closedSets.status 装的是 PACTreatmentStatuses(治疗状态),与回访的"已回访/未回访"同名不同义。
// PAC 侧这几列直接落 PatientReturnVisit 原样展示(不进 fact、不参与召回),
// 取值口径是"与 jvs-dw 的 DW *_name 中文列逐字对齐",而非 canonical-codes 闭集。
if
(
config
.
canonical
===
'patient_return_visit'
)
continue
;
// appointment.status 特例:用 host-side enum
if
(
canonicalField
===
'status'
&&
config
.
canonical
===
'appointment'
)
{
test
(
`
${
host
}
/
${
file
}
appointment.status enum_mapping 目标在 [scheduled/arrived/cancelled/no_show]`
,
()
=>
{
...
...
apps/pac-service/tests/clinic-directory-latest-name.spec.ts
0 → 100644
View file @
ee1ecaa7
/**
* 诊所名字典:必须取**现名**,不是任取 / 不是最高频。
*
* 🔴 起因(2026-09-03 生产实测):原实现 `any(organization_name)` 把
* `273393edd04e4aa4afe9347bb8a2da21` 解析成「正大诊所」—— 那是 2022-08 就停用的旧名,
* 现名是「瑞尔齿科上海花旗诊所」。DW 里 66 家诊所有 **53 家**存着多个历史名字,
* 而且旧名用了六七年、行数往往碾压现名,所以"取最高频"跟 any() 一样错。
*
* 下面的样例数据直接取自生产 DW 的真实形态(含带前导 \t 的脏值)。
*/
import
{
buildClinicNameQuery
,
pickLatestNames
,
type
ClinicDirectoryConfig
,
}
from
'../src/modules/sync/cold-import/clinic-directory'
;
const
DIR
:
ClinicDirectoryConfig
=
{
table
:
'fact_emr_treatment_out'
,
id_field
:
'organization_id'
,
name_field
:
'organization_name'
,
time_field
:
'updated_date'
,
};
// 上海花旗:旧名行数(91,053)远超现名(42,646)—— 最高频口径会选错
const
SHANGHAI_CITI
=
[
...
rows
(
'273393ed'
,
'正大诊所'
,
'2022-08-17 14:16:26'
,
3
),
...
rows
(
'273393ed'
,
'花旗医院'
,
'2023-09-07 15:01:58'
,
2
),
...
rows
(
'273393ed'
,
'瑞尔齿科上海花旗诊所'
,
'2026-09-03 08:55:18'
,
1
),
];
function
rows
(
id
:
string
,
name
:
string
,
at
:
string
,
n
:
number
)
{
return
Array
.
from
({
length
:
n
},
()
=>
({
organization_id
:
id
,
organization_name
:
name
,
updated_date
:
at
,
}));
}
describe
(
'pickLatestNames —— 取现名而不是旧名'
,
()
=>
{
test
(
'⭐ 旧名行数更多也要选最新的那个(上海花旗真实案例)'
,
()
=>
{
expect
(
pickLatestNames
(
SHANGHAI_CITI
,
DIR
)).
toEqual
({
'273393ed'
:
'瑞尔齿科上海花旗诊所'
,
});
});
test
(
'行序打乱不影响结果(文件源的行序等于随机)'
,
()
=>
{
const
shuffled
=
[...
SHANGHAI_CITI
].
reverse
();
expect
(
pickLatestNames
(
shuffled
,
DIR
)[
'273393ed'
]).
toBe
(
'瑞尔齿科上海花旗诊所'
);
});
test
(
'清掉源里的前导/尾随空白(华贸诊所实测有带
\\
t 的行)'
,
()
=>
{
const
r
=
pickLatestNames
(
[
{
organization_id
:
'c59bfc52'
,
organization_name
:
'华贸诊所'
,
updated_date
:
'2020-01-01'
},
{
organization_id
:
'c59bfc52'
,
organization_name
:
'
\
t瑞尔齿科北京华贸诊所A诊区'
,
updated_date
:
'2026-01-01'
},
],
DIR
,
);
expect
(
r
[
'c59bfc52'
]).
toBe
(
'瑞尔齿科北京华贸诊所A诊区'
);
});
test
(
'空名 / 空 id / 全空白名 一律跳过,不写进字典'
,
()
=>
{
const
r
=
pickLatestNames
(
[
{
organization_id
:
'a'
,
organization_name
:
'有效诊所'
,
updated_date
:
'2020-01-01'
},
{
organization_id
:
'a'
,
organization_name
:
' '
,
updated_date
:
'2026-01-01'
},
{
organization_id
:
''
,
organization_name
:
'无 id'
,
updated_date
:
'2026-01-01'
},
{
organization_id
:
'b'
,
organization_name
:
null
,
updated_date
:
'2026-01-01'
},
],
DIR
,
);
expect
(
r
).
toEqual
({
a
:
'有效诊所'
});
// 空白名不能顶掉有效名,b 不该出现
});
test
(
'时间列缺值的行不会盖掉有时间的行'
,
()
=>
{
const
r
=
pickLatestNames
(
[
{
organization_id
:
'a'
,
organization_name
:
'现名'
,
updated_date
:
'2026-01-01'
},
{
organization_id
:
'a'
,
organization_name
:
'无时间旧名'
,
updated_date
:
null
},
],
DIR
,
);
expect
(
r
[
'a'
]).
toBe
(
'现名'
);
});
test
(
'未配 time_field → 退化成字典序最大(只保证确定性,不保证是现名)'
,
()
=>
{
const
noTime
:
ClinicDirectoryConfig
=
{
...
DIR
,
time_field
:
undefined
};
const
a
=
pickLatestNames
(
SHANGHAI_CITI
,
noTime
);
const
b
=
pickLatestNames
([...
SHANGHAI_CITI
].
reverse
(),
noTime
);
expect
(
a
).
toEqual
(
b
);
// 同样输入同样输出,不会跑一次一个名字
});
});
describe
(
'buildClinicNameQuery —— ClickHouse 侧同口径'
,
()
=>
{
test
(
'⭐ 用 argMax 按时间取最新,⛔ 不再是 any()'
,
()
=>
{
const
q
=
buildClinicNameQuery
(
'dw_group.fact_emr_treatment_out'
,
DIR
);
expect
(
q
).
toContain
(
"argMax(trimBoth(organization_name), ifNull(toString(updated_date), ''))"
);
expect
(
q
).
not
.
toMatch
(
/
\b
any
\(
/
);
});
test
(
'trimBoth 同时用在取值和非空判定上(否则纯
\\
t 的行会被当成有效名)'
,
()
=>
{
const
q
=
buildClinicNameQuery
(
't'
,
DIR
);
expect
(
q
).
toContain
(
'notEmpty(trimBoth(organization_name))'
);
});
test
(
'未配 time_field 也不退回 any()(保持确定性)'
,
()
=>
{
const
q
=
buildClinicNameQuery
(
't'
,
{
...
DIR
,
time_field
:
undefined
});
expect
(
q
).
not
.
toMatch
(
/
\b
any
\(
/
);
expect
(
q
).
toContain
(
'argMax(trimBoth(organization_name), trimBoth(organization_name))'
);
});
});
describe
(
'manifest 必须声明 time_field(缺了就会解析成旧名)'
,
()
=>
{
const
yaml
=
require
(
'js-yaml'
);
const
{
readFileSync
}
=
require
(
'node:fs'
);
const
{
join
}
=
require
(
'node:path'
);
for
(
const
host
of
[
'jvs-dw'
,
'friday'
])
{
test
(
`
${
host
}
: clinic_directory.time_field 已声明`
,
()
=>
{
const
m
=
yaml
.
load
(
readFileSync
(
join
(
__dirname
,
`../data/
${
host
}
/manifest.yaml`
),
'utf-8'
),
)
as
{
clinic_directory
?:
ClinicDirectoryConfig
};
expect
(
m
.
clinic_directory
?.
time_field
).
toBeTruthy
();
});
}
});
apps/pac-service/tests/fixtures/reparse-bind-limit/assemblers/diagnosis.yaml
0 → 100644
View file @
ee1ecaa7
canonical
:
diagnosis
emits
:
action
:
diagnosis_recorded
subjectType
:
diagnosis
occurredAtField
:
diagnosedAt
primary
:
table
:
diagnosis_rows
key
:
id
field_mapping
:
diagnosisId
:
id
patientId
:
patient_id
code
:
diag_code
diagnosedAt
:
diagnosed_at
apps/pac-service/tests/fixtures/reparse-bind-limit/manifest.yaml
0 → 100644
View file @
ee1ecaa7
# 最小 manifest —— 只为让 reparse 走到 dryRun 的 count 分支。
# 关键:transforms 让 diagnosis_rows 能回溯到源表 raw_emr,否则 reparse 会把该资源判为
# 「非 transform 产出」直接跳过,count 一次都不发,测试就测了个空。
# 数据文件不存在也没关系:reparse 只读 rawPayload,不读 tables[].file。
host_name
:
fixture-host
tenant_id
:
fixture-tenant
amount_unit
:
yuan
timezone
:
Asia/Shanghai
tables
:
-
table
:
raw_emr
file
:
raw_emr.csv
transforms
:
-
kind
:
derive
input
:
raw_emr
output
:
diagnosis_rows
fields
:
diag_code
:
op
:
trim
from
:
code
assemblers
:
-
file
:
assemblers/diagnosis.yaml
apps/pac-service/tests/friday-return-visit.spec.ts
0 → 100644
View file @
ee1ecaa7
import
{
readFileSync
}
from
'node:fs'
;
import
{
join
}
from
'node:path'
;
import
*
as
yaml
from
'js-yaml'
;
import
{
TransformEngine
}
from
'../src/modules/sync/transforms/transform-engine'
;
import
type
{
TransformsConfig
}
from
'../src/modules/sync/transforms/transforms.schema'
;
import
{
stripHtml
}
from
'../src/modules/sync/transforms/operators/derive.op'
;
/**
* FRIDAY 回访摄入(customer_return_visit → PatientReturnVisit)。
*
* 【源表结构:统一任务表 + 分类型记录表】
* customer_task(task_type: 1咨询 / 2回访 / 4预约备注 / 10…)
* ├─ 1 → customer_consult (咨询,PAC 早已单独摄入)
* └─ 2 → customer_return_visit (回访,本次)
* 实测零交叉:consult 的 64 条 task_id 全指向 type=1,回访 2552 条全指向 type=2。
* 关联 customer_return_visit.task_id = customer_task.id,2552/2552 全中、零空值。
*
* 【为什么枚举要翻成中文】jvs-dw 的 DW 侧本就是 *_name 中文列(常规回访/已回访/已完成),
* FRIDAY 是数字码。同一张 PatientReturnVisit 表里两个宿主的值必须逐字对齐,
* 否则前端展示和按类型筛选会分叉。
*
* 【task_date 含未来】界面「设回访」就是设未来日期。PAC 靠它区分「已发生」与「排了没做」:
* 详情页倒序展示、召回话术只把已发生的算作"联系过";客服名册判在岗则用 sourceCreatedAt,
* 正因为 task_date 含未来排程(jvs-dw 实测最远 2033)。
*/
describe
(
'FRIDAY 回访摄入'
,
()
=>
{
const
dir
=
join
(
__dirname
,
'../data/friday'
);
const
manifest
=
yaml
.
load
(
readFileSync
(
join
(
dir
,
'manifest.yaml'
),
'utf8'
))
as
{
tables
:
Array
<
{
table
:
string
;
file
:
string
}
>
;
incremental
:
{
per_query
:
Record
<
string
,
unknown
>
};
transforms
:
unknown
[];
assemblers
:
Array
<
{
file
:
string
}
>
;
};
const
asm
=
yaml
.
load
(
readFileSync
(
join
(
dir
,
'assemblers/patient_return_visit.yaml'
),
'utf8'
),
)
as
{
canonical
:
string
;
primary
:
{
table
:
string
};
field_mapping
:
Record
<
string
,
string
>
;
enum_mapping
:
Record
<
string
,
Record
<
string
,
string
>>
;
};
describe
(
'① manifest 接线完整'
,
()
=>
{
test
(
'源表已声明,文件名 .csv'
,
()
=>
{
const
t
=
manifest
.
tables
.
find
((
x
)
=>
x
.
table
===
'customer_return_visit'
);
expect
(
t
?.
file
).
toBe
(
'customer_return_visit.csv'
);
});
test
(
'增量游标已配(push / 增量都要)'
,
()
=>
{
expect
(
manifest
.
incremental
.
per_query
).
toHaveProperty
(
'customer_return_visit'
);
});
test
(
'assembler 已注册'
,
()
=>
{
expect
(
manifest
.
assemblers
.
map
((
a
)
=>
a
.
file
)).
toContain
(
'assemblers/patient_return_visit.yaml'
,
);
});
test
(
'⭐ 任务头 customer_task 不单独摄入 —— 属性由宿主 inline'
,
()
=>
{
expect
(
manifest
.
tables
.
map
((
t
)
=>
t
.
table
)).
not
.
toContain
(
'customer_task'
);
});
});
describe
(
'② assembler 映射'
,
()
=>
{
test
(
'是 upsert 资源(无 emits,不进 transaction/fact/召回)'
,
()
=>
{
expect
(
asm
.
canonical
).
toBe
(
'patient_return_visit'
);
expect
(
asm
).
not
.
toHaveProperty
(
'emits'
);
});
test
.
each
([
[
'externalId'
,
'id'
],
[
'patientExternalId'
,
'customer_id'
],
[
'clinicId'
,
'organization_id'
],
[
'taskDate'
,
'task_date'
],
[
'taskStatus'
,
'task_status'
],
[
'taskDirectorId'
,
'task_director_id'
],
[
'sourceCreatedAt'
,
'created_gmt_at'
],
])(
'%s ← %s'
,
(
canonical
,
host
)
=>
{
expect
(
asm
.
field_mapping
[
canonical
]).
toBe
(
host
);
});
test
(
'⭐ 治疗项走合并列、回访内容走剥标签列(而非原列)'
,
()
=>
{
expect
(
asm
.
field_mapping
.
treatmentItems
).
toBe
(
'treatment_items_full'
);
expect
(
asm
.
field_mapping
.
followContent
).
toBe
(
'follow_content_text'
);
});
});
describe
(
'③ 枚举翻中文,与 jvs-dw 逐字对齐'
,
()
=>
{
test
.
each
([
[
'1'
,
'咨询回访'
],
[
'2'
,
'术后回访'
],
[
'3'
,
'常规回访'
],
])(
'type 码%s → %s'
,
(
code
,
zh
)
=>
{
expect
(
asm
.
enum_mapping
.
type
[
code
]).
toBe
(
zh
);
});
test
(
'⭐ 码 4/5 必须显式配置 —— 官方注释只写 1-3,实测占 12% 不能丢'
,
()
=>
{
expect
(
asm
.
enum_mapping
.
type
[
'4'
]).
toBeTruthy
();
expect
(
asm
.
enum_mapping
.
type
[
'5'
]).
toBeTruthy
();
});
test
(
'status / taskStatus 全码覆盖'
,
()
=>
{
expect
(
asm
.
enum_mapping
.
status
[
'1'
]).
toBe
(
'已回访'
);
expect
(
asm
.
enum_mapping
.
status
[
'2'
]).
toBe
(
'未回访'
);
for
(
const
c
of
[
'1'
,
'2'
,
'3'
,
'4'
,
'5'
,
'6'
])
{
expect
(
`taskStatus[
${
c
}
]=
${
asm
.
enum_mapping
.
taskStatus
[
c
]}
`
).
not
.
toContain
(
'undefined'
);
}
});
});
describe
(
'④ transforms:两列合并 + 剥富文本'
,
()
=>
{
const
engine
=
new
TransformEngine
();
const
rows
=
[
{
id
:
'1'
,
treatment_items
:
'种植'
,
treatment_items_two
:
'单颗种植'
,
follow_content
:
'<p>骨灰盒或或</p>'
,
},
{
id
:
'2'
,
treatment_items
:
'诊后回访'
,
treatment_items_two
:
''
,
follow_content
:
'<ol><li>甲</li><li>乙</li></ol>'
,
},
{
id
:
'3'
,
treatment_items
:
''
,
treatment_items_two
:
''
,
follow_content
:
'纯文本'
},
];
const
out
=
engine
.
run
({
tables
:
{
customer_return_visit
:
rows
},
transforms
:
manifest
.
transforms
as
TransformsConfig
,
});
const
got
=
(
out
[
'customer_return_visit'
]
??
[])
as
Array
<
Record
<
string
,
unknown
>>
;
test
(
'两列都有 → 用 · 连接'
,
()
=>
{
expect
(
got
[
0
]
!
.
treatment_items_full
).
toBe
(
'种植 · 单颗种植'
);
});
test
(
'只有一级 → 尾部留分隔符(落库前由 normalizeMergedItems 归一)'
,
()
=>
{
expect
(
String
(
got
[
1
]
!
.
treatment_items_full
).
startsWith
(
'诊后回访'
)).
toBe
(
true
);
});
test
(
'⭐ 富文本剥成纯文本,列表项保住段落边界'
,
()
=>
{
expect
(
got
[
0
]
!
.
follow_content_text
).
toBe
(
'骨灰盒或或'
);
expect
(
got
[
1
]
!
.
follow_content_text
).
toBe
(
'甲
\
n乙'
);
});
test
(
'纯文本原样通过'
,
()
=>
{
expect
(
got
[
2
]
!
.
follow_content_text
).
toBe
(
'纯文本'
);
});
});
describe
(
'⑤ stripHtml 算子'
,
()
=>
{
test
.
each
([
[
'<p>骨灰盒</p>'
,
'骨灰盒'
],
[
'<ol><li>甲</li><li>乙</li></ol>'
,
'甲
\
n乙'
],
[
'a<br>b'
,
'a
\
nb'
],
[
'a b & c'
,
'a b & c'
],
[
'纯文本'
,
'纯文本'
],
])(
'%s → %s'
,
(
input
,
expected
)
=>
{
expect
(
stripHtml
(
input
)).
toBe
(
expected
);
});
test
(
'⭐ 全是标签/空白 → null(空壳不入库)'
,
()
=>
{
expect
(
stripHtml
(
'<p> </p>'
)).
toBeNull
();
expect
(
stripHtml
(
''
)).
toBeNull
();
});
test
(
'&lt; 不被二次解码成 <(实体还原顺序)'
,
()
=>
{
expect
(
stripHtml
(
'&lt;'
)).
toBe
(
'<'
);
});
test
(
'⭐ 双重编码:剥完外层标签、还原实体后又露出字面标签 → 再剥一轮'
,
()
=>
{
// 线上真实数据(customer_return_visit id=266151):外层是真标签,内层是被转义的标签。
// 首轮导入时这条**没被剥干净**,落库后仍是 "<p>阿斯蒂芬撒的发生</p>" —— 由此加的有界第二轮。
const
real
=
'<p><span style="color:#000000"><span style="font-size:13px">'
+
'<span style="background-color:#ffffff"><p>阿斯蒂芬撒的发生</p></span></span></span></p>'
;
expect
(
stripHtml
(
real
)).
toBe
(
'阿斯蒂芬撒的发生'
);
});
test
(
'只剥两轮,不做无界循环(畸形输入不能卡死摄入)'
,
()
=>
{
// 每轮都能再生出标签的构造输入:两轮后仍有残留是可接受的,关键是**必然终止**
const
nested
=
'&amp;lt;p&amp;gt;x'
;
expect
(
typeof
stripHtml
(
nested
)).
toBe
(
'string'
);
});
});
});
apps/pac-service/tests/null-array-pg-cast.spec.ts
0 → 100644
View file @
ee1ecaa7
import
{
readdirSync
,
readFileSync
,
statSync
}
from
'node:fs'
;
import
{
join
}
from
'node:path'
;
/**
* 「可空数组 + 显式 PG 类型转换」的地雷守卫。
*
* ═══ 这条规则从哪来 ═══════════════════════════════════════════════
* 2026-09-21 FRIDAY 宿主接入后,点「确认分配」必 500:
* 42846 cannot cast type integer[] to timestamp with time zone[]
*
* 机理:Prisma 从**值**推断数组元素类型。整个数组**非空且全是 null** 时推不出来,
* 退化成 `integer[]`,撞上显式 `::timestamptz[]` 就报 42846。
* ⚠️ 空数组 `[]` 反而安全(走不到转换);**只有「非空且全 null」才炸**。
*
* 为什么瑞尔一直没事、FRIDAY 一上来就炸:那一列是「该患者专属客服的最近到诊时间」,
* 瑞尔有回访历史 → 多数非 null;FRIDAY 是新宿主 → 整批全 null。
* ⇒ **这不是 FRIDAY 特有的**,瑞尔只要碰上「这批人恰好都没有专属客服」一样炸。
*
* ═══ 只有这两种目标类型会塌(2026-09-21 测试机实测)═══════════════
* ::timestamptz[] ❌ ::uuid[] ❌
* ::text[] ✅ ::double precision[] ✅ ::int[] ✅
* integer 到 text/float/int 有转换路径,到 timestamptz/uuid 没有。
* ⇒ ⛔ 别把同段里的 `::text[]` / `::double precision[]` 参数也"顺手修了",它们没病。
*
* ═══ 断言什么 ═════════════════════════════════════════════════════
* 凡是 `${x}::timestamptz[]` 或 `${x}::uuid[]`,若 `x` 的声明类型里有 `| null`,
* 就是地雷 —— 必须先走 `::text[]` 再整体转(见 plan-assignment.service.ts 的长注释)。
* ⚠️ 只锁「声明可空 ⇒ 不许裸转」这一条,⛔ 不锁具体写法/行号/措辞。
*/
const
SRC
=
join
(
__dirname
,
'../src'
);
function
walk
(
dir
:
string
):
string
[]
{
const
out
:
string
[]
=
[];
for
(
const
name
of
readdirSync
(
dir
))
{
const
p
=
join
(
dir
,
name
);
if
(
statSync
(
p
).
isDirectory
())
out
.
push
(...
walk
(
p
));
else
if
(
p
.
endsWith
(
'.ts'
))
out
.
push
(
p
);
}
return
out
;
}
/** `${ident}::timestamptz[]` / `${ident}::uuid[]` —— 但排除已经过 text[] 中转的写法 */
const
RISKY
=
/
\$\{(\w
+
)\}
::
(
timestamptz|uuid
)\[\]
/g
;
describe
(
'可空数组不许裸转 timestamptz[] / uuid[]'
,
()
=>
{
const
files
=
walk
(
SRC
);
it
(
'仓库里没有「声明可空却裸转」的参数'
,
()
=>
{
const
offenders
:
string
[]
=
[];
for
(
const
file
of
files
)
{
const
src
=
readFileSync
(
file
,
'utf8'
);
for
(
const
m
of
src
.
matchAll
(
RISKY
))
{
const
ident
=
m
[
1
];
const
target
=
m
[
2
];
// 找同文件里该变量的声明
const
decl
=
new
RegExp
(
`const\\s+
${
ident
}
\\s*:\\s*([^=]+)=`
).
exec
(
src
)?.[
1
]
??
''
;
if
(
/
\|\s
*null/
.
test
(
decl
))
{
offenders
.
push
(
`
${
file
.
replace
(
SRC
,
'src'
)}
: \${
${
ident
}
}::
${
target
}
[] —— 声明为
${
decl
.
trim
()}
,`
+
` 全 null 时会退化成 integer[] 并报 42846。改成 ::text[]::
${
target
}
[] 并传 ISO 文本/字符串。`
,
);
}
}
}
expect
(
offenders
).
toEqual
([]);
});
it
(
'🔴 那个出过事的参数确实走了 text[] 中转'
,
()
=>
{
const
f
=
readFileSync
(
join
(
SRC
,
'modules/plan/plan-assignment.service.ts'
),
'utf8'
);
expect
(
f
).
toContain
(
'${aDedicatedCsLastVisit}::text[]::timestamptz[]'
);
expect
(
f
).
not
.
toMatch
(
/
\$\{
aDedicatedCsLastVisit
\}
::timestamptz
\[\]
/
);
});
it
(
'⛔ 没病的那两个没被顺手改坏(text[] / double precision[] 保持原样)'
,
()
=>
{
const
f
=
readFileSync
(
join
(
SRC
,
'modules/plan/plan-assignment.service.ts'
),
'utf8'
);
expect
(
f
).
toContain
(
'${aDedicatedCs}::text[]'
);
expect
(
f
).
toContain
(
'${aSourceConfidence}::double precision[]'
);
});
it
(
'数组里存的是 ISO 文本而不是 Date(否则 text[] 那步会塞进 Date 对象)'
,
()
=>
{
const
f
=
readFileSync
(
join
(
SRC
,
'modules/plan/plan-assignment.service.ts'
),
'utf8'
);
expect
(
f
).
toMatch
(
/aDedicatedCsLastVisit:
\s
*
\(
string
\|
null
\)\[\]
/
);
// ⚠️ 必须锁**这一行**的 toISOString —— 只写 toContain('toISOString()') 没牙:
// 文件别处也有它,把这行的删掉照样绿(2026-09-21 变异测试当场抓到)。
const
push
=
/aDedicatedCsLastVisit
\.
push
\([^
;
]
*
\)
;/
.
exec
(
f
)?.[
0
]
??
''
;
expect
(
push
).
toContain
(
'toISOString()'
);
});
});
apps/pac-service/tests/persona-recompute-perf.spec.ts
View file @
ee1ecaa7
...
...
@@ -10,9 +10,13 @@ import { withCohortDerivedPool } from '../src/prisma/prisma.service';
* ① 批次栅栏 → worker pool:缺口正好是 E[8 次抽样最大值](≈p90)与均值之比;
* 那个 75.7 秒的患者会把同批 7 个 worker 一起冻住。
* ② --concurrency 没放大连接池 —— **潜在**缺陷,咬不咬人看该机 .env:
* 测试服 URL 显式 `connection_limit=30` → 不受影响
* 生产 URL 没写 → Prisma 默认 核数×2+1,4 核机 = 9 → --concurrency>8 会被卡住
* 所以按 --concurrency=8 跑生产时本修复无收益,价值在于解锁更高并发。
* (原文说"测试服 URL 显式 connection_limit=30 所以不受影响" —— 2026-09-02 证伪:
* compose 覆盖了 DATABASE_URL,两台容器里都没有 connection_limit,都是默认池)
*
* 🔴 2026-09-02 补:上面那句"4 核机 = 9"是**错的**,已在实现里更正 ——
* Prisma 默认池按**物理核**算(×2+1),生产 nproc=4 但物理核只有 2 → 池 = **5**。
* 而常驻服务的 `PAC_RECALL_SUBSCENARIO_CONCURRENCY=4` 当时压根没被算进池 →
* 批次占住 4 条,每日健康日报连续三天在 09:00:10 超时。下面 describe 里锁的就是这条。
*/
describe
(
'runPool —— 连续调度,不做批次栅栏'
,
()
=>
{
...
...
@@ -66,12 +70,17 @@ describe('runPool —— 连续调度,不做批次栅栏', () => {
describe
(
'连接池随并发放大 —— PAC_DB_CONCURRENCY'
,
()
=>
{
const
URL_
=
'postgresql://u:p@db:5432/pac?schema=public'
;
const
saved
=
{
db
:
process
.
env
.
PAC_DB_CONCURRENCY
,
cohort
:
process
.
env
.
PAC_COHORT_CONCURRENCY
};
const
set
=
(
db
?:
string
,
cohort
?:
string
)
=>
{
const
saved
=
{
db
:
process
.
env
.
PAC_DB_CONCURRENCY
,
cohort
:
process
.
env
.
PAC_COHORT_CONCURRENCY
,
recall
:
process
.
env
.
PAC_RECALL_SUBSCENARIO_CONCURRENCY
,
};
const
set
=
(
db
?:
string
,
cohort
?:
string
,
recall
?:
string
)
=>
{
if
(
db
===
undefined
)
delete
process
.
env
.
PAC_DB_CONCURRENCY
;
else
process
.
env
.
PAC_DB_CONCURRENCY
=
db
;
if
(
cohort
===
undefined
)
delete
process
.
env
.
PAC_COHORT_CONCURRENCY
;
else
process
.
env
.
PAC_COHORT_CONCURRENCY
=
cohort
;
if
(
recall
===
undefined
)
delete
process
.
env
.
PAC_RECALL_SUBSCENARIO_CONCURRENCY
;
else
process
.
env
.
PAC_RECALL_SUBSCENARIO_CONCURRENCY
=
recall
;
};
afterAll
(()
=>
set
(
saved
.
db
,
saved
.
cohort
));
afterAll
(()
=>
set
(
saved
.
db
,
saved
.
cohort
,
saved
.
recall
));
const
limitOf
=
(
u
:
string
|
undefined
)
=>
u
?
new
URL
(
u
).
searchParams
.
get
(
'connection_limit'
)
:
null
;
...
...
@@ -101,9 +110,30 @@ describe('连接池随并发放大 —— PAC_DB_CONCURRENCY', () => {
});
test
(
'都没设 / =1(常驻 API 进程)→ 不动 URL,走 Prisma 默认池'
,
()
=>
{
set
(
undefined
,
undefined
);
set
(
undefined
,
undefined
,
undefined
);
expect
(
withCohortDerivedPool
(
URL_
)).
toBe
(
URL_
);
set
(
'1'
,
'1'
,
'1'
);
expect
(
withCohortDerivedPool
(
URL_
)).
toBe
(
URL_
);
set
(
'1'
,
'1'
);
});
// ── 🔴 2026-09-02 生产事故:常驻服务的并发旋钮没被算进池 ──
// PAC_RECALL_SUBSCENARIO_CONCURRENCY=4 让批次长期占住 4 条连接,而常驻进程用的是
// Prisma 默认池(生产 2 物理核 → 5)。每日 09:00 健康日报的 6 个并发 count 抢不到连接,
// 连续三天在 09:00:10(= 10s pool_timeout)整点失败,企微收不到日报。
test
(
'⭐ 常驻服务的召回并发旋钮也要放大池(否则批次占满,日报/API 抢不到连接)'
,
()
=>
{
set
(
undefined
,
undefined
,
'4'
);
expect
(
limitOf
(
withCohortDerivedPool
(
URL_
))).
toBe
(
expected
(
4
));
// 29,不再是默认的 5
});
test
(
'召回旋钮与另外两个一起取最大,不会互相盖掉'
,
()
=>
{
set
(
'2'
,
'3'
,
'6'
);
expect
(
limitOf
(
withCohortDerivedPool
(
URL_
))).
toBe
(
expected
(
6
));
// 41 → 封顶 40
set
(
'9'
,
'3'
,
'4'
);
expect
(
limitOf
(
withCohortDerivedPool
(
URL_
))).
toBe
(
expected
(
9
));
// CLI 旋钮更大时以它为准
});
test
(
'召回旋钮 =1(默认串行)→ 行为不变,仍走 Prisma 默认池'
,
()
=>
{
set
(
undefined
,
undefined
,
'1'
);
expect
(
withCohortDerivedPool
(
URL_
)).
toBe
(
URL_
);
});
...
...
apps/pac-service/tests/plan-engine-batch.spec.ts
View file @
ee1ecaa7
import
{
PlanEngineService
}
from
'../src/modules/plan/engine/plan-engine.service'
;
import
{
PlanEngineService
,
resolvePlanBatchSize
}
from
'../src/modules/plan/engine/plan-engine.service'
;
import
type
{
ScenarioHit
}
from
'../src/modules/plan/engine/scenario.interface'
;
/**
...
...
@@ -1156,3 +1156,120 @@ describe('归因继承的边界 — 判据是「客服碰过没」', () => {
for
(
const
[
args
]
of
called
)
expect
(
args
.
where
.
planId
.
in
).
not
.
toContain
(
'p-g'
);
});
});
/**
* 🔴 2026-09-04 端到端分批改造的**核心判据**:结果与批大小无关。
*
* 事故背景:生产 pac-service 挂死约 11.8 小时(2026-09-04 20:06 → 次日 07:52 硬重启)。
* ⚠️ 09-05 更正:当晚**没有** OOM kill(那条内核日志是 08-29 的陈旧 tty 回显),
* 机器也**没有 swap** —— 真正形态是无 swap 下的 page-cache refault 活锁,
* 因为始终有文件页可回收,out_of_memory() 从未被调用,所以没人被杀、也就没有自愈。
* 详见 plan-engine.service.ts 里 resolvePlanBatchSize 上方那段更正说明。
* 堆账里 latestByPatient(1.93GB)+ hitsByPatient(1.06GB)是大头,而它们原先**全程不释放**
* —— 取数早就是 2000 一块,但产物累积在跨全量存活的 Map 里,runPool 的闭包又把它们
* context-allocate 到整个 runAllForHost frame 结束。改造把取数分块升格成端到端分批。
*
* 为什么这组用例是必须的:上面那 20+ 条既有用例**一条都跑不到分批路径的差异** ——
* 它们每个只有 1~4 个患者,默认批大小 2000,永远只有一批。分批写错了它们全绿。
* 这里用 describe.each 把同一份 fixture 在不同批大小下各跑一遍,直接编码
* 「结果与批大小无关」这条不变式。
*
* ⚠️ batchSize=1 是最凶的一档:每批一个患者,同时压测三件事 ——
* ① 跨批不误关(stale-close 拿的是跨批累加的 hitPatientIds,不是被逐批清空的 hitsByPatient)
* ② touchedPlanIds 按批算与全量算等价
* ③ snooze 提在循环外之后仍能被每一批查到
*/
describe
(
'端到端分批 — 结果必须与 PAC_PLAN_BATCH_SIZE 无关'
,
()
=>
{
const
prev
=
process
.
env
.
PAC_PLAN_BATCH_SIZE
;
afterEach
(()
=>
{
if
(
prev
===
undefined
)
delete
process
.
env
.
PAC_PLAN_BATCH_SIZE
;
else
process
.
env
.
PAC_PLAN_BATCH_SIZE
=
prev
;
});
/// 与上面「混合一批」同一份 fixture:5 种结局各一,外加一个 0 命中的患者压 stale-close
const
runMixed
=
async
()
=>
{
const
{
prisma
,
plans
,
logs
}
=
makeStore
({
plans
:
[
{
id
:
'u-old'
,
patientId
:
'p2'
,
status
:
'active'
,
priorityScore
:
50
,
reasons
:
[{
scenario
:
SCEN
,
subKey
:
'k@1'
}]
},
{
id
:
's-old'
,
patientId
:
'p3'
,
status
:
'active'
,
reasons
:
[{
scenario
:
SCEN
,
subKey
:
'k@old'
}]
},
{
id
:
't-term'
,
patientId
:
'p4'
,
status
:
'abandoned'
,
snoozedUntil
:
new
Date
(
'2026-12-01T00:00:00Z'
),
reasons
:
[{
scenario
:
SCEN
,
subKey
:
'k@4'
}],
},
// ⭐ p9 本轮 0 命中且有 active plan → 必须被 stale-close 关掉。
// 分批写错(第 3 步进了循环 / 用了被清空的 hitsByPatient)时,
// 这里会连 p1~p4 一起关掉,plansClosed 从 1 变 5 —— 这条就是照妖镜。
{
id
:
'stale-old'
,
patientId
:
'p9'
,
status
:
'active'
,
reasons
:
[{
scenario
:
SCEN
,
subKey
:
'k@gone'
}]
},
],
});
const
res
=
await
engine
(
prisma
,
makeScenario
([
hit
(
'p1'
,
'k@1'
),
hit
(
'p2'
,
'k@1'
,
50
),
hit
(
'p3'
,
'k@new'
),
hit
(
'p4'
,
'k@4'
),
]),
).
runAllForHost
({
hostId
:
HOST
,
tenantId
:
TENANT
,
now
:
NOW
});
return
{
res
,
plans
,
logs
};
};
/// 把最终状态压成一个可比对的快照(引擎返回值 + 落库结果),批大小不该改变它任何一位
const
snapshot
=
(
r
:
Awaited
<
ReturnType
<
typeof
runMixed
>>
)
=>
({
result
:
{
scenariosRun
:
r
.
res
.
scenariosRun
,
patientsHit
:
r
.
res
.
patientsHit
,
plansCreated
:
r
.
res
.
plansCreated
,
plansSuperseded
:
r
.
res
.
plansSuperseded
,
plansUnchanged
:
r
.
res
.
plansUnchanged
,
plansSuppressed
:
r
.
res
.
plansSuppressed
,
plansClosed
:
r
.
res
.
plansClosed
,
plansSkippedAssigned
:
r
.
res
.
plansSkippedAssigned
,
},
// plan 终态:按 (patientId, version) 排序,避免并发写入顺序带来的假差异
plans
:
r
.
plans
.
map
((
p
)
=>
`
${
p
.
patientId
}
|v
${
p
.
version
}
|
${
p
.
status
}
|
${
p
.
priorityScore
}
`
)
.
sort
(),
logs
:
r
.
logs
.
map
((
l
)
=>
`
${
l
.
patientId
}
|
${
l
.
status
}
`
).
sort
(),
});
const
SIZES
=
[
1
,
2
,
3
,
2000
];
let
baseline
:
ReturnType
<
typeof
snapshot
>
|
null
=
null
;
test
.
each
(
SIZES
)(
'批大小 %s → 结果与基线逐字段一致'
,
async
(
size
)
=>
{
process
.
env
.
PAC_PLAN_BATCH_SIZE
=
String
(
size
);
const
snap
=
snapshot
(
await
runMixed
());
// 先自证这份 fixture 真的走到了 5 种结局,否则"全相等"可能只是都没跑
expect
(
snap
.
result
).
toMatchObject
({
patientsHit
:
4
,
plansCreated
:
1
,
plansUnchanged
:
1
,
plansSuperseded
:
1
,
plansSuppressed
:
1
,
plansClosed
:
1
,
// ⭐ 只关 p9;若变成 5 说明分批把其他批的患者误判成"信号消失"
});
if
(
baseline
===
null
)
baseline
=
snap
;
else
expect
(
snap
).
toEqual
(
baseline
);
});
/**
* 🔴 2026-09-04 测试机实跑炸出来的:初版有个「0 = single-shot 不分批」的回退档,
* 它**绕过了 20000 上限** → 88,728 个患者一次塞进 `patientId: { in: ids }` →
* `too many bind variables ... expected maximum of 32767, received 32769`。
* 而且它压根不等价于改造前(改造前内部有 CHUNK=2000,取数从没超过 2000 个 id)。
* 已删除。这条用例钉住「任何取值都必须落在 [1,20000]」,防止有人再加回来。
*/
test
(
'⛔ 0 也必须被夹进 [1,20000] —— 不许存在绕过 bind 上限的档位'
,
()
=>
{
expect
(
resolvePlanBatchSize
({
PAC_PLAN_BATCH_SIZE
:
'0'
}
as
NodeJS
.
ProcessEnv
)).
toBe
(
2000
);
});
test
(
'⛔ 批大小夹在 [1, 20000] —— 上限守 PG 32767 bind(latest/persona 走 patientId in ids)'
,
()
=>
{
expect
(
resolvePlanBatchSize
({
PAC_PLAN_BATCH_SIZE
:
'999999'
}
as
NodeJS
.
ProcessEnv
)).
toBe
(
20
_000
);
expect
(
resolvePlanBatchSize
({
PAC_PLAN_BATCH_SIZE
:
'-5'
}
as
NodeJS
.
ProcessEnv
)).
toBe
(
1
);
expect
(
resolvePlanBatchSize
({}
as
NodeJS
.
ProcessEnv
)).
toBe
(
2000
);
expect
(
resolvePlanBatchSize
({
PAC_PLAN_BATCH_SIZE
:
'abc'
}
as
NodeJS
.
ProcessEnv
)).
toBe
(
2000
);
});
});
apps/pac-service/tests/reparse-dry-run-bind-limit.spec.ts
0 → 100644
View file @
ee1ecaa7
/**
* reparse --dry-run 的 bind 变量上限回归。
*
* ═══ 事故(2026-08-28 测试服实测)═════════════════════════════════════
* pnpm reparse:prod -- --host=jvs-dw --subject-type=treatment \
* --patients-file=/tmp/reparse-ids.txt --dry-run
* → Assertion violation on the database:
* `too many bind variables in prepared statement, expected maximum of 32767, received 62899`
*
* 根因是**写法**不是业务:dryRun 分支的 count 把整份 patientIds 一次塞进 `patientId IN (...)`,
* 每个 id 占一个 bind,PG 单条 prepared statement 上限 32767。实跑分支早就按 BATCH 切了片,
* 所以**只有 dry-run 会崩**。
*
* 讽刺点值得钉住:`--patients-file` 的文档写着「按受影响患者收窄是 reparse 最有效的提速手段
* (实测可达 250 倍)」—— 清单越大越该先 dry-run 探一探,而那恰恰是它唯一不工作的场景。
*
* 跑:pnpm --filter @pac/service test -- reparse-dry-run-bind-limit
*/
import
{
ColdImportService
}
from
'../src/modules/sync/cold-import/cold-import.service'
;
import
*
as
path
from
'node:path'
;
const
FIXTURE_DIR
=
path
.
join
(
__dirname
,
'fixtures'
,
'reparse-bind-limit'
);
/** PG 单条 prepared statement 的 bind 上限 —— 事故里就是被这堵墙拦下的 */
const
BIND_MAX
=
32
_767
;
type
CountArgs
=
{
where
:
{
patientId
?:
{
in
:
string
[]
}
}
};
/**
* 假 prisma:**照 PG 的规矩发脾气** —— 单条 count 的 bind 数超上限就抛,
* 跟真库同样的报错。不这么做的话,不分批也「测过了」。
*/
function
makePrisma
(
seen
:
number
[])
{
return
{
host
:
{
findFirst
:
async
()
=>
({
id
:
'host-1'
,
name
:
'fixture-host'
})
},
patientTransaction
:
{
count
:
async
(
args
:
CountArgs
)
=>
{
const
ids
=
args
.
where
.
patientId
?.
in
??
[];
// where 里除 IN 之外还有 hostId / subjectType 两个 bind
const
binds
=
ids
.
length
+
2
;
if
(
binds
>
BIND_MAX
)
{
throw
new
Error
(
`Assertion violation on the database: too many bind variables in prepared statement, `
+
`expected maximum of
${
BIND_MAX
}
, received
${
binds
}
`
,
);
}
seen
.
push
(
ids
.
length
);
return
ids
.
length
;
// 每个患者算一条 txn,便于断言累加没丢
},
findMany
:
async
()
=>
[],
},
};
}
function
makeService
(
prisma
:
unknown
):
ColdImportService
{
// dryRun 只用到 prisma + 磁盘上的 manifest/assembler,其余依赖走不到
const
nope
=
null
as
never
;
return
new
ColdImportService
(
prisma
as
never
,
nope
,
nope
,
nope
,
nope
,
nope
,
nope
);
}
const
ids
=
(
n
:
number
)
=>
Array
.
from
({
length
:
n
},
(
_
,
i
)
=>
`p-
${
i
}
`
);
describe
(
'reparse --dry-run 不许把整份 patientIds 一次塞进 IN'
,
()
=>
{
it
(
'🔴 3.2 万+ 患者清单(事故量级)dry-run 不抛错'
,
async
()
=>
{
const
seen
:
number
[]
=
[];
const
svc
=
makeService
(
makePrisma
(
seen
));
await
expect
(
svc
.
reparseFromTransactions
({
dir
:
FIXTURE_DIR
,
hostName
:
'fixture-host'
,
patientIds
:
ids
(
62
_899
),
dryRun
:
true
,
}),
).
resolves
.
toBeDefined
();
// 真发出去了(不是被「非 transform 产出」静默跳过 → 一次 count 都没发的假绿)
expect
(
seen
.
length
).
toBeGreaterThan
(
1
);
expect
(
Math
.
max
(...
seen
)).
toBeLessThanOrEqual
(
BIND_MAX
-
2
);
// 分片不重不漏
expect
(
seen
.
reduce
((
a
,
b
)
=>
a
+
b
,
0
)).
toBe
(
62
_899
);
});
it
(
'⭐ 不给患者清单时走全量 count(没有 IN,不占 bind)'
,
async
()
=>
{
const
seen
:
number
[]
=
[];
const
svc
=
makeService
(
makePrisma
(
seen
));
await
svc
.
reparseFromTransactions
({
dir
:
FIXTURE_DIR
,
hostName
:
'fixture-host'
,
dryRun
:
true
,
});
expect
(
seen
).
toEqual
([
0
]);
// 一条 count,where 里没有 patientId IN
});
it
(
'⛔ PAC_REPARSE_BATCH 调过 bind 上限也不许把墙放回来'
,
async
()
=>
{
const
prev
=
process
.
env
.
PAC_REPARSE_BATCH
;
process
.
env
.
PAC_REPARSE_BATCH
=
'100000'
;
// 有人为了「跑快点」把批调大
try
{
const
seen
:
number
[]
=
[];
const
svc
=
makeService
(
makePrisma
(
seen
));
await
expect
(
svc
.
reparseFromTransactions
({
dir
:
FIXTURE_DIR
,
hostName
:
'fixture-host'
,
patientIds
:
ids
(
50
_000
),
dryRun
:
true
,
}),
).
resolves
.
toBeDefined
();
expect
(
Math
.
max
(...
seen
)).
toBeLessThanOrEqual
(
BIND_MAX
-
2
);
}
finally
{
if
(
prev
===
undefined
)
delete
process
.
env
.
PAC_REPARSE_BATCH
;
else
process
.
env
.
PAC_REPARSE_BATCH
=
prev
;
}
});
});
apps/pac-service/tests/scheduler-kill-switch.spec.ts
View file @
ee1ecaa7
...
...
@@ -45,8 +45,14 @@ describe('闸门覆盖范围 — 只关写侧', () => {
test
(
'⭐ 增量摄入(会改数据)受闸控,且连僵尸锁回收一并跳过'
,
()
=>
{
const
s
=
read
(
'sync-incremental.scheduler.ts'
);
expect
(
s
).
toContain
(
'schedulerDisabled()'
);
// 闸判定必须在 reapStaleRunningLocks 之前 —— 共库场景下回收会误清老实例正在跑的锁
expect
(
s
.
indexOf
(
'schedulerDisabled()'
)).
toBeLessThan
(
s
.
indexOf
(
'await this.reapStaleRunningLocks()'
));
// 闸判定必须在**第一次** reapStaleRunningLocks 调用之前 —— 共库场景下回收会误清老实例正在跑的锁。
// ⚠️ 2026-09-04:调用点从 `reapStaleRunningLocks()` 变成带参数的两处(启动态传
// PROCESS_STARTED_AT,周期态在 runHostSafe 里传 undefined+前缀),写死字面量会假失败。
// 周期态那处是**传递性受闸**的:闸命中时 onModuleInit 提前 return,cron 根本不注册,
// runHostSafe 无从被调用 —— 所以这里只需锁住"闸在第一次回收之前"。
const
firstReap
=
s
.
indexOf
(
'this.reapStaleRunningLocks('
);
expect
(
firstReap
).
toBeGreaterThan
(
-
1
);
expect
(
s
.
indexOf
(
'schedulerDisabled()'
)).
toBeLessThan
(
firstReap
);
});
test
(
'⭐ persona stale-scan(会 enqueue 重算)受闸控'
,
()
=>
{
...
...
apps/pac-service/tests/scheduler-reentrancy-guard.spec.ts
View file @
ee1ecaa7
...
...
@@ -57,6 +57,10 @@ describe('runHostSafe 防重入', () => {
const
{
calls
,
run
,
release
}
=
makeService
();
const
a
=
run
(
'jvs-dw'
);
const
b
=
run
(
'friday'
);
// ⚠️ 2026-09-04:runHostSafe 在进 runOne 之前多了一次 `await 周期回收僵尸锁`,
// 于是 runOne 不再在同一微任务里被调到 —— 断言前必须把微任务队列放干,
// 否则 calls 还是空的(这是真实的行为变化,不是测试写错)。
await
new
Promise
((
r
)
=>
setImmediate
(
r
));
// runOne 收到的是 path.join(dataDir, host) 的完整路径,按后缀断言
expect
(
calls
.
map
((
d
)
=>
d
.
split
(
'/'
).
pop
())).
toEqual
([
'jvs-dw'
,
'friday'
]);
release
();
...
...
apps/pac-service/tests/sync-lock-reap.spec.ts
View file @
ee1ecaa7
...
...
@@ -25,15 +25,23 @@ const before = (ms: number) => new Date(PROCESS_STARTED_AT.getTime() - ms);
const
after
=
(
ms
:
number
)
=>
new
Date
(
PROCESS_STARTED_AT
.
getTime
()
+
ms
);
function
makeService
(
rows
:
Array
<
{
id
:
string
;
hostId
:
string
;
startedAt
:
Date
;
triggeredBy
:
string
}
>
)
{
const
findMany
=
jest
.
fn
().
mockImplementation
(
async
({
where
})
=>
{
const
lt
:
Date
=
where
.
startedAt
.
lt
;
return
rows
.
filter
((
r
)
=>
r
.
startedAt
<
lt
);
});
const
updateMany
=
jest
.
fn
().
mockImplementation
(
async
({
where
})
=>
{
const
lt
:
Date
=
where
.
startedAt
.
lt
;
return
{
count
:
rows
.
filter
((
r
)
=>
r
.
startedAt
<
lt
).
length
};
});
// 假 prisma 必须**同时**认 startedAt.lt 和 triggeredBy.startsWith ——
// 只认前者的话,周期回收的前缀过滤(不许碰 full: / patient_refresh:)就测不出来。
const
match
=
(
where
:
{
startedAt
:
{
lt
:
Date
};
triggeredBy
?:
{
startsWith
:
string
}
})
=>
rows
.
filter
(
(
r
)
=>
r
.
startedAt
<
where
.
startedAt
.
lt
&&
(
!
where
.
triggeredBy
||
r
.
triggeredBy
.
startsWith
(
where
.
triggeredBy
.
startsWith
)),
);
const
findMany
=
jest
.
fn
().
mockImplementation
(
async
({
where
})
=>
match
(
where
));
const
updateMany
=
jest
.
fn
().
mockImplementation
(
async
({
where
})
=>
({
count
:
match
(
where
).
length
}));
const
prisma
=
{
syncLog
:
{
findMany
,
updateMany
}
};
// 假告警器:记下每一条推送。⚠️ 必须真的注入 —— 不注入时 alertSafe 会吞掉 TypeError,
// 于是"告警没发"和"告警发了"在断言上无法区分,用例就成了摆设。
const
sent
:
Array
<
{
level
:
string
;
title
:
string
;
body
:
string
}
>
=
[];
const
send
=
jest
.
fn
().
mockImplementation
(
async
(
a
)
=>
{
sent
.
push
(
a
);
});
const
svc
=
new
SyncIncrementalSchedulerService
(
prisma
as
never
,
{}
as
never
,
...
...
@@ -41,16 +49,28 @@ function makeService(rows: Array<{ id: string; hostId: string; startedAt: Date;
{}
as
never
,
{}
as
never
,
{}
as
never
,
{
send
}
as
never
,
);
return
{
svc
,
findMany
,
updateMany
};
return
{
svc
,
findMany
,
updateMany
,
sent
,
send
};
}
// 触达 private 方法(纯编排,无需走 onModuleInit 以免顺带注册 cron);时间界显式注入
const
reap
=
(
svc
:
SyncIncrementalSchedulerService
,
processStartedAt
=
PROCESS_STARTED_AT
)
=>
(
svc
as
unknown
as
{
reapStaleRunningLocks
:
(
at
:
Date
)
=>
Promise
<
void
>
}
svc
as
unknown
as
{
reapStaleRunningLocks
:
(
at
:
Date
)
=>
Promise
<
unknown
>
}
).
reapStaleRunningLocks
(
processStartedAt
);
/// 周期态回收:不传 processStartedAt(只用年龄闸),可带前缀过滤
const
reapPeriodic
=
(
svc
:
SyncIncrementalSchedulerService
,
triggerPrefix
?:
string
)
=>
(
svc
as
unknown
as
{
reapStaleRunningLocks
:
(
at
:
Date
|
undefined
,
opts
?:
{
triggerPrefix
?:
string
;
reason
?:
string
},
)
=>
Promise
<
unknown
>
;
}
).
reapStaleRunningLocks
(
undefined
,
triggerPrefix
?
{
triggerPrefix
}
:
undefined
);
describe
(
'reapStaleRunningLocks'
,
()
=>
{
test
(
'回收进程启动前的 running 行 → 标 failed'
,
async
()
=>
{
const
{
svc
,
updateMany
}
=
makeService
([
...
...
@@ -101,7 +121,190 @@ describe('reapStaleRunningLocks', () => {
};
const
svc
=
new
SyncIncrementalSchedulerService
(
prisma
as
never
,
{}
as
never
,
{}
as
never
,
{}
as
never
,
{}
as
never
,
{}
as
never
,
{
send
:
jest
.
fn
()
}
as
never
,
);
await
expect
(
reap
(
svc
)).
resolves
.
toBeUndefined
();
// 契约是「不抛」;返回值 2026-09-04 从 void 改成「回收了几条」,失败路径返回 0。
await
expect
(
reap
(
svc
)).
resolves
.
toBe
(
0
);
});
});
/**
* 🔴 2026-09-04 测试机事故的回归 —— 以及**为什么上面那组用例全绿却没拦住它**。
*
* 事故:锁 14:15:00 起跑,容器 14:30:24 被 deploy 拆掉,新进程 14:30:31 跑启动回收 →
* 查到 0 行静默返回(锁才 15.5 分钟大,离 REAP_MIN_AGE_MS=180 分钟差 164.5 分钟)。
* 此后每轮 cron 被并发锁拦下,游标 24.7 小时不动,直到人工重启。
*
* 上面那组为什么测不到:基准 `PROCESS_STARTED_AT` 是一个**过去**的固定日期,
* 而 `ageCutoff = 真实now − 3h` 永远晚于它 ⇒ `min()` 恒取 processStartedAt,
* **REAP_MIN_AGE_MS 一行都没被执行到**。更糟的是「where 用 lt」那条直接断言
* `lt === PROCESS_STARTED_AT`,把**加年龄闸之前**的行为钉成契约 —— 生产已坏、用例全绿。
* ⇒ 这里改用 fake timers 钉住 Date.now(),让年龄闸真正进入判据。
*/
describe
(
'reapStaleRunningLocks — deploy 孤儿锁(年龄闸的盲区)'
,
()
=>
{
/// 重启时刻。锁比它早 15 分钟 —— 复刻事故里的 14:15 起跑 / 14:30 重启。
const
RESTART
=
new
Date
(
'2026-09-03T06:30:24.000Z'
);
const
LOCK_AT
=
new
Date
(
RESTART
.
getTime
()
-
15
*
60
_000
);
const
THREE_H
=
3
*
60
*
60
*
1000
;
/**
* ⚠️ 周期态的用例必须用**远期**基准,不能沿用上面的 2026 日期 —— 否则测不出东西。
* 模块里的 PROCESS_STARTED_AT 是**真实**的模块加载时刻。若假时钟停在过去,
* `ageCutoff = 假now − 3h` 就恒早于它,`min()` 两边选出同一个值,
* "周期态有没有混进 processStartedAt"这件事在判据上**不可观测**。
* 实测:把周期态改回 min(age, PROCESS_STARTED_AT),用 2026 基准时 11 条用例全绿。
* 钉到 2099 后,真实模块加载时刻必然更早 → min() 会选它 → 判据能分辨 → 用例有牙。
*/
const
FAR
=
new
Date
(
'2099-01-01T00:00:00.000Z'
);
const
FAR_LOCK
=
new
Date
(
FAR
.
getTime
()
-
THREE_H
-
60
_000
);
// 躺满 3h 零 1 分
beforeEach
(()
=>
jest
.
useFakeTimers
().
setSystemTime
(
RESTART
));
afterEach
(()
=>
jest
.
useRealTimers
());
const
orphan
=
()
=>
[
{
id
:
'deploy-orphan'
,
hostId
:
'h1'
,
startedAt
:
LOCK_AT
,
triggeredBy
:
'sync:jvs-dw:r1'
},
];
const
farOrphan
=
()
=>
[
{
id
:
'deploy-orphan'
,
hostId
:
'h1'
,
startedAt
:
FAR_LOCK
,
triggeredBy
:
'sync:jvs-dw:r1'
},
];
test
(
'🔴 复现:启动态回收**收不掉** deploy 孤儿锁(锁太年轻,这是结构性必然不是运气)'
,
async
()
=>
{
const
{
svc
,
updateMany
}
=
makeService
(
orphan
());
await
reap
(
svc
,
RESTART
);
// 启动态:cutoff = min(now-3h, RESTART) = now-3h
expect
(
updateMany
).
not
.
toHaveBeenCalled
();
// ← 事故当场的行为,原样钉住
});
test
(
'⭐ 周期态:锁躺满 3 小时后,下一轮 cron 开跑前把它收掉(自愈,不必人工重启)'
,
async
()
=>
{
const
{
svc
,
updateMany
}
=
makeService
(
farOrphan
());
jest
.
setSystemTime
(
FAR
);
await
reapPeriodic
(
svc
,
'sync:jvs-dw:'
);
expect
(
updateMany
).
toHaveBeenCalledTimes
(
1
);
expect
(
updateMany
.
mock
.
calls
[
0
][
0
].
data
.
status
).
toBe
(
'failed'
);
});
test
(
'⭐ 周期态判据里**不许**混进 processStartedAt —— 混进去对长驻进程就是 no-op'
,
async
()
=>
{
// 长驻服务:进程启动早于锁。若周期态仍取 min(age, processStart) → cutoff=processStart,
// 锁比它晚 → 永远收不掉,周期回收等于没加。这条就是钉这个。
const
{
svc
,
findMany
}
=
makeService
(
farOrphan
());
jest
.
setSystemTime
(
FAR
);
await
reapPeriodic
(
svc
,
'sync:jvs-dw:'
);
const
lt
:
Date
=
findMany
.
mock
.
calls
[
0
][
0
].
where
.
startedAt
.
lt
;
expect
(
lt
.
getTime
()).
toBe
(
Date
.
now
()
-
THREE_H
);
// 纯年龄闸
});
test
(
'⛔ 周期态只碰 sync: 前缀 —— full: 补摄(实测 2h25m)和 patient_refresh: 绝不碰'
,
async
()
=>
{
const
{
svc
,
updateMany
}
=
makeService
([
{
id
:
'cron'
,
hostId
:
'h1'
,
startedAt
:
FAR_LOCK
,
triggeredBy
:
'sync:jvs-dw:r1'
},
{
id
:
'backfill'
,
hostId
:
'h1'
,
startedAt
:
FAR_LOCK
,
triggeredBy
:
'full:jvs-dw:r2'
},
{
id
:
'refresh'
,
hostId
:
'h1'
,
startedAt
:
FAR_LOCK
,
triggeredBy
:
'patient_refresh:P9:r3'
},
]);
jest
.
setSystemTime
(
FAR
);
await
reapPeriodic
(
svc
,
'sync:jvs-dw:'
);
expect
(
updateMany
).
toHaveBeenCalledTimes
(
1
);
expect
(
updateMany
.
mock
.
calls
[
0
][
0
].
where
.
triggeredBy
).
toEqual
({
startsWith
:
'sync:jvs-dw:'
});
});
test
(
'⛔ 周期态也认前缀里的 host —— 别的 host 的 cron 锁不碰'
,
async
()
=>
{
const
{
svc
,
updateMany
}
=
makeService
([
{
id
:
'other-host'
,
hostId
:
'h2'
,
startedAt
:
FAR_LOCK
,
triggeredBy
:
'sync:friday:r9'
},
]);
jest
.
setSystemTime
(
FAR
);
await
reapPeriodic
(
svc
,
'sync:jvs-dw:'
);
expect
(
updateMany
).
not
.
toHaveBeenCalled
();
});
test
(
'⛔ 周期态不许收还没躺满 3 小时的锁(正在跑的那轮)'
,
async
()
=>
{
const
{
svc
,
updateMany
}
=
makeService
(
farOrphan
());
jest
.
setSystemTime
(
new
Date
(
FAR_LOCK
.
getTime
()
+
THREE_H
-
60
_000
));
// 差 1 分钟
await
reapPeriodic
(
svc
,
'sync:jvs-dw:'
);
expect
(
updateMany
).
not
.
toHaveBeenCalled
();
});
});
/**
* 2026-09-05 新增:增量链路的告警出口。
*
* 🔴 为什么补:此前 sync-incremental.scheduler **一条告警都不推**(不注入 AlertService),
* 轮次失败只 logger.error 就吞了,唯一出口是次日 09:07 的每日健康报告 —— 最坏晚 24 小时。
* 而 compose 刚加的 mem_limit(8g)会让"容器被 SIGKILL → 轮次失败 → HTTP 30 秒内恢复"
* 成为常见路径:外部探针一路绿,系统却在**稳定地失败**。三条信号必须自己会喊。
*/
describe
(
'增量链路告警'
,
()
=>
{
const
FAR
=
new
Date
(
'2099-01-01T00:00:00.000Z'
);
const
farBefore
=
(
ms
:
number
)
=>
new
Date
(
FAR
.
getTime
()
-
ms
);
beforeEach
(()
=>
{
jest
.
useFakeTimers
().
setSystemTime
(
FAR
);
});
afterEach
(()
=>
{
jest
.
useRealTimers
();
});
it
(
'回收到僵尸锁 → 推 warning,正文带 host / 起跑时刻 / trigger'
,
async
()
=>
{
const
{
svc
,
sent
}
=
makeService
([
{
id
:
'a'
,
hostId
:
'h1'
,
startedAt
:
farBefore
(
4
*
3600
_000
),
triggeredBy
:
'sync:jvs-dw:r1'
},
]);
const
n
=
await
reapPeriodic
(
svc
,
'sync:jvs-dw:'
);
expect
(
n
).
toBe
(
1
);
expect
(
sent
).
toHaveLength
(
1
);
expect
(
sent
[
0
].
level
).
toBe
(
'warning'
);
expect
(
sent
[
0
].
title
).
toContain
(
'1 个僵尸同步锁'
);
expect
(
sent
[
0
].
body
).
toContain
(
'h1'
);
expect
(
sent
[
0
].
body
).
toContain
(
'sync:jvs-dw:r1'
);
// 排查指引必须指向 mem_limit 那条路径,否则运维会照 ExitCode 得出反结论
expect
(
sent
[
0
].
body
).
toContain
(
'CONSTRAINT_MEMCG'
);
});
it
(
'没回收到任何锁 → 不推(常态每轮都会跑,推了就是噪音)'
,
async
()
=>
{
const
{
svc
,
sent
}
=
makeService
([
// 太年轻,不该被回收
{
id
:
'a'
,
hostId
:
'h1'
,
startedAt
:
farBefore
(
60
_000
),
triggeredBy
:
'sync:jvs-dw:r1'
},
]);
expect
(
await
reapPeriodic
(
svc
,
'sync:jvs-dw:'
)).
toBe
(
0
);
expect
(
sent
).
toHaveLength
(
0
);
});
it
(
'⛔ 告警推送炸了不能反过来打断回收 —— 回收值照常返回'
,
async
()
=>
{
const
{
svc
}
=
makeService
([
{
id
:
'a'
,
hostId
:
'h1'
,
startedAt
:
farBefore
(
4
*
3600
_000
),
triggeredBy
:
'sync:jvs-dw:r1'
},
]);
(
svc
as
unknown
as
{
alerter
:
{
send
:
unknown
}
}).
alerter
=
{
send
:
jest
.
fn
().
mockRejectedValue
(
new
Error
(
'webhook 挂了'
)),
};
await
expect
(
reapPeriodic
(
svc
,
'sync:jvs-dw:'
)).
resolves
.
toBe
(
1
);
});
it
(
'轮次抛异常 → 推 critical,且不向外抛(cron 回调必须吞)'
,
async
()
=>
{
const
{
svc
,
sent
}
=
makeService
([]);
const
s
=
svc
as
unknown
as
{
runOne
:
unknown
;
dataDir
:
unknown
;
runHostSafe
:
(
h
:
string
)
=>
Promise
<
void
>
;
};
s
.
dataDir
=
()
=>
'/tmp/data'
;
s
.
runOne
=
jest
.
fn
().
mockRejectedValue
(
new
Error
(
'DW 连不上'
));
await
expect
(
s
.
runHostSafe
(
'jvs-dw'
)).
resolves
.
toBeUndefined
();
const
crit
=
sent
.
filter
((
x
)
=>
x
.
level
===
'critical'
);
expect
(
crit
).
toHaveLength
(
1
);
expect
(
crit
[
0
].
title
).
toContain
(
'jvs-dw'
);
expect
(
crit
[
0
].
body
).
toContain
(
'DW 连不上'
);
});
it
(
'套圈跳过 → 推 warning,且**不**执行本轮(防套圈语义不能被告警改掉)'
,
async
()
=>
{
const
{
svc
,
sent
}
=
makeService
([]);
const
s
=
svc
as
unknown
as
{
runOne
:
jest
.
Mock
;
dataDir
:
unknown
;
runningHosts
:
Set
<
string
>
;
runHostSafe
:
(
h
:
string
)
=>
Promise
<
void
>
;
};
s
.
dataDir
=
()
=>
'/tmp/data'
;
s
.
runOne
=
jest
.
fn
();
s
.
runningHosts
.
add
(
'jvs-dw'
);
await
s
.
runHostSafe
(
'jvs-dw'
);
expect
(
s
.
runOne
).
not
.
toHaveBeenCalled
();
expect
(
sent
.
map
((
x
)
=>
x
.
level
)).
toEqual
([
'warning'
]);
expect
(
sent
[
0
].
title
).
toContain
(
'跳过本轮'
);
});
});
deploy/README.md
View file @
ee1ecaa7
...
...
@@ -10,6 +10,7 @@
|---|---|---|
|
`deploy/gen-env.sh`
| 生成
`.env`
(密钥随机 + 生产开关 + 业务值注入 + 缺失报告) | 拒覆盖已有(
`FORCE=1`
强制) |
|
`deploy/deploy-prod.sh`
| 拉码 + build + 重建容器 + 硬验证(镜像/迁移/health) | ✅ 反复跑 |
|
`deploy/deploy-docs.sh`
|
**文档站单独部署**
(pac-docs 不在 deploy-prod.sh 里)+ 硬验证 OpenAPI Server URL | ✅ 反复跑 |
|
`deploy/first-import.sh`
| 数据首灌:seed + 存量 + 重算画像/计划 | ✅ 可重跑 |
---
...
...
@@ -34,6 +35,25 @@ build 阶段不影响服务(老容器一直跑),停机只发生在重建那一
> 改了 `NEXT_PUBLIC_*`(前端 build-time 变量)必须走本脚本重 build;只改后端 `.env` 用 `docker compose -f docker-compose.prod.yml restart pac-service` 即可。
### 改了文档(`apps/pac-docs/`)要另外跑一条
`deploy-prod.sh`
**只构建 pac-migrate / pac-service / pac-web,不含 pac-docs**
—— 文档站要单独部署:
```
bash
bash deploy/deploy-docs.sh
```
**为什么分开**
:pac-docs 的 Dockerfile 是
`COPY . .`
(整个仓库),任何代码改动都会让它的
build 缓存失效、Next.js SSG 全量重跑。实测(2026-09-03 测试服):什么都没改
**3 秒**
,
改了后端一行代码
**111 秒**
。而绝大多数部署都是改后端、文档没动 —— 并进主流程等于每次白花约 2 分钟。
⚠️
**不要手敲 `docker compose build pac-docs`**
。docs 镜像里 OpenAPI 的「Server URL」来自
build arg
`DOCS_API_URL`
,它回落到
`NEXT_PUBLIC_API_BASE_URL`
,而后者在
**`apps/pac-web/.env`**
里(不在
`pac-service/.env`
)。少传一个
`--env-file`
就会变成
`http://localhost:3101`
,
**且构建照样成功、不报任何错**
—— 只有点开 API 参考页才看得出来,对接方照着填会打不通
(2026-09-03 实际踩到,线上挂了几天没人发现)。
`deploy-docs.sh`
固化了两个 env-file
并在构建后硬校验 servers 地址,写错立刻失败。
---
## 2. 新环境从零上线(换服务器 / 首次生产)
...
...
deploy/deploy-docs.sh
0 → 100755
View file @
ee1ecaa7
#!/usr/bin/env bash
# PAC 文档站(pac-docs)单独部署
#
# 【为什么不并进 deploy-prod.sh】
# pac-docs 的 Dockerfile 里是 `COPY . .`(整个仓库),所以**任何**代码改动都会让它的
# build 缓存失效、Next.js SSG 全量重跑。实测(2026-09-03,测试服):
# 什么都没改 → 3 秒 (17/17 层缓存命中)
# 改了后端代码一行 → 111 秒(COPY 之后全失效)
# 而绝大多数部署都是改后端、文档没动 —— 并进主流程等于每次白白多花约 2 分钟。
# 故保持分离:改了 apps/pac-docs/ 下的东西才跑本脚本。
#
# 【为什么要有这个脚本,而不是手敲 docker compose】
# docs 镜像里的 OpenAPI「Server URL」来自 build arg DOCS_API_URL,而 compose 里它是
# ${DOCS_API_URL:-${NEXT_PUBLIC_API_BASE_URL:-http://localhost:3101}}
# NEXT_PUBLIC_API_BASE_URL 在 **apps/pac-web/.env** 里(不在 pac-service/.env)。
# 手敲时少传一个 --env-file 就会一路回落到 localhost:3101 —— 而且**构建照样成功、
# 不报任何错**,只有点开 API 参考页才看得出来,对接方照着填就打不通。
# 2026-09-03 实际踩到:之前几次手工重建 docs 都只传了 pac-service/.env,
# 线上 API 参考页的 Server URL 一直是 localhost:3101。
# → 本脚本固化两个 env-file,并在构建后**硬校验** servers 地址,写错立刻失败而非静默上线。
#
# 【自更新】脚本会 git pull 更新自己。整个逻辑包在 main() 里防"字节偏移错位"(同 deploy-prod.sh),
# 但那还不够:本次进程跑的是**内存里已载入的旧版函数**,新逻辑这一轮不生效。
# 故 pull 后若本文件有变化,exec 新版重跑一次(带 --no-pull 防递归)。
#
# 用法(服务器上,~/pac):
# bash deploy/deploy-docs.sh # 部署文档站
# COMPOSE_MANAGED=1 bash deploy/deploy-docs.sh # 托管 DB 形态的机器(同 deploy-prod.sh)
# bash deploy/deploy-docs.sh --no-pull # 跳过 git 拉取(代码已就位时)
set
-euo
pipefail
log
()
{
printf
'\n\033[1;36m== %s ==\033[0m\n'
"
$*
"
;
}
die
()
{
printf
'\033[1;31mFAIL: %s\033[0m\n'
"
$*
"
>
&2
;
exit
1
;
}
main
()
{
cd
"
$(
dirname
"
$0
"
)
/.."
if
[[
"
${
1
:-}
"
!=
"--no-pull"
]]
;
then
log
"git 拉取(
$(
git rev-parse
--abbrev-ref
HEAD
)
)"
# ⚠️ 脚本会拉取更新**自己**。包在 main() 里能防"字节偏移错位",但防不了另一件事:
# 本次进程跑的仍是**内存里已载入的旧版函数**,拉下来的新逻辑这一轮不生效。
# 2026-09-03 实测:文件已是新版(curl -sL),报错文案却还是旧版的,排查了一轮才明白。
# → pull 后若脚本自身变了,用新版 exec 重跑一次(带 --no-pull 防无限递归)。
local
before after
before
=
$(
git rev-parse HEAD:deploy/deploy-docs.sh 2>/dev/null
||
echo
none
)
git pull
--ff-only
after
=
$(
git rev-parse HEAD:deploy/deploy-docs.sh 2>/dev/null
||
echo
none
)
if
[[
"
$before
"
!=
"
$after
"
]]
;
then
log
"本脚本已更新 → 用新版重跑"
exec
bash
"
$0
"
--no-pull
fi
fi
# compose 组装与 deploy-prod.sh 保持一致 —— 两个 env-file 缺一不可(见文件头说明)
local
COMPOSE
=(
docker compose
-f
docker-compose.prod.yml
)
[[
"
${
COMPOSE_MANAGED
:-
0
}
"
==
"1"
]]
&&
COMPOSE+
=(
-f
docker-compose.managed.yml
)
COMPOSE+
=(
--env-file
apps/pac-service/.env
--env-file
apps/pac-web/.env
)
# 构建前先把将要注入的地址打出来,肉眼可核
local
api_url
api_url
=
$(
"
${
COMPOSE
[@]
}
"
config 2>/dev/null |
grep
-m1
'DOCS_API_URL:'
| sed
's/.*DOCS_API_URL: *//'
)
log
"OpenAPI Server URL 将注入为:
${
api_url
:-
(空)
}
"
[[
-n
"
$api_url
"
]]
||
die
"解析不到 DOCS_API_URL —— 检查 apps/pac-web/.env 是否有 NEXT_PUBLIC_API_BASE_URL"
case
"
$api_url
"
in
*
localhost
*
|
*
127.0.0.1
*
)
die
"DOCS_API_URL 解析成了本地地址(
$api_url
)。
多半是 apps/pac-web/.env 缺 NEXT_PUBLIC_API_BASE_URL,或该文件不存在。
这么构建出来的 API 参考页,对接方照着填会打不通。"
;;
esac
log
"build pac-docs(显式,不和 up 混 —— 同 deploy-prod.sh 的理由)"
"
${
COMPOSE
[@]
}
"
build pac-docs
log
"force-recreate(不信 compose 的重建判定)"
"
${
COMPOSE
[@]
}
"
up
-d
--force-recreate
pac-docs
# ── 验证 1/2:镜像里的 spec 确实带对了地址(而不是只看构建日志) ──
log
"验证 1/2:容器内 openapi spec 的 servers"
local
got
got
=
$(
docker
exec
"
$(
"
${
COMPOSE
[@]
}
"
ps
-q
pac-docs
)
"
\
node
-e
'const d=require("/app/apps/pac-docs/openapi/pac.json");process.stdout.write(String((d.servers&&d.servers[0]&&d.servers[0].url)||""))'
2>/dev/null
||
true
)
[[
"
$got
"
==
"
$api_url
"
]]
\
||
die
"spec 里的 servers 是「
$got
」,期望「
$api_url
」"
printf
' servers OK (%s)\n'
"
$got
"
# ── 验证 2/2:站点起得来 ──
# ⚠️ 必须 -L 跟随重定向:根路径是 307 → /docs(fumadocs 的正常行为),
# 直接判 200 会把健康的站误报成故障(2026-09-03 首次跑本脚本即踩到)。
log
"验证 2/2:文档站可访问"
local
code
for
_
in
$(
seq 1 20
)
;
do
code
=
$(
curl
-sL
-o
/dev/null
-w
'%{http_code}'
http://localhost:3102/
||
true
)
[[
"
$code
"
==
"200"
]]
&&
break
sleep 2
done
[[
"
$code
"
==
"200"
]]
||
die
"文档站未就绪(跟随重定向后 HTTP
$code
)"
printf
' docs 200(loopback)\n'
log
"文档站部署成功 ✅
$(
git log
--oneline
-1
)
"
}
main
"
$@
"
deploy/deploy-prod.sh
View file @
ee1ecaa7
...
...
@@ -24,6 +24,22 @@ log() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; }
warn
()
{
printf
'\033[1;33mWARN: %s\033[0m\n'
"
$*
"
>
&2
;
}
die
()
{
printf
'\033[1;31mFAIL: %s\033[0m\n'
"
$*
"
>
&2
;
exit
1
;
}
# 探活。⚠️ 两个坑,都踩过:
# ① **路由**:真实健康路由是 `/health` —— 它在 GLOBAL_PREFIX_EXCLUDE 里,**不带** /pac/v1 前缀。
# ② **判据**:全局响应拦截器把 404 也包成 HTTP 200 ——
# /pac/v1/health → 200 {"code":10004,"msg":"Cannot GET /pac/v1/health"}
# 于是"只看状态码"的探活**恒真**。本脚本此前探的正是这个不存在的路由(2026-09-05 发现),
# 三项硬验证里的 health 那项一直只证明了"Nest 的 HTTP 层还在应答",没证明应用健康。
# ⛔ 以后改这里,判据必须落在 body 上,别退回只看 %{http_code}。
probe_health
()
{
# $1=base(如 http://127.0.0.1:3101) → stdout "<code> <body截断>";健康时返回 0
local
out code body
out
=
$(
curl
-s
-m
5
-w
'\n%{http_code}'
"
$1
/health"
2>/dev/null
)
||
out
=
$'
\n
000'
code
=
${
out
##*
$'
\n
'
}
body
=
${
out
%
$'
\n
'
*
}
printf
'%s %s'
"
$code
"
"
${
body
:0:120
}
"
[[
"
$code
"
==
"200"
&&
"
$body
"
==
*
'"status":"ok"'
*
]]
}
main
()
{
cd
"
$(
dirname
"
$0
"
)
/.."
...
...
@@ -135,13 +151,12 @@ main() {
echo
" migrate OK"
log
"验证 3/3:health + web"
local
i
code webcode
local
i
hres webcode
ok
=
0
for
i
in
$(
seq 1 30
)
;
do
code
=
$(
curl
-s
-m
5
-o
/dev/null
-w
'%{http_code}'
http://127.0.0.1:3101/pac/v1/health
||
true
)
[[
"
$code
"
==
"200"
]]
&&
break
if
hres
=
$(
probe_health http://127.0.0.1:3101
)
;
then
ok
=
1
;
break
;
fi
sleep 2
done
[[
"
$
{
code
:-}
"
==
"200"
]]
||
die
"service health=
$code
"
[[
"
$
ok
"
==
1
]]
||
die
"service /health 不健康(重试 30 次):
${
hres
:-
无响应
}
"
# / 现为 307→/plans(next.config redirects),直接验 /plans 的 200
webcode
=
$(
curl
-s
-m
30
-o
/dev/null
-w
'%{http_code}'
http://127.0.0.1:3100/plans
||
true
)
[[
"
$webcode
"
==
"200"
]]
||
die
"web http=
$webcode
"
...
...
@@ -155,8 +170,7 @@ main() {
local
priv phealth pweb
priv
=
$(
ip route get 1 2>/dev/null | awk
'{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}'
)
[[
-n
"
$priv
"
]]
||
die
"拿不到私网 IP(ip route get 1 解析失败)"
phealth
=
$(
curl
-s
-m
5
-o
/dev/null
-w
'%{http_code}'
"http://
$priv
:3101/pac/v1/health"
||
true
)
[[
"
$phealth
"
==
"200"
]]
||
die
"私网
$priv
:3101 health=
$phealth
— 应用可能绑成 loopback,网关够不到(会 502)。托管环境需 web/service 绑 0.0.0.0(见 docker-compose.managed.yml)"
phealth
=
$(
probe_health
"http://
$priv
:3101"
)
||
die
"私网
$priv
:3101 /health 不健康(
$phealth
)— 应用可能绑成 loopback,网关够不到(会 502)。托管环境需 web/service 绑 0.0.0.0(见 docker-compose.managed.yml)"
pweb
=
$(
curl
-s
-m
30
-o
/dev/null
-w
'%{http_code}'
"http://
$priv
:3100/plans"
||
true
)
[[
"
$pweb
"
==
"200"
]]
||
die
"私网
$priv
:3100 web=
$pweb
— 同上(loopback 绑定 → 网关 502)"
echo
" 私网
$priv
health 200 / web 200(网关路径 OK)"
...
...
deploy/health-watch.sh
0 → 100755
View file @
ee1ecaa7
#!/usr/bin/env bash
# PAC 外部存活探针 —— 由 cron 在**被监控机器之外**的主机上跑。
#
# 【为什么必须在外部】2026-09-04 生产宕 11.8 小时,全程零告警。
# PAC 现有 8 类告警**全部由 pac-service 进程内推送**(AlertService → 企微机器人),
# 进程一死/一卡,告警通道跟着一起死 —— 「谁来告诉你告警器坏了」这个经典缺口。
# 当天连云监控 agent 自己都在 20:35 被饿死。于是没有任何人知道,直到第二天早上。
# ⛔ 所以本脚本**绝不能**装在它监控的那台机器上,否则等于没装。
# 当前部署:测试机(47.251.104.47)cron → 探生产 pac.friday.tech。
#
# 【判据为什么看 body 不看状态码】真实健康路由是 `/health`(在 GLOBAL_PREFIX_EXCLUDE 里,
# **不带** /pac/v1 前缀)。而全局响应拦截器把 404 也包成 HTTP 200:
# /pac/v1/health → 200 {"code":10004,"msg":"Cannot GET /pac/v1/health"}
# ⛔ 只看 %{http_code} 的探活**恒真**。deploy-prod.sh 的 health 验证就这么空转着,
# 2026-09-05 发现并一起修。本脚本判据必须落在 body 的 "status":"ok" 上。
#
# 用法:
# bash deploy/health-watch.sh # 探一次(读环境变量配置)
# bash deploy/health-watch.sh --dry-run # 不推送,只打印会推什么
# bash deploy/health-watch.sh --status # 打印当前状态文件,不探测
#
# 环境变量:
# WATCH_URL 被监控服务的 base(默认 https://pac.friday.tech),脚本自己拼 /health
# WATCH_NAME 告警里显示的名字 + 状态文件名(默认 pac-prod)
# ALERT_WEBHOOK_URL 企微群机器人 webhook。⛔ 不设 = 只写日志不推送(等于没装)
# WATCH_FAIL_THRESHOLD 连续失败几次才告警(默认 3)。× cron 间隔 = 真实容忍时长
# WATCH_REPEAT_EVERY 告警后每累计多少次失败重复提醒一次(默认 12)
# WATCH_TIMEOUT 单次 curl 超时秒(默认 10)
# WATCH_CONTROL_URL 可选对照探针。设了则**只有**在对照可达时才判定目标故障 ——
# 防「探针机自己断网 → 误报生产挂了」。默认空=不启用
# (连续 3 次 × 5 分钟 = 15 分钟已能滤掉绝大多数网络抖动)
# WATCH_STATE_DIR 状态文件目录(默认 /var/lib/pac-health-watch)
# WATCH_HEARTBEAT_HOUR 每日心跳的小时(0-23,默认 9;设 -1 关闭)。见下方「为什么要心跳」
#
# 【为什么要心跳(dead-man's switch)】探针本身也会死:测试机宕、cron 被删、脚本被改坏、
# /etc/pac-health-watch.env 丢了 —— 这些情况下探针**静静地停掉**,而所有人以为它还看着。
# 这正是 09-04 的同款失败:告警器坏了没人告诉你。
# 所以每天固定时刻推一条「我还活着 + 过去 24h 探了多少次/失败几次」。
# ⇒ **没收到心跳本身就是告警**。它刻意排在生产每日健康报告(09:07)旁边:
# 两条都到 = 两侧都活;只到一条 = 立刻知道是哪侧断了。
#
# crontab 示例(测试机 root,每 5 分钟):
# */5 * * * * ALERT_WEBHOOK_URL='https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx' \
# /bin/bash /root/pac/deploy/health-watch.sh >> /var/log/pac-health-watch.log 2>&1
#
# 依赖:只要 bash + curl。⛔ 别引入 jq/python —— 探针机的环境不归我们管,少一个依赖少一种
# "探针自己挂了没人知道"。JSON 转义用纯 bash 参数展开(json_escape)。
#
# ⚠️ 本脚本**永远 exit 0**(除 --status / 参数错)—— cron 不该因为"目标挂了"就给 root 发一堆
# 邮件,真正的通知走 webhook。要在别处判断结果,读状态文件。
set
-uo
pipefail
# ⛔ 不加 -e:探测失败是正常业务路径,不是脚本错误
WATCH_URL
=
"
${
WATCH_URL
:-
https
://pac.friday.tech
}
"
WATCH_NAME
=
"
${
WATCH_NAME
:-
pac
-prod
}
"
WATCH_FAIL_THRESHOLD
=
"
${
WATCH_FAIL_THRESHOLD
:-
3
}
"
WATCH_REPEAT_EVERY
=
"
${
WATCH_REPEAT_EVERY
:-
12
}
"
WATCH_TIMEOUT
=
"
${
WATCH_TIMEOUT
:-
10
}
"
WATCH_CONTROL_URL
=
"
${
WATCH_CONTROL_URL
:-}
"
WATCH_STATE_DIR
=
"
${
WATCH_STATE_DIR
:-
/var/lib/pac-health-watch
}
"
WATCH_HEARTBEAT_HOUR
=
"
${
WATCH_HEARTBEAT_HOUR
:-
9
}
"
WATCH_DRY_RUN
=
"
${
WATCH_DRY_RUN
:-
0
}
"
case
"
${
1
:-}
"
in
--dry-run
)
WATCH_DRY_RUN
=
1
;;
--status
)
cat
"
$WATCH_STATE_DIR
/
$WATCH_NAME
.state"
2>/dev/null
||
echo
"(无状态文件)"
;
exit
0
;;
""
)
;;
*
)
echo
"未知参数:
$1
(支持 --dry-run / --status)"
>
&2
;
exit
2
;;
esac
ts
()
{
date
'+%Y-%m-%d %H:%M:%S%z'
;
}
say
()
{
printf
'%s [%s] %s\n'
"
$(
ts
)
"
"
$WATCH_NAME
"
"
$*
"
;
}
mkdir
-p
"
$WATCH_STATE_DIR
"
2>/dev/null
||
{
say
"无法创建状态目录
$WATCH_STATE_DIR
"
;
exit
0
;
}
STATE
=
"
$WATCH_STATE_DIR
/
$WATCH_NAME
.state"
# 防重叠:目标网络卡死时一次探测可能拖到超过 cron 间隔,不加锁会叠出一堆进程、把计数器写乱。
# ⚠️ 用 `flock -n <file> <cmd>` 直接形态,**不用 exec** —— exec 会把 flock 的退出码(拿不到锁=1)
# 变成本脚本的退出码,破坏"永远 exit 0"的约定,cron 会开始发邮件。
if
command
-v
flock
>
/dev/null 2>&1
&&
[[
"
${
_WATCH_LOCKED
:-}
"
!=
"1"
]]
;
then
export
_WATCH_LOCKED
=
1
flock
-n
"
$WATCH_STATE_DIR
/
$WATCH_NAME
.lock"
bash
"
$0
"
"
$@
"
exit
0
fi
# ── 探测 ───────────────────────────────────────────────────────────────
# 返回 0 = 健康。stdout 是给人看的诊断串(状态码 + body 截断)。
probe
()
{
local
base
=
"
$1
"
out code body
out
=
$(
curl
-sS
-m
"
$WATCH_TIMEOUT
"
-w
$'
\n
%{http_code}'
"
$base
/health"
2>&1
)
||
out
=
$'
\n
000'
code
=
${
out
##*
$'
\n
'
}
body
=
${
out
%
$'
\n
'
*
}
body
=
${
body
//
$'
\n
'
/
}
printf
'http=%s body=%s'
"
$code
"
"
${
body
:0:200
}
"
[[
"
$code
"
==
"200"
&&
"
$body
"
==
*
'"status":"ok"'
*
]]
}
# ── 状态读写 ───────────────────────────────────────────────────────────
fails
=
0
;
alerted
=
0
;
since
=
""
;
cnt_ok
=
0
;
cnt_fail
=
0
;
hb_date
=
""
# shellcheck disable=SC1090
[[
-f
"
$STATE
"
]]
&&
.
"
$STATE
"
# ⛔ 每个字段都要校验:状态文件是被 source 的,写坏一次(磁盘满/并发)会让后面的算术
# 直接语法错退出 —— 探针从此静默死亡,而这正是本脚本要防的那类失败。
[[
"
$fails
"
=
~ ^[0-9]+
$
]]
||
fails
=
0
[[
"
$alerted
"
=
~ ^[01]
$
]]
||
alerted
=
0
[[
"
$cnt_ok
"
=
~ ^[0-9]+
$
]]
||
cnt_ok
=
0
[[
"
$cnt_fail
"
=
~ ^[0-9]+
$
]]
||
cnt_fail
=
0
save_state
()
{
printf
"fails=%s
\n
alerted=%s
\n
since='%s'
\n
cnt_ok=%s
\n
cnt_fail=%s
\n
hb_date='%s'
\n
last='%s'
\n
"
\
"
$fails
"
"
$alerted
"
"
$since
"
"
$cnt_ok
"
"
$cnt_fail
"
"
$hb_date
"
"
$(
ts
)
"
>
"
$STATE
"
}
# ── 推送 ───────────────────────────────────────────────────────────────
# 纯 bash 的 JSON 字符串转义(避开 jq/python 依赖)。顺序要紧:反斜杠必须最先换。
json_escape
()
{
local
s
=
$1
s
=
${
s
//\\/\\\\
}
s
=
${
s
//\
"/
\\\"
}
s=
${
s
//
$'
\r
'
/
}
s=
${
s
//
$'
\n
'
/\\n
}
s=
${
s
//
$'
\t
'
/\\t
}
printf '"
%s
"' "
$s
"
}
push() { #
$1
=icon
$2
=color
$3
=标题
$4
=正文
local content resp payload
content=
$(
printf
'%s **<font color="%s">[PAC 外部探针] %s</font>**\n%s\n> 目标: %s/health\n> 探针机: %s\n> time: %s'
\
"
$1
"
"
$2
"
"
$3
"
"
$4
"
"
$WATCH_URL
"
"
$(
hostname
)
"
"
$(
ts
)
"
)
if [[ "
$WATCH_DRY_RUN
" == "
1
" ]]; then
printf '%s [%s] [DRY-RUN 不推送] ↓↓↓
\n
%s
\n
' "
$(
ts
)
" "
$WATCH_NAME
" "
$content
"
return 0
fi
if [[ -z "
${
ALERT_WEBHOOK_URL
:-}
" ]]; then
say "
⚠️ 未设 ALERT_WEBHOOK_URL,只写日志不推送
:
$3
"
return 0
fi
payload="
{\
"msgtype
\"
:
\"
markdown
\"
,
\"
markdown
\"
:{
\"
content
\"
:
$(
json_escape
"
$content
"
)
}}"
# ⚠️ 企微格式错也回 HTTP 200,错误在 body 的 errcode —— 必须看 body(与 AlertService 同坑)
resp=
$(
curl
-sS
-m
10
-H
'Content-Type: application/json'
--data-binary
"
$payload
"
\
"
$ALERT_WEBHOOK_URL
"
2>&1
)
|| { say
"❌ webhook 请求失败:
${
resp
:0:200
}
"
; return 0;
}
if
[[
"
$resp
"
==
*
'"errcode":0'
*
]]
;
then
say
"✅ 已推送:
$3
"
else
say
"❌ webhook 被拒:
${
resp
:0:200
}
"
;
fi
}
# ── 主流程 ─────────────────────────────────────────────────────────────
if
diag
=
$(
probe
"
$WATCH_URL
"
)
;
then
if
[[
"
$alerted
"
==
"1"
]]
;
then
push
"🟢"
"info"
"
$WATCH_NAME
已恢复"
\
"
$(
printf
'连续失败 %s 次后恢复。\n首次失败: %s\n当前: %s'
"
$fails
"
"
${
since
:-
?
}
"
"
$diag
"
)
"
fi
[[
"
$fails
"
-gt
0
]]
&&
say
"恢复正常(此前连续失败
$fails
次)"
fails
=
0
;
alerted
=
0
;
since
=
""
cnt_ok
=
$((
cnt_ok
+
1
))
# 每日心跳。⚠️ 判据是「今天还没推过 且 已到点」而不是「正好等于某分钟」——
# cron 可能因为负载/机器重启错过某一格,按"分钟相等"判会整天不推,
# 而心跳漏推 = 误报"探针死了"。所以用日期去重 + 到点即可。
today
=
"
$(
date +%F
)
"
if
[[
"
$WATCH_HEARTBEAT_HOUR
"
!=
"-1"
&&
"
$hb_date
"
!=
"
$today
"
]]
\
&&
[[
"
$(
date +%-H
)
"
-ge
"
$WATCH_HEARTBEAT_HOUR
"
]]
;
then
hb_date
=
"
$today
"
push
"🟢"
"info"
"
$WATCH_NAME
探针存活(每日心跳)"
\
"
$(
printf
'目标正常。\n上一个周期:探测 %s 次成功 / %s 次失败。\n\n⚠️ **收不到这条 = 探针本身死了**(测试机宕/cron 被删/配置丢失),\n这时生产是死是活没有任何人在看,请手工确认。'
\
"
$cnt_ok
"
"
$cnt_fail
"
)
"
cnt_ok
=
0
;
cnt_fail
=
0
fi
save_state
say
"OK
$diag
"
exit
0
fi
# —— 目标探测失败 ——
# 对照探针:探针机自己断网时不该去指控生产。设了 WATCH_CONTROL_URL 才启用。
if
[[
-n
"
$WATCH_CONTROL_URL
"
]]
;
then
if
!
curl
-sS
-m
"
$WATCH_TIMEOUT
"
-o
/dev/null
"
$WATCH_CONTROL_URL
"
2>/dev/null
;
then
say
"⚠️ 目标失败但对照
$WATCH_CONTROL_URL
也不通 → 判定为**探针机自身网络问题**,本次不计数"
exit
0
fi
fi
fails
=
$((
fails
+
1
))
cnt_fail
=
$((
cnt_fail
+
1
))
[[
-z
"
$since
"
]]
&&
since
=
"
$(
ts
)
"
say
"失败 #
$fails
$diag
"
DIAG_HINT
=
'**照这个顺序看:**
> docker inspect -f "{{.RestartCount}} {{.State.Status}} {{.State.StartedAt}}" pac-pac-service-1
> ⚠️ 别看 State.ExitCode/OOMKilled —— restart:always 会立刻拉起,这两个字段显示的是
> **当前这条命**,被杀那条已被覆盖(09-05 实测:内核明确杀了 2 次,inspect 仍报 ExitCode=0)。
> 判据是 RestartCount 变大 + StartedAt 很新。
> dmesg -T | grep -i CONSTRAINT_MEMCG | tail -3
> 有 = 撞了 compose 的 mem_limit(8g),这是**预期内的自愈**,回头查那轮 plan 的 heapUsed= 日志
> 没有 + 机器整体很卡 = 09-04 那种无 swap 的 page-cache 活锁,内核不留任何 OOM 记录
> free -g; docker logs --tail=200 pac-pac-service-1'
if
[[
"
$alerted
"
==
"0"
&&
"
$fails
"
-ge
"
$WATCH_FAIL_THRESHOLD
"
]]
;
then
alerted
=
1
push
"🔴"
"warning"
"
$WATCH_NAME
探测不通(连续
$fails
次)"
\
"
$(
printf
'%s\n首次失败: %s\n\n%s'
"
$diag
"
"
$since
"
"
$DIAG_HINT
"
)
"
elif
[[
"
$alerted
"
==
"1"
&&
$((
fails
%
WATCH_REPEAT_EVERY
))
-eq
0
]]
;
then
push
"🔴"
"warning"
"
$WATCH_NAME
仍未恢复(连续
$fails
次)"
\
"
$(
printf
'%s\n首次失败: %s'
"
$diag
"
"
$since
"
)
"
fi
save_state
exit
0
docker-compose.prod.yml
View file @
ee1ecaa7
...
...
@@ -91,6 +91,80 @@ services:
dockerfile
:
apps/pac-service/Dockerfile
target
:
prod
restart
:
always
# 🔴 **容器内存上限** —— ⛔ 别删。它不省内存,它决定「出事时炸多大」。
#
# 【为什么加】2026-09-04 生产宕 11.8 小时。同一台机、同样吃到 ~6GB,对照 08-29:
# 08-29 内核 OOM kill → 进程死 → restart:always 拉起 → 分钟级自愈
# 09-04 三道兜底全被绕过 → **整机活锁** → 挂 11.8h,要人硬重启
# 三道兜底怎么被绕过的:
# ① V8 abort —— 天花板 08-26 抬到 8G,堆没顶到就先把机器吃穷了
# ② cgroup OOM —— 容器没上限(HostConfig.Memory=0),这条压根不存在 ← 本行修的就是它
# ③ 内核 OOM killer —— 机器**没有 swap**,内核宁可反复丢/重读 page cache 也不判 OOM
# (file page 永远"可回收"),于是没有任何一次 out_of_memory() 被触发 → refault 活锁。
# 所以本行买的不是"不涨",是**把爆炸半径从整机缩回单容器**:越界 → SIGKILL
# → restart:always 拉起 → 回到 08-29 那种分钟级自愈。
# ✅ 2026-09-05 在测试机用**同一个 pac-service 镜像**、256MB 上限的探针实证过整条链:
# 内核 `oom-kill:constraint=CONSTRAINT_MEMCG` → docker 自动重启(RestartCount 累加)。
# 同一次实测还记到 rss=241MB 时 heapUsed 只有 4MB —— **堆上限管不到堆外**,
# 这正是 max-old-space-size 当不了兜底、必须有本行的原因。
# ⚠️ 事后查证别看 `docker inspect` 的 State.ExitCode / OOMKilled:restart:always 下
# 它们显示的是**重启后那条命**(实测内核杀了 2 次,inspect 仍报 ExitCode=0 OOMKilled=false)。
# 判据是 RestartCount 变大 + `dmesg -T | grep CONSTRAINT_MEMCG`。
#
# 【8g 这个数怎么来的】(生产 pac-friday 实测)
# 全机 15.36 GB
# OS + page cache 必须留 ~2.5 GB ← 无 swap,这块被挤掉就是活锁本身
# dockerd(闲时 0.4G,08-29 压力下实测 2.7G) ~2.7 GB ← ⚠️ 它在容器外,本行限不住
# pac-web + pac-docs(实测 49+64 MB) ~0.2 GB
# ────────────────────────────────────────────────
# 可给 pac-service ~10 GB → 取 8G,余下留给 OS/dockerd
#
# ✅ 2026-09-05 生产实测两整轮(550,481 召回池),RestartCount=0 全程没被杀:
# cgroup memory.peak = 4760 MiB ← **这才是 mem_limit 管的数**,58% of 8192,1.7 倍余量
# 进程 rss 峰 = 2936 MB heapUsed 峰 = 2544 MB
# ⛔ **量余量必须看 cgroup,不能看 `docker stats` 或进程 rss。**
# 三者差 1.6 倍:cgroup 还计入 page cache 与堆外分配,进程 rss 不含前者;
# `docker stats` 是采样,峰值会漏在采样之间(实测采样只看到 3.53GiB,真实 4.65GiB)。
# 正确量法:`docker exec pac-pac-service-1 cat /sys/fs/cgroup/memory.peak`
# (高水位自容器创建起累计,重建即清零 —— 要在一轮跑完之后、下次重建之前读)
# ⚠️ 4760 里有相当部分是**可回收的** page cache(实测跑完后 file 段只剩 2 MiB),
# 撑到上限时内核会先回收它再谈 OOM,所以真实的不可压缩需求低于这个数。
# ⚠️ 定这个数时手上只有 6.03G 那个**分批改造之前**的旧峰值。⛔ 以后调必须重新量。
#
# 【与下面 NODE_OPTIONS 的关系】RSS 恒大于 V8 堆(堆外还有 Buffer/Prisma engine/源码映射,
# 实测约 560MB)。两者若相等,撞顶时**一定是先被 cgroup SIGKILL(零日志零堆栈)**,
# 而不是 V8 抛 heap limit(有 JS 堆栈)。所以堆上限必须**低于** mem_limit ——
# 09-05 拿到实测峰值后已把它从 8192 下调到 6144,理由写在那一行旁边。
#
# ⚠️ **swap 语义**:docker 不显式设 memswap_limit 时,总量 = 2×mem_limit(8G RAM + 8G swap)。
# 生产**没有 swap** → 就是硬 8G,精确。测试机有 16G swapfile → 那边实际是 8G+8G。
# ⛔ 不设 memswap_limit 是刻意的:测试机靠 swap 撑着三十来个容器,一刀切断会造出
# **生产根本不存在的**失败模式,把验证信号污染掉。
#
# ⚠️ 被 SIGKILL 会在 sync_logs 留下 status='running' 的僵尸锁 —— 靠 09-04 那条
# 「下一轮开跑前周期回收」自愈(sync-incremental.scheduler.ts reapStaleRunningLocks),
# 回收时会推一条 warning 告警(09-05 加)。
# ⛔ 删那条回收 = 这里每杀一次就永久卡死增量。三者(限制/回收/告警)是配套的。
#
# 🔴 **本行引入的新风险,必读:`docker exec` 跑的 CLI 和服务共享这 8G。**
# 补摄 / recompute-plans 一直是 `docker exec pac-pac-service-1 node --max-old-space-size=8192 …`,
# **同一个容器 = 同一个 cgroup**。加限制前:服务 4G + CLI 4.9G = 8.9G,宿主 15G 装得下;
# 加限制后:两者之和一旦过 8G,内核会在**这个 cgroup 内部**挑一个杀 —— 有可能杀的是服务。
# ⇒ 规则没变(本来就写着"别手工并发跑全量 CLI"),但**违反的代价从'也许撑得住'变成'必然被杀'**。
# ✅ 正确做法:重活单开一个**带自己上限**的容器,别 exec 进服务里 ——
# docker run --rm --memory=6g --network=pac_default \
# --env-file apps/pac-service/.env \
# --entrypoint node pac-pac-service \
# --max-old-space-size=5120 dist/cli/<名字>.cli.js …
# CLI 撑爆只杀它自己,服务那 8G 一点不受影响。
# (2026-09-05 在测试机实跑验证过:新容器 cgroup memory.max=自己的值、连库正常、
# 同时 `docker stats` 里服务仍是 8GiB 分母,互不干扰。)
# ⚠️ DATABASE_URL 分形态:
# · 托管 DB(本生产机 friday)—— .env 里就是 RDS 地址,`--env-file` 足够;
# · 自带 PG(测试机)—— .env 里是 localhost,单开的容器连不上,须另加
# `-e DATABASE_URL=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@postgres:5432/$POSTGRES_DB?schema=public`
# ⚠️ 真要 exec 进服务容器,先确认没在跑轮次(cron `15 0,8-22/2 * * *` 沪),并盯 `docker stats`。
mem_limit
:
8g
depends_on
:
postgres
:
condition
:
service_healthy
...
...
@@ -123,10 +197,37 @@ services:
# ⛔ 但**别手工并发跑** `recompute-plans --host=<全量>`(它也吃 8G)——
# 那条 CLI **不走 sync 锁**,与服务自己那轮全量撞上时才真有可能把宿主吃满。
#
# ⚠️ 这是**止血不是根治**:池子还在涨,8G 迟早也会到顶。
# 根治是「增量之后别跑全量」—— `recompute-plans` 已经有 `--clinics` 收窄能力,
# 同样的思路搬到 SyncIncrementalSchedulerService 那一处即可。
NODE_OPTIONS
:
--max-old-space-size=8192
# ⚠️ 抬天花板只是**止血**。根治是「增量之后别跑全量」——
# `recompute-plans` 已经有 `--clinics` 收窄能力,同样的思路搬到
# SyncIncrementalSchedulerService 那一处即可。(2026-09-05 的端到端分批
# 把峰值压掉一半多,但"每轮都对全部患者跑一遍召回"这个行为本身没变。)
#
# 🔵 2026-09-05 由 8192 下调到 6144 —— **不是为了省内存,是为了让失败可诊断**。
#
# 加了 mem_limit(8g)之后,堆上限还留在 8192 会形成一个坏顺序:RSS 恒大于堆
# (实测 rss=2873MB 时 heapTotal=2309MB,堆外常驻约 560MB),
# 两者相等时**一定是先撞 cgroup 被 SIGKILL(exit 137,零日志零堆栈)**,
# 而不是 V8 抛 `Reached heap limit`(有 JS 堆栈,能直接定位是哪段吃的)。
# 等于把唯一一次能拿到现场的机会浪费掉 —— 而 09-04 整件事就是败在没有现场。
#
# 6144 的依据(2026-09-05 生产连续两整轮实测,550,481 池):
# 峰值 heapUsed=2544MB,之后单调降到 199MB;进程 rss 峰=2936MB。6144 留 2.4 倍余量。
#
# ⚠️ **这个"V8 先抛"不是硬保证,是大概率。** cgroup 高水位 4760MiB 比堆峰值 2544MB
# 多出约 2.2G(堆外分配 + page cache)。若堆真涨到 6144,总量可能逼近 8192,
# 届时到底是 V8 先抛还是 cgroup 先杀,取决于当时有多少 page cache 可回收。
# ⇒ 想要**硬保证**就把这里降到 5120(5120+2200 < 8192);代价是相对当前峰值只剩 2.0 倍。
# 现在取 6144 是权衡:常见的堆失控场景下 V8 会先抛(page cache 会被先回收掉),
# 而 2.4 倍余量不至于把本来能跑完的轮次变成必崩。
#
# ⚠️ 不影响 CLI:补摄/recompute-plans 走 `node --max-old-space-size=8192 …`,
# 命令行 V8 flag 覆盖 NODE_OPTIONS(Node 把 NODE_OPTIONS 当作排在命令行**之前**),
# 重复 flag 取最后一个。⛔ 但别忘了它们和服务同 cgroup,见上面 mem_limit 那段。
#
# ⚠️ 什么时候该往回调:池子涨到让 heapUsed 峰值逼近 4G(即 6144 的 2/3)时。
# 峰值主要由 selectHits→hitsByPatient 那份双拷贝决定,它随**命中数**涨,
# 不随分批变小 —— 那才是下一个要动的地方,不是这个数。
NODE_OPTIONS
:
--max-old-space-size=6144
# 覆盖 .env 的 localhost URL → 走 docker 内部网络
DATABASE_URL
:
postgresql://${POSTGRES_USER:-pac}:${POSTGRES_PASSWORD:-pac}@postgres:5432/${POSTGRES_DB:-pac}?schema=public
REDIS_URL
:
redis://redis:6379
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment