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

Information
Class: AsyncResponse.DurableFlowExecutor
Assembly: AsyncResponse.Core
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/AsyncResponse.Core/DurableFlowExecutor.cs
Line coverage
97%
Covered lines: 255
Uncovered lines: 7
Coverable lines: 262
Total lines: 525
Line coverage: 97.3%
Branch coverage
91%
Covered branches: 71
Total branches: 78
Branch coverage: 91%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)66.67%66100%
ExecuteAsync()92.86%141497.96%
AcquireExecutionLeaseWithRetryAsync()92.86%141486.49%
ResumeAsync()100%44100%
RecoverAsync()100%66100%
FailAsync()100%66100%
InvokeFlowAsync()50%6694.44%
InvokeFlowByReflectionAsync()75%4494.12%
ResolveFlowFromDi(...)100%11100%
ResolveType(...)100%44100%
NotifyParentAsync(...)100%44100%

File(s)

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

#LineLine coverage
 1using Microsoft.Extensions.DependencyInjection;
 2using Microsoft.Extensions.Logging;
 3using System.Diagnostics.CodeAnalysis;
 4
 5namespace AsyncResponse;
 6
 7/// <summary>
 8/// Executes durable flow runs. Its methods are the durable targets behind every flow: worker jobs
 9/// carry <see cref="ExecuteAsync"/>, and awaited steps register <see cref="RecoverAsync"/> /
 10/// <see cref="FailAsync"/> as their lost-subscriber callbacks — invoked by whichever process
 11/// receives a late response, possibly a different deployment.
 12/// <para>
 13/// <b>Naming contract:</b> like all recovery callbacks, these targets are persisted as
 14/// interface/method name strings and live in stores for up to the configured expiry. The
 15/// interface and method names must stay stable across deployments.
 16/// </para>
 17/// </summary>
 18public interface IDurableFlowExecutor
 19{
 20    /// <summary>
 21    /// Runs the flow body for <paramref name="flowId"/> from the top: completed steps skip via
 22    /// their checkpoints, the in-flight awaited step re-attaches. No-op for terminal runs.
 23    /// </summary>
 24    Task ExecuteAsync(string flowId);
 25
 26    /// <summary>
 27    /// Lost-subscriber resume target: re-enqueues <see cref="ExecuteAsync"/> on the worker
 28    /// transport (never runs the flow inline on a publisher's dispatch path).
 29    /// </summary>
 30    Task ResumeAsync(string flowId);
 31
 32    /// <summary>
 33    /// Lost-subscriber success target: checkpoints the terminal payload into the matching pending
 34    /// step before re-enqueueing execution, so recovery does not wait for a consumed correlation id.
 35    /// </summary>
 36    Task RecoverAsync(string flowId, object payload, string correlationId);
 37
 38    /// <summary>Lost-subscriber failure target: marks the run terminally <see cref="FlowRunStatus.Failed"/>.</summary>
 39    Task FailAsync(string flowId, Exception exception);
 40}
 41
 42/// <inheritdoc cref="IDurableFlowExecutor" />
 43internal sealed class DurableFlowExecutor : IDurableFlowExecutor
 44{
 45    private readonly IServiceScopeFactory _scopeFactory;
 46    private readonly IAsyncResponseBuilder _builder;
 47    private readonly IAsyncResponseSubscriber _subscriber;
 48    private readonly IRecoverableAsyncResponseSubscriber? _recoverableSubscriber;
 49    private readonly AsyncResponseContextPropagation _propagation;
 50    private readonly DurableFlowOptions _options;
 51    private readonly ILogger<DurableFlowExecutor> _logger;
 52    private readonly Dictionary<string, DurableFlowRegistration> _registrations;
 53    private readonly CancellationToken _hostStopping;
 54
 55    /// <summary>Creates the flow executor.</summary>
 356    public DurableFlowExecutor(
 357        IServiceScopeFactory scopeFactory,
 358        IAsyncResponseBuilder builder,
 359        IAsyncResponseSubscriber subscriber,
 360        IRecoverableAsyncResponseSubscriber? recoverableSubscriber,
 361        AsyncResponseContextPropagation propagation,
 362        DurableFlowOptions options,
 363        ILogger<DurableFlowExecutor> logger,
 364        IEnumerable<DurableFlowRegistration>? registrations = null,
 365        Microsoft.Extensions.Hosting.IHostApplicationLifetime? hostLifetime = null)
 66    {
 367        _scopeFactory = scopeFactory;
 368        _builder = builder;
 369        _subscriber = subscriber;
 370        _recoverableSubscriber = recoverableSubscriber;
 371        _propagation = propagation;
 372        _options = options;
 373        FlowStateConcurrency.ValidateOptions(_options);
 374        _logger = logger;
 375        _hostStopping = hostLifetime?.ApplicationStopping ?? CancellationToken.None;
 376        _registrations = new Dictionary<string, DurableFlowRegistration>(StringComparer.Ordinal);
 377        foreach (var registration in registrations ?? [])
 78        {
 79            // Last registration wins, matching DI's usual override semantics.
 180            _registrations[registration.FlowTypeFullName] = registration;
 81        }
 382    }
 83
 84    /// <inheritdoc />
 85    public async Task ExecuteAsync(string flowId)
 86    {
 387        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 88
 389        await using var scope = _scopeFactory.CreateAsyncScope();
 390        var store = scope.ServiceProvider.GetRequiredService<IFlowStateStore>();
 91
 392        await using var lease = await AcquireExecutionLeaseWithRetryAsync(store, flowId).ConfigureAwait(false);
 393        if (lease is null)
 94            return;
 95
 396        var state = await store.LoadAsync(flowId).ConfigureAwait(false);
 397        if (state is null)
 98        {
 299            _logger.LogWarning("Durable flow {FlowId} has no state (unknown, expired, or unreadable); nothing to execute
 2100            return;
 101        }
 102
 3103        if (state.Status != FlowRunStatus.Running)
 104        {
 3105            _logger.LogDebug("Durable flow {FlowId} is already {Status}; skipping execution.", flowId, state.Status);
 3106            await NotifyParentAsync(state).ConfigureAwait(false);
 3107            return;
 108        }
 109
 3110        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.flow.execute");
 3111        activity?.SetTag("asyncresponse.flow_id", flowId);
 3112        activity?.SetTag("asyncresponse.flow_type", state.FlowTypeName);
 113
 3114        state.Attempts++;
 3115        await lease.SaveAsync(state, _options.StateExpiry).ConfigureAwait(false);
 116
 117        // The run may be resumed by a different deployment than the one that started it: restore
 118        // the ambient context captured at start before any flow code runs.
 3119        using var ambientScope = _propagation.Restore(state.Context);
 120
 121        try
 122        {
 3123            var suspended = await InvokeFlowAsync(scope.ServiceProvider, store, state, lease).ConfigureAwait(false);
 3124            if (suspended)
 125            {
 126                // The context persisted the suspended state BEFORE enqueueing the child; saving here
 127                // could overwrite newer checkpoints written by a parent re-execution the child has
 128                // already triggered on another worker.
 2129                _logger.LogDebug("Durable flow {FlowId} suspended: {Message}", flowId, state.LastMessage);
 2130                return;
 131            }
 132
 3133            state.Status = FlowRunStatus.Succeeded;
 3134            state.LastMessage = "Flow completed.";
 3135            await lease.SaveAsync(state, _options.StateExpiry).ConfigureAwait(false);
 136
 3137            _logger.LogInformation("Durable flow {FlowId} completed successfully (attempt {Attempts}).", flowId, state.A
 3138        }
 2139        catch (DurableFlowSuspendedException ex)
 140        {
 141            // Same as the IsSuspended return above: the suspended state is already persisted, and a
 142            // save here races the child-triggered parent re-execution.
 2143            _logger.LogDebug("Durable flow {FlowId} suspended: {Message}", flowId, ex.Message);
 2144            return;
 145        }
 3146        catch (DurableFlowFailedException ex)
 147        {
 148            // Terminal by declaration: mark failed and swallow so the transport acks the job.
 3149            state.Status = FlowRunStatus.Failed;
 3150            state.LastMessage = ex.Message;
 3151            await lease.SaveAsync(state, _options.StateExpiry, cause: ex).ConfigureAwait(false);
 152
 3153            AsyncResponseDiagnostics.SetError(activity, ex);
 3154            _logger.LogWarning(ex, "Durable flow {FlowId} failed terminally: {Message}", flowId, ex.Message);
 3155        }
 2156        catch (Exception ex) when (lease.LostToken.IsCancellationRequested)
 157        {
 2158            AsyncResponseDiagnostics.SetError(activity, ex);
 2159            throw;
 160        }
 2161        catch (Exception ex)
 162        {
 2163            state.LastMessage = ex.Message;
 2164            await lease.SaveAsync(state, _options.StateExpiry, cause: ex).ConfigureAwait(false);
 165
 2166            AsyncResponseDiagnostics.SetError(activity, ex);
 167
 168            // Retriable: propagate so the worker transport redelivers the run with bounded
 169            // attempts and dead-letters it when they are exhausted — the "run is stuck" alarm.
 2170            throw;
 0171        }
 172
 3173        await NotifyParentAsync(state).ConfigureAwait(false);
 3174    }
 175
 176    /// <summary>
 177    /// Acquires the execution lease for <paramref name="flowId"/>, retrying while the current
 178    /// holder's lease window elapses. Returns <c>null</c> when this delivery is safe to ack without
 179    /// executing (flow terminal/absent, or the lease is held by a demonstrably live worker).
 180    /// <para>
 181    /// A held lease alone is NOT proof this delivery is a duplicate: the holder may have died
 182    /// inside its unexpired lease window, and acking would drop the only wake-up the flow has —
 183    /// wake-ups would silently become at-most-once and the <see cref="FlowRunStatus.Running"/> run
 184    /// would strand. A dead holder's lease expires within <see cref="DurableFlowOptions.ExecutionLeaseDuration"/>,
 185    /// so polling for one full duration + renew interval guarantees this delivery either takes over
 186    /// (checkpoints make the re-run idempotent) or proves the holder alive.
 187    /// </para>
 188    /// </summary>
 189    private async Task<FlowExecutionLease?> AcquireExecutionLeaseWithRetryAsync(IFlowStateStore store, string flowId)
 190    {
 3191        var lease = await FlowStateConcurrency.TryAcquireExecutionLeaseAsync(
 3192            store,
 3193            flowId,
 3194            _options,
 3195            _logger).ConfigureAwait(false);
 3196        if (lease is not null)
 3197            return lease;
 198
 199        // Poll ceiling: a lease can only stay held past its duration if the holder renewed it, so
 200        // one full duration + renew interval of failed acquires proves the holder is alive. The 2s
 201        // poll delay is capped by the renew interval so short test-sized leases still get polled.
 2202        var deadline = DateTime.UtcNow + _options.ExecutionLeaseDuration + _options.ExecutionLeaseRenewInterval;
 2203        var pollDelay = _options.ExecutionLeaseRenewInterval < TimeSpan.FromSeconds(2)
 2204            ? _options.ExecutionLeaseRenewInterval
 2205            : TimeSpan.FromSeconds(2);
 206
 207        while (true)
 208        {
 209            // Between attempts, look at the state itself: a terminal or absent flow needs no
 210            // execution, and reporting it accurately beats a misleading "already executing" log.
 2211            var state = await store.LoadAsync(flowId).ConfigureAwait(false);
 2212            if (state is null)
 213            {
 0214                _logger.LogWarning("Durable flow {FlowId} has no state (unknown, expired, or unreadable); nothing to exe
 0215                return null;
 216            }
 217
 2218            if (state.Status != FlowRunStatus.Running)
 219            {
 2220                _logger.LogDebug("Durable flow {FlowId} is already {Status}; skipping duplicate delivery.", flowId, stat
 2221                await NotifyParentAsync(state).ConfigureAwait(false);
 2222                return null;
 223            }
 224
 2225            lease = await FlowStateConcurrency.TryAcquireExecutionLeaseAsync(
 2226                store,
 2227                flowId,
 2228                _options,
 2229                _logger).ConfigureAwait(false);
 2230            if (lease is not null)
 2231                return lease;
 232
 2233            if (DateTime.UtcNow >= deadline)
 234                break;
 235
 236            try
 237            {
 2238                await Task.Delay(pollDelay, _hostStopping).ConfigureAwait(false);
 2239            }
 0240            catch (OperationCanceledException)
 241            {
 242                // Host shutdown must not leave this delivery parked in the poll — but acking it
 243                // would silently drop the flow's only wake-up. Propagate as cancellation so the
 244                // transport treats the job as not executed and redelivers it after restart.
 0245                throw new OperationCanceledException(
 0246                    $"Host is stopping; durable flow '{flowId}' wake-up is abandoned for redelivery.");
 247            }
 248        }
 249
 250        // The lease survived a full duration + renew window, so the holder is alive and renewing.
 251        // Every execution is driven by a worker job that stays unacked until its handler completes,
 252        // so if that live holder crashes later the broker redelivers its own job — this delivery is
 253        // genuinely redundant and safe to ack. The retry loop above exists purely to cover
 254        // deliveries that arrive inside a DEAD holder's unexpired lease window, which broker
 255        // redelivery alone cannot cover.
 2256        _logger.LogDebug(
 2257            "Durable flow {FlowId} is executing on another live worker (lease renewed through the full wait window); ski
 2258            flowId);
 2259        return null;
 3260    }
 261
 262    /// <inheritdoc />
 263    public async Task ResumeAsync(string flowId)
 264    {
 2265        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 266
 2267        await using var scope = _scopeFactory.CreateAsyncScope();
 2268        var store = scope.ServiceProvider.GetRequiredService<IFlowStateStore>();
 269
 2270        var state = await store.LoadAsync(flowId).ConfigureAwait(false);
 2271        if (state is null)
 272        {
 2273            _logger.LogWarning("Durable flow {FlowId} cannot resume: no state (unknown, expired, or unreadable).", flowI
 2274            return;
 275        }
 276
 2277        if (state.Status != FlowRunStatus.Running)
 278        {
 2279            _logger.LogDebug("Durable flow {FlowId} is already {Status}; ignoring resume.", flowId, state.Status);
 2280            return;
 281        }
 282
 2283        _logger.LogDebug("Durable flow {FlowId} resuming via worker transport.", flowId);
 2284        await _builder.EnqueueWorkerAsync<IDurableFlowExecutor>(executor => executor.ExecuteAsync(flowId)).ConfigureAwai
 2285    }
 286
 287    /// <inheritdoc />
 288    public async Task RecoverAsync(string flowId, object payload, string correlationId)
 289    {
 2290        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 2291        ArgumentNullException.ThrowIfNull(payload);
 2292        ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
 293
 2294        await using var scope = _scopeFactory.CreateAsyncScope();
 2295        var store = scope.ServiceProvider.GetRequiredService<IFlowStateStore>();
 2296        var checkpointed = false;
 2297        var running = false;
 2298        var lastStatus = FlowRunStatus.Running;
 299
 2300        var found = await FlowStateConcurrency.MutateAsync(
 2301            store,
 2302            flowId,
 2303            _options.StateExpiry,
 2304            state =>
 2305            {
 2306                checkpointed = false;
 2307                lastStatus = state.Status;
 2308                running = state.Status == FlowRunStatus.Running;
 2309                if (!running || state.Steps is null)
 2310                    return false;
 2311
 2312                var pending = state.Steps.FirstOrDefault(pair =>
 2313                    string.Equals(pair.Value.PendingCorrelationId, correlationId, StringComparison.Ordinal));
 2314                if (pending.Value is null)
 2315                    return false;
 2316
 2317                pending.Value.Completed = true;
 2318                pending.Value.ResultJson = AsyncResponseJson.Serialize(payload, payload.GetType());
 2319                pending.Value.PendingCorrelationId = null;
 2320                pending.Value.Faulted = false;
 2321                pending.Value.Message = "Terminal response recovered after subscriber loss.";
 2322                pending.Value.CompletedAtUtc = DateTime.UtcNow;
 2323                state.LastMessage = $"Step '{pending.Key}' recovered after subscriber loss.";
 2324                checkpointed = true;
 2325                return true;
 2326            }).ConfigureAwait(false);
 327
 2328        if (!found)
 329        {
 2330            _logger.LogWarning("Durable flow {FlowId} cannot recover response {CorrelationId}: no state found.", flowId,
 2331            return;
 332        }
 333
 2334        if (!checkpointed)
 335        {
 2336            if (!running)
 337            {
 2338                _logger.LogDebug("Durable flow {FlowId} is {Status}; ignoring recovered correlationId {CorrelationId}.",
 2339                return;
 340            }
 341
 342            // Still Running with no matching pending step: a previous delivery may have crashed
 343            // between checkpointing this response and enqueueing the run, making this redelivery
 344            // the only remaining wake-up. Re-enqueue instead of dropping — it is idempotent, and
 345            // worst case the job finds a live holder's lease and acks as a duplicate.
 2346            _logger.LogDebug("Durable flow {FlowId} has no pending step for recovered correlationId {CorrelationId}; re-
 2347            await _builder.EnqueueWorkerAsync<IDurableFlowExecutor>(executor => executor.ExecuteAsync(flowId)).Configure
 2348            return;
 349        }
 350
 2351        _logger.LogDebug("Durable flow {FlowId} checkpointed recovered correlationId {CorrelationId}; resuming.", flowId
 2352        await _builder.EnqueueWorkerAsync<IDurableFlowExecutor>(executor => executor.ExecuteAsync(flowId)).ConfigureAwai
 2353    }
 354
 355    /// <inheritdoc />
 356    public async Task FailAsync(string flowId, Exception exception)
 357    {
 2358        ArgumentException.ThrowIfNullOrWhiteSpace(flowId);
 2359        ArgumentNullException.ThrowIfNull(exception);
 360
 2361        await using var scope = _scopeFactory.CreateAsyncScope();
 2362        var store = scope.ServiceProvider.GetRequiredService<IFlowStateStore>();
 363
 2364        FlowState? updated = null;
 2365        var failedNow = false;
 2366        var found = await FlowStateConcurrency.MutateAsync(
 2367            store,
 2368            flowId,
 2369            _options.StateExpiry,
 2370            state =>
 2371            {
 2372                updated = state;
 2373                failedNow = false;
 2374                if (state.Status != FlowRunStatus.Running)
 2375                    return false;
 2376
 2377                state.Status = FlowRunStatus.Failed;
 2378                state.LastMessage = exception.Message;
 2379                failedNow = true;
 2380                return true;
 2381            }).ConfigureAwait(false);
 382
 2383        if (!found || updated is null)
 384        {
 2385            _logger.LogWarning("Durable flow {FlowId} cannot be failed: no state (unknown, expired, or unreadable).", fl
 2386            return;
 387        }
 388
 2389        if (!failedNow)
 390        {
 2391            _logger.LogDebug("Durable flow {FlowId} is already {Status}; ignoring failure signal.", flowId, updated.Stat
 2392            await NotifyParentAsync(updated).ConfigureAwait(false);
 2393            return;
 394        }
 395
 2396        await NotifyParentAsync(updated).ConfigureAwait(false);
 397
 2398        _logger.LogWarning(exception, "Durable flow {FlowId} failed via lost-subscriber routing: {Message}", flowId, exc
 2399    }
 400
 401    private async Task<bool> InvokeFlowAsync(
 402        IServiceProvider serviceProvider,
 403        IFlowStateStore store,
 404        FlowState state,
 405        FlowExecutionLease lease)
 406    {
 3407        var context = new DurableFlowContext(
 3408            state,
 3409            store,
 3410            _builder,
 3411            _propagation,
 3412            _options,
 3413            _subscriber,
 3414            _recoverableSubscriber,
 3415            _logger,
 3416            lease);
 417
 418        // Statically-typed path for flows registered via WithDurableFlow<TFlow, TInput>(): no
 419        // type-name resolution, no MakeGenericType, no MethodInfo.Invoke — the path trimmed and
 420        // Native AOT apps rely on.
 3421        if (state.FlowTypeName is not null && _registrations.TryGetValue(state.FlowTypeName, out var registration))
 422        {
 1423            var flow = ResolveFlowFromDi(serviceProvider, registration.FlowType);
 1424            var input = state.InputJson is null ? null : registration.DeserializeInput(state.InputJson);
 1425            await registration.ExecuteAsync(flow, context, input).ConfigureAwait(false);
 1426            await context.FlushProgressAsync().ConfigureAwait(false);
 1427            return context.IsSuspended;
 428        }
 429
 2430        return await InvokeFlowByReflectionAsync(serviceProvider, state, context).ConfigureAwait(false);
 3431    }
 432
 433    [UnconditionalSuppressMessage("Trimming", "IL2026",
 434        Justification = "Reflection fallback for flows not registered via WithDurableFlow<TFlow, TInput>(). In a trimmed
 435                        "unregistered flow fails closed here with an actionable error telling the operator to register i
 436                        "is silently misexecuted.")]
 437    [UnconditionalSuppressMessage("Trimming", "IL2075",
 438        Justification = "Same fallback contract: the flow type and IDurableFlow<TInput> instantiation exist whenever the
 439                        "actually defines and starts the flow; otherwise resolution fails closed with guidance.")]
 440    [UnconditionalSuppressMessage("AOT", "IL3050",
 441        Justification = "MakeGenericType over the flow's input type re-materializes an interface instantiation the user'
 442                        "class already implements statically; flows whose types were trimmed fail closed with guidance t
 443                        "WithDurableFlow<TFlow, TInput>().")]
 444    private async Task<bool> InvokeFlowByReflectionAsync(
 445        IServiceProvider serviceProvider,
 446        FlowState state,
 447        DurableFlowContext context)
 448    {
 2449        var flowType = ResolveType(state.FlowTypeName, "flow");
 2450        var inputType = ResolveType(state.InputTypeName, "input");
 2451        var input = state.InputJson is null ? null : JsonSafety.SafeDeserialize(state.InputJson, inputType);
 452
 2453        var contract = typeof(IDurableFlow<>).MakeGenericType(inputType);
 454
 2455        var flow = ResolveFlowFromDi(serviceProvider, flowType);
 2456        if (!contract.IsInstanceOfType(flow))
 457        {
 2458            throw new InvalidOperationException(
 2459                $"Durable flow type '{flowType.FullName}' does not implement IDurableFlow<{inputType.Name}> " +
 2460                "matching the persisted input type; the flow state was written by an incompatible flow definition.");
 461        }
 462
 2463        var execute = contract.GetMethod(nameof(IDurableFlow<object>.ExecuteAsync))!;
 464        try
 465        {
 2466            await ((Task)execute.Invoke(flow, [context, input])!).ConfigureAwait(false);
 2467            await context.FlushProgressAsync().ConfigureAwait(false);
 2468            return context.IsSuspended;
 469        }
 2470        catch (System.Reflection.TargetInvocationException ex) when (ex.InnerException is not null)
 471        {
 472            // A synchronously-thrown flow exception arrives wrapped; unwrap so terminal
 473            // DurableFlowFailedException handling (and user-visible stack traces) see the real one.
 2474            System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
 0475            throw;
 476        }
 2477    }
 478
 479    private static object ResolveFlowFromDi(IServiceProvider serviceProvider, Type flowType)
 480    {
 481        try
 482        {
 3483            return serviceProvider.GetRequiredService(flowType);
 484        }
 2485        catch (InvalidOperationException ex)
 486        {
 2487            throw new InvalidOperationException(
 2488                $"Durable flow type '{flowType.FullName}' is not registered in DI. Register it with " +
 2489                $"WithDurableFlow<{flowType.Name}, TInput>() (or services.AddScoped<{flowType.Name}>()) so the flow can 
 2490                "resolved on execute and resume.", ex);
 491        }
 3492    }
 493
 494    private static Type ResolveType(string? fullName, string kind)
 495    {
 2496        if (string.IsNullOrWhiteSpace(fullName))
 2497            throw new InvalidOperationException($"The persisted flow state carries no {kind} type name; it was written b
 498
 2499        return ReflectionExtensions.ResolveServiceType(fullName)
 2500            ?? throw new InvalidOperationException(
 2501                $"Cannot resolve {kind} type '{fullName}'. For plugin/collectible-assembly scenarios register a resolver
 2502                $"via {nameof(AsyncResponseTypeResolution)}.{nameof(AsyncResponseTypeResolution.RegisterAssembly)}.");
 503    }
 504
 505    private Task NotifyParentAsync(FlowState state)
 506    {
 3507        if (string.IsNullOrWhiteSpace(state.ParentFlowId))
 3508            return Task.CompletedTask;
 509
 510        // A suspended child cannot unblock its parent — the parent would only re-attach and go
 511        // back to waiting. The terminal transition after an operator un-suspends notifies then.
 2512        if (state.Status == FlowRunStatus.Suspended)
 2513            return Task.CompletedTask;
 514
 2515        var parentFlowId = state.ParentFlowId;
 2516        _logger.LogInformation(
 2517            "Durable child flow {FlowId} reached {Status}; resuming parent flow {ParentFlowId} step '{ParentStepName}'."
 2518            state.FlowId,
 2519            state.Status,
 2520            parentFlowId,
 2521            state.ParentStepName);
 522
 2523        return _builder.EnqueueWorkerAsync<IDurableFlowExecutor>(executor => executor.ExecuteAsync(parentFlowId));
 524    }
 525}