aboutsummaryrefslogtreecommitdiffstats
path: root/docs/research/081-tail-records.md
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-09-26 23:24:31 +0100
committereunhhu <[email protected]>2026-09-26 23:24:31 +0100
commit2e7ad9f8488d4f02c688ec24d6d0b571b3fa61ec (patch)
tree789183f42e3b68b797e7a09ef2b037e55d34ebfa /docs/research/081-tail-records.md
parenta51232af741cb952d7deb72f238c583919ababc0 (diff)
downloadpure-2e7ad9f8488d4f02c688ec24d6d0b571b3fa61ec.tar.gz
pure-2e7ad9f8488d4f02c688ec24d6d0b571b3fa61ec.zip
Write each commit's head just before its bytes: one range per sync (081)
A tree store commit wrote its bytes over the written zeros of the tail and its head into a slot at the start of the file: two ranges, and on the Pi's SD card a sync of two ranges costs about twice one (2.4 ms against 1.2 ms). A commit now writes a 128-byte record (its head) at the published end and its bytes right after it, then syncs once. Every 16 commits the head also goes into the older slot. The newest commit is found from the newer slot's head, or from the newest head this process has seen in the file while its record is still there, along the records whose bytes are whole; a torn record or bytes end the walk at the commit before, which the next writer overwrites. Slots of this format are PURETRE3; files of the earlier one are read and continued. Store commits went from 220-233/s to 371-396/s (p50 2.5 to 1.3 ms), and the scoreboard's durable posts from 13-16k/s to 21-26k/s with 64 connections and from 2.2-2.4k/s to 2.9-3.9k/s with 8, measured alternately with the previous build. The torn head check now tears a slot (the walk goes on from the other) and the newest record (a new process sees the commit before); a new check walks 42 commits from the slots, in a fresh process too, and stops at a damaged record.
Diffstat (limited to 'docs/research/081-tail-records.md')
-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을 새로 쓴 쪽이 아직 내구성이 없는데 커밋이 그 위에 쓰면 안
+ 되므로, 늘린 꼬리를 먼저 동기화하는 순서가 필요하다.
+- **저장과 적용의 겹치기**는 열린 부하(답을 기다리지 않고 요청이 오는 경우)에서 다시 잰다. 닫힌 루프에서는 위의 까닭으로
+ 얻을 것이 없다.