OpenAI Codex · 세션 저장

JSONL이 진실이고, SQLite는 거울이에요

어제 터미널을 껐는데, 오늘 목록은 그대로 있고 대화도 이어서 고칠 수 있어요. 두 일이 한 저장소처럼 보이지만, 디스크에 쓸 때는 궤도가 둘이에요. 위 궤도는 줄마다 이어 붙이고, 아래 궤도는 표지만 베껴요. 중간에 전원이 나가면, 집어 올 수 있는 건 이미 게이트를 넘긴 그 몇 줄뿐이에요.

강의 목표읽고 나면 세 가지를 말할 수 있어요. 첫째, 세션 목록과 세션 복구가 왜 같은 디스크를 읽지 않는지. 둘째, 한 번 이어 붙일 때 왜 JSONL이 먼저 게이트를 넘고 나서야 SQLite로 투영되는지. 셋째, 전원이 게이트 앞, 게이트 뒤, 또는 압축을 쓰다 만 지점에서 나가면 각각 무엇이 사라지는지요.
먼저 해보기 · 달리면서 남기고, 전원을 뽑기
한 세션이 몇 가지를 쓰고, 고른 자리에서 전원을 뽑아요
전원 차단 시점
게이트는 JSONL의 flush예요. 게이트를 넘기면 복구가 이 줄을 읽을 수 있어요. 투영은 늦어도 되지만, 새치기는 안 됩니다.
JSONL 원문0줄 통과
SQLite 표지빈 카드
flush barrier종이테이프가 아직 게이트에 닿지 않았어요.
복구는 파일을 읽음아직 전원은 안 뽑았어요. 테이프가 어떻게 가는지 먼저 보세요.
목록은 거울을 읽음카드는 제목, 디렉터리, 경로만 베끼고 대화 전문은 베끼지 않아요.
논리 궤적 · 애니메이션 한 걸음이 소스 어디에 대응하는지
  1. Session이 item을 LiveThread에 넘기고, 실패는 로그만 남김session/mod.rs L3753
  2. LiveThread가 원문 슬라이스를 store에 넘김live_thread.rs L203
  3. 화이트리스트가 순간 EventMsg는 버리고, 실행 표시는 무조건 남김policy.rs L9
  4. JSON 한 줄에 개행, write_all 뒤에 flushrecorder.rs L1968
  5. 게이트가 먼저 이기고, 그제야 Paginated가 thread_history를 투영live_writer.rs L337
  6. 투영 실패는 warn만, 다음엔 바이트 오프셋부터 이어서live_writer.rs L345
  7. 필터 결과를 보고, 리터럴 metadata patch를 찍음live_thread.rs L212
  8. 복구는 파일을 줄마다 decode하고, threads 테이블에서 이력을 짜지 않음recorder.rs L1009
  9. 목록에 DB가 없거나 깨지면 sessions 디렉터리를 훑는 쪽으로 후퇴recorder.rs L547
재생을 누르세요. 세션이 달리면서 남기고, 고른 자리에서 전원을 끊어요.
복구
목록
계약
수업용 시연: 줄 수, 제목, 차단 위치는 수업용 설정이에요. 게이트 앞뒤 복구 차이를 견주어 보기하기 위해서요. 논리 궤적 오른쪽 행 번호는 openai/codex 저장소 commit 4f39251a01에 맞춰 있어요.
아이디어 1 · 이어 붙인 로그가 원문, 파생 테이블이 표지
어떤 문제를 푸는가

목록은 빨라야 하고, 복구는 맞아야 해요. 파일 하나로는 둘 다 어렵습니다. JSONL은 이어 붙이기 싸고 jq로 읽을 수 있어요. 워크스페이스, 고정, 보관으로 걸러야 하면 맞지 않아요. SQLite는 그런 필터에 강한 대신, 복구할 때 모델 입력을 짜는 곳이 되면 안 됩니다.

반대로 보이는 사고 두 건이 같은 규칙을 가리켜요. state_5.sqlite를 지워도 목록이 한동안 비었다가 다시 자라고, 대화는 남아 있어요. 손으로 DB의 제목과 cwd를 고치면, 새로고침 뒤에 따라가기도 하고 도로 돌아가기도 해요. 거울은 다시 만들 수도 있고 원문에 덮일 수도 있어요. 원문이 사라지면 거울은 구하지 못합니다.

아이디어는 무엇인가

Codex는 궤도를 둘로 나눕니다. Session은 파일을 직접 건드리지 않고, 이미 만들어 둔 item을 현재 LiveThread에 넘겨요. live handle이 없거나 append가 실패해도 turn 자체는 끊기지 않고, 오류는 로그만 들어갑니다.

출처:codex-rs/core/src/session/mod.rs 3753–3759행

LiveThread는 먼저 정책으로 관찰용 사본을 걸러요. store에 넘기는 건 여전히 원본 슬라이스입니다. store가 화이트리스트를 한 번 더 돌리죠. 스트리밍 증분, 승인, 경고 같은 순간 EventMsg는 JSONL에 못 들어갑니다. Compacted, TurnContext, WorldState, SessionMeta는 무조건 남아요.

쓰는 순서는 고정이에요. JSONL을 먼저 디스크에 남기고, 그다음 SQLite로 투영합니다. 투영 실패는 다음에 다시 하면 돼요. JSONL이 실패하면 SQLite가 앞설 수 없습니다.

Session LiveThread JSONL rollout 한 줄에 하나, flush 뒤에야 게이트를 넘긴 셈 SQLite 거울 threads 행은 제목, 디렉터리, 경로만 베낌 복구, fork, 압축 재생 JSONL만 읽고, 줄마다 decode한 뒤 다시 만듦 목록, 검색, 고정 구역 거울을 타고, DB를 못 쓰면 디렉터리 훑기로 후퇴
수업용 구조도: 같은 item을 먼저 테이프에 찍고, 그다음 카드에 베낍니다. 읽기 경로 둘이 여기서 갈라져요.
왜 오래가는가

이어 붙이기와 임의 조회가 원하는 물리 모양이 달라요. 한 형식에 묶으면 목록이 느려지거나, 붙일 때마다 문서 전체를 고치게 됩니다. 나누면 쓰기 경로는 원문을 먼저 지키고 거울을 고칠 수 있어요. 다른 언어로 다시 써도 계약은 이 문장입니다.

아이디어 2 · 거울은 늦어도 되지만, 앞설 수는 없어요
어떤 문제를 푸는가

SQLite를 먼저 쓰고 JSONL을 나중에 메우면, 두 단계 사이에서 프로세스가 죽을 때 목록에 열리지 않는 세션이 생깁니다. 사용자는 제목을 보고 들어가는데 대응 줄이 없어요. 이런 불일치는 목록이 잠시 비는 것보다 찾기 어렵습니다.

아이디어는 무엇인가

Paginated 모드에서 주석은 SQLite를 다시 만들 수 있는 뷰라고 씁니다. flush 장벽이 먼저 이겨야 하고, 투영은 늦어도 되지만 앞설 수는 없어요. durable_write가 Ok를 돌려준 뒤에야 materialize_to_sqlite가 시작될 수 있어요. 투영 오류는 warn만 남깁니다.

출처:codex-rs/thread-store/src/local/live_writer.rs 335–347행

바이트로 떨어지는 그 단계에서는 JSON 한 줄에 개행 하나, write_all 뒤에 flush입니다. 여기 flush는 tokio 파일 버퍼고, 소스는 sync_all을 다시 부르지 않아요. 프로세스를 바로 죽이면 마지막 몇 줄이 커널 페이지 캐시에 남을 수 있어요. 다음에 열면 개행을 메우고, 깨진 반 줄은 parse_errors에 들어갑니다.

출처:codex-rs/rollout/src/recorder.rs 1968–1974행

복구, fork, 압축 재생은 JSONL만 읽어요. 목록은 SQLite를 우선합니다. DB가 없거나, 열리지 않거나, 되메우기가 끝나지 않으면 전부 ~/.codex/sessions/를 훑는 쪽으로 후퇴하고, 안정 지표 codex.sqlite.fallback.count를 올립니다.

출처:codex-rs/rollout/src/recorder.rs 547–559행

한 번 이어 붙이는 타임라인 write_all flush 게이트 SQLite로 투영 게이트 앞에서 전원 차단 마지막 줄이 아직 페이지 캐시에 있어 복구가 못 봄 게이트 뒤에서 전원 차단 복구는 이 줄을 집어 오고, 목록 표지는 한 박자 늦을 수 있음 장벽은 여기, 투영은 넘어가면 안 됨
수업용 타임라인: 같은 쓰기라도, 전원을 뽑는 자리가 복구가 어느 줄을 볼지 정합니다.
왜 오래가는가

다시 만들 수 있는 것은 잃어도 되지만, 새치기는 안 됩니다. 양쪽을 한 트랜잭션에 묶으면 거울이 느려질 때 원문도 못 써요. 늦는 건 허용, 앞서는 건 금지 — 로그와 파생 테이블의 공통 계약입니다.

아이디어 3 · 압축 계약은 파일에 적혀 있어요
어떤 문제를 푸는가

압축은 이력 한 토막을 바꿉니다. Compacted, WorldState, TurnContext가 메모리에만, 또는 SQLite에만 있으면 DB를 지운 뒤 창 번호와 베이스라인이 같이 사라져요. 사용자는 모델이 잊었다고 생각하지만, 로더가 그 세 줄을 못 읽은 것뿐입니다.

아이디어는 무엇인가

압축은 먼저 메모리 이력을 고치고, Compacted, WorldState, TurnContext 순으로 디스크에 남깁니다. WorldState는 replacement history 뒤에 와야 해요. 그게 새 이력의 베이스라인이니까요.

출처:codex-rs/core/src/session/mod.rs 3417–3427행

복구할 때는 거꾸로 읽어요. 뒤에서 앞으로 훑다 replacement_history가 있는 Compacted를 만나면 더 이른 접미사를 끊고, 더 이른 TurnContext 베이스라인도 지웁니다. 그다음 WorldState를 정방향으로 재생하고, full 스냅샷이 베이스라인을 리셋하며 patch는 그 위에 합쳐집니다.

출처:codex-rs/core/src/session/rollout_reconstruction.rs 155–188행

이 세 종류는 목록에 거의 쓸모가 없어요. apply_rollout_item은 Compacted와 WorldState를 만나면 빈 동작입니다. 거울은 전문 인덱스가 아니라, 목록과 필터가 쓰는 필드예요. 제목은 UserMessage에서 오고, 모델의 ResponseItem에서 짐작하지 않습니다.

출처:codex-rs/state/src/extract.rs 14–34행

원문을 먼저 디스크에 남기세요. 표지는 다시 만들 수 있어요.
왜 오래가는가

복구 계약은 파일에 적혀 있어요. 지속성 모양을 바꾸는 것은 복구 계약을 바꾸는 것과 같습니다. 저장소 루트 AGENTS.md는 기존 rollout에서 세션을 복구하는 일을 파괴적 변경 점검 목록에 넣어요. 파일이 남아 있으면 세션은 계약대로 재생할 수 있습니다.

가로 비교 · 같은 이력, 남는 곳 세 곳

DSH: 같은 로그, 백엔드 둘

DeepSeek Harness의 지속성 단위는 메모리 안의 SessionEvent예요. JSONL과 SQLite가 같은 SessionPersistence seam을 구현하고, 백엔드를 바꾸면 저장 원어만 바뀌지 로그 의미는 안 바뀝니다. header에는 SESSION_FORMAT_VERSION = 0이 있어요. 버전이 틀리거나, 알 수 없고 ignorable도 아닌 이벤트가 나오면 해석을 거절하고, 오류 이름은 SessionFormatUnsupportedError입니다.

조용한 결손은 오류보다 찾기 어려워서, DSH는 오류를 택해요. Codex는 되도록 열고, 알 수 없는 모양은 serde 실패로 parse_errors에 넣으며, item이 있으면 그래도 builder를 만들려고 합니다.

양쪽 모두 소스 대조 완료 · 2026-08-22 · DSH · 세션 지속성

Claude Code: JSONL 하나, 세션 거울 DB는 없음

현재 세션 경로는 projects/<project>/<sessionId>.jsonl예요. 이어 붙이기는 동기 appendFileSync이고, JSON 한 줄에 개행, 권한은 0o600입니다. 목록은 getSessionFilesLite로 파일 머리와 꼬리를 읽고 SQLite를 거치지 않아요.

한 파일 읽기에는 50 MB 상한이 있고, 주석은 세션 JSONL이 수 GB까지 커질 수 있으니 호출 쪽이 먼저 빠져 메모리를 터뜨리지 말라고 적어요. Codex는 그 디스크 훑기 비용을 시작 되메우기로 옮겨요. 양쪽 모두 JSONL이 커진다는 걸 인정합니다. 한쪽은 상한에 닿으면 파일 전체 읽기를 거절하고, 한쪽은 되메우기 일꾼이 표지를 다시 DB에 베낍니다.

양쪽 모두 소스 대조 완료 · 2026-08-22
수업 실습
01

압축을 쓰다 말고 전원을 뽑으면

한 세션이 이미 SessionMeta, 사용자 메시지 하나, 어시스턴트 답 하나를 썼어요. 이어서 압축이 시작되고 Compacted는 이미 flush됐는데 WorldState는 아직 안 쓴 채 전원이 나갑니다.

세 가지를 추론해 보세요. 복구가 어느 이력 토막을 집어 오는지, 목록 카드 제목이 바뀌는지, 빠진 그 한 줄 베이스라인이 재생 때 무엇으로 놓이는지요.

Takeaway:JSONL은 이력 유실을 막고, SQLite는 느린 목록을 막아요. 되메우기는 빈 거울을 막고, fallback은 거짓말하는 거울을 막습니다. 어느 층이든 실패할 수 있어요. 기본은 목록을 낮추고, 복구는 낮추지 마세요.