-
Notifications
You must be signed in to change notification settings - Fork 3
Plan Atomic Commit
commit()의 파괴적 제자리 쓰기를 없애는 설계안이다. 배경과 다른 시스템 비교는
Write-Durability에 있고, 이 문서는 무엇을 어떻게 만들
것인가만 다룬다.
구현 완료. PR #24로 머지됐다. 계획대로 되지 않은 부분과 리뷰에서 잡힌 것은 §구현 결과에 있다.
논의 중 검토했다가 접은 2파일 append-only 레이아웃은 §4에 정리해 뒀다.
커밋을 temp 파일에 준비하고 os.replace()로 원자적으로 설치한다. RCS와 같은
방식인데, 우리 레이아웃에서는 RCS보다 훨씬 싸다.
1. [0, S) 를 temp에 복제 # S = 현 HEAD 블록 시작 오프셋
2. 구 HEAD를 델타로 만들어 append
3. 새 HEAD 전문을 append
4. fsync(temp)
5. os.replace(temp, main) # 원자적
6. fsync(디렉토리)
- 살아있는 데이터를 덮어쓰는 지점이 0개가 된다.
- 포맷 변경이 없다. 결과 파일은 현행 v2와 바이트 동일하다.
- 파일 개수도 그대로다. 두 번째 영구 파일이 생기지 않는다.
- 파일 경로 백엔드 한정 opt-in. 메모리·caller 스트림 백엔드는 현행 유지.
RCS가 매 커밋 ,v 전체를 재작성하는 건 낭비가 아니라 레이아웃의 귀결이다.
RCS는 HEAD를 파일 앞에 두므로 커밋하면 앞부분이 바뀌고 뒤가 전부 밀린다.
공유되는 부분이 없으니 통째로 재직렬화할 수밖에 없다.
우리는 HEAD가 뒤다. 그래서 바뀌지 않는 부분이 prefix로 남는다.
old: [헤더][v1.0 델타] ... [v1.N-1 델타][v1.N 전문 ]
new: [헤더][v1.0 델타] ... [v1.N-1 델타][v1.N 델타][v1.N+1 전문]
└──────────── 바이트 동일 [0, S) ────────────┘└─ 새로 쓰는 부분 ─┘
실측으로 확인했다 — 커밋 전후 before[:S] == after[:S]가 참이다. 즉 우리의
"전체 재작성"은 실제로는 prefix 복사 + 작은 tail 쓰기이고, prefix는
파싱도 재직렬화도 거치지 않는 원시 바이트 복사다.
이게 Write-Durability §6의 비트 플립 논증을 뒤집는다. RCS가
위험했던 건 이력 전체가 RAM을 통과하며 재파싱·재직렬화되기 때문이었다. 원시
복사에는 그 단계가 없고, sendfile을 쓰면 우리 주소공간에 들어오지도 않는다.
RCS의 노출을 물려받지 않는다.
300줄 위키 페이지에 리비전을 쌓아가며 잰 값이다.
| 파일 (리비전) | prefix 복사 (sendfile) |
fsync 단독 | 복사+fsync | 현행 커밋 (fsync 없음) |
|---|---|---|---|---|
| 143 KiB (200) | 0.18 ms (676 MiB/s) | 1.47 ms | 1.88 ms | 0.63 ms |
| 650 KiB (1000) | 0.65 ms (947 MiB/s) | 1.51 ms | 3.73 ms | 0.67 ms |
| 2.5 MiB (4000) | 2.08 ms (1196 MiB/s) | 1.39 ms | 9.28 ms | 0.70 ms |
복사 자체는 무시할 수준이고 비용을 지배하는 건 fsync다. 그리고 fsync는 어떤 내구성 스킴을 써도 내야 하는 값이다. 현행 커밋이 0.7 ms인 건 in-place라서가 아니라 fsync를 하나도 안 하기 때문이다. 공정하게 비교하면 위키 규모 (100 KB~1 MB)에서 격차는 2 ms 미만이다.
O(파일)이 실제로 아픈 건 파일이 수 MB로 자란 뒤인데, 그때도 9 ms다.
btrfs / XFS(reflink=1) / bcachefs에서는 prefix를 복사하지 않고 공유할 수
있다. FICLONE은 파일 전체만 복제하므로 전체 clone → S로 truncate → append가
되고, FICLONERANGE로 [0,S)만 복제하면 truncate도 생략된다. 데이터 이동 0,
추가 공간 0이다.
미검증. 개발 머신이 전부 ext4라 FICLONE이 EOPNOTSUPP(errno 95)로
떨어지고, 루프백 파일시스템을 마운트할 권한이 없었다. 도입한다면 btrfs 또는
XFS(reflink) 루프백에서 별도로 재야 한다.
ext4에서는 copy_file_range · sendfile · 유저스페이스 복사가 1.1~1.4 GB/s로
셋 다 사실상 동일했다. 커널 경유가 이득이 아니므로, 폴백 경로에서 어느 것을
고르든 성능 근거는 없다 — 가용성으로 고르면 된다.
로그와 HEAD를 별도 파일로 두고 HEAD만 os.replace. 유효한 설계지만 파일이
둘로 늘고 "파일 하나"라는 성질을 깬다. §1을 알고 나면 그 대가를 치를 이유가
없다 — 단일 파일에서도 prefix 공유로 같은 원자성을 얻는다.
commit(snapshot=True)가 이미 순수 append라는 것을 실측으로 확인했다.
옛 HEAD 블록을 똑같은 바이트로 다시 쓰기 때문에(before == after[:len(before)])
덮어쓰기 구간이 멱등이다. 같은 지점에서 끊었을 때:
| 모드 | verify() | checkout 1.0 | checkout 1.1 |
|---|---|---|---|
| normal | False | '' |
'' |
| snapshot | False | 'aaa\n' |
'aaa\nbbb\n' |
normal은 세 버전이 동시에 전멸하는데 snapshot은 복구 코드 없이도 과거 버전이 전부 정상으로 읽힌다. 이 사실 자체는 유효하니 기록해 둔다 (Write-Durability §7 C의 "스냅샷을 써도 append-only가 되지 않는다"는 서술은 틀렸다 — 코드 경로만 보고 바이트를 안 본 판단이었다).
접은 이유: HEAD가 두 벌 남고, 그걸 되돌리는 compaction이 결국 파괴적 쓰기라 문제가 그대로 미뤄질 뿐이다.
새 tail을 side 파일에 fsync한 뒤 main을 truncate하고 이어붙이는 redo 저널. 프로토타입으로 세 지점에서 끊어 전부 복구되는 것을 확인했다.
| 끊긴 지점 | 직후 | recover() 후 |
|---|---|---|
| side 기록 후, truncate 전 | 정상 (687B) | 정상 (685B) |
| truncate 후, concat 전 | 209B, verify=True, versions=[] | 정상 |
| concat 도중 | 249B, verify=False | 정상 |
접은 이유는 두 번째 줄이다. truncate 직후의 main이 "손상"이 아니라 유효한 빈
파일로 읽힌다. side를 잃으면 전손인데 아무 신호가 없고, 백업이나 다른 도구가
그대로 받아들인다. os.replace는 그런 창을 아예 만들지 않는다.
Write-Durability §7 A와 §7의 시그널 차단 항목. 둘 다 폐기한다. 살아있는
데이터 위의 write+truncate가 사라지므로 되돌릴 대상도, Ctrl-C 창도 없다.
| # | 실패 모드 | 현행 | #18 | 이 계획 |
|---|---|---|---|---|
| 1 | ENOSPC / quota | 전손 가능 | 막힘 | 막힘 (temp에서 실패, 원본 무사) |
| 2 | SIGKILL·크래시 중간사 | 전손 가능 | 안 막힘 | 막힘 |
| 3 | 전원 차단 / 커널 패닉 | 전손 가능 | 안 막힘 | 막힘 (fsync 필요) |
| 4 | Ctrl-C | 잔여 바이트 | 안 막힘 | 막힘 |
| 5 | 비트 반전 / 메모리 오염 | 손상 | 안 막힘 | 안 막힘 (탐지만) |
| 6 | 디바이스 부분 기록 | 전손 가능 | 안 막힘 | 막힘 |
1번은 재평가가 필요하다. Write-Durability §7 B는 "디스크가 꽉 찬 상황에서 제일
먼저 실패한다"고 적었는데 절반만 맞다. 실패가 temp에서 나므로 원본은
무사하다 — posix_fallocate가 주려던 "안전하게 실패"를 구조적으로 준다.
대신 정직하게: temp+rename은 순간적으로 2배 공간이 필요해서 in-place보다 더 일찍 실패한다. 트레이드오프가 사라진 게 아니라 "안전 vs 가용"으로 바뀐 것이다. 여유가 파일 크기보다 적으면 커밋 자체가 불가능해진다. reflink에서는 이 트레이드오프도 없다.
5번은 어떤 갱신 프로토콜로도 못 막는다. 다만 §1대로 재직렬화를 안 하므로 RCS보다 노출이 작고, 해시 체인으로 탐지된다.
| 백엔드 | file_path |
적용 |
|---|---|---|
| 파일 경로 | 있음 | temp + os.replace |
| caller-supplied 스트림 | 없음 | 현행 (제자리 재작성) |
BytesIO / bytes / str
|
없음 | 현행 |
caller 스트림은 핸들의 수명과 위치를 호출자가 소유하므로 os.replace 대상이 될
수 없다. 메모리 백엔드는 프로세스가 죽으면 어차피 전부 사라지므로 내구성 요구가
애초에 없다.
정정. 이 자리에 원래 "#18은 이 두 백엔드에서 계속 유효하다 — 버려지는 작업이
아니다"라고 적었는데 사실이 아니다. _reserve_space는 stream.fileno()가 있어야
동작하므로 BytesIO에서는 문서화된 no-op이고, 남는 유일한 대상인 "caller가 실제 파일
핸들을 넘긴 경우"는 tools/·scripts/·tests/ 어디에도 호출자가 없다. #18의 기존
테스트 4건 중 3건은 path 백엔드로 제자리 경로를 검증하던 것이라 이 계획이
들어가면 의미를 잃는다. 즉 #18은 죽는 코드가 된다 — 머지하지 않고 닫는다. 브랜치는
남으므로 파일 속성 승계(하드링크·xattr·ACL) 때문에 path 백엔드에 제자리 폴백을
만들게 되면 그때 되살린다.
-
os.copy_file_range를 믿을 수 없다. uv가 제공하는 CPython 3.13.5(Clang 빌드)에는copy_file_range도fallocate도 없다. 시스템 CPython 3.10(glibc)에는 둘 다 있다. #18에서os.fallocate가 없던 것과 같은 계열의 결손이다. -
os.sendfile은 양쪽 모두 있다. 이걸 기본 경로로 두고,FICLONERANGE/copy_file_range는 ctypes로 기회주의적으로 시도한다. - temp는 같은 디렉토리에 만들고, rename 후 디렉토리 fsync가 필요하다.
- Windows:
os.replace는 동작하지만 원자성 보장이 POSIX만큼 명시적이지 않고, 열려 있는 파일을 대체할 때 실패할 수 있다. 그래도posix_fallocate처럼 아예 없는 것보다는 낫다 — #18보다 이식성이 좋다.
-
분기점 확보 — main에는
_rewrite_head()가 없다. 그건 #18이 만든 것이고, main에는 파괴적 지점이commit():1538-1540과sign_head():1962-1964로 흩어져 있다. 먼저 하나로 모으는 리팩터 커밋을 낸다. (이전 판에:1311하나로 적었던 것은 #18 브랜치 기준이었다.) -
prefix 복제 유틸 —
sendfile루프 기본,FICLONERANGE/copy_file_range기회주의. 부분 복사·짧은 반환·EINTR 처리. - temp+replace 경로 — §요약의 6단계. temp 이름은 같은 디렉토리에. 실패 시 temp 정리.
- fsync 정책 — 기본값과 끄는 옵션. 여기가 실제 비용이다.
-
sign_head()— 같은 경로를 그대로 탄다. HEAD 블록만 바뀌므로 prefix는 더 길다. -
스트림 재오픈 —
os.replace후 열려 있던 핸들은 옛 inode를 가리킨다. 이걸 놓치면 다음 커밋이 사라진 파일에 쓴다. 가장 놓치기 쉬운 지점.
- 2: prefix 복제 결과가 원본과 바이트 동일한가. 짧은 반환을 강제해도 완전한가.
- 3:
RLIMIT_FSIZE로 각 단계를 실패시켰을 때 원본이 바이트 동일하게 남는가. - 3: 커밋 결과 파일이 현행 in-place 경로의 결과와 바이트 동일한가 (포맷 무변경 증명).
- 5:
sign_head()후 서명이 읽히고verify()가 참인가. - 6: 커밋 → 같은 인스턴스로 다시 커밋 → 두 버전 모두 읽히는가.
무력화 mutation으로 커버리지를 증명할 것. #18에서 _reserve_space 본문을
return으로 바꾸자 바이트 동일성 테스트 2건이 실패했다 — 이 수준의 증명을 각
단계에 요구한다.
-
잠금. 동시 커밋 시 뒤늦은
os.replace가 앞선 커밋을 통째로 날린다. in-place보다 오히려 나빠지는 유일한 축이다.flock이 필요하다. -
파일 속성.
os.replace는 새 inode를 설치하므로 퍼미션·소유자·xattr·하드 링크가 승계되지 않는다. temp 생성 시 원본 stat을 복사해야 한다. - fsync 기본값. 켜면 커밋이 1.5~9 ms가 되고, 끄면 실패 모드 3이 다시 열린다.
- reflink 검증. §3.
PR #24, main에 다섯 커밋으로 들어갔다.
-
refactor(commit)— 두 파괴적 지점을_rewrite_head()하나로 모음 (§8-1) -
feat(commit)—_copy_prefix/_atomic_rewrite_head/durable -
test(commit)— 11건 -
fix(commit)— 리뷰에서 잡힌 HIGH 2건 -
perf(commit)— HEAD 캐시 무효화
os.sendfile을 기본 경로로 삼은 것이 이식성 버그였다. §7에서 "os.sendfile은
양쪽 모두 있다"고 적었는데, 확인한 것은 uv 파이썬 대 시스템 파이썬이었지
플랫폼이 아니었다. file→file 형태는 Linux 전용이다 — Windows에는 존재하지
않고, BSD/macOS 변형은 out_fd가 소켓이어야 한다. CPython 자신이 같은 선을 긋는다:
# shutil.py
_USE_CP_SENDFILE = hasattr(os, "sendfile") and sys.platform.startswith("linux")path 백엔드가 기본 경로이므로 Linux 밖에서는 모든 커밋이 죽었을 것이다. 같은 PR에서
os.replace의 Windows 동작과 _fsync_directory의 Windows no-op을 따로 처리해 놓고
정작 복사에서 Windows를 부순 꼴이다. CI가 ubuntu-latest 하나뿐이라 잡히지 않았다.
최종 구현은 self.stream을 통해 _COPY_CHUNK(1 MiB)씩 읽는 루프다. ext4 실측으로
디스크립터 수준 호출 ~1.4 GB/s 대 이 루프 ~1.2 GB/s인데, 옆에 붙은 fsync가 1.4 ms라
묻히는 차이다.
abspath는 심볼릭 링크를 해소하지 않는다. os.replace는 경로에 있는 것을
대체하므로, 링크로 커밋하면 링크가 일반 파일로 바뀌고 실제 저장소는 커밋을 받지
못한다. 제자리 쓰기는 열린 핸들을 통해 쓰므로 링크를 따라갔다 — 즉 회귀다.
main after: alias is symlink: True target sees 1.1: True
feat/atomic after: alias is symlink: False target sees 1.1: False
realpath로 해소한다. 부수적으로 temp가 최종 대상과 같은 파일시스템에 생겨
os.replace 전제도 만족한다. 하드 링크는 이걸로 안 고쳐진다 — nlink 2→1로
끊기고 별칭은 옛 내용에 남는다. rename의 본질이라 §9 미결로 남긴다.
계획 §1은 _copy_prefix의 flush()를 "raw fd는 파이썬 버퍼를 못 본다"는 근거로
필수라고 했다. 실제로는 커밋 경로에서 도달 불가였다 — _load_head가 매번
seek(0, SEEK_END)를 하고 BufferedRandom은 seek에서 쓰기 버퍼를 밀어낸다.
그래서 처음에는 유닛 레벨 테스트를 붙여 남겼는데, 이식성 수정으로 raw fd를 쓰지
않게 되자 전제 자체가 사라졌다. flush를 지워도 깨지는 테스트가 없다 = 실패할 수
없는 테스트다. flush와 그 테스트를 함께 삭제하고, 대신 진짜 성질을 테스트로 바꿨다:
복사가 HEAD에서 멈추는가 (파일 끝까지 복사하면 새 HEAD 뒤에 옛 HEAD가 남아
스캔이 낡은 블록을 집는다).
_atomic_rewrite_head 끝의 _load_head(force=True)를 두고 "커밋의 12~17%"라고
지적했는데 틀린 계산이었다. 단독 시간을 커밋 시간으로 나눴을 뿐, 그 일이
사라지는 게 아니라 이동한다는 걸 확인하지 않았다. 호출을 계수하면:
평범한 커밋 1회 -> 2 calls:
1. CACHE HIT <- commit() 시작
2. FULL PARSE <- _atomic_rewrite_head 끝
커밋 시작이 캐시 히트인 이유가 바로 앞 커밋이 끝에서 미리 파싱해뒀기 때문이다. 한 인스턴스로 10회 커밋해도 커밋당 full parse는 어느 쪽이든 1.0회다.
그래도 self._head_cache_size = -1로 바꾼 이유는 둘이다. 짧게 사는 인스턴스는 미리
낸 값을 못 쓰는데 tools/srcs_commit.py가 정확히 그 모양이고(1.1 MiB 저장소에서
open+commit+exit 47.5 ms → 32.8 ms), 더 중요하게는 AGENTS.md가 "after committing,
head_info is stale"이라 못박아 둔 계약을 원자 경로만 어기고 있었다.
file_path를 인자로 넘긴다. 메서드 안에서 assert self.file_path is not None
으로 좁히면 ruff S101에 걸리고, 대신 넣는 방어 분기는 테스트가 지나가지 않는 죽은
코드가 된다. 디스패처가 이미 아는 값이므로 start와 함께 넘긴다.
8건이 각각 겨냥한 테스트를 깬다:
| 무력화 | 깨지는 테스트 |
|---|---|
realpath → abspath
|
심볼릭 링크 |
min(_COPY_CHUNK, remaining) → _COPY_CHUNK
|
5건 (exactly-nbytes 포함) |
_head_cache_size = -1 제거 |
동일 크기 재작성 |
fchmod 제거 |
퍼미션 |
durable 무시하고 항상 fsync |
durable=False |
| 디스패치를 항상 원자 경로로 | 비-path 백엔드 + 바이트 동일성 |
temp unlink 제거 |
실패 테스트 2건 |
| 스트림 재오픈 제거 | 12건 중 9건 |
검증: 186개 통과, ruff check clean, mypy --strict는 이 파일의 기존 오류 125건에서
늘지 않았다 (이 파일은 원래 strict clean이 아니다).
- 크래시로 남은
<name>.XXXXXXXX.tmp를 아무도 치우지 않는다 (리뷰 LOW) -
_fsync_directory가 모든OSError를 삼켜durable=True약속을 조용히 낮춘다 (리뷰 LOW) - 잠금, reflink — §9
논의 중에 나온 것들. 이 계획과 독립적이다.
-
v1 파일에서
commit(snapshot=True)가 히스토리를 깨뜨린다._format_block이 버전 확인 없이delta/text키워드를 쓰는데, v1 리더는_get_prev_block에서 HEAD 아닌 블록을 무조건is_delta=True로 강제한다(simple_rcs.py:1060). 스냅샷 블록이 델타로 읽혀Invalid delta format이 난다. v1은 스냅샷 개념이 없으니 거절하는 게 맞다. -
블록이 0개인 파일을
verify()가 True로 판정한다. 헤더만 남은 파일이 "정상"으로 읽힌다.
- Write Durability — 진단, 다른 시스템 비교, 실측 표
- Storage Format — 블록 레이아웃과 역델타 구조
-
PR #18 —
posix_fallocate예약. 이 계획으로 대체된다 (§6의 정정)._rewrite_head()추출만 가져간다.