/* * ★ 인스턴스별 캐시는 **트윈과 함께 사라진다.** * * ── 왜 이 검사가 필요한가 ───────────────────────────────────────────────────── * 엔진은 트윈마다 메모리에 몇 가지를 들고 있다(실측 추정기·구조 리비전·복구 표식). 지움은 저널과 * 등록부까지 갔는데 **캐시는 남았다** — 삭제된 트윈의 항목이 서버 재기동까지 살아 있었다. 항목 하나가 * 하루치 KPI 그룹을 물고 있으므로, 트윈을 만들고 지우는 일이 반복되는 현장에서는 그것이 곧 누수다. * * TTL 은 이것을 막지 못한다: TTL 은 **값이 낡는 것**을 막을 뿐 **없어진 주인**을 지우지 않는다. * * 그래서 규율은 둘이다. * ① 트윈이 사라지면 그 트윈에 대한 메모리를 **전부** 잊는다(정확한 쪽). * ② 우리 API 를 지나지 않고 사라지는 길(도메인째 삭제·DB 직접 조작)에는 **상한**이 막는다(방어). * * 사설(private) 필드를 들여다보는 검사인 것을 안다. 그런데 여기서 지켜야 하는 것은 겉으로 드러나는 * 값이 아니라 **메모리가 자라지 않는다**는 사실이고, 그것은 안을 봐야 볼 수 있다. */ 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 { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string const INSTANCE = 'cache-probe' const master = (): any => ({ source: INSTANCE, system: 'wms', siteName: '캐시 창고', space: { spaceId: 'cache-site', width: 40, depth: 30, unit: 'm' }, locations: [ { id: 'dock', type: 'dock', role: 'location', name: '도크', capacity: 50 }, { id: 'bin-1', type: 'storage', role: 'location', name: '빈1', capacity: 20 } ], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock' }] }) /** 캐시 안을 본다 — 겉으로는 안 보이는 사실을 지키는 검사이므로. */ const caches = () => ({ measured: (TwinEngine as any).measuredCache as Map, structureRev: (TwinEngine as any).structureRevCache as Record, recovered: (TwinEngine as any).recovered as Record }) const keyOf = (id: string) => `${domainId}:${id}` 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: 'cache-test', subdomain: 'cache-test' } as any) domainId = d.id }) after(async () => { await TwinEngine.stop(domainId, INSTANCE).catch(() => {}) await ds?.destroy() }) test('지운 트윈의 캐시는 남지 않는다 — TTL 은 없어진 주인을 지우지 않는다', async () => { await TwinEngine.ingestMaster(domainId, master(), 'reset') /* 캐시를 실제로 채운다 — 손으로 넣으면 "채우는 경로" 가 바뀌어도 검사가 통과한다. */ await TwinEngine.structureRevOf(domainId, INSTANCE) const { measured, structureRev } = caches() assert.ok(keyOf(INSTANCE) in structureRev, '구조 리비전이 캐시에 올라와야 이 검사가 뜻이 있다') await TwinEngine.remove(domainId, INSTANCE) const after = caches() assert.equal(keyOf(INSTANCE) in after.structureRev, false, '지운 트윈의 구조 리비전이 남았다') assert.equal(after.measured.has(keyOf(INSTANCE)), false, '지운 트윈의 실측 캐시가 남았다') assert.equal(keyOf(INSTANCE) in after.recovered, false, '지운 트윈의 복구 표식이 남았다') assert.equal(measured, after.measured, '캐시 객체를 갈아치우는 것이 아니라 항목을 지운다') }) test('상한을 넘으면 가장 오래 손대지 않은 것부터 버린다 — 주인 없이 사라진 트윈까지 막는다', async () => { const { measured } = caches() measured.clear() const MAX = (TwinEngine as any).MEASURED_MAX as number assert.ok(MAX > 0, '상한이 선언되어 있어야 방어가 성립한다') /* * 상한 + 2 개를 넣고, 넣는 경로(`measuredEstimator`)를 그대로 쓴다. 트윈이 등록되어 있지 않으면 * KPI 조회가 빈 값을 내고 캐시에는 `undefined` 가 들어간다 — **그 자체도 캐시할 값이다**(없다는 * 사실을 알고 있는 것과 매번 다시 묻는 것은 다르다). */ const call = (TwinEngine as any).measuredEstimator.bind(TwinEngine) for (let i = 0; i < MAX + 2; i++) await call(domainId, `ghost-${i}`) assert.ok(measured.size <= MAX, `상한을 넘어 자랐다(${measured.size} > ${MAX})`) assert.equal(measured.has(`${domainId}:ghost-0`), false, '가장 오래된 항목이 남아 있다 — 버릴 순서가 어긋났다') assert.ok(measured.has(`${domainId}:ghost-${MAX + 1}`), '가장 최근 항목은 남아야 한다') measured.clear() })