Skip to content

perf: 변경 폴에서 바뀌지 않은 blob의 git show를 blob OID 캐시로 건너뛴다 - #79

Merged
say8425 merged 8 commits into
mainfrom
perf/blob-oid-cache
Sep 21, 2026
Merged

say8425 merged 8 commits into
mainfrom
perf/blob-oid-cache

Conversation

@say8425

@say8425 say8425 commented Sep 21, 2026

Copy link
Copy Markdown
Owner

--watch에서 파일 하나를 고치면 지문이 바뀌고 getDiffFilesdiff 전체를 다시 만듭니다. 파일마다 old 쪽을 git show <base>:<path>로 다시 읽으므로, 176파일 diff에서 한 파일만 바꿔도 git 프로세스가 176번 떴습니다. 설계 때 실측으로 변경 폴 230ms 중 176ms가 이 old 쪽 읽기였고, new 쪽을 전부 다시 읽는 비용은 4.3ms뿐이었습니다.

old 쪽은 그 blob이 바뀌지 않는 한 바이트 단위로 불변이니, blob을 키로 재사용하면 됩니다.

무엇을

  • 목록을 git diff --raw -z --no-abbrev로 뽑습니다(예전 --name-status). 같은 한 번의 호출에 파일마다 old/new blob OID가 실려 옵니다. 상태 매핑·경로·끝의 --는 그대로입니다.
  • blobCache.ts — OID를 키로 하는 바이트 상한 LRU(기본 64MB). getDiffFiles(repo, opts, blobs)가 선택 인자로 받고, 서버는 핸들러마다 하나를 만들어 넘깁니다(prewarm과 /api/diff가 공유).
  • gitRun — git 헬퍼가 종료 코드도 돌려줍니다.

계약 넷

  1. 키는 전체 OID이고, 값도 그 OID로 읽습니다(git show <oid>). 워킹트리 모드의 base는 HEAD라는 이름이라, 이름으로 키를 잡으면 커밋 직후 옛 내용이 나옵니다. OID는 내용의 해시라 무효화 로직이 없습니다. (처음 구현은 값을 이름으로 읽었고 리뷰에서 결함으로 잡혔습니다 — 아래 "리뷰" 절.) git show <rev>:<path>의 출력이 OID만으로 정해진다는 것은 textconv·eol=crlf·필터가 걸린 경로와 안 걸린 경로에 같은 blob을 두고 확인했습니다. --full-index로는 7자 약어가 나와서 --no-abbrev를 씁니다.
  2. git show의 종료 코드가 0일 때만 저장합니다. 잠깐 못 읽은 빈 결과를 저장하면 영구히 남기 때문입니다. 구현 중 알게 된 것: 워킹트리와 비교할 때 stat이 바뀐 파일은 old blob이 없으면 목록(git diff --raw) 단계에서 먼저 fatal: unable to read로 죽습니다. 그래서 테스트는 목록이 트리 OID만 보는 head 모드에서 재현합니다(stat이 깨끗한 항목은 워킹트리 모드에서도 목록에 나오므로 가드는 두 모드 모두에서 쓰입니다).
  3. OID는 캐시 키로만 씁니다. old 쪽을 읽을지는 지금처럼 파일 상태가 정합니다.
  4. 워킹트리 모드의 new 쪽은 캐시하지 않습니다. 디스크가 진실이고, 전부 읽어도 싸기 때문입니다.

서브모듈(gitlink, 모드 160000)의 OID는 blob이 아니라 커밋이라 키로 쓰지 않습니다. payload 캐시·지문·etag·selectionCacheKey는 손대지 않았습니다.

실측

176파일 변경 픽스처에서 한 파일만 바꾼 변경 폴 15회의 중앙값, 3라운드(main 워크트리와 이 브랜치를 번갈아).

변경 폴 중앙값 콜드 로드
before (main) 263~283ms 358~375ms
after 58~60ms 361~380ms

변경 폴이 약 4.7배 빨라졌습니다. 콜드 로드는 설계대로 그대로입니다(cat-file --batch로 miss를 모으는 가속은 범위 밖).

TDD — 각 테스트가 잡는 깨짐

테스트 잡는 깨짐 뮤테이션 확인
diff-blob-cache ① old 쪽 재사용 + new 쪽은 디스크 캐시를 안 거치거나 워킹트리 new 쪽까지 캐시 RED에서 hit 0
diff-blob-cache ② 커밋 후 old 쪽이 새 HEAD를 따라감 이름(rev:path)으로 키를 잡은 구현 이름 키로 바꾸면 이 테스트만 실패
diff-blob-cache ③ 실패한 git show는 저장 안 함 (head 모드) 실패 결과를 저장한 구현 종료 코드 조건을 지우면 이 테스트만 실패
diff-blob-cache ④ head 모드 캐시 유무 결과 동일 캐시 경로가 다른 바이트를 냄 RED에서 저장 0
diff-blob-cache ⑤ 목록과 읽기 사이 ref 이동 (워킹트리 HEAD·head 브랜치 2종) 값을 이름으로 읽는 구현 이름으로 읽기로 되돌리면 둘 다 실패
diff-blob-cache ⑥ 전체 OID 키 --no-abbrev 누락 빼면 실패
diff-raw 5종 필드 어긋남·rename 경로 뒤바뀜·64자 OID 절단·gitlink OID를 키로 씀 입력은 git 2.55 실제 출력 리터럴
blob-cache 6종 퇴출 순서·상한 계산·덮어쓰기 이중 계산
diff-server 배선 2종 서버가 캐시를 안 넘김 워킹트리·base 모드 호출을 하나씩 빼면 각자의 테스트만 실패

계획은 배선 테스트 하나가 두 호출을 다 지킨다고 적었는데 틀렸습니다(기본 요청은 워킹트리 모드만 지나감). 그래서 base 모드 테스트를 따로 더했습니다.

리뷰

Critical 1 · Important 2 · Minor 3. 전부 반영했습니다.

  • Critical — 키는 OID인데 값은 이름으로 읽었습니다. 목록과 git show HEAD:<path> 사이에 ref가 움직이면(watch 중의 커밋·체크아웃·리베이스, head로 보는 브랜치의 전진) 새 커밋의 내용이 옛 OID 아래 저장되고 세션 내내 남았습니다. 캐시 전엔 다음 폴에 저절로 회복되던 경합이 영구 오염이 된 것이라, 스펙의 "낡을 가능성 0"이 틀렸습니다. 이제 git show <oid>로 읽습니다(git show <rev>:<path>·git cat-file blob과 md5 동일 — textconv·eol=crlf·필터 경로에서 확인). 처음 miss가 날 때 ref를 움직이는 래퍼 캐시로 경합을 결정론적으로 재현하는 테스트 2종을 더했고, 이름 읽기로 되돌리면 둘 다 죽습니다. 캐시 없이도 이제 목록이 본 바로 그 내용을 읽습니다.
  • Important — --no-abbrev 계약을 지키는 테스트가 없었습니다. 전체 OID 키 테스트를 더했습니다.
  • Important — 낡은 호출처 서술. 파일별 버스트는 이제 readBlob인데 주석·docblock·CLAUDE.md가 showBytes·name-status를 가리키고 있었습니다. 전부 고쳤습니다.
  • Minor — 계약 ② 문구 과장(stat이 깨끗한 항목은 워킹트리 모드에서도 목록에 나옴), gitlink OID 캐시, "C 레코드는 안 나온다"는 주석(diff.renames=copies면 나옴).

리뷰 반영 뒤: bun test 817 pass, 커버리지 100%, lint·format·typecheck 통과.

테스트 플랜

  • bun test — 813 pass / 0 fail
  • bun run test:coverage — 100% (blobCache.ts·diff.ts·gitOutput.ts·server.ts 포함)
  • bun run lint · format:check · typecheck 통과
  • e2e 전체 로컬 2회: 1차 110 passed / 1 failed(grab.e2e.ts ⑪, 전제 조건의 boundingBox()가 null — find가 다른 파일로 스크롤한 직후의 가상화 타이밍으로 보임), 2차 111 passed. ⑪ 단독 5회 모두 통과. 이 스펙은 첫 로드만 써서 캐시 hit 경로를 지나지 않습니다.
  • CI 6잡

🤖 Generated with Claude Code

https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK

say8425 and others added 8 commits September 21, 2026 18:46
변경 폴의 재빌드가 바뀌지 않은 blob을 다시 `git show`하지 않게 할 캐시.
키가 내용의 해시라 무효화가 없고, 상한(기본 64MB)을 넘으면 오래된 것부터
버린다. 상한보다 큰 항목은 저장하지 않는다. 아직 아무도 쓰지 않는다 —
다음 커밋들이 `getDiffFiles`와 서버에 배선한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK
blob 캐시가 실패한 `git show`(빈 출력)를 저장하지 않으려면 빈 파일과 실패를
갈라야 한다. `gitRun`이 stdout과 종료 코드를 함께 주고, `gitBytes`는 그 위에서
지금과 같은 동작(종료 코드 무시)을 유지한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK
`git diff --name-status -z`를 `git diff --raw -z --no-abbrev`로 바꾼다. 같은
한 번의 호출에 파일마다 old/new blob OID가 실려 온다 — 다음 커밋의 blob
캐시가 이것을 키로 쓴다. 상태 매핑·경로·끝의 `--`는 그대로라 동작은 같다.

`--no-abbrev`가 계약이다: `--full-index`로는 7자 약어가 나온다(실측). 없는
쪽 OID(0)는 null로 둔다. `buildFile`은 필드가 늘어 spec 객체를 받는다.
파서 테스트의 입력은 git 2.55의 실제 출력(A·D·M·R·T, SHA-256)을 옮긴 것이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK
`getDiffFiles(repo, opts, blobs)`가 선택 인자로 blob 캐시를 받는다. old 쪽과
head 모드의 new 쪽은 `--raw`가 준 OID로 캐시를 먼저 보고, 없으면 지금과 같은
`git show`로 읽는다. 워킹트리 new 쪽은 캐시하지 않는다(디스크가 진실).

- 키는 전체 OID — 이름으로 잡으면 커밋 직후 옛 내용을 낸다.
- `git show`의 종료 코드가 0일 때만 저장한다. 워킹트리 비교에선 old blob이
  없으면 `git diff --raw`가 먼저 죽으므로(실측), 이 가드가 실제로 지키는 곳은
  목록이 blob을 읽지 않는 head 모드다 — 테스트도 그 모드에서 재현한다.
- OID는 캐시 키로만 쓴다. old 쪽을 읽을지는 지금처럼 상태가 정한다.

회귀망 `diff-blob-cache.test.ts` 4종. 이름 키 뮤테이션과 실패 저장 뮤테이션을
각각 정확히 한 테스트가 잡는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK
`createHandler`가 `diffCache` 옆에 blob 캐시를 하나 만들어 `/api/diff`의 두
`getDiffFiles` 호출(워킹트리 모드·base 모드)에 넘긴다. prewarm과 `/api/diff`가
공유한다. `startDiffServer`에 테스트용 `blobCache` 이음새를 둔다
(`flightTimeoutMs`·`cwdDeps`와 같은 종류).

배선 테스트는 두 모드를 따로 찌른다 — 한 모드만 보면 다른 호출에서 캐시를
빼도 초록이다. 호출을 하나씩 빼는 뮤테이션으로 각각 한 테스트만 죽는 것을
확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK
키는 전체 OID(`--no-abbrev`, 이름 금지), 종료 코드 0만 저장(가드가 실제로
지키는 곳은 목록이 blob을 읽지 않는 head 모드 — 워킹트리 비교는 old blob이
없으면 목록이 먼저 죽는다), OID는 키로만, 워킹트리 new 쪽은 캐시 안 함.
회귀망과 각 테스트가 잡는 뮤테이션도 함께 적는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK
리뷰(Critical): 키는 목록(`git diff --raw`)이 준 OID인데 값은
`git show HEAD:<path>`처럼 이름으로 읽고 있었다. 목록과 읽기 사이에 ref가
움직이면(watch 중의 커밋·체크아웃·리베이스, head로 보는 브랜치의 전진) 새
커밋의 내용이 옛 OID 아래 저장되고 세션 내내 남는다 — 캐시 전엔 다음 폴에
저절로 회복되던 경합이 영구 오염이 된다. 스펙의 "낡을 가능성 0"은 틀렸다.

- OID가 있으면 `git show <oid>`로 읽는다. 출력은 `git show <rev>:<path>`와
  바이트 단위로 같다(textconv·eol·필터 경로에서도 md5 동일 — 실측).
  캐시가 없어도 목록이 본 바로 그 내용을 읽게 된다.
- 서브모듈(gitlink, 160000)의 OID는 커밋이라 키로 쓰지 않는다.

회귀망: 처음 miss에서 ref를 움직이는 래퍼 캐시로 경합을 결정론적으로 만든다
(워킹트리 HEAD 이동, head 브랜치 전진 둘). 이름으로 읽기로 되돌리면 둘 다
죽는다. `--no-abbrev` 계약을 지키는 테스트가 없던 것(리뷰 Important)도 전체
OID 키 테스트로 메운다 — 빼면 죽는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK
- 파일별 버스트는 이제 `readBlob`(`gitRun`)이고 `showBytes`는 `/api/blob`만
  맡는다. `diff.ts`·`gitOutput.ts` 주석, `diff-large-blob.test.ts` docblock,
  CLAUDE.md "Loading…" 항목의 호출처 목록을 그에 맞춘다.
- `name-status`를 가리키던 테스트 이름·docblock·픽스처 주석을 `diff --raw`로
  (이력 서술은 살리고 옛 파서 이름만 현재 이름과 잇는다).
- 계약 ②: "워킹트리 비교는 old blob이 없으면 목록이 먼저 죽는다"는 stat이
  바뀐 파일에만 맞다 — stat이 깨끗한 항목은 목록에 나온다(리뷰 실측).
- 계약 ①: 값도 OID로 읽는다는 것, 그리고 이게 한때 계획의 결함이었다는 것을
  적는다. gitlink 규칙도 적는다.
- C(copy) 주석: `diff.renames=copies`면 -C 없이도 나온다.
- 테스트 헬퍼의 템플릿 문자열에 Buffer를 그대로 넣던 것(lint)을 고친다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK
@say8425
say8425 merged commit 35f3b0b into main Sep 21, 2026
6 checks passed
@say8425
say8425 deleted the perf/blob-oid-cache branch September 21, 2026 10:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant