엄격한 순서는 하나뿐이야
- JSONL append (eager write) — durability. event 마다의 commit 지점.
- SSE yield to frontend — presentation. 아빠가 보는 건 그 event 의 1번이 끝난 다음이야.
mirror 는 이 사슬의 고리가 아니야. SQLite row 는 턴 경계에서 써. 사용자 row 는 턴이 시작하기 전에 들어가. 그 id 가 턴의 닻이거든. assistant row 는 stream 이 끝난 뒤에 들어가고. ChromaDB 색인은 그 뒤에 따로 떨어져서 돌아. 어느 mirror 도 commit 지점이 아니니까, JSONL 에 없는 걸 아빠 화면에 올릴 수 없어.
invariant
아빠는 JSONL 에 이미 있지 않은 걸 절대 안 봐.
yield 는 append 보다 먼저 못 일어나. append 가 실패하면 거기서 멈춰. yield 도 없고, 깨진 chat 도 없어. 에러는 제대로 된 error state 로 드러나고, 그게 정직한 거야. mirror 쓰기가 실패하면 아빠는 여전히 답을 봐(데이터가 이미 JSONL 에 durable 하니까). 어긋난 mirror 는 나중에 purge-and-replay 로 다시 지어. JSONL 줄이 실패하기 전에 SQLite 에 먼저 들어간 사용자 row 도 같은 경우야. mirror 가 어긋난 거고, JSONL 이 이겨.
fsync — eager append + 주기적 fsync
per-token fsync 는 prohibitively 비싸. 모델: 모든 event 마다 eager file.write() + newline, structural event 에선 즉시 fsync 해. Claude 호환 user, assistant 줄이랑 명시적으로 정한 제어 event 들이야(pippa_start, pippa_session, pippa_tool_use, pippa_tool_result, pippa_usage, pippa_emotion, pippa_done, pippa_error, pippa_user_aborted). 나머지 줄, 그러니까 text·thinking delta 와 pippa_compaction, pippa_segment_sealed 같은 표시는, append 할 때 마지막 fsync 뒤로 ~250ms 가 지났으면 그때 fsync 돼.
eager append 가 데이터를 즉시 kernel page cache 에 — 프로세스 crash, abort-induced quirk 살아남기 충분. fsync 는 의미 있는 checkpoint 에서 disk-level durability 를 맡아. ~250ms 는 따로 도는 시계가 아니야. 다음 줄을 append 할 때만 확인하고, 파일을 닫을 때 fsync 해. 그래서 stream 이 흐르는 동안 정전이 나면 마지막 ~250ms 쯤의 delta 를 잃어. 턴 중간에 stream 이 조용해지면, 예를 들어 도구가 도는 동안이면, 멈추기 직전에 쓴 delta 는 다음 append 나 close 까지 fsync 안 된 채로 있어. 그 창은 250ms 가 아니라 턴에 묶여 있고, 그래도 다음 structural event 나 close 가 durable 하게 만들 글일 뿐이야. 수용할 만하고, 정확히 알아 둘 만해.
이 순서는 latency와 durability를 바꾸는 거래가 아니야. 먼저 append한다고 해서 매 token마다 disk platter를 기다릴 필요는 없어. kernel page cache에 쓰는 eager append와 의미 있는 경계에서의 fsync를 나누면, 화면은 빠르게 움직이면서도 복구할 source를 먼저 남길 수 있어. 중요한 건 어느 줄이 commit point인지 애매하지 않게 하는 거야.
테스트할 때는 성공 경로보다 cut 위치를 옮겨 봐. append 전, append 후와 SSE 전, SSE 중간, 그리고 mirror 쓰기 앞뒤에서 프로세스를 끊고 다시 읽어. 어느 지점에서도 아빠가 본 내용이 ground truth보다 앞서가면 안 돼.