aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-10-04 12:58:53 +0100
committereunhhu <[email protected]>2026-10-04 12:58:53 +0100
commit21bf7dbf9a388fdb73e76118eeb8144b51f2481c (patch)
treefbd3ce8ebc0c8e1244ba95f1c63baac4fe4328dd
parent9fd2e16b68e1a83e2605e8a9cbd9b6d04a4da2f6 (diff)
downloadpure-21bf7dbf9a388fdb73e76118eeb8144b51f2481c.tar.gz
pure-21bf7dbf9a388fdb73e76118eeb8144b51f2481c.zip
Say what an app's tokens cost (017 3b, research 116)
Requests to an app that checks tokens go 2.92% slower than to an open one on 64 keep-alive connections, 2.2% of it the head the token makes longer (an open app sent the same head is as slow); hashing every request on a new connection costs 0.91%. 031 measures the three in turns.
-rw-r--r--CHECKPOINT.md4
-rw-r--r--docs/design/017-apps-on-one-host.md10
-rw-r--r--docs/research/116-route-tokens.md58
-rw-r--r--research/experiments/031-route-tokens/2026-10-04-tokens.jsonl33
-rw-r--r--research/experiments/031-route-tokens/tokens.py76
5 files changed, 178 insertions, 3 deletions
diff --git a/CHECKPOINT.md b/CHECKPOINT.md
index d179039..591f5c9 100644
--- a/CHECKPOINT.md
+++ b/CHECKPOINT.md
@@ -18,7 +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 % 적다. 명세가 있고, 명세만 보고 쓴 파이썬 받는 쪽이 노드와 맞붙는다. 받는 입력은 필드를 이름으로 받는다(순서가 달라도, 받는 쪽 필드가 더 있어도 되고, 보낸 필드를 버리지는 않는다). 그래서 두 노드가 함께 쓰는 보드를 한 노드씩 올려도 멈추지 않고, 기다리는 보내기도 새 필드로 옮겨진다([105](docs/research/105-growing-a-shared-board.md), [014](docs/design/014-fields-by-name.md)) | [설계 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) |
-| 여러 앱 | 한 호스트가 앱 여럿을 품는다(`pure-serve --apps`, 경로는 `/앱/…`). 앱마다 스레드를 두지 않고 정해진 실행 스레드가 차례로 돌리며, 앱마다 연결이 모이는 집 연결 스레드가 있다. 앱마다 실행량(쓴 그대로의 명령 수, 넘으면 422 `work`)·대기열(503 `busy`)·메모리 예산이 있고, 모듈이나 저장소를 열지 못한 앱은 그 앱만 멈춘다. 앱 하나에 120 KiB(프로세스 하나의 PSS의 9%), 앱 100개에도 스레드 넷(연결 하나, 실행 둘, 구성 파일을 읽는 하나). 한 앱이 몰릴 때 다른 앱의 p99는 앱마다 프로세스일 때의 0.5배, 둘 다 바쁠 때 처리량과 p99는 같다. 앱마다 보냄을 배선하고(`send=`), 링크 포트 하나가 모든 앱의 입력을 `앱/입력`으로 받는다. 같은 호스트의 앱끼리는 소켓 없이 건네 링크의 두 배(초당 114만 건)를 CPU 40%로 나른다. 링크와 전달선은 프로세스의 보내는 루프 하나가 보낸다(링크와 전달선 스무 개에 스레드 24개가 5개, 링크 +63%, 전달선 같음). 달리는 호스트가 SIGHUP에 구성 파일을 다시 읽어 앱을 더하고, 멈추고, 그 자리에서 바꾼다(상태는 메모리에서 이름으로 옮긴다: 100만 항목의 앱이 1.1 ms만 쉬고, 다른 앱의 p99는 그대로) | [017](docs/design/017-apps-on-one-host.md), [111](docs/research/111-apps-on-one-host.md), [112](docs/research/112-sending-between-apps.md), [113](docs/research/113-one-sending-loop.md), [115](docs/research/115-apps-that-change.md) |
+| 여러 앱 | 한 호스트가 앱 여럿을 품는다(`pure-serve --apps`, 경로는 `/앱/…`). 앱마다 스레드를 두지 않고 정해진 실행 스레드가 차례로 돌리며, 앱마다 연결이 모이는 집 연결 스레드가 있다. 앱마다 실행량(쓴 그대로의 명령 수, 넘으면 422 `work`)·대기열(503 `busy`)·메모리 예산이 있고, 모듈이나 저장소를 열지 못한 앱은 그 앱만 멈춘다. 앱 하나에 120 KiB(프로세스 하나의 PSS의 9%), 앱 100개에도 스레드 넷(연결 하나, 실행 둘, 구성 파일을 읽는 하나). 한 앱이 몰릴 때 다른 앱의 p99는 앱마다 프로세스일 때의 0.5배, 둘 다 바쁠 때 처리량과 p99는 같다. 앱마다 보냄을 배선하고(`send=`), 링크 포트 하나가 모든 앱의 입력을 `앱/입력`으로 받는다. 같은 호스트의 앱끼리는 소켓 없이 건네 링크의 두 배(초당 114만 건)를 CPU 40%로 나른다. 링크와 전달선은 프로세스의 보내는 루프 하나가 보낸다(링크와 전달선 스무 개에 스레드 24개가 5개, 링크 +63%, 전달선 같음). 달리는 호스트가 SIGHUP에 구성 파일을 다시 읽어 앱을 더하고, 멈추고, 그 자리에서 바꾼다(상태는 메모리에서 이름으로 옮긴다: 100만 항목의 앱이 1.1 ms만 쉬고, 다른 앱의 p99는 그대로). 앱의 경로는 토큰 파일로 닫는다(SHA-256으로 적은 토큰마다 read, write, 입력 하나; 봉인된 전달은 키로; 보기 −2.9%, 대부분 길어진 머리) | [017](docs/design/017-apps-on-one-host.md), [111](docs/research/111-apps-on-one-host.md), [112](docs/research/112-sending-between-apps.md), [113](docs/research/113-one-sending-loop.md), [115](docs/research/115-apps-that-change.md), [116](docs/research/116-route-tokens.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) |
@@ -33,7 +33,7 @@
블록을 받지 않는다(저장소 커밋 +21%, 저널 커밋 −59%, [085](docs/research/085-cycle-sized-files.md)).
남은 것: macOS의 호스트(`pure-serve`는 리눅스의 것), 바뀐 모듈을 놓기(바꿀 때마다 19 KB, 115), 압축을 없애는 제자리 재사용 저장소(085 다음), 응답을 기다리는 출력의 묶음 전달(083), 앱
-라우트의 인증·TLS(017의 3b, 문서 피어의 TLS는 있다), aup·Vega의 배포 묶음, 캐시보다 큰 목록을 찾는 걸음(109: 미리 가져오기로는 줄지
+라우트의 TLS(앞단이 한다; 문서 피어의 TLS는 있다), HTTP로 앱을 더하고 바꾸는 길, aup·Vega의 배포 묶음, 캐시보다 큰 목록을 찾는 걸음(109: 미리 가져오기로는 줄지
않았다. 한 사건의 조회를 겹치거나 구조를 바꿔야 한다).
## 공개할 작업물과 완료 증거
diff --git a/docs/design/017-apps-on-one-host.md b/docs/design/017-apps-on-one-host.md
index 4fcc1df..fb9cbcd 100644
--- a/docs/design/017-apps-on-one-host.md
+++ b/docs/design/017-apps-on-one-host.md
@@ -2,7 +2,8 @@
작성일: 2026-10-01. 상태: 1·2단계와 2b 구현, 검사로 고정하고 쟀다([111](../research/111-apps-on-one-host.md),
[112](../research/112-sending-between-apps.md), [113](../research/113-one-sending-loop.md)). 3단계(수명) 구현, 검사로 고정하고
-쟀다([115](../research/115-apps-that-change.md)). 3b(경로의 인증)는 설계 전.
+쟀다([115](../research/115-apps-that-change.md)). 3b(경로의 인증) 구현, 검사로 고정하고 쟀다
+([116](../research/116-route-tokens.md)).
[외부 리뷰 004](../outside-feedback/feedback-004.md)의 "구현 방향에 대한 평가" 셋째에서 시작한다. 작은 OS의 운영 모델(
[007](007-execution-domains.md))을 Pure가 실제로 갖게 하는 단계다.
@@ -306,6 +307,13 @@ HTTP로 앱을 더하는 길을 두지 않는 까닭은 인증이다. 그 길은
- **하지 않는 것.** TLS: 호스트는 루프백에서 듣고, 밖으로는 TLS를 하는 앞단(Caddy 같은)이 잇는다. 사용자와 세션: 토큰은 앱을
부르는 쪽(서비스, 웹훅, 앞단)의 것이고, 앱 안의 사용자는 앱의 상태다.
+### 3b에서 알게 된 것
+
+- **값은 확인이 아니라 머리다.** 토큰을 확인하는 앱의 보기가 2.92% 느렸는데, 확인하지 않는 앱에 같은 머리를 보내도 2.2%
+ 느렸다. 토큰만큼 길어진 머리를 읽는 값이다. 연결이 마지막 토큰의 다이제스트를 기억해서, 확인은 같은 토큰이면 64바이트를 견주는
+ 것뿐이다. 요청마다 새 연결로 매번 해시해도 0.91%다.
+- 커밋: Pure `51ff7ea`(토큰), 측정은 그 뒤.
+
## 3b의 착수 기준
- **바꿀 결정:** 앱의 경로에 토큰을 요구할지다. 아래를 채우지 못하면 경로의 인증을 앞단에 맡긴다.
diff --git a/docs/research/116-route-tokens.md b/docs/research/116-route-tokens.md
new file mode 100644
index 0000000..3a9028f
--- /dev/null
+++ b/docs/research/116-route-tokens.md
@@ -0,0 +1,58 @@
+# 116 — 앱의 경로에 토큰
+
+2026-10-04. [017](../design/017-apps-on-one-host.md)의 3b를 잰다. 기준은 구현하기 전에 017에 적어 커밋했다(`b15b5c6`).
+
+- 바꿀 결정: 앱의 경로에 토큰을 요구할지. 채우지 못하면 경로의 인증을 앞단에 맡긴다.
+- 통과 조건(RAM의 Pi 5): 토큰이 있는 앱의 보기(64연결, keep-alive)의 처리량 중앙값이 토큰 없는 같은 앱보다 3% 넘게 낮지 않다.
+ 요청마다 새 연결일 때 해시 하나의 값을 밝힌다.
+
+## 쉬운 말로
+
+- **통과했다(아슬아슬하게).** 토큰 파일이 있는 앱은 그 파일이 허락하는 토큰을 가진 요청만 받는다. 토큰을 확인하는 앱은 열린
+ 앱보다 2.92% 느리다. 그 가운데 2.2%는 확인이 아니라 요청의 머리가 토큰만큼(약 90바이트) 길어서다. 확인하지 않는 열린 앱에
+ 같은 머리를 보내도 그만큼 느리다. 확인 자체는 약 0.7%다.
+- 요청마다 새 연결이면 토큰을 매번 해시한다. 그래도 0.91%다. 연결을 맺는 일이 훨씬 크다.
+- 토큰 파일에는 토큰이 아니라 그 SHA-256이 있다. 해시는 호스트 안의 SHA-256(`serve/sha256.c`)이 한다. OpenSSL을 불러오지
+ 않는다.
+
+## 결과
+
+원시 결과와 명령은 [031-route-tokens](../../research/experiments/031-route-tokens/)에 있다(`tokens.py`).
+
+**방법.** 한 호스트(`--apps`, 연결 스레드 하나, 실행 스레드 둘, 저장소는 RAM, 코어 0–2)에 점수판 앱 둘: `open`(토큰 없음)과
+`held`(쓰기 토큰 하나를 허락하는 토큰 파일). 부하 도구(코어 3)가 `total` 보기를 10초 읽는다. 셋을 번갈아 다섯 라운드다. `open`,
+토큰을 붙인 채의 `open`(머리 길이만 같다), `held`다. keep-alive는 64연결(연결이 토큰을 한 번 해시한다), close는 16연결이다(요청마다
+새 연결이라 매번 해시한다).
+
+| 중앙값(초당 요청) | open | 토큰을 붙인 open | held |
+|---|---:|---:|---:|
+| keep-alive, 64연결 | 153,821 | 150,441 (−2.2%) | 149,336 (**−2.92%**) |
+| close, 16연결 | 22,868 | 22,713 (−0.68%) | 22,660 (−0.91%) |
+
+keep-alive에서 `held`는 다섯 라운드 모두 `open`보다 2–3% 낮았다. 흔들림이 아니라 값이다. 먼저 잰 두 방식만의 판에서도 −2.4%였고,
+토큰을 붙인 `open`을 따로 잰 판에서는 붙인 `open`과 `held`가 같았다(149,841 대 150,009). 확인의 몫은 0에서 0.7% 사이다.
+
+**검사**(`tests/check.py`의 `server_tokens`).
+- 토큰이 없으면 보기, 입력, 문서, 질의가 401이다(`WWW-Authenticate: Bearer`). 모르는 토큰도 401이다.
+- 권한마다:
+ - `read`는 보기, 질의, 문서 읽기만 된다. 입력과 문서 쓰기는 403이다.
+ - `write`는 모두 된다.
+ - `input=add`는 add에 보내기만 된다. 다른 입력과 보기는 403이다.
+- 거절된 쓰기는 아무것도 바꾸지 않는다.
+- SHA-256의 블록 경계에 걸친 길이(1, 55, 56, 63, 64, 65, 119, 200바이트)의 토큰이 모두 맞는다(Python `hashlib`의 다이제스트로 적었다).
+- 한 연결에서 같은 토큰, 다른 토큰, 다시 같은 토큰, 토큰 없음을 차례로 보내면 200, 200, 200, 401, 200, 401이다.
+- 토큰 파일을 바꾸고 SIGHUP을 보내면, 그동안 쉬지 않고 부른 요청이 하나도 503을 받지 않는다. 뺀 토큰은 401, 넣은 토큰은 200이다.
+- 토큰 파일이 아닌 파일이 오면 그 앱은 멈춘다(열려 있지 않는다). 고친 뒤의 SIGHUP에 다시 시작한다.
+- 이 호스트가 이름을 대는 보내는 쪽의 봉인된 전달은 토큰 없이 들어오고, 봉인 없는 전달은 토큰이 있어야 한다.
+- 앱 하나의 호스트는 `--tokens`로 같다.
+
+## 판정
+
+- **017의 3b는 들어간다.** 앱의 경로는 토큰 파일로 닫을 수 있다. 토큰 파일이 없는 앱은 지금처럼 열려 있다.
+- TLS는 들어가지 않는다. 호스트는 루프백에서 듣고, 밖으로는 TLS를 하는 앞단이 잇는다.
+
+## 다음
+
+- **머리를 읽는 값.** 비용의 대부분은 길어진 머리다. 머리의 끝을 바이트마다 찾는 일(`head_end`)과 머리의 이름을 차례로 견주는
+ 일은 모든 요청의 값이기도 하다. 줄이면 토큰이 있든 없든 빨라진다.
+- **HTTP로 앱을 더하고 바꾸는 길.** 이제 토큰이 있으니 열 수 있다(관리 권한의 토큰).
diff --git a/research/experiments/031-route-tokens/2026-10-04-tokens.jsonl b/research/experiments/031-route-tokens/2026-10-04-tokens.jsonl
new file mode 100644
index 0000000..68fba88
--- /dev/null
+++ b/research/experiments/031-route-tokens/2026-10-04-tokens.jsonl
@@ -0,0 +1,33 @@
+{"mode": "keep-alive", "round": 1, "way": "open", "requests_per_second": 152428, "completed": 1524282, "2xx": 1524282, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 368, "p99_us": 576, "max_us": 21504}
+{"mode": "keep-alive", "round": 1, "way": "bearing", "requests_per_second": 150441, "completed": 1504408, "2xx": 1504408, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 416, "p99_us": 512, "max_us": 3712}
+{"mode": "keep-alive", "round": 1, "way": "held", "requests_per_second": 150050, "completed": 1500509, "2xx": 1500509, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 416, "p99_us": 480, "max_us": 1792}
+{"mode": "keep-alive", "round": 2, "way": "held", "requests_per_second": 147988, "completed": 1479885, "2xx": 1479885, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 416, "p99_us": 608, "max_us": 5376}
+{"mode": "keep-alive", "round": 2, "way": "bearing", "requests_per_second": 151377, "completed": 1513777, "2xx": 1513777, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 416, "p99_us": 480, "max_us": 1792}
+{"mode": "keep-alive", "round": 2, "way": "open", "requests_per_second": 153512, "completed": 1535126, "2xx": 1535126, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 368, "p99_us": 576, "max_us": 2176}
+{"mode": "keep-alive", "round": 3, "way": "open", "requests_per_second": 154031, "completed": 1540330, "2xx": 1540330, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 368, "p99_us": 576, "max_us": 1728}
+{"mode": "keep-alive", "round": 3, "way": "bearing", "requests_per_second": 150391, "completed": 1503917, "2xx": 1503917, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 416, "p99_us": 496, "max_us": 1792}
+{"mode": "keep-alive", "round": 3, "way": "held", "requests_per_second": 149007, "completed": 1490066, "2xx": 1490066, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 416, "p99_us": 576, "max_us": 2304}
+{"mode": "keep-alive", "round": 4, "way": "held", "requests_per_second": 149336, "completed": 1493362, "2xx": 1493362, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 416, "p99_us": 544, "max_us": 3456}
+{"mode": "keep-alive", "round": 4, "way": "bearing", "requests_per_second": 150868, "completed": 1508682, "2xx": 1508682, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 416, "p99_us": 480, "max_us": 2176}
+{"mode": "keep-alive", "round": 4, "way": "open", "requests_per_second": 153821, "completed": 1538220, "2xx": 1538220, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 368, "p99_us": 576, "max_us": 1728}
+{"mode": "keep-alive", "round": 5, "way": "open", "requests_per_second": 157154, "completed": 1571538, "2xx": 1571538, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 400, "p99_us": 544, "max_us": 1856}
+{"mode": "keep-alive", "round": 5, "way": "bearing", "requests_per_second": 149931, "completed": 1499311, "2xx": 1499311, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 416, "p99_us": 512, "max_us": 3456}
+{"mode": "keep-alive", "round": 5, "way": "held", "requests_per_second": 150122, "completed": 1501220, "2xx": 1501220, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 416, "p99_us": 496, "max_us": 1792}
+{"mode": "close", "round": 1, "way": "open", "requests_per_second": 22893, "completed": 228933, "2xx": 228933, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 896, "max_us": 6656}
+{"mode": "close", "round": 1, "way": "bearing", "requests_per_second": 22754, "completed": 227538, "2xx": 227538, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 896, "max_us": 4864}
+{"mode": "close", "round": 1, "way": "held", "requests_per_second": 22567, "completed": 225673, "2xx": 225673, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 928, "max_us": 7680}
+{"mode": "close", "round": 2, "way": "held", "requests_per_second": 22338, "completed": 223380, "2xx": 223380, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 992, "max_us": 4864}
+{"mode": "close", "round": 2, "way": "bearing", "requests_per_second": 22806, "completed": 228062, "2xx": 228062, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 928, "max_us": 5376}
+{"mode": "close", "round": 2, "way": "open", "requests_per_second": 22868, "completed": 228679, "2xx": 228679, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 896, "max_us": 1344}
+{"mode": "close", "round": 3, "way": "open", "requests_per_second": 22714, "completed": 227151, "2xx": 227151, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 928, "max_us": 5376}
+{"mode": "close", "round": 3, "way": "bearing", "requests_per_second": 22713, "completed": 227134, "2xx": 227134, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 928, "max_us": 3456}
+{"mode": "close", "round": 3, "way": "held", "requests_per_second": 22812, "completed": 228123, "2xx": 228123, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 896, "max_us": 5120}
+{"mode": "close", "round": 4, "way": "held", "requests_per_second": 22660, "completed": 226607, "2xx": 226607, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 928, "max_us": 4864}
+{"mode": "close", "round": 4, "way": "bearing", "requests_per_second": 22707, "completed": 227074, "2xx": 227074, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 928, "max_us": 4864}
+{"mode": "close", "round": 4, "way": "open", "requests_per_second": 22912, "completed": 229117, "2xx": 229117, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 896, "max_us": 3456}
+{"mode": "close", "round": 5, "way": "open", "requests_per_second": 22707, "completed": 227074, "2xx": 227074, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 928, "max_us": 5120}
+{"mode": "close", "round": 5, "way": "bearing", "requests_per_second": 22705, "completed": 227051, "2xx": 227051, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 896, "max_us": 6400}
+{"mode": "close", "round": 5, "way": "held", "requests_per_second": 22775, "completed": 227749, "2xx": 227749, "4xx": 0, "5xx": 0, "errors": 0, "p50_us": 736, "p99_us": 896, "max_us": 5376}
+{"mode": "keep-alive", "open_median": 153821, "bearing_median": 150441, "held_median": 149336, "bearing_change": -2.2, "held_change": -2.92}
+{"mode": "close", "open_median": 22868, "bearing_median": 22713, "held_median": 22660, "bearing_change": -0.68, "held_change": -0.91}
+{"unauthorized": 0, "forbidden": 0}
diff --git a/research/experiments/031-route-tokens/tokens.py b/research/experiments/031-route-tokens/tokens.py
new file mode 100644
index 0000000..9b70be6
--- /dev/null
+++ b/research/experiments/031-route-tokens/tokens.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+"""What an app's tokens cost its requests (docs/design/017's 3b, research 116).
+A host of apps (one connection thread, two workers, stores in RAM, cores
+0-2) holds the scoreboard (benchmarks/http/scores.aup) twice: open, with
+no tokens, and held, whose tokens file grants a write token. The load
+(benchmarks/http/load, core 3) reads its total view for SECONDS on
+CONNECTIONS connections, keep-alive (a connection's token hashed once) or
+close (a connection a request: hashed every time): open with no token,
+open bearing the token all the same (its head as long, nothing checked),
+and held; the three in turns, their order turned each round.
+
+tokens.py PURE_SERVE SECONDS ROUNDS a JSON line per run, then medians
+"""
+import hashlib
+import json
+import os
+import shutil
+import statistics
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+ROOT = HERE.parents[2]
+AUP = ROOT.parent / 'aup' / 'build' / 'aup'
+LOAD = ROOT / 'benchmarks' / 'http' / 'load'
+TOKEN = 'a' * 64
+
+
+def load(port, way, connections, seconds, mode):
+ env = dict(os.environ)
+ app = 'held' if way == 'held' else 'open'
+ if way != 'open':
+ env['PURE_LOAD_TOKEN'] = TOKEN
+ out = subprocess.run(['taskset', '-c', '3', str(LOAD), str(port), str(connections), str(seconds), 'total', '1', mode,
+ '16', f'/{app}'], capture_output=True, text=True, env=env, check=True).stdout
+ return json.loads(out)
+
+
+def main():
+ serve, seconds, rounds = Path(sys.argv[1]).resolve(), int(sys.argv[2]), int(sys.argv[3])
+ work = Path(tempfile.mkdtemp(prefix='pure-031-', dir='/dev/shm'))
+ try:
+ module = work / 'scores.pmod'
+ subprocess.run([str(AUP), 'build', str(ROOT / 'benchmarks' / 'http' / 'scores.aup'), '-o', str(module)], check=True)
+ (work / 'tokens').write_text(f'write {hashlib.sha256(TOKEN.encode()).hexdigest()}\n')
+ (work / 'apps.conf').write_text(f"open {module} {work / 'open'}\nheld {module} {work / 'held'} "
+ f"tokens={work / 'tokens'}\n")
+ host = subprocess.Popen(['taskset', '-c', '0-2', str(serve), '--apps', str(work / 'apps.conf'), '--port', '0',
+ '--threads', '1', '--workers', '2'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ port = json.loads(host.stdout.readline())['port']
+ rates = {}
+ for mode, connections in (('keep-alive', 64), ('close', 16)):
+ for round_ in range(1, rounds + 1):
+ ways = ['open', 'bearing', 'held']
+ for way in (ways if round_ % 2 else ways[::-1]):
+ result = load(port, way, connections, seconds, mode)
+ assert result['2xx'] == result['completed'] and not result['errors'], result
+ rates.setdefault((mode, way), []).append(result['requests_per_second'])
+ print(json.dumps({'mode': mode, 'round': round_, 'way': way, **result}), flush=True)
+ host.terminate()
+ _, error = host.communicate(timeout=60)
+ stats = json.loads(error.decode().strip().splitlines()[-1])
+ for mode in ('keep-alive', 'close'):
+ open_, bearing, held = (statistics.median(rates[(mode, way)]) for way in ('open', 'bearing', 'held'))
+ print(json.dumps({'mode': mode, 'open_median': open_, 'bearing_median': bearing, 'held_median': held,
+ 'bearing_change': round((bearing / open_ - 1) * 100, 2),
+ 'held_change': round((held / open_ - 1) * 100, 2)}), flush=True)
+ print(json.dumps({'unauthorized': stats['unauthorized'], 'forbidden': stats['forbidden']}), flush=True)
+ finally:
+ shutil.rmtree(work)
+
+
+if __name__ == '__main__':
+ main()