본문 바로가기
C.W.K.
Lesson 03 of 05 · published

시작할 때마다 다시 짓기, 마이그레이션은 없어

~14 min · sqlite, replay, projection, schema

Level 0밑준비
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

먼저 지우고, 그다음 재생

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.boundSizzle 스레드 키를 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 레슨이야. 둘 다 맞아. 어느 파일이 기록이냐만 다를 뿐이야.

Code

cwkSizzle, backend/store.py: Store.rebuild, 파일 지우고 스키마 돌리고 재생하고 한 번 커밋 (원문 그대로, 157-172행)·python
    def rebuild(self, events: Iterable[dict[str, Any]]) -> int:
        with self._lock:
            if self._conn is not None:
                self._conn.close()
            for suffix in ("", "-wal", "-shm"):
                Path(str(self.path) + suffix).unlink(missing_ok=True)
            self.path.parent.mkdir(parents=True, exist_ok=True)
            self._conn = sqlite3.connect(self.path, check_same_thread=False)
            self._conn.row_factory = sqlite3.Row
            self._conn.executescript(SCHEMA)
            count = 0
            for event in events:
                self._apply(event)
                count += 1
            self._conn.commit()
            return count
cwkSizzle, backend/store.py: apply는 살아 있는 이벤트마다 커밋하고, _apply는 타입으로 부르고 모르는 건 거부 (원문 그대로, 188-197행)·python
    def apply(self, event: dict[str, Any]) -> None:
        with self._lock:
            self._apply(event)
            self.conn.commit()

    def _apply(self, event: dict[str, Any]) -> None:
        handler = getattr(self, "_on_" + event["type"].replace(".", "_"), None)
        if handler is None:
            raise ValueError(f"unknown event type {event['type']}")
        handler(event["data"], event["at"])

External links

Exercise

이벤트 저널이 있고 읽기 모델을 다시 짓는 앱에서, 이벤트엔 이미 실려 있는데 어느 테이블에도 없는 사실 하나를 컬럼으로 더해 봐. 스키마와 그 값을 채우는 핸들러 하나만 고치고, 다시 켜서 옛 행에도 값이 들어왔는지 봐. 이어서 이벤트 타입을 하나 새로 만들어서 한 번 쓰고, 이전 커밋을 체크아웃해서 옛 코드가 새 저널을 어떻게 다루는지 지켜봐.
Hint
이벤트가 그 사실을 한 번도 안 실었으면 백필은 안 돼. 그것도 배울 거리야. 뭘 남길지는 이벤트를 설계할 때 정해. 되돌리기에서 원하는 실패는 시작할 때 시끄럽게 거부하는 거야. 조용히 건너뛰면 문제가 숨어 버려.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.