< Summary

Information
Class: Elsa.UserTasks.Services.DefaultUserTaskReconciler
Assembly: Elsa.UserTasks
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Services/DefaultUserTaskReconciler.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 97
Coverable lines: 97
Total lines: 163
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 62
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
ReconcileAsync()0%1980440%
RecreateMissingProjectionsAsync()0%156120%
MaterializationKey(...)100%210%
Deserialize(...)0%4260%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Services/DefaultUserTaskReconciler.cs

#LineLine coverage
 1using System.Text.Json;
 2using Elsa.Common;
 3using Elsa.Common.Models;
 4using Elsa.UserTasks.Contracts;
 5using Elsa.UserTasks.Models;
 6using Elsa.Workflows.Models;
 7using Elsa.Workflows.Runtime;
 8using Elsa.Workflows.Runtime.Entities;
 9using Elsa.Workflows.Runtime.Filters;
 10
 11namespace Elsa.UserTasks.Services;
 12
 13/// <summary>
 14/// Performs a bounded, tenant-scoped repair pass. The workflow bookmark is the source of truth: when a
 15/// bookmark exists, accepted transitions are retried; when it is gone, the projection is finalized after
 16/// commit. The bookmark store is optional so the service remains usable with the Core in-memory stack.
 17/// </summary>
 018public sealed class DefaultUserTaskReconciler(
 019    IUserTaskRepository repository,
 020    IUserTaskManager manager,
 021    IUserTaskProjectionService projectionService,
 022    IUserTaskWorkflowResumer workflowResumer,
 023    ISystemClock clock,
 024    IBookmarkStore? bookmarkStore = null) : IUserTaskReconciler
 25{
 26    public async Task<UserTaskReconciliationResult> ReconcileAsync(UserTaskReconciliationRequest request, CancellationTo
 27    {
 028        var olderThan = request.OlderThan ?? clock.UtcNow.Subtract(TimeSpan.FromMinutes(5));
 029        var pageSize = Math.Clamp(request.PageSize, 1, 200);
 030        var recreated = bookmarkStore == null ? 0 : await RecreateMissingProjectionsAsync(request.TenantId, pageSize, ca
 031        var cursor = (string?)null;
 032        var requeued = 0;
 033        var finalized = 0;
 034        var ambiguous = 0;
 35
 36        do
 37        {
 038            var result = await repository.QueryAsync(new UserTaskQuery
 039            {
 040                TenantId = request.TenantId,
 041                Cursor = cursor,
 042                Sort = "created",
 043                Limit = pageSize
 044            }, cancellationToken);
 045            foreach (var task in result.Items.Where(x => x.UpdatedAt <= olderThan))
 46            {
 047                var bookmark = bookmarkStore == null
 048                    ? null
 049                    : await bookmarkStore.FindAsync(new BookmarkFilter
 050                    {
 051                        BookmarkId = task.BookmarkId,
 052                        WorkflowInstanceId = task.WorkflowInstanceId
 053                    }, cancellationToken);
 54
 055                if (bookmarkStore != null && bookmark == null && task.IsOpen)
 56                {
 057                    var before = task.Status;
 058                    await projectionService.FinalizeBookmarkRemovalAsync(new UserTaskBookmarkRemoval(task.TenantId, task
 059                    var after = await repository.GetAsync(task.TenantId, task.Id, cancellationToken);
 060                    if (after?.Status != before)
 061                        finalized++;
 062                    continue;
 63                }
 64
 065                if (task.Status is not (UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelli
 66                    continue;
 67
 068                var operation = task.Operations.LastOrDefault(x => x.Status == UserTaskOperationStatus.Accepted && x.Kin
 069                if (operation == null)
 70                {
 071                    ambiguous++;
 072                    continue;
 73                }
 74
 075                if (bookmarkStore != null && bookmark == null)
 76                {
 077                    var before = task.Status;
 078                    await projectionService.FinalizeBookmarkRemovalAsync(new UserTaskBookmarkRemoval(task.TenantId, task
 079                    var after = await repository.GetAsync(task.TenantId, task.Id, cancellationToken);
 080                    if (after?.Status != before)
 081                        finalized++;
 082                    continue;
 83                }
 84
 85                try
 86                {
 087                    var action = task.Status == UserTaskStatus.TimingOut ? "Timeout" : task.Status == UserTaskStatus.Can
 088                    await workflowResumer.ResumeAsync(task, new UserTaskStimulus(task.TenantId, task.Id, operation.Opera
 089                        action, task.CompletionData, task.CompletedBy, task.CompletedAt ?? operation.CreatedAt, task.Boo
 090                    requeued++;
 091                }
 092                catch (Exception exception) when (exception is not OperationCanceledException)
 93                {
 94                    // Keep the accepted operation durable for the next bounded pass.
 095                    await repository.TryMutateAsync(task.TenantId, task.Id, task.Revision, current =>
 096                    {
 097                        current.HealthSeverity = UserTaskHealthSeverity.Advisory;
 098                        current.HealthCode = "stale-transition";
 099                        current.HealthMessage = "A workflow outcome requires reconciliation.";
 0100                        return true;
 0101                    }, cancellationToken);
 102                }
 0103            }
 104
 0105            cursor = result.NextCursor;
 0106        }
 0107        while (cursor != null);
 108
 0109        return new UserTaskReconciliationResult(recreated, requeued, finalized, ambiguous);
 0110    }
 111
 112    private async Task<int> RecreateMissingProjectionsAsync(string tenantId, int pageSize, CancellationToken cancellatio
 113    {
 0114        var recreated = 0;
 0115        var page = 0;
 0116        while (true)
 117        {
 0118            var bookmarks = (await bookmarkStore!.FindManyAsync(new BookmarkFilter { Name = nameof(Elsa.UserTasks.Activi
 0119                PageArgs.FromPage(page, pageSize), cancellationToken)).Items.ToArray();
 0120            if (bookmarks.Length == 0)
 121                break;
 122
 123            // OfType drops undeserializable bookmarks and narrows the sequence in one step, so the tenant
 124            // filter reads explicitly and no null-forgiving operator is needed downstream.
 0125            foreach (var materialization in bookmarks
 0126                         .Select(Deserialize)
 0127                         .OfType<UserTaskMaterialization>()
 0128                         .Where(x => string.Equals(x.TenantId, tenantId, StringComparison.Ordinal)))
 129            {
 130                // Stays a guard rather than a filter: it awaits the repository, which LINQ cannot express here.
 0131                if (await repository.FindByMaterializationKeyAsync(tenantId, MaterializationKey(materialization), cancel
 132                    continue;
 0133                await manager.ProjectAsync(materialization, cancellationToken);
 0134                recreated++;
 0135            }
 136
 0137            if (bookmarks.Length < pageSize)
 138                break;
 0139            page++;
 0140        }
 0141        return recreated;
 0142    }
 143
 0144    private static string MaterializationKey(UserTaskMaterialization materialization) => string.Join("/", materializatio
 145
 146    private static UserTaskMaterialization? Deserialize(StoredBookmark bookmark)
 147    {
 148        try
 149        {
 0150            return bookmark.Payload switch
 0151            {
 0152                UserTaskMaterialization materialization => materialization,
 0153                JsonElement element => element.Deserialize<UserTaskMaterialization>(),
 0154                null => null,
 0155                _ => JsonSerializer.Deserialize<UserTaskMaterialization>(JsonSerializer.Serialize(bookmark.Payload))
 0156            };
 157        }
 0158        catch (JsonException)
 159        {
 0160            return null;
 161        }
 0162    }
 163}