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
identity_linksis a forest structure with no chains and no cycles — enforced by a DB trigger.idempotency_keyis UNIQUE — a retry of the same logical merge returns:already_processed.- lock-all-roots before INSERT —
SELECT ... FOR UPDATEall 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. - 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_idstate → B's canonical is what came before A or (if B already absorbed) B itself. - The second transaction has
MergeServicecallcanonical_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→AThe 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
| Error | Retry? | Meaning |
|---|---|---|
merge_contention | yes | PG deadlock detected. Retry after backoff. |
:already_processed | effectively success | The same idempotency_key was already processed. The result is identical. |
:merge_cycle | no | An intended rejection. The caller passed bad input. |
:user_in_purge | no | The 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_idtwice → 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.