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

버려도 되는 미러

~15 min · sqlite, derived-state, replay, schema

Level 0첫 단어
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

자기가 뷰라는 걸 아는 뷰

순간 하나 읽자고 달 파일을 훑고 싶은 사람은 없어. 페이지엔 인덱스가 필요해. 최근 캡처부터, 한 여행에 속한 것 전부, 한 표현에 달린 결과 전부. 그래서 엔진은 로그 옆에 SQLite 파일 mirror.sqlite3를 두고, 읽기는 전부 거기로 가. 이 모듈의 독스트링은 첫 문단에서 자기 위상부터 밝혀. 진실은 로그야. 미러는 op가 덧붙을 때마다 반영하고, 통째로 다시 지을 땐 로그를 리플레이해. 손으로 고치는 일은 없어. 가족은 이걸 지우고 다시 돌리기 규칙이라고 부르고, Spark는 그대로 지켜.

이 한 문장이 파일을 대하는 태도를 바꿔. 지워도 돼. 내키면 다시 지어도 돼. 접기 코드에 버그가 있으면 코드를 고치고 리플레이하면 끝이야. 행을 손으로 만지는 일은 없어.

테이블의 모양

레코드 테이블마다 접힌 레코드 전체를 record 열에 JSON으로 넣고, 읽기가 거르고 정렬하는 데 쓰는 열 몇 개만 옆에 둬. 예를 들어 moments 테이블의 열은 id, kind, captured_at, local_date, trip_id, deleted, seq뿐이고 나머진 다 JSON 안에 있어. 그래서 op에 필드가 하나 늘어도 스키마는 그대로고, 모든 읽기에 바로 닿아. 열이 필요한 건 그 필드로 거르고 싶을 때뿐이야.

seq는 그 op가 로그에서 몇 번째인지야. 보관함은 captured_at 내림차순으로 순간을 늘어놓고, 시각이 같으면 seq로 갈라. 그래서 같은 초에 찍은 두 순간도 페이지를 넘길 때 순서가 흔들리지 않아. 테스트 하나가 이 페이지 넘김을 붙잡고 있어.

파생 테이블은 열한 개야. moments, media, results, turns, marks, links, trips, purposes, threads, requests, search. 여기에 스키마 버전과 로그 지문을 담는 meta가 붙어. 열한 개 중 아홉이 레코드 테이블이야. 나머지 둘엔 record 열이 없어. requests는 클라이언트 요청 id를 그 요청이 만든 op와 id에 잇고, search는 텍스트랑 순간으로 돌아가는 포인터만 들고 있어. search는 trigram 토크나이저를 쓰는 FTS5 테이블이라 어떤 문자든 단어 중간까지 찾아. 이 퀘스트의 뒤쪽 트랙에서 다시 만나.

접기

op마다 그걸 테이블에 접어 넣는 작은 처리기가 있어. 생성은 INSERT OR IGNORE를 써서, 이미 있는 id면 아무것도 안 바뀌어. 수정은 지금 레코드를 읽고, 고쳐도 되는 필드만 덧대서 다시 써. turn.finish는 turn.start가 만든 레코드 위에 합쳐지고. 다시 더해진 표시나 링크는 active 열이 1로 돌아가. 모아 보면 미러는 로그를 접은 결과야. op를 로그 순서대로 적용하면 상태가 나와.

이 미러, 지금 맞아?

두 번째 코드 블록이 meta에 든 사실 두 개로 답해. 스키마 버전이 코드의 SCHEMA_VERSION(지금은 4)과 같아야 하고, 저장된 로그 지문이 지금 파일들로 계산한 지문과 같아야 해. 세 번째 블록의 Corpus.open은 부팅할 때마다 이걸 묻고, 하나라도 틀리면 다시 지어. 직접 다시 짓는 길도 라우트 하나야. POST /api/admin/rebuild인데, 독스트링 설명대로 파생 상태만 지우고 로그에서 다시 돌려.

재구축은 트랜잭션 하나 안에서 돌아. 파생 행이랑 meta를 다 지우고, seq를 0으로 돌리고, 로그가 내놓는 op를 전부 적용하고, 스키마 버전을 적고, 커밋해. 중간에 예외가 나면 통째로 롤백해. 그러니까 반쯤 하다 실패한 재구축은 이전 미러를 그대로 남기고, 다시 시도할 땐 처음부터 깨끗하게 시작해.

스키마 변경은 리플레이야

첫 번째 코드 블록 자리에 보통 앱들은 마이그레이션 사슬을 둬. Spark엔 그게 없어. 미러를 열었는데 저장된 스키마 버전이 코드와 다르면, 파생 테이블을 전부 버리고 meta를 비우고 지금 모양으로 새로 만들어. 그러면 open이 버전 불일치를 보고 로그를 새 테이블에 리플레이해. 모듈 주석 말대로, 미러 파일엔 자기만의 마이그레이션이 필요 없어.

질문마이그레이션 사슬파생 미러
역사는 어디 있어?테이블 안. 마이그레이션마다 모양이 바뀌어로그 안. 모양이 안 바뀌어
스키마 변경 비용은?버전마다 스크립트 하나, 영원히 보관버전 숫자 하나 올리고 전체 리플레이
접기에 버그가 있었다면?데이터 수정용 마이그레이션코드 고치고 리플레이
시간이 지나며 자라는 건?마이그레이션 목록리플레이 시간, 로그와 함께

대가는 마지막 줄에 있어. 리플레이 시간은 로그와 함께 늘고, 스키마를 올릴 때마다 한 번씩 치러. 한 사람이 손과 카메라로 쌓는 코퍼스라서, Spark는 이 거래를 골랐어.

신경 써야 할 연결 설정

  • WAL 저널 모드. 읽는 쪽이 쓰는 쪽을 막지 않고, 쓰는 쪽도 읽는 쪽을 막지 않아.
  • isolation_level=None. Python sqlite3 모듈이 몰래 트랜잭션을 열지 않아. 그래서 미러의 BEGIN과 COMMIT은 전부 눈에 보이는 곳에 적혀 있어.
  • check_same_thread=False, 연결은 하나. 쓰기는 asyncio.to_thread를 타고 작업 스레드에서 들어와. 그러니 연결이 스레드를 넘나들어야 해. 그래도 안전한 건 앞 레슨의 코퍼스 잠금 덕분이야. 클래스 독스트링도 연결 하나를 코퍼스 잠금으로 줄 세운다고 적어 둬.

이 중에 처음 보는 게 있으면 SQLite Quest가 제대로 다뤄. 쓰기, 트랜잭션, WAL 트랙이랑, 검색 테이블을 위한 FTS5 레슨을 보면 돼.

"버려도 된다"는 말을 증명하는 테스트

테스트 하나가 이 주장을 반증 가능하게 만들어. 코퍼스를 열고, 여행을 만들고, 그 여행에 속한 순간을 만들고, 장소를 고치고 나서 순간을 읽어 둬. 그리고 코퍼스를 닫고, mirror.sqlite3를 지우고, 다시 열어서 세 가지를 확인해. 다시 지은 순간이 아까 읽은 것과 같은지, 여행이 있는지, 그 여행으로 걸러도 순간이 나오는지. 조금씩 접은 결과와 통째로 리플레이한 결과가 레코드 하나까지 같아야 해.

원칙: 지우기 겁나는 캐시는 두 번째 진실이야. 파생 저장소는 부수기 싸게 만들고, 실제로 부수는 테스트로 증명해.

Code

cwkSpark, backend/mirror.py: 예전 스키마는 마이그레이션 대신 파생 테이블을 버려 (원문 그대로, 157-173행)·python
class Mirror:
    """One connection, serialized by the corpus lock (a single-user app)."""

    def __init__(self, path: Path) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        self.path = path
        self.db = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
        self.db.execute("PRAGMA journal_mode=WAL")
        self.db.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)")
        if self.meta("schema_version") not in (None, str(SCHEMA_VERSION)):
            # Derived tables from an older shape: drop them; open() sees the
            # version mismatch and replays the log into the current shape.
            for table in _TABLES:
                self.db.execute(f"DROP TABLE IF EXISTS {table}")
            self.db.execute("DELETE FROM meta")
        self.db.executescript(_SCHEMA)
        self._seq = self._max_seq()
cwkSpark, backend/mirror.py: 사실 두 개가 최신 여부를 정하고, 재구축은 트랜잭션 하나야 (원문 그대로, 194-217행)·python
    def is_current(self, fingerprint: str) -> bool:
        return (
            self.meta("schema_version") == str(SCHEMA_VERSION)
            and self.meta("log_fingerprint") == fingerprint
        )

    def rebuild(self, ops: Iterable[dict[str, Any]]) -> int:
        """Drop every derived row and apply the whole log. Returns the op count."""
        self.db.execute("BEGIN")
        try:
            for table in _TABLES:
                self.db.execute(f"DELETE FROM {table}")
            self.db.execute("DELETE FROM meta")
            self._seq = 0
            count = 0
            for op in ops:
                self._apply(op)
                count += 1
            self.set_meta("schema_version", str(SCHEMA_VERSION))
            self.db.execute("COMMIT")
        except BaseException:
            self.db.execute("ROLLBACK")
            raise
        return count
cwkSpark, backend/corpus.py: 부팅할 때마다 미러가 최신인지 물어 (원문 그대로, 178-195행)·python
    def open(self) -> None:
        self.config.ensure_runtime_dirs()
        with self._lock:
            self.mirror = Mirror(self.config.mirror_path)
            fingerprint = self.log.fingerprint()
            if not self.mirror.is_current(fingerprint):
                self._rebuild_locked(fingerprint)

    def close(self) -> None:
        with self._lock:
            if self.mirror is not None:
                self.mirror.close()
                self.mirror = None

    def rebuild(self) -> dict[str, Any]:
        with self._lock:
            self._rebuild_locked(self.log.fingerprint())
            return self.health()

External links

Exercise

앞 연습에서 만든 로그에 SQLite 뷰를 붙여 봐. meta 테이블에 스키마 버전과 로그 지문을 저장하고, 재구축은 트랜잭션 하나 안에서 돌리고, 뷰를 열 때 둘 중 하나라도 낡았으면 다시 짓게 해. 그리고 테스트 두 개를 짜. 하나는 조금씩 쌓은 뒤 데이터베이스 파일을 지우고 다시 지어서 레코드를 전부 비교해. 다른 하나는 스키마 버전을 올리고 거르는 열을 하나 더해. 그러고 나서 마이그레이션 스크립트 없이 뷰가 새 모양으로 돌아오는 걸 보여 줘.
Hint
접힌 레코드 전체는 JSON으로, 그 옆에 거르는 열 몇 개만 둬. 로그에서 몇 번째 op인지 나타내는 순번도 같이 저장해. 재구축이 중간에 예외를 내면 롤백해서 이전 뷰를 건드리지 않고 남겨.

Progress

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

댓글 0

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

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