{"record":{"id":"18da60563bccab69","repo":"elsa-workflows/elsa-core","slug":"the-restored-previous-link-was-removed-after-its-user-was","errorCode":null,"errorMessage":"The restored previous link was removed after its user was deleted, but its first cleanup attempt failed.","messagePattern":"The restored previous link was removed after its user was deleted, but its first cleanup attempt failed\\.","errorType":"exception","errorClass":"InvalidOperationException","httpStatus":null,"severity":"error","filePath":"src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Stores/EFCoreExternalIdentityProvisioner.cs","lineNumber":336,"sourceCode":"                    \"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);\n                throw new InvalidOperationException(\n                    \"The restored previous link was removed after its user was deleted, but its first cleanup attempt failed.\",\n                    cleanupException);\n            }\n        }\n    }\n\n    private async ValueTask<bool> LinkExistsAsync(string linkId, CancellationToken cancellationToken)\n    {\n        await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);\n        return await dbContext.ExternalIdentityLinks.AsNoTracking().AnyAsync(x => x.Id == linkId, cancellationToken);\n    }\n\n    private async ValueTask RemoveReplacementLinksOrThrowAsync(\n        string oldLinkId,\n        string replacementLinkId,\n        Exception operationException,\n        CancellationToken cancellationToken)\n    {","sourceCodeStart":318,"sourceCodeEnd":354,"githubUrl":"https://github.com/elsa-workflows/elsa-core/blob/fe9217bdfa0e27f0e09e45006eb6898f616e513d/src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Stores/EFCoreExternalIdentityProvisioner.cs#L318-L354","documentation":"Thrown in CompensateReplacementAsync's cleanup phase. After the restored previous link is detected as removed (its user was also deleted), the code attempts a final ExecuteDeleteAsync of the old link; if that delete throws, RemoveReplacementLinksOrThrowAsync runs and this InvalidOperationException is thrown with the cleanup exception attached, ensuring no orphan link to a deleted user remains.","triggerScenarios":"The restored old link's user is deleted (link auto-removed or detectably gone) and the compensating ExecuteDeleteAsync on ExternalIdentityLinks fails — DB timeout, deadlock, connection failure, or cancellation during cleanup.","commonSituations":"Transient SQL outages during sign-in; deadlock with a concurrent cleanup job deleting the same link row; command cancellation when the HTTP request aborts mid-compensation.","solutions":["Retry the sign-in/replace operation once the database is healthy; compensation will re-run from the current link state.","Check the inner cleanupException for transient errors and configure the DbContext/retry strategy (EnableRetryOnFailure) for transient faults.","Inspect ExternalIdentityLinks for leftover old/replacement link rows and delete any pointing to deleted users.","Reduce contention between user-cleanup jobs and sign-in provisioning to avoid deadlocks on link rows."],"exampleFix":"// after\noptionsBuilder.UseSqlServer(cs, o => o.EnableRetryOnFailure()); // survive transient cleanup failures","handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"try { await provisioner.ReplaceAsync(...); }\ncatch (InvalidOperationException ex) when (ex.Message.Contains(\"first cleanup attempt failed\"))\n{\n    logger.LogError(ex.InnerException, \"Cleanup delete failed transiently; safe to retry after DB recovers\");\n    await Task.Delay(TimeSpan.FromSeconds(2), ct);\n    await provisioner.ReplaceAsync(...); // retry\n}","preventionTips":["Enable transient-fault retry (EnableRetryOnFailure) on the DbContext","Schedule cleanup jobs to avoid deadlocking with sign-in provisioning","Watch for cancellation-prone long compensation paths","Audit link rows after DB incidents for orphans pointing to deleted users"],"tags":["compensation","ef-core","transient-failure","data-consistency"],"backgroundTag":"database-write-failed","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"}