-
Notifications
You must be signed in to change notification settings - Fork 3
Write Durability
commit()이 도중에 끊기면 어떻게 되는가, 다른 버전 관리 시스템은 이 문제를
어떻게 푸는가, 우리는 무엇을 해야 하는가에 대한 정리다.
발단은 단순한 질문이었다. 디스크가 꽉 차기 직전에 커밋을 하다가 마지막 부분을 쓰는 중 오류가 나면, 버전 파일을 살릴 수 있나?
살릴 수 있는 경우가 있고 없는 경우가 있는데, 지금 코드는 둘 다 못 살린다. 그리고 살릴 수 있었던 경우가 생각보다 많다는 게 이 문서를 쓴 이유다.
-
commit()은 옛 HEAD를 제자리에서 파괴적으로 다시 쓴다. 되돌릴 기록이 없다. -
write()한 번은 원자적이지 않다. 코드 주석이 그렇다고 전제하고 있는데 사실이 아니고, 실측으로 반례를 만들었다. - 코드 전체에
fsync가 하나도 없다. 다만 이건 RCS와 hg도 마찬가지라 특별히 이상한 건 아니다. - 정말 이상한 건 우리만 파괴적 제자리 쓰기를 한다는 점이다. RCS·git·hg 중 어느 것도 살아있는 데이터를 덮어쓰지 않는다.
- 반대로 비트 플립 관점에서는 우리가 RCS보다 낫다. 커밋할 때 옛 블록을 건드리지 않고, 해시 체인이 손상을 탐지한다.
commit()의 두 번째 이후 커밋 경로다.
# Single write() call: two separate writes would leave the file with an
# overwritten old HEAD but no new HEAD if the process dies in between.
self.stream.seek(self.head_info["start"])
self.stream.write(old_block_bytes + new_block_bytes)
self.stream.truncate()옛 HEAD가 있던 자리로 되돌아가서, 그것을 델타로 강등한 바이트와 새 HEAD의
full text를 이어 쓴다. 주석은 write() 한 번이면 중간에 죽어도 안전하다는
취지인데, 그렇지 않다. 버퍼가 부분적으로 flush될 수 있고, 실제로
그렇게 된다.
RLIMIT_FSIZE로 디스크 부족을 흉내 내서, 300줄 파일에 세 번째 버전을
커밋하다가 잘리는 지점을 옮겨가며 측정했다.
| 끊긴 지점 | commit() |
checkout 1.2 / 1.1 / 1.0 |
verify() |
|---|---|---|---|
| 델타 ~ 새 HEAD 본문 도중 | OSError | 전부 오답 (len 0 / 7 / 14) | KeyError로 죽음 |
| 본문 완료 후 hash 도중 | OSError | 전부 정상 |
False (정상 탐지) |
| 완료 | 정상 | 전부 정상 | True |
첫 줄이 최악이다. 세 버전이 동시에 오답이 된다. 역델타 구조라 구버전은 전부 HEAD 기준 체인이고, HEAD 하나가 깨지면 그 이전 전체가 복원 불가가 되기 때문이다.
그리고 손상을 탐지하라고 있는 verify()가 KeyError로 죽었다. 잘린 블록은
text 필드 없이 파싱되는데 그걸 그대로 인덱싱하고 있었다. 이건
PR #17에서 고쳤다 — 이제
False를 반환한다.
델타 블록까지는 온전히 쓰였고 새 HEAD를 쓰다가 끊긴 경우를 보자. 우리는
RCS의 ci와 달리 작업 파일을 소비하지 않고 그대로 둔다. 그러니 커밋하려던
내용은 디스크에 살아있다.
그렇다면 온전한 델타를 그 내용에 적용하면 이전 버전이 나온다. 실제로 해봤다.
1.1 block (248B) parses: ver=1.1 is_delta=True has_text=True
recover 1.1 from working file + delta : True
recover 1.0 by chaining further back : True
완전히 복구된다. 그런데 SimpleRCS는 그 복구를 하지 않고 checkout()이
조용히 빈 문자열을 반환한다. 데이터가 디스크에 멀쩡히 있는데 못 꺼내는
상황이다.
델타를 쓰다가 끊긴 경우는 다르다. 옛 HEAD의 full text가 반쯤 지워졌고 그것을 되살릴 재료가 없다. 이건 진짜 손실이다.
소스를 직접 읽고 확인한 것만 적는다.
| 라이브 데이터 변형 | 안전장치 | fsync | |
|---|---|---|---|
| RCS |
,v 전체를 temp에 다시 씀 |
temp + rename(), rename 구간 시그널 차단, lock 파일. 실패하면 temp 위치를 알려줌 |
없음 |
| CVS | RCS ,v를 그대로 씀 |
RCS 것을 상속 | 없음 |
| git | 없음 — object/pack 불변 | lockfile(O_CREAT|O_EXCL) + rename + atexit·시그널 정리 |
설정 가능, 기본값이 loose object를 제외 |
| hg | 없음 — revlog append-only | 트랜잭션 저널에 append 직전 offset 기록, 롤백 = truncate | 없음 |
| SimpleRCS | 제자리 tail-rewrite | 없음 | 없음 |
넷 중 셋이 fsync를 안 한다. 우리가 안 하는 게 특별한 게 아니라는 뜻이다. 특별한 건 마지막 줄이다.
transaction.py:438이 저널에 쓰는 건 이게 전부다.
self._file.write(b"%s\0%d\n" % (file, offset))파일 이름과 append 직전의 길이. 롤백은 그 오프셋으로 truncate하면 끝이고 데이터 복사가 0이다. revlog가 append-only라서 가능한 일이다. 모듈 docstring이 설계 의도를 그대로 말한다.
As the whole repository is effectively log-structured, this should amount to simply truncating anything that isn't referenced in the changelog.
content-addressed라 찢어진 오브젝트는 자기 해시 검증에 실패해서 스스로 드러난다. 그냥 버리면 되니 fsync할 이유가 없다. 반면 refs와 index는 fsync한다 — 잃으면 도달 가능성이 사라진다.
우리에게도 해시 체인이 있어서 탐지는 된다. 없는 건 복구이고, git과 결정적으로 다른 점은 우리는 찢어진 데이터를 버릴 수가 없다는 것이다. 그게 이전 버전의 유일한 사본이었으니까.
흔한 오해가 "RCS = reverse delta"인데, 실제로는 둘 다 쓴다. 브랜치를 만든
,v를 직접 열어봤다.
1.2 (trunk HEAD) text @AAA / BBB-v2 / CCC@ <- full text
1.1 (trunk) text @d2 1 / a2 1 / BBB@ <- reverse delta (1.2→1.1)
1.1.1.1 (branch) text @d2 1 / a2 1 / BBB-branch
a3 1 / DDD@ <- forward delta (1.1→1.1.1.1)
trunk는 reverse, 브랜치는 forward다. 이유가 있다. trunk HEAD는 늘 뜨거운 읽기 대상이라 reverse로 두면 공짜로 O(1)이 되고, 브랜치는 덜 읽히니 forward로 둬도 된다. 대신 RCS의 긴 브랜치는 실제로 느려진다 — forward 체인인데 중간 스냅샷 장치가 없어서다.
revlog.py:517의 deltaparent()는 항상 앞쪽 리비전을 가리킨다. 새
리비전은 뒤에 붙기만 하고 기존 데이터는 손대지 않는다. 대가는 체인 길이인데,
이걸 중간 스냅샷과 format.maxchainlen으로 막는 구조다.
이론은 그런데 실제로 재보니 흥미로웠다. 300줄 파일에 401번 커밋한 revlog:
revisions : 401
snapshot : 1 (0.25%) <- lvl-0 하나뿐
deltas : 400 (99.75%)
revision size : 20806 <- 401개 버전 전체가 20KB
tip chainlen : 401
sparserevlog가 켜진 저장소인데도 중간 스냅샷이 하나도 안 생겼다. 순차
편집만 한 선형 히스토리라 델타가 작아서 크기 휴리스틱이 "필요 없다"고 판단한
결과다. 즉 tip을 읽으려면 델타 401개를 적용해야 한다.
RCS : O(파일) 재작성 + 원자성 HEAD O(1)
hg : append-only + 스냅샷 HEAD 체인 walk (실측 401)
SimpleRCS : O(tail) 재작성 + 안전장치 없음 HEAD O(1)
RCS의 전체 재작성은 낭비가 아니라 원자성의 대가다. 어차피 옛 HEAD를 델타로 강등해야 한다면 — 파일 중간을 건드리는 일이다 — 통째로 새로 쓰고 rename하는 게 가장 단순하고, 크래시 안전성이 따라온다.
SimpleRCS는 그 O(파일)을 O(tail)로 줄인 최적화인데, 줄이면서 안전성도 같이 버렸다. 속도 이득은 진짜지만 공짜가 아니었다.
지금까지는 크래시 안전성 축이었다. 메모리 오류 축에서는 이야기가 달라진다.
RCS의 donerewrite()는 커밋마다 이걸 한다.
fro_spew (from, frew); /* 옛 ,v 전체를 새 파일로 복사 */
...
chnamemod (...); /* rename */fro_spew_partial은 mmap/메모리 경로에서 전체를 통과시킨다. 그러니:
- 커밋 한 번에 전체 이력이 RAM을 통과한다
- 비트 하나가 뒤집힌다
- 손상된 내용이 temp에 쓰인다
-
rename()이 원자적으로 설치하며 멀쩡했던 원본을 지운다 - RCS에는 무결성 검사가 없으니 아무도 모른다
integrity 필드가 구조체에 있긴 한데(base.h:587) 파싱해서 그대로 다시 쓸
뿐 검증하는 코드가 없다.
여기서 원자성이 독으로 작용한다. 중간 상태가 없으니 "뭔가 이상하다"는 신호가 없고, 이전 파일은 이미 사라졌다. 게다가 10년 전 리비전도 커밋할 때마다 RAM을 다시 통과하므로 노출이 커밋 수에 비례해 누적된다. 불안정한 머신에서 RCS 파일이 무더기로 깨지는 게 정확히 이 모양이다.
| 커밋당 RAM 통과 범위 | 무결성 검사 | 손상 시 | |
|---|---|---|---|
| RCS | 이력 전체 | 없음 | 조용히 영구 손상, 원본 소실 |
| SimpleRCS | tail만 | 해시 체인 | 나머지 블록 무사, verify()가 탐지 |
| hg | 새 델타만 | revlog 노드 해시 | 탐지 가능 |
| git | 새 오브젝트만 | 전면 content-addressed |
git fsck, 손상분만 폐기 |
우리 tail-rewrite는 크래시에 약하지만 비트 플립 노출 면적은 RCS의 1/N이다. 오래된 블록은 커밋할 때 읽지도 쓰지도 않는다.
정리하면 보장의 종류를 나눠 봐야 한다.
- 탐지는 해시가 있으면 가능하다. 이건 이미 갖고 있다.
- 복구는 중복 없이는 불가능하다. 해시는 "깨졌다"까지만 말한다.
- 노출 면적 축소는 설계로 가능하다. 손대지 않는 데이터는 깨지지 않는다.
파괴적 쓰기 전에 .srcs.journal에 [head_start][옛 HEAD 블록 원본 바이트]를
쓰고 flush, 성공하면 unlink. 열 때 저널이 있으면 복원한다.
hg는 append-only라 undo가 정수 하나로 끝나지만 우리는 덮어쓰므로 옛 바이트가 필요하다. 비용은 커밋당 이전 full text 크기의 추가 쓰기 한 번이고, tail-rewrite 설계는 그대로 유지된다.
주의: 저널은 쓰기 양을 늘리므로 §6의 비트 플립 노출도 함께 늘린다. RCS의 전체 재작성보다는 여전히 훨씬 작지만 공짜는 아니다. 저널 자체에 해시를 붙이면 "저널이 깨졌는지"를 판별할 수 있어서, 손상된 저널로 멀쩡한 파일을 덮어쓰는 최악은 막을 수 있다.
가장 확실하고 구현도 단순하다. 대신 커밋마다 O(파일 크기) I/O에 일시적으로 2배 공간이 필요하다. tail-rewrite가 존재하는 이유를 정면으로 부정하고, 무엇보다 디스크가 꽉 찬 상황에서 제일 먼저 실패한다 — 이 논의의 출발점이 바로 그 상황이었다. §6까지 감안하면 비트 플립 노출도 최악이다.
델타 방향을 forward로 뒤집으면 구조적으로 안전해지고 복구가 truncate로 끝난다. 대신 HEAD 읽기 O(1)을 잃는다. hg 실측이 보여주듯 tip을 읽는 데 체인 전체를 걸어야 할 수 있다.
hg가 이 선택을 한 건 분산 VCS라 임의 리비전 접근과 push/pull 병합을 감당해야 했기 때문이지, 안전성만 보고 고른 게 아니다. 위키처럼 HEAD를 압도적으로 자주 읽는 워크로드에서는 손해다.
commit(snapshot=True)가 이미 옛 HEAD를 full text로 남기는 경로라 재료가
아예 없지는 않다. 다만 그것을 써도 옛 HEAD 블록을 제자리에서 다시 쓰는 건
마찬가지라 append-only가 되지는 않는다.
-
posix_fallocate사전 예약.statvfs로 여유를 재는 것과 달리 경합이 없고, ENOSPC를 파일을 건드리기 전에 잡는다. 값싸고 A와 잘 맞는다 — 흔한 실패(디스크 부족)는 시작조차 안 하고, 드문 실패(전원·SIGKILL)는 저널로 복구. -
파괴적 쓰기 구간 시그널 차단. RCS가 rename 주변에서 하는
IGNOREINTS/RESTOREINTS와 같은 것이다.signal.pthread_sigmask몇 줄로 Ctrl-C에 반쯤 쓰인 파일이 나오는 걸 막을 수 있다. - 작업 파일 기반 복구 경로. §3에서 보듯 델타가 온전하면 완전 복구가 가능하다. 지금은 그 데이터가 디스크에 있는데도 안 쓰고 있다.
A + fallocate + 시그널 차단.
우리 워크로드는 HEAD를 압도적으로 자주 읽는 위키 페이지다. reverse delta와 HEAD O(1)은 그 워크로드에 맞는 선택이므로 유지한다. 대신 RCS가 rename으로 공짜로 얻던 원자성을, 우리는 줄여놓은 쓰기 구간에 저널로 되돌려준다.
C는 사실상 다른 저장 엔진이라 별도 논의가 필요하고, B는 이 논의의 출발점이던 상황에서 가장 먼저 실패한다.
- 끊김 지점 실측:
RLIMIT_FSIZE를base + N으로 걸고 커밋한 뒤 파일 상태를 확인.SIGXFSZ를 무시해야 프로세스가 죽지 않고OSError를 받는다. - RCS 델타 방향:
ci/co로 브랜치를 만든 뒤,v를 직접 열어본다. - hg revlog: 소스 트리에서
PYTHONPATH=<hg소스> python3 <hg소스>/hg로 바로 실행할 수 있다.hg debugrevlog/hg debugdeltachain이 스냅샷 수와 체인 길이를 보여준다.
- Storage Format — 블록 레이아웃과 역델타 구조
- Wiki Backend Design — 저장 백엔드 선택과 벤치마크
-
PR #17 —
verify()가 잘린 블록에 죽던 문제