- 03 Sep, 2026 7 commits
-
-
【解决的问题】docs 镜像里 OpenAPI 的「Server URL」来自 build arg DOCS_API_URL: ${DOCS_API_URL:-${NEXT_PUBLIC_API_BASE_URL:-http://localhost:3101}} 而 NEXT_PUBLIC_API_BASE_URL 在 apps/pac-web/.env 里,不在 pac-service/.env。 deploy-prod.sh 本来就传两个 --env-file,但它**不含 pac-docs**,所以改文档得手工重建; 手敲时少传一个 env-file 就回落成 localhost:3101 —— 而且**构建照样成功、不报任何错**, 只有点开 API 参考页才看得出来,对接方照着填会打不通。 2026-09-03 实际踩到:之前几次手工重建 docs 都只传了 pac-service/.env, 线上 API 参考页的 Server URL 一直是 localhost:3101,挂了几天没人发现, 直到对接方看文档时提出来。 【为什么不并进 deploy-prod.sh(先前的想法,量化后否掉)】 pac-docs 的 Dockerfile 是 `COPY . .`(整个仓库),任何代码改动都会让它的 build 缓存失效、 Next.js SSG 全量重跑。测试服实测: 什么都没改 → 3 秒(17/17 层缓存命中) 改了后端代码一行 → 111 秒(COPY 之后全失效) 而绝大多数部署都是改后端、文档没动 —— 并进主流程等于每次白花约 2 分钟买"不会忘"。 真正的问题不是"要不要自动跑",是"手敲参数容易写错",所以治后者。 【脚本做什么】 - compose 组装与 deploy-prod.sh 完全一致(含 COMPOSE_MANAGED override + 两个 env-file) - 构建**前**先解析并打印将注入的地址,解析不到 / 是 localhost 直接 die(带修复提示) - 显式 build → force-recreate(同 deploy-prod.sh 不信 compose 重建判定的理由) - 构建**后**硬校验:读容器内 openapi/pac.json 的 servers[0].url,与期望值不等即失败 (而不是只看构建日志 —— 日志对了不代表镜像里对) - 再验文档站 200 deploy/README.md 登记脚本 + 说明为何分开、为何不要手敲 compose。luoqi committed -
真实数据打脸:首轮导入 2552 条回访后,有 1 条落库仍是 "<p>阿斯蒂芬撒的发生</p>"。 查源库(customer_return_visit id=266151)发现是**双重编码**: <p><span style="color:#000000">…<p>阿斯蒂芬撒的发生</p>…</span></p> 外层是真标签,内层是被转义的标签。 原实现「剥标签 → 还原实体」跑一轮:外层剥掉后,<p> 被还原成字面的 <p> 留在结果里。 算子逻辑本身没错(单次剥离),但真实数据证明必须处理这种形态 —— 宿主富文本编辑器 把粘贴进来的 HTML 又转义了一层。 改成**有界两轮**:还原实体后若又出现标签则再剥一轮,最多 2 轮。 不做无界循环 —— 构造输入(每轮都能再生标签,如 &lt;p&gt;)会把摄入卡死, 两轮后仍有残留是可接受的,关键是必然终止。第 2 轮只在确实又露出标签时才跑, 绝大多数行一轮即净,无谓开销为零。 回归测试 2 项:线上那条原文按原样断言;畸形嵌套输入断言必然返回(不卡死)。
luoqi committed -
FRIDAY 此前没接回访(patient_return_visit 一直空)。全库扫 58 张相关表, 只有 customer.customer_return_visit 有实际数据(2552 行 / 1122 患者 / 27 诊所 / 77 客服), 其余是空表(arrail-medical-server.return_visit 0 行、tenant_apply.return_visit* 全 0)、 配置字典、或「召回」另一套(customer_recall_*,量极小)。 【源表是「统一任务表 + 分类型记录表」结构】 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 —— 所以这不是重复摄入咨询。关联 rv.task_id = t.id 实测 2552/2552 全中、零空值 (反向的 t.return_visit_id 全空,是电子病历迁移遗留列,不可用)。 任务头不单独摄入,task_date/task_status 由宿主 inline 进回访行 —— 与 customer_treat_plan_item inline 计划头 organization_id/plan_name 同形态。 【task_date 含未来排程,不能丢】界面「设回访」就是设未来日期(截图实证)。 PAC 靠它区分「已发生」与「排了没做」:详情页倒序展示、召回话术只把已发生的算作 "联系过";而客服名册判「在岗」必须用 sourceCreatedAt 而非 task_date,正因后者含未来 (jvs-dw 实测最远 2033)。测试库当前无未来样本,但字段语义按含未来实现。 【枚举翻中文,与 jvs-dw 逐字对齐】jvs-dw 的 DW 侧本就是 *_name 中文列,FRIDAY 是数字码。 同一张 PatientReturnVisit 表里两个宿主的值必须一致,否则前端展示与按类型筛选会分叉。 注意 return_visit_type 官方注释只写 1-3,实测存在 4/5(311 条,占 12%),不能落 _default 丢掉: 4 → 100% 带治疗项,取值含「取消预约回访」「自定义」→ 自定义/事件驱动回访 5 → 100% 带治疗项且全是诊断名(残根/龋齿/根尖周炎),task_director 全为 System → 系统按诊断自动生成的召回 【新增 strip_html 算子】宿主回访内容用富文本编辑器录入,实测 794/2552(31%)带 <p>/<ol>/<li>。PAC 侧该字段是纯文本语义(详情页直接展示 + 喂召回话术 LLM), 带标签会原样显示成 "<p>xxx</p>" 并污染 LLM 上下文,故在摄入层剥掉而非留给每个消费方。 口径:块级标签转换行保住段落边界、实体还原(& 放最后避免二次解码)、全空 → null。 【canonical-fact-layer 闸 4 加特例】该闸按字段名校验 enum_mapping 目标 ∈ canonical-codes, 但 closedSets.status 装的是 PACTreatmentStatuses(治疗状态),与回访的「已回访/未回访」 同名不同义。回访三列是展示用自由文本(不进 fact、不参与召回),口径是与 jvs-dw 中文对齐, 故整个 patient_return_visit 跳过该闸(同 appointment.status 已有的特例)。 宿主多给的列(suggested_return_person / actual_return_person / actual_return_time / customer_status / is_first)原样带出存进 raw_payload,PAC 当前不映射 —— 将来要用不必再找宿主改。 测试 29 项;契约文档新增第 7 节(source 数 9 → 10);export.sh 补导出语句。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
luoqi committed
-
2026-08-28 那次修的是 dryRun 分支(整份 patientIds 塞进 `IN` → 3.2 万清单直接炸 `too many bind variables ... expected maximum of 32767, received 62899`)。 但只修了一半:**实跑分支的旋钮没有上限** const BATCH = Math.max(1, Number(process.env.PAC_REPARSE_BATCH) || 3000); ↑ 只夹下限 `PAC_REPARSE_BATCH=50000`(有人想"跑快点")就会让同一堵墙从实跑那侧长回来 —— 每个 patientId 占一个 bind,墙在 32,767。 改动:抽出 `reparseBatchSize()`,夹在 `PG_MAX_BIND_VARS - 100`(留 where 里 hostId/subjectType 等其余 bind 的余量),**dryRun 与实跑共用同一个值** —— 两侧各写一份是这个 bug 反复长回来的原因。 测试(tests/reparse-dry-run-bind-limit.spec.ts,3 条): · 假 prisma **照 PG 的规矩发脾气**:单条 count 的 bind 超限就抛同样的错。 不这么做的话"不分批"也能测过,是假绿。 · 62,899 患者(事故量级)dry-run 不抛;分片不重不漏(累加 = 62,899); 并断言 count 真发出去过(>1 次)—— 防"被判为非 transform 产出静默跳过"的假绿。 · 不给清单时走全量 count(where 里没有 IN,不占 bind)。 ·⛔ PAC_REPARSE_BATCH=100000 也不许把墙放回来 ← 本次新增的那半。 有牙验证:把夹子退回 `Math.max(1, raw)`,第 3 条立刻以生产原话失败 (`received 50002`),不是写完就绿的摆设。 出处:这份改动originally 躺在 worktree claude/peaceful-nightingale-940534 里未提交 (fixtures + spec 都建好了但没落),清理分支时捞出来落到 main 当前的代码形态上。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
luoqi committed
-
`refresh-clinic-names` 用 `any(organization_name)` 从源表派生诊所名。ClickHouse 的 `any()` 是**任取一个**,不是"取唯一的那个" —— 而诊所会改名,源表里同一个 id 存着历次名字。 2026-09-03 生产 DW 实测(fact_emr_treatment_out,66 家): 273393edd04e4aa4afe9347bb8a2da21 「正大诊所」 91,053 行 2016-07 ~ 2022-08 ← any() 抓到的 「花旗医院」 3,096 行 2023-06 ~ 2023-09 「瑞尔齿科上海花旗诊所」 42,646 行 2023-09 ~ 至今 ← 现名(宿主花名册也是这个) · **66 家里 53 家(80%)有多个名字**,不是个案 · 旧名用了六七年,**行数往往碾压现名** → 换成"取最高频"一样错 · c59bfc52…(华贸)有 6 个名字,其中两个带前导 `\t` 按最高频/任取都会把停用多年的旧名当成现名下发给前端。改为按 time_field 取最新: argMax(trimBoth(name), ifNull(toString(time), '')) `trimBoth` 同时用在取值和 notEmpty 判定上 —— 只用在取值上的话,纯 `\t` 的行仍会被 当作有效名参与比较。 改动: · 新增 clinic-directory.ts:buildClinicNameQuery / pickLatestNames 两个纯函数 (CLI 底部是 `void main()`,逻辑留在里面没法单测,故抽出) · 文件源分支同口径 —— 原来是"遍历行、后者覆盖前者",行序即文件顺序,等于随机取名 · manifest schema 加 time_field;jvs-dw 配 updated_date,friday 配 updated_gmt_at · 缺 time_field 时退化成字典序最大(只保证确定性,不保证是现名)并告警,不再 any() 生产 DW 实跑新 SQL 验证(只读,未写库):66 家全部派生成功, 273393ed… → 瑞尔齿科上海花旗诊所✅ c18cadf2… → 江苏瑞泰通善口腔学前街医院✅ 仍带空白/空名的:0⚠ ️ 生产 host.clinic_names 目前是空的({}),本次**不含**任何生产写入 —— 要不要跑这个 CLI 单独决策。另注:前端真实显示的诊所名以宿主换票传的 dictionary.clinics 为准(auth.controller 里它覆盖服务端派生值), 服务端这份只是打底,不能据此断言前端现在显示的是 GUID。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed
-
- 02 Sep, 2026 3 commits
-
-
luoqi committed
-
查上一条修复时,拿测试机做对照发现两件事: ① **`patient_return_visit` 少了个 s**(daily-health-report.service.ts:256)。 真实表名是 `patient_return_visits`(schema.prisma:455 的 @@map)。 报错被 pacPatientCount 的 try/catch 吞成 WARN + 返回 null → 日报照发, 只是「回访」那行长期没有 PAC 侧计数,静默缺了一项对账。 生产上看不到这个 WARN,因为它的日报根本没跑到这一步就先被连接池超时打死了 —— 一个 bug 把另一个 bug 遮住了。 ② **「测试服 URL 里显式 connection_limit=30,所以不受影响」是错的**,而这句话被 prisma.service.ts 的注释、测试文件的注释一路传下来,也是我判断"测试机不用验"的依据。 实测(`docker inspect`)两台容器里的 DATABASE_URL 都是 `postgresql://…@postgres:5432/pac?schema=public` —— **没有 connection_limit**: compose 的 environment: 段整条覆盖了 .env 里的值(.env.example 早就写了这条,只是没人联想到)。 佐证:测试机 09-01 的健康日报也报了同一句 `connection limit: 5`。 → 测试机跟生产是同一个池、同一个 bug,**这个修复在测试机验得出来**。 判断逃生口生不生效只能看容器实际环境变量,不能看 .env 文件;三处注释都已改口径。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luoqi committed -
现象:企微收不到每日健康日报。09-01 / 09-02 两天都在 **09:00:10** 失败 (= cron 09:00:00 + 10s pool_timeout),报错是拿不到连接而不是推送失败: Invalid `prisma.followupPlan.count()` invocation: Timed out fetching a new connection from the connection pool (Current connection pool timeout: 10, connection limit: 5) 根因两条叠加: ① **常驻服务的并发旋钮没被算进池。** withCohortDerivedPool 只认 PAC_DB_CONCURRENCY / PAC_COHORT_CONCURRENCY(都是 CLI 的旋钮),而 PAC_RECALL_SUBSCENARIO_CONCURRENCY=4 (2026-08-30 在生产开启,场景段 ×2.31)是**常驻进程**的旋钮 —— 批次因此长期占住 4 条连接, 池却还是 Prisma 默认。函数开头那句「一调并发就得手动调池,这里把两者联动」说的就是这个病, 只是当时只联动了 CLI 那半边。 ② **生产的默认池只有 5,不是注释里写的 9。** Prisma 默认池按**物理核**×2+1 算: 生产机 nproc=4 但 `Core(s) per socket`=2 / `Thread(s) per core`=2 → 物理核 2 → 池 = 5。 原注释按逻辑核算成 9,低估了一倍,实现和测试文件里都跟着错。 5 条连接里批次占 4 条,日报要并发发 6 个 count → 排队 → 10 秒超时。 同一根因还打掉过一条 plan upsert(`Unable to start a transaction in the given time`, 09-01 21:47,549,106 个命中患者里 1 个)。 改动:把 PAC_RECALL_SUBSCENARIO_CONCURRENCY 并入取最大 → 生产池 4×6+5 = 29。 生产 RDS max_connections=820(实测,当时全库仅 23 条在用),29 条毫无压力。
⚠ ️ 09:00 撞在 08:15 那轮的召回场景段(08:44~09:35)中间是**天天必撞**,不是偶发; 这里选择扩池而不是挪 cron —— 挪 cron 只躲开这一个碰撞,扩池同时修掉 API/plan 侧的抢连接。 顺带把三处写错或缺失的口径补上:实现注释、.env.example(DATABASE_URL 与并发旋钮的耦合、 显式 connection_limit 会让自动放大整个失效)、以及 conc 读取处的反向指引。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed
-
- 31 Aug, 2026 6 commits
-
-
luoqi committed
-
预约事实历来只有 doctor_id 没有姓名(前端/话术拿到裸 ID),而 DW 的 fact_appointment_out 每一行都带 resource_name。列名叫「排班资源名」有误导性, 先把它到底是不是医生姓名核实清楚: · 椅位另有列 appo_chair(仅 3 个取值),资源另有 resource_id —— 不是它们 · resource_name 与 appo_doc_id 的绑定比与 resource_id 更紧 (本地 (doc_id,name) 140 组 < (resource_id,name) 151 组) · 已到诊预约按 (患者,日期) 对当天病历 doctor_name:生产近 90 天 99,170/101,192 = 98.0% 命中;144,148 条明细里「id 对上而名字不对」与 「名字对上而 id 不对」各 0 条 → 就是 appo_doc_id 这个人的姓名 · create_name / appo_handler 是呼叫中心约号人(值形如 400-叶玉娇), director_name 是另一个角色 —— 都排除 口径:doctor_name = **约号时约的那位医生**,不是实际接诊医生(改派时不同, 实际接诊仍以病历为准)。两个口径别混。 少数排班资源不是人而是房间/服务/台席("预约"/"学前街手术室"/"正畸咨询"), 生产近 180 天 9,838/1,047,901 = 0.94%,不拦的话时间轴每 106 条就出现一次 「预约医生」,详情页「主治医生」的最高频兜底还可能解析成「学前街手术室」。 故加 AppointmentParser.isPersonResource: · 九词词表从那份实测清单反推,覆盖全部 19 个取值 · 误伤核验:生产 1,314 个真实医生名过规则,命中 1 个(「公共诊室」, 本身就是漏进病历的房间名)—— 真人零误伤 · 独立判据复核(不用词表):过闸的 428 个资源里 426 个的 id 在临床记录里 真的当过医生,漏网 105 行 = 0.010% · 被拦下的原值不丢,另存 content.resource_name; `WHERE resource_name IS NOT NULL AND doctor_name IS NULL` 即可审计 本地全量 reparse 验证(241,228 个版本 / 15 分钟):最新版 240,517 条, resource_name 100%、doctor_name 99.83%,拦下 413 条全是非人资源; 逐行对源 transaction,(doctor_id, resource_name) 240,517/240,517 成对回溯。 前端零改动 —— facts-timeline 的预约分支本来就把医生拼进 note,只是取不到值。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
luoqi committed
-
新增三节: · 4b 证据链 —— 那条 47.4h 退费,中间 17 轮增量 + **2 次全表重读**(491 万/426 万行) 都没拉到,第 20 次才出现。19 次查询看不见 = DW 当时确实没有,不是我们没去问。 且最后那轮窗口只剩 58 分钟余量,险过。 另记游标真实语义:cursor_after = run_start(墙钟),不是 max(updated_date); 原注释「run_start 之后 DW 任何写入下次都能捞回」有漏洞——过滤的是 updated_date。 · 4c 分表超时率 —— refund 18.7%>12h、recommendation 18.9%>6h、appointment 仅 2.3%。 中位数 2.39h 守住了承诺,尾巴没守住;分表差异极大 → DW 内部不同表走不同 ETL 链路。 recommendation 是召回第二信号源,18.9% 超 6h,直接影响时效。 · 4d 时效与丢失边界是同一个数(48h)——比 T+2 更晚的根本进不来,永远不可知。 且 full: 全量补摄是**人工临时跑**没有定时任务(最近一次 08-26),漏掉的不会自动修复。 记录两个未采纳兜底:定期全量补摄 / 跟 DW 对账。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luoqi committed -
luoqi committed
-
生产只读实测(7 天 / 281,760 条增量记录): 滞后 p50 2.39h / p99.9 15.39h / max 47.43h;>24h 31 条、>48h 0 条 按表拆:refund max 47.4h(>24h 有 27 条)、临床表 max 15.5h(>24h 全 0) 根因:游标跟 updated_date(源 HIS 改动时刻,≈事件时间),而数据到达取决于 DW 自己的 ETL —— 两者无关。所以必然有"updated_date 比游标旧、此刻才到达"的行,游标追不上。 退费那几条是铁证:源系统 13:05 建、13:13 改完,PAC 47 小时后才看到。
⛔ 不能缩:缩 24h 丢 31 条、36h 丢 19 条,且游标推过去是**永久**漏拉。 且 max=47.43h 恰好顶在 48h 边界、>48h 为 0 —— 这是**截断的指纹**, 真实尾巴可能更长。本测量方向单边:能证"够用"不能证"不够"。 根治不在我们这边:DW 十张表一个入仓时间字段都没有(rq 是业务日期已排除)。 若 DW 加一列 ETL 写入的时间戳,游标即可在到达顺序上单调 → 漏拉结构上不可能, fetched 从 24.8 万降到约 1.6 万,摄入 31m → 预计 5~10m。⛔ 另记一个取样陷阱:第一版按 received_at 时间窗取样,混进 5 次 full: 全量补摄的 历史数据,得出"滞后 5 年"的荒谬结果。既有增量又有补摄的系统,取样必须按事件来源限定。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed
-
- 30 Aug, 2026 13 commits
-
-
测试机验证(87,885 命中患者 / 44 chunk): 预取 288,003ms / 247,250ms(旧基线) → 183,810ms(新) −26~31% 命中患者 87,885,在 87,849~87,853 漂移带内 —— 口径没变 本地(19,476 患者 / 10 chunk):预取 8,383 → 4,899ms;plan_reasons 行级 diff=0 推算生产:预取 20m09s → 省 5~6 分钟,整轮 2h02m → 约 1h56m,回到 2 小时线内。
luoqi committed -
① snooze 抑制集提到 chunk 循环外一次查完。 它的代价跟「终态且冷静期未到期的计划数」走,跟患者数无关 —— 2026-08-30 生产实测全库符合条件只有 **106 行**,而按 chunk 查要跑 274 次 (54.8 万患者 / 2000),**273 次在查空**。
🔴 这不是省常数,是消掉一个 O(患者数) 项:到 200 万患者原写法是 1000 次往返, 新写法仍是 1 次。生产百万级且在涨,这类项要按规模判断而不是按当下耗时。 索引 (status,…) 前导 status,completed/abandoned 是稀有态 → Bitmap 扫 42 buffers/0.6ms。 ② 余下三条(latest plan / persona / 末次到诊诊所)彼此独立,改 Promise.all 并行。 每 chunk 墙钟从「三条之和」降到「最慢那条」。并发度恒为 3,不随患者数涨, 不会挤爆 Prisma 池(默认 核数×2+1)。 口径零变化(三条都是只读、无共享状态;snooze map 只会被本批患者查到)。 本地实测:预取 8,383ms → 4,899ms;**plan_reasons 逐行 diff = 0**(39,225 行)。⚠ ️ 预取是纯性能路径,单测覆盖不到,所以靠真实数据端到端行级对拍来验。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
luoqi committed
-
生产 10 万患者子集对拍 11/11 零差异 → 开启 → 20:15 轮第 1 批判据失败 → 回退。 missing_tooth 8m13s → 30m42s(慢 3.7×) endo_no_rct 8m12s → 18m21s(慢 2.2×) 第 1 批墙钟 17m03s → 30m42s(+80%) 命中数仍在漂移带内 —— **正确性没问题,是性能不达标**。 临时文件零增长、swap 为 0 —— 也不是内存问题(这次没再编错误归因)。 归因:集合式代价跟**患者域**走(gap_scope 要预聚合全域),legacy 跟**候选行数**走。 规模一变结论就翻转:30K/585K/10万子集 都快 1.5×,113 万全量慢 3.7×。
⛔ 教训:方案里写过「子集零差异 ≠ 全量零差异」,但只想着正确性覆盖。 真正被掩盖的是**性能**,而且方向都反了。子集测正确性有效,测性能无效 —— 除非能先证明代价与规模线性,而集合式恰恰不是。 生产维持 legacy + 并发4(场景段 50m25s / 整轮 1h17m)。代码留 main,默认关闭。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
luoqi committed
-
生产 113 万患者全量差分要几小时,还会跟 2 小时批次抢 RDS;收窄到几十万后 几分钟就能在**生产真实数据**上拿到证据。抽样偏向"有 active 诊断/建议信号"的患者, 否则大多数抽中的人两版都返回空,验了个寂寞。
⚠ ️ 收窄降低的是**覆盖**不是可信度:差异一旦出现仍是真差异;但「子集零差异」 ≠「全量零差异」,所以报告行里带上患者域,逼自己写清跑的是多少人。 本地 3000 位子集实测:11/11 零差异。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
luoqi committed
-
把 buildGapCore 的 resolvedTeethSql 从**逐 (患者×信号) 行相关子查询**改成 **集合式预聚合 + 反连接**。核心恒等式 ∃x∈G: t(x) ⋛ a ⟺ max{t(x)} ⋛ a, 分组 G=(患者,牙位)。13 个分支对 sig 的相关性只有三类: 时间门(10)/ 病历号等值(1)/ 无相关(2),外加「建议优先」一条的 sig.type 标量谓词。⚠ ️ 集合式是**独立重写一份**,刻意不与 legacy 共用片段 —— 共用则重构写错的地方两边 一起错、对拍互相抵消。等价性靠 verify-gap-equivalence 逐行差分来证。 全口码(K05/K07)不进集合式:其 lateral PG 本来就会摘掉(零收益),硬套还慢 2~3 倍。 **默认 legacy**,靠 PAC_GAP_VARIANT=setbased 显式开启。生产不设该变量 → 行为零变化。 验证(测试机 585K 患者 / 本地 30K): · SQL 层 11/11 子场景逐 (患者×信号×牙位) **零差异**,行数逐个相同 · 画像消费方 2000 位患者零差异,单患者 p95 47→27ms 不劣化 · 端到端 plan_reasons 行级 diff=0;测试机 32 万条差异仅时间漂移、**零删除** · 交互路径(详情页刷新)200 位 × 11 子场景零差异,一次刷新 89→75ms · 13 条分支源行全部非空 —— 零差异不是空转 收益(测试机相邻两轮,并发4):场景段 794s → 545s(×1.46)。**生产未验**。 新增 src/cli/verify-gap-equivalence.cli.ts:两版同一 REPEATABLE READ 快照双向 EXCEPT ALL 差分;--self 自对拍先证工具可信;--persona/--single/--conc 覆盖 画像、交互、并发标定四条路径。 新增 tests/gap-setbased-parity.spec.ts:结构对拍,守「两种形态的分支集合不许走散」 (加分支只改一边 = 静默错召,tsc 和现有 spec 都发现不了)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
删掉四类冗余: · 「该计划共 N 条理由,另 X 条判得对」的旁注(#8/#10/#14/#15/#17) · 算法机制的重复解释(#9「算法逻辑没错,是事实没进结构化数据」等) · 「该修复上线晚于本单建单,故当时未生效」这类时序补注(#19/#22) · 举证细节的收尾展开(#8 总院回访那段收成一句) 个案表是给人看结论的,不是复述推理的地方。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luoqi committed -
luoqi committed
-
生产全量实测(113 万患者 / 54.8 万命中,同一台 RDS,相邻两轮): 串行(=1): 场景段 7,200,096ms(2h00m) 整轮 8,654,105ms(2h24m) 并发(=4): 场景段 3,115,928ms(51m56s) 整轮 4,806,869ms(1h20m) → 场景段 ×2.31,整轮 ×1.80,省 64 分钟 先在生产一个 39,148 患者的诊所交替标定(×2.18),再由全量复现(×2.31)。
🔴 推翻原有的「⛔ 别指望靠这个旋钮提速」结论 —— 它让生产一年多没开这根杠杆。 原依据是「并发3=24.1分 vs 串行23.3分」,两处都错: ① 3% 差距落在 ±25% 的环境噪音里(用「两边跑同一条 SQL」的对照组量过),那次比较 什么也没证明; ② 机制归因也错:真瓶颈是**延迟**(逐次索引探查等 page,CPU 与磁盘都闲着)不是吞吐, 所以并发 2 就超线性(生产 ×1.50 / 测试机 ×2.88)。若真是吞吐受限,墙钟应约等于 各查询耗时之和;实测墙钟只有求和的 43%。 同时把「集合式重写是唯一出路」那段改成现状:并发已解决窗口问题,集合式转为可选。 .env.example 补文档,含「判据只能看阶段墙钟、不能看单条 sql=」的读数陷阱。 本提交只改注释与 .env.example,不改任何运行逻辑(生产开关已于 2026-08-30 09:16 生效)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
生产事故修复(2026-08-30):在 pac-service 容器里 docker exec 跑任何 CLI, 都会 createApplicationContext(AppModule) → 跑一遍 SyncIncrementalScheduler.onModuleInit → reapStaleRunningLocks 把 service 里**正在跑**的那轮同步当僵尸锁清掉。 实测 08:17:11 起 recompute-plans → 08:17:13 正常跑着的 08:15 那轮被标 failed (前 19 轮全 success)。数据未丢(cursor_after=null,下轮同水位 catchup),但白丢一轮。 两道防线:年龄阈值 REAP_MIN_AGE_MS=3h + 16 个 CLI 建上下文前设 PAC_SCHEDULER_DISABLED=1。 不带任何开关,上线即生效。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luoqi committed -
🔴 事故(2026-08-30 生产):在 pac-service 容器里 docker exec 跑 recompute-plans, 08:17:11 起进程 → 08:17:13 正在跑的 08:15 那轮同步被标 failed。前 19 轮全 success, 只死了撞上的这一轮。数据未丢(cursor_after=null,下轮同水位 catchup),但白丢一轮。 根因:每个 CLI 都 createApplicationContext(AppModule) → 跑一遍 SyncIncrementalScheduler.onModuleInit → reapStaleRunningLocks。 原判据是「startedAt < 本进程启动 = 僵尸锁」,注释里写着"两者的进程都不可能比本进程 启动得更早还活着"—— 但**长驻的 pac-service 恰恰就是那个更早启动还活着的进程**。 理由写反了方向,而且只在"回收逻辑跑在长驻服务里"时才成立。 两道防线: ① scheduler 加年龄阈值 REAP_MIN_AGE_MS=3h —— 真僵尸锁必然躺很久,正在跑的不会。 用年龄区分,不靠猜进程身份。(生产单轮摄入实测 28~52 分钟) ② 新增 src/cli/bootstrap-flags.ts,16 个 CLI 在建上下文**之前**设 PAC_SCHEDULER_DISABLED=1(该总闸本就会跳过回收,只是没人用)。 豁免 sync-incremental.cli(它就是要触发同步),由 ① 兜底。 回归测试 tests/cli-scheduler-guard.spec.ts:遍历所有会建上下文的 CLI, 断言调用存在**且位置早于** createApplicationContext;并锁住年龄阈值 ≥2h。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed
-
- 29 Aug, 2026 11 commits
-
-
luoqi committed
-
2026-08-29 生产六轮实测(endo_no_rct,72,769 行,全部在生产 RDS 上跑): 配置 耗时 相对基线 无索引 + 4MB (冷) 18:43 无索引 + 4MB (暖) 17:33 ← 基线;冷暖只差 6%,缓存不是主因 无索引 + 256MB (暖) 11:37 −34% 无索引 + 256MB (暖,复现) 12:12 −30% 有索引 + 4MB (暖) 16:42 −5% ← 噪声内,索引已从生产撤除 有索引 + 128MB (暖) 14:47 −16% ← 128MB 只吃到一半收益
⭐ 取 256MB:收益对取值很敏感,128→256 还差一倍。原本按「溢出只有 4 批、 内存用量 8MB」推断 128MB 够用,实测否定了这个推断。⛔ 事务级 SET LOCAL,不能全局调:work_mem 是每个排序/哈希节点的上限,不是每连接。 生产 RDS 约 7GB 内存,全局设大值遇上并发排序会吃穿。SET LOCAL 出事务自动还原, Web API 连接不受影响;场景查询串行,同一时刻只有一条,峰值可控。⛔ 别再加信号码部分索引:测试机与生产都测过,生产 −5% 落在噪声里 (同配置两次测量本身差 6%),不值得引入一个 Prisma 管不到的索引。 tests/recall-future-return-visit-gate.spec.ts 的 SQL 捕获桩补 $transaction/$executeRaw。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
补的是常态可观测性:2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟仍未跑完, 而引擎中间什么都不打,只能临时起 pg_stat_activity 采样器反推。 现在每个子场景一行(SQL 与 Node 后处理分开计时)+ 每轮阶段耗时一行。 同时带上义齿按颌分支的预过滤(逻辑恒等,单患者路径实测 16.7×)—— 它针对的正是本次新增的两条 exam_findings 分支,是生产回归的头号嫌疑。
luoqi committed -
luoqi committed
-
2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟仍未跑完,而引擎从「
▶ Running engine」 到最后的统计块之间**什么都不打**,整轮是个黑盒 —— 只能临时起 pg_stat_activity 采样器 反推,才勉强定位到子场景粒度。这组日志就是为了不再重复那个过程。 ① 每个子场景一行(常态开启,每轮 11 行): [recall] sub=missing_tooth code=K08 sql=1234ms rows=567 post=89ms hits=42⭐ SQL 与 Node 后处理**分开计时** —— 今天最大的困难就是知道慢、却分不清 慢在 SQL 还是慢在 Node。 ② runAllForHost 三段(常态开启,每轮 1 行): [plan] 阶段耗时 场景=Xms 预取=Yms 写入=Zms 命中患者=N 总计=Wms 今天一度误以为瓶颈在预取,有这行就不会跑偏。 ③ PAC_RECALL_DUMP_SQL=1(默认关)保留,需要拿 SQL 原文做 EXPLAIN 时才开。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
luoqi committed
-
2026-08-29 用 dump 开关 + pg_stat_activity 采样 + EXPLAIN ANALYZE 测出来的, 结论与直觉相反,写进代码免得后来人重走弯路: ① 11 个子场景只有 2 个慢(K05 牙周 9m13s / K01 阻生牙 13m+),其余 9 个秒回; 且与结果集大小无关 —— K02 理由数最多(49,382)却秒回。 ② 11 条 SQL 逐字节相同,只有绑定参数不同 → 是数据分布 + 执行计划,不是 SQL 写法。 ③ 真实热点:Append(resolvedTeeth 的 UNION) loops=117,256 × 1.71ms ≈ 201 秒, 占 K01 单条 301 秒的 68% —— 每候选行跑一次的 gap 相关子查询。
⛔ 已实测否定,别再试: · 子场景并发=3:24.1 分钟 vs 串行 23.3。瓶颈是共享磁盘 I/O,并行只抢同一批 page。 · 信号码部分索引:EXPLAIN 估算成本降 13×(122 万行 → 5,593 行), 墙钟 24.5 vs 23.3 —— 无效。教训:别拿估算成本当依据,要看 EXPLAIN ANALYZE 的实际时间。 (该索引的迁移已撤回,不上生产。)✅ 真正的方向(独立项目,未做):resolvedTeethSql 从逐行相关子查询改集合式。 它是召回与画像共用的单一真理源,重写必须配等价性验证,否则是拿静默少召赌运气。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
PAC_RECALL_DUMP_SQL=1 时把每个子场景的完整 SQL + 绑定值打到日志。默认关,零开销。 为什么需要:2026-08-29 排查 plan 段耗时,采样抓到某条子场景查询单次跑 6.7 分钟, 但 pg_stat_activity.query 被 track_activity_query_size(默认 1024 字节)截断 —— 拿不到全文就没法 EXPLAIN,排查卡死在这一步。调 track_activity_query_size 要重启 PG, 生产上不划算;做成开关更可控。 实现上主查询从「$queryRaw 标签模板」改为「先建 Prisma.sql 对象再 $queryRaw(obj)」, 两者等价,但对象有 .sql / .values 可检视。 tests/recall-future-return-visit-gate.spec.ts 的 SQL 捕获桩同步兼容两种调用形态。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luoqi committed -
修的是 2026-08-29 生产的两个真实故障: ① plan 段被拖过 2 小时 cron 间隔后套圈,雪崩自持(源头消失也不自愈) ② 18 万患者 reparse 跑满 61/61 批后倒在收尾统计的 bind 变量溢出
luoqi committed -
2026-08-29 测试服实测(585K 患者 / 87,661 命中,空闲机): 串行(默认 1):24.6 / 21.7 / 23.5 分钟(三次) 并发 3: 24.1 分钟 —— 不但没快,还略慢 采样显示全程 DataFileRead:本阶段是共享磁盘 I/O 受限,不是查询延迟受限, 并行只让几条查询抢同一批 page,总读取量不变。要提速得减少读取量(索引/收窄扫描), 不是提高并行度。把这个负结果写进注释,免得后来人再拧一次这个旋钮。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luoqi committed -
## 一、cron 防重入(sync-incremental.scheduler.ts)
🔴 2026-08-29 生产事故:plan 段耗时涨过 2 小时后,cron(每 2 小时)照常触发下一轮, 两轮 plan 段并发抢同一批 I/O → 都更慢 → 更易被再下一轮套圈 → 雪崩。 实测 08-28 20:15 起连续多轮 plan 段一次都没跑完;到 08-29 11:19(即触发原因 reparse 结束 1.4 小时后)仍有两轮在并行互拖 —— 雪崩是**自持**的,源头消失也不会自愈。 现有的锁都挡不住,这是本修复存在的理由: ① NestJS CronJob **默认不防重入** —— 上一次回调还在 await,下一次照样进; ② sync_logs 的 partial UNIQUE(host_id) WHERE status='running' 只覆盖**摄入段**, 摄入一结束锁就放了,而最慢的 persona / plan 段还在跑。 所以必须在回调入口用进程内 Set 挡。跳过而非排队:摄入是游标增量,下轮自然 catchup; plan 是时间驱动全量,跳一轮只是晚 2 小时评估,远好过雪崩。 释放放在 finally —— 抛异常时不释放会把该 host 锁死到进程重启。 tests/scheduler-reentrancy-guard.spec.ts 锁四条:并发跳过 / 结束后放行 / 按 host 而非全局 / 抛异常也释放。 ## 二、reparse 两处 bind 变量溢出(cold-import.service.ts) `patientId: { in: [...] }` 直接塞完整患者清单会撞 PG 的 32767 上限。 实跑路径(第 3 步统计受影响患者)—— 2026-08-29 生产实测:18 万患者的 reparse 跑满 61/61 批、写完全部事实(superseded=9,062)之后**倒在最后一步**: Assertion violation: too many bind variables ... received 32769 6.6 小时的活全干完,只因收尾统计炸掉而 exit 1。 dry-run 路径同病:>3.2 万患者直接崩,而 --patients-file 的文档恰恰说 「按受影响患者收窄是最有效的提速手段(可达 250 倍)」—— 最需要先 dry-run 探路的 大清单场景,正好是它唯一不工作的场景。 两处都按 3000 分块(与 PAC_REPARSE_BATCH 同款),每块 3001 个变量,离上限很远。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed
-