오입금 반환 전송을 100건씩 나눠 돌렸다. 웨이브마다 몇 건씩 실패해서 그때마다 노드 응답 로그를 조회해 사유를 분류했다.
Table of contents
Open Table of contents
결과 — 다섯으로 모인 사유
몇 웨이브를 돌리니 사유가 모였다.
반환 주소 무효 (-5) 가장 빈번
핫월렛 잔액 부족 (-6)
더스트 금액 (-4, amount too small)
mempool 거절 (-4, rejected)
무음 스킵
무음 스킵 까지 다섯 종이 반복해서 나오고 새 사유가 더 나오지 않았다.
오류 코드는 sendtoaddress 가 돌려주는 JSON-RPC 오류의 code 다. 이 코드 표는 공식 문서의 RPC 항목이 아니라 Bitcoin Core 0.15 소스의 rpc/protocol.h 에 있고, 더스트와 mempool 거절이 둘 다 -4 로 오는 것도 거기서 확인했다. 코드만으로는 둘이 안 갈리니 message 까지 봐야 했다.
const rpc = require('./rpc');
try {
row.txid = await rpc.call('sendtoaddress', [row.refund_address, row.amount]);
} catch (e) {
if (e.code === -5) reason = 'invalid_address';
else if (e.code === -6) reason = 'insufficient_funds';
else if (e.code === -4 && /too small/.test(e.message)) reason = 'dust';
else if (e.code === -4 && /rejected/.test(e.message)) reason = 'mempool_rejected';
}
새로운 것이 안 나온다는 것 자체가 정보였다. 남은 웨이브에서도 이 다섯 안에서 실패할 것이라고 볼 수 있게 된다.
비율이 안정적이었다
실패율도 세어 봤다.
100건 웨이브 기준 두세 건
100건 짜리 웨이브마다 비슷하게 나왔다.
[비율을 모름] 얼마나 걸릴지 모름
[웨이브당 두세 건] 그만큼의 판정·원복
비율이 정해지면 남은 작업량을 미리 계산할 수 있다.
100건 웨이브를 열 번 돌리면 스무 건에서 서른 건의 후속 작업이 나온다는 뜻이다. 그만큼의 시간을 일정에 미리 넣어 둘 수 있게 됐다.
조치 — 사유별 사전 게이트
사유가 다섯으로 정해지니 미리 거를 수 있는 것이 보였다.
[전송 후 실패] 실패 처리 → 원복 → 재작업
[사전 게이트] 대상에서 미리 제외
각 사유를 전송 전에 확인 가능한 조건으로 바꾸면 된다.
반환 주소 무효 → validateaddress 로 사전 확인
더스트 금액 → 최소 반환 금액 미만은 대상에서 제외
핫월렛 잔액 부족 → 웨이브 합계와 getbalance 를 비교한 뒤 시작
반환 주소 무효 부터 핫월렛 잔액 부족 까지 셋은 우리 쪽 자료와 노드 조회만으로 판정할 수 있었다. mempool 거절 은 보내 봐야 아는 종류라 게이트에 못 넣었다.
전송 후 실패 처리 대신 사전 게이트 쪽을 골랐다. 이유는 실패 한 건마다 원복과 재작업이 따라오는데 게이트는 조회 한 번이라서다.
미리 거르면 보내는 건수가 줄고 노드 부담도 준다. 다만 그 조건이 틀리면 보낼 수 있는 건까지 빠지므로 거른 건수를 따로 세어 실패했을 수와 맞는지 대조했다.
다섯째가 달랐다
무음 스킵만 성격이 달랐다.
워커는 웨이브를 정상 종료
실패 목록도 비어 있음
그런데 txid 가 안 생김
txid 없이 정상 종료로 끝난다.
[다른 넷] sendtoaddress 가 오류를 돌려줌 → 실패 로그가 남음
[무음 스킵] sendtoaddress 를 부르지 않음 → 로그가 없음
워커가 같은 반환 주소로 이미 보낸 건이 있으면 중복으로 보고 조용히 건너뛰고 있었다. 한 사용자가 오입금을 두 번 한 경우가 그 조건에 걸렸다.
fail_reason 을 채우는 조회로는 이 종류가 잡히지 않는다.
앞서 정리한 것과 같은 모양이다. 오류는 목록에 남고 미발생은 아무 데도 안 남는다.
검증 — 건수 대조
없는 것을 찾으려면 다른 방법이 필요했다.
전송한 대상 목록
vs
txid 가 생긴 목록
보낸 COUNT(*) 와 txid IS NOT NULL 인 COUNT(*) 를 맞춰 보면 그 차이가 무음 스킵 이다.
[로그 기반] 실패한 것을 찾음
[건수 대조] 없어진 것을 찾음
두 방법이 서로 다른 것을 잡으므로 로그만 보면 다섯 중 넷만 보인다.
분류한 사유는 반환 표에 컬럼을 두고 웨이브마다 기록했다.
UPDATE misdeposit_refunds SET fail_reason = ? WHERE id = ?;
fail_reason 을 쌓아 두니 분포를 볼 수 있게 됐다.
SELECT fail_reason, COUNT(*) FROM misdeposit_refunds
WHERE fail_reason IS NOT NULL GROUP BY 1;
기록하지 않고 웨이브마다 로그만 봤으면 사유가 다섯인지 열인지 몰랐을 것이다. 한 웨이브의 실패는 흩어져 보이고 열 웨이브의 실패는 분포를 만든다.
대응 — 재개 지점을 조건으로
여러 웨이브를 돌리니 어디부터 다시 할지가 필요해졌다.
SELECT r.id FROM misdeposit_refunds r
JOIN deposits d ON d.id = r.deposit_id
WHERE r.txid IS NULL
AND r.reverted_at IS NULL
AND r.fail_reason IS NULL
AND d.status = 'misdeposit'
ORDER BY r.id
LIMIT 100;
txid 와 reverted_at 과 fail_reason 과 status 네 조건이 남은 대상을 고른다.
[진행 번호로] 어디까지 했는지 기억해야 함
[조건으로] 테이블이 상태를 갖고 있음
조건이 상태를 표현하니 중간에 멈춰도 같은 쿼리로 이어지고 fail_reason 이 채워진 건은 자동으로 빠진다.
ORDER BY 를 넣은 것은 실행마다 순서가 달라지지 않게 하려는 것이었다. 순서가 고정되면 같은 대상이 같은 묶음에 들어가고 문제가 생겼을 때 어디까지 갔는지가 확실해진다.
실패건을 전량 원복할 수 있다는 것이 이 방식의 전제였다. 다섯 사유 전부 브로드캐스트 전에 멈추는 것이라 우리 쪽 상태만 되돌리면 됐고, 체인에 나간 뒤에 되돌릴 일이 없었기에 웨이브를 계속 돌릴 수 있었다.
정리
- 반복 실행하며 분류하니 실패 사유가 다섯으로 수렴했다
- 새 사유가 안 나온다는 것 자체가 정보다
- 비율이 안정적이면 후속 작업을 일정에 미리 넣을 수 있다
- 사유가 정해지면 사전 게이트로 미리 거를 수 있다
- 거른 건수를 실패했을 수와 대조해 그 조건을 검증한다
- 한 종류는 노드를 부르지 않아 로그에 안 남는다
- 보낸 건수와 txid 가 생긴 건수를 대조해야 잡힌다
- 로그 기반과 건수 대조가 서로 다른 것을 잡는다
- 사유를 누적 기록해야 수렴이 보인다
- 재개 지점을 조건으로 정의하면 중간에 멈춰도 이어진다
- 원복이 되는지가 얼마나 빠르게 진행할지를 정한다