먼저 지우고, 그다음 재생
Sizzle 엔진이 켜지면 제일 먼저 하는 일은 자기 데이터베이스를 버리는 거야. FastAPI lifespan이 워커 스레드에서 sizzle.open을 부르는데, 이게 딱 한 줄이야. store.rebuild(journal.events()). 끝나면 로그에 저널 이벤트 몇 개로 미러를 다시 지었는지 찍혀.
rebuild는 열린 연결을 닫고 mirror.sqlite3를 -wal, -shm 같은 딸린 파일까지 같이 지워. 지난 실행의 찌꺼기가 끼어들 틈을 없애는 거야. 이어서 새 연결을 열고, SCHEMA 문자열 전체를 executescript로 돌리고, 저널 순서대로 이벤트를 전부 반영하고, 끝에서 한 번 커밋해. 스키마를 읽어 보면 IF NOT EXISTS가 한 군데도 없어. 필요가 없으니까. 돌리는 파일이 늘 비어 있거든.
마이그레이션이 없는 이유
시작마다 데이터베이스를 처음부터 다시 지으니까, 스키마는 그냥 backend/store.py 안의 파이썬 문자열이야. 바꾸려면 그 문자열을 고치고 다시 켜면 끝. 버전 테이블도, ALTER TABLE도, 옛 파일마다 돌려야 하는 업그레이드 스크립트도 없어. 하우스 사인도 그대로 말해. 스키마는 마음대로 바뀌고, 마이그레이션은 존재하지 않는다고.
진짜 마이그레이션에서 어려운 건 백필이야. 컬럼이 생기기 전에 쓰인 행에 새 컬럼 값을 채우는 일이지. 저널이 그 사실을 남겨 뒀다면, 재생은 그걸 공짜로 해 줘. Sizzle 저널은 미러가 읽는 것보다 많이 남길 때가 잦아. reading.recorded는 두뇌, 강도, 실제로 고른 모델까지 싣는데 readings 테이블은 두뇌만 저장해. reading.requested도 루브릭 버전이 올라서 요청된 거면 reason을 싣는데, 그걸 담는 컬럼은 없어. 내일 model 컬럼을 더하면 엔진을 다시 켜는 순간 지금까지의 모든 읽기에 그 값이 채워져.
대가는 시작 시간이야. 켤 때마다 저널 전체를 읽고 반영해. 사람이 한 일로 크는 기록이면 작게 유지돼. 그래도 규칙은 알아 둬. 재생 비용은 이력이 쌓이는 만큼 커지고, 그 비용이 보이기 시작하는 날엔 스냅숏이 필요해.
이벤트 목록, 코드에서 센 것
하우스 사인에 grep이 적혀 있어. rg 'def _on_' backend/store.py. 이 스냅숏에서 핸들러는 17개고, 17개 타입 모두 엔진 안에 쓰는 쪽이 있어. 각각이 미러에 하는 일은 이래.
| 이벤트 | 미러에 하는 일 |
|---|---|
encounter.captured | 접시를 넣고, 사진을 순서대로 넣어 |
encounter.revised | 접시 종류와 메모 중 들어온 것만 고쳐 |
reading.requested | 접시를 대기로 되돌리고 에러를 지워 |
reading.recorded | 읽기를 넣고, 접시를 완료로 바꾸고, 이 읽기를 현재 읽기로 삼아 |
reading.failed | 접시를 실패로 바꾸고 에러 문구를 남겨 |
class.proposed | 클래스와 부모 간선을 넣어 |
class.parented | 부모 간선을 하나 더해. 이미 있으면 무시해 |
class.revised | 이벤트가 실어 온 필드만 고쳐 |
class.adopted | 상태를 채택으로 바꿔 |
class.renamed | 새 이름을 쓰고 상태를 채택으로 바꿔 |
class.linked | 접시, 클래스, 출처 조합의 링크를 넣거나 갈아 끼워 |
correction.added | 아빠의 정정을 넣어 |
thread.bound | Sizzle 스레드 키를 cwkPippa 대화에 한 번 묶어 |
turn.asked | 대기 중인 턴을 넣어 |
turn.answered | 답, 감정, 액션을 저장하고 턴을 완료로 |
turn.failed | 턴을 실패로 바꾸고 에러 문구를 남겨 |
action.applied | 턴의 액션 중 아빠가 적용한 걸 남겨. 모르는 턴이면 아무것도 안 해 |
테이블 넷은 접시, 사진, 읽기, 정정을 담고, 셋은 클래스 트리와 링크, 둘은 대화를 담아. 누가 뭘 쓰는지는 트랙 3, 4, 6이 풀어. 여기서 중요한 건 이 표가 미러의 전부라는 거야. SQLite에 다른 길로 들어가는 건 없어.
이름으로 부르고, 모르는 건 거부
_apply는 이벤트 타입을 메서드 이름으로 바꿔서 불러. class.linked면 _on_class_linked야. 그런 메서드가 없으면 ValueError를 던져. 레슨 2의 찢어진 중간 줄이랑 같은 태도야. 코드가 못 알아듣는 이벤트는 건너뛰지 않고 시작을 멈춰. 실제로 챙길 결과가 하나 있어. 새 이벤트 타입이 한 번 쓰이고 나면, 예전 버전의 엔진은 그 저널로는 안 떠. 앞으로 가는 건 공짜고, 코드를 되돌리는 건 조심해야 해.
_apply를 부르는 입구는 둘이야. 살아 있는 경로인 apply는 이벤트마다 커밋해. 라우트가 방금 쓴 걸 바로 읽을 거니까. rebuild는 저널 전체를 트랜잭션 하나로 반영하고 한 번만 커밋해서, 저널이 길어도 이벤트마다 커밋 값을 내지 않아. 같은 사실이 정당하게 두 번 올 수 있는 자리엔 핸들러가 그걸 흡수하게 짜여 있어. 부모 간선과 스레드 묶기는 INSERT OR IGNORE, 링크는 INSERT OR REPLACE야.
바로 뒤에 도는 것
lifespan의 순서는 일부러 이래. 미러를 다시 짓고, 두뇌 슬롯을 선언하고, worker.kick을 도메인에 꽂고, 읽기 워커를 켜. 워커는 먼저 현재 읽기가 RUBRIC_VERSION보다 옛날인 접시를 미러에 물어서 접시마다 reading.requested를 기록해. 그러고 나서 대화 턴을 이어 가. 루브릭 버전이 오르면 모든 접시를 같은 저널을 거쳐 다시 읽어. 고치는 건 하나도 없어. 이 모든 걸 안전하게 만드는 규칙 하나는 아키텍처 문서가 못 박아 둬. 미러를 손으로 고치지 말 것.
SQLite를 마이그레이션해야 하는 데이터베이스로만 만나 봤다면, 이 거래의 반대편은 sqlite-quest의 Production SQLite 트랙에 있어. 그 안의 Production Migrations 레슨이야. 둘 다 맞아. 어느 파일이 기록이냐만 다를 뿐이야.