Skip to content

[durable-functions v4] gRPC channel created per invocation in client-starter handlers (no reuse / stop) #320

Description

@YunchuWang

Follow-up from PR #282 (automated-reviewer thread, robustness/resource)

In the new durable-functions v4 provider, the app.client.* starter helpers wrap the user handler with convertToFunctionHandler (packages/azure-functions-durable/src/app-client.ts:260-264):

function convertToFunctionHandler(clientHandler) {
  return (triggerInput, context) => {
    const client = getClient(context);            // <-- per invocation
    return clientHandler(triggerInput, client, context);
  };
}

getClient (src/get-client.ts) constructs a new DurableFunctionsClient on every call:

return new DurableFunctionsClient(asClientInput(bindingData));

DurableFunctionsClient extends TaskHubGrpcClient, and the core TaskHubGrpcClient eagerly opens a gRPC channel in its constructor (packages/durabletask-js/src/client/client.ts:146):

this._stub = new GrpcClient(resolvedHostAddress, resolvedOptions, resolvedUseTLS, resolvedCredentials).stub;

It also exposes stop() which closes the channel (client.ts:152-153), but the compat provider never calls it and does not cache/reuse the client.

Impact

Every client-starter invocation creates a new @grpc/grpc-js channel that is never closed. In a long-lived Functions worker process under sustained load this accumulates open channels / sockets and their keep-alive machinery — a slow resource leak. The v3 provider reused a single client via the durable-client input binding, so this is a regression in resource behavior.

Options

  1. Cache the DurableFunctionsClient keyed by the resolved client-binding config (most bindings resolve to the same host/task-hub within a worker), reusing the channel across invocations.
  2. Construct the client once per registered function (at registration time) rather than per invocation, if the binding value is stable.
  3. If per-invocation construction must stay, stop() the client when the invocation completes (adds teardown cost per call; least preferred).

Caveats to design around: binding data can in principle differ per invocation; a cache needs a bounded size / eviction and must stop() evicted channels; and gRPC channel sharing must stay compatible with per-call metadata (task hub / auth token) which is already generated per call via metadataGenerator.

Acceptance

  • Client-starter invocations reuse a gRPC channel instead of opening one per call.
  • No unbounded growth of open channels in a long-running worker (add a focused test/benchmark or a channel-count assertion if feasible).

Links

Follow-up to an automated-reviewer thread on PR #282.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions