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, ¬Found) {
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)
- Pre-populate the
TemporalClientPool with a stub SDK client whose WorkerDeploymentClient().GetHandle(name).Describe(...) returns context.DeadlineExceeded.
- Create a
WorkerDeployment + Connection referencing the same (HostPort, TemporalNamespace, SecretName, AuthMode).
- Call
handleDeletion directly.
- 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.
Summary
When a
WorkerDeploymentis deleted,handleDeletion(internal/controller/worker_controller.go) reuses a cached Temporal SDK client fromTemporalClientPoolto drive cleanup. If the cached client is broken at the transport layer (e.g. every gRPC call returnscontext.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
Reconcilepath already evicts onserviceerror.PermissionDenied/Unauthenticated(viaisAccessDeniedErratworker_controller.go:324), buthandleDeletionhas no eviction at all, and transport-layer errors don't surface as access-denied.Environment
ghcr.io/temporalio/temporal-worker-controller:main-…(in sync with upstreammainf9d01cd)WorkerDeployment/Connection)What happened (sanitised production timeline)
Single review env, one
WorkerDeploymentnamedexample-nestjs-temporal-workerin namespacereview-review-dev-ylmdqj:11:08Connection.temporal.io "temporal-cloud" not found11:08Reconciler error(generic event-emit follow-up)11:0811:09–11:15WorkerDeploymentis deleted (chart re-render) → finalizer starts11:15–11:54failed to clean up Temporal server-side deployment data, will retry— underlying error:unable to describe worker deployment: context deadline exceeded11:54temporal-worker-controller-managerpod11:54So the failure spans ~40 minutes and 240+ retries of
handleDeletionwith the exact same error, each one re-using the cached SDK client that was poisoned during the brief window when theConnectionwas being created but the API-key Secret was not yet readable.Root cause (code refs)
internal/controller/worker_controller.go#L544-L600:Compare to the main
Reconcilepath (worker_controller.go#L322-L340):isAccessDeniedErronly matchesserviceerror.PermissionDeniedandserviceerror.Unauthenticated(worker_controller.go#L923-L928), so acontext.DeadlineExceededagainst a broken client never triggers eviction even in the main reconcile path.Repro (synthetic)
TemporalClientPoolwith a stub SDK client whoseWorkerDeploymentClient().GetHandle(name).Describe(...)returnscontext.DeadlineExceeded.WorkerDeployment+Connectionreferencing the same(HostPort, TemporalNamespace, SecretName, AuthMode).handleDeletiondirectly.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
deferinhandleDeletion(immediately after the SDK client is acquired) that evicts the cached client wheneverhandleDeletionreturns a non-nil error.EvictClientis documented as safe-when-missing, so the defer is also harmless on the dial-but-don't-cache path.NotFoundonDescribeis intentionally treated as success and returnsnil, so the defer skips eviction there and the healthy client stays in the pool.PR:
Alternative considered (broader)
Track consecutive Temporal-server failures per
ClientPoolKeyin the mainReconcilepath and evict after N failures (or wrap everytemporal.GetWorkerDeploymentState/Describecall in an eviction-on-transport-error helper). Rejected for the initial PR because:connectionFinalizerExistspath already self-recovers onConnection.HostPortchanges.handleDeletionis 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.