elsa-workflows/elsa-core · warning · InvalidOperationException

Drain already completed in this generation; subsequent…

Error message

Drain already completed in this generation; subsequent non-force invocations are rejected.

What it means

DrainOrchestrator caches the outcome of a completed drain per generation. Any subsequent DrainAsync call without DrainTrigger.OperatorForce after a drain already completed is rejected with InvalidOperationException. Operator-force invocations get the cached outcome (WasCached = true) instead.

Solutions

  1. Track whether the drain already ran for this generation and skip the second call.
  2. Use DrainTrigger.OperatorForce when a re-invocation is intentional so the cached outcome is returned.
  3. Create/reset the orchestrator (new generation) if a fresh drain is genuinely required.
  4. Catch InvalidOperationException and read the previous outcome instead of failing.

Example fix

// before
// called on every shutdown hook
await drainOrchestrator.DrainAsync(DrainTrigger.Shutdown, ct);

// after
if (!_drainCompleted)
{
    var outcome = await drainOrchestrator.DrainAsync(DrainTrigger.Shutdown, ct);
    _drainCompleted = true;
}
else
{
    var outcome = await drainOrchestrator.DrainAsync(DrainTrigger.OperatorForce, ct); // cached
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Track locally: if a drain already completed this generation, use OperatorForce or skip.

Try / catch

try
{
    await drainOrchestrator.DrainAsync(DrainTrigger.Shutdown, ct);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("Drain already completed"))
{
    var cached = await drainOrchestrator.DrainAsync(DrainTrigger.OperatorForce, ct);
}

Prevention

When it happens

Trigger: Calling DrainAsync a second time after the first drain finished, without DrainTrigger.OperatorForce — e.g. repeated shutdown hooks or periodic jobs firing after the generation's drain already ran.

Common situations: Application shutdown handlers registered twice; a retry policy re-running a drain that already succeeded; tests invoking DrainAsync multiple times against a shared orchestrator instance without resetting generation.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of elsa-workflows/elsa-core@fe9217bdfa (2026-09-13). Data as JSON: /api/errors/6c59523623e4df48. Report an issue: GitHub.

Appendix: source

Thrown at src/modules/Elsa.Workflows.Runtime/Services/DrainOrchestrator.cs:91

        _identityGenerator = identityGenerator;
        _logger = logger;
    }

    /// <inheritdoc />
    public async ValueTask<DrainOutcome> DrainAsync(DrainTrigger trigger, CancellationToken cancellationToken = default)
    {
        lock (_sync)
        {
            if (_drainInProgress)
            {
                if (trigger == DrainTrigger.OperatorForce && _previousOutcome is not null)
                    return _previousOutcome with { WasCached = true };
                throw new InvalidOperationException($"Drain already in progress; second invocation rejected (trigger={trigger}).");
            }
            if (_previousOutcome is not null)
            {
                if (trigger == DrainTrigger.OperatorForce) return _previousOutcome with { WasCached = true };
                throw new InvalidOperationException("Drain already completed in this generation; subsequent non-force invocations are rejected.");
            }
            _drainInProgress = true;
        }

        var startedAt = _clock.UtcNow;
        var deadline = ComputeEffectiveDeadline(trigger);
        var sw = Stopwatch.StartNew();
        TimeSpan pausePhase = TimeSpan.Zero;
        TimeSpan waitPhase = TimeSpan.Zero;

        try
        {
            await _signal.BeginDrainAsync(cancellationToken);
            _logger.LogInformation("Drain initiated (trigger={Trigger}, deadline={Deadline}).", trigger, deadline);

            var deadlineAt = startedAt + deadline;

            // Phase 1: parallel pause. Each source has its own timeout independent of the overall deadline,

View on GitHub (pinned to fe9217bdfa)