The snack machine died before ACK
The Snack Order Was Saved, but the Warehouse Never Knew
한국어 원문으로 표시합니다.
목표
업무와 전송 의도, 수신 ID와 업무 효과를 각각 원자적으로 저장하고 실제 HTTP 재전송·프로세스 종료 후 복구합니다.
왜 중요한가
응답이 없다는 이유만으로 업무가 실행되지 않았다고 단정하면 중복 반영을 만듭니다. 반대로 전송 전에 완료 표시하면 아직 전달하지 않은 업무를 잃습니다. Python과 SQLite로 두 경계를 분리해 구현하며 정리 코드가 실행되지 않는 프로세스 종료에서도 남는 상태를 확인합니다.
예상 85분입니다. 기본 세션보다 길므로 만료 전에 +시간을 눌러 연장하세요. 세션 종료 후 파일이 사라집니다. 필요한 코드는 별도로 보관하세요. Python 함수·예외·SQLite 트랜잭션과 앞 모듈의 ACK 복구를 알고 시작합니다.
데이터 계약
모든 제출 코드는 /root/outbox/worker.py입니다. 아래 함수가 받은 con은 올바른 역할의 열린 연결이며 호출 전에 트랜잭션이 없습니다. open_store를 제외한 함수는 빌린 연결을 닫지 않습니다. 모든 성공·실패 뒤 열린 트랜잭션을 남기지 않습니다. source와 sink는 반드시 별개 파일로 시험하며 운영 DB에는 접속하지 않습니다.
source 스키마:
CREATE TABLE orders (id TEXT PRIMARY KEY, qty INTEGER NOT NULL);
CREATE TABLE outbox (seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE, qty INTEGER NOT NULL,
sent INTEGER NOT NULL CHECK(sent IN (0,1)));
sink 스키마:
CREATE TABLE inbox (id TEXT PRIMARY KEY, qty INTEGER NOT NULL);
CREATE TABLE stock (id INTEGER PRIMARY KEY CHECK(id=1), total INTEGER NOT NULL);
stock 초기 행은 (1,0)입니다. 각 역할의 초기화는 한 트랜잭션이며 기존 내용을 보존합니다. 검사기가 임시 DB·loopback HTTP 서버·자식 프로세스를 만들고 정리하므로 DB 경로나 고정 포트를 코드에 넣지 마세요.
단계
- 전송 ID와 수량의 계약을 만든다 — worker.py에 Exception 하위 Conflict·DeliveryError와 validate_event(event)를 구현하세요. event는 정확히 id·qty 두 키인 dict입니다. id는 ASCII 영문·숫자·밑줄·하이픈 1–64자, qty는 bool 제외 int 1–1000입니다. 잘못되면 ValueError, 정상이면 입력을 바꾸지 않고 새 dict 사본을 반환합니다.
- 서로 다른 두 저장소를 초기화한다 — open_store(path, role)는 source 또는 sink 역할의 sqlite3.Connection을 반환합니다. isolation_level=None·busy timeout 1초로 열고 아래 스키마를 없을 때만 만드세요. sink의 stock 초기 행 (1,0)은 없을 때만 넣습니다. 초기화는 한 트랜잭션이며 실패에는 연결을 닫고 오류를 전달합니다. 기존 기록을 초기값으로 덮어쓰지 않습니다. 다른 role은 연결 전에 ValueError입니다.
- 주문과 전송 의도를 함께 남긴다 — enqueue(con,event,fault=None)는 validate_event 후 BEGIN IMMEDIATE에서 처리합니다. 새 ID는 orders 삽입→fault('after-order')→outbox(sent=0) 삽입→fault('after-outbox')→COMMIT→fault('after-commit') 순서로 진행하고 True를 반환합니다. fault는 있을 때만 호출합니다. 기존 ID·같은 qty는 변경 없이 False, 다른 qty는 Conflict입니다. 커밋 전 오류는 전부 롤백하고 원래 오류를 전달하며, 커밋 후 오류는 확정된 기록을 지우지 않습니다.
- 완료하지 않은 배치를 순서대로 읽는다 — pending(con,limit)는 sent=0인 outbox를 seq 오름차순으로 최대 limit개 반환합니다. 각 원소는 id·qty만 가진 새 dict입니다. limit는 bool 제외 int 1–16이며 다른 값은 ValueError입니다. 조회는 DB나 입력을 변경하지 않습니다.
- 중복 수신의 효과를 한 번으로 제한한다 — receive(con,event,fault=None)는 sink 연결에서 동작합니다. 검증 뒤 BEGIN IMMEDIATE에서 새 ID의 inbox 삽입→fault('after-inbox')→stock.total에 qty 더하기→fault('after-total')→COMMIT→fault('after-commit') 순서로 True를 반환합니다. 같은 ID·qty는 False, 같은 ID·다른 qty는 Conflict입니다. 다른 ID·같은 qty는 별개 업무입니다. 커밋 전 오류는 전체 롤백, 커밋 후 오류는 확정 상태 보존이며 원래 오류를 전달합니다.
- 확인한 항목에만 완료 표식을 남긴다 — mark_sent(con,event)는 검증한 id·qty와 일치하는 source outbox의 sent만 1로 바꿉니다. 없는 ID는 ValueError, 다른 qty는 Conflict이며 실패에 DB 상태를 바꾸지 않습니다. 새 표식이면 True, 이미 완료면 False입니다. orders와 outbox 행을 삭제하지 않고 트랜잭션을 남기지 않습니다.
- ACK 뒤 표시하고 첫 실패에서 멈춘다 — dispatch(con,send,limit=8,fault=None)는 pending의 유한 배치만 순서대로 처리합니다. send에 event 사본을 주고 정확한 True ACK를 받은 뒤 fault('after-delivery'), mark_sent를 호출합니다. False·None·1·문자열은 DeliveryError이고 send/fault의 예외는 원래대로 전달합니다. 첫 실패에서 멈추며 앞선 완료는 보존합니다. 반환값은 이번에 완료 표시한 개수입니다. 전송 중 쓰기 트랜잭션을 잡지 않으며 콜백이 인자를 바꿔도 원래 선택한 항목만 완료 표시합니다.
- HTTP와 실제 프로세스 재시작으로 복구한다 — send_http(url,event,timeout=1)를 구현하세요. validate_event 후 HTTP POST JSON을 보내고 상태 200, 1024바이트 이하 JSON dict의 정확한 두 키 id·accepted, 요청과 같은 id, accepted is True일 때만 True를 반환합니다. 잘못된 ACK는 DeliveryError, HTTP·연결 오류는 전달하며 응답을 닫습니다. timeout은 bool 제외 양의 유한 int/float 5초 이하이고 아니면 ValueError입니다. URL은 http://127.0.0.1:포트/events만 허용하며 명시적 포트 1–65535, 사용자정보·쿼리·fragment 없음이어야 합니다. 자동 프록시와 리다이렉트를 사용하지 않습니다. 최종 검사는 수신 커밋 뒤 발행자를 실제 종료하고 새 프로세스가 같은 ID를 재전송해 재고를 한 번만 늘리는지 확인합니다.
참고
- 최종 진단 명령: python3 -B /opt/fixtures/outbox/check.py 8 /root/outbox/worker.py. 앞 단계는 8을 해당 단계 번호로 바꿉니다. 제출 파일을 고치지 않고 임시 저장소에서 실행하며 채점은 12초로 제한합니다.
- 기본 라이브러리만 쓰고 인터넷·설치·추가 capability가 필요 없습니다. 제공 HTTP 서버는 loopback 전용이며 인증·TLS가 없는 교육용입니다. send_http의 timeout은 소켓의 블로킹 동작별 대기 상한이지 전체 요청 총 기한이 아닙니다.
- 단일 전달 루프의 유한 배치와 같은 디스크의 프로세스 재시작을 검증합니다. 자동 재시도 스케줄러·다중 작업자 claim/lease·디스크 소실·외부 결제 원자성·대규모 성능은 검증하지 않습니다.
- 수신 커밋 뒤 응답이 사라져도 기존 업무를 지우지 마세요. 같은 ID·내용으로 다시 전달하고 효과가 한 번인지 독립 연결로 확인합니다.
전송 ID와 수량의 계약을 만든다
worker.py에 Exception 하위 Conflict·DeliveryError와 validate_event(event)를 구현하세요. event는 정확히 id·qty 두 키인 dict입니다. id는 ASCII 영문·숫자·밑줄·하이픈 1–64자, qty는 bool 제외 int 1–1000입니다. 잘못되면 ValueError, 정상이면 입력을 바꾸지 않고 새 dict 사본을 반환합니다.
int(True)가 1이라는 사실과 수량이 유효하다는 계약은 다릅니다. 정규식은 문자열 전체를 검사하세요.
서로 다른 두 저장소를 초기화한다
open_store(path, role)는 source 또는 sink 역할의 sqlite3.Connection을 반환합니다. isolation_level=None·busy timeout 1초로 열고 아래 스키마를 없을 때만 만드세요. sink의 stock 초기 행 (1,0)은 없을 때만 넣습니다. 초기화는 한 트랜잭션이며 실패에는 연결을 닫고 오류를 전달합니다. 기존 기록을 초기값으로 덮어쓰지 않습니다. 다른 role은 연결 전에 ValueError입니다.
CREATE TABLE IF NOT EXISTS와 INSERT OR IGNORE의 역할은 다릅니다. 성공 시 열린 트랜잭션을 남기지 마세요.
주문과 전송 의도를 함께 남긴다
enqueue(con,event,fault=None)는 validate_event 후 BEGIN IMMEDIATE에서 처리합니다. 새 ID는 orders 삽입→fault('after-order')→outbox(sent=0) 삽입→fault('after-outbox')→COMMIT→fault('after-commit') 순서로 진행하고 True를 반환합니다. fault는 있을 때만 호출합니다. 기존 ID·같은 qty는 변경 없이 False, 다른 qty는 Conflict입니다. 커밋 전 오류는 전부 롤백하고 원래 오류를 전달하며, 커밋 후 오류는 확정된 기록을 지우지 않습니다.
두 INSERT 사이에 COMMIT을 넣으면 프로세스 종료 때 주문만 남습니다. fault 지점을 유지해야 중간 상태를 시험할 수 있습니다.
완료하지 않은 배치를 순서대로 읽는다
pending(con,limit)는 sent=0인 outbox를 seq 오름차순으로 최대 limit개 반환합니다. 각 원소는 id·qty만 가진 새 dict입니다. limit는 bool 제외 int 1–16이며 다른 값은 ValueError입니다. 조회는 DB나 입력을 변경하지 않습니다.
ID z가 a보다 먼저 접수될 수 있습니다. 업무 ID의 사전순 대신 저장한 삽입 순서를 쓰세요.
중복 수신의 효과를 한 번으로 제한한다
receive(con,event,fault=None)는 sink 연결에서 동작합니다. 검증 뒤 BEGIN IMMEDIATE에서 새 ID의 inbox 삽입→fault('after-inbox')→stock.total에 qty 더하기→fault('after-total')→COMMIT→fault('after-commit') 순서로 True를 반환합니다. 같은 ID·qty는 False, 같은 ID·다른 qty는 Conflict입니다. 다른 ID·같은 qty는 별개 업무입니다. 커밋 전 오류는 전체 롤백, 커밋 후 오류는 확정 상태 보존이며 원래 오류를 전달합니다.
수신 기록과 업무 효과를 분리 커밋하지 마세요. False는 실패가 아니라 이번에 새 효과를 적용하지 않은 유효한 중복입니다.
확인한 항목에만 완료 표식을 남긴다
mark_sent(con,event)는 검증한 id·qty와 일치하는 source outbox의 sent만 1로 바꿉니다. 없는 ID는 ValueError, 다른 qty는 Conflict이며 실패에 DB 상태를 바꾸지 않습니다. 새 표식이면 True, 이미 완료면 False입니다. orders와 outbox 행을 삭제하지 않고 트랜잭션을 남기지 않습니다.
나중에 대조할 업무와 의도를 보존합니다. WHERE id 조건 없이 전체 배치를 완료하지 마세요.
ACK 뒤 표시하고 첫 실패에서 멈춘다
dispatch(con,send,limit=8,fault=None)는 pending의 유한 배치만 순서대로 처리합니다. send에 event 사본을 주고 정확한 True ACK를 받은 뒤 fault('after-delivery'), mark_sent를 호출합니다. False·None·1·문자열은 DeliveryError이고 send/fault의 예외는 원래대로 전달합니다. 첫 실패에서 멈추며 앞선 완료는 보존합니다. 반환값은 이번에 완료 표시한 개수입니다. 전송 중 쓰기 트랜잭션을 잡지 않으며 콜백이 인자를 바꿔도 원래 선택한 항목만 완료 표시합니다.
완료 표시를 먼저 하면 전송 전 종료 때 미완료 항목을 잃습니다. receive의 False를 HTTP ACK 실패와 혼동하지 마세요.
HTTP와 실제 프로세스 재시작으로 복구한다
send_http(url,event,timeout=1)를 구현하세요. validate_event 후 HTTP POST JSON을 보내고 상태 200, 1024바이트 이하 JSON dict의 정확한 두 키 id·accepted, 요청과 같은 id, accepted is True일 때만 True를 반환합니다. 잘못된 ACK는 DeliveryError, HTTP·연결 오류는 전달하며 응답을 닫습니다. timeout은 bool 제외 양의 유한 int/float 5초 이하이고 아니면 ValueError입니다. URL은 http://127.0.0.1:포트/events만 허용하며 명시적 포트 1–65535, 사용자정보·쿼리·fragment 없음이어야 합니다. 자동 프록시와 리다이렉트를 사용하지 않습니다. 최종 검사는 수신 커밋 뒤 발행자를 실제 종료하고 새 프로세스가 같은 ID를 재전송해 재고를 한 번만 늘리는지 확인합니다.
urllib.request의 ProxyHandler({})와 HTTPRedirectHandler를 확인하세요. 요청이 두 번 도착해도 창고의 효과는 한 번이어야 합니다.