/* * ★ **조치가 반대인 두 사실을 한 이름으로 부르면 그 이름은 정보가 아니라 오해다.** * * ── 무엇이 반대 방향을 가리켰나 (2026-08-24) ──────────────────────────────── * 2026-08-23 에 「원본에 닿지 못한다」를 트윈이 말할 수 있게 `onReadFailure` 를 만들었다. 그런데 그 문으로 * **다른 사실**이 함께 나가고 있었다. * * 원본에 닿지 못했다 접속 실패·시간 초과 → **기다리면 풀린다** * 읽었는데 커서가 못 넘어간다 한 시각에 한 페이지보다 많은 행 → **기다려도 안 풀린다** * * 둘째는 **읽기가 성공한 실패**다. 원본은 답했고 그 답의 모양이 커서를 이겼다. 그런데 화면이 * 「원본에 닿지 못한다」고 말하면 사람은 원본을 의심하고 기다린다 — 실제로 필요한 조치는 페이지를 * 키우거나 같은 시각 안에서 순서를 정하는 것이고, 기다림으로는 영원히 풀리지 않는다. * * 게다가 그 문구가 스트림 이름을 꺼내는 규칙에 맞지 않아 **어느 표가 막혔는지도 남지 않았다.** * * ── 이 시험이 지키는 것 ───────────────────────────────────────────────────── * ① 두 축이 **서로를 지우지 않는다** ② 두 축이 **각자 뷰로 나간다**(유입이 없는 갈래에서도) * ③ 「몇 번·언제부터」를 정체 쪽도 든다 ④ 넘어가면 지운다 */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { clearCursorStall, clearReadFailure, ingestHealth, newIngestLedger, recordCursorStall, recordIngest, recordReadFailure } from '../server/engine/ingest-health.ts' const T0 = Date.parse('2026-08-24T06:00:00.000Z') test('정체는 읽기 실패와 **다른 칸**이다 — 서로를 지우지 않는다', () => { /* * ★ 한 칸을 쓰면 나중에 온 것이 앞의 것을 덮는다. 그러면 「닿지 못한다」와 「못 넘어간다」 중 * 마지막 것만 남고, 화면은 조치를 반대로 안내할 수 있다. */ const l = newIngestLedger() recordReadFailure(l, 'connect ETIMEDOUT', T0, 'inventory') recordCursorStall(l, 'one timestamp holds more rows than a page', T0 + 1000, 'trace') assert.equal(l.readFailure?.reason, 'connect ETIMEDOUT', '읽기 실패가 남아 있다') assert.equal(l.cursorStall?.reason, 'one timestamp holds more rows than a page') assert.equal(l.readFailure?.stream, 'inventory') assert.equal(l.cursorStall?.stream, 'trace', '어느 표가 막혔나가 조치의 절반이다') }) test('각자 지워진다 — 하나가 풀렸다고 다른 하나를 지우지 않는다', () => { const l = newIngestLedger() recordReadFailure(l, 'ETIMEDOUT', T0) recordCursorStall(l, 'cursor cannot advance', T0) clearReadFailure(l) assert.equal(l.readFailure, undefined, '다시 닿았다') assert.ok(l.cursorStall, '그렇다고 커서가 넘어간 것은 아니다') clearCursorStall(l) assert.equal(l.cursorStall, undefined, '넘어갔으면 지운다') }) test('「몇 번·언제부터」를 정체 쪽도 든다 — 한 번 밀린 것과 사흘째는 다르다', () => { const l = newIngestLedger() recordCursorStall(l, 'stuck at 2026-04-15T01:49', T0) recordCursorStall(l, 'stuck at 2026-04-15T01:49', T0 + 600_000) recordCursorStall(l, 'stuck at 2026-04-15T01:49', T0 + 1_200_000) assert.equal(l.cursorStall?.consecutive, 3) assert.equal(l.cursorStall?.sinceMs, T0, '처음 막힌 시각을 지킨다') assert.equal(l.cursorStall?.lastAtMs, T0 + 1_200_000) /* 넘어간 뒤 다시 막히면 **새 정체**다 — 옛 시작 시각을 물려받지 않는다. */ clearCursorStall(l) recordCursorStall(l, 'stuck again', T0 + 3_600_000) assert.equal(l.cursorStall?.consecutive, 1) assert.equal(l.cursorStall?.sinceMs, T0 + 3_600_000) }) test('뷰로 나간다 — **유입이 없는 갈래에서도**', () => { /* * ★ 여기가 아무 신호도 나지 않기 가장 쉬운 자리다. 커서가 막히면 유입이 0이므로 장부가 「빈 장부」 갈래를 * 지나고, 그 갈래에서 이 값을 떨어뜨리면 **정체가 실제로 일어나는 바로 그 상황에서** 침묵한다. * `unhandled` 를 스냅샷이 떨어뜨려 정합성 검사가 영원히 아무 신호도 내지 않았던 것과 같은 부류다. */ const l = newIngestLedger() recordCursorStall(l, 'one timestamp holds more rows than a page', T0, 'trace') const v = ingestHealth(l, 'attached', undefined, T0 + 60_000) assert.ok(v.cursorStall, '유입이 없어도 뷰에 실린다') assert.equal(v.cursorStall?.consecutive, 1) assert.equal(v.cursorStall?.since, new Date(T0).toISOString()) assert.equal(v.cursorStall?.stream, 'trace') assert.equal(v.readFailure, undefined, '닿지 못한 것은 아니다 — 섞이지 않는다') }) test('유입이 있는 갈래에서도 나간다 — 두 갈래를 다 채운다', () => { /* 갈래가 둘인 함수에서 한쪽만 채우는 실수를 이 저장소가 이미 겪었다. */ const l = newIngestLedger() recordIngest(l, 5, [], T0) recordCursorStall(l, 'cursor cannot advance', T0 + 1000, 'lot') const v = ingestHealth(l, 'attached', undefined, T0 + 2000) assert.equal(v.total.offered, 5, '유입이 있는 갈래다 — 빈 비교로 통과하지 않는다') assert.equal(v.cursorStall?.stream, 'lot') }) test('막히지 않았으면 그 칸을 만들지 않는다 — 빈 값이 「막혔다」로 읽히지 않게', () => { const l = newIngestLedger() recordIngest(l, 3, [], T0) assert.equal(ingestHealth(l, 'attached', undefined, T0 + 1000).cursorStall, undefined) }) test('두 사실이 같은 문으로 나가지 않는다 — 배선을 소스로 확인한다', () => { /* * 장부만 시험하면 **호스트가 여전히 읽기 실패 문으로 보내도** 초록이다. 실제로 그랬던 것이 이 결함의 * 시작이었다. */ const src = readFileSync(join(import.meta.dirname, '..', 'server', 'service', 'reference', 'reference-live.ts'), 'utf8') const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/.*$/gm, '$1') assert.match(code, /onCursorStall:/, '어댑터의 정체 통로를 잇는다') assert.match(code, /recordIngestCursorStall/, '정체는 정체 장부로 간다') /* 그리고 그 문구가 「닿지 못한다」와 달라야 한다 — 같은 말이면 화면이 여전히 반대로 안내한다. */ const at = code.indexOf('onCursorStall:') const body = code.slice(at, at + 900) assert.match(body, /cursor cannot advance/, '무엇이 막혔는지 말한다') assert.match(body, /Waiting will not clear this/, '기다림이 답이 아니라고 말한다') assert.doesNotMatch(body, /recordIngestReadFailure/, '읽기 실패 장부로 보내지 않는다') /* 재부착의 자격(`status`)은 여기서도 건드리지 않는다 — 2026-08-23 에 되돌린 그 규율. */ assert.doesNotMatch(body, /status:/, '정체가 피드를 영구히 끄지 않는다') })