< Summary - AsyncResponse (Release / net8.0+net10.0 / unit+integration)

Information
Class: AsyncResponse.FlowStateConcurrency
Assembly: AsyncResponse.Core
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/AsyncResponse.Core/FlowStateConcurrency.cs
Line coverage
100%
Covered lines: 46
Uncovered lines: 0
Coverable lines: 46
Total lines: 272
Line coverage: 100%
Branch coverage
100%
Covered branches: 20
Total branches: 20
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
TryCreateAsync(...)100%11100%
TryAcquireExecutionLeaseAsync()100%11100%
MutateAsync()100%66100%
ValidateOptions(...)100%1414100%

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/src/AsyncResponse.Core/FlowStateConcurrency.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2
 3namespace AsyncResponse;
 4
 5/// <summary>Coordinates atomic flow creation, optimistic updates, and one active executor per flow id.</summary>
 6internal static class FlowStateConcurrency
 7{
 8    private const int MaxUpdateAttempts = 8;
 9
 10    public static Task<bool> TryCreateAsync(
 11        IFlowStateStore store,
 12        string flowId,
 13        FlowState state,
 14        TimeSpan ttl,
 15        CancellationToken cancellationToken = default)
 16    {
 317        state.Revision = 0;
 318        return store.TryCreateAsync(flowId, state, ttl, cancellationToken);
 19    }
 20
 21    public static async Task<FlowExecutionLease?> TryAcquireExecutionLeaseAsync(
 22        IFlowStateStore store,
 23        string flowId,
 24        DurableFlowOptions options,
 25        ILogger logger,
 26        CancellationToken cancellationToken = default)
 27    {
 328        ValidateOptions(options);
 29
 330        var leaseId = Guid.NewGuid().ToString("N");
 331        if (!await store.TryAcquireLeaseAsync(
 332                flowId,
 333                leaseId,
 334                options.ExecutionLeaseDuration,
 335                cancellationToken).ConfigureAwait(false))
 236            return null;
 37
 338        return new FlowExecutionLease(store, flowId, leaseId, options, logger);
 339    }
 40
 41    public static async Task<bool> MutateAsync(
 42        IFlowStateStore store,
 43        string flowId,
 44        TimeSpan ttl,
 45        Func<FlowState, bool> mutate,
 46        CancellationToken cancellationToken = default)
 47    {
 248        for (var attempt = 0; attempt < MaxUpdateAttempts; attempt++)
 49        {
 250            var state = await store.LoadAsync(flowId, cancellationToken).ConfigureAwait(false);
 251            if (state is null)
 252                return false;
 53
 254            if (!mutate(state))
 255                return true;
 56
 257            var expectedRevision = state.Revision;
 258            state.Revision = checked(expectedRevision + 1);
 259            state.UpdatedAtUtc = DateTime.UtcNow;
 260            if (await store.TryUpdateAsync(
 261                    flowId,
 262                    state,
 263                    expectedRevision,
 264                    ttl,
 265                    leaseId: null,
 266                    cancellationToken).ConfigureAwait(false))
 267                return true;
 68        }
 69
 270        throw new InvalidOperationException(
 271            $"Durable flow '{flowId}' changed repeatedly while applying a recovery update; retry the operation.");
 272    }
 73
 74    internal static void ValidateOptions(DurableFlowOptions options)
 75    {
 376        if (options.StateExpiry <= TimeSpan.Zero)
 277            throw new InvalidOperationException($"{nameof(DurableFlowOptions)}.{nameof(options.StateExpiry)} must be pos
 378        if (options.DefaultStepTimeout is { } defaultStepTimeout && defaultStepTimeout <= TimeSpan.Zero)
 279            throw new InvalidOperationException($"{nameof(DurableFlowOptions)}.{nameof(options.DefaultStepTimeout)} must
 380        if (options.ExecutionLeaseDuration <= TimeSpan.Zero)
 281            throw new InvalidOperationException($"{nameof(DurableFlowOptions)}.{nameof(options.ExecutionLeaseDuration)} 
 382        if (options.ExecutionLeaseRenewInterval <= TimeSpan.Zero
 383            || options.ExecutionLeaseRenewInterval >= options.ExecutionLeaseDuration)
 84        {
 285            throw new InvalidOperationException(
 286                $"{nameof(DurableFlowOptions)}.{nameof(options.ExecutionLeaseRenewInterval)} must be positive and shorte
 287                $"{nameof(DurableFlowOptions.ExecutionLeaseDuration)}.");
 88        }
 389        if (options.ProgressPersistenceInterval < TimeSpan.Zero)
 290            throw new InvalidOperationException($"{nameof(DurableFlowOptions)}.{nameof(options.ProgressPersistenceInterv
 391    }
 92}
 93
 94/// <summary>One distributed durable-flow execution lease.</summary>
 95internal sealed class FlowExecutionLease : IAsyncDisposable
 96{
 97    private readonly IFlowStateStore _store;
 98    private readonly string _flowId;
 99    private readonly string _leaseId;
 100    private readonly DurableFlowOptions _options;
 101    private readonly ILogger _logger;
 102    private readonly CancellationTokenSource _stop = new();
 103    private readonly CancellationTokenSource _lost = new();
 104    private readonly Task _renewal;
 105    private DateTime _validUntilUtc;
 106    private int _disposed;
 107
 108    public FlowExecutionLease(
 109        IFlowStateStore store,
 110        string flowId,
 111        string leaseId,
 112        DurableFlowOptions options,
 113        ILogger logger)
 114    {
 115        _store = store;
 116        _flowId = flowId;
 117        _leaseId = leaseId;
 118        _options = options;
 119        _logger = logger;
 120        _validUntilUtc = DateTime.UtcNow.Add(options.ExecutionLeaseDuration);
 121        _renewal = RenewLoopAsync();
 122    }
 123
 124    public CancellationToken LostToken => _lost.Token;
 125
 126    /// <summary>
 127    /// Throws when the lease is lost. <paramref name="cause"/> (e.g. the exception that made the
 128    /// caller check) is attached as the inner exception so the real failure is not discarded.
 129    /// </summary>
 130    public void ThrowIfLost(Exception? cause = null)
 131    {
 132        if (_lost.IsCancellationRequested)
 133            throw new InvalidOperationException($"Durable flow '{_flowId}' lost its execution lease; the worker will ret
 134    }
 135
 136    public async Task SaveAsync(FlowState state, TimeSpan ttl, CancellationToken cancellationToken = default, Exception?
 137    {
 138        ThrowIfLost(cause);
 139        var expectedRevision = state.Revision;
 140        state.Revision = checked(expectedRevision + 1);
 141        state.UpdatedAtUtc = DateTime.UtcNow;
 142
 143        try
 144        {
 145            if (await _store.TryUpdateAsync(
 146                    _flowId,
 147                    state,
 148                    expectedRevision,
 149                    ttl,
 150                    _leaseId,
 151                    cancellationToken).ConfigureAwait(false))
 152                return;
 153        }
 154        catch
 155        {
 156            state.Revision = expectedRevision;
 157            MarkLost();
 158
 159            // The store exception propagates; keep the failure this save was recording from
 160            // vanishing with it.
 161            if (cause is not null)
 162                _logger.LogWarning(cause, "Durable flow '{FlowId}' failed to checkpoint; the failure it was recording is
 163            throw;
 164        }
 165
 166        state.Revision = expectedRevision;
 167        MarkLost();
 168        throw await CreateSaveRejectedExceptionAsync(expectedRevision, cause, cancellationToken).ConfigureAwait(false);
 169    }
 170
 171    /// <summary>
 172    /// Builds the exception for a rejected checkpoint write. The store's compare-and-swap only
 173    /// returns <c>false</c>, so the reason is diagnosed with a best-effort re-read: a revision
 174    /// conflict — a concurrent lease-bypassing writer such as <c>RecoverAsync</c>, <c>FailAsync</c>,
 175    /// or an operator parking the run — is reported as such instead of as a lost lease, which sent
 176    /// operators hunting phantom lease problems. Behavior is unchanged either way: the lease is
 177    /// abandoned (<see cref="MarkLost"/> already ran) and the delivery retries from the last
 178    /// checkpoint; <paramref name="cause"/> rides along as the inner exception so the failure that
 179    /// triggered the save is not discarded.
 180    /// </summary>
 181    private async Task<InvalidOperationException> CreateSaveRejectedExceptionAsync(
 182        long expectedRevision,
 183        Exception? cause,
 184        CancellationToken cancellationToken)
 185    {
 186        var reason = "its execution lease was no longer held (expired or taken over)";
 187        try
 188        {
 189            var current = await _store.LoadAsync(_flowId, cancellationToken).ConfigureAwait(false);
 190            if (current is null)
 191                reason = "its ledger entry is gone (expired or deleted)";
 192            else if (current.Revision != expectedRevision)
 193                reason = $"a concurrent write advanced the ledger (revision {expectedRevision} -> {current.Revision}: a 
 194        }
 195        catch
 196        {
 197            // Best-effort diagnosis only — the rejection itself is what matters.
 198        }
 199
 200        return new InvalidOperationException(
 201            $"Durable flow '{_flowId}' could not checkpoint because {reason}; the worker abandons this execution and the
 202            cause);
 203    }
 204
 205    private async Task RenewLoopAsync()
 206    {
 207        while (!_stop.IsCancellationRequested)
 208        {
 209            try
 210            {
 211                await Task.Delay(_options.ExecutionLeaseRenewInterval, _stop.Token).ConfigureAwait(false);
 212                if (!await _store.TryRenewLeaseAsync(
 213                        _flowId,
 214                        _leaseId,
 215                        _options.ExecutionLeaseDuration,
 216                        _stop.Token).ConfigureAwait(false))
 217                {
 218                    MarkLost();
 219                    return;
 220                }
 221
 222                _validUntilUtc = DateTime.UtcNow.Add(_options.ExecutionLeaseDuration);
 223            }
 224            catch (OperationCanceledException) when (_stop.IsCancellationRequested)
 225            {
 226                return;
 227            }
 228            catch (Exception ex)
 229            {
 230                _logger.LogWarning(ex, "Failed to renew durable flow {FlowId} execution lease; retrying before expiry.",
 231                if (DateTime.UtcNow >= _validUntilUtc)
 232                {
 233                    MarkLost();
 234                    return;
 235                }
 236            }
 237        }
 238    }
 239
 240    private void MarkLost()
 241    {
 242        try
 243        {
 244            _lost.Cancel();
 245        }
 246        catch (ObjectDisposedException)
 247        {
 248            // Disposal won the race.
 249        }
 250    }
 251
 252    public async ValueTask DisposeAsync()
 253    {
 254        if (Interlocked.Exchange(ref _disposed, 1) != 0)
 255            return;
 256
 257        _stop.Cancel();
 258        await _renewal.ConfigureAwait(false);
 259
 260        try
 261        {
 262            await _store.ReleaseLeaseAsync(_flowId, _leaseId, CancellationToken.None).ConfigureAwait(false);
 263        }
 264        catch (Exception ex)
 265        {
 266            _logger.LogWarning(ex, "Failed to release durable flow {FlowId} execution lease; it will expire.", _flowId);
 267        }
 268
 269        _stop.Dispose();
 270        _lost.Dispose();
 271    }
 272}