Commit 62bcc0af by luoqi

fix(sync): source_unit 解析器不再挂单例实例字段 —— 根治跨宿主串味建出的重复主档

ColdImportService 是 Nest 单例,却把 source_unit 解析器存成实例字段,每次摄入开头
覆写、在长 async 循环里读。原注释「cold-import 按 host 串行,实例字段安全」在 push
(webhook,按请求并发)接入后失效:

  friday  identity_namespace_field = tenant_id
  jvs-dw  identity_namespace_field = brand

两者并发摄入互相覆写 resolver → 对方的行解析不出命名空间列 → source_unit=''
→ 患者索引 (source_unit, external_id) 未命中 → 建出空命名空间的重复患者主档。

测试服务器实测(2026-07-29 查证):
  - friday 首推 07-23 16:24:26,jvs-dw 首个空品牌患者 16:24:31(5 秒内);
    此前 jvs-dw 自 06-28 起 39 万患者零个空品牌。
  - jvs-dw 空品牌患者 51063 条,其中 51037(99.95%)与真主档同 external_id;
    friday 空品牌 498 条,496 条落在 jvs-dw pull 窗口内,354 条是重复档。

改法照抄 tenantResolver 一贯的纪律:局部 const 构建 + 逐层传参。涉及 4 个入口
(reparse / ingestRawTables / importDirectory / importPatient)与 5 个读取方法
(processPatients / processPatientRelations / processPatientReturnVisits /
processSubject / hydratePushLookupTables)。纯管道改造,无行为变更。

并加源码闸测试 ingest-resolver-no-instance-state.spec.ts:这类 bug 单跑任何一条
路径都正确,只有并发交错才炸,单测抓不到,只能在源码层禁止 `this.*Resolver =`。
(已验证该闸对修复前的代码报 4 处赋值 / 9 处读取。)

注:线上已污染的 5.1 万条空命名空间主档需另行归并,不在本次范围。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 4bd77113
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* 摄入侧的 per-request 解析器**不许挂单例实例字段**。
*
* 踩过的坑(2026-07-23 → 07-29,测试服务器):
* ColdImportService 是 Nest 单例,却把 source_unit 解析器存成实例字段
* `private sourceUnitResolver`,每次摄入开头覆写、然后在长 async 循环里读。
* 当时的注释写着「cold-import 按 host 串行,实例字段安全」—— 这个前提在 push
* (webhook,按请求并发)接入后就失效了:
*
* friday identity_namespace_field = tenant_id
* jvs-dw identity_namespace_field = brand
*
* 两个 host 并发摄入时互相覆写 resolver,对方的行解析不出命名空间列 → source_unit=''
* → 患者索引 `(source_unit, external_id)` 未命中 → 建出空命名空间的**重复主档**。
* friday 首次 push 是 07-23 16:24:26,jvs-dw 第一个空品牌患者出现在 16:24:31(5 秒内),
* 此前 39 万条 jvs-dw 患者零个空品牌。到 07-29 累计 5.1 万条,其中 99.95% 与真主档
* 同 external_id —— 纯污染。
*
* 单测抓不到这类 bug(单跑任何一条路径都正确,只有并发交错才炸),所以在源码层设闸:
* 解析器一律**局部构建 + 逐层传参**(tenantResolver 从一开始就是这么做的,照抄它)。
*/
describe('摄入解析器不得存为单例实例状态', () => {
const file = join(__dirname, '../src/modules/sync/cold-import/cold-import.service.ts');
const src = readFileSync(file, 'utf8');
// 注释行不算(类头就写着这段事故说明,里面必然出现这些词)
const code = src
.split('\n')
.filter((l) => !/^\s*(\/\/|\/\*|\*|\/\/\/)/.test(l))
.join('\n');
test('⭐ 不存在 `this.<x>Resolver = ...` 赋值(per-request 状态写进单例)', () => {
const hits = code.match(/this\.\w*[Rr]esolver\s*=[^=]/g) ?? [];
expect(hits).toEqual([]);
});
test('⭐ 不存在 `this.sourceUnitResolver` 读取(必须走入参)', () => {
const hits = code.match(/this\.sourceUnitResolver/g) ?? [];
expect(hits).toEqual([]);
});
test('每个读 source_unit 的 process* 方法都把 resolver 声明为入参', () => {
const readers = [
'processPatients',
'processPatientRelations',
'processPatientReturnVisits',
'processSubject',
'hydratePushLookupTables',
];
for (const m of readers) {
const sig = code.slice(code.indexOf(`private async ${m}(`));
const params = sig.slice(0, sig.indexOf('): Promise<'));
expect(`${m}: ${params.includes('sourceUnitResolver')}`).toBe(`${m}: true`);
}
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment