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
|
# 013 — 앱 전체 메모리 예산
작성일: 2026-09-30. 상태: 세 단계 모두 했다(3단계는 측정).
[외부 리뷰 004](../outside-feedback/feedback-004.md)의 둘째 권고에서 시작한다. 측정은 [104](../research/104-where-memory-goes.md)에 있다.
이 설계 전에는 `--budget`이 요청 payload의 접수만 묶었다. 앱의 목록, outbox, 붙들린 스냅숏, 전송의 사본은 아무것도 묶지
않았다. 작은 기기에서 오래 도는 노드라면 무엇이 얼마나 쓰는지 정해져 있어야 하고, 넘칠 때 무엇을 하는지도 정해져 있어야 한다.
## 결정
앱마다 메모리 예산이 하나 있다(`pure-serve --memory BYTES`). 지금은 노드 하나가 앱 하나다.
**예산이 세는 것: 상태 바이트.**
- 목록 상태의 항목: 저장 형식의 바이트다(수 8 B, 글 4 B와 그 길이). 목록이 늘 알고 있는 값이다.
- 텍스트 상태의 길이
- outbox(보냈지만 받는 쪽이 아직 저장하지 않은 것)와 들은 번호 목록
이 수는 결정적이다. 같은 앱과 같은 입력이면 어느 기기에서나 같다. 상주 메모리는 이것의 약 1.6–1.8배다(104: outbox 한 건이
상주 221 B에 상태 136 B, 4 MB 예산에서 1.72배). 예산을 상주 메모리로 세면 할당기와 기기에 따라 달라지고, 검사할 수 없다.
**넘칠 때: 상태를 늘리는 사건을 거절한다.**
- 사건이 끝났을 때 상태 바이트가 예산을 넘고 사건 전보다 늘었으면, 그 사건은 fault `memory`로 실패한다. 바꾼 것은 다른 실패한
사건처럼 모두 되돌린다.
- 상태를 줄이거나 그대로 두는 사건, 보기, 읽기는 예산을 넘은 채로도 된다. 그래야 줄일 수 있다.
- HTTP로는 503 `memory`다. 요청 payload의 예산(503 `payload_budget`)처럼 "잠시 뒤 다시"의 뜻이다.
- 다른 노드에서 온 전달이 이렇게 거절되면, 들은 번호로 세지 않는다. 앱이 거절한 메시지(한 번 전달되고 끝남)와 달리, 이것은
일시적이다. 보내는 쪽이 다시 보낸다.
- 링크(`pure://`): 받는 쪽이 기록을 쥔 채 100 ms에서 두 배씩 500 ms까지 쉬었다가 다시 넘긴다. 그동안 보내는 쪽에 앱이 가득
찼다고 알린다(STORED의 깃발 2, [012](012-link-protocol.md)). 보내는 쪽은 쥐고 있는 것을 다시 보내지 않는다.
- HTTP: 503 `memory`를 받은 보내는 쪽이 100 ms에서 두 배씩 10초까지 쉬었다가 다시 보낸다(전부터의 규칙).
**답은 자기 보내기의 자리를 쓴다.** HTTP로 보낸 것의 답(`--reply`)은 그 보내기를 outbox에서 지우는 일과 한 사건이다
(`pure_app_answer`).
- 따로 두면 멈출 수 있다. outbox가 예산을 채운 앱에서, 받은 것을 적어 두는 답은 예산을 넘으니 거절된다. 그런데 보내기는 답이
적용된 뒤에야 outbox를 떠난다. 그러면 답도 보내기도 영영 그대로다.
- 한 사건이면 답은 보내기가 쥐던 자리만큼 늘 수 있다. 그보다 많이 쥐는 답은 예산을 넘으면 기다린다. 잃지 않고, 두 번
적용되지 않으며, 그 뒤의 답도 순서대로 기다린다. 앱이 자리를 비우면(줄이는 사건) 100 ms 안에 들어간다.
**느린 수신자: outbox도 상태다.**
- 받는 쪽이 늦거나 꺼져 있으면 outbox가 예산을 채운다. 그 뒤로는 보내는 사건이 거절된다. 이것이 앱의 클라이언트까지 닿는
역압(backpressure)이다.
- 받는 쪽이 저장해 outbox가 줄면 다시 받는다.
- 보낸 것을 버리거나 디스크로만 넘기지 않는다. 보낸 것은 전달될 때까지 앱의 상태이고, 목록은 메모리의 상태이며 저장소는 그
기록이다. 메모리보다 큰 데이터는 문서(키로 여는 모음, 트리 저장소)가 맡는다. 문서는 저장소에 있고 읽기는 빌린 extent라서,
예산에 들지 않는다.
- 두 노드가 서로에게 보내고 둘 다 가득 차면, 서로 상대가 비우기를 기다린다. 링크의 outbox는 받는 쪽이 저장하면 비므로 요청이
응답을 기다리지는 않는다. 그러나 응답을 받는 사건이 늘기만 하면 두 앱이 함께 멈출 수 있다. 응답을 받는 사건이 요청의
기록을 지우게 쓰면 그 사건은 줄이는 사건이라 늘 된다. 런타임이 대신 풀 수 없는 앱의 설계다.
**세지 않는 것과 그 상한.** 007의 원칙대로 세지 않는 것은 그 상한을 적는다.
| 무엇 | 상한 | 이유 |
|---|---|---|
| 붙들린 스냅숏 | 붙든 판마다 상태 한 벌. 붙드는 쪽(연결 스레드, 표면, 체크포인트)이 H이면 최악 (1 + H) × 예산 | 104에서 고쳤다. 전에는 한이 없었다 |
| 전송의 사본 | 보내는 곳(`--send`)마다 창 하나: 받는 쪽이 가진 것 너머로 `--window` 바이트(4 MiB)와 65,536건 | 2단계. 전에는 outbox를 한 번 더 들었다(한 건에 0.8배, 1.4배) |
| 요청 payload | `--budget` | 지금의 접수 예산 |
| 체크포인트 | 이미지를 쓰는 동안 스냅숏 하나. 그동안 고친 잎과 바꾼 텍스트만 더 든다 | 붙들린 스냅숏의 한 경우. SD 카드에서 22.4 MB 목록을 8초쯤 쓰는 동안 +3.0–4.4 MiB였다(104) |
## 단계
1. 상태 바이트를 세고, `--memory`로 예산을 두고, 넘칠 때 사건을 거절한다. 전달의 거절은 들은 번호로 세지 않는다.
2. 전송의 창: 링크와 HTTP 전달이 outbox 전체가 아니라 창만큼만 받는다. 전달되면 다음을 받는다.
- 소유자가 보내는 곳마다 outbox에서의 자리를 기억해, 새로 넣을 것만 본다. outbox에서 나간 보내기의 수(`sends_gone`)만큼
자리를 뒤로 물리므로, 앞의 것이 지워져도 건너뛰는 것이 없다. 저장 뒤와 전달 뒤에 창을 채운다.
- 창이 차도 하나는 넣는다. 창보다 큰 보내기 하나도 간다.
3. 체크포인트가 쓰는 동안의 메모리를 잰다. 잰 값은 104에 있다. 쓰는 동안 고친 것만큼 는다. 예산이 세는 상태의 2배(상태
한 벌과 스냅숏 한 벌)를 넘지 않고, 잰 최고는 상태 바이트의 약 2배(상주)였다.
## 검사
- test-module `memory_budget`:
- 예산을 넘기는 사건이 거절되고 되돌려진다.
- 줄이는 사건은 넘은 채로도 된다.
- outbox가 차면 보내는 사건이 거절되고, 덜어 내면 다시 된다.
- 전달의 거절은 들은 번호를 올리지 않는다. 자리가 나서 다시 오면 한 번 적용된다.
- 보내기보다 많이 쥐는 답은 예산에서 통째로 거절되고(보내기가 outbox에 남는다) 자리가 나면 된다. 적게 쥐는 답은 예산에서도
된다. 다시 시작한 뒤에도 그대로다.
- test-link `full`: 앱이 5초 동안 가득 찬다. 그동안 기록을 다시 넘긴 것은 12번이고, 자리가 난 뒤 0.5초 안에 받는다. 보내는
쪽이 다시 보낸 기록은 0건이다(깃발 2가 없으면 21,640건).
- check.py `server_memory`:
- `--memory`를 준 노드의 링크를 아무도 듣지 않는 곳으로 둔다. 13번째 뒤의 주문이 503 `memory`를 받고, 보기는 읽힌다. 받는
쪽이 뜨면 outbox가 줄고 다시 200이다.
- 받는 쪽을 가진 것보다 작은 예산으로 다시 띄운다. 링크의 전달과 클라이언트의 주문이 거절되고, 비우는 사건은 된다. 비우면
기다리던 주문이 모두 한 번씩 들어온다. 가득 찬 2초 동안 거절은 7번이다(기다림이 없으면 27,809번).
- check.py `server_answers_memory`: 답이 보내기보다 큰 영수증을 남긴다. 영수증이 예산을 채우면 답이 기다리고 주문이 503
`memory`를 받는다. 비우면 기다리던 답이 모두 한 번씩 적용되고, API는 주문마다 한 번씩만 불렸다.
- check.py `server_windows`: 출력 둘(링크 하나, HTTP 하나)의 받는 쪽이 모두 꺼진 채 300건을 보낸다. 창(2,000 B)이 찬다.
받는 쪽이 뜨면 둘 다 300건을 한 번씩 받는다. 전달 뒤에 창을 채우지 않으면 117건에서 멈춘다.
- 021의 run.py `budget`: 상태 4 MB가 4초 남짓에 차고, 상주 메모리가 9.6–9.8 MiB에 멈춘다(예산 없이 30초에 45.9 MiB).
- 021의 run.py `link-down`, `wire-down`(창 4 MiB): 창이 찬 뒤로는 보낸 것 한 건에 상주 219 B, 218 B로 outbox만큼만 는다(30초
전체로 전에는 399 B, 534 B).
- 021의 run.py `unheld --items 200000 --stores`(SD 카드): 체크포인트 두 번이 각각 +3.0 MiB, +4.4 MiB를 남겼다. 최고 상주
42.6 MiB는 상태 22.4 MB의 약 2배다.
## 중단 기준
| 조건 | 결정 |
|---|---|
| 상태 바이트를 세는 비용이 사건마다 측정 가능하게 든다(작은 쓰기의 처리량이 잡음 밖으로 준다) | 셈을 목록 안으로 옮긴다. 가장 작은 사건에서 +38 %였던 것을 세는 상태만 걷게 해 +9 %로 줄였다. 처리량에는 보이지 않는다(104) |
| 거절된 전달이 반복해 돌며 CPU를 쓴다 | 전달에 기다림을 둔다(1단계에서 일어났고, 두었다) |
| 예산 안에서 상주 메모리가 상태 바이트의 3배를 넘는다 | 세는 것을 다시 본다. 잰 값은 1.72배다 |
|