/* * 구간 성과 질의 비용 측정 — **브로드캐스팅이냐 질의냐를 숫자로 정한다.** * * ── 왜 재는가 (2026-08-06) ───────────────────────────────────────────────── * 성과 카드를 여러 단계(공간·트윈·구역·자리·설비)에 붙이려 한다. 두 방법이 있다. * * · 브로드캐스팅 — 서버가 주기마다 전 단계를 계산해 밀어 준다. 자리가 트윈당 최대 200개고 지표가 6개라 * 태그가 1,200개가 된다. **모델에 카드를 하나도 안 놓아도 계산은 돈다.** * · 질의 — 카드가 필요할 때 묻는다. **모델에 놓인 카드 수만큼만** 든다. 시각(`toTime`)도 물을 수 있어 * 타임라인과 맞고, 공간 단위 합산(여러 트윈을 한 번에 계산)은 질의에만 있다. * * 질의로 정하려면 **한 번의 비용**을 알아야 한다. 카드 20장이 30초마다 물으면 감당되는지가 결정을 * 가른다. 짐작하지 않고 잰다. * * 빌드 산출물을 실행한다 — **소스를 고친 뒤에는 `npm run build:server` 를 먼저 돌린다.** */ import { test, before, after } from 'node:test' 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 { computeTwinKpi } = 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 INSTANCE = 'kpi-bench' const TO = Date.parse('2026-03-10T10:00:00.000Z') const WINDOW_MIN = 60 /** 자리 200개 — 실측한 최대 규모(트윈당 자리 최대 200, 평균 19). */ const LOCATIONS = 200 /** 창 안의 완료 작업 수 — 한 건이 두 줄(시작·완료)이라 이벤트는 두 배가 된다. */ const TASK_COUNTS = [500, 2_000, 10_000] function taskRows(count: number, revBase: number) { const rows: any[] = [] for (let i = 0; i < count; i++) { const done = TO - 60_000 - (i % (WINDOW_MIN * 60)) * 1000 const started = done - 30_000 const at = `loc-${i % LOCATIONS}` rows.push( { domain: { id: domainId }, instanceId: INSTANCE, revision: revBase + i * 2, eventType: 'task.status', eventTime: new Date(started).toISOString(), payload: { data: { taskId: `t-${i}`, status: 'in-progress', resourceRef: 'fk-1', locationRef: at } } }, { domain: { id: domainId }, instanceId: INSTANCE, revision: revBase + i * 2 + 1, eventType: 'task.status', eventTime: new Date(done).toISOString(), payload: { data: { taskId: `t-${i}`, status: 'completed', resourceRef: 'fk-1', locationRef: at } } } ) } return rows } 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: 'kpi-bench', subdomain: 'kpi-bench' } as any) domainId = d.id /* 자리 계층 — 자리 200개를 구역 4개에 나눠 붙인다(구역 단위 계산 비용도 재려면 부모가 있어야 한다). */ const locations = Array.from({ length: LOCATIONS }, (_, i) => ({ id: `loc-${i}`, type: 'storage', capacity: 10, parentId: `area-${i % 4}` })) await ds.getRepository(TwinInstance).save({ domain: { id: domainId }, instanceId: INSTANCE, kind: 'wms', restartPolicy: 'reset', /* 선언은 이제 필수 — 비워 두면 읽는 자리마다 기본값을 고른다 */ purpose: 'operational', status: 'stopped', model: { locations, equipment: [{ id: 'fk-1', kind: 'forklift', homeLocation: 'loc-0' }] } } as any) }) after(async () => { await ds?.destroy() }) const ms = async (fn: () => Promise) => { const t = process.hrtime.bigint() await fn() return Number(process.hrtime.bigint() - t) / 1e6 } test('bench: 구간 성과 폴드 한 번의 비용 vs 창 안의 작업 수', async () => { let seeded = 0 for (const count of TASK_COUNTS) { await ds.getRepository(TwinEvent).save(taskRows(count - seeded, seeded * 2 + 1000)) seeded = count const args = { domainId, instanceId: INSTANCE, toTime: new Date(TO).toISOString(), windowMinutes: WINDOW_MIN } await computeTwinKpi(args) // 워밍업(연결·스키마 캐시) const plain = await ms(() => computeTwinKpi(args)) const byArea = await ms(() => computeTwinKpi({ ...args, groupBy: 'area' })) console.log(` 작업 ${count.toLocaleString()}건(이벤트 ${(count * 2).toLocaleString()}줄) · 자리 ${LOCATIONS}개`) console.log(` 폴드 1회 ${plain.toFixed(1)}ms`) console.log(` 구역별 폴드 1회 ${byArea.toFixed(1)}ms`) console.log(` 카드 20장 동시 ${(plain * 20).toFixed(0)}ms (직렬 상한 — 실제로는 한 번 계산해 나눠 쓸 수 있다)`) } })