diff options
| author | eunhhu <[email protected]> | 2026-09-27 07:30:51 +0100 |
|---|---|---|
| committer | eunhhu <[email protected]> | 2026-09-27 07:30:51 +0100 |
| commit | eb37aad3ae7f9df1d6c0b09108e3a2d69d0d4d82 (patch) | |
| tree | 4ae7ca2e9ab8c0319c5215040b22c20b5832a909 | |
| parent | 194d243165c2b78d64e0263f39f7a63dbe4a1310 (diff) | |
| download | pure-eb37aad3ae7f9df1d6c0b09108e3a2d69d0d4d82.tar.gz pure-eb37aad3ae7f9df1d6c0b09108e3a2d69d0d4d82.zip | |
Read JSON bodies in place, groups included (082)
pure-serve no longer copies a JSON body's strings into the message's
payload. The ingress is the prefix, a table of each field's start and
size, and the body as it came; the connection thread scans the body
once, puts numbers into the prefix and records where each string is.
Only a string with escapes is unescaped, over itself (it never grows).
A group of fields comes as an object inside the body and, for a fixed
field, as its path in the query (?to.zip=7). A group where a field
belongs, a value where a group does, an array or true/false/null is
json_type; an unknown member, inside a group too, json_field.
The string scan and UTF-8 checks now pass over eight bytes at a time
while none needs a look (pure_utf8_step, so every text check gains).
On the Pi, posting {"name":"k","text":"x..."} to a stateless app went
from 652-655 to 3,737-3,953 requests/s with 256 KiB of text (server
CPU 1.52 to 0.25 ms a request, peak RSS 5.2 to 3.2-3.4 MiB) and from
29.1k to 76.3-76.7k/s with 4 KiB; the load generator gains a json
method and benchmarks/http/echo.aup is the app.
| -rw-r--r-- | CHECKPOINT.md | 2 | ||||
| -rw-r--r-- | README.md | 13 | ||||
| -rw-r--r-- | benchmarks/http/README.md | 3 | ||||
| -rw-r--r-- | benchmarks/http/echo.aup | 5 | ||||
| -rw-r--r-- | benchmarks/http/load.c | 33 | ||||
| -rw-r--r-- | docs/design/008-module-format.md | 15 | ||||
| -rw-r--r-- | docs/research/082-json-in-place.md | 78 | ||||
| -rw-r--r-- | prototypes/connections/module-text.c | 5 | ||||
| -rw-r--r-- | prototypes/connections/serve.c | 185 |
9 files changed, 260 insertions, 79 deletions
diff --git a/CHECKPOINT.md b/CHECKPOINT.md index 08fd936..0ab4bfe 100644 --- a/CHECKPOINT.md +++ b/CHECKPOINT.md @@ -15,7 +15,7 @@ | 앱 표현 | 상태(수, text와 커서·마크, 레코드의 리스트와 키 있는 리스트, 인덱스), 입력(키, text, 필드 있는 메시지, 타이머), 원자적 이벤트 핸들러, 순수 보기(조건식, 표와 걸러진 표, count·sum·max·min·first), `fn`·`use` 라이브러리, text 비교, 시간(`now`, `every`, `floor`, `%`). 로드할 때 타입·스택·합류·커서와 인덱스의 소속을 모두 검증하고 이벤트의 실행량에 상한이 있다 | 008, aup `docs/design/002-dsl-direction.md`, aup `make check`(파이썬 참조 모델 대조, 진단 132건) | | 영속 | 선언한 상태가 곧 영속 상태다. 트리 저장소에 커밋당 동기화 한 번(헤드를 바이트 바로 앞에 두어 한 연속 구간), 이벤트 묶음마다 저장 한 번. `kill -9` 뒤 확인 응답한 이벤트가 모두 남는다. 레코드·키가 바뀐 모듈로 바꿔도 데이터가 이름으로 옮겨진다 | [076](docs/research/076-one-sync-commit.md), [081](docs/research/081-tail-records.md), 008 상태 영속·교체 | | 인터페이스 | 한 명세가 TUI(셀 단위 갱신), CLI, 문장(NUI), 웹 GUI가 된다. 저장하면 실행 중인 앱이 상태를 이어 새 모듈로 바뀐다(`aup watch`, `vega … --watch`) | Vega `docs/design/001-first-component.md`, Vega `make check`(가상 터미널, 헤드리스 Chromium 포함) | -| HTTP 서비스 | 보기가 GET, 입력이 POST다(JSON 본문, 라우터 코드 없음). 답은 저장 뒤에 나간다. 상태를 가진 앱은 소유자 스레드 하나가 입력을 적용하고, 연결 스레드는 마지막 저장의 스냅숏으로 보기를 답한다(쓰기가 동기화하는 동안에도 읽기는 기다리지 않는다) | 008 HTTP 호스트, [077](docs/research/077-apps-as-http-services.md), [080](docs/research/080-snapshot-reads.md) | +| HTTP 서비스 | 보기가 GET, 입력이 POST다(JSON 본문과 그 안의 그룹 객체를 제자리에서 읽는다, 라우터 코드 없음). 답은 저장 뒤에 나간다. 상태를 가진 앱은 소유자 스레드 하나가 입력을 적용하고, 연결 스레드는 마지막 저장의 스냅숏으로 보기를 답한다(쓰기가 동기화하는 동안에도 읽기는 기다리지 않는다) | 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) | | 성능(Pi 5) | 문서 서버: 작은 GET 151.6k/s, 1 MiB GET 2,794/s(axum 1,372, drogon 708), 충돌 안전 PUT은 axum-atomic의 5.1–5.8배. 앱 서비스(aup 22줄 대 손으로 쓴 서비스): 내구성 있는 쓰기 약 76배(그 뒤 저장소 커밋을 한 구간으로 바꿔 64연결 21–26k/s, axum·drogon 내구 약 180/s), 20행 JSON GET 145k/s(axum 95k, drogon 144k), 쓰기와 섞인 읽기 149k/s·p99 0.6 ms(axum·drogon 내구 1.1–1.3k/s·p99 111–156 ms), 최대 RSS 3.0–4.1 MiB(axum 6.4, drogon 11.6) | [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) | 남은 것: macOS 검증, `bool`·`i64`, 꼬리 늘리기·압축을 커밋 밖으로와 열린 부하에서의 저장·적용 겹치기(081 다음 결정), 앱 라우트의 인증·TLS(문서 @@ -1,14 +1,15 @@ # Pure -**Current app model (2026-09-26).** Apps are written in [aup](/srv/git/aup.git) and run as Pure modules +**Current app model (2026-09-27).** Apps are written in [aup](/srv/git/aup.git) and run as Pure modules ([format v5](docs/design/008-module-format.md)), verified completely before they execute: declared states persist in Pure's tree store, every event is a transaction, views are pure functions of state. The same spec becomes a terminal UI, a CLI, spoken sentences and a web page through [Vega](/srv/git/vega.git), and an HTTP service through -`pure-serve` (views are GETs, inputs are POSTs with JSON bodies; answers are durable; views are read from a snapshot of -the last save, so reads never wait for a sync; a commit is one contiguous write and one sync). Measurements against -axum and drogon on a Raspberry Pi 5 are in [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) and -[081](docs/research/081-tail-records.md); the status table is [CHECKPOINT.md](CHECKPOINT.md). The text below +`pure-serve`: views are GETs and inputs are POSTs, with JSON bodies (groups included) read in place; answers are +durable, a commit being one contiguous write and one sync; views are read from a snapshot of the last save, so +reads never wait for a sync. Measurements against axum and drogon on a Raspberry Pi 5 are in +[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) and +[082](docs/research/082-json-in-place.md); the status table is [CHECKPOINT.md](CHECKPOINT.md). The text below records the earlier document runtime this grew from. A standalone native micro runtime being designed as a small operating diff --git a/benchmarks/http/README.md b/benchmarks/http/README.md index 3672c08..af9f62d 100644 --- a/benchmarks/http/README.md +++ b/benchmarks/http/README.md @@ -32,6 +32,9 @@ python3 run.py --module scores.pmod --suite app --rounds 3 --seconds 4 \ --only pure-serve,pure-serve-2,axum-memory,axum-durable,drogon-memory,drogon-durable ``` +JSON 본문의 비용은 상태 없는 앱 [echo.aup](echo.aup)에 `load PORT 연결 초 json 1 keep-alive 본문바이트`로 잰다 +(`{"name":"k","text":"x…"}`를 `POST /inputs/echo`로, [082](../../docs/research/082-json-in-place.md)). + 앱 벤치마크의 마지막 작업(`mixed`)은 내구성 점수 POST 8연결과 합계 GET 56연결을 부하 생성기 둘로 함께 보내, 쓰기가 동기화하는 동안 읽기가 기다리는지 본다([080](../../docs/research/080-snapshot-reads.md)). 두 행(posts, reads)으로 나오고, CPU 1초당 답은 두 행을 합친 것이다. diff --git a/benchmarks/http/echo.aup b/benchmarks/http/echo.aup new file mode 100644 index 0000000..cac3e04 --- /dev/null +++ b/benchmarks/http/echo.aup @@ -0,0 +1,5 @@ +input echo = message { + name: text + text: text +} +on echo { reply echo.name } diff --git a/benchmarks/http/load.c b/benchmarks/http/load.c index fe50deb..d07b2bb 100644 --- a/benchmarks/http/load.c +++ b/benchmarks/http/load.c @@ -2,7 +2,7 @@ /* HTTP/1.1 load for Pure's benchmarks: N connections on one epoll thread, * each sending one request at a time and measuring its latency. * - * load PORT CONNECTIONS SECONDS get|put|score|total|page|note KEYS [keep-alive|close] [BODY] + * load PORT CONNECTIONS SECONDS get|put|score|total|page|note|json KEYS [keep-alive|close] [BODY] * get: GET /objects/kI for I in [0, KEYS). * put: connection C edits its own key kC (C mod KEYS), inserting BODY bytes * at 0 with If-Match on the revision it last saw (If-None-Match: * at @@ -10,7 +10,8 @@ * The scoreboard app: score posts a point for kC (POST /inputs/score?points=1, * the name as the body), total reads GET /views/total and page GET * /views/leaders?from=0&count=20. note reads GET /views/page (a text state - * shown as it is, as in a notes app). + * shown as it is, as in a notes app). json posts {"name":"k","text":"x..."} + * (BODY bytes of text) to POST /inputs/echo as application/json. * Prints one JSON line: requests/s, statuses, latency percentiles (µs). */ #include <arpa/inet.h> #include <errno.h> @@ -68,7 +69,7 @@ static uint64_t percentile(uint64_t total, double p) { } static int port, connections_count, keys; -static enum { GET, PUT, SCORE, TOTAL, PAGE, NOTE } method; +static enum { GET, PUT, SCORE, TOTAL, PAGE, NOTE, JSON } method; static bool put, keep_alive; static char *body; static size_t body_bytes; @@ -76,6 +77,15 @@ static int epoll_fd; static void prepare(Connection *c) { const char *closing = keep_alive ? "" : "Connection: close\r\n"; + if (method == JSON) { + c->request_size = (size_t)snprintf(c->request, sizeof(c->request), + "POST /inputs/echo HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\n" + "Content-Length: %zu\r\n%s\r\n", body_bytes, closing); + c->sent = c->received = c->body_sent = 0; + c->body_size = body_bytes; + c->started = now_us(); + return; + } if (method >= SCORE) { char name[16]; int length = snprintf(name, sizeof(name), "k%d", c->key); @@ -141,21 +151,28 @@ static bool complete(Connection *c, int *status, bool *closing, size_t *length) int main(int argc, char **argv) { if (argc < 6) { - fputs("load PORT CONNECTIONS SECONDS get|put|score|total|page|note KEYS [keep-alive|close] [BODY]\n", stderr); + fputs("load PORT CONNECTIONS SECONDS get|put|score|total|page|note|json KEYS [keep-alive|close] [BODY]\n", stderr); return 2; } port = atoi(argv[1]); connections_count = atoi(argv[2]); double seconds = atof(argv[3]); - static const char *const methods[] = {"get", "put", "score", "total", "page", "note"}; - for (method = GET; method <= NOTE && strcmp(argv[4], methods[method]); ++method) {} - if (method > NOTE) { fprintf(stderr, "load: unknown method %s\n", argv[4]); return 2; } + static const char *const methods[] = {"get", "put", "score", "total", "page", "note", "json"}; + for (method = GET; method <= JSON && strcmp(argv[4], methods[method]); ++method) {} + if (method > JSON) { fprintf(stderr, "load: unknown method %s\n", argv[4]); return 2; } put = method == PUT; keys = atoi(argv[5]); keep_alive = argc > 6 && !strcmp(argv[6], "keep-alive"); body_bytes = argc > 7 ? strtoul(argv[7], NULL, 10) : 16; - body = calloc(body_bytes + 8, 1); + body = calloc(body_bytes + 32, 1); memset(body + 8, 'x', body_bytes); + if (method == JSON) { + static const char open[] = "{\"name\":\"k\",\"text\":\""; + memcpy(body, open, sizeof(open) - 1); + memset(body + sizeof(open) - 1, 'x', body_bytes); + memcpy(body + sizeof(open) - 1 + body_bytes, "\"}", 2); + body_bytes += sizeof(open) - 1 + 2; + } epoll_fd = epoll_create1(0); Connection *all = calloc((size_t)connections_count, sizeof(*all)); for (int i = 0; i < connections_count; ++i) { diff --git a/docs/design/008-module-format.md b/docs/design/008-module-format.md index 0f0a6d8..5156e0a 100644 --- a/docs/design/008-module-format.md +++ b/docs/design/008-module-format.md @@ -266,16 +266,21 @@ text·리스트가 남을 때만 위치를 유지한다. |---|---| | `GET /views/이름` | 보기의 값. 수는 가장 짧은 왕복 10진수, text는 UTF-8 그대로(`text/plain`). text 상태를 그대로 보이는 보기(`view page = note`)는 저장된 익스텐트를 빌려준다([079](../research/079-lent-views.md)) | | `GET /views/표?from=N&count=M` | 표의 행: `{"rows":전체,"from":N,"items":[{열: 값}…]}`(`application/json`). 요청한 행의 칸만 계산한다. 값이 없는 칸은 `null` | -| `POST /inputs/이름` | 이벤트. text 입력은 본문이 text, 키 입력은 누름(down 뒤 up). 메시지는 고정 필드(u32, 수)를 쿼리(`?points=3`)로, 가변 필드 하나를 본문으로 받거나, `Content-Type: application/json` 본문의 객체로 필드를 받는다(문자열은 바이트·text, 수는 u32·수). 응답 본문은 핸들러의 `reply`(메시지 필드) | +| `POST /inputs/이름` | 이벤트. text 입력은 본문이 text, 키 입력은 누름(down 뒤 up). 메시지는 고정 필드(u32, 수)를 쿼리(`?points=3`, 그룹 안이면 `?to.zip=7`)로, 가변 필드 하나를 본문으로 받거나, `Content-Type: application/json` 본문의 객체로 필드를 받는다(문자열은 바이트·text, 수는 u32·수, 그룹은 안의 객체). 응답 본문은 핸들러의 `reply`(메시지 필드) | | `GET`·`PUT /objects/키` | 모듈에 키별 메시지 `show`·`edit`가 있으면 기존 문서 서버(`server.c`)와 같은 계약. `GET`은 `show`, `PUT`(`If-Match`/`If-None-Match`)은 `edit` | - **실패는 실패 종류로 답한다.** 이벤트가 실패하면 `X-Pure-Error`에 종류를 싣는다. `not_found` 404, `exists` 409, `limit`·`divide`·`clamp`·`non_finite`·`encoding` 422, UTF-8이 아닌 text 400. 값이 없는 보기는 404 `no_value`, 없는 이름은 404 `no_view`·`no_input`, 모자라거나 모르는 쿼리 필드는 400 `missing_field`·`query`다. -- **JSON은 연결 스레드가 풀어 둔다.** 앱 입력의 ingress는 `[u32 페이로드 크기][페이로드][온 그대로의 JSON]`이다. 본문을 - 다 받으면 연결 스레드가 JSON을 메시지의 페이로드로 옮기고(이스케이프, 서로게이트 쌍, UTF-8, 수의 문법과 범위를 확인한다) - 나서야 요청이 준비된다. 그래서 앱 소유자는 해석하지 않고, 응답은 여전히 요청에서 빌린다. 필드가 빠지거나(`missing_field`), - 모르거나 두 번 오거나(`json_field`), 타입이 다르거나(`json_type`), JSON이 아니면(`json`) 400이고 요청은 실행되지 않는다. +- **JSON은 연결 스레드가 제자리에서 읽는다.** JSON 본문을 가진 앱 입력의 ingress는 `[u32 페이로드 크기 | 2^31][필드마다 + u32 시작과 크기][페이로드: 접두부, 그다음 온 그대로의 JSON]`이다. 본문을 다 받으면 연결 스레드가 JSON을 한 번 훑는다(8바이트씩). + 수는 접두부에 넣고, 문자열은 JSON 안의 그 자리를 표에 적는다. 이스케이프가 있는 문자열만 그 자리에 풀어 쓴다(언제나 + 짧아진다). 이스케이프, 서로게이트 쌍, UTF-8, 수의 문법과 범위를 확인한 뒤에야 요청이 준비되고, 소유자는 표대로 메시지를 + 적용한다(`pure_app_message_fields`: 가변 필드가 페이로드 어디에 있든 자리를 받는다). 그래서 앱 소유자는 해석하지 않고, + ingress는 본문 크기에 표만 더하며, 응답은 여전히 요청에서 빌린다. 필드가 빠지거나(`missing_field`), 모르거나 두 번 오거나 + 쿼리와 본문에 함께 오거나(`json_field`), 타입이 다르거나(필드 자리에 그룹, 그룹 자리에 값, 배열, `true`·`false`·`null`: + `json_type`), JSON이 아니면(`json`) 400이고 요청은 실행되지 않는다. 256 KiB 본문의 요청이 CPU 0.25 ms다 + ([082](../research/082-json-in-place.md)). - **라우트는 작업 슬롯에 둔다.** 요청이 가리키는 것(문서, 보기, 입력)과 메시지 필드는 받을 때 풀어 슬롯별 표와 ingress에 둔다. 연결이 묶음 전에 끊겨도 받아들인 이벤트는 실행된다. 런타임 코어는 그대로다(키는 문서 키 또는 보기·입력 이름). - **시간.** 요청의 이벤트는 적용되는 순간의 벽시계를 `now`로 본다. 앱을 돌리는 스레드(한 스레드면 그 스레드, 아니면 diff --git a/docs/research/082-json-in-place.md b/docs/research/082-json-in-place.md new file mode 100644 index 0000000..6a456b8 --- /dev/null +++ b/docs/research/082-json-in-place.md @@ -0,0 +1,78 @@ +# 082 — JSON 본문은 제자리에서: 그룹 필드, 복사 없는 문자열, 8바이트씩 훑기 + +2026-09-27. [로드맵](../roadmap.md) 단기 4 "JSON 본문 역직렬화 확장". + +착수 기준: + +- 바꿀 결정: 앱 입력의 JSON 본문을 메시지 페이로드로 옮겨 적을지(문자열을 풀어 복사), 본문을 그 자리에 두고 필드의 + 위치만 넘길지. 중첩된 JSON 객체를 받으려면 메시지 형식에 무엇이 필요한지. +- 남은 질문: 여러 text 필드는 이미 받는다([077](077-apps-as-http-services.md)). 그러나 문자열을 모두 페이로드로 복사해 + ingress가 본문의 두 배였고, 중첩 객체는 받지 못했다. 복사를 없애면 무엇이 달라지는가? 중첩을 실행 비용 없이 표현할 수 + 있는가? +- 최소 확인: 같은 요청을 복사 빌드와 새 빌드로 번갈아 재고(처리량, 요청당 CPU, 최대 RSS), 무작위 JSON(이스케이프, 제어 + 바이트, 서로게이트 쌍, 한글, 이모지, 섞인 멤버 순서, 그룹)을 파이썬 모델과 대조한다. +- 중단 기준: 모델과 한 번이라도 다르거나 틀린 요청 하나라도 받아들이면 되돌린다. + +## 의심한 관례 + +| 관례 | 이 설계 | +|---|---| +| 요청 본문은 파싱해서 객체 트리로 만든 뒤 필드를 꺼낸다 | 본문을 한 번 훑으며 필드의 자리(시작, 크기)만 적는다. 트리도 사본도 없다 | +| 문자열은 풀어서(unescape) 새 버퍼에 담는다 | 이스케이프가 없는 문자열은 본문의 그 바이트가 필드다. 있는 것만 그 자리에 풀어 쓴다(언제나 짧아진다) | +| 중첩 객체는 중첩 타입(클래스, 구조체)으로 받는다 | 그룹은 이름의 경로다(`to.city`). 모듈에서는 평평한 필드라 런타임은 아무것도 더 하지 않는다 | + +## 바꾼 것 + +- **그룹.** aup 메시지의 필드 블록 안에 `to: { city: text, zip: u32 }`를 쓸 수 있고(겹쳐도 된다), 핸들러는 + `ship.to.city`로 읽는다. 컴파일러는 그룹을 `to.city` 같은 경로 이름의 필드로 펼친다. Pure 모듈 형식은 메시지 필드 + 이름에 식별자를 점으로 이은 경로를 받고, 한 이름이 필드이면서 그룹인 모듈(`user`와 `user.name`)은 거절한다. 모듈의 + 메시지 필드 한도는 32에서 64로 올렸다(`PureModule` 11.9 → 13.2 KiB). +- **자리를 받는 메시지.** `pure_app_message_fields`는 가변 필드마다 페이로드 안의 시작과 크기를 표로 받는다. 필드가 + 접두부 뒤에 차례로 있을 필요가 없다. 런타임은 전처럼 범위와 UTF-8을 확인한다. +- **제자리 JSON.** `pure-serve`의 JSON 입력 ingress는 `[크기 | 2^31][필드마다 시작·크기][접두부][온 그대로의 JSON]`이다 + (전에는 `[크기][접두부 + 풀어 쓴 문자열 자리(본문 크기)][JSON]`). 연결 스레드는 본문을 한 번 훑어 수는 접두부에 넣고, + 문자열은 자리를 적는다. 그룹은 안의 객체로 받고, 쿼리의 고정 필드는 경로(`?to.zip=7`)로 받는다. 필드 자리에 그룹, + 그룹 자리에 값, 배열, `true`·`false`·`null`은 `json_type`, 모르는 멤버(그룹 안 포함)는 `json_field`다. +- **8바이트씩.** 문자열의 끝을 찾을 때 따옴표·백슬래시·제어 바이트가 없는 8바이트는 한 번에 넘긴다. UTF-8 검사도 글자 + 사이의 ASCII 8바이트를 한 번에 넘긴다(`pure_utf8_step`, 모든 text 검사가 함께 빨라진다). + +## 장치가 말한 것 + +Raspberry Pi 5, 서버는 CPU 0–2(연결 스레드 하나), 부하는 CPU 3. 상태 없는 앱 `echo` +([echo.aup](../../benchmarks/http/echo.aup): text 필드 둘, 이름을 답한다)에 `{"name":"k","text":"x…"}`를 4초간 +보낸다(`load … json`). 세 빌드를 번갈아 두 번씩 쟀다. + +| 본문 | 복사 | 제자리 | 제자리 + 8바이트씩 | +|---|---:|---:|---:| +| 256 KiB, 8연결: 요청/s | 652–655 | 823–826 | **3,737–3,953** | +| 요청당 서버 CPU | 1,517–1,529 µs | 1,208–1,212 µs | **252–262 µs** | +| 최대 RSS | 5.2 MiB | 3.2–3.6 MiB | **3.2–3.4 MiB** | +| 4 KiB, 64연결: 요청/s | 29,139–29,150 | 33,548–34,609 | **76,270–76,695** | +| 요청당 서버 CPU | 34.2 µs | 28.8–29.7 µs | **13.0–13.1 µs** | +| 64 B, 64연결: 요청/s | 93,183–93,246 | 92,766–92,929 | — | + +- 복사를 없애자 큰 본문의 메모리가 3분의 1 줄고 요청당 CPU가 20% 줄었다. 작은 본문은 같다. +- 남은 CPU의 대부분은 바이트마다 도는 두 루프였다. 문자열의 끝 찾기와 UTF-8 검사(연결 스레드에서 한 번, 런타임에서 한 + 번)다. 8바이트씩 넘기자 256 KiB 요청이 1.2 ms에서 0.25 ms가 됐다. 이제 초당 약 1 GB를 훑는다. + +## 검증 + +- `test-module`: 가변 필드를 JSON 본문 안의 자리로 받아(순서가 선언과 달라도) 전과 같은 상태와 답이 된다. 페이로드 밖에서 + 시작하거나 끝나는 자리는 `limit`, UTF-8이 아닌 자리는 `encoding`으로 실패하고 아무것도 바꾸지 않는다. 경로 이름 + `user.name`은 받고, `user..name`, `.user`, `user.`, `user.1st`, `user name`, 그리고 `user`와 `user.name`을 함께 가진 + 모듈은 거절한다. +- aup `make check`의 `server_json`: 전의 무작위 평평한 JSON 300개와 틀린 요청 14가지에 더해, 그룹이 두 단계인 메시지에 + 무작위 JSON 100개(멤버 순서를 단계마다 섞고, 문자열에 이스케이프·제어 바이트·한글·이모지·U+2028)를 보내 답과 표가 + 파이썬 모델과 같다. 그룹의 고정 필드를 쿼리 경로로 준 요청, 틀린 그룹 요청 10가지(모르는 멤버, 그룹 자리에 값, 필드 + 자리에 객체, 빈 그룹, 두 번 온 필드, 배열, `null`, 외톨이 서로게이트, 닫히지 않은 객체), 쿼리와 본문에 함께 준 필드도 + 확인한다(모두 424건). 진단 6가지(그룹을 값으로 읽기, 그룹에 없는 필드, 필드와 그룹이 같은 이름, 그룹 두 번, 빈 그룹, + 31자를 넘는 경로)를 더했다. +- 나머지 aup·Vega 검사, Pure `make check`·`check-tree`가 통과하고, ASan·UBSan 빌드의 `pure-serve`로 서버 검사 + (모델 대조, 대여, JSON, 타이머)를 통과한다. + +## 다음 결정 + +- 배열(리스트 필드)은 아직 받지 않는다. 받으려면 메시지에 반복되는 그룹이 있어야 하고, 핸들러가 그것을 도는 방법(리스트 + 상태에 한꺼번에 더하기 같은)이 먼저 정해져야 한다. +- UTF-8을 연결 스레드와 런타임이 두 번 검사한다. 런타임의 검사는 호스트를 믿지 않기 위한 경계라 남긴다. 연결 스레드의 + 검사는 400 `json`으로 답하기 위한 것이다. 둘을 합치려면 런타임의 실패를 요청 오류로 옮겨 답하는 길이 필요하다. diff --git a/prototypes/connections/module-text.c b/prototypes/connections/module-text.c index 596585c..68c03b0 100644 --- a/prototypes/connections/module-text.c +++ b/prototypes/connections/module-text.c @@ -12,6 +12,11 @@ enum { CHUNK = 4096 }; bool pure_utf8_step(unsigned *state, PureSpan span) { unsigned remaining = *state & 3, range = *state >> 2; for (size_t i = 0; i < span.size; ++i) { + /* Eight ASCII bytes at a time between characters. */ + uint64_t word; + while (!remaining && span.size - i >= 8 && (memcpy(&word, span.data + i, 8), !(word & UINT64_C(0x8080808080808080)))) + i += 8; + if (i == span.size) break; unsigned char b = span.data[i]; if (remaining) { unsigned low = range == 1 ? 0xA0 : range == 3 ? 0x90 : 0x80; diff --git a/prototypes/connections/serve.c b/prototypes/connections/serve.c index d5c7aa4..87485c0 100644 --- a/prototypes/connections/serve.c +++ b/prototypes/connections/serve.c @@ -80,7 +80,9 @@ typedef struct Connection { RouteKind route; /* An app input's message: its fixed fields from the query, which fields * were given there, and whether the body is a JSON object of the rest. - * Its ingress is [u32le payload size][payload][the JSON as it came]. */ + * Its ingress is [u32le payload size][payload]; with JSON it is [u32le + * payload size | PLACED][u32le start and size of each field][payload: the + * prefix, then the JSON as it came], its strings read where they are. */ unsigned char fields[8 * PURE_MODULE_FIELDS]; size_t field_bytes; bool given[PURE_MODULE_FIELDS], json; @@ -552,6 +554,15 @@ static const char *resolve(const Host *host, Connection *c, char *query, Route * * its length, or 0 when it does not close or holds a control byte. */ static size_t json_string_end(const unsigned char *p, size_t size) { for (size_t i = 0; i < size; ++i) { + /* Eight bytes at a time while none is a quote, a backslash or a control byte. */ + const uint64_t ones = UINT64_C(0x0101010101010101), highs = UINT64_C(0x8080808080808080); + for (uint64_t w, q, s; size - i >= 8; i += 8) { + memcpy(&w, p + i, 8); + q = w ^ (ones * '"'); + s = w ^ (ones * '\\'); + if (((q - ones) & ~q & highs) | ((s - ones) & ~s & highs) | ((w - ones * 0x20) & ~w & highs)) break; + } + if (i == size) break; if (p[i] == '"') return i + 1; if (p[i] < 0x20) return 0; if (p[i] == '\\' && ++i == size) return 0; @@ -617,68 +628,118 @@ static size_t json_space(const unsigned char *p, size_t at, size_t size) { return at; } -/* An app input's JSON body, as it came after the payload's room, becomes its - * message's payload: an object with a member for each field the query did - * not give (a u32, a number, or a string for bytes and text), and no other. +/* Parsing a JSON body in place: the payload it is in, the placed fields' + * table, and which fields it gave. */ +#define PLACED (UINT32_C(1) << 31) +typedef struct { + Connection *c; + const PureInputShape *shape; + unsigned char *payload, *table; + bool seen[PURE_MODULE_FIELDS]; +} Body; + +/* The field named path.key (key alone at the top), or the field count when + * none is; group says whether fields are named inside it instead. */ +static uint32_t field_named(const Body *b, const char *path, size_t path_size, const unsigned char *key, + size_t key_size, bool *group) { + const PureModule *m = b->c->host->module; + size_t size = path_size + !!path_size + key_size; + *group = false; + for (uint32_t f = 0; f < b->shape->field_count; ++f) { + const char *name = m->field[b->shape->field_start + f].name; + size_t length = strlen(name); + if (length < size || memcmp(name, path, path_size) || (path_size && name[path_size] != '.') || + memcmp(name + path_size + !!path_size, key, key_size)) continue; + if (length == size) return f; + if (name[size] == '.') *group = true; + } + return b->shape->field_count; +} + +/* An object's members from json[*at] (its opening brace), in the group named + * path: a field's value, or a group's object. Strings stay where they are + * (an escaped one is unescaped over itself, which never makes it longer). * NULL, or the error to answer. */ -static const char *convert(Connection *c) { - const PureModule *m = c->host->module; - const PureInputShape *shape = &m->input[c->input]; - const unsigned char *json = c->body; - size_t size = c->body_size; - unsigned char *payload = c->ingress + 4; - struct { const unsigned char *value; size_t size; bool string; } member[PURE_MODULE_FIELDS]; - bool seen[PURE_MODULE_FIELDS] = {false}; - size_t at = json_space(json, 0, size); - if (at == size || json[at++] != '{') return "json"; - at = json_space(json, at, size); - for (bool first = true; at < size && json[at] != '}'; first = false) { +static const char *members(Body *b, unsigned char *json, size_t size, size_t *at, const char *path, size_t path_size) { + const PureModule *m = b->c->host->module; + if (*at == size || json[(*at)++] != '{') return "json"; + *at = json_space(json, *at, size); + for (bool first = true; *at < size && json[*at] != '}'; first = false) { if (!first) { - if (json[at++] != ',') return "json"; - at = json_space(json, at, size); + if (json[(*at)++] != ',') return "json"; + *at = json_space(json, *at, size); } - size_t key_end = at < size && json[at] == '"' ? json_string_end(json + at + 1, size - at - 1) : 0; + size_t key_end = *at < size && json[*at] == '"' ? json_string_end(json + *at + 1, size - *at - 1) : 0; if (!key_end) return "json"; - const unsigned char *key = json + at + 1; + const unsigned char *key = json + *at + 1; size_t key_size = key_end - 1; - at = json_space(json, at + 1 + key_end, size); - if (at == size || json[at++] != ':') return "json"; - at = json_space(json, at, size); - uint32_t f = 0; - while (f < shape->field_count && (strlen(m->field[shape->field_start + f].name) != key_size || - memcmp(m->field[shape->field_start + f].name, key, key_size))) ++f; - if (f == shape->field_count || seen[f] || c->given[f]) return "json_field"; - seen[f] = true; - bool integer, string = at < size && json[at] == '"'; - size_t length = string ? json_string_end(json + at + 1, size - at - 1) : json_number(json + at, size - at, &integer); - if (!length) return "json"; - member[f].value = json + at + string; - member[f].size = length - string; - member[f].string = string; - at = json_space(json, at + string + length, size); - } - if (at == size || json_space(json, at + 1, size) != size) return "json"; - /* The fields in order: fixed ones into the prefix, strings after it. */ - size_t used = shape->prefix; - for (uint32_t f = 0; f < shape->field_count; ++f) { - const PureField *field = &m->field[shape->field_start + f]; - bool variable = field->type == PURE_FIELD_BYTES || field->type == PURE_FIELD_TEXT; - if (!seen[f]) { - if (!c->given[f]) return "missing_field"; + *at = json_space(json, *at + 1 + key_end, size); + if (*at == size || json[(*at)++] != ':') return "json"; + *at = json_space(json, *at, size); + bool group; + uint32_t f = field_named(b, path, path_size, key, key_size, &group); + if (*at < size && json[*at] == '{') { + if (!group) return f < b->shape->field_count ? "json_type" : "json_field"; + char inner[PURE_MODULE_NAME]; + size_t inner_size = path_size + !!path_size + key_size; + memcpy(inner, path, path_size); + if (path_size) inner[path_size] = '.'; + memcpy(inner + path_size + !!path_size, key, key_size); + const char *why = members(b, json, size, at, inner, inner_size); + if (why) return why; + *at = json_space(json, *at, size); continue; } - if (member[f].string != variable) return "json_type"; - if (!variable) { - if (!fixed_field(field, member[f].value, member[f].size, payload)) return "json_type"; + if (f == b->shape->field_count) return group ? "json_type" : "json_field"; + if (b->seen[f] || b->c->given[f]) return "json_field"; + b->seen[f] = true; + const PureField *field = &m->field[b->shape->field_start + f]; + bool variable = field->type == PURE_FIELD_BYTES || field->type == PURE_FIELD_TEXT, integer; + if (*at < size && json[*at] == '"') { + size_t end = json_string_end(json + *at + 1, size - *at - 1), written; + if (!end) return "json"; + if (!variable) return "json_type"; + unsigned char *value = json + *at + 1; + if (!memchr(value, '\\', end - 1)) written = end - 1; + else if (!json_unescape(value, end - 1, value, &written)) return "json"; + if (field->type == PURE_FIELD_TEXT && !pure_utf8_valid((PureSpan){value, written})) return "json"; + put_u32(b->table + 8 * f, (uint32_t)(value - b->payload)); + put_u32(b->table + 8 * f + 4, (uint32_t)written); + *at = json_space(json, *at + 1 + end, size); continue; } - size_t written; - if (!json_unescape(member[f].value, member[f].size, payload + used, &written) || - !pure_utf8_valid((PureSpan){payload + used, written})) return "json"; - if (field->offset != PURE_FIELD_REST) put_u32(payload + field->offset, (uint32_t)written); - used += written; + size_t length = json_number(json + *at, size - *at, &integer); + if (!length) { + static const char *const words[] = {"true", "false", "null"}; + for (size_t w = 0; w < 3 && !length; ++w) + if (size - *at >= strlen(words[w]) && !memcmp(json + *at, words[w], strlen(words[w]))) return "json_type"; + return *at < size && json[*at] == '[' ? "json_type" : "json"; + } + if (variable || !fixed_field(field, json + *at, length, b->payload)) return "json_type"; + *at = json_space(json, *at + length, size); } - put_u32(c->ingress, (uint32_t)used); + if (*at == size) return "json"; + ++*at; + return NULL; +} + +/* An app input's JSON body becomes its message: an object with a member for + * each field the query did not give (a u32, a number, or a string for bytes + * and text), groups as objects inside it, and nothing else. Fixed fields go + * into the prefix; strings stay in the body, and the table says where. + * NULL, or the error to answer. */ +static const char *convert(Connection *c) { + const PureInputShape *shape = &c->host->module->input[c->input]; + Body b = {.c = c, .shape = shape, .table = c->ingress + 4}; + b.payload = b.table + 8 * shape->field_count; + unsigned char *json = c->body; + size_t size = c->body_size, at = json_space(json, 0, size); + const char *why = members(&b, json, size, &at, "", 0); + if (why) return why; + if (json_space(json, at, size) != size) return "json"; + for (uint32_t f = 0; f < shape->field_count; ++f) + if (!b.seen[f] && !c->given[f]) return "missing_field"; + put_u32(c->ingress, (uint32_t)(c->field_bytes + c->body_size) | PLACED); return NULL; } @@ -714,8 +775,8 @@ static void start_request(Connection *c, size_t head_size) { /* A document's PUT carries its body; an input's event its payload's size, * the payload (the fixed fields, then the body), and room for its JSON. */ bool write = route.kind == ROUTE_INPUT || c->put; - size_t input_size = route.kind == ROUTE_INPUT ? 4 + c->field_bytes + c->body_size * (c->json ? 2 : 1) : - write ? c->body_size : 0; + size_t table = route.kind == ROUTE_INPUT && c->json ? 8 * (size_t)host->module->input[c->input].field_count : 0; + size_t input_size = route.kind == ROUTE_INPUT ? 4 + table + c->field_bytes + c->body_size : write ? c->body_size : 0; PureResult admitted = pure_runtime_accept(&host->runtime, write ? PURE_INPUT_WRITE : PURE_INPUT_READ, c->key, route.kind == ROUTE_INPUT ? 0 : c->expected, input_size, &c->ticket); if (admitted != PURE_OK) { @@ -732,8 +793,8 @@ static void start_request(Connection *c, size_t head_size) { c->body = ingress; if (route.kind == ROUTE_INPUT) { put_u32(ingress, (uint32_t)(c->field_bytes + c->body_size)); - if (c->field_bytes) memcpy(ingress + 4, c->fields, c->field_bytes); - c->body = ingress + 4 + c->field_bytes + (c->json ? c->body_size : 0); + if (c->field_bytes) memcpy(ingress + 4 + table, c->fields, c->field_bytes); + c->body = ingress + 4 + table + c->field_bytes; } size_t take = c->in_used < c->body_size ? c->in_used : c->body_size; if (take) memcpy(c->body, c->in, take); @@ -1005,7 +1066,9 @@ static PureResult show_view(PureApp *app, const Route *route, size_t available, * whose reply (a field of it) is borrowed from the request. */ static PureResult apply_input(PureApp *app, const Route *route, const PureEffect *effect, Applied *out) { const PureInputShape *shape = &app->module->input[route->target]; - PureSpan payload = {effect->value.data + 4, get_u32(effect->value.data)}; + uint32_t header = get_u32(effect->value.data); + size_t table = header & PLACED ? 8 * (size_t)shape->field_count : 0; + PureSpan payload = {effect->value.data + 4 + table, header & ~PLACED}; PureResult status; if (shape->kind == PURE_KIND_TEXT) { status = pure_app_text(app, route->target, payload); @@ -1013,7 +1076,11 @@ static PureResult apply_input(PureApp *app, const Route *route, const PureEffect status = pure_app_input(app, route->target, PURE_EDGE_DOWN); if (status == PURE_OK) status = pure_app_input(app, route->target, PURE_EDGE_UP); } else { - status = pure_app_message(app, route->target, NULL, -1, payload); + uint32_t placed[2 * PURE_MODULE_FIELDS]; + for (size_t i = 0; table && i < 2 * (size_t)shape->field_count; ++i) + placed[i] = get_u32(effect->value.data + 4 + 4 * i); + status = table ? pure_app_message_fields(app, route->target, NULL, -1, payload, placed) : + pure_app_message(app, route->target, NULL, -1, payload); Reply reply = {0}; if (status == PURE_OK) pure_app_reply(app, measure_reply, &reply); out->reply = (PureSpan){reply.size ? reply.first : NULL, reply.size}; |
