/* * ★ 인제스트 이력 — **재기동 뒤에도 추이가 이어진다.** * * ── 무엇이 미완이었나 (2026-08-20) ────────────────────────────────────────── * 닫힌 창을 표로 남기기 시작했는데 **읽는 코드가 없었다.** 21행이 쌓여 있고 아무도 보지 않았다 — * 이 프로젝트가 결함으로 보는 상태다(「필드만 만들고 아무도 읽지 않는다」). 표를 만든 이유가 * 「메모리 장부는 재기동에 사라지고 6시간까지인데 추이는 그보다 오래 봐야 한다」였으니 목적이 * 절반만 서 있었다. * * ── 지키는 것 ─────────────────────────────────────────────────────────────── * ① 표와 메모리를 이어 붙이되 **겹친 창을 두 번 내지 않는다** — 같은 구간이 두 번 그려지면 추이가 * 거짓이 된다. 겹치면 메모리 쪽을 쓴다(같은 사실의 원본이다). * ② 어디서부터 메모리인지 밝힌다(`liveFrom`) — 화면이 근거를 말할 수 있게. 재기동 직후에는 `null` * 이고, 그때 보이는 것은 전부 표에서 온 것이다. * ③ **알리지 않고 자르지 않는다** — 상한에서 밀려난 창 수와, 물은 기간이 잘렸다는 사실을 함께 낸다. * ④ 남의 도메인·남의 트윈의 창을 집지 않는다. * ⑤ 제시가 0 인 창의 통과율은 `null` 이다 — 빈 창은 완벽한 창이 아니다. */ import { test, before, after } from 'node:test' import assert from 'node:assert/strict' import { createRequire } from 'node:module' import type { DataSource } from 'typeorm' const req = createRequire(import.meta.url) const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell') const { entities: authEntities } = req('@things-factory/auth-base') const { entities: twinEntities } = req('../dist-server/service/index.js') const { TwinEngine } = req('../dist-server/engine/index.js') const { INGEST_WINDOW_MS } = req('../dist-server/engine/ingest-health.js') const { TwinIngestWindow } = req('../dist-server/service/twin-ingest-window/twin-ingest-window.js') const { TwinIngestWindowQuery, HISTORY_WINDOW_LIMIT, HISTORY_HOURS_MAX } = req( '../dist-server/service/twin-ingest-window/twin-ingest-window-query.js' ) const { TwinInstance } = req('../dist-server/service/twin-instance/twin-instance.js') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string let otherDomainId: string const TWIN = 'hist-twin' const HOUR = 3_600_000 const resolver = new TwinIngestWindowQuery() const ask = (instanceId: string, hours?: number, domain = domainId) => resolver.twinIngestHistory(instanceId, hours, { state: { domain: { id: domain } } } as any) /** 표에 닫힌 창 하나 — 시각은 「몇 시간 전」으로 준다. */ async function storedWindow(instanceId: string, hoursAgo: number, offered: number, rejected = 0, domain = domainId) { const closed = new Date(Date.now() - hoursAgo * HOUR) const opened = new Date(closed.getTime() - 600_000) const repo = ds.getRepository(TwinIngestWindow) await repo.save( repo.create({ domain: { id: domain } as any, instanceId, openedAt: opened, closedAt: closed, offered, rejected, undelivered: 0, journaledRows: 0, journalFlushes: 0, ...(offered > 0 ? { acceptedRatio: (offered - rejected) / offered } : {}) } as any) ) return opened.toISOString() } async function instance(instanceId: string, domain = domainId) { const repo = ds.getRepository(TwinInstance) await repo.save( repo.create({ domain: { id: domain } as any, instanceId, spaceId: 'hist-space', kind: 'wms', status: 'stopped', restartPolicy: 'reset', purpose: 'operational' } as any) ) } before(async () => { ds = new TypeOrmDataSource({ type: 'sqlite', database: ':memory:', synchronize: true, dropSchema: true, namingStrategy: new NamingStrategy(), entities: [...shellEntities, ...authEntities, ...twinEntities], logging: false }) await ds.initialize() addDataSource('default', ds) domainId = (await ds.getRepository(Domain).save({ name: 'hist', subdomain: 'hist' } as any)).id otherDomainId = (await ds.getRepository(Domain).save({ name: 'hist-other', subdomain: 'hist-other' } as any)).id await instance(TWIN) }) after(async () => { await ds?.destroy() }) test('표에 남은 창을 읽어 온다 — 재기동으로 메모리가 비어도 추이가 있다', async () => { await storedWindow(TWIN, 5, 100, 4) await storedWindow(TWIN, 3, 200, 0) const r = await ask(TWIN) assert.equal(r.windows.length, 2) assert.equal(r.storedCount, 2) assert.equal(r.liveCount, 0, '메모리에는 닫힌 창이 없다(이 트윈은 이 프로세스에서 받은 적이 없다)') assert.equal(r.liveFrom, null, '그래서 「여기서부터 메모리」가 없다 — 0 이나 지금으로 채우지 않는다') }) test('오래된 것이 앞, 최신이 뒤 — 추이는 시간순이어야 그림이 된다', async () => { const r = await ask(TWIN) const times = r.windows.map((w: any) => Date.parse(w.from)) assert.deepEqual(times, [...times].sort((a, b) => a - b), '오름차순') }) test('통과는 트윈에 닿은 것만 — 표의 통과율을 그대로 쓴다', async () => { const r = await ask(TWIN) const w = r.windows.find((x: any) => x.offered === 100) assert.equal(w.rejected, 4) assert.equal(w.accepted, 96) assert.equal(w.acceptedRatio, 0.96) }) test('제시가 0 인 창의 통과율은 「없음」이다 — 빈 창은 완벽한 창이 아니다', async () => { await storedWindow(TWIN, 2, 0, 0) const r = await ask(TWIN) const empty = r.windows.find((x: any) => x.offered === 0) assert.equal(empty.acceptedRatio, null) }) test('물은 기간 밖의 창은 오지 않는다', async () => { await storedWindow(TWIN, 40, 999, 0) // 40시간 전 const r = await ask(TWIN, 24) assert.ok( !r.windows.some((w: any) => w.offered === 999), '24시간을 물었으면 40시간 전 창은 안 온다' ) const wide = await ask(TWIN, 48) assert.ok( wide.windows.some((w: any) => w.offered === 999), '48시간을 물으면 온다' ) }) test('기간 상한을 넘겨 물으면 자르고, 자른 사실을 낸다', async () => { const r = await ask(TWIN, HISTORY_HOURS_MAX + 100) assert.equal(r.hours, HISTORY_HOURS_MAX, '상한까지만 본다') assert.equal(r.hoursClamped, HISTORY_HOURS_MAX + 100, '얼마를 물었는지 밝힌다 — 알리지 않고 자르지 않는다') }) test('상한 안에서는 잘렸다고 말하지 않는다 — 없는 문제를 만들지 않는다', async () => { const r = await ask(TWIN, 24) assert.equal(r.hoursClamped, undefined) assert.equal(r.storedDropped, 0) }) test('창 수 상한에서 밀려난 수를 센다', async () => { const many = 'hist-many' await instance(many) /* 상한 + 5개를 심는다. 10분 간격이므로 상한을 넘기려면 그만큼의 창이 필요하다. */ for (let i = 0; i < HISTORY_WINDOW_LIMIT + 5; i++) await storedWindow(many, 1 + i * 0.01, 10, 0) const r = await ask(many, HISTORY_HOURS_MAX) assert.equal(r.windows.length, HISTORY_WINDOW_LIMIT, '상한까지만 낸다') assert.equal(r.storedDropped, 5, '밀려난 수를 말한다') }) test('남의 도메인 창을 집지 않는다', async () => { await instance('hist-shared', otherDomainId) await storedWindow('hist-shared', 1, 777, 0, otherDomainId) /* 같은 instanceId 를 내 도메인에서 물어도 남의 창이 오면 안 된다. */ await instance('hist-shared') const mine = await ask('hist-shared') assert.ok( !mine.windows.some((w: any) => w.offered === 777), '남의 도메인 창이 새지 않는다' ) }) test('내 도메인에 없는 트윈은 「없음」이다 — 남의 것이라 말하지 않는다', async () => { assert.equal(await ask('does-not-exist'), null) }) test('겹친 창을 두 번 내지 않는다 — 같은 구간이 두 번 그려지면 추이가 거짓이 된다', async () => { /* * 프로세스가 살아 있는 동안 닫힌 창은 **표와 메모리에 둘 다** 있다(영속 훅이 창이 닫힐 때 쓴다). * 정리하지 않으면 그 구간이 추이에 두 번 들어가 유입이 두 배로 보인다. */ const twin = 'hist-overlap' await instance(twin) /* 메모리에 닫힌 창 하나를 만든다 — 창은 다음 유입이 있을 때 닫힌다. */ const t0 = Date.now() - Math.floor(INGEST_WINDOW_MS * 1.4) TwinEngine.recordIngestResult(domainId, twin, 50, [], t0) TwinEngine.recordIngestResult(domainId, twin, 1, [], t0 + INGEST_WINDOW_MS + 1) const live = TwinEngine.ingestHealthOf(domainId, twin).trend assert.equal(live.length, 1, '메모리에 닫힌 창 하나') /* 같은 창을 표에도 심는다(영속 훅이 했을 일). 값은 일부러 다르게 두어 어느 쪽이 이겼는지 본다. */ const repo = ds.getRepository(TwinIngestWindow) await repo.save( repo.create({ domain: { id: domainId } as any, instanceId: twin, openedAt: new Date(Date.parse(live[0].from)), closedAt: new Date(Date.parse(live[0].to)), offered: 999, rejected: 0, undelivered: 0, journaledRows: 0, journalFlushes: 0, acceptedRatio: 1 } as any) ) const r = await ask(twin) const sameStart = r.windows.filter((w: any) => Date.parse(w.from) === Date.parse(live[0].from)) assert.equal(sameStart.length, 1, '한 번만 낸다') assert.equal(sameStart[0].source, 'live', '겹치면 메모리 쪽 — 같은 사실의 원본이다') assert.equal(sameStart[0].offered, 50, '표의 999 가 아니라 메모리의 50') assert.equal(r.liveFrom, live[0].from, '어디서부터 메모리인지 밝힌다') }) test('겹치지 않는 표의 창은 메모리 앞에 이어 붙는다', async () => { const twin = 'hist-overlap' /* 메모리 창보다 오래된 것 — 이건 표에서만 온다. */ await storedWindow(twin, 6, 30, 1) const r = await ask(twin) assert.equal(r.windows.length, 2, '표 하나 + 메모리 하나') assert.equal(r.windows[0].source, 'stored', '오래된 것이 앞') assert.equal(r.windows[1].source, 'live') })