LabHub
배우기 러닝패스 코스

ACK 전에 꺼진 간식 자판기 · 새 학기 재고를 어제 번호로 읽지 않는다 · 실습

주말에 꺼진 전광판과 사라진 간식 로그

LabHub 에서 이어서 보기

목표

주말 동안 꺼진 간식 재고 전광판을 복구합니다. 로그의 보관 범위가 끝났으면 같은 시점의 스냅샷을 설치하고 다음 이벤트부터 따라갑니다.

왜 중요한가

연결이 복구돼도 이미 지운 이벤트는 돌아오지 않습니다. 재고와 커서가 다른 시점을 가리키면 재생에 성공해도 잘못된 숫자를 표시합니다. 이번에는 보관 경계·세대·동일 시점 읽기·원자적 설치를 직접 구현하고 실제 종료 뒤의 디스크 상태로 확인합니다.

예상 100분입니다. 기본 세션보다 길므로 만료 전에 +시간을 눌러 연장하세요. 세션 종료 후 파일이 사라집니다. 필요한 코드는 따로 보관하세요. 앞 모듈의 Python 예외·SQLite 트랜잭션·커서와 outbox 복구를 알고 시작합니다.

데이터 계약

산출물은 /root/snapshot/worker.py 한 파일입니다. 원본과 복제본은 동일한 아래 스키마를 가진 별개의 일회용 로컬 SQLite 파일입니다. 검사기가 만들고 정리하므로 DB 경로를 하드코딩하지 않습니다. 모두 정상 스키마의 신뢰한 로컬 파일이며, 함수에 빌려준 con은 시작 시 열린 트랜잭션이 없습니다. open_store·sync_files 외의 함수는 빌린 연결을 닫지 않고 성공·실패 뒤 트랜잭션을 남기지 않습니다.

CREATE TABLE checkpoint (id INTEGER PRIMARY KEY CHECK(id=1),    epoch TEXT NOT NULL, last INTEGER NOT NULL, floor INTEGER NOT NULL);CREATE TABLE stock (id INTEGER PRIMARY KEY CHECK(id=1), total INTEGER NOT NULL);CREATE TABLE events (seq INTEGER PRIMARY KEY, delta INTEGER NOT NULL);

checkpoint 초기 행은 (1, 전달한 epoch, -1, 0), stock 초기 행은 (1,0)입니다. 같은 세대 안에서 번호는 재사용하지 않으며 retained events는 floor부터 last까지 연속합니다. 전부 잘린 경우 floor=last+1입니다. total은 초기 0에서 각 delta를 더한 업무 상태입니다. expected_epoch는 신뢰한 연결 설정에서 받으며 snapshot 자체에서 추출해 기대값으로 삼지 않습니다.

실습의 쓰기는 순차적으로 하나씩 실행합니다. 읽기 트랜잭션 사이에 다른 연결이 쓰기를 커밋하는 방식으로 시점 격리를 검사합니다. 별도 스레드의 동시 체크포인트나 다중 writer 성능 시험은 아닙니다. 모든 연결에 wal_autocheckpoint=0을 설정하며 쓰기가 끝난 뒤 순서대로 연결을 닫습니다. 장기 운영의 WAL 관리 정책으로 그대로 사용하지 마세요.

단계

1. 세대와 복구 상태를 저장한다 — worker.py에 Exception 하위 Conflict·ResyncRequired·Gap·CoveredBySnapshot을 정의하고 validate_snapshot(snapshot), open_store(path,epoch)를 구현하세요. 스냅샷은 version·epoch·last·total만 가진 dict입니다. version은 정확한 int 1, epoch는 ASCII 영문·숫자·밑줄·하이픈 1–64자, last는 bool 제외 int -1부터 2147483647, total은 bool 제외 int이며 절댓값은 1000*(last+1) 이하입니다. 유효하면 새 dict 사본, 아니면 ValueError입니다. open_store는 epoch 검증 후 아래 세 테이블과 초기 행을 없을 때만 원자적으로 만듭니다. isolation_level=None, timeout=1, WAL·synchronous=FULL·wal_autocheckpoint=0으로 열고 sqlite3.Connection을 반환합니다. 기존 내용을 덮지 않으며 초기화 실패에는 연결을 닫습니다.
2. 재고와 다음 번호를 함께 확정한다 — append(con,delta,fault=None)는 bool 제외 int -1000부터 1000의 변화를 추가합니다. BEGIN IMMEDIATE 안에서 다음 seq=last+1을 검사하고 events 삽입→fault('after-event')→stock.total에 delta 더하기와 checkpoint.last 갱신→fault('after-state')→COMMIT→fault('after-commit') 순서로 진행합니다. seq는 0부터 2147483647이며 범위 밖은 ValueError입니다. fault는 있을 때 호출하고 seq를 반환합니다. 커밋 전 오류는 전체 롤백, 커밋 후 오류는 확정 상태 보존이며 원래 오류를 전달합니다.
3. 동일 시점의 스냅샷을 내보낸다 — export_snapshot(con,between=None)는 BEGIN 읽기 트랜잭션에서 epoch·last 조회→between()→total 조회→COMMIT 순서로 version=1 스냅샷을 반환합니다. 훅은 있을 때만 한 번 호출하며 다른 연결의 쓰기가 여기서 커밋될 수 있어야 합니다. 실패 시 읽기 트랜잭션을 정리하고 원래 오류를 전달합니다. 두 SELECT 사이 COMMIT이나 BEGIN IMMEDIATE를 쓰지 않습니다.
4. 보관 범위와 재생 커서를 비교한다 — trim(con,through)는 bool 제외 int -1부터 2147483647을 받고 한 쓰기 트랜잭션에서 floor-1 <= through <= last인지 검사합니다. 아니면 ValueError, 맞으면 seq<=through 삭제와 floor=through+1만 갱신하고 None을 반환합니다. replay(con,epoch,last,limit=16)는 epoch 형식, bool 제외 last -1부터 2147483647, limit 1부터 16을 검증합니다. 같은 읽기 트랜잭션에서 다른 세대는 ResyncRequired, 서버보다 앞선 커서는 ValueError, last<floor-1은 ResyncRequired입니다. 정상은 seq>last인 (seq,delta) tuple의 list를 번호순으로 최대 limit개 반환합니다. 빈 결과와 누락 오류를 구별합니다.
5. 스냅샷을 한 번에 설치한다 — install_snapshot(con,snapshot,expected_epoch,fault=None)는 snapshot과 expected_epoch 형식을 검증합니다. snapshot.epoch와 기대 세대가 다르면 ResyncRequired입니다. 한 쓰기 트랜잭션에서 동일 세대의 더 작은 last 또는 동일 last·다른 total은 Conflict, 동일 last·total은 변경 없이 False입니다. 그 외에는 events 전체 삭제→fault('after-clear')→total 교체와 checkpoint의 epoch·last·floor=last+1 갱신→fault('after-install')→COMMIT→fault('after-commit') 뒤 True를 반환합니다. 커밋 전 오류는 전체 롤백, 커밋 후 오류는 확정 상태 보존입니다. 허용된 다른 세대는 작은 번호로도 교체할 수 있습니다.
6. 검증 가능한 중복과 새 배치를 적용한다 — apply_batch(con,epoch,events,fault=None)는 epoch와 events를 먼저 검증합니다. events는 최대 16개인 list이며 빈 list도 허용합니다. 원소는 seq·delta 둘인 tuple/list, seq는 bool 제외 int 0부터 2147483647, delta는 bool 제외 int -1000부터 1000입니다. 형식 오류는 ValueError, 배치 내부 번호가 1씩 증가하지 않으면 Gap입니다. 한 쓰기 트랜잭션에서 세대 불일치는 ResyncRequired입니다. seq<=last는 보관된 동일 delta면 무효과, 다른 delta는 Conflict, 개별 내역이 없으면 CoveredBySnapshot입니다. 신규 seq는 last+1만 허용하고 아니면 Gap입니다. 새 이벤트·재고·커서를 갱신할 때마다 fault('after-one'), 전체 COMMIT 뒤 fault('after-commit')를 호출합니다. 이번 새 효과 수를 반환하고 입력을 바꾸지 않습니다. 커밋 전 오류는 배치 전체 롤백, 이후 오류는 확정 상태 보존입니다.
7. 한 번의 복구 시도를 유한하게 끝낸다 — sync_once(source,replica,expected_epoch,after_snapshot=None)는 기대 세대를 검증하고 source의 실제 세대와 다르면 ResyncRequired를 냅니다. replica의 epoch·last로 replay를 한 번 요청합니다. ResyncRequired일 때만 export_snapshot(source)→install_snapshot(replica,...,expected_epoch)→after_snapshot()→스냅샷 last 이후 replay로 복구합니다. 훅은 있을 때만 호출합니다. 배치는 최대 16개이며 apply_batch 후 export_snapshot(replica)를 반환합니다. 두 번째 replay에서 다시 잘리면 오류를 그대로 전달하고 설치한 유효 상태를 유지합니다. 다른 오류도 전달하며 내부 무한 재시도는 하지 않습니다.
8. 실제 종료 뒤 별개 파일에서 복구한다 — sync_files(source_path,replica_path,expected_epoch)는 기대 세대를 검증하고 로컬 원본 파일이 없으면 FileNotFoundError입니다. realpath가 같거나 기존 두 경로가 samefile이면 열기 전에 ValueError입니다. 별개 원본·복제본을 open_store로 열어 sync_once를 한 번 실행한 결과를 반환하고 모든 경로에서 소유한 연결을 닫습니다. 복제본을 못 열어도 원본을 닫습니다. 검사기는 append·install·batch의 커밋 전후에서 실제 자식 프로세스를 종료 코드 73으로 끝내고 새 연결로 디스크 상태를 검사합니다.

참고

단계 8개

  1. 세대와 복구 상태를 저장한다
  2. 재고와 다음 번호를 함께 확정한다
  3. 동일 시점의 스냅샷을 내보낸다
  4. 보관 범위와 재생 커서를 비교한다
  5. 스냅샷을 한 번에 설치한다
  6. 검증 가능한 중복과 새 배치를 적용한다
  7. 한 번의 복구 시도를 유한하게 끝낸다
  8. 실제 종료 뒤 별개 파일에서 복구한다