LabHub
Get started
배우기 러닝패스 코스

Building an EAI Middleware Layer

The Same GUID Arrived Twice — Stop It with a Ledger

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

중계 계층에 GUID 기준 상태 원장(SQLite)을 붙여 이중 처리를 막고, 결과 미확정 거래를 조회로 확정하고, 미전송 확정 거래만 안전하게 재처리한다.

왜 중요한가

채널은 타임아웃이 나면 같은 GUID 로 재전송하고, 계정계는 멱등하지 않다. 중계 계층이 "이 GUID 를 이미 보냈는가, 결과를 아는가" 를 기억하지 못하면 재전송이 곧 이중 이체다. 그리고 실패 목록을 통째로 다시 보내는 재처리가 가장 흔한 대형 사고다 — 결과를 모르는 거래와 보내지 못한 거래를 구분해야 한다.

단계

  1. /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).
  2. cp /opt/lab/fixtures/eaimw/dedup/relay_base.py /root/eaimw/dedup/relay.py 로 시작한다. --db <경로> 인자를 더하고, 뜰 때 같은 디렉터리의 schema.sql 을 적용한다. 계정계를 부르기 전에 GUID 로 한 줄을 선점하고(SENT), 결과를 받으면 DONE 과 응답 전문을 저장한다. DONE 인 GUID 가 다시 오면 계정계를 부르지 않고 저장한 응답을 그대로 돌려준다.
  3. 선점한 GUID 가 아직 처리 중(SENT)일 때 같은 GUID 가 오면 기다리지 말고 즉시 E903 으로 답한다. 선점은 원자적으로(INSERT ... ON CONFLICT(guid) DO NOTHING 의 행 수).
  4. 읽기 타임아웃(E901)은 원장에 UNKNOWN, 연결 실패(E902)는 FAILED 로 남긴다. UNKNOWN 인 GUID 가 다시 오면 계정계를 부르지 않고 E901 로 답한다. FAILED 인 GUID 가 다시 오면 다시 보내도 된다(시도 횟수 +1).
  5. /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. 조회 중 연결 오류면 그대로 둔다. 이체를 다시 보내지 않는다.
  6. /root/eaimw/dedup/reprocess.py --db <경로> --core <URL> --max-attempts <N>: FAILED 이면서 rsp_code 가 E902 이고 attempts 가 N 미만인 행만, 저장한 요청 전문으로 같은 GUID 로 다시 보낸다(선점 후 attempts +1, 결과는 2단계와 같은 규칙으로 기록). UNKNOWN 은 절대 보내지 않는다.
  7. /root/eaimw/dedup/purge.py --db <경로> --days <N>: N 일이 지난 DONE 만 지운다. UNKNOWN·FAILED·SENT 는 기간과 무관하게 남긴다.

참고

상태 원장 스키마

/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 을 지우면 그 거래는 조사할 근거가 사라집니다.