Commit e83d0ac7 by luoqi

fix(monitor): DW 滞后告警只管 pull 宿主 —— push 宿主不再被误报

测试服务器每小时告警一次「friday DW 数据滞后 37 小时」,是误报:
friday 是 **push 宿主**,数据由宿主主动推,sync_logs 的 cursor_before/after
按设计恒为 null,根本没有游标可推进。它之所以有游标,是历史上误跑过一次增量、
留下一条 fetched=0 的 incremental_bundle 记录,游标就永久停在 2026-07-30T10:00Z。

而同期 friday 正常 push 了 614 批 10.79 万行(含新接的跟进闸字段),数据流毫无问题
—— 拿"游标多久没推进"衡量 push 宿主,是**指标本身用错了**。

危害不只是烦人:该告警**永不自愈**(游标不可能再推进),每小时响一次,
最终把真告警淹掉 —— 一个永远在响的黄灯,看的人很快就不看了。

改法:遍历宿主时按 manifest 是否声明 sql_source 短路。
  - 判据选 sql_source 而非 auto_sync:前者与 files **二选一**(manifest.schema 原话),
    是"数据从哪来"的定义即摄入模式本身;auto_sync 只是"要不要自动跑",可临时关,
    跟模式是两回事。
  - 短路放在「还没跑过增量(无 cursor)」那条 warn **之前** —— 否则 push 宿主
    只是换个姿势继续刷日志。
  - getHostOpsConfig 读不到 manifest 时 hasSqlSource 兜底 false:宁可不报,
    也不要对着未知宿主刷告警。

回归闸 tests/dw-lag-monitor-scope.spec.ts(5 项),已验证去掉守卫会报 2 项失败。

注:push 宿主的健康度应看「距上次成功 push 的时长」,是当前的监控盲区
(FRIDAY 断推三天也不会有任何告警),本次按要求不做,单独评估。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 6693bc11
Pipeline #3505 failed in 0 seconds
......@@ -566,6 +566,9 @@ export class ColdImportService {
dwLagWarnHours: number | null;
dwLagErrorHours: number | null;
pushLagErrorHours: number | null;
/// 是否 pull/DW 宿主 —— manifest 声明了 sql_source(与 files 二选一)才由 PAC 按游标去数仓拉。
/// push / 文件宿主没有游标可推进,DW 滞后监控对它们不适用(见 dw-lag-monitor)。
hasSqlSource: boolean;
} {
const empty = {
incrementalCron: null,
......@@ -573,6 +576,7 @@ export class ColdImportService {
dwLagWarnHours: null,
dwLagErrorHours: null,
pushLagErrorHours: null,
hasSqlSource: false,
};
try {
const m = this.readManifest(this.resolveHostDir(hostName));
......@@ -582,6 +586,7 @@ export class ColdImportService {
dwLagWarnHours: m.monitoring?.dw_lag_warn_hours ?? null,
dwLagErrorHours: m.monitoring?.dw_lag_error_hours ?? null,
pushLagErrorHours: m.monitoring?.push_lag_error_hours ?? null,
hasSqlSource: !!m.sql_source,
};
} catch {
return empty;
......
......@@ -45,6 +45,17 @@ export class DwLagMonitorService {
const hosts = await this.prisma.host.findMany({ select: { id: true, name: true } });
for (const host of hosts) {
const ops = this.coldImport.getHostOpsConfig(host.name);
// ⚠️ 只对 **pull/DW 宿主**(manifest 声明 sql_source)算游标滞后。
// push / 文件宿主的数据由宿主主动推,sync_logs 的 cursor_before/after 按设计恒为 null,
// 拿"游标多久没推进"衡量它们是**指标本身用错了**:数据流正常也照报,且**永不自愈**
// —— 每小时响一次,直到把真告警淹掉。
// 2026-07-31 实测:friday(push 宿主)因一条 fetched=0 的误跑增量留下游标,
// 连报 37 小时"DW 数据滞后";而同期它 push 了 614 批 10.79 万行,数据好得很。
// push 宿主的健康度应看"距上次成功 push 的时长",不在本监控职责内。
if (!ops.hasSqlSource) {
this.logger.debug(`dw-lag: host=${host.name} 非 pull/DW 宿主(无 sql_source)— 跳过游标滞后检查`);
continue;
}
const warnH = ops.dwLagWarnHours ?? gWarnH;
const errorH = ops.dwLagErrorHours ?? gErrorH;
const last = await this.prisma.syncLog.findFirst({
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as yaml from 'js-yaml';
/**
* DW 滞后监控只管 **pull/DW 宿主**,push / 文件宿主必须跳过。
*
* 踩过的坑(2026-07-31,测试服务器):
* dw-lag-monitor 原本 `host.findMany()` 遍历**所有** host 算"增量游标多久没推进"。
* friday 是 push 宿主(数据由宿主主动推,sync_logs 的 cursor 按设计恒为 null),
* 却因为历史上误跑过一次增量、留下一条 fetched=0 的 incremental_bundle 记录,
* 游标就永久停在那一刻 → 每小时告警一次「DW 数据滞后 37 小时」,**永不自愈**。
* 而同期 friday 正常 push 了 614 批 10.79 万行,数据流毫无问题。
*
* 这类噪音的真正危害不是烦人,是**把真告警淹掉** —— 一个永远在响的黄灯,
* 看的人很快就不看了。
*
* 判据用 `sql_source` 有无,而不是 `auto_sync`:
* sql_source 与 files **二选一**(manifest.schema 原话),是"数据从哪来"的定义,
* 即摄入模式本身;auto_sync 只是"要不要自动跑",可以临时关掉,跟模式是两回事。
*/
describe('DW 滞后监控的适用范围', () => {
const dataRoot = join(__dirname, '../data');
const manifestOf = (host: string) =>
yaml.load(readFileSync(join(dataRoot, host, 'manifest.yaml'), 'utf8')) as Record<string, unknown>;
describe('宿主摄入模式判据(sql_source 有无)', () => {
test('jvs-dw 是 pull/DW 宿主 —— 有 sql_source,该被监控', () => {
expect(!!manifestOf('jvs-dw').sql_source).toBe(true);
});
test('⭐ friday 是 push 宿主 —— 无 sql_source,必须跳过', () => {
expect(!!manifestOf('friday').sql_source).toBe(false);
});
});
describe('监控源码闸', () => {
const src = readFileSync(
join(__dirname, '../src/queues/dw-lag-monitor.service.ts'),
'utf8',
);
const code = src
.split('\n')
.filter((l) => !/^\s*(\/\/|\/\*|\*)/.test(l))
.join('\n');
test('⭐ 遍历宿主时按 hasSqlSource 短路', () => {
expect(code).toMatch(/if\s*\(\s*!\s*ops\.hasSqlSource\s*\)/);
expect(code).toContain('continue;');
});
test('⭐ 短路发生在「无 cursor」告警之前 —— 否则 push 宿主换个姿势继续刷日志', () => {
const guardAt = code.indexOf('ops.hasSqlSource');
const noCursorWarnAt = code.indexOf('还没跑过增量');
expect(guardAt).toBeGreaterThan(-1);
expect(noCursorWarnAt).toBeGreaterThan(-1);
expect(`guard 在前: ${guardAt < noCursorWarnAt}`).toBe('guard 在前: true');
});
});
describe('getHostOpsConfig 暴露 hasSqlSource', () => {
const src = readFileSync(
join(__dirname, '../src/modules/sync/cold-import/cold-import.service.ts'),
'utf8',
);
test('从 manifest.sql_source 派生,读不到 manifest 时保守取 false(不误报)', () => {
expect(src).toContain('hasSqlSource: !!m.sql_source');
// empty 兜底必须是 false —— 读不到 manifest 时宁可不报,也不要对着未知宿主刷告警
expect(src).toMatch(/hasSqlSource:\s*false/);
});
});
});
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