/* * ★ **재기동이 저널을 처음부터 다시 계산하지 않는다** — 미러가 저장된 재개점에서 이어 선다. * * ── 무엇이 오래 잘못돼 있었나 (2026-08-24 실측) ───────────────────────────── * 재개점을 **읽는** 쪽은 다 있었다: `recover` 는 `fold` 가 있으면 꼬리만 계산하고, 없으면 저널을 0부터 * 계산한다. 그런데 **쓰는 쪽이 없었다** — `persistSnapshot` 은 `{revision, state, structureRev}` 만 * 적었다. 실측으로 저장된 스냅샷 34건 전부 `fold` 가 비어 있었고, 저널은 2,960만 줄이었다. * * 그래서 두 가지가 함께 일어났다. * * ① 켤 때마다 처음부터 계산했다 — 규모 기준(엔티티 10만·품목 100만)에서 느린 것이 아니라 못 하는 것이다 * ② 미러는 되찾은 상태를 **버렸다**(`startLive`). 「다음 계측이 정정한다」는 전제였는데, 원본이 커서 * 증분으로 말하는 현장에서는 그 정정이 오지 않는다 — 미러가 영구히 빈 채로 남고 화면은 들어온 것이 없었다 * * ── 왜 커널 시험만으로 충분하지 않나 ──────────────────────────────────────── * 커널은 「재개점 + 꼬리 == 0부터 계산하기」를 증명한다(`observed-checkpoint.test.ts`). 그것이 참이어도 * 호스트가 재개점을 **적지 않거나 되세우지 않으면** 아무 일도 일어나지 않는다 — 이 저장소가 실제로 * 넉 달 동안 그 상태였다. 그래서 여기서 DB 를 물고 실제로 걷는다. * * ── 결정적인 단정 ─────────────────────────────────────────────────────────── * 재기동 전에 **저널을 지운다.** 그래도 상태가 돌아오면 그것은 저널을 계산해 온 것이 **아니다** — * 저장된 재개점에서 온 것이다. 그리고 그 상황은 가정이 아니라 설계된 거동이다: 보존 정책이 체크포인트 * 리비전 이하의 행을 지운다(「그 앞은 스냅샷이 대신한다」). */ 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') /* 체크포인트는 `cache-service` 표에 앉는다 — 그 엔티티를 빼면 저장이 알리지 않고 실패한다(스냅샷도 씨앗도). */ const { entities: cacheEntities } = req('@things-factory/cache-service/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 { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string const INSTANCE = 'resume-probe' /** 미러 하나 — 원본이 사실을 말해 주는 트윈이므로 시나리오도 레시피도 필요 없다. */ const master = (): any => ({ source: INSTANCE, system: 'wms', siteName: '재개점 창고', origin: { kind: 'reference' }, // 현실 출처 선언 — 이것이 미러로 뜨는 근거다 space: { width: 40, depth: 20, unit: 'm' }, locations: [ { id: 'dock', type: 'dock', role: 'location', capacity: 100 }, { id: 'bin-1', type: 'storage', role: 'location', capacity: 100 } ], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock' }] }) /** 원본이 말한 사실 — 오더와 작업. 미러는 이것을 계산해 상태를 만든다. */ function facts(n: number): any[] { const out: any[] = [] for (let i = 1; i <= n; i++) { out.push({ eventId: `o-${i}`, eventType: 'order.status', eventTime: '2026-08-24T01:00:00.000Z', tenantId: domainId, data: { orderId: `WO-${i}`, kind: 'workorder', status: 'in-progress', requested: 10, fulfilled: 3 } }) out.push({ eventId: `t-${i}`, eventType: 'task.status', eventTime: '2026-08-24T01:00:01.000Z', tenantId: domainId, data: { taskId: `TK-${i}`, kind: 'putaway', status: 'in-progress', orderId: `WO-${i}`, fromNode: 'dock', toNode: 'bin-1', intent: 'transport', durationMs: 60000, remainingMs: 30000 } }) } return out } const observed = (s: any): number => (s?.items?.length ?? 0) + (s?.orders?.length ?? 0) + (s?.tasks?.length ?? 0) before(async () => { ds = new TypeOrmDataSource({ type: 'sqlite', database: ':memory:', synchronize: true, dropSchema: true, namingStrategy: new NamingStrategy(), entities: [...shellEntities, ...authEntities, ...twinEntities, ...cacheEntities], logging: false }) await ds.initialize() addDataSource('default', ds) const d = await ds.getRepository(Domain).save({ name: 'resume-test', subdomain: 'resume-test' } as any) domainId = d.id await TwinEngine.ingestMaster(domainId, master(), 'reset') }) after(async () => { await TwinEngine.stop(domainId, INSTANCE).catch(() => {}) await ds?.destroy() }) test('체크포인트가 **이어 계산할 씨앗을 함께 적는다** — 이 줄이 없어서 넉 달 동안 처음부터 계산했다', async () => { const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'resync') assert.equal(rt.mode, 'live', '미러로 떠야 이 검사가 뜻이 있다') const applied = TwinEngine.ingestLive(domainId, INSTANCE, facts(5)) assert.equal(applied, 10, '원본이 말한 사실이 커널에 들어갔다') const before = TwinEngine.snapshot(domainId, INSTANCE) assert.ok(observed(before) > 0, `상태에 사실이 있어야 이 검사가 뜻이 있다 (실제 ${observed(before)})`) await TwinEngine.persistSnapshot(domainId, INSTANCE) const stored = await TwinEngine.loadSnapshot(domainId, INSTANCE) assert.ok(stored, '스냅샷이 저장됐다') assert.ok(stored.fold?.reducer, '★ 재개점이 함께 적혀야 한다 — 없으면 다음 기동이 저널을 0부터 계산한다') assert.ok(stored.fold?.oee, '가동 누적기도 함께 — 상태만 되돌리면 가동률이 부팅 이후로만 잡힌다') /* 씨앗이 실제로 사실을 들었는지 — 빈 껍데기를 적어 두고 초록이 되지 않게. */ assert.ok((stored.fold.reducer as any).orders?.length, '씨앗에 오더가 담겨 있다') assert.ok((stored.fold.reducer as any).tasks?.length, '씨앗에 작업이 담겨 있다') }) test('★ 저널을 지워도 미러가 상태를 들고 다시 선다 — 그러므로 저널을 계산한 것이 아니다', async () => { /* * 이 단정이 이 축의 전부다. 저널이 없는데 상태가 돌아오면 그 상태는 **저장된 재개점**에서 온 것이다. * 그리고 이 상황은 가정이 아니다 — 보존 정책이 체크포인트 리비전 이하의 행을 실제로 지운다. */ const before = observed(TwinEngine.snapshot(domainId, INSTANCE)) assert.ok(before > 0, '멈추기 전에 상태가 있어야 이 검사가 뜻이 있다') await TwinEngine.stop(domainId, INSTANCE) /* 이 인스턴스의 저널만 — 정확한 소유 표식으로 지운다. */ await ds.getRepository(TwinEvent).delete({ domain: { id: domainId } as any, instanceId: INSTANCE } as any) assert.equal( await ds.getRepository(TwinEvent).countBy({ domain: { id: domainId } as any, instanceId: INSTANCE } as any), 0, '저널이 비었다 — 이제 계산할 것이 없다' ) await TwinEngine.bootDeclared(domainId, INSTANCE, 'resync') const after = TwinEngine.snapshot(domainId, INSTANCE) assert.equal(observed(after), before, '★ 사실이 그대로 돌아왔다 — 저널 없이') assert.equal(after.orders.length, 5, '오더 다섯이 그대로다') assert.equal(after.tasks.length, 5, '작업 다섯이 그대로다') }) test('되세운 뒤 들어온 사실이 앞의 것을 덮지 않는다 — 더해진다', async () => { /* * 미러는 첫 봉투가 올 때 리듀서를 만든다. 되돌리기가 그보다 먼저 서지 않으면 첫 봉투가 **빈 리듀서**를 * 만들고 되세운 것을 덮는다 — 오류 없이 상태가 사라진다. */ const before = observed(TwinEngine.snapshot(domainId, INSTANCE)) TwinEngine.ingestLive(domainId, INSTANCE, [ { eventId: 'o-new', eventType: 'order.status', eventTime: '2026-08-24T02:00:00.000Z', tenantId: domainId, data: { orderId: 'WO-NEW', kind: 'workorder', status: 'in-progress', requested: 1, fulfilled: 0 } } as any ]) const after = TwinEngine.snapshot(domainId, INSTANCE) assert.equal(observed(after), before + 1, '앞의 것이 남고 새 것이 더해진다') assert.ok(after.orders.some((o: any) => o.id === 'WO-1'), '되세운 오더가 남아 있다') assert.ok(after.orders.some((o: any) => o.id === 'WO-NEW'), '새 오더도 있다') }) test('씨앗이 없으면 지어내지 않는다 — 전과 같이 빈 채로 시작한다', async () => { /* * 처음 뜨는 트윈에는 씨앗이 없다. 여기서 무엇이든 심으면 없던 재고가 생긴다 — 그것은 이 작업이 * 고치려던 것보다 나쁘다. */ const FRESH = 'resume-probe-fresh' await TwinEngine.ingestMaster(domainId, { ...master(), source: FRESH }, 'reset') try { await TwinEngine.bootDeclared(domainId, FRESH, 'resync') assert.equal(observed(TwinEngine.snapshot(domainId, FRESH)), 0, '아무것도 듣지 못했으므로 비어 있다') } finally { await TwinEngine.stop(domainId, FRESH).catch(() => {}) } })