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
- Track whether the drain already ran for this generation and skip the second call.
- Use DrainTrigger.OperatorForce when a re-invocation is intentional so the cached outcome is returned.
- Create/reset the orchestrator (new generation) if a fresh drain is genuinely required.
- 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
- Track whether a drain already ran per generation
- Use OperatorForce for intentional re-invocations
- Avoid repeated shutdown hooks invoking the drain
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
- Drain already in progress; second invocation rejected
- Outbox item does not contain a dispatch command for kind .
- Failed to insert AI conversation
- Failed to insert AI proposal
- The Elsa user was deleted while its external identity link…
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)