Skip to content

Storage Format

Won-Kyu Park edited this page Aug 28, 2026 · 3 revisions

저장 포맷

.srcs 파일이 실제로 어떻게 생겼는지, 그리고 커밋할 때 바이트 단위로 무슨 일이 일어나는지를 다룬다. 이 문서는 System Overview에서 설명한 "역방향 델타" 개념을 코드 레벨로 한 단계 더 들어간 버전이다.

헤더

v2 파일은 첫 줄에 헤더가 있다.

# SimpleRCS v2.0; hash_algo=sha256; encoding=utf-8;

v1은 헤더가 없는 옛날 포맷이다 (아래 "v1과의 차이" 참고).

블록 하나의 모양

블록은 key @value@; 형태의 줄을 여러 개 이어붙인 것이고, 빈 줄 하나로 다음 블록과 구분된다. @ 문자는 값 안에서 @@로 이스케이프된다 — 바이너리 페이로드까지 예외 없이. 그 이유는 아래 "이스케이프와 패리티"에 있다.

ver @1.2@;
date @2026-08-04T12:00:00@;
author @wkpark@;
log @fix typo@;
delta @0a1,1
> new line
@;
prev_hash @9f86d0...@;
hash @a3f5c2...@;

필드는 이런 식으로 채워진다.

  • ver, date, author, log — 항상 있음
  • text — 이 블록이 전체 텍스트를 담고 있을 때 (텍스트 HEAD)
  • delta — 이 블록이 델타(다음 버전에서 이 버전으로 되돌리는 명령)를 담고 있을 때
  • binary — 콘텐츠가 바이너리일 때 (text/delta 대신 씀). <길이>;<인코딩>,<페이로드> 형태. 인코딩 태그는 raw / base64 / base85 셋 중 하나고, 길이는 이스케이프를 마친 뒤 실제로 디스크에 놓인 바이트 수다 (그래야 정방향 파서가 seek 한 번으로 건너뛴다)
  • prev_hash, hash — v2부터. 각각 이전 블록 해시, 이 블록의 해시
  • signature — GPG 서명이 붙어 있으면 여러 줄 반복

이스케이프와 패리티

이스케이프는 인코딩의 성질이 아니라 컨테이너의 성질이다. 값이 무엇이든 @@@가 된다 — base64처럼 알파벳에 @가 없어 결과적으로 no-op인 경우에도 규칙은 똑같이 적용된다. 인코딩 태그는 "바이트를 어떻게 인코딩했나"만 말하고 이스케이프 여부는 말하지 않는다.

이렇게 해야 얻는 게 하나 있다. 값 안의 모든 @가 짝수 개로 나타나므로, 어떤 @ 런이 홀수면 그건 구분자다. 파서가 왼쪽에서든 오른쪽에서든 필드 경계를 확정적으로 판정할 수 있다는 뜻이고, 파일 끝에서 뒤로 스캔해 HEAD를 찾는 코드가 이 규칙에 기대고 있다.

base85@를 알파벳에 포함하고 raw는 임의 바이트를 그대로 담기 때문에, 이 규칙이 없으면 페이로드가 블록 구조를 흉내 낼 수 있다.

해시는 뭘 대상으로 계산하나

여기가 좀 미묘한 지점이다. 해시는 저장된 델타가 아니라 그 버전의 논리적 전체 텍스트를 대상으로 계산한다 (codec.calculate_block_hash). 페이로드는 ver|date|author|log|<전체텍스트>|<이전해시> 순서로 이어 붙여서 해시한다.

왜 이렇게 하냐면 — HEAD였던 블록이 다음 커밋에서 델타로 바뀌어도, 그 블록의 해시값 자체는 바뀌면 안 되기 때문이다. 저장 표현이 바뀌어도 "이 버전이 실제로 담고 있던 내용"은 그대로니까, 해시도 그대로 유지돼야 체인이 끊기지 않는다.

커밋할 때 디스크에서 일어나는 일

commit()은 두 블록을 만든다.

  1. 지금 HEAD를 델타 블록 형태로 다시 인코딩한다 (is_delta=True_format_block 호출)
  2. 새 콘텐츠를 전체 텍스트 블록으로 인코딩한다 (is_delta=False)

그 둘을 파일에 앉히는 방식은 백엔드에 따라 갈린다 (_rewrite_head).

파일 경로 백엔드 — temp + os.replace

  1. HEAD 블록이 시작하는 위치 앞까지, 즉 바뀌지 않는 prefix를 옆에 만든 임시 파일로 복사한다
  2. 그 뒤에 "재인코딩된 이전 HEAD" + "새 HEAD"를 이어 쓴다
  3. durable이면 임시 파일을 fsync한다
  4. os.replace()로 원본 위에 원자적으로 갈아끼운다
  5. durable이면 디렉토리도 fsync한다

살아있는 데이터를 덮어쓰는 지점이 하나도 없다. 커밋이 도중에 끊기면 잃는 것은 그 커밋이지 이력이 아니다. 역델타 구조에서는 이게 중요한데, 과거 버전이 전부 HEAD를 앵커로 삼기 때문에 HEAD를 반쯤 덮어쓰면 그 앞의 버전이 동시에 복원 불가가 되기 때문이다.

RCS도 같은 방식(temp + rename)을 쓰지만 커밋마다 ,v 전체를 다시 직렬화한다. RCS는 HEAD를 파일 에 두기 때문에 커밋하면 뒤가 전부 밀려서다. 우리는 HEAD가 라 바뀌지 않는 부분이 prefix로 남고, 그건 파싱도 재직렬화도 없는 원시 바이트 복사로 끝난다. 자세한 근거와 실측은 Plan Atomic Commit에 있다.

메모리·호출자 소유 스트림 백엔드

BytesIO나 호출자가 넘긴 파일 객체는 갈아끼울 경로가 없으므로 예전 방식대로 제자리에서 다시 쓴다.

self.stream.seek(self.head_info["start"])
self.stream.write(payload)
self.stream.truncate()

BytesIO는 프로세스가 죽으면 어차피 사라지므로 내구성 요구가 없고, 호출자 스트림은 핸들의 수명을 호출자가 소유한다.

durable

SimpleRCS(path, durable=True)가 기본값이고, 임시 파일과 디렉토리를 fsync한다. 비용을 지배하는 건 복사가 아니라 이 fsync다 — 대량 커밋 (벤치마크, 마이그레이션)에서는 durable=False로 끌 수 있다.

정리하면: 매 커밋마다 마지막 블록 하나만 바뀌고, 그 앞의 모든 이력은 바이트 그대로 유지된다. 파일 경로 백엔드에서는 그 "유지"가 원본을 놔둔 채 복사본을 만드는 형태로 이뤄진다.

스냅샷

commit(..., snapshot=True)를 쓰면 3번 단계에서 이전 HEAD를 델타 대신 전체 텍스트로 저장한다. 그러면 그 지점에서 델타 체인이 끊긴다 — 그 버전을 읽을 때 델타를 하나도 안 거치고 바로 전체 텍스트를 읽을 수 있다는 뜻이다. 히스토리가 아주 길어질 게 예상되는 지점에서 미리 찍어두면 과거 조회 성능을 그 구간만큼 확보할 수 있다. 다만 자동으로 찍히는 게 아니라 호출하는 쪽에서 선택해야 한다.

과거 버전을 읽을 때

checkout(ver)는 HEAD에서 시작해서 목표 버전을 찾을 때까지 뒤로 한 블록씩 걸어가며 델타를 적용한다 (_get_prev_block). 스냅샷 블록을 만나면 그 지점에서 바로 점프한다. 파일 전체를 메모리에 올리지 않고 필요한 블록만 순서대로 읽는다.

v1과의 차이 (참고용, 읽기 전용 지원만 남아있음)

v1은 실제로 배포된 적 없는 초기 포맷이다 — 헤더가 없고, 델타 블록도 그냥 text 키를 썼다(v2의 delta 키 구분이 없음). 해시체인도, GPG 서명도 없다. simple_rcs.pyself._version < 2 분기로 읽기 지원만 남아 있다. v1→v2 마이그레이션 도구는 저장소 히스토리에는 남아있지만 (add-then-revert 패턴으로 커밋 0c7783e/90f2706) 현재 트리에는 없다.

텍스트/바이너리 델타 포맷

  • 텍스트: RCS 스타일 diff -n 스크립트. 사람이 봐도 읽을 수 있는 add/delete 명령 목록이다.

  • 바이너리: BSDIFF40 호환 패치. raw/base64/base85 중 하나로 인코딩해 블록 안에 넣는다. 포맷이 표준이라 시스템에 설치된 bsdiff/bspatch 도구와도 상호운용된다. raw는 RCS식으로 바이트를 그대로 두고 이스케이프에 맡기는 방식이라 오버헤드가 base64의 33% 대비 ~0.4%다.

    이건 우리 저장 포맷이고 git의 바이너리 패치와는 호환되지 않는다 (git은 pack-delta → zlib → base85 3층). git이 읽을 수 있는 형태가 필요하면 srcs_diff --binarysimple_rcs/gitpatch.py를 통해 GIT binary patch 블록을 따로 출력한다 — CLI Tools 참고.

실측 특성

리비전마다 전체 스냅샷을 찍는 게 아니기 때문에, 파일 전체 크기는 "리비전 수 × 페이지 크기"가 아니라 "델타 크기 합 + 전체 텍스트 하나"에 가깝다. 한 줄만 고친 커밋은 보통 수백 바이트짜리 델타 하나만 늘어난다. 이 특성을 DB에 저장할 때 실제로 어떻게 활용할 수 있는지는 Wiki Backend Design에 실측과 함께 정리해뒀다.

Clone this wiki locally