~15 min · master-chief, jsonl, append-only-log, durability
Level 0구경꾼
0 XP0/33 lessons0/11 achievements
0/100 XP to next level100 XP to go0% complete
보여주기 전에 적는다
프라이빗 대화만 빼면, 피파 대화마다 밑에 덧붙이기만 하는 JSONL 기록이 하나씩 깔려 있어. 프라이빗 대화엔 기록이 아예 없어. 로거가 파일을 안 열고, 이벤트는 전부 버려. 이게 있는 이유는 신뢰성 지표가 아니라 아빠가 한 말 한 줄이야. "Do not present broken chat to your dad. That hurts." 한 턴의 모든 이벤트가 화면에 뜨기 전에 이 기록에 먼저 적혀. 텍스트와 생각 조각, 도구 호출과 결과, 사용량 보고, 에러, 아빠가 Stop을 눌렀다는 표시까지. 줄마다 바로 쓰고 운영체제로 넘겨서, 프로세스가 죽어도 잃는 게 없어. 구조적인 이벤트 뒤엔 fsync가 따라오고, 마지막 fsync 뒤로 4분의 1초가 지난 줄에도 따라와. 그래서 전기가 나가도 잃는 건 흘러나오던 텍스트 1초도 안 되는 분량이야.
이 기록을 읽어서 고치는 치유는 Pippa Quest의 Pippa's Truth 트랙이 가르쳐. 여기선 대화가 끝나지 않을 때 이 기록이 어떻게 되는지를 봐.
저장할 때 암호화, 한 줄씩
기록은 디스크에 암호화돼 있고, 단위는 파일이 아니라 줄이야. 덧붙이기 전용이라서 자연스럽게 나온 선택이야. 파일째 암호화하면 메시지마다 파일을 다시 써야 하는데, 줄 단위면 암호화된 줄도 그냥 덧붙이면 돼. 리더는 첫 글자만 보고 암호화된 줄인지 옛날 평문 줄인지 알아. 코드에도 그렇게 적혀 있으니까, 위협 모델은 정직하게 말해 둘게. 이건 실수로 보이는 걸 막는 안심용이야 (파일 미리보기, 엉뚱한 데로 새어 나간 백업 사본). 디스크랑 키를 둘 다 쥔 사람까지 막는 방어는 아냐.
지금 눈여겨볼 비용도 있어. 기록을 읽을 때마다 복호화가 따라와. 대화의 전체 기록을 읽는 건 싼 파일 읽기가 아니고, 대화가 하루 살 때마다 더 비싸져. 답 하나 때문에 Chief의 기록 전체를 복호화하던 도구 기록 복구가 각주로 끝나지 않고 콜드 리뷰 지적이 된 이유가 그거야.
파일 하나가 줄줄이 이어진 파일로
coop #351 이후로 기록은 파일 여럿이 이어진 형태야. 활성 파일은 원래 이름 {id}.jsonl을 지키고, 쓰는 쪽은 예전과 똑같이 거기 덧붙여. 봉인은 로거가 대화를 열 때 일어나. 같은 프로세스에서 그 대화를 쥔 다른 로거가 없고, 활성 파일이 세그먼트 크기(기본 8 MiB)에 닿았으면, 쓰기 전에 먼저 봉인해. 파일 통째로 {id}.segments/ 안의 다음 여섯 자리 번호로 이름을 바꾸는 거야. 새 활성 파일은 앞선 기록이 어디로 갔는지 적은 pippa_segment_sealed 줄로 시작해. 봉인된 세그먼트엔 다시는 안 써. 이름 바꾸기가 실패하면 활성 파일은 제자리에 있어. 기록은 한 줄도 안 잃어.
봉인이 어디서 일어나는지에서 성질 두 개가 나와. 둘 다 훔쳐 갈 만해.
턴 하나가 두 파일에 걸치는 일이 없어. 한 턴은 로거 하나가 쓰고, 봉인은 아무도 안 쓰는 상태에서 로거가 열 때만 일어나. 세그먼트 경계는 언제나 턴 경계야.
세그먼트는 딱 8 MiB가 아니라 8 MiB 이상이야. 크기는 열 때만 재니까, 문턱을 넘는 턴은 같은 파일에서 끝까지 써. Chief의 첫 세그먼트는 16,436,942바이트야. 봉인 기능이 나왔을 때 파일이 이미 그만큼이었거든. 그다음 둘은 8.48 MB, 8.52 MB쯤. 설계 문서엔 "sealed 8 MiB segments"라고 적혀 있고, 코드는 "닿았으면"이라고 해. 돌아가는 건 코드야.
봉인은 복사가 아니라 이름 바꾸기야. 같은 파일 시스템 안에서 이름 바꾸기는 원자적이야. 어느 순간이든 바이트는 활성 파일이거나 봉인된 세그먼트지, 반반인 순간은 없어.
함정: glob은 활성 파일만 본다
기록을 쪼개고 나면 순진한 리더는 제일 조용한 방식으로 틀려. sessions/*.jsonl을 glob하는 스크립트는 대화마다 파일을 여전히 찾고, 파싱도 되고, 이벤트도 돌려줘. 근데 Chief에 대해선 마지막 며칠치만 읽어. 에러는 하나도 안 나. 그래서 모든 리더는 저장소의 세그먼트를 아는 헬퍼를 거쳐. 하나는 대화의 파일을 오래된 순서로 전부 돌려줘 (옛 세션 이름 파일, 봉인된 세그먼트, 활성 파일). 하나는 전체 기록이 필요한 일에 전체를 읽어 (내보내기, 복원 훈련, 아카이브 스윕, 삭제). 하나는 최신부터 거꾸로 걸으면서 턴 단위 리더가 일찍 멈추게 해 줘 (치유, 도구 기록 채우기). 하나는 저장소 전체를 도는 작업에 세그먼트까지 포함한 파일 목록을 줘. 아키텍처 문서는 이 glob을 콕 집어서, 그 문단이 막으려는 함정이라고 적어 놨어.
정직한 단서: 기준 기록이 모든 행은 아냐
"기준 기록"은 브레인 어댑터를 거쳐 흘러나온 턴에 대해서만 성립해. 그게 모든 행은 아냐. 7월의 첫 복원 훈련에서 뒤를 받쳐 주는 JSONL 줄이 없는 SQLite 행들이 나왔어. 치유가 넣은 자리표시, 크론 대체 경로가 만든 중복 행(원인에서 고침), 가져온 대화(일부러 메시지 id 없이 씀), 다른 대화에서 복사해 온 브랜치나 카운슬 자식. 그래서 재구성은 두 저장소를 다 읽고, 아카이브는 둘의 합집합을 읽어. 피파엔 애초에 JSONL 원본이 없는 일차 상태도 있어. 그리고 콜드 리뷰 전까진 압축 요약이 SQLite에만 있었어. 지금은 기록에 남는 압축 표시 줄이 요약 본문을 들고 다녀. 불변식은 단단하고, 구멍은 적혀 있어. 베낄 가치가 있는 건 그쪽이야.
자라는 기록은 아무것도 진행 중이 아닐 때 쪼개. 이미 쥐고 있는 경계를 골라 (여기선 턴), 옛 파일은 원자적 이름 바꾸기로 통째로 옮기고, 리더가 쓰는 문은 헬퍼 하나로 정해. 그러면 쪼개도 쓰는 쪽은 아무것도 안 바뀌고, 그 문으로 들어오는 리더도 아무것도 안 바뀌어.
Code
엔진 발췌 (원문 그대로, 세션 로거): 활성 파일을 다음 세그먼트로 봉인하기·python
def seal_active_segment(sessions_dir: Path, conversation_id: str, *, threshold: int | None = None) -> Path | None:
"""Seal the active file into the next segment when it has reached the
threshold. Returns the sealed path, or None when nothing was sealed.
The caller guarantees no writer holds the conversation (SessionLogger
calls this under its process lock, with no logger open for it). The
rename is atomic on one filesystem; the next writer creates a fresh
active file.
"""
active = conv_jsonl_path(sessions_dir, conversation_id)
try:
size = active.stat().st_size
except FileNotFoundError:
return None
if size < (segment_bytes() if threshold is None else threshold):
return None
directory = segments_dir(sessions_dir, conversation_id)
directory.mkdir(parents=True, exist_ok=True)
existing = sealed_segment_paths(sessions_dir, conversation_id)
last = int(_SEGMENT_NAME_RE.match(existing[-1].name).group(1)) if existing else 0
target = directory / f"{last + 1:06d}.jsonl"
if target.exists():
raise FileExistsError(target)
os.rename(active, target)
logger.info("Sealed JSONL segment %s (%d bytes) for %s", target.name, size, conversation_id)
return target
돌려볼 수 있는 최소 스케치: 열 때 봉인하는 세그먼트, 절대 안 쪼개지는 턴, 거의 아무것도 못 보는 glob·python
# A minimal sketch of a segmented, append-only conversation log.
import json
import os
import tempfile
from pathlib import Path
SEGMENT_BYTES = 200 # tiny so the demo seals; the engine's default is 8 MiB
def active_path(root: Path, cid: str) -> Path:
return root / f"{cid}.jsonl"
def sealed_paths(root: Path, cid: str) -> list[Path]:
directory = root / f"{cid}.segments"
return sorted(directory.glob("*.jsonl")) if directory.is_dir() else []
def open_logger(root: Path, cid: str):
"""Seal only when a logger opens, never mid-turn: one logger writes one whole turn."""
active = active_path(root, cid)
if active.exists() and active.stat().st_size >= SEGMENT_BYTES:
directory = root / f"{cid}.segments"
directory.mkdir(exist_ok=True)
target = directory / f"{len(sealed_paths(root, cid)) + 1:06d}.jsonl"
os.rename(active, target) # atomic on one filesystem; the file moves whole
return open(active, "a", encoding="utf-8")
def write_turn(root: Path, cid: str, turn: int) -> None:
with open_logger(root, cid) as log:
for kind in ("user", "text_delta", "text_delta", "done"):
log.write(json.dumps({"turn": turn, "type": kind}) + "\n")
log.flush() # on disk before it would be shown
def conversation_paths(root: Path, cid: str) -> list[Path]:
"""Oldest first: the sealed segments, then the active file."""
active = active_path(root, cid)
return sealed_paths(root, cid) + ([active] if active.exists() else [])
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
for turn in range(1, 8):
write_turn(root, "chief", turn)
naive = sum(len(p.read_text().splitlines()) for p in root.glob("*.jsonl"))
events = [json.loads(line) for p in conversation_paths(root, "chief") for line in p.read_text().splitlines()]
print(f"a glob of the directory sees {naive} lines")
print(f"the segment-aware reader sees {len(events)} lines, turns {sorted({e['turn'] for e in events})}")
for p in conversation_paths(root, "chief"):
turns = sorted({json.loads(line)["turn"] for line in p.read_text().splitlines()})
print(f" {p.relative_to(root)}: {p.stat().st_size} bytes, turns {turns}")
네 프로젝트에서 덧붙이기만 하는 기록을 하나 찾아 (이벤트 로그, 채팅 로그, 감사 기록). 열 때 봉인을 넣어. 쓰는 쪽이 열 때 파일이 문턱을 넘었으면 통째로 바로 옆 번호 붙은 세그먼트로 이름을 바꾸는 거야. 그다음 파일을 오래된 순서로 전부 돌려주는 리더 헬퍼 하나를 짜고, 코드에서 그 기록을 직접 열거나 glob하는 곳을 전부 찾아서 헬퍼로 돌려. 마지막으로 문턱을 여러 번 넘게 쓰는 테스트를 넣고, 헬퍼가 이벤트를 전부 보는지, 작업 단위 하나가 두 파일에 갈라지지 않는지 확인해.
Hint
테스트에선 문턱을 아주 작게 잡아 (몇백 바이트). 그래야 자주 봉인돼. 사람들이 건너뛰는 건 찾기 쪽이야. 헬퍼의 옛 이름만 말고, 기록 디렉터리 이름이랑 파일 확장자로 grep해.
Progress
Progress is local-only — sign in to sync across devices.