/* * ★ 저널을 시간으로 거르면 **시간으로 정렬한다** — 축이 어긋나면 상한이 무력해진다. * * ── 무엇을 재서 알았나 (2026-08-22) ───────────────────────────────────────── * 서버가 간헐적으로 아주 느렸다. 원인은 저널 질의 둘이었고, 둘 다 같은 모양이었다: **거르는 축은 * `eventTime`(시간창)이고 정렬 축은 `revision`.** * * 그러면 sqlite 는 조건에 맞는 행을 **전부 찾아 전부 정렬한 뒤** 상한을 적용한다. 상한이 정렬 **뒤에** * 걸리므로 아무것도 줄여 주지 않는다. 실측(인스턴스 하나가 375만 행): * * 성과 폴드 ORDER BY revision LIMIT 20000 → 32.2초 · LIMIT 5000 → 28.3초 · 2000 → 28.2초 * ORDER BY eventTime, revision LIMIT 20000 → 0.41초 (78배) * 저널 목록 ORDER BY revision DESC 200건 → 33.4초 * ORDER BY eventTime DESC, revision 200건 → 0.007초 (4,700배) * * **상한을 낮추는 것으로는 고쳐지지 않는다** — 위 숫자가 그것을 말한다. 축이 원인이다. * * 계획도 그렇게 말한다: 어긋나면 `USE TEMP B-TREE FOR ORDER BY`(전체 정렬), 맞으면 * `... FOR LAST TERM OF ORDER BY` — 시각은 인덱스(`ix_twin_event_1`)에서 순서대로 나오고 동시각만 * 임시 정렬한다. 즉 범위를 훑다가 상한에서 멈춘다. * * ── 이 가드가 지키는 것 ───────────────────────────────────────────────────── * 사람은 이 어긋남을 **눈으로 못 본다.** 두 줄이 멀리 떨어져 있고, 작은 저널에서는 밀리초라 시험도 * 초록이다. 규모에서만 드러나고, 그때는 「서버가 느리다」로만 보인다. * * 그래서 **소스를 훑어** 잡는다: 저널을 시간으로 거르는 질의가 시간으로 정렬하지 않으면 붉어진다. * 어휘를 세지 않고 **짝**을 본다 — 한 질의 안에서 시간 조건과 정렬 축이 함께 있는지. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' /** 저널을 시간으로 거르는 표현 — TypeORM 의 두 방식(객체 조건·쿼리빌더). */ const TIME_FILTER = /\beventTime\s*[:=]\s*(Between|LessThan|LessThanOrEqual|MoreThan|MoreThanOrEqual)\(|['"`][^'"`]*\beventTime\b[^'"`]*(BETWEEN|<=|>=|<|>)/ /** 정렬 축 — `order: { … }` 와 `orderBy('e.…')` 양쪽. */ const ORDER_AXIS = /order(?:By)?\s*[:(]\s*[{'"`]?\s*(?:'?e\.)?(\w+)/g function walk(dir: string, out: string[] = []): string[] { for (const name of readdirSync(dir)) { if (name === 'node_modules' || name.startsWith('.')) continue const full = join(dir, name) if (statSync(full).isDirectory()) walk(full, out) else if (name.endsWith('.ts') && !name.endsWith('.d.ts')) out.push(full) } return out } /** 한 질의로 보이는 덩어리 — 저널 저장소를 부른 뒤 그 문장이 끝날 때까지. */ function journalQueries(src: string): string[] { const out: string[] = [] const re = /(?:getRepository|getReadRepository)\(\s*TwinEvent\s*\)/g let m: RegExpExecArray | null while ((m = re.exec(src))) { /* 그 호출부터 다음 빈 줄까지를 한 질의로 본다 — 이 저장소의 질의는 그 안에서 끝난다. */ const rest = src.slice(m.index) const end = rest.search(/\n\s*\n/) out.push(rest.slice(0, end === -1 ? 1200 : Math.min(end, 1200))) } return out } test('저널을 시간으로 거르는 질의는 시간으로 정렬한다 — 상한이 살아 있게', () => { const offenders: string[] = [] for (const file of walk(join(import.meta.dirname, '..', 'server'))) { const src = readFileSync(file, 'utf8') if (!src.includes('TwinEvent')) continue for (const q of journalQueries(src)) { if (!TIME_FILTER.test(q)) continue /* * **상한이 있는 질의만 본다.** 이 결함은 「상한이 정렬 뒤에 걸려 무력해지는 것」이다. 상한이 없는 * 질의(재개 시 체크포인트 뒤를 전부 계산하는 경로)는 어차피 전부 읽어야 하므로 정렬 축을 바꿔도 * 얻는 것이 없다 — 그 자리를 붉히면 **거짓 빨강**이고, 그런 가드는 없는 것보다 나쁘다. */ if (!/\b(take|limit)\s*[:(]/.test(q)) continue const axes: string[] = [] let a: RegExpExecArray | null const re = new RegExp(ORDER_AXIS.source, 'g') while ((a = re.exec(q))) axes.push(a[1]) if (!axes.length) continue /* 정렬이 없으면 이 결함이 아니다 */ /* 첫 정렬 축이 시간이어야 한다 — 뒤에 `revision` 이 오는 것은 동시각 가름이라 정상이다. */ if (axes[0] !== 'eventTime') { offenders.push(`${file.replace(/^.*\/server\//, 'server/')} — 시간으로 거르면서 '${axes[0]}' 로 정렬`) } } } assert.deepEqual( offenders, [], '시간창으로 거르면서 다른 축으로 정렬하면 sqlite 가 전량 정렬한 뒤 상한을 적용한다 ' + '(실측 32.2초 대 0.41초). 정렬을 `eventTime` 먼저로 두고 동시각 가름을 위해 `revision` 을 뒤에 두라.\n' + offenders.join('\n') ) }) test('가드가 실제로 잡는다 — 거짓 초록을 내지 않는지 확인한다', () => { /* * 가드는 스스로를 증명해야 한다. 이 저장소에서 실제로 고친 두 모양을 그대로 넣어 **잡히는지** 본다. * 잡지 못하는 가드는 없는 것보다 나쁘다(있다는 사실이 안심을 준다). */ const bad = ` const rows = await getReadRepository(TwinEvent) .createQueryBuilder('e') .andWhere('e.eventTime BETWEEN :from AND :to', { from, to }) .orderBy('e.revision', 'ASC') .limit(MAX_EVENTS) ` const good = ` const rows = await getReadRepository(TwinEvent) .createQueryBuilder('e') .andWhere('e.eventTime BETWEEN :from AND :to', { from, to }) .orderBy('e.eventTime', 'ASC') .addOrderBy('e.revision', 'ASC') .limit(MAX_EVENTS) ` const firstAxis = (q: string): string | undefined => { const re = new RegExp(ORDER_AXIS.source, 'g') const m = re.exec(q) return m?.[1] } /* 상한이 없는 질의는 이 결함이 아니다 — 가드가 그것을 지나쳐야 한다. */ const noLimit = ` const rows = await getRepository(TwinEvent).find({ where: { ...baseWhere, eventTime: LessThanOrEqual(new Date(cutoffMs)) }, order: { revision: 'ASC' } }) ` assert.equal(/\b(take|limit)\s*[:(]/.test(noLimit), false, '상한이 없는 질의를 알아본다(지나쳐야 한다)') assert.equal(/\b(take|limit)\s*[:(]/.test(bad), true, '상한이 있는 질의를 알아본다') assert.equal(TIME_FILTER.test(bad), true, '시간 조건을 알아본다') assert.equal(firstAxis(bad), 'revision', '어긋난 정렬을 알아본다') assert.equal(firstAxis(good), 'eventTime', '맞는 정렬을 알아본다') })