Skip to content

WorkerDeployment deletion finalizer infinite-loops on transport-level Temporal SDK errors (cached client never evicted) #328

Description

@rupesh-parab-one-app

Summary

When a WorkerDeployment is deleted, handleDeletion (internal/controller/worker_controller.go) reuses a cached Temporal SDK client from TemporalClientPool to drive cleanup. If the cached client is broken at the transport layer (e.g. every gRPC call returns context.DeadlineExceeded), every requeue of the deletion finalizer re-uses the same poisoned client and the cleanup loop never makes progress. The only way to recover is to restart the controller pod — which drops the in-memory pool.

The main Reconcile path already evicts on serviceerror.PermissionDenied / Unauthenticated (via isAccessDeniedErr at worker_controller.go:324), but handleDeletion has no eviction at all, and transport-layer errors don't surface as access-denied.

Environment

  • Controller image: ghcr.io/temporalio/temporal-worker-controller:main-… (in sync with upstream main f9d01cd)
  • Temporal: Temporal Cloud (API-key auth)
  • Kubernetes: EKS 1.30
  • CRD revision: post-v1.7 rename (WorkerDeployment / Connection)
  • Helm-rendered Secret materialised by ExternalSecrets-operator

What happened (sanitised production timeline)

Single review env, one WorkerDeployment named example-nestjs-temporal-worker in namespace review-review-dev-ylmdqj:

Time (UTC) Pattern Count
11:08 Connection.temporal.io "temporal-cloud" not found 6
11:08 Reconciler error (generic event-emit follow-up) 18
11:08 Helm finishes; Secret + Connection + WD all Healthy
11:09–11:15 WorkerDeployment is deleted (chart re-render) → finalizer starts
11:15–11:54 failed to clean up Temporal server-side deployment data, will retry — underlying error: unable to describe worker deployment: context deadline exceeded ~240
11:54 Operator restarts temporal-worker-controller-manager pod
11:54 Finalizer succeeds on next reconcile (fresh client dial)

So the failure spans ~40 minutes and 240+ retries of handleDeletion with the exact same error, each one re-using the cached SDK client that was poisoned during the brief window when the Connection was being created but the API-key Secret was not yet readable.

Root cause (code refs)

internal/controller/worker_controller.go#L544-L600:

temporalClient, ok := r.TemporalClientPool.GetSDKClient(clientpool.ClientPoolKey{
    HostPort:   temporalConnection.Spec.HostPort,
    Namespace:  workerDeploy.Spec.WorkerOptions.TemporalNamespace,
    SecretName: secretName,
    AuthMode:   authMode,
})
// ... fallback dial path, no eviction on subsequent failures ...

resp, err := deploymentHandler.Describe(ctx, sdkclient.WorkerDeploymentDescribeOptions{})
if err != nil {
    var notFound *serviceerror.NotFound
    if errors.As(err, &notFound) {
        l.Info("Worker Deployment not found on Temporal server, nothing to clean up")
        return nil
    }
    return fmt.Errorf("unable to describe worker deployment: %w", err)
}

Compare to the main Reconcile path (worker_controller.go#L322-L340):

if err != nil {
    if isAccessDeniedErr(err) {
        r.TemporalClientPool.EvictClient(clientPoolKey)
    }
    // ... rate-limit handling, status update ...
}

isAccessDeniedErr only matches serviceerror.PermissionDenied and serviceerror.Unauthenticated (worker_controller.go#L923-L928), so a context.DeadlineExceeded against a broken client never triggers eviction even in the main reconcile path.

Repro (synthetic)

  1. Pre-populate the TemporalClientPool with a stub SDK client whose WorkerDeploymentClient().GetHandle(name).Describe(...) returns context.DeadlineExceeded.
  2. Create a WorkerDeployment + Connection referencing the same (HostPort, TemporalNamespace, SecretName, AuthMode).
  3. Call handleDeletion directly.
  4. Observe the error propagated and assert the cached client is still in the pool — i.e. the next reconcile will re-use the broken client and re-fail.

A passing unit test for the fix is included in the PR linked below; it inverts step 4 to assert the broken client is evicted and a healthy client is retained on NotFound.

Proposed fix (minimal)

Add a defer in handleDeletion (immediately after the SDK client is acquired) that evicts the cached client whenever handleDeletion returns a non-nil error. EvictClient is documented as safe-when-missing, so the defer is also harmless on the dial-but-don't-cache path. NotFound on Describe is intentionally treated as success and returns nil, so the defer skips eviction there and the healthy client stays in the pool.

PR:

Alternative considered (broader)

Track consecutive Temporal-server failures per ClientPoolKey in the main Reconcile path and evict after N failures (or wrap every temporal.GetWorkerDeploymentState / Describe call in an eviction-on-transport-error helper). Rejected for the initial PR because:

  • It adds state and changes hot-path behaviour for every reconcile.
  • The deletion-cleanup path is the one we observed wedged in production; the main reconcile has periodic requeues and the connectionFinalizerExists path already self-recovers on Connection.HostPort changes.
  • The minimal fix in handleDeletion is enough to close the recovery gap; a broader change can land separately if maintainers prefer.

Happy to expand the PR scope if reviewers prefer the broader direction.

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