Skip to content

cold-start summary 중복 합산 버그 + dedup 경로 효율화 여지 (Claude Code jsonl 저장 방식 분석 포함) #9

Description

@korjwl1

요약

cold-start 집계 경로에서 출력 summary가 dedup 이전 값으로 합산되는 정확성 버그 1건과, dedup 설계 관련 효율화 여지 3건을 정리한다. 배경 이해를 위해 이 모든 문제의 근원인 Claude Code의 jsonl 저장 방식도 함께 기록한다.

  • 영향 범위: cold-start 초기 스캔 시 emit_summary로 출력되는 값. (쿼리 리포트는 DB에서 읽으므로 정상)
  • 분석 기준: toki v2.1.0, Claude Code 네이티브 v2.1.169

배경: Claude Code의 데이터 저장 방식 (왜 dedup이 필요한가)

toki의 dedup 설계 의도를 정확히 이해하려면 원본 데이터가 왜 중복을 갖는지부터 짚어야 한다. Claude Code 바이너리(v2.1.169, Bun 컴파일 단일 실행파일)를 추출해 확인한 사실:

1) 한 응답이 content block 수만큼 jsonl 줄로 쪼개진다

한 API 응답(requestId 1개, message.id 1개)이 thinking + text + tool_use × N 같은 여러 content block을 반환하면, Claude Code는 이를 block마다 별도 assistant 줄로 기록한다. 그리고 각 줄에 전체 응답의 동일한 usage를 그대로 박는다.

  • 쪼개진 줄들은 같은 message.id + 같은 requestId + 동일 usage, uuid만 다르게 parentUuid로 체이닝됨
  • 즉 block들은 jsonl 상에서 항상 연속으로 나타난다 (toki의 인접 기반 최적화 근거가 됨 — 아래 효율화 Add TSDB, daemon/client architecture, UDS report protocol #2 참조)

실측 (실제 세션 1개, assistant 1141줄 / 486 requestId):

합산 방식 cache_read output
단순 줄 합산 (naive) 506,292,368 2,134,414
message.id dedup 215,130,529 834,118
부풀림 배수 2.35× 2.56×

486개 requestId 중 351개가 2줄 이상으로 분할됨 (분포: 2줄 162, 3줄 146, 최대 10줄). tool_use가 많은 턴(병렬 Read/Grep 등)일수록 더 크게 부풀려진다.

2) Claude Code 자체 /usage도 dedup하지 않는다

Claude Code의 /usage 대시보드(캐시: ~/.claude/stats-cache.json)를 그리는 집계 함수는 디스크의 세션 jsonl을 줄 단위로 순회하며 usage를 그대로 누적한다 — message.id 기반 dedup 가드가 전혀 없다. (함수 내 미사용 Set 변수만 선언돼 있고 dedup에 쓰이지 않음.) 같은 바이너리 안에 dedup 키 추출기(message.id 반환)와, context window 계산 시 "마지막 usage만" 취하는 로직은 존재하지만, /usage 집계 경로엔 적용돼 있지 않다. → Claude Code /usage의 토큰 수치는 위 배수만큼 과대계상된다.

3) OTEL export는 정상

OTEL 메트릭(claude_code.token.usage)은 스트리밍 핸들러에서 응답당 1회만 counter에 add된다 (최종 message.usage 기준). transcript를 줄 단위로 도는 별도 기록 함수는 테스트 픽스처 전용 경로라 실런타임에 타지 않는다. → OTEL은 중복 없음. /cost·statusline도 라이브 인메모리 누적기 기반이라 정상.

결론: jsonl을 직접 파싱하는 모든 소비자(toki 포함)는 반드시 message.id 단위 dedup을 해야 한다. toki는 이걸 올바르게 하고 있고(아래), 오히려 Claude Code 자체 /usage보다 정확하다.


toki의 dedup 설계 (현행)

핵심: parser.rsevent_key = "{message_id}:{timestamp}"를 만들고, 실제 dedup은 bare msg_id(: 앞 = msg_XXX) 단위로 "한 msg_id당 마지막 1건만 keep".

db.rs의 버전 히스토리가 설계 의도를 보여준다:

// - v2: added idx_msg keyspace for msg_id dedup (streaming snapshot handling)
// - v3: added message_id to event key, changed dedup to bare_msg_id

content block 분할 줄은 같은 msg_id, 스트리밍 스냅샷은 같은 msg_id에 timestamp만 다르게 여러 번 기록되므로, msg_id:timestamp 단위 dedup으로는 스냅샷이 안 합쳐진다. v3에서 bare msg_id 단위로 바꿔 둘 다 잡도록 한 것. "마지막 1건"이 옳은 이유: 분할 줄은 usage가 전부 동일, 스냅샷은 마지막이 최종값.

dedup이 걸리는 3개 계층:

계층 위치 방식
파일 내 (cold start) engine.rs::dedup_cold_start_events (L902) HashMap<msg_id, last_idx>, 파일 단위 마지막만
배치 내 (watch/live) writer.rs::flush_pending (L198~) flush 배치 안에서 HashMap<&str,usize> 마지막만
DB 영속 (cross-batch) db.rs::insert_event_dedup (L186) idx_msg(bare_msg_id → 최신 event key)로 이전 이벤트 삭제 후 삽입
  • cold start마다 cleanup_old_idx_msg(L470)로 24h 지난 idx_msg 정리. 이 설계 자체는 정확하고 견고하다 (분할·스트리밍·재읽기·세션 fork/resume 중복까지 커버).

🐛 버그: cold-start 출력 summary가 dedup 이전에 합산됨

cold_start_provider에서 into_summary_and_event합산과 이벤트 생성을 한 함수에 묶어 줄마다 호출되어, summary는 분할 줄까지 다 더한 값이 된다. 이벤트는 이후 dedup되어 DB로 가지만 summary는 보정되지 않는다.

engine.rs L194, L255, L279:

// handle_parsed! 매크로 — 파싱된 줄마다 호출 (dedup 전)
file_events.push(parsed.into_summary_and_event(summary, ...));  // summary += 모든 줄
...
let deduped = dedup_cold_start_events(file_events);  // 이벤트만 dedup → DB
...
self.sink.emit_summary(&result_summaries, ...);      // ← dedup 전 누적값을 출력

providers/mod.rs L99~124 — 합산과 이벤트 생성이 한 몸:

pub fn into_summary_and_event(self, summary: &mut ModelUsageSummary, ...) -> ColdStartEvent {
    summary.input_tokens  += self.tokens.input_tokens;     // ← dedup 전 모든 줄 누적
    summary.output_tokens += self.tokens.output_tokens;
    summary.cache_creation_input_tokens += self.tokens.cache_creation_input_tokens;
    summary.cache_read_input_tokens     += self.tokens.cache_read_input_tokens;
    summary.event_count += 1;
    ColdStartEvent { message_id: self.event_key, ... }
}

결과: 초기 스캔 시 찍히는 summary(토큰 합계 + event_count)가 Claude Code /usage와 동일하게 부풀려진다(위 실측 기준 cache_read 2.35×, output 2.56× 수준). DB 기반 쿼리 리포트는 정상이라 사용자가 두 숫자의 불일치를 보게 된다.

참고: 레거시 경로 cold_start(L343/358/380)도 동일 패턴.

수정 방향: 합산과 이벤트 생성을 분리해, dedup 이후의 deduped 이벤트로 summary를 합산한다. 정확성을 잡으면서 중복 줄에 대한 합산 연산도 절약된다.


⚡ 효율화 여지

#1 event_key:timestamp는 사실상 죽은 데이터 (가장 깔끔한 개선)

DB 이벤트 키는 [ts_ms 8B BE][event_key 문자열]인데 event_key가 "msg_XXX:2026-...Z"라서 timestamp가 키에 두 번 들어간다 (앞 8B + 문자열 내부). 그런데:

  • dedup은 bare_msg_id(: 앞)만 사용 → :timestamp는 dedup에 안 쓰임 (db.rs::bare_msg_id L178)
  • ts는 별도 8B prefix에 이미 존재 → 시간 정렬/조회에도 문자열 timestamp 불필요

parser.rs L100/121/142의 format!("{}:{}", message_id, timestamp)로 이벤트당 String을 할당하고, bare_msg_id에서 매번 .split(':')로 도로 떼어내는 게 전부 불필요한 왕복이다. msg_id(bare)와 ts_ms를 따로 전달하면:

  • 이벤트당 String 할당 1회 제거
  • 모든 .split(':') 제거
  • DB 키마다 ISO timestamp(~24B) 제거 → LSM 키스페이스 크기·비교 비용 감소

v2→v3에서 dedup 단위가 event_keybare msg_id로 바뀌며 남은 레거시 잔재로 보인다.

#2 cold-start dedup을 인접 기반 single-pass로

dedup_cold_start_events(L902)는 유니크 이벤트까지 포함해 전부 msg_id.to_string() 할당 → HashMap 구축 → HashSet(indices) → 재필터링의 다중 패스다. 그런데 위 배경 #1에서 보듯 분할 줄·스트리밍 스냅샷은 jsonl에서 항상 연속으로 나타난다. 이 보장을 쓰면 **"인접한 동일 msg_id 런에서 마지막만 keep"**하는 단일 패스로 끝난다 — HashMap·to_string()·HashSet 전부 불필요. 대용량 히스토리 cold start에서 의미 있는 절감.

#3 cold-start의 DB read-back 중복

cold start는 engine에서 이미 파일 단위 dedup을 끝냈는데, writer.rs::flush_bulk_events(L312)도 insert_event_dedup을 호출해 이벤트마다 idx_msg.get + events.get + 조건부 remove를 또 한다 (주석 L310도 "engine already deduped … but idx_msg still needs populating"이라 인정). cold start 한정으로 "plain insert + idx_msg만 채우기" 경로를 분리하면 이벤트당 DB read 2회를 제거할 수 있다. 크로스파일 중복(fork/resume) 안전망은 cold start 후 1회 정리 패스로 대체 가능.

부차: cleanup_old_idx_msg(L470)는 idx_msg 전체 full-scan. 키가 bare msg_id라 시간 range-scan이 불가한 구조적 제약이며, 키를 [ts_ms][msg_id]로 바꾸면 range 삭제가 가능하나 get-by-msg_id가 깨져 secondary index가 필요 — 득실 미묘하여 보류 권장.


우선순위 제안

  1. (정확성) cold-start summary를 dedup 이후로 합산 — 사용자가 보는 숫자 불일치 해소
  2. (효율, 큰 효과·낮은 리스크) event_key의 timestamp 잔재 제거
  3. (효율, 핫패스 미세) cold-start dedup single-pass / DB read-back 제거

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions