/* * ★ 저널 보존 — **선언이 있을 때만 지운다. 그리고 도는 트윈에서만 체크포인트를 상한으로 본다.** * * ── 왜 필요한가 (2026-08-22 실측) ─────────────────────────────────────────── * 개발 저널이 시간당 **581,794행 · 0.85GB**(하루 20GB)로 자랐다. 데모 MES 트윈 셋이 전체의 **97%** 를 * 만들고 지우는 것이 없었다. * * ── 체크포인트를 전제 조건으로 두었던 것이 결함이었다 (2026-08-27) ────────── * 예전 규율은 「체크포인트가 없는 인스턴스는 건드리지 않는다」였다. 근거는 「그때 저널이 유일한 복구 * 수단이다」였고, 그 근거가 틀렸다. 트윈이 자기 저널과 어떤 관계인지는 `restartPolicy` 가 선언한다. * * resync 연결된 시스템에서 다시 읽는다 저널은 복구 수단이 아니다 * reset 씨앗부터 다시 돌린다 저널은 복구 수단이 아니다 * resume 저널을 이어 세운다 보관된 구간에서 세운다 * * 셋 다 보관 기간 밖의 사건을 요구하지 않는다. 보존 기간을 7일로 선언한 것이 곧 「7일 전 상태로는 * 세우지 않는다」는 뜻이다. 그 전제 때문에 멈춘 트윈 셋에 2,900만 행이 영구히 남았다. * * 체크포인트가 남는 일은 하나다. **도는 트윈**은 메모리에서 이어 집계하므로, 체크포인트가 아직 반영하지 * 못한 사건을 지우면 그 구간이 어디에도 없어진다. 그래서 도는 트윈에서만 그 리비전을 상한으로 쓴다. * * ── 이 테스트가 무엇을 재는가 ─────────────────────────────────────────────── * DB 를 띄우지 않는다. 이 규율은 **조건을 어떻게 세우나**에 있고 그것은 순수하게 확인할 수 있다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { join } from 'node:path' const ENGINE = join(import.meta.dirname, '..', 'server', 'engine', 'twin-engine.ts') const src = readFileSync(ENGINE, 'utf8') /** `pruneJournal` 본문만 — 다른 자리의 조건이 섞여 판정이 흐려지지 않게. */ function pruneBody(): string { const at = src.indexOf('static async pruneJournal') assert.ok(at > 0, 'pruneJournal 이 있어야 한다') const rest = src.slice(at) const end = rest.indexOf('\n /** 보존 정리 주기 기동') return rest.slice(0, end === -1 ? 4000 : end) } test('선언이 없으면 아무것도 지우지 않는다 — 기본값은 없음이다', () => { /* 계약의 기본값 — 여기에 숫자가 박히면 아무도 정하지 않은 채로 지워진다. */ assert.match( src, /static JOURNAL_RETENTION_DAYS\?: number = undefined/, '보존 기간의 기본값은 undefined 여야 한다(알리지 않고 지우는 것보다 알리지 않고 쌓는 편이 낫다)' ) /* 그리고 그 값이 없으면 즉시 돌아간다. */ assert.match(pruneBody(), /if \(!days \|\| days <= 0\) return/, '선언이 없으면 지우지 않고 돌아간다') }) test('★ 체크포인트는 상한이지 전제 조건이 아니다 — 없다는 이유로 건너뛰지 않는다', () => { const body = pruneBody() /* * 한 줄이 이 규율의 전부다. 있으면 그 리비전이 상한이고, 없으면 상한이 없다. * * 예전에는 없으면 그 트윈을 건너뛰었다. 그래서 멈춘 트윈 셋에 2,900만 행이 영구히 남았다 — * 멈춘 트윈은 체크포인트를 다시 쓸 주체가 없어서 만료된 뒤 영원히 조건을 못 넘긴다. */ assert.match( body, /const upTo = at > 0 \? at : Number\.MAX_SAFE_INTEGER/, '체크포인트가 없으면 상한 없이 지운다' ) assert.doesNotMatch(body, /if \(!upTo\)[\s\S]{0,200}?continue/, '없다는 이유로 건너뛰지 않는다') assert.doesNotMatch(body, /skipped/, '건너뛰는 갈래가 아예 없다') }) test('지우는 조건이 넷 다 있다 — 하나라도 빠지면 지나친 삭제가 된다', () => { const body = pruneBody() for (const [what, re] of [ ['도메인', /domain_id = :domainId/], ['인스턴스', /instance_id = :instanceId/], ['체크포인트 리비전 이하(도는 트윈에서는 상한, 멈춘 트윈에서는 무한)', /revision <= :upTo/], ['보존 기간 이전(실제 시계)', /created_at < :cutoff/] ] as [string, RegExp][]) { assert.match(body, re, `${what} 조건이 있어야 한다`) } /* 시각을 모르는 옛 행은 「오래됐다」로 읽지 않는다. */ assert.match(body, /created_at IS NOT NULL/, '시각이 빈 행은 지우지 않는다') /* * **`event_time` 을 기준으로 쓰지 않는다** — 그것은 트윈의 시계다(실측: order-check 의 가장 늦은 * eventTime 이 2027-06-08 로 실제보다 10개월 앞). 보존은 저장 나이의 문제이고 그것은 실제 시계다. */ assert.doesNotMatch(body, /event_time\s*<\s*:cutoff/, '도메인 시각을 보존 기준으로 쓰지 않는다') }) test('도메인의 정책이 프로세스 기본값보다 먼저다 — 그리고 엔진은 그것이 어디서 오는지 모른다', () => { const body = pruneBody() /* * 보존 기간은 **테넌트의 정책**이다(한 고객은 30일, 다른 고객은 7일). 그래서 도메인에 먼저 묻고, * 답이 없을 때만 프로세스 기본값으로 내려간다. 순서가 뒤집히면 설정 화면에서 고친 값이 먹지 않는다. */ assert.match(body, /this\.retentionDaysOf\(domainId\)/, '도메인에 먼저 묻는다') assert.match(body, /perDomain \?\? this\.JOURNAL_RETENTION_DAYS/, '도메인 답이 없을 때만 프로세스 기본값이다') /* * 그리고 그 답이 **어디서 오는지 엔진은 모른다** — 시임일 뿐이다. 엔진이 `Setting` 을 직접 읽으면 * 커널의 호스트가 화면 관심사를 알게 된다(방향이 거꾸로다). */ assert.doesNotMatch(src, /from '@things-factory\/setting-base'/, '엔진은 Setting 을 알지 않는다') }) test('★ 보존 기간을 스냅샷 TTL 로 올리지 않는다 — 두 값은 다른 것을 정한다', () => { /* * 예전에는 `max(보존 기간, 스냅샷 TTL)` 이었다. 둘 다 7일이라 상향은 없었지만, 그 식은 두 값을 묶어 * 놓아서 보존 기간을 줄이면 스냅샷 TTL 이 따라오게 만든다. * * 보존 기간 이력을 며칠 두나 * 스냅샷 TTL 빠르게 세우기 위한 저장본이 며칠 사나 * * 저장본이 만료되어도 트윈은 보관된 구간에서 세워진다. 그래서 보존 기간이 더 짧아도 성립한다. */ const body = pruneBody() assert.doesNotMatch(body, /Math\.max\(days, minDays\)/, '두 값을 묶지 않는다') assert.match(body, /const effectiveDays = days/, '선언한 보존 기간을 그대로 쓴다') }) test('★ 멈출 때는 만료 없이 적는다 — 그러지 않으면 7일 뒤 그 상태를 아는 것이 없다', () => { /* * 멈춘 트윈은 아무도 스냅샷을 다시 쓰지 않아서 7일 뒤 만료된다. 멈추는 순간이 그 상태가 메모리에 * 있는 마지막 순간이므로 그때 만료 없이 적는다. 그러면 다시 뜰 때 보관된 구간을 계산하지 않아도 된다. */ assert.match(src, /static SNAPSHOT_KEEP_S = /, '만료 없이 두는 기간에 이름이 있어야 한다') const at = src.indexOf('static async stop(') assert.ok(at > 0, 'stop 이 있어야 한다') const stopBody = src.slice(at, at + 1600) assert.match(stopBody, /persistSnapshot\(domainId, id, true\)/, '멈출 때는 만료 없이 적는다') assert.match(src, /keepForever \? this\.SNAPSHOT_KEEP_S : this\.SNAPSHOT_TTL_S/, '그 값이 실제로 쓰인다') }) test('지운 것을 말한다 — 알리지 않고 줄어든 저널은 없었던 일과 구별되지 않는다', () => { const body = pruneBody() assert.match(body, /journal pruned/, '지운 건수를 로그에 남긴다') assert.match(body, /revision ≤/, '어디까지 지웠는지 함께 남긴다') }) test('raw SQL 을 쓰지 않는다 — 드라이버 다섯을 다 지나야 한다', () => { const body = pruneBody() assert.doesNotMatch(body, /\.query\(/, '드라이버 방언을 직접 적지 않는다') assert.match(body, /createQueryBuilder\(\)[\s\S]{0,80}\.delete\(\)/, '조건 삭제는 쿼리빌더로 한다') }) test('선언도 없고 물을 길도 없으면 주기 정리를 걸지 않는다', () => { const at = src.indexOf('static startRetentionLoop') assert.ok(at > 0, 'startRetentionLoop 이 있어야 한다') const body = src.slice(at, at + 900) /* * 조건이 **둘**이다. 프로세스 기본값(`JOURNAL_RETENTION_DAYS`)만 보면 안 된다 — 도메인마다 다른 값을 * 앱이 답하는 시임(`retentionDaysOf`)이 있고, 그것이 심겨 있으면 기본값이 없어도 걸어야 한다 * (그 도메인이 자기 값을 가질 수 있다). 반대로 **둘 다 없으면 물을 곳이 없으므로** 걸지 않는다. * * 기본값만 보게 두면 시임만 심은 배포에서 정리가 알리지 않고 돌지 않는다 — 「보존을 켰는데 왜 안 줄어드나」. */ assert.match( body, /if \(!this\.JOURNAL_RETENTION_DAYS && !this\.retentionDaysOf\) return/, '기본값도 없고 도메인 시임도 없을 때만 타이머를 걸지 않는다' ) /* 타이머가 프로세스를 붙잡지 않게 — 지우는 일이 종료를 막을 이유가 없다. */ assert.match(body, /unref/, '타이머는 unref 한다') }) /* * ★ 나눠서 지운다 — 한 문장으로 통째 지우면 그동안 호스트가 멈춘다. * * 실측(2026-08-25 · 개발 환경 44GB): 보관 기간 7일에 13일치가 쌓여 있었고, 그 밀린 몫이 한 번에 * 나왔다. 한 번의 지우기가 74초·79초였고 그 뒤에 한 줄짜리 `domains` 조회가 67초로 찍혔다 — 그 * 질의의 문제가 아니라 줄을 선 값이다. sqlite 는 쓰기가 하나다. * * 이 테스트는 소스를 전수 조회해 규율을 지킨다: 배치 경계를 먼저 찾는가 · 한 창의 시간 상한이 있는가 · * 배치 사이에 루프를 비워 주는가. 실제 지우기는 큰 기록이 있어야 재현되므로 여기서 재지 않는다. */ test('★ 지우기를 배치로 나눈다 — 쓰기 잠금을 한 번에 오래 잡지 않는다', () => { const src = readFileSync(ENGINE, 'utf8') assert.ok(/JOURNAL_PRUNE_BATCH\s*=\s*[0-9_]+/.test(src), '한 배치의 행 수가 이름을 갖고 있어야 한다') assert.ok(/JOURNAL_PRUNE_BUDGET_MS\s*=\s*[0-9_]+/.test(src), '한 창의 시간 상한이 이름을 갖고 있어야 한다') const prune = src.slice(src.indexOf('static async pruneJournal')) const body = prune.slice(0, prune.indexOf('static startRetentionLoop')) assert.ok(body.includes('JOURNAL_PRUNE_BATCH'), '배치 경계를 찾는 데 그 수를 쓴다') assert.ok(body.includes('JOURNAL_PRUNE_BUDGET_MS'), '한 창의 시간 상한을 본다') assert.ok(/setImmediate/.test(body), '배치 사이에 루프를 비워 준다 — 없으면 나눈 뜻이 없다') /* `DELETE … LIMIT` 은 드라이버마다 다르다 — 경계 `revision` 을 찾아 그 아래만 지운다. */ assert.ok(!/delete[\s\S]{0,200}limit\(/i.test(body), 'DELETE 에 LIMIT 을 붙이지 않는다') }) /* * ★ 앞부분이 지워진 것을 목록이 말한다. * * 정리가 오래된 사건을 지우면 목록을 아무리 내려가도 어느 지점에서 기록이 끝난다. 화면이 그것을 말하지 * 않으면 사람은 「이 트윈은 그때 시작했다」로 읽는다. 없었던 것과 지워진 것은 다른 사실이다. */ test('★ 남아 있는 가장 오래된 리비전을 목록이 함께 낸다 — 지워진 것을 없었던 것으로 보이지 않게', () => { const type = readFileSync( join(import.meta.dirname, '..', 'server', 'service', 'twin-event', 'twin-event-type.ts'), 'utf8' ) assert.match(type, /oldestRevision\?: number/, '목록 응답에 그 칸이 있어야 한다') assert.match(type, /retention policy/, '무슨 뜻인지 설명이 있어야 한다(화면이 문구를 만든다)') const query = readFileSync( join(import.meta.dirname, '..', 'server', 'service', 'twin-journal', 'twin-journal-query.ts'), 'utf8' ) /* 저장하지 않고 조회할 때 구한다 — 저장한 값은 실제 데이터와 어긋날 수 있다. */ assert.match(query, /orderBy\('e\.revision', 'ASC'\)[\s\S]{0,120}?limit\(1\)/, '가장 작은 리비전 한 행만 읽는다') /* 트윈 하나를 물었을 때만 — 여러 트윈을 합치면 리비전은 트윈마다 따로 세는 번호다. */ assert.match(query, /if \(instanceIds\.length === 1\)/, '트윈 하나일 때만 낸다') assert.match(query, /nextCursor, oldestRevision \}/, '응답에 실어 보낸다') }) /* * ★ 경계를 시각순으로 찾는다 — 그러지 않으면 색인이 쓰이지 않는다. * * ── 무엇이 났나 (2026-08-27 실측 · 개발 파일 54GB · 3,128만 행) ─────────────── * 배치 경계를 `ORDER BY revision` 으로 찾았다. 그러면 계획기가 리비전 색인을 고르고, 지울 대상인지는 * 행마다 `created_at` 을 열어 확인한다. 지울 것이 적으면 그 트윈의 저널을 끝까지 걸어야 「한 배치를 * 못 채웠다」를 알 수 있다 — 1,029만 행에서 그것이 2초 예산을 다 썼고 44건만 지우고 멈췄다. * * 실측한 질의 계획. * * ORDER BY revision SEARCH USING INDEX ix_twin_event_0 ← 행마다 테이블을 연다 * ORDER BY createdAt SEARCH USING COVERING INDEX ix_twin_event_8 ← 테이블을 열지 않는다 * DELETE (정렬 없음) SEARCH USING INDEX ix_twin_event_8 ← 색인만으로 이미 고쳐졌다 * * 그리고 `created_at` 축 색인을 얹었다(`ix_twin_event_8`). 7일 밖 건수 조회가 208초에서 1.68초가 됐다. */ test('★ 배치 경계를 createdAt 순으로 찾는다 — revision 순으로 찾으면 색인을 못 쓴다', () => { const body = pruneBody() assert.match(body, /\.orderBy\('e\.createdAt', 'ASC'\)/, '시각순으로 물어야 색인을 쓴다') assert.doesNotMatch(body, /\.orderBy\('e\.revision', 'ASC'\)/, '리비전 순으로 되돌아가지 않는다') /* 같은 시각의 행이 여럿일 수 있으므로 경계는 `<=` 다 — `<` 로 두면 제자리걸음이 된다. */ assert.match(body, /created_at <= :batchCutoff/, '경계 시각 이하까지 지운다') }) test('★ createdAt 축 색인이 선언돼 있다 — 없으면 정리가 전량 주사한다', () => { const entity = readFileSync( join(import.meta.dirname, '..', 'server', 'service', 'twin-event', 'twin-event.ts'), 'utf8' ) assert.match( entity, /@Index\('ix_twin_event_8', \(e: TwinEvent\) => \[e\.domain, e\.instanceId, e\.createdAt\]/, '(domain, instanceId, createdAt) 색인이 있어야 한다' ) /* 그리고 그것을 만드는 마이그레이션이 배선돼 있어야 한다 — 선언만 있으면 배포에 생기지 않는다. */ const list = readFileSync(join(import.meta.dirname, '..', 'server', 'migrations', 'index.ts'), 'utf8') assert.match(list, /IndexEventCreatedAt1786400000000/, '마이그레이션 목록에 있어야 한다') }) test('★ 한 문장이 짧아야 화면이 오래 멈추지 않는다 — 실측이 그 수를 정한다', () => { /* * ── 무엇이 났나 (2026-08-28 실측) ────────────────────────────────────────── * 배치가 20,000 이었고, 한 문장이 그만큼 지우는 데 4~5.6초였다(서버 기록: hatio-mx2 20,130줄 * 5.2초). sqlite 는 연결이 하나라 그동안 **화면의 모든 질의가 줄을 선다** — 첫 화면 조회가 * 12.2초였다(정상 상태 40밀리초). * * 그리고 예산이 **문장이 끝난 뒤에** 검사되므로 한 문장이 2초 예산을 2~3배 넘겼다. 배치를 나눈 뜻이 * 그만큼 없어진다. * * 고칠 축은 총 시간이 아니라 **한 번에 멈추는 길이**다. 그래서 배치를 줄이고 예산을 늘렸다 — * 지우는 양은 같고 잠금만 잘게 나뉜다. */ const src = readFileSync(ENGINE, 'utf8') const batch = /JOURNAL_PRUNE_BATCH\s*=\s*([0-9_]+)/.exec(src)?.[1]?.replace(/_/g, '') const budget = /JOURNAL_PRUNE_BUDGET_MS\s*=\s*([0-9_]+)/.exec(src)?.[1]?.replace(/_/g, '') assert.ok(batch && budget) /* 줄당 약 0.26밀리초로 재었다 — 한 문장이 1초를 넘지 않아야 한다. */ const perStatementMs = Number(batch) * 0.26 assert.ok(perStatementMs <= 1000, `한 문장이 ${Math.round(perStatementMs)}밀리초로 추정된다 — 1초를 넘으면 화면이 그만큼 멈춘다`) /* 예산이 문장 여럿을 담아야 나눈 뜻이 있다 — 하나만 담으면 예산 검사가 아무 일도 하지 않는다. */ assert.ok(Number(budget) / perStatementMs >= 3, '예산이 한 문장에 삼켜지면 배치를 나눈 뜻이 없다') /* 실측 근거가 코드에 적혀 있어야 한다 — 다음 사람이 다시 재지 않고 바꾸지 않게. */ assert.match(src, /20,130줄/, '무엇을 재서 이 수가 나왔는지 적혀 있어야 한다') })