From 781b484fc326c58d9a8962696fa84a785c36ae9a Mon Sep 17 00:00:00 2001 From: eunhhu Date: Wed, 30 Sep 2026 05:59:50 +0100 Subject: Say how growing a board two nodes share went (105) 103's next step: grow a real app, adding and reordering fields, cutting the connection, starting again, and write down what gets in the way. Two nodes share a board (from Vega's examples/board.aup), each sending its changes to the other, the later change winning. Upgrading them one after the other deadlocked: fixed by recasting waiting sends by name (9aa123e) and taking fields by name (014, 5ce7655), and under it a link that stopped for good when a refused stream's try was lost (baaf955). Now the older node's changes reach the newer one during the upgrade, the newer one's new field waits for the older, and the boards agree a second after. Fields in another order agree at once; a partition healed agrees on the later change; both nodes started again go on. Two rough edges eased on the way (the node's name, message like in aup); sends and applies are still written field by field. CHECKPOINT's row for links says so. --- CHECKPOINT.md | 2 +- docs/research/105-growing-a-shared-board.md | 119 ++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 docs/research/105-growing-a-shared-board.md diff --git a/CHECKPOINT.md b/CHECKPOINT.md index 7692742..644a6cd 100644 --- a/CHECKPOINT.md +++ b/CHECKPOINT.md @@ -17,7 +17,7 @@ | 인터페이스 | 한 명세가 TUI(셀 단위 갱신), CLI, 문장(NUI), 웹 GUI가 된다. 저장하면 실행 중인 앱이 상태를 이어 새 모듈로 바뀐다(`aup watch`, `vega … --watch`) | Vega `docs/design/001-first-component.md`, Vega `make check`(가상 터미널, 헤드리스 Chromium 포함) | | 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) | +| 노드 사이 전달 | 연결 없는 링크가 기본이다(`--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) | | 메모리 한도 | 앱마다 상태의 예산이 있다(`--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) | diff --git a/docs/research/105-growing-a-shared-board.md b/docs/research/105-growing-a-shared-board.md new file mode 100644 index 0000000..f0b8b97 --- /dev/null +++ b/docs/research/105-growing-a-shared-board.md @@ -0,0 +1,119 @@ +# 105 — 두 노드가 함께 쓰는 보드를 키우며 + +2026-09-30. [103](103-review-004-defects.md)의 "다음" 둘째(리뷰 004의 권고)다. 보드·메신저 계열의 실제 앱 하나를 키우면서 +필드를 더하고 재배치하고, 연결을 끊고, 다시 띄우고 맞추며, 무엇에 걸리는지 적는다. + +착수 기준: + +- **바꿀 결정.** 앱을 키울 때 걸리는 것을 언어(aup), 런타임, 링크, 진단으로 가르고, 앱을 멈추게 하거나 데이터를 잃게 하는 것부터 + 고친다. +- **최소 확인.** Vega의 `examples/board.aup`에서 시작해, 두 노드가 서로에게 바뀐 카드를 보내는 보드를 키운다. 한 노드씩 올리는 + 업그레이드를 거친 뒤 두 판이 같아야 하고, 그동안 잃은 변경이 없어야 한다. +- **중단 기준.** 고치려면 런타임에 보드 전용 개념(카드, 판)을 넣어야 한다면 멈춘다. + +## 쉬운 말로 + +- 알아낸 것: + - **필드 하나를 더하는 업그레이드가 두 노드를 영영 멈췄다.** 한 노드를 새 카드(`who`)로 올리면 두 노드가 서로의 보내기를 "다른 + 모양"이라며 거절했다. 옛 노드는 옛 보내기가 기다린다는 이유로 새 모듈로 시작하지도 못했다. 나오는 말은 `Cannot start + the module on the store`뿐이었다. + - 그 밑에 결함이 둘 더 있었다. 거절된 스트림의 시험 패킷이 사라지면 링크가 영영 멈췄다. 링크는 필드의 순서까지 같아야 받았다. + - 모두 고쳤다(아래 "고친 것"). 지금은 한 노드를 올리는 동안에도 옛 노드의 변경이 새 노드로 가고, 새 노드의 새 필드는 옛 노드가 + 올라갈 때까지 기다린다. 올린 뒤 1초 안에 두 판이 같다. +- 곁에서 찾아 고친 것: 스냅숏 등록부의 참조 수 경쟁과 앱을 멈출 때의 누수(`eebdeea`가 남긴 것). AddressSanitizer로 누수를 세니 + 보였다. `make check`가 이제 그 빌드도 돌린다. +- 필드 순서를 바꾼 판(v4)은 한 노드만 올린 채로 0.1초 만에 같아진다. 두 노드를 갈라 같은 카드를 양쪽에서 고치면, 다시 이을 때 + 나중 변경이 두 판 모두에서 이긴다. 두 노드를 다 멈췄다 띄워도 이어진다. +- 남은 걸림: 보내기와 받은 것을 적용하는 곳은 여전히 필드마다 적는다(언어). 출력과 입력의 필드 목록은 `message like`로 한 번만 + 적는다(aup `aa279fb`). 노드 이름은 시작 줄이 알린다(`f695e9a`). + +## 앱 + +두 노드가 같은 모듈을 돈다. 카드를 더하거나 옮기거나 누구의 것으로 정하면 그 카드를 상대에게 보내고(`send changed`), +상대에게서 온 변경(`on remote`)은 더 늦은 것이 이긴다(시각, 같으면 노드 이름). 노드는 서로를 `pure://`로 봉인해 잇는다. + +| 판 | 바뀐 것 | +|---|---| +| v2 | 공유: 카드 {title, lane, since, node}, `output changed`, `input remote` | +| v3 | 카드가 누구의 것인가: `who` 필드와 `input assign` | +| v4 | 카드와 메시지의 필드 순서를 바꿨다 | +| v5 | 받는 입력을 `input remote = message like changed`로 적었다 | + +## 걸린 것 + +| # | 걸림 | 어디 | 어떻게 됐나 | +|---|---|---|---| +| 1 | 카드의 "누가 바꿨나" 필드를 `by`로 쓸 수 없다(`list of Card by title`의 예약어) | aup | 진단은 분명하다. `node`로 썼다 | +| 2 | 노드 이름을 주지 않으면 모듈 파일 이름(`board2`)이 된다. 링크의 source가 `board2.changed`가 되어 받는 쪽(`--sender a.changed`)이 거절했다 | pure-serve | `--name`으로 풀었다. 이제 시작 줄이 이름을 알리고 사용법이 기본값을 말한다(`f695e9a`) | +| 3 | 필드 하나(`who`)를 더하려고 레코드, 출력, 입력, 보내기 셋, 받은 것을 적용하는 곳을 고쳤다 | aup | 출력과 입력은 `message like`로 한 번 적는다(aup `aa279fb`). 보내기와 적용은 여전히 필드마다다(남음) | +| 4 | 한 노드씩 올리면 교착: 새 노드는 옛 보내기를, 옛 노드는 새 보내기를 거절하고, 옛 노드는 옛 보내기가 기다려 새 모듈로 시작할 수 없다 | 런타임, 링크 | 고쳤다: 기다리는 보내기를 이름으로 옮긴다(`290cbdb`), 필드를 이름으로 받는다(014) | +| 5 | 시작이 거절될 때 이유를 말하지 않는다 | 런타임 | 고쳤다: `PureApp.why`, pure-serve와 Vega가 찍는다 | +| 6 | 옛 노드가 새 노드의 보내기를 거절하는 동안 옛 노드가 다시 뜨면, 새 노드의 링크가 영영 멈췄다 | 링크 | 고쳤다(`d6f4eb8`) | +| 7 | 출력과 입력의 필드가 같아도 순서가 다르면 영영 거절된다 | 링크 | 고쳤다(014) | + +### 4. 업그레이드의 교착 + +리뷰 004의 F3는 필드가 바뀐 출력에 보내기가 기다리면 모듈을 거절하게 했다. 그 바이트의 뜻이 바뀌기 때문이다. 안전하지만, +한 노드씩 올리는 업그레이드에서는 빠져나갈 길이 없었다. 링크도 입력과 모양이 똑같아야 받았다. + +- **기다리는 보내기는 이름으로 옮긴다.** 목록의 항목이 모듈이 바뀔 때 옮겨지는 규칙과 같다. 새 타입이 담을 수 있는 값은 가고, + 나머지는 0이나 빈 값이다(`pure_send_recast`). 시작과 교체 모두 한다. +- **옮긴 뜻은 죽어도 남아야 한다.** 옮긴 보내기를 outbox 이미지에 쓴 뒤, 새 필드를 links에 적는 저장 전에 죽으면, 다음 + 시작이 한 번 더 옮겨 뜻이 뒤섞인다. 그래서 outbox 이미지가 제 보내기의 필드 이름을 스스로 적는다("PLO1"). 목록 이미지가 + 레코드의 필드 이름을 적는 것과 같다. 모듈의 출력이 이미지와 다르면 보내기를 기록하기 전에 이미지를 다시 쓴다. + test-module `output_meaning`이 교체 뒤 저장 없이 멈췄다가 다시 떠도 뜻이 맞는지 본다. +- **필드는 이름으로 받는다**([설계 014](../design/014-fields-by-name.md)). 받는 입력이 보낸 필드를 모두 가지면 받는다. 입력에만 + 있는 필드는 0이나 빈 값이다. 입력에 없는 필드를 보내면 받지 않는다. 받으면 그 값을 버리게 되고, 버린 채 번호를 들으면 그 + 값은 다시 오지 않아 두 판이 영영 달라진다. 그래서 필드를 더할 때는 받는 쪽부터, 뺄 때는 보내는 쪽부터 올리면 멈추지도 잃지도 + 않는다. + +### 6. 사라진 시험 패킷 + +거절된 스트림의 보내는 쪽은 기다림마다 기록을 실은 패킷 하나로 시도하고, 그 패킷이 REFUSED 없이 확인되면 거절이 끝난다. 시도가 +아무에게도 닿지 않으면(받는 쪽이 다시 뜨는 중이었다) 확인이 오지 않는다. 보내는 쪽은 에폭을 잃었다고 보고 새로 협상한 뒤에도 +그 시도를 기다리며 아무것도 보내지 않았다. 날고 있는 기록이 없으니 탐침도 없었다. 시도가 사라졌다고 판단되면(손실, 에폭을 +잃음) 다음 패킷이 다시 시도한다. test-link `refused_restarted`: 고치기 전 120초 동안 0건 저장, 고친 뒤 18.2초에 200건. + +### 갈라짐과 재동기화 + +v4 두 노드에서, B를 아무도 듣지 않는 곳으로 떼어 놓고(듣는 포트와 보내는 곳을 둘 다 바꿔 띄운다) 같은 카드를 B가 먼저 `kim`에게, +A가 나중에 `lee`에게 준다. B는 떨어진 채 카드 하나를 더한다. 떨어진 동안 두 판은 다르다. B를 제자리로 다시 띄우면 서로 기다리던 +변경이 가고, 두 판 모두 A의 나중 배정(`lee`)과 B가 만든 카드를 갖는다. 앱의 규칙(더 늦은 변경이 이긴다)과 링크의 "번호마다 한 +번"이 합쳐진 결과다. 런타임에 보드 전용 코드는 없다. + +## 결과 + +한 노드씩 올리는 업그레이드(v2에서 v3). A를 먼저 올리고, A는 카드 하나를 `eun`의 것으로 정하고, 아직 v2인 B는 카드 하나를 +더한다. 3초 뒤 B를 올린다. + +| | 고치기 전 | 기다리는 보내기를 옮긴 뒤 | 필드를 이름으로 받은 뒤 | +|---|---|---|---| +| B가 v3로 시작 | 안 됨(교착) | 됨 | 됨 | +| B의 새 카드가 A에 닿음 | 아니오 | B를 올린 뒤 | B가 v2인 동안 | +| A의 배정이 B에 닿음 | 아니오 | 아니오(링크가 멈춤, 6) | B를 올린 뒤 | +| 두 판이 같아짐 | 아니오 | 아니오 | B를 올리고 1.0초 뒤 | + +검사: + +- check.py `server_upgrade`: 받는 쪽이 꺼진 채 옛 출력으로 보낸 5건이 기다린다. 보내는 쪽이 새 출력으로 다시 뜨면 이름으로 옮긴다. + 출력이 없는 모듈은 그 이름을 대며 거절된다. 옛 받는 쪽은 `note`가 없어 거절하고(버리지 않는다), 필드 순서가 다른 새 받는 + 쪽이 8건을 한 번씩 받는다. +- check.py `server_fields`: 한 노드의 출력 다섯(링크 셋, HTTP 둘)이 한 받는 쪽으로 간다. 순서만 다른 필드를 받고, 필드가 하나 + 더 있는 입력은 없는 메시지를 빈 값으로 받는다. 필드가 모자란 입력은 링크(REFUSED 4)와 HTTP(`json_field`) 모두에서 거절하고, + 받는 쪽이 그 필드를 갖고 다시 뜨면 기다리던 것이 모두 온다(값을 잃지 않는다). +- test-link `named`: FIELDS를 대는 다른 모양의 스트림이 받아들여져 200건이 저장되고, 전달마다 필드 목록이 따라간다. 받지 않는 + 입력은 이유 4로 거절한다. + +이어서(같은 두 노드, 같은 저장소): + +| 단계 | 결과 | +|---|---| +| v4를 A에만(필드 순서가 다르다) | 두 방향 모두 곧바로 간다. 0.1초 만에 같다 | +| B도 v4 | 같다 | +| 갈라 놓고 양쪽에서 같은 카드를 고침 | 떨어진 동안 다르다. 다시 이으면 나중 변경(`lee`)으로 같다 | +| 둘 다 멈췄다 띄움, 카드 하나 더함 | 같다 | + +## 다음 + +- 걸림 3의 나머지: 보내기와 받은 것의 적용을 필드마다 적지 않게 하는 것(레코드를 펼쳐 보내기 같은 것)은 aup의 일이다. +- 다른 모양의 기록을 옮기는 비용은 재지 않았다(014의 중단 기준). 업그레이드하는 동안만 드는 비용이다. -- cgit v1.2.3