/* * 동기화 건강 — **「이 트윈이 현장과 맞춰지고 있나」에 정직하게 답한다.** * * 지키는 것: ① 판정 일곱이 서로 섞이지 않고, **끊긴 피드가 옛 창으로 초록을 내지 않으며, 버려지고 있는 * 것을 「해당 없음」으로 덮지 않는다** ② 닫힌 창만 「최근」이다 ③ 0 과 「없음」이 다르다 ④ 상한에서 * 밀려난 것을 함께 낸다 ⑤ 사유 종류가 무한히 자라지 않는다 ⑥ 같은 사유를 두 번 직렬화하지 않는다 * ⑦ 영속 훅이 장부의 진행을 막지 않는다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { HEALTHY_ACCEPTED_RATIO, INGEST_WINDOW_MS, OTHER_REASON, REASON_LIMIT, STALE_AFTER_MS, TREND_LIMIT, ingestHealth, newIngestLedger, recordIngest, rollIngestWindow, syncVerdictOf } from '../server/engine/ingest-health.ts' /* ── ① 판정 ─────────────────────────────────────────────────────────────── */ test('시뮬 트윈에는 「동기화 안 됨」이라 말하지 않는다 — 거짓 경보는 참 경보를 죽인다', () => { assert.equal(syncVerdictOf({ feedState: 'not-applicable', recent: null }), 'not-applicable') assert.equal(syncVerdictOf({ feedState: 'not-applicable', recent: { offered: 0, rejected: 0 } }), 'not-applicable') }) test('피드가 없는 것과 붙었는데 안 오는 것을 가른다', () => { assert.equal(syncVerdictOf({ feedState: 'detached', recent: null }), 'not-connected') assert.equal(syncVerdictOf({ feedState: 'attached', recent: { offered: 0, rejected: 0 } }), 'no-traffic') }) test('재지 않은 것을 정상·비정상으로 채우지 않는다', () => { assert.equal(syncVerdictOf({ feedState: 'attached', recent: null }), 'unknown') }) test('오래 끊긴 피드는 건강했던 옛 창으로 초록을 내지 않는다 — 이 기능이 있는 이유', () => { /* * 창은 다음 유입이 있을 때 닫힌다. 유입이 멈추면 「최근 창」은 멈추기 직전의 건강했던 창으로 남는다. * 그 창만 보면 어제 죽은 피드가 오늘도 「동기화 중」이다. */ const healthy = { offered: 1000, rejected: 0 } assert.equal( syncVerdictOf({ feedState: 'attached', recent: healthy, lastAtMs: 0, nowMs: STALE_AFTER_MS + 1 }), 'no-traffic' ) assert.equal( syncVerdictOf({ feedState: 'attached', recent: healthy, lastAtMs: 0, nowMs: STALE_AFTER_MS }), 'syncing', '경계까지는 아직 들어온 것이 없다고 하지 않는다' ) }) test('침묵이 거부보다 앞선다 — 끊긴 피드에 「매핑이 막고 있음」이라 말하지 않는다', () => { /* 매핑을 고치라는 말은 유입이 있을 때만 맞는 처방이다. 오지 않는 것은 매핑 문제가 아니다. */ assert.equal( syncVerdictOf({ feedState: 'attached', recent: { offered: 100, rejected: 90 }, lastAtMs: 0, nowMs: STALE_AFTER_MS + 1 }), 'no-traffic' ) }) test('붙었지만 한 번도 받지 않은 것을 「유입 없음」이라 단정하지 않는다', () => { /* `-1` 은 「없음」이다. 0(1970년)으로 읽으면 방금 띄운 트윈이 곧바로 「끊겼다」가 된다. */ assert.equal( syncVerdictOf({ feedState: 'attached', recent: null, lastAtMs: -1, nowMs: STALE_AFTER_MS * 100 }), 'unknown' ) }) test('시뮬 트윈은 오래 들어온 것이 없어도 「유입 없음」이 아니다', () => { assert.equal( syncVerdictOf({ feedState: 'not-applicable', recent: null, lastAtMs: -1, nowMs: STALE_AFTER_MS * 100 }), 'not-applicable' ) }) test('들어오는데 트윈이 안 돌면 「버려지는 중」이다 — 「해당 없음」으로 안심시키지 않는다', () => { /* * 실제로 겪었다: 트윈이 멈춘 뒤에도 피드는 남아 레코드를 나르고, 커널은 그것을 알리지 않고 버렸다. * 그런데 화면은 「해당 없음 · 150 통과 · 100%」였다 — 사실이 사라지는 동안 화면이 안심시킨 것이다. * * 멈춘 트윈의 피드 사실은 `not-applicable` 이므로, 미전달을 **그보다 먼저** 봐야 이 상태가 드러난다. */ assert.equal( syncVerdictOf({ feedState: 'not-applicable', recent: null, lastUndeliveredAtMs: 0, nowMs: 1000 }), 'twin-not-running' ) assert.equal( syncVerdictOf({ feedState: 'attached', recent: { offered: 100, rejected: 0 }, lastUndeliveredAtMs: 0, nowMs: 1000 }), 'twin-not-running', '통과율이 좋아 보여도 버려지고 있으면 그것을 말한다' ) }) test('버려진 지 오래됐으면 더는 「버려지는 중」이라 하지 않는다 — 지난 일을 현재로 말하지 않는다', () => { assert.equal( syncVerdictOf({ feedState: 'not-applicable', recent: null, lastUndeliveredAtMs: 0, nowMs: STALE_AFTER_MS + 1 }), 'not-applicable' ) }) test('버려진 적이 없으면 미전달 판정이 서지 않는다 — `-1` 을 시각 0 으로 읽지 않는다', () => { assert.equal( syncVerdictOf({ feedState: 'not-applicable', recent: null, lastUndeliveredAtMs: -1, nowMs: 1000 }), 'not-applicable' ) }) test('버려진 것을 「통과」로 세지 않는다 — 통과율이 그만큼 내려간다', () => { const l = newIngestLedger() /* 100건 제시 · 매핑 거부 0 · 그런데 40건이 트윈에 닿지 못했다 */ recordIngest(l, 100, [], 0, undefined, 40) recordIngest(l, 1, [], INGEST_WINDOW_MS + 1) // 창을 닫는다 const v = ingestHealth(l, 'attached', undefined, INGEST_WINDOW_MS + 1) assert.equal(v.recent!.offered, 100) assert.equal(v.recent!.rejected, 0, '매핑은 아무것도 거부하지 않았다') assert.equal(v.recent!.undelivered, 40) assert.equal(v.recent!.accepted, 60, '트윈에 닿은 것만 통과다') assert.equal(v.recent!.acceptedRatio, 0.6, '100% 가 아니다') assert.equal(v.total.undelivered, 40) }) test('거부가 문턱을 넘으면 매핑을 지목한다 — 문턱 경계를 정확히 지킨다', () => { /* 문턱은 「이상이면 정상」이다. 통과율이 정확히 문턱이면 정상으로 본다. */ assert.equal(syncVerdictOf({ feedState: 'attached', recent: { offered: 100, rejected: 5 } }), 'syncing') assert.equal(syncVerdictOf({ feedState: 'attached', recent: { offered: 100, rejected: 6 } }), 'mapping-blocked') assert.equal(HEALTHY_ACCEPTED_RATIO, 0.95) }) /* ── ② 창 ───────────────────────────────────────────────────────────────── */ test('채우는 중인 창을 「최근」이라 부르지 않는다', () => { const l = newIngestLedger() recordIngest(l, 100, [], 0) recordIngest(l, 100, [], 1000) /* 읽는 시각을 넘긴다 — 넘기지 않으면 벽시계와 견주어 「오래 들어온 것이 없음」으로 읽힌다(1970년 장부이므로). */ const v = ingestHealth(l, 'attached', undefined, 1000) assert.equal(v.recent, null, '창이 아직 닫히지 않았다') assert.equal(v.verdict, 'unknown', '그래서 판정도 모름이다') assert.equal(v.total.offered, 200, '누적은 이미 센다') }) test('창이 닫히면 최근이 되고, 통과와 거부를 함께 낸다', () => { const l = newIngestLedger() recordIngest(l, 200, [{ errors: ['epc is required'] }, { errors: ['epc is required'] }], 0) recordIngest(l, 5, [], INGEST_WINDOW_MS + 1) // 창을 닫는다 const v = ingestHealth(l, 'attached', undefined, INGEST_WINDOW_MS + 1) assert.equal(v.recent!.offered, 200) assert.equal(v.recent!.rejected, 2) assert.equal(v.recent!.accepted, 198) assert.equal(v.recent!.acceptedRatio, 0.99) assert.equal(v.verdict, 'syncing') }) test('추이 상한을 넘으면 오래된 창이 밀려나고, 밀려난 수를 낸다', () => { const l = newIngestLedger() for (let i = 0; i <= TREND_LIMIT + 3; i++) recordIngest(l, 1, [], i * (INGEST_WINDOW_MS + 1)) const v = ingestHealth(l, 'attached') assert.equal(v.trend.length, TREND_LIMIT) assert.ok(v.trendDropped > 0, `밀려난 창을 알리지 않고 버리지 않는다 — 실제 ${v.trendDropped}`) }) /* ── ③ 0 과 없음 ────────────────────────────────────────────────────────── */ test('한 번도 받지 않은 트윈은 수치를 0 으로 채우지 않는다', () => { const v = ingestHealth(newIngestLedger(), 'attached') assert.equal(v.lastAt, null, '받은 적이 없다') assert.equal(v.total.acceptedRatio, null, '비율이 없다 — 1 이 아니다') assert.equal(v.recent, null) assert.equal(v.verdict, 'unknown') }) test('장부가 없어도 피드 사실만으로 답할 수 있는 판정은 낸다', () => { const v = ingestHealth(undefined, 'not-applicable') assert.equal(v.verdict, 'not-applicable') assert.equal(v.total.acceptedRatio, null, '없는 값을 지어내지 않는다') }) test('제시는 있고 거부가 0 이면 비율은 1 이다 — 이건 「없음」이 아니다', () => { const l = newIngestLedger() recordIngest(l, 50, [], 0) assert.equal(ingestHealth(l, 'attached').total.acceptedRatio, 1) }) test('1,000건 중 1건 거부가 「전부 통과」로 읽히지 않는다', () => { const l = newIngestLedger() recordIngest(l, 1000, [{ errors: ['bad'] }], 0) assert.equal(ingestHealth(l, 'attached').total.acceptedRatio, 0.999) }) /* ── ④⑤⑥ 상한과 비용 ───────────────────────────────────────────────────── */ test('사유 종류가 무한히 자라지 않는다 — 문구에 변수가 섞여도', () => { const l = newIngestLedger() for (let i = 0; i < REASON_LIMIT * 5; i++) recordIngest(l, 1, [{ errors: [`unknown bizStep "S${i}"`] }], 1) const v = ingestHealth(l, 'attached') assert.ok(v.reasons.length <= REASON_LIMIT + 1, `사유 칸이 ${v.reasons.length}개 — 상한 + 기타 한 칸`) assert.equal(v.reasonsCapped, true, '접혔다는 사실을 말한다') assert.ok( v.reasons.some(r => r.reason === OTHER_REASON), '모은 것들이 갈 칸이 있다' ) assert.equal(v.total.rejected, REASON_LIMIT * 5, '건수는 하나도 잃지 않는다') }) test('같은 사유는 한 번만 표본을 뜬다 — 나머지는 직렬화하지 않는다', () => { const l = newIngestLedger() let serialized = 0 const record = () => { serialized++ return { epc: 'x' } } for (let i = 0; i < 500; i++) recordIngest(l, 1, [{ errors: ['epc is required'], record: record() }], 1) const v = ingestHealth(l, 'attached') assert.equal(v.samples.length, 1, '사유 하나 → 표본 하나') assert.equal(v.sampleDropped, 499, '나머지는 세어서 밝힌다') assert.equal(v.total.rejected, 500, '건수는 그대로') assert.equal(serialized, 500, '(레코드를 만든 횟수 — 장부가 직렬화한 횟수는 아래에서 본다)') }) test('사유가 여럿이면 사유마다 표본이 하나씩 생긴다', () => { const l = newIngestLedger() recordIngest(l, 3, [{ errors: ['a'] }, { errors: ['b'] }, { errors: ['a'] }], 1) const v = ingestHealth(l, 'attached') assert.equal(v.samples.length, 2, 'a·b 하나씩') assert.equal(v.sampleDropped, 1, '두 번째 a 는 안 뜬다') }) test('긴 레코드는 자르고, 잘랐다고 표시한다', () => { const l = newIngestLedger() recordIngest(l, 1, [{ errors: ['big'], record: { blob: 'x'.repeat(5000) } }], 1) const s = ingestHealth(l, 'attached').samples[0] assert.equal(s.recordTruncated, true) assert.ok(s.record.length <= 2000) }) test('직렬화할 수 없는 레코드가 장부를 멈추지 않는다', () => { const l = newIngestLedger() const cyclic: any = {} cyclic.self = cyclic recordIngest(l, 1, [{ errors: ['cyclic'], record: cyclic }], 1) assert.equal(ingestHealth(l, 'attached').total.rejected, 1, '건수는 센다') }) /* ── ⑦ 영속 훅 ──────────────────────────────────────────────────────────── */ test('창이 닫힐 때만 영속 훅이 불린다 — 건마다가 아니다', () => { const l = newIngestLedger() const closed: { offered: number; rejected: number }[] = [] for (let i = 0; i < 50; i++) recordIngest(l, 10, [], i * 1000, w => closed.push(w)) assert.equal(closed.length, 0, '10분이 안 지났으면 한 번도 안 부른다') recordIngest(l, 10, [], INGEST_WINDOW_MS + 1, w => closed.push(w)) assert.equal(closed.length, 1, '창이 닫힐 때 한 번') assert.equal(closed[0].offered, 500, '닫힌 창의 값을 그대로 넘긴다') }) test('영속이 실패해도 장부는 계속 굴러간다', () => { const l = newIngestLedger() recordIngest(l, 10, [], 0) assert.doesNotThrow(() => recordIngest(l, 10, [], INGEST_WINDOW_MS + 1, () => { throw new Error('DB down') }) ) assert.equal(l.trend.length, 1, '창은 닫혔다') assert.equal(ingestHealth(l, 'attached').total.offered, 20, '집계도 이어진다') }) /* ── 입력 방어 ──────────────────────────────────────────────────────────── */ test('망가진 제시 수는 기록하지 않는다 — 통과율이 거짓이 되지 않게', () => { const l = newIngestLedger() recordIngest(l, -5, [], 1) recordIngest(l, NaN, [], 1) assert.equal(ingestHealth(l, 'attached').lastAt, null, '아무것도 안 쌓였다') }) test('사유가 비어 있으면 지어내지 않고 unknown 으로 둔다', () => { const l = newIngestLedger() recordIngest(l, 1, [{ errors: [] }], 1) assert.equal(ingestHealth(l, 'attached').reasons[0].reason, 'unknown') })