Skip to content

Merge Idempotency & Concurrency

MergeService is the single entry point that all of logi's merge paths (T1/T2/T3) use. It is designed so that even when the same operation comes in twice — concurrently or via retry — the effect is applied exactly once.

Core invariants

  1. identity_links is a forest structure with no chains and no cycles — enforced by a DB trigger.
  2. idempotency_key is UNIQUE — a retry of the same logical merge returns :already_processed.
  3. lock-all-roots before INSERTSELECT ... FOR UPDATE all of [survivor_canonical_id, merged_canonical_id] in ORDER BY id ASC. When both sides try to absorb someone else at once, this serializes them without a deadlock.
  4. The outbox INSERT is inside the same transaction — at the moment the merge becomes visible to the RP, it is atomic with the identity change in the DB.

The idempotency_key contract

The caller (the T1/T2/T3 wiring) builds the key with the following formulas:

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}"

If you try twice with the same device_uuid and the same absorption target, the same key is produced, and MergeService returns :already_processed on the second call. The result is the same identity_link row as the first call.

The webhook's per-RP idempotency goes one step further:

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

This value, combined with the unique constraint of webhook_outbox(oauth_application_id, event_id), ensures the RP does not receive a duplicate webhook even on a dispatcher retry (the RP is additionally recommended to dedupe by X-Logi-Event-Id).

Concurrency scenarios

Race 1 — the same user requests T3 from both devices at once

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)

Both requests build the idempotency_key from the same OTP token id. The first request starts a transaction → row-locks via merge_otp_tokens.lock! → consumes → runs MergeService → inserts into identity_links (UNIQUE idempotency_key). The second request waits for the lock, then sees that the OTP token is already consumed and returns otp_already_used.

Even if there were a path for the second request's transaction to commit before the first (for example, if the first crashed just before the outbox INSERT), the second request's identity_links(idempotency_key) UNIQUE catches it → :already_processed.

Race 2 — A→B and B→C almost simultaneously

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)

Both try to lock B, so the latecomer waits. Once the first transaction commits, the later transaction wakes up and resolves the canonical again:

  • After the first commit, B is in a linked_user_id state → B's canonical is what came before A or (if B already absorbed) B itself.
  • The second transaction has MergeService call canonical_id_for(B) again → resolves to A.
  • As a result, the second attempt is reinterpreted as "absorb A into C", or, if A is already canonical, proceeds as-is.

It is auto-serialized this way, but a chain is never created — the DB trigger rejects the case where NEW.primary_user_id is already someone's linked_user_id.

Race 3 — a cycle attempt

T=0:  A→B already exists in identity_links
T=1:  Service tries to insert B→A

The DB BEFORE INSERT trigger identity_links_no_cycle_trg walks B's hops in a chain walk, reaches A → RAISE EXCEPTION 'identity_links cycle detected' → transaction rollback. MergeService runs the same check app-side beforehand via guard_cycle!, raising a clean exception (CycleAttempted).

The reason cycle defense exists on both the DB and the app is defense-in-depth — the app-side pre-check may be stale due to a race, but the DB is the last safety net.

Why lock ordering matters

If the service locks [survivor, merged] in an arbitrary order, two concurrent merges can deadlock:

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

The fix: always lock in ascending user.id order. logi's MergeService.lock_canonical_pair! enforces this. When PG detects a deadlock, it aborts one side and the caller receives a retryable error (merge_contention) — SolidQueue retries automatically.

Retryable vs non-retryable errors

ErrorRetry?Meaning
merge_contentionyesPG deadlock detected. Retry after backoff.
:already_processedeffectively successThe same idempotency_key was already processed. The result is identical.
:merge_cyclenoAn intended rejection. The caller passed bad input.
:user_in_purgenoThe absorption target is in the hard-delete queue during the grace period. Operator intervention needed.

What the RP needs to care about

The RP is not directly exposed to MergeService's internal concurrency. What the RP needs to know:

  • It may receive a webhook for the same event_id twice → deduping is mandatory.
  • Both polling and webhook can fetch the same event → cross-tier dedupe by event_id.
  • We recommend that the RP's own DB also place the same UNIQUE(linked_user_id) constraint on logi_identity_links.

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