LabHub
배우기 러닝패스 코스

ACK 전에 꺼진 간식 자판기 · 실패한 간식 주문을 지우지 않는다 · 실습

간식 전달자 두 명과 끝나지 않는 재시도

LabHub 에서 이어서 보기

목표

축제 간식 전달자 두 명이 경쟁하고 전송 직후 한 명이 꺼져도, 남은 업무를 유한한 예산으로 복구합니다. 오래된 작업자가 새 작업자의 완료 기록을 덮지 못하게 합니다.

왜 중요한가

네트워크 타임아웃은 업무가 처리되지 않았다는 뜻이 아닙니다. 임대가 끝나도 이전 요청은 계속 실행될 수 있습니다. 이번에는 큐의 선점 토큰과 수신 inbox를 구분하고, 잠금 밖의 전송·실패 예산·격리 기록을 함께 설계합니다. 앞선 outbox 실습의 중복 효과 방지를 다중 전달자 상황으로 확장합니다.

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

데이터 계약

산출물은 /root/lease/worker.py입니다. 큐 파일과 HTTP 수신 서버는 검사기가 임시로 만들고 정리하므로 경로·포트를 하드코딩하지 않습니다. 모든 큐는 정상 스키마를 가진 신뢰한 로컬 파일입니다. 아래 스키마를 사용합니다.

CREATE TABLE jobs (seq INTEGER PRIMARY KEY AUTOINCREMENT,  id TEXT NOT NULL UNIQUE, qty INTEGER NOT NULL,  status TEXT NOT NULL CHECK(status IN ('pending','leased','done','dead')),  attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL, token INTEGER NOT NULL,  available_at INTEGER NOT NULL, deadline INTEGER NOT NULL,  owner TEXT, lease_until INTEGER, reason TEXT);CREATE TABLE redrives (seq INTEGER PRIMARY KEY AUTOINCREMENT,  id TEXT NOT NULL, token INTEGER NOT NULL, at_ms INTEGER NOT NULL, note TEXT NOT NULL);

식별자는 ASCII 영문·숫자·밑줄·하이픈 1–64자의 정확한 str입니다. now_ms와 clock 반환값은 공통 시간 기준의 정확한 int 0부터 (10**15-60000)까지이며, 모든 정수 계약은 bool을 거부합니다. 개별 DB 함수는 형식·범위 오류를 변경 전에 ValueError로 거부하고 입력을 바꾸지 않습니다. run_once의 두 번째 clock이 잘못되거나 뒤로 간 경우에는 이미 커밋한 선점을 보존하고 오류를 전달합니다. 호스트 사이 시계를 맞추는 합의 알고리즘은 아닙니다.

빌린 con은 호출 시작 시 트랜잭션이 없으며, 함수는 이를 닫지 않습니다. 성공·실패 뒤 열린 트랜잭션을 남기지 않습니다. 쓰기 선택과 변경은 BEGIN IMMEDIATE로 묶고, 커밋 전 fault 오류는 전체 롤백하며 원래 오류를 전달합니다. 커밋 후 오류는 이미 확정된 상태를 보존합니다. fault=None이면 호출하지 않으며 훅은 해당 변경을 수행한 경로에서만 호출합니다. None·False 등 무변경 반환 경로에는 훅이 없습니다. claim은 대상이 없어도 앞서 수행한 격리 갱신을 커밋합니다.

작업마다 의미가 독립적입니다. 앞 업무의 실패가 뒤 업무를 막는 엄격한 순서 보장 큐가 아닙니다. token은 업무별 단조 증가 선점 번호이며 검사 범위에서는 10**15 이내입니다. 큐의 표식 검사는 외부 서버의 권한 검증이나 외부 자원의 fencing을 대신하지 않습니다. 수신 서버는 동일 ID·동일 수량의 중복 효과를 제거하도록 제공됩니다. redrive는 권한 있는 운영자가 호출한다는 전제이며 로그인·역할 관리 기능은 구현하지 않습니다.

단계

1. 재시도 지연의 상한과 지터를 계산한다 — worker.py에 Exception 하위 Conflict·Retryable·Permanent·BadAck를 정의하고 retry_delay(attempt,base_ms,cap_ms,jitter)를 구현하세요. attempt는 1–16, base_ms는 1–60000, cap_ms는 base_ms–60000인 정확한 int입니다. jitter는 bool을 제외한 정확한 int/float이며 유한한 0–1입니다. floor(min(cap_ms,base_ms*2**(attempt-1))*jitter)를 반환합니다. 잘못된 입력은 ValueError입니다. 내부 난수나 sleep 없이 주어진 비율로 계산하세요.
2. 재시작 뒤에도 남는 큐를 연다 — open_queue(path)는 아래 jobs·redrives 테이블을 없을 때만 한 트랜잭션으로 만들고 sqlite3.Connection을 반환합니다. isolation_level=None, timeout=1초, journal_mode=DELETE, synchronous=FULL을 사용합니다. 기존 업무·감사 행을 보존하고 초기화 실패에는 연결을 닫습니다. 인메모리 DB가 아닌 일회용 로컬 파일을 받습니다.
3. 중복 접수로 예산을 초기화하지 않는다 — enqueue(con,event,now_ms,ttl_ms,max_attempts=3)는 id·qty만 가진 정확한 dict를 받습니다. id는 아래 식별자, qty는 정확한 int 1–1000, ttl_ms는 1–60000, max_attempts는 1–16입니다. 새 업무를 pending, attempts=0, token=0, available_at=now_ms, deadline=now_ms+ttl_ms, owner·lease_until·reason=NULL로 삽입하고 True를 반환합니다. 같은 ID·같은 수량은 다른 입력 예산에도 기존 행 전체를 보존하며 False, 다른 수량은 Conflict입니다. 먼저 모든 입력을 검증하고 선택·삽입을 하나의 쓰기 트랜잭션으로 처리합니다.
4. 두 전달자가 동시에 같은 일을 가져가지 못하게 한다 — claim(con,owner,now_ms,lease_ms=1000,fault=None)을 구현하세요. owner는 식별자, lease_ms는 정확한 int 1–60000입니다. BEGIN IMMEDIATE 안에서 pending 또는 만료된 leased 행 중 deadline<=now_ms나 attempts>=max_attempts인 행을 dead로 옮깁니다. reason은 마감이면 deadline, 아니면 exhausted이며 owner·lease_until을 NULL로 합니다. 남은 행 중 pending이고 available_at<=now_ms 또는 leased이고 lease_until<=now_ms인 업무를 seq 오름차순으로 하나 선택합니다. 없으면 None입니다. 있으면 attempts·token을 각각 1 올리고 leased, owner, lease_until=min(now_ms+lease_ms,deadline), reason=NULL로 저장합니다. id·qty·owner·token·attempt·lease_until만 있는 dict를 반환합니다. attempt는 갱신한 attempts입니다. 갱신 뒤 fault('after-claim'), COMMIT 뒤 fault('after-commit')를 호출합니다.
5. 오래된 전달자의 완료를 거절한다 — finish(con,ticket,outcome,now_ms,delay_ms=0,fault=None)을 구현하세요. ticket은 claim의 6키만 가진 dict이며 id·owner는 식별자, qty=1–1000, token=1–1015, attempt=1–16, lease_until=1–1015인 정확한 int입니다. outcome은 ok·retry·permanent 중 str, delay_ms는 정확한 int 0–60000입니다. 한 쓰기 트랜잭션에서 업무가 없거나 leased가 아니거나 표식의 수량·소유자·토큰·시도·만료가 다르거나 now_ms>=lease_until 또는 deadline이면 변경 없이 False입니다. 유효한 ok는 done/NULL 사유, permanent는 dead/permanent입니다. retry는 최대 시도에 도달했으면 dead/exhausted, 아니면 now_ms+delay_ms>=deadline이면 dead/deadline, 나머지는 pending/retry입니다. pending일 때만 available_at=now_ms+delay_ms, 나머지는 now_ms이며 owner·lease_until은 NULL로 합니다. 다른 필드는 보존하고 새 status 문자열을 반환합니다. 갱신 뒤 fault('after-finish'), COMMIT 뒤 fault('after-commit')를 호출합니다.
6. 격리 해제와 감사 기록을 함께 남긴다 — redrive(con,job_id,now_ms,ttl_ms,note,fault=None)는 존재하는 dead 업무만 재조정하고 True를 반환합니다. job_id·note는 식별자이며 ttl_ms는 정확한 int 1–60000입니다. 대상이 없거나 dead가 아니면 ValueError입니다. 한 쓰기 트랜잭션에서 redrives에 id·현재 token·at_ms=now_ms·note를 삽입→fault('after-audit')→업무를 pending, attempts=0, available_at=now_ms, deadline=now_ms+ttl_ms, owner·lease_until·reason=NULL로 변경→fault('after-redrive')→COMMIT→fault('after-commit') 순서입니다. qty·max_attempts·token은 보존합니다.
7. 네트워크 전송을 쓰기 잠금 밖에서 수행한다 — run_once(con,owner,clock,send,jitter,lease_ms=1000)는 jitter를 먼저 검증하고 clock()의 첫 시각으로 claim합니다. 업무가 없으면 None이고 send를 부르지 않습니다. 있으면 트랜잭션 밖에서 id·qty만 가진 새 dict를 send에 한 번 전달합니다. 정확한 True만 ok이고 다른 반환값은 BadAck입니다. Retryable만 retry로 분류하여 retry_delay(ticket.attempt,100,1000,jitter)를 사용하며 Permanent는 permanent로 분류합니다. 다른 오류·BadAck는 그대로 전달하고 leased 상태를 보존합니다. 정상·분류된 오류 뒤 clock()을 다시 호출하고 두 번째 값이 첫 값보다 작으면 ValueError입니다. finish에 두 번째 시각과 결과·지연을 전달한 뒤 id·token·status dict를 반환합니다. finish가 False면 status는 stale입니다. 전송 콜백이 받은 dict를 바꾸거나 별도 연결로 업무를 넣어도 현재 표식은 유지되어야 합니다.
8. 전송 직후 죽은 작업을 다른 프로세스가 복구한다 — run_file(path,owner,clock,send,jitter,lease_ms=1000)는 기존 일반 큐 파일이 없으면 FileNotFoundError로 거부하며 빈 큐를 새로 만들지 않습니다. open_queue로 연결을 소유하고 run_once 결과를 반환하며 성공·실패 모두 연결을 닫습니다. 최종 검사에서는 실제 두 자식 프로세스의 동시 claim 중 하나만 성공하는지, 커밋 전후 강제 종료 뒤 상태가 원자적인지 검사합니다. 별도 HTTP 수신 서버가 수량을 커밋한 직후 첫 전달자를 종료하고 두 번째 전달자 프로세스에서 재전송합니다. HTTP 요청은 두 번이되 inbox와 수신 효과는 한 번이어야 합니다. 수신 서버와 임시 DB는 검사기가 제공합니다.

참고

단계 8개

  1. 재시도 지연의 상한과 지터를 계산한다
  2. 재시작 뒤에도 남는 큐를 연다
  3. 중복 접수로 예산을 초기화하지 않는다
  4. 두 전달자가 동시에 같은 일을 가져가지 못하게 한다
  5. 오래된 전달자의 완료를 거절한다
  6. 격리 해제와 감사 기록을 함께 남긴다
  7. 네트워크 전송을 쓰기 잠금 밖에서 수행한다
  8. 전송 직후 죽은 작업을 다른 프로세스가 복구한다