aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--benchmarks/http/load.c17
-rw-r--r--docs/design/008-module-format.md2
-rw-r--r--docs/research/079-lent-views.md52
-rw-r--r--prototypes/connections/module.c6
-rw-r--r--prototypes/connections/module.h3
-rw-r--r--prototypes/connections/serve.c56
-rw-r--r--prototypes/connections/state.h4
7 files changed, 113 insertions, 27 deletions
diff --git a/benchmarks/http/load.c b/benchmarks/http/load.c
index fd074e8..fe50deb 100644
--- a/benchmarks/http/load.c
+++ b/benchmarks/http/load.c
@@ -2,14 +2,15 @@
/* 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 KEYS [keep-alive|close] [BODY]
+ * load PORT CONNECTIONS SECONDS get|put|score|total|page|note 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
* first); a 412 takes the revision from the ETag and tries again.
* 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.
+ * /views/leaders?from=0&count=20. note reads GET /views/page (a text state
+ * shown as it is, as in a notes app).
* Prints one JSON line: requests/s, statuses, latency percentiles (µs). */
#include <arpa/inet.h>
#include <errno.h>
@@ -67,7 +68,7 @@ static uint64_t percentile(uint64_t total, double p) {
}
static int port, connections_count, keys;
-static enum { GET, PUT, SCORE, TOTAL, PAGE } method;
+static enum { GET, PUT, SCORE, TOTAL, PAGE, NOTE } method;
static bool put, keep_alive;
static char *body;
static size_t body_bytes;
@@ -82,7 +83,7 @@ static void prepare(Connection *c) {
snprintf(c->request, sizeof(c->request), "POST /inputs/score?points=1 HTTP/1.1\r\nHost: localhost\r\n"
"Content-Length: %d\r\n%s\r\n%s", length, closing, name) :
snprintf(c->request, sizeof(c->request), "GET /views/%s HTTP/1.1\r\nHost: localhost\r\n%s\r\n",
- method == TOTAL ? "total" : "leaders?from=0&count=20", closing));
+ method == TOTAL ? "total" : method == NOTE ? "page" : "leaders?from=0&count=20", closing));
c->sent = c->received = c->body_sent = c->body_size = 0;
c->started = now_us();
return;
@@ -140,15 +141,15 @@ 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 KEYS [keep-alive|close] [BODY]\n", stderr);
+ fputs("load PORT CONNECTIONS SECONDS get|put|score|total|page|note 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"};
- for (method = GET; method <= PAGE && strcmp(argv[4], methods[method]); ++method) {}
- if (method > PAGE) { fprintf(stderr, "load: unknown method %s\n", argv[4]); return 2; }
+ 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; }
put = method == PUT;
keys = atoi(argv[5]);
keep_alive = argc > 6 && !strcmp(argv[6], "keep-alive");
diff --git a/docs/design/008-module-format.md b/docs/design/008-module-format.md
index db597e7..1c4d397 100644
--- a/docs/design/008-module-format.md
+++ b/docs/design/008-module-format.md
@@ -252,7 +252,7 @@ text·리스트가 남을 때만 위치를 유지한다.
| 요청 | 뜻 |
|---|---|
-| `GET /views/이름` | 보기의 값. 수는 가장 짧은 왕복 10진수, text는 UTF-8 그대로(`text/plain`) |
+| `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`(메시지 필드) |
| `GET`·`PUT /objects/키` | 모듈에 키별 메시지 `show`·`edit`가 있으면 기존 문서 서버(`server.c`)와 같은 계약. `GET`은 `show`, `PUT`(`If-Match`/`If-None-Match`)은 `edit` |
diff --git a/docs/research/079-lent-views.md b/docs/research/079-lent-views.md
new file mode 100644
index 0000000..1cb8453
--- /dev/null
+++ b/docs/research/079-lent-views.md
@@ -0,0 +1,52 @@
+# 079 — 보기도 빌려준다: text 상태를 저장된 익스텐트 그대로
+
+2026-09-26. [로드맵](../roadmap.md) 단기 1 "Vectored loan 완성".
+
+착수 기준:
+
+- 바꿀 결정: 앱의 보기를 HTTP로 내보낼 때 값을 버퍼에 복사해 보낼지, 저장소 매핑의 익스텐트를 그대로 `writev`에 넘길지.
+- 남은 질문: 문서 GET은 이미 빌려준다([075](075-http-host-against-axum.md)). 앱 보기(`GET /views/NAME`)는 text 상태를
+ 그대로 보여 줄 때도 매번 복사했다. 빌려주면 얼마나 달라지고, 빌려준 것은 언제나 돌아오는가(답이 끝날 때, 읽지 않고
+ 떠날 때, 다른 스레드가 답할 때, 그사이 저장소가 압축될 때)?
+- 최소 확인: 같은 1 MiB 보기를 복사하는 빌드와 빌려주는 빌드로 번갈아 재고, 빌려준 동안과 뒤의 저장소 매핑 수와 예산을
+ 확인한다.
+- 중단 기준: 한 번이라도 다른 버전이 섞여 나가거나, 답이 끝난 뒤 매핑이나 예산이 남으면 되돌린다.
+
+## 한 것
+
+- `pure_module_text_state`: 보기가 text 상태 그대로(`view page = note`, 명령 하나 `TEXT_LOAD`)인지 알려 준다.
+- `pure-serve`는 그런 보기의 text draft 현재 버전을 저장소 매핑이 모두 담고 있으면 그 익스텐트를 답의 본문으로 빌려준다.
+ 문서 GET과 같은 경로다. 답은 매핑 참조를 쥐고(예산에 계상), 소켓에 다 들어가고 티켓이 풀릴 때 놓는다. 방금 고쳐
+ 아직 저장소에 없는 조각이 있으면 전처럼 복사한다. 계산한 text(보간, 조건식)는 복사한다.
+- 익스텐트 배열은 필요한 만큼 늘린다(세는 패스가 없다). 대여 답도 보기의 `Content-Type`을 유지한다.
+
+## 장치가 말한 것
+
+Raspberry Pi 5, 서버는 CPU 0–2, 부하는 CPU 3. 1 MiB 메모를 `GET /views/page`로 8연결이 5초간 읽는다. 복사 빌드와 대여
+빌드를 번갈아 두 번씩 쟀다.
+
+| | 복사 | 대여 |
+|---|---:|---:|
+| 연결 스레드 1: 답/s | 776–817 | **1,875–1,901** |
+| p50 | 9.2 ms | **3.5 ms** |
+| 최대 RSS | 10.3 MiB | **5.7 MiB** |
+| 연결 스레드 3 + 소유자: 답/s | 590–617 | **1,167–1,194** |
+| 최대 RSS | 11.9–12.4 MiB | **6.2 MiB** |
+
+같은 크기의 문서 GET은 axum 1,372/s, drogon 708/s였다([075](075-http-host-against-axum.md)). 큰 답에서는 스레드 하나가 더
+빠르다. 답 하나에 1 MiB를 받는 부하 생성기(코어 하나)와 스레드 사이의 인계가 몫을 차지하는 것으로 보인다.
+
+## 회수 확인
+
+aup `make check`의 `server_lent_views`(스레드 1, 연결 스레드 3 + 소유자):
+
+- 한 연결에 1 MiB 보기 32개를 이어 보내면 커널 버퍼가 받지 못한 답이 빌린 채 기다린다. 그동안 메모를 다섯 번 바꿔도
+ 32개 답은 각각 한 버전 전체로 나온다(첫 답은 바꾸기 전 버전).
+- 기다리는 동안 저장소 파일의 매핑은 2개다(빌려준 옛 매핑 + 현재). 1 MiB 쓰기가 이어지면 저장소가 압축해 파일을 바꾸는데,
+ 빌려준 옛 매핑은 `(deleted)`인 옛 파일의 페이지를 쥐고 있다. 답을 다 읽으면 1개로 돌아온다.
+- 읽지 않고 연결을 닫아도(답의 티켓이 끝남) 1개로 돌아오고, 서버를 멈출 때 `retained_payload_bytes`는 0이다. 연결
+ 스레드 3개일 때는 소유자가 빌려준 매핑을 연결 스레드가 놓는다(참조 수가 원자적이다).
+
+검사를 만들며 두 가지를 배웠다. 수신 버퍼를 4 KiB로 줄인 독자는 루프백에서도 지연 ACK와 맞물려 초당 40 KB쯤으로 기어간다.
+그러면 서버의 쓰기가 10초 넘게 진전이 없어 연결 기한이 연결을 닫는다(의도된 동작). 그래서 답이 서버 쪽에 걸리게 하려면
+수신 버퍼를 줄이는 대신, 커널이 흡수하는 양(수 MB)보다 훨씬 많이 이어 보내야 한다.
diff --git a/prototypes/connections/module.c b/prototypes/connections/module.c
index 1b96ac1..e4c7309 100644
--- a/prototypes/connections/module.c
+++ b/prototypes/connections/module.c
@@ -662,6 +662,12 @@ int pure_module_position(const PureModule *m, uint32_t view) {
return block->length == 2 && code[0].op == PURE_OP_INDEX_LOAD && code[1].op == PURE_OP_INDEX_NUM ? (int)code[0].arg : -1;
}
+int pure_module_text_state(const PureModule *m, uint32_t view) {
+ if (view >= m->views || m->view_type[view] != PURE_VIEW_TEXT || m->view_table[view]) return -1;
+ const PureBlock *block = &m->view_code[view];
+ return block->length == 1 && m->code[block->start].op == PURE_OP_TEXT_LOAD ? (int)m->code[block->start].arg : -1;
+}
+
int pure_module_column(const PureModule *m, uint32_t table, const char *name) {
for (uint32_t i = 0; i < m->views; ++i)
if (m->view_table[i] == table + 1 && !strcmp(m->view_names[i], name)) return (int)i;
diff --git a/prototypes/connections/module.h b/prototypes/connections/module.h
index c3edc54..313c6e1 100644
--- a/prototypes/connections/module.h
+++ b/prototypes/connections/module.h
@@ -148,6 +148,9 @@ int pure_module_column(const PureModule *, uint32_t table, const char *name);
/* The index state a number view shows as its position (a view that is just
* `at.position`), or -1: what a host moves with pure_app_point. */
int pure_module_position(const PureModule *, uint32_t view);
+/* The text state a text view shows as it is (a view that is just the
+ * state), or -1: a host may lend its stored extents instead of copying. */
+int pure_module_text_state(const PureModule *, uint32_t view);
/* Keyed collections live in a data host (for example the tree store). One
* event opens at most one document, the message's key, at its current
diff --git a/prototypes/connections/serve.c b/prototypes/connections/serve.c
index 469c487..5e11f67 100644
--- a/prototypes/connections/serve.c
+++ b/prototypes/connections/serve.c
@@ -825,17 +825,34 @@ static bool measure_reply(void *context, PureSpan span) {
return true;
}
-/* The reply's extents, if the store mapping holds every one. */
-typedef struct { struct iovec *extents; size_t count; const PureMapping *mapping; bool held; } Gather;
+/* A value's extents, if the store mapping holds every one. */
+typedef struct { struct iovec *extents; size_t count, capacity; const PureMapping *mapping; bool held; } Gather;
static bool gather_extents(void *context, PureSpan span) {
Gather *g = context;
if (!span.size) return true;
- g->held = g->held && pure_mapping_holds(g->mapping, span);
+ g->held = g->held && pure_mapping_holds(g->mapping, span) &&
+ grow(&g->extents, &g->capacity, g->count + 1, sizeof(*g->extents));
if (g->held) g->extents[g->count++] = (struct iovec){(void *)span.data, span.size};
return g->held;
}
+/* A gathered value goes out as the mapping's extents, not copied: the answer
+ * holds the mapping (charged to the budget) until it is sent and its ticket
+ * released. false (and nothing held) when some extent was not stored. */
+static bool lend(Applied *out, PureMapping *mapping, Gather *gather, bool read, size_t size) {
+ if (!read || !gather->held) {
+ free(gather->extents);
+ pure_mapping_release(mapping);
+ return false;
+ }
+ out->extents = gather->extents;
+ out->extent_count = gather->count;
+ out->resource = (PureResource){.body = {NULL, size}, .allocation = mapping, .allocation_size = size,
+ .release = release_loan};
+ return true;
+}
+
static bool copy_reply(void *context, PureSpan span) {
Reply *r = context;
if (span.size) memcpy(r->bytes + r->size, span.data, span.size);
@@ -894,9 +911,11 @@ static void put_number(Output *o, double value) {
/* A view's value: a number in Pure's shortest form or a text, as text; a
* table as {"rows":N,"from":F,"items":[{column: value}...]} for the rows
* asked, each cell computed only for them (a cell without a value is null). */
-static PureResult show_view(PureApp *app, const Route *route, size_t available, PureResource *out, const char **type) {
+static PureResult show_view(PureApp *app, const Route *route, size_t available, Applied *answer) {
const PureModule *m = app->module;
uint32_t view = route->target;
+ PureResource *out = &answer->resource;
+ const char **type = &answer->type;
/* Room for a page of short rows at once. */
Output o = {0};
PureResult status = PURE_OK;
@@ -908,6 +927,17 @@ static PureResult show_view(PureApp *app, const Route *route, size_t available,
else status = PURE_NOT_FOUND;
} else if (m->view_type[view] == PURE_VIEW_TEXT) {
*type = "text/plain; charset=utf-8";
+ /* A text state shown as it is goes out as its stored extents. */
+ int state = pure_module_text_state(m, view);
+ PureDraft *text = state >= 0 ? app->texts[state] : NULL;
+ PureVersion version = text ? pure_draft_version(text) : (PureVersion){0, 0};
+ if (version.bytes > available) return PURE_BUDGET;
+ PureMapping *mapping = version.bytes ? pure_draft_mapping(text) : NULL;
+ if (mapping) {
+ Gather gather = {NULL, 0, 0, mapping, true};
+ bool read = pure_draft_read_version(text, version, 0, (size_t)version.bytes, gather_extents, &gather) == PURE_OK;
+ if (lend(answer, mapping, &gather, read, (size_t)version.bytes)) return PURE_OK;
+ }
status = pure_app_view_text(app, view, put_span, &o);
} else {
*type = "application/json";
@@ -986,7 +1016,7 @@ static void apply_effect(Executor *ex, const Job *job, Applied *out) {
*out = (Applied){.host = job->host, .ticket = effect->ticket, .input = effect->input, .route = job->route.kind};
memcpy(out->key, effect->key, strlen(effect->key) + 1);
if (job->route.kind == ROUTE_VIEW) {
- out->status = show_view(app, &job->route, effect->available, &out->resource, &out->type);
+ out->status = show_view(app, &job->route, effect->available, out);
return;
}
if (job->route.kind == ROUTE_INPUT) {
@@ -1012,16 +1042,9 @@ static void apply_effect(Executor *ex, const Job *job, Applied *out) {
/* A stored document is lent, extent by extent, not copied. */
PureMapping *mapping = app->document ? pure_draft_mapping(app->document) : NULL;
if (mapping) {
- Gather gather = {malloc(reply.spans * sizeof(struct iovec)), 0, mapping, true};
- if (gather.extents && pure_app_reply(app, gather_extents, &gather) == PURE_OK && gather.held) {
- out->extents = gather.extents;
- out->extent_count = gather.count;
- out->resource = (PureResource){.body = {NULL, reply.size}, .allocation = mapping,
- .allocation_size = reply.size, .release = release_loan};
- return;
- }
- free(gather.extents);
- pure_mapping_release(mapping);
+ Gather gather = {NULL, 0, 0, mapping, true};
+ bool read = pure_app_reply(app, gather_extents, &gather) == PURE_OK;
+ if (lend(out, mapping, &gather, read, reply.size)) return;
}
unsigned char *copy = malloc(reply.size);
if (!copy) { out->status = PURE_ALLOCATION; return; }
@@ -1107,7 +1130,8 @@ static void answer_applied(Host *host, Applied *a) {
if (!pure_runtime_result(a->ticket, &result)) abort();
/* A lent answer's body is its extents (the runtime charged them). */
if (result.status == PURE_OK && a->extents)
- respond_extents(c, 200, "OK", "none", result.revision, a->extents, a->extent_count, result.body.size);
+ respond_typed(c, 200, "OK", "none", result.revision, a->type ? a->type : "application/octet-stream",
+ a->extents, a->extent_count, result.body.size);
else if (a->route != ROUTE_OBJECT) answer_app(c, result, a->route == ROUTE_VIEW, a->fault, a->type);
else answer(c, result);
drive(c); /* most answers fit the socket buffer at once */
diff --git a/prototypes/connections/state.h b/prototypes/connections/state.h
index 0945475..43489c1 100644
--- a/prototypes/connections/state.h
+++ b/prototypes/connections/state.h
@@ -34,8 +34,8 @@ PureResult pure_store_state(PureStore *, const char *name, PureDraft **out);
* reference to the mapping a draft opened through a handle reads its stored
* nodes from (NULL for other drafts). Spans that mapping holds stay readable
* until the reference is released, after the draft closes and after the
- * store moves on. Spans of a draft's unsaved edits are not in it. Use it on
- * the handle's owner thread. */
+ * store moves on. Spans of a draft's unsaved edits are not in it. Take it on
+ * the handle's owner thread; the reference may be released on any thread. */
typedef struct PureMapping PureMapping;
PureMapping *pure_draft_mapping(const PureDraft *);
bool pure_mapping_holds(const PureMapping *, PureSpan);