aboutsummaryrefslogtreecommitdiffstats
path: root/docs/research/081-tail-records.md
blob: 57264ce98334b5094f8c5ce1b1896d55bc05710a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# 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이 드문 느린 동기화다. 상한을 재 봤다. 시작할 때 꼬리를
  64 MiB 늘려 두고 압축을 끈 빌드는 5초 동안 꼬리를 늘리지도 압축하지도 않는다. POST 64연결이 23.1–23.8k에서
  24.3–25.8k/s(+5–8%), p99가 10–14 ms에서 5.4–6.4 ms가 됐다. 4 ms를 넘는 동기화는 그래도 81–129번 남는다(장치의 잡음).
  처리량보다 꼬리 지연의 몫이다. 0을 써 넣는 일과 압축을 저장이 없는 틈이나 다른 스레드에서 하려면, 0을 새로 쓴 쪽이
  아직 내구성이 없는데 커밋이 그 위에 쓰지 않도록 늘린 꼬리를 먼저 동기화하는 순서가 필요하다.
- **저장과 적용의 겹치기**는 열린 부하(답을 기다리지 않고 요청이 오는 경우)에서 다시 잰다. 닫힌 루프에서는 위의 까닭으로
  얻을 것이 없다.