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 INSERT — SELECT ... 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가 제품의 신뢰를 만듭니다.