aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/research/081-tail-records.md104
1 files changed, 104 insertions, 0 deletions
diff --git a/docs/research/081-tail-records.md b/docs/research/081-tail-records.md
new file mode 100644
index 0000000..fdb6833
--- /dev/null
+++ b/docs/research/081-tail-records.md
@@ -0,0 +1,104 @@
+# 081 — 헤드를 커밋 바이트 바로 앞에: 동기화가 쓰는 구간을 하나로
+
+2026-09-26. [로드맵](../roadmap.md) 단기 2 "저장과 적용의 겹치기"를 재 보다가 바꾼 것. 트리 저장소(`store-tree.c`)의
+파일 형식이 바뀌었다(슬롯 `PURETRE2` → `PURETRE3`, 커밋 레코드 `PUREREC1`). 이전 형식의 파일은 그대로 읽고 이어 쓴다.
+
+착수 기준:
+
+- 바꿀 결정: 동기화를 소유자의 경로에서 빼기 위해 저장을 다른 스레드로 넘기고 적용과 겹칠지, 동기화 자체를 줄일지.
+- 남은 질문: 소유자의 한 라운드에서 동기화는 몇 %이고 겹쳐서 가릴 수 있는 일은 몇 %인가? 커밋의 동기화가 이
+ 장치에서 가장 싼 쓰기(0을 써 둔 곳을 덮어쓰고 `fdatasync`)보다 비싼 까닭은 무엇인가?
+- 최소 확인: 라운드를 나눠 재고, 방식별 동기화 비용을 장치에서 번갈아 재고, 바꾼 저장소를 기존 고장·찢김·오라클
+ 검사와 새 검사에 통과시킨 뒤 저장소와 HTTP 수준에서 이전 저장소와 번갈아 잰다.
+- 중단 기준: 찢긴 커밋이나 잃어버린 커밋을 받아들이는 경우가 하나라도 있으면 되돌린다.
+
+## 라운드가 쓰는 시간
+
+점수판 앱([077](077-apps-as-http-services.md)), 내구성 점수 POST 64연결, 소유자의 라운드 평균(Pi 5, SD 카드의 ext4):
+
+| | µs | 몫 |
+|---|---:|---:|
+| `fdatasync` | 3,287 | 71% |
+| 다음 요청을 기다리기(답이 클라이언트를 돌아 새 요청이 오고 모이기까지) | 1,149 | 25% |
+| 적용, 커밋 쓰기, 게시, 답 돌려주기 | 164 | 4% |
+
+라운드당 이벤트 61개. 적용을 동기화와 겹쳐 가릴 수 있는 것은 4%다. 게다가 답을 받아야 다음 요청을 보내는 클라이언트
+(닫힌 루프)에서는 앞 동기화 중에 다음 동기화를 준비하면 클라이언트가 두 묶음으로 갈려 동기화 한 번에 담기는 이벤트가
+반으로 준다. 동기화가 라운드를 지배하는 동안에는 모두를 한 번의 동기화에 모으는 지금 방식이 낫다. 그래서 동기화를
+다른 스레드로 넘기기 전에 동기화 자체를 봤다.
+
+## 장치가 말한 것
+
+[076](076-one-sync-commit.md)은 0을 써 둔 꼬리를 덮어써 저널 커밋을 없앴다. 그래도 커밋은 두 곳에 쓴다. 꼬리의 바이트와
+파일 앞 슬롯의 헤드다. 방식별로 40번씩 번갈아 잰 p50(ms, 세 번 반복, [probe.c](../../research/experiments/012-tail-records/probe.c)):
+
+| 커밋 | 2 KiB | 8 KiB |
+|---|---:|---:|
+| 바이트, 앞 슬롯에 헤드, `fdatasync` (이전) | 2.40–2.44 | 2.40–2.71 |
+| **헤드를 바이트 바로 앞에, `fdatasync`** | **1.19–1.23** | **1.18–1.57** |
+| 바이트만, `fdatasync` | 1.20–1.23 | 1.12–1.21 |
+
+떨어진 두 구간은 한 구간의 두 배 가깝다. 헤드를 바로 앞에 두면 바이트만 쓰는 것과 같다.
+
+## 바꾼 것
+
+- **커밋은 레코드와 바이트다.** 커밋은 공개된 끝에 128 B 레코드(헤드: 순번, 끝, 카탈로그, 바이트의 시작과
+ 다이제스트, 레코드 자체의 다이제스트)를 쓰고 바로 뒤에 노드를 쓴 다음 `fdatasync`를 한 번 한다. 한 연속 구간이다.
+ 다이제스트는 쓰는 동안 갱신되므로 레코드는 노드를 다 쓴 뒤 비워 둔 자리에 쓴다.
+- **슬롯은 체크포인트다.** 16커밋마다 헤드를 오래된 슬롯에도 쓴다(그 커밋만 두 구간). 슬롯의 헤드는 이전처럼 자기
+ 바이트의 다이제스트가 맞아야 믿는다.
+- **최신 커밋 찾기.** 새 슬롯의 믿을 수 있는 헤드에서 시작해, 헤드의 끝에 순번이 하나 큰 레코드가 있고 그 바이트의
+ 다이제스트가 맞는 동안 따라간다. 찢긴 레코드나 바이트에서 멈추므로 그 앞 커밋이 최신이다. 쓰는 쪽은 그 위에 쓰고,
+ 멈춘 자리의 레코드와 바이트를 덮어쓴다. 프로세스는 파일마다 자기가 본 최신 헤드를 기억해 거기서 따라간다(파일의 그
+ 자리에 같은 레코드가 그대로 있을 때만). 그래서 커밋마다 슬롯에서 다시 걷지 않는다.
+- **새로 고침.** 저장소 핸들은 현재 헤드의 끝에 다음 순번의 레코드가 있는지를 `pread` 하나로 본다. 슬롯의 순번은
+ 이전 형식의 커밋을 위해 계속 본다.
+- 이전 형식의 파일은 슬롯의 헤드 뒤에 레코드가 없으므로 그대로 읽히고, 새 커밋은 레코드로 이어진다. 이 형식의 슬롯은
+ `PURETRE3`이라 이전 빌드는 이 형식을 읽지 않는다.
+
+## 측정
+
+**저장소 단독.** 64개 문서 중 하나의 앞에 16 B를 넣는 커밋 400번 연달아
+([commits.c](../../research/experiments/010-one-sync-commit/commits.c), 이전 저장소 `2ef0d40`과 라운드마다 순서를 바꿔 세 번,
+[run.py](../../research/experiments/012-tail-records/run.py),
+[원시 결과](../../research/experiments/012-tail-records/2026-09-26-ext4-linux-aarch64.json)):
+
+| | 헤드를 슬롯에 | 헤드를 레코드로 |
+|---|---:|---:|
+| 초당 커밋 | 220–233 | **371–396** |
+| p50 | 2.48–2.55 ms | **1.26–1.29 ms** |
+| p90 | 5.54–6.04 ms | 3.90–4.04 ms |
+
+**앱 서비스.** 점수판의 내구성 POST, 이전 빌드(2ef0d40)와 번갈아 세 번(4초씩, `run.py --suite app --workloads score`):
+
+| | 이전 | 레코드 |
+|---|---:|---:|
+| 64연결, 연결 스레드 1: POST/s | 13,136–16,480 | **21,006–26,080** |
+| 64연결, 연결 스레드 3 | 15,127–15,423 | **21,990–25,408** |
+| 8연결, 연결 스레드 1 | 2,174–2,344 | **2,912–3,796** |
+| 8연결, 연결 스레드 3 | 2,217–2,406 | **3,388–3,870** |
+| p50(64연결) | 3.2–3.3 ms | **2.0 ms** |
+
+소유자의 라운드로 보면 동기화가 평균 3.3 ms에서 1.9 ms로, 한 라운드가 4.6 ms에서 3.0 ms로 줄었다. 동기화의 p50은
+1.24 ms로 탐침과 같다. 평균을 끌어올리는 것은 4 ms를 넘는 4%의 동기화(전체 동기화 시간의 33%)다. 꼬리를 늘린 뒤의
+동기화(5초에 13번, 새로 쓴 0과 새 크기를 함께 내보낸다), 압축(2번), 체크포인트(16커밋마다, 두 구간), 그리고 장치의
+잡음이다. 문서 서버의 내구성 PUT은 같은 빌드끼리도 p99가 0.1–1.1 s로 흔들려(장치 상태) 차이를 가려내지 못했다. 같은
+범위다.
+
+## 검증
+
+- `test-tree-store`: 기존 검사(무작위 오라클, 실패 단계, 단계마다 죽는 프로세스, 잃어버린 커밋, 동기화 중인 헤드,
+ 압축, 손상, 커밋당 동기화 한 번과 파일 크기 불변)가 그대로 통과한다. 찢긴 헤드 검사는 이제 두 가지다. 새 슬롯을 찢어도
+ 다른 슬롯에서 레코드를 따라가 최신 커밋을 찾고, 최신 레코드를 찢으면 새 프로세스는 그 앞 커밋을 보며 다음 쓰기가 찢긴
+ 자리에 쓴다. 새 검사 `records`: 42커밋 뒤 슬롯의 헤드는 33이고, 헤드를 모르는 새 프로세스도 최신 커밋을 읽으며, 37번째
+ 커밋의 레코드를 손상시키면 36에서 멈추고 다음 커밋이 그 위에 쓴다. ASan·UBSan 빌드에서도 같다.
+- `check-tree`(저장소 위의 런타임·splice·비동기·실제 PTY 편집기 검사), `test-module`(상태 영속, 단계마다 죽는 앱),
+ aup `make check`(모델 대조, `kill -9` 뒤 확인 응답 보존, 저장한 상태로 다시 시작), Vega `make check`가 통과한다.
+
+## 다음 결정
+
+- **꼬리 늘리기와 압축을 커밋 밖으로.** 동기화 시간의 3분의 1이 드문 느린 동기화다. 0을 써 넣는 일과 압축을 저장이
+ 없는 틈이나 다른 스레드에서 하면 그만큼 줄 것이다. 다만 0을 새로 쓴 쪽이 아직 내구성이 없는데 커밋이 그 위에 쓰면 안
+ 되므로, 늘린 꼬리를 먼저 동기화하는 순서가 필요하다.
+- **저장과 적용의 겹치기**는 열린 부하(답을 기다리지 않고 요청이 오는 경우)에서 다시 잰다. 닫힌 루프에서는 위의 까닭으로
+ 얻을 것이 없다.