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
108558dd
Commit
108558dd
authored
Aug 01, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: feat/jvs-dw-complex-case-gate → main(jvs-dw 潜在治疗池不进召回 + 增量链路四处修复)
parents
257ba23b
82d782dd
Pipeline
#3514
failed in 0 seconds
Changes
5
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
281 additions
and
24 deletions
+281
-24
apps/pac-service/data/jvs-dw/manifest.yaml
+14
-4
apps/pac-service/src/modules/sync/cold-import/clickhouse-source.service.ts
+160
-19
apps/pac-service/src/modules/sync/cold-import/manifest.schema.ts
+5
-0
apps/pac-service/tests/cohort-only-patient.spec.ts
+42
-1
apps/pac-service/tests/complex-case-gate-sql.spec.ts
+60
-0
No files found.
apps/pac-service/data/jvs-dw/manifest.yaml
View file @
108558dd
...
...
@@ -115,9 +115,12 @@ sql_source:
clinic_scope
:
from_table
:
dw_group.fact_emr_treatment_out
org_column
:
organization_id
# 增量「反向拉主档」的来源表 —— 这些表有自己的 cursor,它们变了就要把对应患者的主档补拉回来
# (主档 cursor 是 last_visit_time,人不来诊拉不到)。前四张是历史默认(EMR 编辑等场景);
# fact_complex_cases_out 是本次新增:宿主开/关复杂病例时把人带进 cohort,跟进闸才跟得上。
# 增量「反向拉主档」的来源表 —— 这些表变了就把对应患者的主档补拉回来
# (主档 cursor 是 last_visit_time,人不来诊拉不到)。
# ⚠️ **仅 single-shot(非 cohort)模式走这条**。jvs-dw 日常增量是 cohort 模式,
# 靠 incremental.per_query 的 cursor 拼 UNION 分支列患者(见上面 fact_complex_cases_out
# 的注释),不经过反向拉。这里保持声明是为了两点:① 该列表原先四张表名硬编码在通用
# service 里,挪进 yaml 才符合"宿主差异只在 yaml";② single-shot 路径仍然会用到。
reverse_pull_from
:
-
fact_appointment_out
-
fact_emr_treatment_out
...
...
@@ -151,8 +154,15 @@ sql_source:
# ── 复杂病例(潜在治疗池)──
# ⚠️ 本表**不产 fact、无 assembler**,拉它只为一件事:增量时把"病例变了但人没来诊"的患者
# 带进 cohort
(见 cohort.reverse_pull_from)
。真正的判定在上面主档 SQL 的标量子查询里。
# 带进 cohort。真正的判定在上面主档 SQL 的标量子查询里。
# 不拉它的话:患者不来诊 → 主档 cursor(last_visit_time)拉不到 → 病例开/关 PAC 永远不知道。
# ⭐ 生效的是下面 incremental.per_query 给它配的 cursor:listPatientPairs 会把每张
# **配了 cursor 且有水位**的表拼成 UNION 分支来列患者(cohort 模式走这条,不走反向拉)。
# 2026-08-01 测试服实测:接入后 UNION 从 6 张变 7 张,窗口内 19 个"末次就诊在几个月前、
# 但病例刚变"的患者被正确带进 cohort 并写上闸。
# ⚠️ **新表首轮不生效**:无历史水位 → cursorValue 为空 → 该分支被跳过(见
# clickhouse-source.service.ts 的 `if (!cfg.cursorValue) continue`)。第一轮只建水位,
# 第二轮起才开始感知变化。上线时别把首轮的"没反应"当 bug。
# changed_at:updated_gmt_at 有 44% 为 NULL(建后没改过,恰是刚入池的新病例),
# 直接拿它当 cursor 会 `NULL > x` 恒 UNKNOWN 静默漏掉这批 → 必须 coalesce 到 created_gmt_at。
fact_complex_cases_out
:
|
...
...
apps/pac-service/src/modules/sync/cold-import/clickhouse-source.service.ts
View file @
108558dd
...
...
@@ -11,20 +11,55 @@ import { DEFAULT_REVERSE_PULL_FROM, type ClickHouseSource } from './manifest.sch
* 未设置 / 空 → 返回 [](调用方据此判断"是否定向模式",影响 cohort WHERE 与 cursor 推进)。
*/
export
function
resolveOnlyPatientIds
():
string
[]
{
return
resolveOnlyPatientKeys
().
map
((
k
)
=>
k
.
key
);
}
/**
* 同上,但保留**命名空间维**(manifest cohort.tenant_key_column 声明的那一维)。
*
* 【为什么需要第二维】患者主键只在命名空间内唯一:jvs-dw 的 patient_id 261067 在
* 「瑞尔」和「瑞泰」是两个不同的人(集团模型,见 manifest identity_namespace_field)。
* 只按 key 定向 → 两边同号的人一起被拖进 cohort。2026-08-01 测试服实测:
* 7 万个 id 的定向名单列出 **140,566** 个 cohort key —— 整整翻倍,一半是无关的另一命名空间患者。
*
* 【通用性】这里刻意**不出现 brand 字样**:第二维叫什么由各宿主的 manifest 声明
* (jvs-dw 是 brand;别的宿主可能是区域 / 诊所 / 不设)。没配 tenant_key_column 的
* 单命名空间宿主写纯 id 即可,行为与本改动前完全一致。
*
* 格式(每行 / 每个逗号项):
* `1855960` → { key } 单命名空间,或"该 id 在所有命名空间下都要"
* `1855960|瑞尔` → { key, tenant } 精确到命名空间
* `1855960<TAB>瑞尔` → 同上(便于 SQL 直接 dump TSV)
*/
export
interface
OnlyPatientKey
{
key
:
string
;
tenant
?:
string
;
}
export
function
resolveOnlyPatientKeys
():
OnlyPatientKey
[]
{
const
raw
=
process
.
env
.
PAC_COHORT_ONLY_PATIENT
?.
trim
();
if
(
!
raw
)
return
[];
let
lines
:
string
[];
if
(
raw
.
startsWith
(
'@'
))
{
const
file
=
raw
.
slice
(
1
);
if
(
!
fs
.
existsSync
(
file
))
{
throw
new
Error
(
`PAC_COHORT_ONLY_PATIENT 指向的文件不存在:
${
file
}
`
);
}
return
fs
.
readFileSync
(
file
,
'utf-8'
)
.
split
(
'
\
n'
)
.
map
((
s
)
=>
s
.
trim
())
.
filter
(
Boolean
);
lines
=
fs
.
readFileSync
(
file
,
'utf-8'
).
split
(
'
\
n'
);
}
else
{
lines
=
raw
.
split
(
','
);
}
return
raw
.
split
(
','
).
map
((
s
)
=>
s
.
trim
()).
filter
(
Boolean
);
return
lines
.
map
((
s
)
=>
s
.
trim
())
.
filter
(
Boolean
)
.
map
((
line
)
=>
{
// `|` 优先(显式分隔),否则 TAB(SQL dump 的天然形态);都没有 → 只有 key
const
parts
=
line
.
includes
(
'|'
)
?
line
.
split
(
'|'
)
:
line
.
split
(
'
\
t'
);
const
key
=
(
parts
[
0
]
??
''
).
trim
();
const
tenant
=
parts
.
length
>
1
?
(
parts
[
1
]
??
''
).
trim
()
:
undefined
;
return
tenant
?
{
key
,
tenant
}
:
{
key
};
})
.
filter
((
k
)
=>
k
.
key
);
}
/**
...
...
@@ -449,14 +484,30 @@ export class ClickHouseSourceService {
if (incremental) {
for (const [tbl, cfg] of Object.entries(incremental.perQuery)) {
if (!cfg.cursorColumn || !cfg.cursorValue) continue;
const m = source.queries[tbl]?.match(/FROM\s+([\w.]+)/i);
const fqn = m?.[1] ?? tbl;
const q = source.queries[tbl];
const keyCols = tenant_key_column
? `
$
{
patient_key_column
},
$
{
tenant_key_column
}
`
: patient_key_column;
incBranches.push(
`
SELECT
$
{
keyCols
}
FROM
$
{
fqn
}
WHERE
$
{
cfg
.
cursorColumn
}
>
'${cfg.cursorValue.replace(/'
/
g
,
"''"
)}
'`,
);
// ⭐ 分支 SQL **包裹 manifest 的 query**,不直接查物理表。两个原因:
// ① 患者键列名未必与 patient_key_column 同名(fact_complex_cases_out 是 customer_id,
// 靠 query 里 `
AS
patient_id
` 对齐)——直接查表读不到别名,CH 报 Missing columns。
// ② 顺带继承该 query 的业务过滤(退费单等),与真实拉取同口径 —— 否则会因
// "游标之后但会被业务条件过滤掉"的行把无关患者拖进 cohort(同 extractBusinessFilters 的教训)。
// injectIncrementalCursor 已按顶层 FROM 解析,子查询里的 FROM/WHERE 不会串味。
// 解析不了(非常规 SQL)→ 回退旧形态:直查表名,至少不比改动前差。
let branch: string | null = null;
if (q) {
try {
branch = `
SELECT
$
{
keyCols
}
FROM
(
$
{
this
.
injectIncrementalCursor
(
q
,
cfg
.
cursorColumn
,
cfg
.
cursorValue
)})
`;
} catch {
branch = null;
}
}
if (!branch) {
const fqn = (q && this.splitSelectFrom(q)?.fromTable) ?? tbl;
branch = `
SELECT
$
{
keyCols
}
FROM
$
{
fqn
}
WHERE
$
{
cfg
.
cursorColumn
}
>
'${cfg.cursorValue.replace(/'
/
g
,
"''"
)}
'`;
}
incBranches.push(branch);
}
}
const unionMode = incBranches.length > 0;
...
...
@@ -468,11 +519,34 @@ export class ClickHouseSourceService {
// 单患者复现 / 定向重摄受影响子集(分类修复后只重摄 reclassify 的患者)用。
// ⭐ 大名单(上万个 id)用 `@/path/to/file` 从文件读(每行一个 id):环境变量单值有
// 128KB(MAX_ARG_STRLEN)上限,3 万个 id 的逗号串约 275KB 会直接 E2BIG。
const ids = resolveOnlyPatientIds();
if (ids.length > 0) {
const quoted = ids.map((id) => `'
$
{
id
.
replace
(
/'/g
,
"''"
)}
'`).join('
,
');
whereParts.push(ids.length === 1 ? `${patient_key_column} = ${quoted}` : `${patient_key_column} IN (${quoted})`);
this.logger.log(`[clickhouse·cohort] PAC_COHORT_ONLY_PATIENT 定向:${ids.length} 个患者`);
// ⭐ 名单可带**命名空间维**(`id|<tenant值>` 或 TAB 分隔)—— 患者主键只在命名空间内唯一,
// 只按 key 定向会把别的命名空间下的同号患者一起拖进来(实测 7 万名单列出 14 万 key)。
// 维名不写死:用 manifest 的 tenant_key_column;没配该列的宿主写纯 id,行为不变。
const onlyKeys = resolveOnlyPatientKeys();
const ids = onlyKeys.map((k) => k.key);
if (onlyKeys.length > 0) {
const q = (v: string) => `'
$
{
v
.
replace
(
/'/g
,
"''"
)}
'`;
const withTenant = onlyKeys.filter((k) => k.tenant);
if (tenant_key_column && withTenant.length === onlyKeys.length) {
// 全部带命名空间 → 复合键精确定位(与 cohort key 同形态)
const tuples = onlyKeys.map((k) => `(${q(k.key)}, ${q(k.tenant!)})`).join('
,
');
whereParts.push(`(${patient_key_column}, ${tenant_key_column}) IN (${tuples})`);
} else {
if (withTenant.length > 0) {
this.logger.warn(
`[clickhouse·cohort] 名单里只有 ${withTenant.length}/${onlyKeys.length} 项带命名空间 —— ` +
`混写不做部分匹配,整体退回按 ${patient_key_column} 定向(同号跨命名空间会一并纳入)`,
);
}
const quoted = ids.map(q).join('
,
');
whereParts.push(
ids.length === 1 ? `${patient_key_column} = ${quoted}` : `${patient_key_column} IN (${quoted})`,
);
}
this.logger.log(
`[clickhouse·cohort] PAC_COHORT_ONLY_PATIENT 定向:${onlyKeys.length} 个患者` +
(tenant_key_column && withTenant.length === onlyKeys.length ? `(含命名空间,精确匹配)` : ''),
);
}
// --clinics=X,Y:把 cohort 收窄到「在这些诊所看过」的患者。诊所过滤挂在有 org 的事实表
// (cohort.clinic_scope),用与 cohort 相同的患者键求交集。⚠️ 只收窄「列哪些患者」,
...
...
@@ -534,12 +608,69 @@ export class ClickHouseSourceService {
const selectCols =
(tenant_key_column ? `${patient_key_column}, ${tenant_key_column}` : patient_key_column) + extraSelect;
const listFrom = unionMode ? `(${incBranches.join('
UNION
ALL
')})` : patient_list_from;
// ⭐ 定向名单分片跑 —— ClickHouse `max_query_size` 默认 **256 KiB**,一条 SQL 装不下大名单。
// 2026-08-01 测试服实测:7 万个 id 拼成 `IN (...)` 约 630KB →
// `Syntax error: failed at position 262142`(= 256 KiB 边界),重试 3 次全败、整跑 fatal。
// ⚠️ 这跟 resolveOnlyPatientIds 的 `@file` 是**两个不同的上限**:那个解的是环境变量
// 128KB(E2BIG,传参侧),这个是 SQL 文本长度(服务端解析侧)。文件读进来了,SQL 还是超。
// 分片只切 ONLY_PATIENT 这一维:其余条件(cursor / clinics / union 分支)每片原样带上,
// DISTINCT 在合并后用 Map 去重 —— 与单条 SQL 的结果集完全等价。
// 注:PAC_COHORT_LIMIT 采样与分片叠加时按"每片各取 LIMIT"处理会超量,故分片时不走
// orderTail 的 LIMIT(定向重摄本就是显式点名,不该再被采样截断)。
// 分片切的是 onlyKeys(带不带命名空间维都按同一形态重建 IN 子句)。
const ID_CHUNK = 10_000; // 10k × ~9B ≈ 90KB,离 256KiB 有足够余量(带命名空间也顶不到)
const idChunks: OnlyPatientKey[][] =
onlyKeys.length > ID_CHUNK
? Array.from({ length: Math.ceil(onlyKeys.length / ID_CHUNK) }, (_, i) =>
onlyKeys.slice(i * ID_CHUNK, (i + 1) * ID_CHUNK),
)
: [];
/// 本片的 IN 子句 —— 与上面 whereParts 的形态保持一致(复合键 / 单键)
const chunkInClause = (chunk: OnlyPatientKey[]): string => {
const q = (v: string) => `'
$
{
v
.
replace
(
/'/g
,
"''"
)}
'`;
return tenant_key_column && chunk.every((k) => k.tenant)
? `(${patient_key_column}, ${tenant_key_column}) IN (${chunk.map((k) => `(${q(k.key)}, ${q(k.tenant!)})`).join('
,
')})`
: `${patient_key_column} IN (${chunk.map((k) => q(k.key)).join('
,
')})`;
};
const started = Date.now();
let rows: Array<Record<string, unknown>>;
if (idChunks.length > 0) {
this.logger.log(
`[clickhouse·cohort] 定向名单 ${ids.length} 个 > ${ID_CHUNK} → 分 ${idChunks.length} 片查询(CH max_query_size 256KiB)`,
);
const merged = new Map<string, Record<string, unknown>>();
for (const [i, chunk] of idChunks.entries()) {
// 用本片的 IN 替换掉整名单那一条(其余 whereParts 保持不变)。
// 三种起手形态都要认:单键 IN / 单键 = / 复合键 IN。
const chunkIn = chunkInClause(chunk);
const chunkWhere = whereParts.map((w) =>
w.startsWith(`${patient_key_column} IN (`) ||
w.startsWith(`${patient_key_column} = `) ||
w.startsWith(`(${patient_key_column}, ${tenant_key_column}) IN (`)
? chunkIn
: w,
);
const chunkSql = `SELECT DISTINCT ${selectCols} FROM ${listFrom} WHERE ${chunkWhere.join('
AND
')}`;
const part = (await this.queryJsonWithRetry(
client,
chunkSql,
`list-patient-keys[${i + 1}/${idChunks.length}]`,
)) as Array<Record<string, unknown>>;
for (const r of part) {
merged.set(
`${String(r[patient_key_column] ?? '')}|||${tenant_key_column ? String(r[tenant_key_column] ?? '') : ''}`,
r,
);
}
}
rows = [...merged.values()];
} else {
const sql = `SELECT DISTINCT ${selectCols} FROM ${listFrom}${whereSql}${orderTail}`;
this.logger.log(
`[clickhouse·cohort] list patient keys${unionMode ? `(union of ${incBranches.length} cursored tables)` : ''} — ${sql.slice(0, 300)}`,
);
const started = Date.now()
;
const rows = (await this.queryJsonWithRetry(client, sql, '
list
-
patient
-
keys
')) as Array<Record<string, unknown>>;
rows = (await this.queryJsonWithRetry(client, sql, '
list
-
patient
-
keys
')) as Array<Record<string, unknown>>
;
}
const keys: CohortKey[] = rows.map((r) => ({
key: String(r[patient_key_column] ?? ''),
tenant: tenant_key_column ? String(r[tenant_key_column] ?? '') : undefined,
...
...
@@ -599,7 +730,17 @@ export class ClickHouseSourceService {
await Promise.all(
Object.entries(source.queries).map(async ([tableName, sql]) => {
const incCfg = incremental?.perQuery[tableName];
const sqlWithCursor = incCfg
// ⭐ 患者主档表**不注入 cursor** —— cohort 已经限定了"要处理哪些患者",主档只负责
// 把这些患者的属性拉全。再叠一层 `last_visit_time > cursor` 会把**被别的表带进
// cohort 的患者**(病例变了/EMR 被编辑,但人没来诊)的主档挡掉:
// cohort 列出了他,主档却拉不到 → 该患者主档字段整轮不更新。
// 2026-08-01 测试服实测:cohort batch 726 人,主档只拉回 675 人,差的就是这批;
// 宿主跟进闸(派生列 has_active_complex_case)因此始终停在旧值。
// 非 cohort 模式有 reversePullPatientMaster 兜这个,cohort 模式此前没有兜底 ——
// 与其再补一次反向拉,不如从源头不加这个多余条件(cohort 本就是更强的限定)。
const isPatientMaster = tableName === this.tableKeyOf(cohort.patient_list_from);
const sqlWithCursor =
incCfg && !isPatientMaster
? this.injectIncrementalCursor(sql, incCfg.cursorColumn, incCfg.cursorValue)
: sql;
const sqlWithCohort = this.injectCohortFilter(sqlWithCursor, cohortClause);
...
...
apps/pac-service/src/modules/sync/cold-import/manifest.schema.ts
View file @
108558dd
...
...
@@ -126,6 +126,11 @@ export const ClickHouseSourceSchema = z.object({
/// 患者不来诊则主档拉不到他 —— 但他的**事实**可能变了(EMR 被编辑、复杂病例被关闭)。
/// 增量跑完后从这些表里收集患者键,反向补拉一次主档,保证"事实变了主档也在场"。
///
/// ⚠️ **只作用于 single-shot(非 cohort)模式** —— reversePullPatientMaster 仅在
/// loadAllTables 里调用。cohort 模式下同一诉求由 listPatientPairs 的 UNION 分支满足
/// (每张"配了 cursor 且有水位"的表各出一条分支来列患者),不经过反向拉;
/// 且主档在 cohort 模式下不再注入 cursor,被带进来的患者主档一定拉得到。
///
/// 【为什么要可配】此前是四张 jvs-dw 表名硬编码在 service 里,新增一张要改通用代码;
/// 而"哪些表的变化该带出主档"本就是**宿主口径**(取决于该宿主哪些表有独立 cursor)。
/// 不配 → 保持历史默认(见 DEFAULT_REVERSE_PULL_FROM),行为不变。
...
...
apps/pac-service/tests/cohort-only-patient.spec.ts
View file @
108558dd
import
*
as
fs
from
'node:fs'
;
import
*
as
os
from
'node:os'
;
import
*
as
path
from
'node:path'
;
import
{
resolveOnlyPatientIds
}
from
'../src/modules/sync/cold-import/clickhouse-source.service'
;
import
{
resolveOnlyPatientIds
,
resolveOnlyPatientKeys
,
}
from
'../src/modules/sync/cold-import/clickhouse-source.service'
;
/**
* PAC_COHORT_ONLY_PATIENT(定向重摄名单)解析。
...
...
@@ -64,4 +67,42 @@ describe('PAC_COHORT_ONLY_PATIENT 解析', () => {
process
.
env
.
PAC_COHORT_ONLY_PATIENT
=
'@/no/such/pids.txt'
;
expect
(()
=>
resolveOnlyPatientIds
()).
toThrow
(
/不存在/
);
});
/**
* 命名空间维(manifest cohort.tenant_key_column 声明的那一维)。
*
* 患者主键只在命名空间内唯一 —— jvs-dw 的 patient_id 261067 在两个品牌下是两个人。
* 2026-08-01 测试服实测:7 万个纯 id 的定向名单列出 **140,566** 个 cohort key(翻倍),
* 一半是另一命名空间下的同号患者,白摄一倍数据。
* ⚠️ 维名不写死("brand" 只是 jvs-dw 填进 tenant_key_column 的值),单命名空间宿主写纯 id。
*/
describe
(
'命名空间维'
,
()
=>
{
test
(
'纯 id → 无 tenant(单命名空间宿主,行为与改动前一致)'
,
()
=>
{
process
.
env
.
PAC_COHORT_ONLY_PATIENT
=
'1855960,1855959'
;
expect
(
resolveOnlyPatientKeys
()).
toEqual
([{
key
:
'1855960'
},
{
key
:
'1855959'
}]);
});
test
(
'`id|命名空间` → 复合键'
,
()
=>
{
process
.
env
.
PAC_COHORT_ONLY_PATIENT
=
'261067|瑞尔,261067|瑞泰'
;
expect
(
resolveOnlyPatientKeys
()).
toEqual
([
{
key
:
'261067'
,
tenant
:
'瑞尔'
},
{
key
:
'261067'
,
tenant
:
'瑞泰'
},
]);
});
test
(
'TAB 分隔 → 同义(SQL dump TSV 可直接喂)'
,
()
=>
{
tmpFile
=
path
.
join
(
os
.
tmpdir
(),
`pids-tsv-
${
Date
.
now
()}
.txt`
);
fs
.
writeFileSync
(
tmpFile
,
'261067
\
t瑞尔
\
n261068
\
t瑞泰
\
n'
);
process
.
env
.
PAC_COHORT_ONLY_PATIENT
=
`@
${
tmpFile
}
`
;
expect
(
resolveOnlyPatientKeys
()).
toEqual
([
{
key
:
'261067'
,
tenant
:
'瑞尔'
},
{
key
:
'261068'
,
tenant
:
'瑞泰'
},
]);
});
test
(
'resolveOnlyPatientIds 仍只返回 key —— 旧调用点(是否定向模式的判定)不受影响'
,
()
=>
{
process
.
env
.
PAC_COHORT_ONLY_PATIENT
=
'261067|瑞尔,261068|瑞泰'
;
expect
(
resolveOnlyPatientIds
()).
toEqual
([
'261067'
,
'261068'
]);
});
});
});
apps/pac-service/tests/complex-case-gate-sql.spec.ts
View file @
108558dd
...
...
@@ -127,6 +127,66 @@ describe('injectIncrementalCursor — 带派生列的主档 SQL', () => {
});
});
describe
(
'增量列患者的 UNION 分支 — 别名列 + 顶层表名'
,
()
=>
{
/// 复刻 listPatientPairs 里 incBranches 的构造(见 clickhouse-source.service.ts)
const
branchOf
=
(
q
:
string
,
cursorCol
:
string
,
cursorVal
:
string
)
=>
`SELECT patient_id, brand FROM (
${
priv
.
injectIncrementalCursor
(
q
,
cursorCol
,
cursorVal
)}
)`
;
test
(
'⭐ 主档分支查的是主档表,不是 SELECT 列里子查询的那张'
,
()
=>
{
const
b
=
branchOf
(
MASTER_SQL
,
'last_visit_time'
,
'2026-07-30 08:15:00'
);
// 2026-08-01 测试服 dry-run 真实翻车过:分支被拼成
// SELECT patient_id, brand FROM dw_group.fact_complex_cases_out WHERE last_visit_time > …
// → 表名取自子查询、cursor 列取自主档,CH 报 Missing columns: 'last_visit_time' 'patient_id'
expect
(
b
).
not
.
toMatch
(
/FROM
\s
+dw_group
\.
fact_complex_cases_out
\s
+WHERE
\s
+last_visit_time/
);
expect
(
b
).
toContain
(
'FROM dw_group.fact_client_out'
);
});
test
(
'⭐ 复杂病例分支能读到别名列 —— 物理表只有 customer_id,没有 patient_id'
,
()
=>
{
const
q
=
`
SELECT customer_id AS patient_id, brand,
coalesce(updated_gmt_at, toDateTime(created_gmt_at)) AS changed_at
FROM dw_group.fact_complex_cases_out
WHERE (customer_id, brand) IN (
SELECT patient_id, brand FROM dw_group.fact_client_out WHERE last_visit_time IS NOT NULL
)`
;
const
b
=
branchOf
(
q
,
'changed_at'
,
'2026-07-30 08:15:00'
);
// 外层 SELECT patient_id 必须落在**子查询之上**(别名在那里才存在)
expect
(
b
).
toMatch
(
/^SELECT patient_id, brand FROM
\(
/
);
expect
(
b
).
toContain
(
'customer_id AS patient_id'
);
});
});
describe
(
'定向名单分片 — ClickHouse max_query_size 256KiB'
,
()
=>
{
/// CH 服务端默认上限;超过即 `Syntax error: failed at position 262142`
const
MAX_QUERY_SIZE
=
256
*
1024
;
const
ID_CHUNK
=
10
_000
;
const
mkIds
=
(
n
:
number
)
=>
Array
.
from
({
length
:
n
},
(
_
,
i
)
=>
String
(
1
_000_000
+
i
));
test
(
'⭐ 7 万 id 单条 SQL 会超 256KiB —— 这正是 2026-08-01 定向补数 fatal 的原因'
,
()
=>
{
const
oneShot
=
`
${
mkIds
(
70
_283
).
map
((
id
)
=>
`'
${
id
}
'`
).
join
(
', '
)}
`
;
expect
(
oneShot
.
length
).
toBeGreaterThan
(
MAX_QUERY_SIZE
);
});
test
(
'⭐ 按 10k 分片后每片都远低于上限'
,
()
=>
{
const
ids
=
mkIds
(
70
_283
);
const
chunks
=
Array
.
from
({
length
:
Math
.
ceil
(
ids
.
length
/
ID_CHUNK
)
},
(
_
,
i
)
=>
ids
.
slice
(
i
*
ID_CHUNK
,
(
i
+
1
)
*
ID_CHUNK
),
);
expect
(
chunks
).
toHaveLength
(
8
);
for
(
const
c
of
chunks
)
{
const
clause
=
c
.
map
((
id
)
=>
`'
${
id
}
'`
).
join
(
', '
);
expect
(
clause
.
length
).
toBeLessThan
(
MAX_QUERY_SIZE
/
2
);
// 留一半余量给 SQL 其余部分
}
// 分片是**无损**切分:并集 = 原名单,不重不漏
expect
(
chunks
.
flat
()).
toEqual
(
ids
);
});
test
(
'名单不超过阈值时不分片(保持单条 SQL,行为不变)'
,
()
=>
{
const
ids
=
mkIds
(
500
);
expect
(
ids
.
length
>
ID_CHUNK
).
toBe
(
false
);
});
});
describe
(
'manifest 契约'
,
()
=>
{
const
raw
=
readFileSync
(
join
(
__dirname
,
'../data/jvs-dw/manifest.yaml'
),
'utf-8'
);
const
manifest
=
ColdImportManifestSchema
.
parse
(
yaml
.
load
(
raw
));
...
...
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