빠른 것부터 보여주려면 밀어 줘야 해
Spark 불변식 중 하나가 빠른 것부터야. 층마다 생기는 즉시 보여줘. 피파가 메뉴를 읽는 답은 한 줄씩 흘러 들어오고, 엔진은 줄이 파싱되는 즉시 result.add로 기록해. 폰이 몇 초마다 물어보는 방식이면 그 줄들은 결국 뭉텅이로 도착해. 그래서 엔진이 밀어 줘. 순간을 가리키는 op는 그 순간을 지켜보는 쪽으로 곧장 가.
하나뿐인 문에서 내보내기
시작점은 이미 봤어. Corpus.record의 마지막 단계야. moment_id를 단 op, 아니면 그 자체가 moment.*인 op는 그 순간 이름으로 {"type": op 이름, "record": 본문} 모양으로 나가. 이 규칙은 글자 그대로라서 빈틈이 하나 있어. mark.remove와 link.remove는 표시나 링크 자기 id만 들고 있어. 그래서 표현 남기기를 취소하거나 크럼 링크를 끊어도 지켜보는 쪽엔 아무것도 안 가. 클라이언트는 순간을 다시 읽을 때에야 그 변화를 봐. 턴 op는 특별해. 미러가 들고 있는 모양 그대로, 상태까지 담아서 나가. 어떤 턴은 엔진이 알아서 시작하거든. 버전 하나가 끝나면 따라붙는 콜드 리뷰 같은 거. 클라이언트는 그런 턴을 이 이벤트로만 알게 돼.
일부러 코퍼스에 안 넣는 이벤트도 몇 개 있어. Corpus.publish는 지켜보는 쪽에 일시적인 이벤트를 보내고 아무것도 기록하지 않아. 실용 번역 턴은 이걸로 turn.prose를 보내. 구조화된 결과 둘레에서 자라나는 말, 그러니까 피파가 아빠한테 하는 말이야. 쇼 카드 통역은 진행 상황을 exchange.* 이벤트로 보내고. 이벤트를 하나 놓쳐도 클라이언트가 잃는 건 진행 상황뿐이야. 실용 번역 턴은 끝날 때 다 쓴 말을 따로 기록해.
프로세스 하나, 루프 여럿
퍼뜨리는 쪽은 첫 번째 코드 블록의 작은 프로세스 내부 클래스 Broadcaster야. 순간 id마다 구독자 집합을 들고 있고, 구독자 하나는 짝 하나야. 그 구독자를 만든 이벤트 루프와 asyncio.Queue.
재밌는 건 이 짝이야. record는 작업 스레드에서 돌아. 코퍼스 쓰기는 asyncio.to_thread로 이벤트 루프 밖에 밀려나 있으니까. 그런데 asyncio 큐는 스레드 안전하지 않아. 작업 스레드가 거기 직접 넣으면 안 돼. 그래서 publish는 평범한 스레드 잠금 아래서 구독자 목록을 복사하고, 배달은 구독자 자기 루프한테 call_soon_threadsafe로 넘겨. 그 루프가 이미 닫혔으면 RuntimeError가 나고, 그 구독자는 조용히 빠져.
브로커도, Redis도, 메시지 버스도 없어. 그걸로 충분한 건 쓰는 쪽이 엔진 프로세스 하나뿐이라서야. 생길 수 있는 이벤트는 전부 그 안에서 태어나.
스트림 그 자체
두 번째 코드 블록이 라우트 GET /api/moments/{id}/events 전부야. Server-Sent Events로 나가.
- 모르는 순간이면 스트림을 열기 전에 404로 답해.
- 구독부터 해. 그러고 나서 순간의 스냅숏을 읽어서 첫 이벤트로 보내. 순서가 반대면, 읽기와 구독 사이에 기록된 op는 어디에도 안 실려. 이 순서면 양쪽에 다 실릴 수는 있는데, 클라이언트가 결과를 id로 맞추니까 상관없어.
- 그 뒤로는 오는 대로 이벤트를 넘겨. 15초 동안 조용하면
: keepalive를 보내. 클라이언트가 무시하는 SSE 주석이고, 연결이 놀고 있는 것처럼 보이지 않게 해 줘. - 클라이언트가 떠나면 생성기가 닫히고,
finally가 구독을 풀어.
응답 헤더 두 개가 중요해. Cache-Control: no-cache, 그리고 중간 프록시가 이벤트를 버퍼에 붙잡아 두지 않게 하는 X-Accel-Buffering: no. 테스트 하나가 순간을 구독하고, 작업 스레드에서 결과를 기록하고, turn.prose를 하나 보내고 나서, 둘이 순서대로 도착하는지 확인해. 구독을 푼 뒤엔 지켜보는 쪽이 아무도 없는지도 보고.
브라우저의 EventSource는 알아서 다시 붙어. 폰은 Swift에서 같은 라우트를 열어. 폰은 테일넷으로만 엔진에 닿으니까, 폰에서 순간 스트림이 끊기면 아빠한테 Tailscale을 확인하라고 알려줘. 웹도 실패한 요청에 같은 안내를 붙이고.
엔진의 모양
| 관심사 | Spark의 방식 |
|---|---|
| 프로세스 | launchd LaunchAgent 하나, com.cwk.spark-serve. python -m backend.serve를 RunAtLoad, KeepAlive, 10초 스로틀로 돌리고, 출력은 데이터 루트 아래 serve.log로 가 |
| 듣는 곳 | 127.0.0.1에서 uvicorn. 포트는 가족 레지스트리가 Spark한테 준 번호(7900) |
| 닿는 길 | Tailscale Serve가 그 루프백 포트 위에 HTTPS 쌍둥이(7901)를 열어. 레지스트리가 HTTP 쌍둥이를 선언했으면 그것도 |
| 형제 서비스 | cwkPippa, 가족 제어 창구, cwkBellows. 전부 레지스트리가 준 루프백 주소로 불러 |
| 웹 클라이언트 | 빌드한 프런트엔드를 맨 마지막에 붙여. 그래야 /api 라우트가 페이지 대체 경로보다 먼저 이겨 |
| 예약 작업 | 없어. 데이터 루트는 가족의 매일 백업이 맡아 |
루프백 규칙의 이유는 설정 모듈 독스트링에 있어. 그 모듈엔 LAN 호스트 이름을 하나도 적으면 안 돼. launchd 에이전트는 macOS 로컬 네트워크 관문을 못 넘거든. 엔진은 와일드카드 주소에서 듣는 일이 없고, 테일넷은 Serve를 거쳐서만 엔진에 닿아. 포트, 주소, 데이터 루트는 전부 가족 레지스트리에서 오고, 환경 변수는 명시적으로 덮어쓸 때만 써. 저장소에 든 plist조차 템플릿이야. @@HOME@@, @@DATA_ROOT@@ 같은 토큰이 있고, 설치 스크립트가 자기가 도는 Mac에 맞춰 채워.
어디 있는지부터 확인하는 설치
설치 스크립트는 가족 킷 템플릿에서 생성돼. 모든 엔진이 같은 모양이야(공유 패키지보다 복사본을 들이는 게 나은 이유는 Sibling Kit Quest가 다뤄). 세 번째 코드 블록이 그 첫 동작이야. Spark가 이 Mac에 속하는지 레지스트리한테 묻고, 아니면 더 가지 않아. 통과하면 conda 환경과 프런트엔드를 짓고, plist를 채우고, 예전 작업을 내리고 그 종료 제한 시간이 지날 때까지 기다려. 새 작업은 재시도를 곁들여 올리고, /api/health를 2초 간격으로 열다섯 번까지 찔러 봐. 마지막으로 두 번째 스크립트로 테일넷 쌍둥이를 여는데, 그 스크립트도 Tailscale을 건드리기 전에 레지스트리한테 같은 질문을 해.
빠진 한 줄에 가족이 비싸게 배운 교훈이 담겨 있어. 올린 뒤에 launchctl kickstart -k가 없다는 거야. RunAtLoad 작업은 올리자마자 이미 시작돼. 예전엔 그 뒤에 kickstart가 따라붙었는데, 그게 첫 부팅을 죽였어. 데이터베이스 마이그레이션 도중이었을 수도 있고.