{"record":{"id":"7fd9e36fdd7ca73c","repo":"elsa-workflows/elsa-core","slug":"the-replacement-link-was-removed-after-its-target-user-was","errorCode":null,"errorMessage":"The replacement link was removed after its target user was deleted, but the previous link could not be restored.","messagePattern":"The replacement link was removed after its target user was deleted, but the previous link could not be restored\\.","errorType":"exception","errorClass":"InvalidOperationException","httpStatus":null,"severity":"error","filePath":"src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Stores/EFCoreExternalIdentityProvisioner.cs","lineNumber":317,"sourceCode":"                UserId = oldLink.UserId,\n                CreatedAt = oldLink.CreatedAt,\n                LastSignedInAt = oldLink.LastSignedInAt\n            });\n            await dbContext.SaveChangesAsync(cancellationToken);\n            commitAttempted = true;\n            await transaction.CommitAsync(cancellationToken);\n        }\n        catch (Exception compensationException)\n        {\n            // The transaction scope has been disposed before this handler runs. A lost commit acknowledgement must\n            // not turn a successfully restored previous link into data loss.\n            var restorationCommitted = commitAttempted &&\n                                       await LinkExistsAsync(oldLink.Id, cancellationToken) &&\n                                       !await LinkExistsAsync(replacementLink.Id, cancellationToken);\n            if (!restorationCommitted)\n            {\n                await RemoveReplacementLinksOrThrowAsync(oldLink.Id, replacementLink.Id, compensationException, cancellationToken);\n                throw new InvalidOperationException(\n                    \"The replacement link was removed after its target user was deleted, but the previous link could not be restored.\",\n                    compensationException);\n            }\n        }\n\n        // An indeterminate user-directory failure must not be mistaken for a failed link restoration.\n        // Only remove the restored link when the directory positively reports that its user is gone.\n        var previousUser = new User { Id = oldLink.UserId, TenantId = oldLink.TenantId };\n        if (!await _userProvisioningService.ExistsAsync(previousUser, false, cancellationToken))\n        {\n            try\n            {\n                await using var cleanupContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);\n                await cleanupContext.ExternalIdentityLinks.Where(x => x.Id == oldLink.Id).ExecuteDeleteAsync(cancellationToken);\n            }\n            catch (Exception cleanupException)\n            {\n                await RemoveReplacementLinksOrThrowAsync(oldLink.Id, replacementLink.Id, cleanupException, cancellationToken);","sourceCodeStart":299,"sourceCodeEnd":335,"githubUrl":"https://github.com/elsa-workflows/elsa-core/blob/fe9217bdfa0e27f0e09e45006eb6898f616e513d/src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Stores/EFCoreExternalIdentityProvisioner.cs#L299-L335","documentation":"Thrown inside CompensateReplacementAsync when it cannot prove the compensation committed: either no restoration attempt was made, the old link is missing, or the replacement link is still present. RemoveReplacementLinksOrThrowAsync first removes both links so no link references a deleted user, then this InvalidOperationException is thrown with the original compensation exception attached. The database ends up with neither link rather than a link to a deleted user.","triggerScenarios":"During replacement compensation the old link is not found (restoration transaction rolled back or was concurrently deleted), or the replacement link still exists after the restore attempt, i.e. restorationCommitted evaluates false.","commonSituations":"Database failure/timeout during the restore transaction; concurrent modification of the same link rows by another sign-in request; transaction scope mismatches leaving the restore uncommitted.","solutions":["Re-run the external sign-in: provisioning will recreate the correct link for the (still-live) old user mapping.","Manually inspect ExternalIdentityLinks for both link ids and re-insert the old link if the user still exists.","Investigate the inner compensationException (timeout, deadlock, constraint) and fix the underlying DB issue.","Avoid concurrent replace operations on the same external identity (queue or lock per external subject)."],"exampleFix":"// manual repair when compensation left no link\nINSERT INTO ExternalIdentityLinks (Id, UserId, Provider, Subject, ...) VALUES ('old-link-id', 'old-user-id', ...);","handlingStrategy":"try-catch","validationCode":"bool oldOk = await LinkExistsAsync(oldLink.Id, ct);\nbool replGone = !await LinkExistsAsync(replacementLink.Id, ct);\nif (!oldOk || !replGone) throw new InvalidOperationException(\"Compensation incomplete before retry\");","typeGuard":null,"tryCatchPattern":"try { await provisioner.ReplaceAsync(...); }\ncatch (InvalidOperationException ex) when (ex.Message.Contains(\"previous link could not be restored\"))\n{\n    logger.LogError(ex.InnerException, \"Compensation failed; repair ExternalIdentityLinks manually\");\n    // inspect both link ids and re-insert/repair before retrying\n}","preventionTips":["Monitor the inner compensationException to catch DB-level root causes","Avoid concurrent replaces on the same external identity","Use transactions that guarantee the restore commits or rolls back fully","Alert on this error — it means the DB is left with neither link"],"tags":["compensation","race-condition","ef-core","data-consistency"],"backgroundTag":"internal-invariant-violation","analyzedSha":"fe9217bdfa0e27f0e09e45006eb6898f616e513d","analyzedAt":"2026-09-13T20:32:34.702Z","contentChangedAt":"2026-09-13T20:32:34.702Z","schemaVersion":2},"datasetVersion":"2026-09-15T23:17:13.987Z"}