/* * **저널을 쓰는 문은 하나다** — 그리고 멈출 때 남은 것을 흘린다 (ingest-scale §7.1, 2026-08-20). * * ── 무엇이 관측됐나 ───────────────────────────────────────────────────────── * 데모 자극을 켠 트윈 23개가 도는 서버에서 프로세스가 CPU 81~112% 를 계속 쓰고, 아무 일도 하지 않는 * 질의가 `twinLoad` 8초 · `twinInstanceList` 14초였다. `sample` 프로파일은 `JSON.parse`·대형 GC· * 스레드풀을 가리켰고, 계기는 **틱의 몫이 16%** 라고 말했다 — 틱이 아니었다. * * 코드가 이유였다: 기록 경로가 둘인데 **배치는 라이브만**이었다. * · 라이브 — 200ms 마다 모아 벌크 저장 * · 시뮬 — 커널이 낸 **델타 하나마다** 행 하나 저장(그리고 그 안에서 구조 리비전을 물었다) * * 같은 파일이 **브로드캐스팅**에서 같은 함정을 이미 겪고 고쳤다(틱 하나가 35초). 쓰기만 단건으로 남아 있었다. * ADR-0030 이 *"기록 경로가 둘인 것은 시뮬만 옆문으로 들어오기 때문"* 이라 예고한 그 자리다. * * ── 성능을 사려고 사실을 팔지 않는다 ──────────────────────────────────────── * 배치로 옮기면 주기 사이에 멈출 때 그 구간이 사라진다 — **라이브도 이미 그랬다**(최대 200ms). 그래서 * 정지 때 남은 것을 흘리는 자리를 함께 만들었다. 상태만 남기고 저널을 잃으면 「왜 그렇게 됐나」가 빈다. * * 이 시험은 소스로 규율을 지킨다(DB 없이 도는 스위트다). 정량화는 들어온 것이 없는 서버의 `journalRate` 가 답한다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' const engine = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8') /** * 메서드 본문만 — 다른 자리의 같은 낱말에 걸리지 않게. * * **다음 메서드 선언에서 끊는다.** 길이로 자르면 옆 메서드가 딸려 와, 「이 메서드가 몇 번 부르나」를 * 세는 단정이 오류 없이 틀린 값을 낸다(실제로 그렇게 2를 세었다). */ function methodOf(src: string, name: string): string { const at = src.indexOf(`${name}(`) assert.ok(at >= 0, `${name} 가 없다`) const rest = src.slice(at + name.length) const next = rest.search(/\n (static|private static|async|[a-zA-Z]+\()/) return rest.slice(0, next > 0 ? next : 3000) } test('시뮬이 델타마다 행을 쓰지 않는다 — 모아서 한 문으로 보낸다', () => { const at = engine.indexOf("if (msg.kind === 'delta')") const body = engine.slice(at, at + 2600) assert.match(body, /pendingJournal/, '델타는 버퍼에 담긴다') assert.equal(/this\.persist\(domainId, id, msg\)/.test(body), false, '단건 저장으로 되돌아갔다') }) test('단건 저장 경로가 아예 없다 — 남겨 두면 다시 부를 자리가 된다', () => { assert.equal(/static async persist\(domainId/.test(engine), false, '`persist` 가 되살아났다') /* 쓰는 문은 둘이지만 **부르는 자리는 하나**다(`flushJournal`). */ /* 약속을 돌려준다 — 정지가 기다릴 수 있어야 한다(기다리지 않으면 웜스타트가 빈 저널을 읽는다). */ assert.match(engine, /private static flushJournal\(inst: InstanceRuntime\): Promise/) assert.match(engine, /await this\.flushJournal\(i\)/, '정지는 흘리기를 기다린다') assert.match(engine, /static async persistBatch\(/) assert.match(engine, /static async persistCarried\(/) /* 넣기만 한다 — `save` 는 행마다 SELECT 를 한 번 더 한다(시험 로그에서 그 질의가 보였다). */ assert.match(engine, /private static async insertRows\(/) assert.equal(/repo\.save\(rows, \{ chunk: 500 \}\)/.test(engine), false, '저널을 save 로 넣는 모양으로 되돌아갔다') }) test('커널이 매긴 리비전을 호스트가 다시 번호 붙이지 않는다 — 시간여행이 그것을 딛는다', () => { const flush = methodOf(engine, 'private static flushJournal') /* 시뮬은 자기 리비전을 들고 오고(`{ event, revision }`), 라이브는 flush 에서 이어 붙인다. */ assert.match(flush, /typeof b\.revision === 'number'/, '들고 온 리비전을 가려낸다') assert.match(flush, /inst\.revision = start \+ plain\.length/, '라이브만 이어 붙인다') const carried = methodOf(engine, 'static async persistCarried') assert.match(carried, /it\.revision/, '들고 온 리비전을 그대로 쓴다') /* 구조 리비전은 배치에 **한 번**만 묻는다 — 행마다 묻던 것이 문제였다. */ assert.equal((carried.match(/structureRevOf/g) ?? []).length, 1) }) test('멈출 때 남은 것을 흘린다 — 성능을 사려고 사실을 팔지 않는다', () => { const stop = methodOf(engine, 'static async stop') assert.match(stop, /this\.flushJournal\(i\)/, '정지 경로가 버퍼를 흘린다') const flushAt = stop.indexOf('this.flushJournal(i)') const dropAt = stop.indexOf('delete this.instances') assert.ok(flushAt >= 0 && dropAt >= 0 && flushAt < dropAt, '런타임을 버리기 전에 흘려야 한다') }) test('두 구동이 같은 계기를 든다 — 세지 않는 개선은 되돌려도 아무도 모른다', () => { /* * 계기(`inst.metrics`)가 `startLive` 에만 있었다. 그래서 도는 트윈 22개가 시뮬인 서버에서 「초당 몇 행을 * 쓰나」·「밀린 게 있나」를 물을 자리가 없었고, 모니터 목록에서도 시뮬이 알리지 않고 빠졌다(계기가 null 이라 * 걸러졌다) — 부하가 거의 없는 것처럼 보였다. */ assert.match(engine, /private static newMetrics\(\): TwinMetrics/, '계기 한 벌을 한 곳에서 만든다') const sim = engine.slice(engine.indexOf("mode: 'sim'"), engine.indexOf("mode: 'sim'") + 1200) assert.match(sim, /metrics: this\.newMetrics\(\)/, '시뮬도 계기를 든다') /* 비율 갱신이 라이브만 보면 시뮬 칸은 영원히 0 이다. */ assert.equal(/const m = isLive \? inst\.metrics : undefined/.test(engine), false, '비율 갱신이 시뮬을 다시 잘라 냈다') }) test('적은 것을 장부에 남긴다 — 계기는 지금만 답하고 재기동에 0 으로 돌아간다', () => { const flush = methodOf(engine, 'private static flushJournal') /* 실패한 배치를 「남았다」고 세지 않으려면 **쓴 뒤에** 적어야 한다. */ assert.match(flush, /this\.recordJournalWrite\(inst\.domainId, inst\.id, written\)/) const thenAt = flush.indexOf('.then(') const recAt = flush.indexOf('this.recordJournalWrite') assert.ok(thenAt >= 0 && recAt > thenAt, '약속이 풀린 뒤에 적는다 — 터진 배치를 세지 않게') }) test('구조 리비전은 「없다」도 기억한다 — 없는 트윈이 부를 때마다 질의하지 않게', () => { const body = methodOf(engine, 'static async structureRevOf') assert.match(body, /this\.structureRevCache\[key\] = latest \? latest\.rev : null/, '미스를 기억한다') assert.equal(/if \(latest\) this\.structureRevCache/.test(body), false, '미스를 흘려버리는 모양으로 되돌아갔다') /* 캐시가 `null` 을 담으므로 「모름」과 구별된다 — 형이 그 사실을 말해야 다음 사람이 0 으로 읽지 않는다. */ assert.match(engine, /structureRevCache: Record/) })