/* * ★ 시각축은 **두 수만 묻는다** — 저널을 다 읽어 오지 않는다. * * ── 무엇을 겪었나 (2026-08-20 실측) ───────────────────────────────────────── * `TwinEngine.timeRange` 는 공간의 인스턴스마다 `find()` 로 **행을 전부 엔티티로 하이드레이션**한 뒤 * JS 에서 min/max 를 골랐다. 그런데 이 함수는 화면 상단의 컨텍스트 띠가 **4초마다** 부른다. 개발 * 서버에서 `order-check` 한 트윈이 275,882행 · `payload` 131MB 였다 — 4초마다 그 JSON 을 전부 파싱했다. * * 결과: 프로세스 CPU 81~152%, 아무 일도 하지 않는 질의가 8~14초. JS 프로파일 상위가 TypeORM 의 * `RelationIdLoader`·`RawSqlResultsToEntityTransformer`·`stringToSimpleJson` 이고 GC 가 16.7% 였다. * 트윈 틱(계기가 16%)도 저널 쓰기도 아니라 **이 읽기**였다. * * ── 이 시험이 지키는 것 ───────────────────────────────────────────────────── * ① 답이 맞다(공간의 모든 트윈에 걸친 min/max, 남의 공간은 안 섞인다) * ② 「없음」을 0 으로 떨어뜨리지 않는다(시각축이 1970년에서 시작하면 스크러버가 못 쓰게 된다) * ③ **행을 실어 오지 않는다** — 고친 방식이 되돌아가면(다시 `find()`) 답은 그대로 맞고 느려지기만 * 하므로, 결과만 보는 시험으로는 회귀를 잡을 수 없다. 그래서 소스를 함께 본다. */ import { test, before, after } from 'node:test' import assert from 'node:assert/strict' import { createRequire } from 'node:module' import { readFileSync } from 'node:fs' 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 { TwinEvent } = req('../dist-server/service/twin-event/twin-event.js') const { TwinInstance } = req('../dist-server/service/twin-instance/twin-instance.js') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string const SPACE = 'space-yard' const OTHER_SPACE = 'space-elsewhere' /** 저널 행 하나 — 시각축이 보는 것은 `eventTime` 뿐이다. */ async function event(instanceId: string, eventTime: string | null, revision: number): Promise { await ds.getRepository(TwinEvent).save( ds.getRepository(TwinEvent).create({ domain: { id: domainId } as any, instanceId, eventType: 'test.event', revision, eventTime: eventTime === null ? null : new Date(eventTime), payload: { note: 'x' } } as any) ) } async function instance(instanceId: string, spaceId: string): Promise { await ds.getRepository(TwinInstance).save( ds.getRepository(TwinInstance).create({ domain: { id: domainId } as any, instanceId, spaceId, kind: 'yms', 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) const d = await ds.getRepository(Domain).save({ name: 'range-test', subdomain: 'range-test' } as any) domainId = d.id }) after(async () => { await ds?.destroy() }) test('공간의 여러 트윈에 걸쳐 가장 이른 것과 가장 늦은 것을 낸다', async () => { await instance('yard-a', SPACE) await instance('yard-b', SPACE) /* 가장 이른 것은 b 에, 가장 늦은 것은 a 에 있다 — 한 트윈만 보면 답이 틀린다. */ await event('yard-a', '2026-03-01T00:00:00.000Z', 1) await event('yard-a', '2026-06-30T12:00:00.000Z', 2) await event('yard-b', '2026-01-15T09:00:00.000Z', 1) const r = await TwinEngine.timeRange(domainId, SPACE) assert.equal(r.minTime, '2026-01-15T09:00:00.000Z') assert.equal(r.maxTime, '2026-06-30T12:00:00.000Z') }) test('다른 공간의 저널은 섞이지 않는다 — 시각축은 그 현장의 것이다', async () => { await instance('far-c', OTHER_SPACE) await event('far-c', '2020-01-01T00:00:00.000Z', 1) await event('far-c', '2030-01-01T00:00:00.000Z', 2) const r = await TwinEngine.timeRange(domainId, SPACE) assert.equal(r.minTime, '2026-01-15T09:00:00.000Z', '2020년이 새어 들어오지 않는다') assert.equal(r.maxTime, '2026-06-30T12:00:00.000Z', '2030년도 그렇다') }) test('시각이 빈 행이 시각축을 1970년으로 끌어내리지 않는다', async () => { /* 옛 행에는 `eventTime` 이 없을 수 있다. 그것을 0 으로 읽으면 스크러버가 56년을 그린다. */ await event('yard-a', null, 3) const r = await TwinEngine.timeRange(domainId, SPACE) assert.equal(r.minTime, '2026-01-15T09:00:00.000Z', '빈 시각은 「없음」이고 0 이 아니다') }) test('이벤트가 없으면 「없음」이다 — 0 이나 지금이 아니다', async () => { await instance('empty-d', 'space-empty') const r = await TwinEngine.timeRange(domainId, 'space-empty') assert.equal(r.minTime, null) assert.equal(r.maxTime, null) }) test('그 공간에 트윈이 없으면 「없음」이다', async () => { const r = await TwinEngine.timeRange(domainId, 'space-nobody') assert.equal(r.minTime, null) assert.equal(r.maxTime, null) }) test('두 수를 구하려고 행을 실어 오지 않는다 — 되돌아가면 답은 맞고 느려지기만 한다', () => { /* * 결과만 보는 시험으로는 이 회귀를 잡을 수 없다(`find()` 로 돌려도 min/max 는 맞다). 그래서 함수의 * 소스를 본다: 집계(MIN/MAX)를 쓰고, 저널을 `find()` 로 긁지 않는다. */ const src = readFileSync(new URL('../server/engine/twin-engine.ts', import.meta.url).pathname, 'utf-8') const at = src.indexOf('static async timeRange(') assert.ok(at > 0, 'timeRange 를 찾지 못했다 — 이름이 바뀌면 이 검사도 함께 옮긴다') /* 함수 본문만 떼어 본다 — 다음 멤버 선언까지. */ const body = src.slice(at, src.indexOf('\n /** 정지 훅', at) > 0 ? src.indexOf('\n /** 정지 훅', at) : at + 4000) assert.ok(body.includes("MIN(e.eventTime)") && body.includes("MAX(e.eventTime)"), '집계는 DB 가 한다') assert.ok( !/getRepository\(TwinEvent\)\s*\.?\s*\n?\s*\.find\(/.test(body), '저널을 행 단위로 실어 오면 안 된다 — 275,882행 · payload 131MB 를 4초마다 파싱했던 자리다' ) })