ACK 전에 꺼진 간식 자판기 · 끊긴 연결 뒤의 처리 기록 · 실습
ACK 전에 꺼진 간식 자판기를 복구한다
목표
영속 커서·원자적 처리·재생 범위를 구현하고 실제 TCP 재접속으로 중복 처리를 막습니다.
왜 중요한가
연결 복구와 업무 복구는 다릅니다. 저장과 확인 사이에 죽는 수신기를 직접 검사해 둘의 경계를 배웁니다. Python 함수·예외·async/await, SQL 기초와 앞 실시간 구독자 코스를 선수 지식으로 권합니다. 설치나 인터넷은 필요 없습니다. 75분 실습이므로 기본 60분 세션에서 +시간으로 연장하세요. 세션 종료 시 모든 파일은 사라지니 필요한 코드는 별도로 보관하세요.
단계
1. 찢어진 마지막 줄을 명령으로 받지 않는다 — parse_line(line)을 구현하세요. bytes만 받고 64바이트 이하의 ASCII EVENT 순번 변화량 LF를 (seq, delta) 튜플로 반환합니다. seq는 0–2147483647, delta는 -1000–1000입니다. 숫자 0 외의 앞자리 0, + 부호, -0, CRLF, 개행 누락, 여러 줄, 비ASCII는 ValueError입니다.
2. 재시작해도 기억하는 저장소를 연다 — open_store(path)는 sqlite3 연결을 반환합니다. checkpoint(id INTEGER PRIMARY KEY CHECK(id=1), last INTEGER NOT NULL, total INTEGER NOT NULL), ledger(seq INTEGER PRIMARY KEY, delta INTEGER NOT NULL) 테이블을 없을 때만 만들고 checkpoint 초기 행 (1,-1,0)을 없을 때만 삽입합니다. state(con)은 (last,total) 튜플입니다. 명시적 SQL 트랜잭션을 사용할 수 있게 isolation_level=None, busy timeout=1초로 연결하세요. 초기화 실패에는 연결을 닫고 오류를 전달합니다.
3. 합계와 커서를 함께 저장하거나 함께 취소한다 — Exception 하위 Gap·Conflict를 선언하고 apply_event(con,seq,delta,fault=None)를 구현하세요. bool을 제외한 1단계 범위 int만 허용하고 잘못되면 ValueError입니다. seq=last+1은 ledger 삽입→total 갱신→fault가 있으면 fault()→last 갱신을 한 트랜잭션으로 커밋하고 True를 반환합니다. seq<=last는 ledger의 같은 delta면 False, 내용이 다르면 Conflict입니다. 더 큰 순번은 Gap입니다. 실패에는 모든 변경을 롤백하고 원래 오류를 전달하며 트랜잭션을 남기지 마세요.
4. 잘린 기록을 복구 성공이라고 숨기지 않는다 — Exception 하위 ResyncRequired와 replay(events,last,limit)를 구현하세요. events는 1–128개의 (seq,delta) 튜플로 된 list이며 모든 순번이 연속·오름차순이고 값은 1단계 범위 int입니다. last는 -1–2147483647 int, limit는 1–16 int입니다. bool·형식·범위 오류, 빈 로그, last>마지막 순번은 ValueError입니다. last<첫 순번-1은 ResyncRequired, 그 외에는 seq>last인 항목을 최대 limit개 새 list로 반환합니다. 입력 전체를 먼저 검사하고 변경하지 않습니다.
5. 커밋한 뒤에만 업무 ACK를 만든다 — consume_line(con,line,fault=None)은 parse_line과 apply_event를 호출하고 성공 후 b"ACK 순번\n"을 반환합니다. fault를 apply_event에 전달하세요. 같은 내용의 중복에도 ACK하지만 파싱·갭·충돌·업무 실패는 오류를 전달하며 ACK하지 않습니다.
6. 오류와 취소에도 연결을 닫는다 — async consume_session(reader,writer,con,timeout,before_ack=None)를 구현하세요. timeout은 bool 제외 양의 유한 int/float이며 아니면 ValueError입니다. 매번 readline 대기를 timeout으로 제한하고 EOF면 state를 반환합니다. 한 줄을 consume_line으로 커밋한 뒤 before_ack가 있으면 before_ack(seq)를 호출하고 writer.write(ack), timeout 제한 drain을 수행합니다. 오류·취소를 전달하며 정상·실패·취소 모두 close→timeout 제한 wait_closed로 회수합니다. 종료 대기 TimeoutError·ConnectionError에는 writer.transport.abort하고 기존 오류를 숨기지 않습니다. DB 연결은 닫지 않습니다. 잘못된 timeout의 종료 대기는 1초를 사용합니다.
7. 수신기를 죽이고 같은 DB에서 이어받는다 — async run_client(host,port,path,timeout,before_ack=None)를 구현하세요. timeout을 먼저 검증하고 open_store(path), timeout 제한 asyncio.open_connection(host,port,limit=64) 순서로 엽니다. DB의 last를 b"RESUME last\n"으로 쓰고 timeout 제한 drain 후 consume_session에 넘겨 반환값을 돌려줍니다. 모든 경로에서 DB 연결을 닫고, consume_session에 넘기기 전 실패에는 직접 소켓을 회수합니다. 오류·취소는 전달합니다. 검사기가 임시 DB와 loopback 서버를 만들고 첫 프로세스를 커밋 1/ACK 1 사이에 종료한 뒤 새 프로세스로 재접속합니다. 재전송 1을 중복 반영하지 않아야 합니다.
참고
mkdir -p /root/resume 후 /root/resume/client.py에 구현합니다. 검사기는 이전 단계도 실행하며 임시 DB·loopback 포트·수신 프로세스를 준비하고 회수합니다. 학습자가 서버나 DB 파일을 계속 켜 둘 필요는 없습니다. 제출 파일은 수정하지 않습니다. 단계당 실행 제한은 15초이며 학습 시간을 제한하는 것이 아닙니다. 마지막 검사는 실제 프로세스를 종료하므로 정리 finally가 실행된다는 가정을 두지 않습니다. 프로덕션용 TLS·인증·자동 백오프·스냅샷 복구·분산 exactly-once를 구현한 실습은 아닙니다.
단계 7개
- 찢어진 마지막 줄을 명령으로 받지 않는다
- 재시작해도 기억하는 저장소를 연다
- 합계와 커서를 함께 저장하거나 함께 취소한다
- 잘린 기록을 복구 성공이라고 숨기지 않는다
- 커밋한 뒤에만 업무 ACK를 만든다
- 오류와 취소에도 연결을 닫는다
- 수신기를 죽이고 같은 DB에서 이어받는다