aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-09-30 03:19:45 +0100
committereunhhu <[email protected]>2026-09-30 03:19:45 +0100
commit41cb1cca55c67dd2aac2afa2f96bdc09a5d50b78 (patch)
treee2929b52580a1d52544178c18e2f5cfaea50f993
parent385754c2d00ba3f4c74a22f55d4756e101f215b1 (diff)
downloadpure-41cb1cca55c67dd2aac2afa2f96bdc09a5d50b78.tar.gz
pure-41cb1cca55c67dd2aac2afa2f96bdc09a5d50b78.zip
Say where a node's memory goes, and the budget it now keeps (104, 013)
104 measures a node's memory under a receiver that is down and under a snapshot held for long (021's run.py: resident memory against what was posted, 30 s a case). A held snapshot grew the node without end (6.4 to 678 MiB, fixed in d1e8f38); a slow receiver grows it by what was sent, 221 B a send, which is state until delivered, so the budget bounds state and refuses past it. 013 is that budget's design: what it counts (state bytes, deterministic), what an event over it does, why an answer takes its send's room, how links and HTTP wait, what it does not count and those bounds, and the checks. With --memory 4000000 the same load fills it in about 4 s and the node stays at 9.6-9.8 MiB (45.9 MiB and growing without). 012 records the hole the budget found in the link (the full flag), and 103 that its clock fix fell short (385754c). CHECKPOINT gains the row.
-rw-r--r--CHECKPOINT.md3
-rw-r--r--docs/design/012-link-protocol.md4
-rw-r--r--docs/design/013-memory-budget.md95
-rw-r--r--docs/research/103-review-004-defects.md13
-rw-r--r--docs/research/104-where-memory-goes.md98
-rw-r--r--research/experiments/021-memory/2026-09-30-budget-linux-aarch64.jsonl2
-rw-r--r--research/experiments/021-memory/2026-09-30-held-before-linux-aarch64.jsonl1
-rw-r--r--research/experiments/021-memory/2026-09-30-memory-linux-aarch64.jsonl5
-rw-r--r--research/experiments/021-memory/hold-surface.c32
-rw-r--r--research/experiments/021-memory/run.py221
10 files changed, 473 insertions, 1 deletions
diff --git a/CHECKPOINT.md b/CHECKPOINT.md
index e6e9fa7..7692742 100644
--- a/CHECKPOINT.md
+++ b/CHECKPOINT.md
@@ -18,6 +18,7 @@
| HTTP 서비스 | 보기가 GET, 입력이 POST다(JSON 본문과 그 안의 그룹 객체를 제자리에서 읽는다, 라우터 코드 없음). 앱의 `send`는 이벤트와 함께 저장되고, `--send 출력=URL`로 다른 앱이나 HTTP 끝점에 순서대로 묶어 전해지며, 받는 앱은 번호마다 한 번 적용한다(서버를 죽였다 살려도 정확히 한 번). `--seal 출력=공개키`면 받는 노드의 키로 봉인해(HPKE, 전달선마다 세션 하나, 보내는 노드의 키로 인증) 사이의 중계가 읽지도 바꾸지도 못하고, 받는 노드는 `--sender 이름=키`로 보낸 이를 가린다(봉인 전달 462k/s, 봉인 없이 468k/s). 답은 저장 뒤에 나간다. 상태를 가진 앱은 소유자 스레드 하나가 입력을 적용하고, 연결 스레드는 마지막 저장의 스냅숏으로 보기를 답한다(쓰기가 동기화하는 동안에도 읽기는 기다리지 않는다) | 008 HTTP 호스트, [077](docs/research/077-apps-as-http-services.md), [080](docs/research/080-snapshot-reads.md), [082](docs/research/082-json-in-place.md), [083](docs/research/083-delivery-over-http.md), [086](docs/research/086-sealed-sends.md), [088](docs/research/088-sealed-sessions.md) |
| 성능(Pi 5) | 2026-09-27, 같은 기기에서 axum·drogon과 함께 쟀다. 문서 서버(한 스레드): 64 B GET 139k/s(CPU 1초당 140k; axum 127k·63k, drogon 147k·63k), 1 MiB GET 2,362/s(axum 1,304, drogon 754), 충돌 안전 PUT은 axum-atomic의 5.1–5.8배. 앱 서비스: 내구성 있는 쓰기 64연결 21.3k/s(axum·drogon 내구 약 200/s), 합계 GET 126k/s·두 스레드 153k(axum 136k, drogon 151k), 20행 페이지 두 스레드 148k(axum 93k, drogon 138k), 쓰기와 섞인 읽기 119k/s(axum·drogon 내구 1.1–1.4k/s). 유휴 RSS 2.3 MiB(axum 2.8, drogon 10.9), 최대 3.2–5.8 MiB(axum 6.6–9.7, drogon 11.9–78.8) | [075](docs/research/075-http-host-against-axum.md), [077](docs/research/077-apps-as-http-services.md), [080](docs/research/080-snapshot-reads.md), [081](docs/research/081-tail-records.md), [087](docs/research/087-small-reads-and-idle-memory.md) |
| 노드 사이 전달 | 연결 없는 링크가 기본이다(`--send 출력=pure://호스트:포트/입력`, 2026-09-29 채택). 두 노드의 키로 첫 패킷부터 봉인해 보내고 핸드셰이크가 없다. 어느 쪽을 kill -9 해도, 받는 노드의 저장소를 다른 주소로 옮겨도 다음 패킷으로 이어지며 잃거나 겹친 기록이 없다(기기 1만 개가 초당 2,000건을 보내는 중에 저장이 멈춘 100 ms 칸 0). 쉬는 링크는 받는 노드 메모리 132 B(quinn 연결의 1/289). 에폭마다 키를 바꿔 전방 비밀성을 얻는다. 전달당 CPU는 1 KiB 이상에서 quinn·ngtcp2보다 25 % 넘게 적고 61 B에서 비긴다. 손실·재정렬·병목 경로(M6)에서 재정렬 아래 quinn·ngtcp2보다 많이 나르고, 무작위 손실 아래 같으며, 깨끗한 병목에서 틀의 바이트로 3–5 % 적다. 명세가 있고, 명세만 보고 쓴 파이썬 받는 쪽이 노드와 맞붙는다 | [설계 011](docs/design/011-pure-transport.md), [명세 012](docs/design/012-link-protocol.md), [093](docs/research/093-connectionless-link.md)–[102](docs/research/102-adoption-and-spec.md) |
+| 메모리 한도 | 앱마다 상태의 예산이 있다(`--memory`). 목록의 항목, 텍스트, outbox, 들은 번호를 저장 형식의 바이트로 센다(어느 기기에서나 같다). 예산을 넘기며 늘리는 사건은 거절하고(503 `memory`) 줄이는 사건은 받는다. 받는 쪽이 늦으면 outbox가 차서 클라이언트까지 역압이 가고, 가득 찬 받는 쪽은 링크로 알려 보내는 쪽이 다시 보내지 않는다. 답(`--reply`)은 자기 보내기의 자리를 쓴다. 상태 4 MB를 준 노드는 부하 아래 상주 9.6–9.8 MiB에 멈춘다(예산 없이 30초에 45.9 MiB). 오래 붙든 스냅숏은 그때 상태 한 벌만 쥔다(전: 30초에 678 MiB) | [설계 013](docs/design/013-memory-budget.md), [104](docs/research/104-where-memory-goes.md) |
| 표면과 메신저 | 사람을 위한 표면이 노드 안에서 돈다(`pure-serve --surface`): 마지막 저장의 스냅숏을 제자리에서 읽고, 친 키와 글자는 HTTP 요청과 같은 줄로 소유자에게 간다. Vega의 디스플레이·픽셀이 그 표면이다. 두 노드의 메신저(Vega `examples/messenger.aup`)는 서로에게 봉인·인증해 보내고, 한 줄이 상대 노드에 보이기까지 RAM 저장소에서 p50 0.79 ms·p99 1.1 ms, SD 카드에서 p50 3.5 ms(꼬리는 SD의 동기화) | [설계 010](docs/design/010-surfaces.md), [089](docs/research/089-messenger.md) |
| 표 크기의 상태 | 목록이 영속 B+트리다(변경·키 탐색·저장본 위치 O(log n), 스냅숏 O(1), 1,600만 항목, 런타임 상태 1 GiB). pgbench의 TPC-B 같은 이체(세 표의 행과 이력, 한 트랜잭션)가 10만 계좌에서 RAM 24.5k/s(전: 3만 계좌 3.2k/s), SD 카드 내구 1,339/s(SQLite WAL FULL 84–153/s) | [090](docs/research/090-tables.md) |
| 일반 트랜잭션 | TPC-C의 다섯 트랜잭션을 aup 앱 하나로 쓴다(여러 표 읽기·쓰기, 조각 훑기, 중복 없는 셈, 실패하면 통째 되돌림). 같은 입력에서 SQLite와 아홉 표의 모든 행이 같다. W=1에서 RAM 초당 39,977건(SQLite FULL 9,529, 트랜잭션당 CPU 44–49 µs, HTTP·JSON 포함), SD 카드 내구 4,502건(SQLite FULL 50, NORMAL 372) | [092](docs/research/092-tpcc.md) |
@@ -28,7 +29,7 @@
블록을 받지 않는다(저장소 커밋 +21%, 저널 커밋 −59%, [085](docs/research/085-cycle-sized-files.md)).
남은 것: macOS 검증, `bool`·`i64`, 압축을 없애는 제자리 재사용 저장소(085 다음), 응답을 기다리는 출력의 묶음 전달(083), 앱
-라우트의 인증·TLS(문서 피어의 TLS는 있다), aup·Vega의 배포 묶음.
+라우트의 인증·TLS(문서 피어의 TLS는 있다), aup·Vega의 배포 묶음, 전송 사본의 창과 체크포인트 동안의 메모리(013의 2·3단계).
## 공개할 작업물과 완료 증거
diff --git a/docs/design/012-link-protocol.md b/docs/design/012-link-protocol.md
index f2e9820..99d7a91 100644
--- a/docs/design/012-link-protocol.md
+++ b/docs/design/012-link-protocol.md
@@ -346,3 +346,7 @@ LOST는 그 패킷의 번호를 담아 에폭 0의 키로 봉인한다. 답 run�
갔다. 기록의 상한을 16 MiB로 맞췄다.
- 받는 쪽은 피어마다 run 자리를 둘만 두었다. 한 노드의 출력 셋 이상이 같은 노드로 가면 run들이 서로 자리를 밀어내어, 답이
다른 run으로 갔다. 살아 있는 run이 더 오면 자리를 늘린다.
+- 앱의 메모리 예산([013](013-memory-budget.md))을 더하며 하나를 더 찾았다. 앱이 가득 차 전달을 거절하면, 받는 쪽은 같은
+ 기록을 곧바로 다시 넘겨 한 코어를 태웠고(2초에 거절 27,809번), 보내는 쪽은 받은 것이 1초째 저장되지 않자 창 전체를 매초
+ 다시 보냈다(시뮬레이션에서 5초 동안 21,640건). 가득 참 깃발(2)과 다시 넘기는 간격을 더했다. 같은 조건에서 거절은 7번,
+ 다시 보낸 기록은 0건이다.
diff --git a/docs/design/013-memory-budget.md b/docs/design/013-memory-budget.md
new file mode 100644
index 0000000..fe8736a
--- /dev/null
+++ b/docs/design/013-memory-budget.md
@@ -0,0 +1,95 @@
+# 013 — 앱 전체 메모리 예산
+
+작성일: 2026-09-30. 상태: 1단계 구현.
+[외부 리뷰 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에서 고쳤다. 전에는 한이 없었다 |
+| 전송의 사본 | 링크 ring과 HTTP 큐가 outbox를 한 번 더 든다(한 건에 0.8배, 1.4배) | 2단계에서 창으로 묶는다 |
+| 요청 payload | `--budget` | 지금의 접수 예산 |
+| 체크포인트 | 이미지를 쓰는 동안 스냅숏 하나 | 붙들린 스냅숏의 한 경우 |
+
+## 단계
+
+1. 상태 바이트를 세고, `--memory`로 예산을 두고, 넘칠 때 사건을 거절한다. 전달의 거절은 들은 번호로 세지 않는다.
+2. 전송의 창: 링크와 HTTP 전달이 outbox 전체가 아니라 창만큼만 받는다. 전달되면 다음을 받는다.
+3. 체크포인트가 쓰는 동안의 메모리를 잰다.
+
+## 검사
+
+- 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는 주문마다 한 번씩만 불렸다.
+- 021의 run.py `budget`: 상태 4 MB가 4초 남짓에 차고, 상주 메모리가 9.6–9.8 MiB에 멈춘다(예산 없이 30초에 45.9 MiB).
+
+## 중단 기준
+
+| 조건 | 결정 |
+|---|---|
+| 상태 바이트를 세는 비용이 사건마다 측정 가능하게 든다(작은 쓰기의 처리량이 잡음 밖으로 준다) | 셈을 목록 안으로 옮긴다. 가장 작은 사건에서 +38 %였던 것을 세는 상태만 걷게 해 +9 %로 줄였다. 처리량에는 보이지 않는다(104) |
+| 거절된 전달이 반복해 돌며 CPU를 쓴다 | 전달에 기다림을 둔다(1단계에서 일어났고, 두었다) |
+| 예산 안에서 상주 메모리가 상태 바이트의 3배를 넘는다 | 세는 것을 다시 본다. 잰 값은 1.72배다 |
diff --git a/docs/research/103-review-004-defects.md b/docs/research/103-review-004-defects.md
index 3273ab3..e897c41 100644
--- a/docs/research/103-review-004-defects.md
+++ b/docs/research/103-review-004-defects.md
@@ -42,6 +42,19 @@
이어받은 뒤 필드를 재배치한 모듈로 바꾸는 경우만 여전히 타입까지만 본다.
- **시계 검사의 방법.** 이 기기에서 시스템 시계를 바꿀 권한은 없고, 바꿔서도 안 된다. 그래서 `CLOCK_REALTIME`만 한 시간
뒤로 돌리는 LD_PRELOAD 라이브러리를 썼다. Vega의 검사도 Pure가 만든 이 라이브러리를 쓴다(`make check`가 넘긴다).
+- **뒤에 찾은 것(2026-09-30): 시계의 고침이 모자랐다.**
+ - `run_timers`는 마감을 monotonic clock으로 셌지만, 그 마감까지 소유자가 기다리는 `pthread_cond_timedwait`는 여전히 wall
+ clock의 시각까지 기다렸다. HTTP 전달의 다시 보내기 간격과, 저장소의 group commit이 함께 커밋할 스레드를 기다리는 2 ms도
+ 같았다.
+ - 이 라이브러리는 프로세스가 읽는 시각만 옮긴다. 그래서 되돌린 시각으로 센 마감은 커널에게 이미 지난 시각이고, 기다림이
+ 곧바로 끝나 되풀이된다. 검사는 틱이 이어지는 것만 보았고, 그동안 소유자가 한 코어의 64 %를 쓰는 것은 보지 못했다.
+ 실제로 시계를 뒤로 돌리면 반대로 기다림이 한 시간 늘어난다. 타이머가 멈추고, 전달이 한 시간 쉬며, 혼자 커밋하는 요청이
+ 한 시간 걸린다.
+ - 고쳤다. 세 기다림 모두 monotonic clock으로 센다(`core/wait.h`). 라이브러리가 시각을 앞으로도 옮길 수 있게 했고(`CLOCK_SHIFT_S`),
+ check.py `server_clocks`가 한 시간 뒤로, 또 앞으로 옮긴다.
+ - 고치기 전: 뒤로 옮기면 CPU 159 %, 전달 시도 1.2초에 50,886번. 앞으로 옮기면 틱 1, 전달 시도 1번, 세 요청이 함께 커밋한
+ 뒤 혼자 온 커밋이 끝나지 않았다(스무 번째 안에).
+ - 고친 뒤: 두 방향 모두 틱 59, CPU 0 %, 전달 시도 4번, 혼자 온 커밋 2.6 ms.
## 리뷰의 다른 지적
diff --git a/docs/research/104-where-memory-goes.md b/docs/research/104-where-memory-goes.md
new file mode 100644
index 0000000..03749c7
--- /dev/null
+++ b/docs/research/104-where-memory-goes.md
@@ -0,0 +1,98 @@
+# 104 — 메모리가 어디로 가는가: 느린 수신자와 오래 붙든 스냅숏
+
+2026-09-30. [외부 리뷰 004](../outside-feedback/feedback-004.md)의 둘째 권고에서 시작한다. 앱 전체의 메모리 예산을 정하기
+전에, 느린 수신자와 오래 열린 화면 스냅숏이 있을 때 노드의 메모리가 어디로 가는지 잰다.
+
+착수 기준:
+
+- **바꿀 결정.** 앱 전체 메모리 예산([013](../design/013-memory-budget.md))이 무엇을 세고, 넘칠 때 무엇을 할지.
+- **최소 확인.** 두 조건에서 상주 메모리가 무엇에 비례해 느는지 잰다. 한계가 없는 증가가 있으면, 예산보다 먼저 고친다.
+- **중단 기준.** 고친 것이 스냅숏의 읽기(연결 스레드, 표면, 체크포인트)를 하나라도 틀리게 하면 되돌린다.
+
+## 쉬운 말로
+
+- 알아낸 것:
+ - **스냅숏 하나를 오래 붙들면 메모리가 끝없이 늘었다.**
+ - 표면 하나가 항목 2만 개의 스냅숏을 붙든 채 글을 고치게 했다. 노드가 30초 만에 6.4 MiB에서 678 MiB가 됐다(고칠 때마다
+ 3.5 KB).
+ - 스냅숏들이 옛것에서 새것으로 사슬을 이루어, 하나를 붙들면 그 뒤의 모든 판을 붙들었기 때문이다.
+ - 고쳤다. 이제 붙든 스냅숏은 그때의 상태 한 벌만 쥔다. 같은 경우가 6.5 MiB에서 10.4 MiB로 늘고 멈춘다.
+ - **받는 쪽이 늦으면 보낸 것에 비례해 는다.** 이것은 한계가 아니라 설계다. 보낸 것은 전달될 때까지 앱의 상태이기 때문이다.
+ - 보내지 못한 한 건마다 outbox에 221 B가 든다(100 B 글의 주문).
+ - 링크(`pure://`)로 보내면 링크가 따로 사본을 들어 399 B, HTTP로 보내면 534 B다.
+ - 그래서 예산은 "상태의 상한"과 "넘칠 때 거절"로 정해야 한다. 전송의 사본은 줄일 수 있는 상수배다([013](../design/013-memory-budget.md)).
+ - **예산을 주면 멈춘다.** 013의 1단계(`--memory`) 뒤, 받는 쪽 없이 보내는 노드에 상태 4 MB를 주었다. 4초 남짓에 차고,
+ 그 뒤로 보내는 요청은 503 `memory`를 받으며 상주 메모리가 9.6–9.8 MiB에 머문다. 예산이 없으면 30초 동안 45.9 MiB까지 는다.
+- 아직 모르는 것: 체크포인트가 쓰는 동안의 메모리는 따로 재지 않았다.
+
+## 방법
+
+[`research/experiments/021-memory/run.py`](../../research/experiments/021-memory/run.py)가 경우마다 `pure-serve`를 띄운다.
+클라이언트 넷이 연결을 유지한 채 쉬지 않고 보내고, 0.5초마다 노드의 상주 메모리(`/proc/PID/status`의 VmRSS)와 보낸 수를
+적는다. 끝나면 저장소 크기와 노드의 종료 통계를 남긴다.
+
+| 경우 | 앱 | 조건 |
+|---|---|---|
+| outbox | 주문마다 출력으로 보내는 앱 | 출력에 연결이 없다. 보낸 것은 모두 outbox에 남는다 |
+| link-down | 같은 앱 | 출력을 아무도 듣지 않는 포트로 링크했다(`pure://`) |
+| wire-down | 같은 앱 | 출력을 아무도 듣지 않는 포트로 HTTP 전달한다 |
+| unheld | 항목 2만 개(각 100 B 글)를 두고 무작위 항목의 글을 고치는 앱 | 스냅숏을 붙드는 쪽이 없다 |
+| held | 같은 앱 | 표면 하나([hold-surface.c](../../research/experiments/021-memory/hold-surface.c))가 항목이 다 든 스냅숏을 끝까지 붙든다 |
+
+## 결과
+
+30초씩, 이 기기(Raspberry Pi 5)에서 잰 값이다(`research/experiments/021-memory/2026-09-30-memory-linux-aarch64.jsonl`, 고치기
+전의 held는 `2026-09-30-held-before-linux-aarch64.jsonl`: `de2860a`의 pure-serve를 `PURE_SERVE`로 주었다).
+
+| 경우 | 보낸 수 | 상주 메모리 | 한 건마다 | 저장소 |
+|---|---:|---|---:|---:|
+| outbox | 202,763 | 3.0 → 45.7 MiB | 221 B | 34.7 MB |
+| link-down | 198,874 | 8.5 → 84.1 MiB | 399 B | 34.7 MB |
+| wire-down | 198,269 | 3.0 → 104.0 MiB | 534 B | 34.7 MB |
+| unheld | 206,179 | 6.5 → 6.8 MiB | 1.9 B | 19.3 MB |
+| held, 고치기 전(`de2860a`) | 203,791 | 6.4 → 677.8 MiB | 3,455 B | |
+| held | 205,354 | 6.5 → 10.4 MiB | 19.9 B | 19.3 MB |
+
+"한 건마다"는 늘어난 상주 메모리를 보낸 수로 나눈 것이다.
+
+### 스냅숏의 사슬
+
+- 앱의 스냅숏은 옛것이 다음 것의 참조를 쥐는 사슬이었다. 목록이 스냅숏을 뜬 뒤에 놓는 노드와 텍스트는, 그때 가장 새 스냅숏과
+ 함께 풀렸다. 그래서 스냅숏이 풀리는 순서가 옛것부터로 정해졌다.
+- 그런데 스냅숏 하나를 오래 붙들면 그 뒤의 스냅숏이 모두 붙들렸고, 그동안 놓인 노드와 텍스트가 모두 남았다. 표면, 느린 읽기,
+ 체크포인트가 스냅숏 하나를 오래 쥐면 노드의 메모리가 쓰기에 비례해 끝없이 늘었다.
+- 옛 스냅숏이 읽을 수 있는 것은 자기가 떠진 때 이미 있던 노드뿐이다. 그래서 이렇게 바꿨다(`eebdeea`).
+ - 스냅숏에 번호(epoch)를 매긴다. 목록의 사본도 그 번호를 받고, 그 뒤에 만든 노드와 텍스트는 그 번호를 적는다.
+ - 붙들린 스냅숏들은 함께 쓰는 등록부 아래 옛것부터 이어진다.
+ - 스냅숏이 풀릴 때, 그것과 함께 가던 것 가운데 아직 붙들린 바로 앞 스냅숏이 읽을 수 있는 것(그 번호보다 먼저 만든 것)만
+ 그 스냅숏에 넘기고, 나머지는 곧바로 풀어 준다.
+ - 이음은 등록부의 스핀락 아래서 옮기고, 해제는 그 밖에서 한다.
+- 검사: test-module의 `snapshot_held`가 스냅숏 하나를 붙든 채 사건 4만 번과 스냅숏 4만 개를 지나간다. 뒤쪽 2만 번 동안 남은
+ 할당은 0 B다. 옛 방식처럼 붙들게 하면 이 검사가 실패한다. 스냅숏 여덟 개를 무작위 순서로 풀며 내용을 대조하는 기존 검사와
+ 두 스레드의 읽기 검사도 AddressSanitizer 아래에서 통과한다. ThreadSanitizer는 이 커널(가상 주소 47비트)에서 돌지 않는다.
+
+### 느린 수신자
+
+- 보낸 것은 받는 쪽이 저장했다고 할 때까지 앱의 상태다. 그래서 받는 쪽이 꺼져 있으면 outbox는 보낸 만큼 는다. 막을 수 없고,
+ 막아서도 안 된다. 정할 것은 상한과 넘칠 때의 행동이다.
+- outbox 한 건은 221 B였다. 주문의 글 100 B, 출력 이름과 번호, 목록 노드와 할당의 머리다.
+- 전송은 outbox를 한 번 더 복사해 든다. 링크의 보내는 쪽은 자기 ring에(한 건에 178 B 더), HTTP 전달은 자기 큐에(313 B 더)
+ 둔다. 둘 다 저장소에 이미 있는 것의 사본이다.
+
+### 예산을 준 뒤
+
+013의 1단계를 넣은 뒤(`883d64c`), 같은 앱을 같은 부하로 30초씩 다시 쟀다
+(`research/experiments/021-memory/2026-09-30-budget-linux-aarch64.jsonl`).
+
+| 경우 | 받아들임 | 거절(503 `memory`) | 상주 메모리 | 상태 바이트 |
+|---|---:|---:|---|---:|
+| outbox, 예산 없음 | 203,928 | 0 | 3.0 → 45.9 MiB, 계속 는다 | 27,734,208 |
+| budget(`--memory 4000000`) | 29,411 | 177,258 | 3.0 → 9.6 MiB, 6초부터 9.6–9.8 MiB | 3,999,896 |
+
+- 예산은 4초 남짓에 찼다. 그 뒤로 보내는 요청은 곧바로 거절되어, 같은 시간에 받아들임과 거절을 합친 수가 예산 없는 경우와
+ 비슷하다.
+- 늘어난 상주 메모리는 상태 바이트의 1.72배(예산), 1.62배(예산 없음)다. 보낸 것 한 건이 상태 136 B, 상주 221 B다. 013의
+ 중단 기준(3배)보다 낮다.
+- 세는 비용: 가장 작은 사건(수 하나를 더함, 저장소 없음)이 상태 1개인 앱에서 125 → 136 ns(+8 %), 32개인 앱에서 321 →
+ 349 ns(+9 %)다. 처음에는 사건마다 모든 상태를 두 번 걸어 125 → 146 ns(+17 %), 328 → 454 ns(+38 %)였다. 목록과 텍스트만
+ 비트로 골라 걷게 했다(`883d64c`). 예산이 없으면 비용이 없다. HTTP로 받는 쓰기는 한 건에 수십 µs라 처리량에 보이지 않는다.
diff --git a/research/experiments/021-memory/2026-09-30-budget-linux-aarch64.jsonl b/research/experiments/021-memory/2026-09-30-budget-linux-aarch64.jsonl
new file mode 100644
index 0000000..9b6fc4d
--- /dev/null
+++ b/research/experiments/021-memory/2026-09-30-budget-linux-aarch64.jsonl
@@ -0,0 +1,2 @@
+{"case": "budget", "seconds": 30, "posted": 29411, "refused": 177258, "rss_began_kib": 3072, "rss_end_kib": 9808, "rss_per_post_bytes": 234.5, "store_bytes": 9512871, "samples": [[0.0, 3072, 0], [2.0, 6400, 13775], [4.01, 9248, 27484], [6.01, 9872, 29411], [8.01, 9856, 29411], [10.02, 9936, 29411], [12.02, 9808, 29411], [14.02, 9936, 29411], [16.02, 9872, 29411], [18.03, 9888, 29411], [20.03, 9840, 29411], [22.03, 9856, 29411], [24.04, 10016, 29411], [26.04, 9872, 29411], [28.04, 9808, 29411], [30.05, 9872, 29411], [30.05, 9872, 29411]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 206669, "bursts": 150678, "burst_events": 29411, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 17913, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 150679, "grouped_commits": 150679, "checkpoints": 0, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0, "state_bytes": 3999896, "memory_refused": 177258}}
+{"case": "outbox", "seconds": 30, "posted": 203928, "refused": 0, "rss_began_kib": 3088, "rss_end_kib": 47040, "rss_per_post_bytes": 220.7, "store_bytes": 34678695, "samples": [[0.0, 3088, 0], [2.0, 6320, 13620], [4.01, 9312, 27246], [6.01, 12160, 41022], [8.01, 15024, 54518], [10.02, 18016, 68266], [12.02, 20928, 82068], [14.02, 23856, 95792], [16.03, 26720, 109011], [18.03, 29680, 122729], [20.03, 32592, 136438], [22.04, 35648, 150137], [24.04, 38432, 163833], [26.05, 41136, 176611], [28.05, 44064, 190213], [30.05, 47040, 203924], [30.05, 47040, 203924]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 203928, "bursts": 122896, "burst_events": 203928, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 122897, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 122897, "grouped_commits": 122897, "checkpoints": 0, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0, "state_bytes": 27734208, "memory_refused": 0}}
diff --git a/research/experiments/021-memory/2026-09-30-held-before-linux-aarch64.jsonl b/research/experiments/021-memory/2026-09-30-held-before-linux-aarch64.jsonl
new file mode 100644
index 0000000..6036e49
--- /dev/null
+++ b/research/experiments/021-memory/2026-09-30-held-before-linux-aarch64.jsonl
@@ -0,0 +1 @@
+{"label": "de2860a", "case": "held", "seconds": 30, "posted": 203791, "refused": 0, "rss_began_kib": 6576, "rss_end_kib": 694096, "rss_per_post_bytes": 3454.6, "store_bytes": 19287708, "samples": [[0.0, 6576, 0], [2.0, 50000, 12778], [4.01, 96624, 26556], [6.01, 143488, 40444], [8.01, 189488, 54067], [10.02, 236320, 67956], [12.02, 283360, 81869], [14.02, 328080, 95138], [16.03, 375056, 109037], [18.03, 419184, 122186], [20.04, 466144, 136107], [22.04, 508992, 148894], [24.04, 555392, 162669], [26.05, 601664, 176432], [28.05, 647600, 190046], [30.05, 694080, 203787], [30.05, 694080, 203787]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 8, "requests": 223791, "bursts": 134467, "burst_events": 223791, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 134468, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 0, "grouped_commits": 0, "checkpoints": 10, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}}
diff --git a/research/experiments/021-memory/2026-09-30-memory-linux-aarch64.jsonl b/research/experiments/021-memory/2026-09-30-memory-linux-aarch64.jsonl
new file mode 100644
index 0000000..407c273
--- /dev/null
+++ b/research/experiments/021-memory/2026-09-30-memory-linux-aarch64.jsonl
@@ -0,0 +1,5 @@
+{"case": "outbox", "seconds": 30, "posted": 202763, "rss_began_kib": 3088, "rss_end_kib": 46768, "rss_per_post_bytes": 220.6, "store_bytes": 34678695, "samples": [[0.0, 3088, 0], [2.0, 6320, 13658], [4.01, 9248, 27323], [6.01, 12192, 40969], [8.01, 15072, 54665], [10.01, 18064, 68230], [12.02, 20848, 81832], [14.02, 23808, 95269], [16.03, 26752, 108838], [18.03, 29584, 122559], [20.03, 32560, 136189], [22.03, 35520, 149978], [24.04, 38416, 163368], [26.04, 41184, 176553], [28.04, 44000, 189399], [30.04, 46736, 202759], [30.04, 46736, 202759]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 202763, "bursts": 120723, "burst_events": 202763, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 120724, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 120724, "grouped_commits": 120724, "checkpoints": 0, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}}
+{"case": "link-down", "seconds": 30, "posted": 198874, "rss_began_kib": 8688, "rss_end_kib": 86144, "rss_per_post_bytes": 398.8, "store_bytes": 34678695, "samples": [[0.0, 8688, 0], [2.0, 14368, 13447], [4.01, 19536, 26781], [6.01, 24608, 39799], [8.01, 29744, 53146], [10.02, 34928, 66594], [12.02, 40144, 80020], [14.02, 45136, 92957], [16.02, 50240, 106415], [18.03, 55520, 119903], [20.03, 60784, 133388], [22.03, 65600, 145927], [24.04, 70736, 159166], [26.04, 75952, 172647], [28.04, 81152, 186071], [30.04, 86304, 198870], [30.04, 86304, 198870]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 198874, "bursts": 119928, "burst_events": 198874, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 119929, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 119929, "grouped_commits": 119929, "checkpoints": 0, "link_packets": 67, "link_records": 349, "link_resent": 32, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}}
+{"case": "wire-down", "seconds": 30, "posted": 198269, "rss_began_kib": 3120, "rss_end_kib": 106512, "rss_per_post_bytes": 534.0, "store_bytes": 34678695, "samples": [[0.0, 3120, 0], [2.0, 10512, 13272], [4.01, 17376, 26528], [6.01, 24336, 39975], [8.01, 31440, 53404], [10.02, 38272, 66774], [12.02, 45216, 80113], [14.02, 52000, 92988], [16.03, 58704, 106250], [18.03, 65824, 119668], [20.03, 72640, 133038], [22.03, 79552, 146378], [24.04, 86288, 159271], [26.04, 92880, 171996], [28.04, 99776, 185352], [30.05, 106512, 198265], [30.05, 106512, 198265]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 198269, "bursts": 119629, "burst_events": 198269, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 119630, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 9, "answers": 0, "commit_groups": 119630, "grouped_commits": 119630, "checkpoints": 0, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}}
+{"case": "unheld", "seconds": 30, "posted": 206179, "rss_began_kib": 6608, "rss_end_kib": 6992, "rss_per_post_bytes": 1.9, "store_bytes": 19287708, "samples": [[0.0, 6608, 0], [2.0, 6976, 13925], [4.01, 6976, 27728], [6.01, 6976, 41750], [8.01, 6976, 55762], [10.02, 6992, 69046], [12.02, 6992, 82578], [14.02, 6992, 95917], [16.03, 6992, 109910], [18.03, 6992, 123963], [20.03, 6992, 137643], [22.04, 6992, 151687], [24.04, 6992, 165710], [26.05, 6992, 178567], [28.05, 6992, 192132], [30.05, 6992, 206175], [30.05, 6992, 206175]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 8, "requests": 226179, "bursts": 134438, "burst_events": 226179, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 134439, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 0, "grouped_commits": 0, "checkpoints": 10, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}}
+{"case": "held", "seconds": 30, "posted": 205354, "rss_began_kib": 6640, "rss_end_kib": 10640, "rss_per_post_bytes": 19.9, "store_bytes": 19287708, "samples": [[0.0, 6640, 0], [2.0, 9168, 13836], [4.01, 9872, 27868], [6.01, 10240, 41809], [8.01, 10416, 54381], [10.02, 10512, 68479], [12.02, 10560, 82526], [14.02, 10592, 96125], [16.03, 10592, 109730], [18.03, 10592, 123623], [20.03, 10624, 137733], [22.04, 10640, 151837], [24.04, 10640, 164413], [26.04, 10640, 178153], [28.05, 10640, 192298], [30.05, 10640, 205350], [30.05, 10640, 205350]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 8, "requests": 225354, "bursts": 132173, "burst_events": 225354, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 132174, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 0, "grouped_commits": 0, "checkpoints": 10, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}}
diff --git a/research/experiments/021-memory/hold-surface.c b/research/experiments/021-memory/hold-surface.c
new file mode 100644
index 0000000..c1c2d4b
--- /dev/null
+++ b/research/experiments/021-memory/hold-surface.c
@@ -0,0 +1,32 @@
+/* A surface that holds a snapshot until the server stops: a reader that
+ * never lets go, for run.py's held case (research/experiments/021-memory).
+ * It holds the first snapshot whose number view VIEW is at least AT (the
+ * first snapshot at all without them), and lets every other one go.
+ *
+ * pure-serve ... --surface hold-surface.so [-- VIEW AT] */
+#include "surface.h"
+#include <poll.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+int pure_surface_run(const PureSurface *s, int argc, char **argv) {
+ static PureApp reader;
+ int view = argc > 2 ? pure_module_view(s->module, argv[1]) : -1;
+ double at = argc > 2 ? atof(argv[2]) : 0, value = 0;
+ PureSnapshot *held = NULL;
+ while (!s->stopping(s->host)) {
+ struct pollfd wait = {s->changes, POLLIN, 0};
+ if (poll(&wait, 1, 20) > 0 && (wait.revents & POLLIN)) {
+ uint64_t saves;
+ if (read(s->changes, &saves, sizeof(saves)) < 0) { /* cleared already */ }
+ }
+ if (held) continue;
+ PureSnapshot *read_now = s->read(s->host, &reader);
+ if (!read_now) continue;
+ if (view < 0 || (pure_app_view(&reader, (uint32_t)view, &value) && value >= at)) held = read_now;
+ else s->release(s->host, read_now);
+ }
+ if (held) s->release(s->host, held);
+ return 0;
+}
diff --git a/research/experiments/021-memory/run.py b/research/experiments/021-memory/run.py
new file mode 100644
index 0000000..49bb5a9
--- /dev/null
+++ b/research/experiments/021-memory/run.py
@@ -0,0 +1,221 @@
+#!/usr/bin/env python3
+"""Where a Pure node's memory goes when what it keeps cannot leave (review
+004: an app's whole memory budget, not its requests' alone). pure-serve runs
+an app while clients post as fast as they may for --seconds; its resident
+memory (VmRSS) is read every 0.5 s beside what was posted, and the store's
+size at the end.
+
+ outbox the sender's output wired nowhere: every send stays in its outbox
+ budget the same with --memory 4000000 (docs/design/013-memory-budget.md):
+ sends past it are refused, 503 memory
+ link-down the output linked (pure://) to a port no one listens on
+ wire-down the output sent over HTTP to a port no one listens on
+ held a list of items whose notes are set again and again, a surface
+ holding the snapshot of all of them (hold-surface.so) the whole time
+ unheld the same, no surface
+
+ python3 research/experiments/021-memory/run.py [--seconds 30] [--cases outbox,budget,held] [--out FILE]
+ [--label NAME]
+
+PURE_SERVE=PATH runs another build's pure-serve (--label names it in the rows).
+"""
+import argparse
+import http.client
+import json
+import os
+import random
+import select
+import signal
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+HERE = Path(__file__).resolve().parent
+SERVE = Path(os.environ.get('PURE_SERVE', ROOT / 'build/pure-serve')) # another build's, to compare
+sys.path.insert(0, str(ROOT / 'tests'))
+import check # noqa: E402
+
+ITEMS = """record Item {
+ id = 0
+ note: text
+}
+state items: list of Item by id
+input add = message {
+ id: number
+ note: text
+}
+input set = message {
+ id: number
+ note: text
+}
+on add { items.add(Item { id: add.id, note: add.note }) }
+on set { items[set.id].note = set.note }
+view count = items.count
+"""
+SEEDED, NOTE, MEMORY = 20000, 100, 4000000
+
+
+def build(work):
+ if 'PURE_SERVE' not in os.environ:
+ subprocess.run(['make', '-s', '-C', str(ROOT), 'build/pure-serve'], check=True)
+ aup = Path(os.environ.get('AUP', ROOT.parent / 'aup/build/aup'))
+ for name, text in (('sender', check.LINK_SENDER), ('items', ITEMS)):
+ (work / f'{name}.aup').write_text(text)
+ check.compile_module(aup, work / f'{name}.aup', work / f'{name}.pmod')
+ subprocess.run(['cc', '-O2', '-std=c17', '-Wall', '-Wextra', '-Werror', '-fPIC', '-shared', f'-I{ROOT}/core',
+ f'-I{ROOT}/serve', str(HERE / 'hold-surface.c'), '-o', str(work / 'hold-surface.so')], check=True)
+ key = subprocess.Popen([str(SERVE), '--module', str(work / 'items.pmod'), '--store',
+ str(work / 'key-store'), '--key', str(work / 'node.key'), '--port', '0'],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ select.select([key.stdout], [], [], 10)
+ key.stdout.readline()
+ key.terminate()
+ key.communicate(timeout=30)
+
+
+def start(args):
+ args = [str(a) for a in args]
+ at = args.index('--') if '--' in args else len(args) # the surface's arguments come last
+ process = subprocess.Popen(args[:at] + ['--port', '0'] + args[at:], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ ready, _, _ = select.select([process.stdout], [], [], 10)
+ line = process.stdout.readline() if ready else b''
+ assert line, process.stderr.read(2000) if process.poll() is not None else 'no start'
+ return process, json.loads(line)['port']
+
+
+def rss_kib(pid):
+ with open(f'/proc/{pid}/status') as status:
+ for line in status:
+ if line.startswith('VmRSS:'):
+ return int(line.split()[1])
+ return 0
+
+
+def store_bytes(path):
+ return sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file())
+
+
+def post_all(port, bodies, stop, counts, index, refused):
+ """Posts (path, body) from bodies() on one keep-alive connection until
+ stop: counts[index] the ones taken, refused[index] those refused 503."""
+ connection = http.client.HTTPConnection('127.0.0.1', port, timeout=30)
+ while not stop.is_set():
+ path, body = bodies()
+ data = json.dumps(body).encode()
+ try:
+ connection.request('POST', path, data, {'Content-Length': str(len(data)),
+ 'Content-Type': 'application/json'})
+ response = connection.getresponse()
+ response.read()
+ if response.status == 200:
+ counts[index] += 1
+ elif response.status == 503:
+ refused[index] += 1
+ except OSError:
+ connection.close()
+ connection = http.client.HTTPConnection('127.0.0.1', port, timeout=30)
+ connection.close()
+
+
+def run_case(case, work, seconds, clients=4):
+ store = work / f'store-{case}'
+ args = [SERVE, '--store', store, '--threads', '2']
+ note = 'n' * NOTE
+ if case in ('outbox', 'budget', 'link-down', 'wire-down'):
+ args += ['--module', work / 'sender.pmod']
+ if case == 'budget':
+ args += ['--memory', MEMORY]
+ if case == 'link-down':
+ args += ['--key', work / 'node.key', '--send', 'ordered=pure://127.0.0.1:9/order',
+ '--seal', f'ordered={os.urandom(32).hex()}']
+ elif case == 'wire-down':
+ args += ['--send', 'ordered=http://127.0.0.1:9/inputs/order']
+
+ def bodies():
+ return '/inputs/buy', {'item': note, 'count': 1}
+ else:
+ args += ['--module', work / 'items.pmod']
+ if case == 'held': # the snapshot of all the items, held from then on
+ args += ['--surface', work / 'hold-surface.so', '--', 'count', str(SEEDED)]
+ rng = random.Random(7)
+
+ def bodies():
+ return '/inputs/set', {'id': rng.randrange(SEEDED), 'note': f'{rng.getrandbits(32):08x}' + note[8:]}
+ process, port = start(args)
+ try:
+ if case in ('held', 'unheld'): # the items first, then the sets
+ seeded = [0]
+ done = threading.Event()
+ batches = [iter(range(k, SEEDED, clients)) for k in range(clients)]
+
+ def seeding(k):
+ connection = http.client.HTTPConnection('127.0.0.1', port, timeout=30)
+ for i in batches[k]:
+ data = json.dumps({'id': i, 'note': note}).encode()
+ connection.request('POST', '/inputs/add', data, {'Content-Length': str(len(data)),
+ 'Content-Type': 'application/json'})
+ response = connection.getresponse()
+ response.read()
+ assert response.status == 200, response.status
+ connection.close()
+ threads = [threading.Thread(target=seeding, args=(k,)) for k in range(clients)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+ del seeded, done
+ time.sleep(1.0) # the surface reads the first snapshot after the seeding's saves
+ stop = threading.Event()
+ counts, refused = [0] * clients, [0] * clients
+ began, rss_began = time.time(), rss_kib(process.pid)
+ samples = [(0.0, rss_began, 0)]
+ threads = [threading.Thread(target=post_all, args=(port, bodies, stop, counts, k, refused))
+ for k in range(clients)]
+ for t in threads:
+ t.start()
+ while time.time() - began < seconds:
+ time.sleep(0.5)
+ samples.append((round(time.time() - began, 2), rss_kib(process.pid), sum(counts)))
+ stop.set()
+ for t in threads:
+ t.join()
+ rss_end = rss_kib(process.pid)
+ finally:
+ process.send_signal(signal.SIGTERM)
+ _, error = process.communicate(timeout=60)
+ stats = [json.loads(line) for line in error.decode().splitlines() if line.startswith('{"active"')]
+ posted = sum(counts)
+ return {'case': case, 'seconds': seconds, 'posted': posted, 'refused': sum(refused), 'rss_began_kib': rss_began,
+ 'rss_end_kib': rss_end,
+ 'rss_per_post_bytes': round((rss_end - rss_began) * 1024 / posted, 1) if posted else None,
+ 'store_bytes': store_bytes(store), 'samples': samples[::4] + [samples[-1]],
+ 'stats': stats[-1] if stats else None}
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--seconds', type=int, default=30)
+ parser.add_argument('--cases', default='outbox,budget,link-down,wire-down,unheld,held')
+ parser.add_argument('--out', type=Path, default=None)
+ parser.add_argument('--label', default=None, help='the build, named in each row')
+ args = parser.parse_args()
+ with tempfile.TemporaryDirectory(prefix='pure-memory-') as directory:
+ work = Path(directory)
+ build(work)
+ for case in args.cases.split(','):
+ row = run_case(case, work, args.seconds)
+ if args.label:
+ row = {'label': args.label, **row}
+ line = json.dumps(row)
+ print(line, flush=True)
+ if args.out:
+ with open(args.out, 'a') as out:
+ out.write(line + '\n')
+
+
+if __name__ == '__main__':
+ main()