aboutsummaryrefslogtreecommitdiffstats
path: root/docs/research/083-delivery-over-http.md
blob: eedc5f8a12d384621ad2677d4f6f0084b02ffdc0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# 083 — 앱의 보냄을 HTTP로: 저장된 것만, 순서대로, 받는 쪽은 한 번

2026-09-27. [로드맵](../roadmap.md) 중기 1 "비동기 아웃바운드 호출"의 첫 단계이자 [078](078-app-connections.md)의 3·4단계.

착수 기준:

- 바꿀 결정: 앱이 다른 서비스를 부를 때 핸들러 안에서 HTTP 클라이언트를 쓰고 재시도·중복 제거·outbox를 앱이 짤지,
  보냄을 이벤트의 일부로 저장하고 호스트가 저장 뒤에 전하며 받는 쪽 런타임이 번호로 한 번만 적용할지.
- 남은 질문: 보내는 서버와 받는 서버가 아무 순간에 죽어도, 보내는 앱이 저장한 메시지가 받는 앱에 정확히 한 번씩
  적용되는가? 전달이 받는 쪽의 저장(동기화)에 묶이면 얼마나 느린가?
- 최소 확인: 두 `pure-serve`를 잇고, 클라이언트 넷이 주문하는 동안 받는 서버와 보내는 서버를 `kill -9`로 죽였다 살린
  뒤, 받는 앱의 합계와 건수가 보내는 앱이 저장한 것과 같은지 본다. 전달 속도를 잰다.
- 중단 기준: 한 번이라도 합계가 다르면(잃거나 겹치면) 되돌린다.

## 의심한 관례

| 관례 | 이 설계 |
|---|---|
| 외부 호출은 핸들러 안에서 한다. 이벤트가 실패하면 보상 호출을 짠다 | `send`는 이벤트의 일부로 저장되고, 호스트는 저장된 보냄만 전한다. 실패한 이벤트는 보낸 적이 없다 |
| 재시도, 중복 제거, outbox 테이블은 앱 코드다 | 번호, 순서, 재시도, 덜어 내기는 호스트가, 들은 번호는 받는 쪽 런타임이 맡는다. 앱에는 `send` 한 줄이다 |
| 서비스 사이에는 메시지 브로커가 있다 | 보내는 앱의 저장소가 곧 줄이다. 받는 쪽이 저장한 뒤에야 덜어 낸다 |
| 묶음 크기는 설정이다 | 받는 쪽이 한 요청에 받을 수를 알리고(`X-Pure-Batch`), 보내는 쪽은 밀린 만큼 묶는다 |

## 한 것

- **배선.** `pure-serve --send 출력=http://호스트:포트/경로`. 보내는 쪽 이름은 `--name`(없으면 모듈 파일 이름)과 출력
  이름을 이은 것(`orders.ordered`)이다. 출력이 있는 앱은 상태가 없어도 소유자를 둔다(보냄이 상태이므로).
- **전달.** 저장이 끝날 때마다(시작할 때 저장소에 남은 것도) 소유자가 배선된 출력의 새 보냄을 JSON으로 만들어 그
  출력의 줄에 넣는다. 출력마다 전달 스레드 하나가 순서대로 `POST`한다(`X-Pure-Source`, `X-Pure-Sequence`,
  `Idempotency-Key`). 2xx면 전해졌고, Pure 받는 쪽이 앱의 거절(실패 종류가 있는 4xx)로 답해도 전해졌다(들었고 거절했다).
  408·429·5xx·응답 없음은 0.1초에서 두 배씩 10초까지 쉬었다가, 다른 4xx는 기록하고 10초마다 다시 보낸다. 버리지 않는다.
  전해진 번호까지 소유자가 덜어 내고(`pure_app_trim`), 다음 저장이 저장소에서 지운다. 연결은 이어 쓰고, 끊긴 연결에 보낸
  것은 새 연결로 한 번 더 보낸다(받는 쪽이 번호로 거른다).
- **받기.** `X-Pure-Source`와 `X-Pure-Sequence`가 붙은 `POST /inputs/이름`을 `pure_app_deliver`로 적용한다. 이미 들은
  번호는 적용하지 않고 200이다. 앱이 거절하면 들은 것으로 치고 그 실패 종류로 답한다. 답에는 `X-Pure-Batch: 256`을 싣는다.
- **묶음.** 그 헤더를 본 보내는 쪽은 줄에 쌓인 연속 번호를 최대 256개까지 JSON 배열 하나로 보낸다(`X-Pure-Count`).
  받는 쪽은 한 요청의 메시지를 차례로 한 묶음에서 적용하고 한 번 저장한다. 거절한 것은 들은 것으로 치고 넘어가고, 다른
  실패에서 멈추면 묶음 전체를 다시 받는다(앞부분은 번호로 걸러진다). 제3자 끝점은 이 헤더를 모르므로 하나씩 받는다.
- JSON 본문은 출력의 필드와 그룹 그대로다([082](082-json-in-place.md)의 입력과 같은 모양). 한 그룹의 필드가 떨어져 있는
  출력은 시작할 때 거절한다. UTF-8이 아닌 bytes 필드는 JSON으로 만들 수 없어 그 출력이 거기서 멈춘다(보냄은 남는다).
- **TLS.** `https://` 받는 곳에는 OpenSSL로 TLS 1.2 이상을 쓴다. 인증서는 시스템 CA(또는 `--send-ca 파일`)로 검증하고, 호스트
  이름(SNI와 `SSL_set1_host`, IP 주소면 그 주소)과 맞아야 한다. 맞지 않으면 기록하고 10초마다 다시 시도한다(보냄은 남는다).

## 응답을 이벤트로

결제처럼 답이 필요한 호출은 `--reply 출력=입력`으로 잇는다. 그 출력으로 보낸 요청의 최종 응답(2xx, 그리고 408·429를 뺀
4xx)이 입력의 메시지 이벤트가 된다.

- 필드는 응답 JSON 객체에서 이름으로 채우고, 모르는 멤버는 값째 건너뛴다(API는 많은 것을 돌려준다). 응답에 없는 필드는
  보낸 메시지의 같은 이름·타입 필드에서 채운다. 그래서 요청의 `order`를 응답 이벤트에서 그대로 볼 수 있다(API가 되풀이하지
  않아도). u32 필드 `http_status`는 응답의 상태 코드다. 어느 쪽에도 없는 필드가 있으면 기록하고 10초마다 다시 보낸다.
- 응답은 보낸 쪽 이름과 보낸 번호로 `pure_app_deliver`된다. 소유자는 응답을 적용한 뒤에 그 보냄을 덜어 내고, 둘은 한 저장에
  담긴다. 그 사이에 죽으면 보냄이 남아 다시 보내지고(`Idempotency-Key`로 API가 같은 답을 준다), 응답은 번호로 한 번만 적용된다.
- 응답을 기다리는 출력은 한 요청에 하나씩 보낸다. 요청이 기다리는 라운드가 있으면 그 저장에 응답을 합치고, 쉴 때는 따로
  저장한다.

**찾은 것: 조각으로 쓰는 서버와 지연 ACK.** 처음에는 응답이 초당 24건쯤밖에 오지 않았다. 파이썬 `http.server`는 헤더와
본문을 두 번에 나눠 쓰고(Nagle이 둘째 조각을 첫 조각의 ACK까지 붙잡는다), 우리 클라이언트는 ACK를 40 ms 늦췄다. 요청마다
40 ms다. 읽을 때마다 `TCP_QUICKACK`으로 곧바로 ACK하자 같은 검사가 30초를 넘기던 것에서 1.8초가 됐다. 받는 서버가 어떻게
쓰든 클라이언트가 막히지 않아야 하므로 클라이언트에서 푼다.

## 장치가 말한 것

Raspberry Pi 5. 받는 서버는 CPU 0–1(연결 스레드 2), 보내는 서버는 CPU 2, 클라이언트(파이썬 16스레드)는 나머지.

| | 하나씩 | 묶음 |
|---|---:|---:|
| 주문 2,992건을 넣는 동안 | 2.49 s(1,200/s) | 1.04 s(2,864/s) |
| 받는 쪽이 다 받은 때 | 7.64 s(**391건/s**) | 1.10 s(주문을 따라감) |
| 받는 쪽이 꺼진 동안 쌓인 20,000건 | — | 첫 묶음 뒤 0.32 s(**60,215건/s**) |

하나씩 보내면 전달 하나가 받는 쪽의 저장 한 번(SD의 동기화 1–2 ms)과 왕복을 기다린다. 묶으면 저장 한 번이 256건까지
나눈다. 보내는 앱의 저장이 이미 주문을 묶어 저장하므로, 받는 앱도 같은 비율로 묶어 받는 것이 맞다.

## 검증

- aup `make check`의 `server_links`: 클라이언트 넷이 보내는 앱에 주문하는 동안 받는 서버를, 그다음 보내는 서버를
  `kill -9`로 죽이고 같은 저장소로 다시 띄운다. 그 뒤 수량 150(받는 앱이 거절한다)과 7의 주문을 더 넣는다. 받는 앱의 건수와
  합계가 보내는 앱이 저장한 것(거절한 한 건을 빼고)과 같아지고, 확인 응답한 주문은 모두 그 안에 있다. 다섯 번(각 약
  7,300건)과 ASan·UBSan 빌드로 한 번 통과했다. 거절한 주문 뒤의 주문도 전해졌다(막히지 않는다).
- aup `make check`의 `server_webhook`: 파이썬 HTTPS 서버(자체 인증서)를 제3자 웹훅으로 두고, 주문 다섯 건이 순서대로
  출력의 JSON 모양과 헤더(`X-Pure-Source`, `Idempotency-Key`, 묶음 헤더 없음)로 도착한다. 처음에 503으로 답한 2번은 다시 온다.
  그 인증서를 믿지 않는 보내는 쪽에서는 아무것도 도착하지 않고 기록이 남는다. ASan·UBSan 빌드로도 통과했다.
- aup `make check`의 `server_answers`: 파이썬 "결제 API"(1000 이하는 200 `succeeded`, 넘으면 402 `declined`, 같은
  `Idempotency-Key`에는 첫 답을 되풀이)에 출력을 잇고 응답을 `charged` 입력으로 받는다. 클라이언트 셋이 사는 동안 보내는 서버를
  `kill -9`로 죽였다 살린다. 끝에 앱의 결제·거절 수의 합이 저장된 구매 수와 API가 받은 키 수와 같고, 결제 수와 결제 합계가 API의
  200 응답과 같다(각 3,774–3,979건, 네 번, ASan·UBSan 빌드로 한 번 더).
- `test-module`의 링크 검사([078](078-app-connections.md), 008)가 런타임 쪽(이벤트의 보냄, 저장 실패, 재시작, 중복·거절)을
  맡는다.

## 다음 결정

- 저장이 끝날 때마다 outbox를 가장 새것부터 거꾸로 훑어, 배선된 출력마다 이미 줄에 넣은 번호에 닿으면 멈춘다
  (`pure_app_outbox_newest`). 받는 쪽이 오래 꺼져 수만 건이 밀려도 저장마다 새 보냄만큼만 본다.
- 덜어 낸 것은 다음 저장에 지워진다. 요청이 없는 앱은 저장하지 않으므로, 다시 시작하면 전한 것을 한 번 더 보낸다(받는
  쪽이 걸러 낸다).