같은 GUID 가 두 번 왔다 — 원장으로 막는다
목표
중계 계층에 GUID 기준 상태 원장(SQLite)을 붙여 이중 처리를 막고, 결과 미확정 거래를 조회로 확정하고, 미전송 확정 거래만 안전하게 재처리한다.
왜 중요한가
채널은 타임아웃이 나면 같은 GUID 로 재전송하고, 계정계는 멱등하지 않다. 중계 계층이 "이 GUID 를 이미 보냈는가, 결과를 아는가" 를 기억하지 못하면 재전송이 곧 이중 이체다. 그리고 실패 목록을 통째로 다시 보내는 재처리가 가장 흔한 대형 사고다 — 결과를 모르는 거래와 보내지 못한 거래를 구분해야 한다.
단계
/root/eaimw/dedup/schema.sql을 쓴다. 테이블tx:guid(기본 키),tx_code,state(RECEIVED·SENT·DONE·UNKNOWN·FAILED 만 허용하는 CHECK),rsp_code,request(요청 전문 BLOB),response(응답 전문 BLOB),attempts(정수),updated_at(datetime('now')형식 문자열). 두 번 적용해도 오류가 없어야 한다(IF NOT EXISTS).cp /opt/lab/fixtures/eaimw/dedup/relay_base.py /root/eaimw/dedup/relay.py로 시작한다.--db <경로>인자를 더하고, 뜰 때 같은 디렉터리의schema.sql을 적용한다. 계정계를 부르기 전에 GUID 로 한 줄을 선점하고(SENT), 결과를 받으면 DONE 과 응답 전문을 저장한다. DONE 인 GUID 가 다시 오면 계정계를 부르지 않고 저장한 응답을 그대로 돌려준다.- 선점한 GUID 가 아직 처리 중(SENT)일 때 같은 GUID 가 오면 기다리지 말고 즉시 E903 으로 답한다. 선점은 원자적으로(
INSERT ... ON CONFLICT(guid) DO NOTHING의 행 수). - 읽기 타임아웃(E901)은 원장에 UNKNOWN, 연결 실패(E902)는 FAILED 로 남긴다. UNKNOWN 인 GUID 가 다시 오면 계정계를 부르지 않고 E901 로 답한다. FAILED 인 GUID 가 다시 오면 다시 보내도 된다(시도 횟수 +1).
/root/eaimw/dedup/resolve.py --db <경로> --core <URL> --min-age <초>: 갱신된 지--min-age초가 지난 UNKNOWN 만 계정계 조회 API(GET /v1/transfers/<guid>)로 확정한다. 200 이면 DONE·0000 과 응답 전문(요청 헤더로 만든 R 전문 + 조회 결과로 만든 45바이트 본문), 404 면 FAILED·E902. 조회 중 연결 오류면 그대로 둔다. 이체를 다시 보내지 않는다./root/eaimw/dedup/reprocess.py --db <경로> --core <URL> --max-attempts <N>: FAILED 이면서 rsp_code 가 E902 이고 attempts 가 N 미만인 행만, 저장한 요청 전문으로 같은 GUID 로 다시 보낸다(선점 후 attempts +1, 결과는 2단계와 같은 규칙으로 기록). UNKNOWN 은 절대 보내지 않는다./root/eaimw/dedup/purge.py --db <경로> --days <N>: N 일이 지난 DONE 만 지운다. UNKNOWN·FAILED·SENT 는 기간과 무관하게 남긴다.
참고
- 원장은 스레드마다 연결을 따로 연다:
sqlite3.connect(path, timeout=5, isolation_level=None)(자동 커밋 — 문장 하나가 원자적). - 선점:
cur = c.execute("INSERT INTO tx(...) VALUES(...) ON CONFLICT(guid) DO NOTHING", ...)뒤cur.rowcount == 1이면 내가 선점한 것. - 채점기는 여러분의 원장(
/root/eaimw/dedup/relay.db)을 건드리지 않고,--db로 새 임시 원장을 넘겨 확인한다. 그래서 스키마 적용은 중계가 뜰 때 해야 한다. - 장애 재현: 메모가
SLOW로 시작하면 계정계가 늦게(그래도 처리는) 한다. 호출 통계curl -s localhost:9201/_stats의by_guid가 GUID 별 호출 횟수다. - 흔한 실수: 조회 → 없으면 삽입 의 두 단계로 선점하는 것(동시 재전송에서 둘 다 통과), 재처리에서 새 GUID 를 따는 것, UNKNOWN 을 재처리 대상에 넣는 것.
상태 원장 스키마
/root/eaimw/dedup/schema.sql 에 tx 테이블(guid 기본 키, 상태 CHECK, 요청·응답 원문, 시도 횟수, 갱신 시각)을 쓴다.
CREATE TABLE IF NOT EXISTS 로 두 번 적용해도 안전하게. CHECK (state IN (...)) 가 오타 상태를 막습니다. 원문은 BLOB 입니다.
끝난 거래의 재전송 — 저장한 답을 그대로
relay_base.py 를 복사해 원장을 붙인다. 보내기 전에 SENT 로 선점, 결과는 DONE 과 응답 전문으로, DONE 재전송에는 저장한 응답을 그대로.
handle 에서 call_core 를 부르기 전에 INSERT ... ON CONFLICT(guid) DO NOTHING 로 한 줄을 선점하고, 이미 있으면 SELECT 로 상태를 봅니다. 뜰 때 schema.sql 을 executescript 로 적용하세요.
처리 중 재전송 — 즉시 E903
SENT 상태인 GUID 가 다시 오면 기다리지 말고 E903 으로 답한다.
선점에 실패했는데 DONE 이 아니면 누군가 처리 중입니다. 조회 후 삽입의 두 단계가 아니라 삽입 한 번의 행 수로 판단해야 동시 재전송에서도 한쪽만 선점합니다.
모르는 것은 UNKNOWN, 못 보낸 것은 FAILED
E901 은 UNKNOWN, E902 는 FAILED 로 남긴다. UNKNOWN 재전송은 부르지 않고 E901, FAILED 재전송은 다시 보낸다.
결과를 적을 때 응답코드에 따라 상태를 고릅니다. FAILED 의 재선점도 UPDATE ... WHERE state='FAILED' 의 행 수로 원자적으로.
조회로 확정한다 — 다시 보내지 않는다
/root/eaimw/dedup/resolve.py 가 오래된 UNKNOWN 만 조회 API 로 확정한다(200→DONE, 404→FAILED/E902, 오류→그대로).
updated_at <= datetime('now', '-N seconds') 로 오래된 것만 고릅니다. 방금 타임아웃 난 거래는 계정계가 아직 처리 중일 수 있습니다. 응답 전문은 lhstd.reply(요청전문, '0000', 본문).
미전송 확정만 같은 GUID 로 재처리
/root/eaimw/dedup/reprocess.py 가 FAILED/E902 이고 attempts 가 한도 미만인 행만 저장한 요청으로 다시 보낸다.
대상 선택 조건이 전부입니다 — state, rsp_code, attempts. 보내기 전에 SENT 로 다시 선점하고 attempts 를 올립니다. GUID 는 저장한 요청 전문의 것을 그대로 씁니다.
원장 정리 — 지워도 되는 것만
/root/eaimw/dedup/purge.py --days N 이 N 일 지난 DONE 만 지운다.
DELETE 의 WHERE 에 상태와 기간을 둘 다 겁니다. UNKNOWN 을 지우면 그 거래는 조사할 근거가 사라집니다.