Skip to content

Merge Idempotency & Concurrency

MergeService 는 logi 의 모든 통합 경로 (T1/T2/T3) 가 공통으로 거치는 단일 entry point 입니다. 같은 작업이 동시에 또는 재시도로 두 번 들어와도 정확히 한 번만 효과가 적용되도록 설계되었습니다.

핵심 invariant

  1. identity_links 는 chain 도 cycle 도 없는 forest 구조 — DB 트리거가 강제.
  2. idempotency_key 가 UNIQUE — 같은 logical merge 의 재시도는 :already_processed 를 반환.
  3. lock-all-roots before INSERT[survivor_canonical_id, merged_canonical_id] 를 ORDER BY id ASC 로 모두 SELECT ... FOR UPDATE. 양쪽이 동시에 다른 사람을 흡수하려 할 때 deadlock 없이 직렬화.
  4. outbox INSERT 는 같은 transaction 안에 — 통합이 RP 에게 보이려는 순간 DB 상의 identity 변경과 atomic.

idempotency_key 계약

호출자(T1/T2/T3 wiring) 는 다음 식으로 key 를 만듭니다:

T1 device-link:   "t1:#{device_uuid}:#{linked_user_id}"
T2 email-match:   "t2:#{normalized_email}:#{linked_user_id}"
T3 OTP-merge:     "t3:#{otp_token_id}"

같은 device_uuid 와 같은 흡수 대상으로 두 번 시도하면 같은 key 가 만들어지고, MergeService 는 두 번째 호출에서 :already_processed 를 반환. 결과는 첫 번째 호출과 동일한 identity_link row 입니다.

webhook 의 per-RP idempotency 는 한 단계 더 들어갑니다:

per_rp_key = "#{idempotency_key}:rp:#{oauth_application_id}"

이 값이 webhook_outbox(oauth_application_id, event_id) 의 unique constraint 와 결합해 dispatcher 재시도 시에도 RP 는 중복 webhook 을 받지 않습니다 (RP 는 추가로 X-Logi-Event-Id dedupe 권장).

동시성 시나리오

Race 1 — 같은 사용자가 양쪽 기기에서 동시에 T3 요청

Device A: POST /me/merge with OTP for user B  (t=0ms)
Device A: POST /me/merge with OTP for user B  (t=1ms, retry due to network)

두 요청 모두 같은 OTP token id 로 idempotency_key 를 빌드. 첫 요청이 transaction 시작 → merge_otp_tokens.lock! 으로 row lock → consume → MergeService 실행 → identity_links INSERT (UNIQUE idempotency_key). 두 번째 요청은 lock 대기 후 OTP token 이 이미 consumed 임을 보고 otp_already_used 반환.

설사 첫 요청보다 두 번째 요청의 transaction 이 먼저 commit 될 만한 경로가 있었더라도 (예: 첫 요청이 outbox INSERT 직전 crash), 두 번째 요청의 identity_links(idempotency_key) UNIQUE 가 그것을 잡습니다 → :already_processed.

Race 2 — A→B 와 B→C 가 거의 동시에

T=0:  Service 1 wants to merge A into B  (lock A, B in id order)
T=1:  Service 2 wants to merge B into C  (lock B, C in id order)

둘 다 B 에 lock 을 잡으려 하므로 후행자는 대기. 첫 transaction 이 commit 되면 후행 transaction 이 깨어나서 다시 canonical 을 resolve:

  • 첫 commit 후 B 는 linked_user_id 인 상태 → B 의 canonical 은 A 이전이거나 (이미 B 가 흡수했으면) B 자신.
  • 두 번째 transaction 은 MergeService 가 다시 canonical_id_for(B) 호출 → A 로 해석.
  • 결과적으로 두 번째 시도는 "A 를 C 로 흡수" 로 재해석되거나, A 가 이미 canonical 이라면 그대로 진행.

이렇게 자동 직렬화되지만 체인은 절대 만들어지지 않습니다 — DB 트리거가 NEW.primary_user_id 가 이미 누군가의 linked_user_id 인 경우를 거부합니다.

Race 3 — Cycle 시도

T=0:  identity_links 에 A→B 이미 존재
T=1:  Service tries to insert B→A

DB BEFORE INSERT 트리거 identity_links_no_cycle_trg 가 chain walk 로 B 의 hop 을 따라가다 A 에 도달 → RAISE EXCEPTION 'identity_links cycle detected' → transaction rollback. MergeService 는 app-side 에서 미리 guard_cycle! 로 같은 검사를 해서 깨끗한 예외 (CycleAttempted) 를 raise.

DB 와 app 양쪽에 cycle 방어를 둔 이유는 defense-in-depth — app 쪽 미리 검사가 race 때문에 stale 일 수 있어도 DB 가 마지막 안전망.

Lock 순서가 왜 중요한가

서비스가 [survivor, merged] 를 임의 순서로 lock 하면 두 동시 merge 가 deadlock 에 빠질 수 있습니다:

Service 1:  lock A → lock B
Service 2:  lock B → lock A   (deadlock!)

해결: 항상 user.id 오름차순 으로 lock. logi 의 MergeService.lock_canonical_pair! 는 이를 강제합니다. PG 가 deadlock 을 감지하면 한쪽을 abort 하고 caller 는 retryable error (merge_contention) 를 받습니다 — SolidQueue 가 자동 재시도.

재시도 가능 vs 비가능 에러

에러재시도?의미
merge_contentionyesPG deadlock detected. backoff 후 재시도.
:already_processed사실상 성공같은 idempotency_key 가 이미 처리됨. 결과 동일.
:merge_cycleno의도된 거부. caller 가 입력을 잘못 줌.
:user_in_purgeno흡수 대상이 grace 중인 hard-delete 큐에 있음. 운영자 개입 필요.

RP 가 신경 써야 할 부분

RP 는 MergeService 의 내부 동시성에 직접 노출되지 않습니다. RP 가 알아야 할 것은:

  • 같은 event_id 의 webhook 을 두 번 받을 수 있음 → dedupe 필수.
  • polling 과 webhook 모두 같은 이벤트를 fetch 할 수 있음 → event_id 로 cross-tier dedupe.
  • 자기 DB 의 logi_identity_links 도 동일한 UNIQUE(linked_user_id) 제약을 두는 것을 권장.

관련 문서

MIT License · Identity가 제품의 신뢰를 만듭니다.