Skip to content

Add worker drain signal#3454

Open
sophiatev wants to merge 2 commits into
devfrom
stevosyan/add-worker-drain-signal
Open

Add worker drain signal#3454
sophiatev wants to merge 2 commits into
devfrom
stevosyan/add-worker-drain-signal

Conversation

@sophiatev

Copy link
Copy Markdown
Contributor

Summary

What changed?

This PR introduces a new worker drain signal in the case of Function execution - if this signal is set and the Function failed, we will retry the execution rather than permanently failing it.

Why is this change needed?

We have this signal for host shutdown but this is decoupled from worker shutdown.

Issues / work items

Project checklist

  • Documentation changes are not required
    • Otherwise: Documentation PR is ready to merge and referenced in pending_docs.md
  • Release notes are not required for the next release
    • Otherwise: Notes added to release_notes.md
  • Backport is not required
    • Otherwise: Backport tracked by issue/PR #issue_or_pr
  • All required tests have been added/updated (unit tests, E2E tests)
  • No extra work is required to be leveraged by OutOfProc SDKs
    • Otherwise: Work tracked here: #issue_or_pr_in_each_sdk
  • No change to the version of the WebJobs.Extensions.DurableTask package
    • Otherwise: Major/minor updates are reflected in /src/Worker.Extensions.DurableTask/AssemblyInfo.cs
  • No EventIds were added to EventSource logs
  • This change should be added to the v2.x branch
    • Otherwise: This change applies exclusively to WebJobs.Extensions.DurableTask v3.x and will be retained only in the dev and main branches
  • Breaking change?
    • If yes:
      • Impact:
      • Migration guidance:

AI-assisted code disclosure (required)

Was an AI tool used? (select one)

  • No
  • Yes, AI helped write parts of this PR (e.g., GitHub Copilot)
  • Yes, an AI agent generated most of this PR

If AI was used:

  • Tool(s):
  • AI-assisted areas/files:
  • What you changed after AI output:

AI verification (required if AI was used):

  • I understand the code and can explain it
  • I verified referenced APIs/types exist and are correct
  • I reviewed edge cases/failure paths (timeouts, retries, cancellation, exceptions)
  • I reviewed concurrency/async behavior
  • I checked for unintended breaking or behavior changes

Testing

Automated tests

  • Result: Passed / Failed (link logs if failed)

Manual validation (only if runtime/behavior changed)

  • Environment (OS, .NET version, components):
  • Steps + observed results:
    1.
    2.
    3.
  • Evidence (optional):

Notes for reviewers

  • N/A

Copilot AI review requested due to automatic review settings June 10, 2026 21:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a “worker draining” signal intended to prevent Durable executions from being permanently marked as failed when the underlying Functions worker is shutting down, by aborting the current dispatch so the work item can be retried on a healthy worker.

Changes:

  • Worker-side: detect draining via IHostApplicationLifetime.ApplicationStopping and either (a) throw a sentinel WorkerDrainingException for activities or (b) stamp IsWorkerDraining onto orchestrator/entity protobuf responses.
  • Host-side: update OutOfProcMiddleware to treat worker-draining activity failures (via sentinel marker) and orchestrator/entity failures (via IsWorkerDraining response flag) as retryable by throwing SessionAbortedException.
  • Tests: expand OutOfProcMiddlewareTests to cover the new draining behaviors for activity/orchestrator/entity paths.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test/FunctionsV2/Tests/Unit/OutOfProcMiddlewareTests.cs Adds unit coverage for the new worker-draining retry behaviors.
src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs Detects worker-draining conditions and forces durable retries instead of committing failures.
src/Worker.Extensions.DurableTask/Execution/DurableFunctionExecutor.cs Introduces worker-draining detection via IHostApplicationLifetime.
src/Worker.Extensions.DurableTask/Execution/DurableFunctionExecutor.Activity.cs Throws a sentinel exception after activity execution when draining.
src/Worker.Extensions.DurableTask/Execution/DurableFunctionExecutor.Orchestration.cs Stamps IsWorkerDraining into the orchestrator response when draining.
src/Worker.Extensions.DurableTask/Execution/DurableFunctionExecutor.Entity.cs Stamps IsWorkerDraining into the entity batch result when draining.
src/Worker.Extensions.DurableTask/Exceptions/WorkerDrainingException.cs Adds a sentinel exception + stable marker string for host-side detection.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 154 to 159
byte[] triggerReturnValueBytes = Convert.FromBase64String(triggerReturnValue);
P.OrchestratorResponse response = P.OrchestratorResponse.Parser.ParseFrom(triggerReturnValueBytes);

workerRequiresHistory = response.RequiresHistory;
isWorkerDraining = response.IsWorkerDraining;

Comment on lines 405 to 409
byte[] triggerReturnValueBytes = Convert.FromBase64String(triggerReturnValue);
P.EntityBatchResult response = P.EntityBatchResult.Parser.ParseFrom(triggerReturnValueBytes);
workerRequiresEntityState = response.RequiresState;
isWorkerDraining = response.IsWorkerDraining;
context.Result = response.ToEntityBatchResult(this.Options.DefaultVersion);
Comment on lines +200 to +214
[Fact]
[Trait("Category", PlatformSpecificHelpers.TestCategory)]
public async Task CallOrchestratorAsync_WorkerDrainingResponseWithFailedResult_ThrowsSessionAbortedException()
{
// Arrange: the orchestration failed AND the worker flagged the response as produced while
// draining (shutting down). The host should abort and requeue so it is retried on a healthy
// worker rather than recording the failure.
var response = new P.OrchestratorResponse
{
InstanceId = "test-instance-id",
IsWorkerDraining = true,
};

(OutOfProcMiddleware middleware, DispatchMiddlewareContext dispatchContext) =
this.SetupOrchestratorTestWithResponse(EncodeProtobuf(response), succeeded: false);
// The Function may have failed because the worker is draining, so we want to retry its execution in this case
if (isWorkerDraining)
{
throw new Exception("Worker is shutting down. The orchestration execution will be aborted and retried.");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't be throwing System.Exception as a general practice. Why not SessionAbortedException like we do elsewhere?

// The Function may have failed because the worker is draining, so we want to retry its execution in this case
if (isWorkerDraining)
{
throw new Exception("Worker is shutting down. The entity execution will be aborted and retried.");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about the type of exception we throw.

this.Options.HubName,
functionName.Name,
instance.InstanceId,
"The worker is shutting down. The activity execution will be aborted and retried.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider saying "out-of-process worker" instead of just "worker" in all of these messages to disambiguate from the durable task worker.

internal const string Sentinel = "[DurableTask:WorkerDraining]";

public WorkerDrainingException()
: base($"The worker is shutting down and will not commit this result; the work item should be retried on another worker. {Sentinel}")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: it probably makes sense to put the sentinel in the beginning of the message. That also allows us to do a more efficient StartsWith string comparison instead of a Contains.

await this.RunDirectActivityAsync(context, triggerBinding);
return;
}
else

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this else branch change for? It seems to be adding unnecessary complexity to the diff and the code overall.

private DataConverter Converter => options.Value.DataConverter;

private bool IsWorkerDraining =>
hostApplicationLifetime?.ApplicationStopping.IsCancellationRequested ?? false;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DurableFunctionsExecutor runs really late in the execution pipeline so it will only work in cases where execution actually makes it here. If I understand correctly, the person who opened the original issue observed an exception that happened somewhere else in the pipeline, suggesting that cancellation-related issues can occur before or after we get to this middleware. So, it seems that this will catch some cases but not all. A more robust approach would be to add this kind of logic directly in the .NET Isolated worker code rather than trying to put it in the Durable Functions worker extension code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ObjectDisposedException during host shutdown

3 participants