Skip to content

fix: evict cached Temporal client on bad-client failures - #329

Open
rupesh-parab-one-app wants to merge 2 commits into
temporalio:mainfrom
rupesh-parab-one-app:fix/evict-client-on-deletion-cleanup-failure
Open

fix: evict cached Temporal client on bad-client failures#329
rupesh-parab-one-app wants to merge 2 commits into
temporalio:mainfrom
rupesh-parab-one-app:fix/evict-client-on-deletion-cleanup-failure

Conversation

@rupesh-parab-one-app

@rupesh-parab-one-app rupesh-parab-one-app commented May 21, 2026

Copy link
Copy Markdown

What

Cached Temporal SDK clients are now evicted consistently when the controller observes failures that indicate the cached client may no longer be usable.

This expands the original deletion-cleanup-only fix to cover both places that reuse a cached client:

  • the main Reconcile path after GetWorkerDeploymentState / DescribeWorkerDeployment
  • handleDeletion while cleaning up Temporal server-side Worker Deployment data

The shared shouldEvictClient(err) predicate keeps the existing auth behavior and adds bounded recovery for transport/connectivity failures:

  • serviceerror.PermissionDenied
  • Unauthenticated
  • context.DeadlineExceeded
  • serviceerror.Unavailable

It intentionally does not evict on broader server/application responses such as ResourceExhausted, context.Canceled, or NotFound.

Why

In #328 we observed the controller repeatedly reusing the same cached SDK client after DescribeWorkerDeployment returned context.DeadlineExceeded. The controller did not recover until the manager pod restarted and dropped the in-memory pool.

The earlier narrow version of this PR only evicted in handleDeletion, but reviewers correctly pointed out that the main Reconcile path has the same shape: get a cached client, call Describe, then requeue on transport failure without evicting. This PR now closes that recovery gap in both paths.

Changes

  • internal/controller/worker_controller.go
    • adds shouldEvictClient(err) next to isAccessDeniedErr
    • replaces main Reconcile isAccessDeniedErr eviction checks with shouldEvictClient
    • updates handleDeletion to use the same predicate instead of evicting on every non-nil return
  • internal/controller/reconciler_events_test.go
    • adds TestShouldEvictClient to lock down included/excluded error classes
    • adds TestReconcile_EvictsCachedClientOnTransportFailure
    • keeps the deletion cleanup regression test and its NotFound-retains-client case
    • keeps the no-op Close() on stubTemporalClient so EvictClient can close test clients safely

Test plan

  • go test ./internal/controller -run 'Test(ShouldEvictClient|Reconcile_EvictsCachedClientOnTransportFailure|HandleDeletion_EvictsCachedClientOnTemporalFailure|Reconcile_DescribeWorkerDeploymentNotFound)' -count=1
  • go test ./internal/controller -count=1

Closes #328.

@rupesh-parab-one-app
rupesh-parab-one-app requested review from a team and jlegrone as code owners May 21, 2026 13:28
@CLAassistant

CLAassistant commented May 21, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@rupesh-parab-one-app
rupesh-parab-one-app force-pushed the fix/evict-client-on-deletion-cleanup-failure branch 2 times, most recently from 9b56a0d to 8bc7c67 Compare May 27, 2026 04:38
@rupesh-parab-one-app rupesh-parab-one-app changed the title fix: evict cached Temporal client when deletion cleanup fails fix: evict cached Temporal client on bad-client failures May 27, 2026
@jaypipes

Copy link
Copy Markdown
Collaborator

@rupesh-parab-one-app I'm super embarrassed at the long delay in getting back to this important PR. Apologies. If you wouldn't mind rebasing to main to address the merge conflicts, that would be great. I'll do a review on the revision ASAP.

Reusing a cached SDK client after access or transport failures can keep the
controller wedged on the same unhealthy client until the manager pod restarts
and drops the in-memory pool.

Centralize the eviction decision in shouldEvictClient and use it from both the
main Reconcile path and WorkerDeployment deletion cleanup. The predicate keeps
the existing PermissionDenied/Unauthenticated behavior and adds transport cases
that benefit from redialing: context.DeadlineExceeded and serviceerror.Unavailable.
It intentionally leaves ResourceExhausted, context.Canceled, and domain responses
such as NotFound alone so ordinary server-side or lifecycle responses do not
churn otherwise healthy clients.

Add regressions for Reconcile and deletion cleanup so a cached client returning
context.DeadlineExceeded is evicted before the next reconcile retries.

Co-authored-by: Cursor <cursoragent@cursor.com>
@rupesh-parab-one-app
rupesh-parab-one-app force-pushed the fix/evict-client-on-deletion-cleanup-failure branch from 8bc7c67 to dfb8ce2 Compare August 16, 2026 14:35
@jaypipes
jaypipes self-requested a review August 17, 2026 11:18
@jaypipes

Copy link
Copy Markdown
Collaborator

@rupesh-parab-one-app thanks for pushing a new rev! FYI, if you'd like to chat about this PR (and any other one!), feel free to find us on the Temporal community Slack #temporal-workers channel. :)

@jaypipes jaypipes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

👍 good stuff, thank you @rupesh-parab-one-app :)

Comment on lines +957 to +958
var unavailable *serviceerror.Unavailable
return errors.As(err, &unavailable)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why evict on Unavailable? The gRPC definition of this status describes it as

most likely a transient condition, which can be corrected by retrying with a backoff
so I don't think it should be included here

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

As discussed in the other comment, if we evict and it still errors the next time (because the problem was not the client), that is ultimately ok. So.. both of these comments are non-blocking

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You're right, and I don't have the evidence to defend it. I'll drop Unavailable unless you'd rather keep it once you've seen the other thread.

Two things I got wrong when I added it. First, I assumed the SDK would have retried a genuinely transient one before we saw it. That is true wherever we go through a deployment handle — all nine handle methods wrap the call in newGRPCContext(ctx, defaultGrpcRetryParameters(ctx)) (go.temporal.io/sdk@v1.41.1/internal/internal_worker_deployment_client.go, e.g. Describe at :181, SetCurrentVersion at :258) — so it covers handleDeletion and all of executePlan's writes. But GetWorkerDeploymentState calls WorkflowService().DescribeWorkerDeployment(ctx, ...) directly (internal/temporal/worker_deployment.go:82) with the plain reconcile context and no retry.ConfigKey, so NewRetryOptionsInterceptor takes its else branch and calls grpc_retry.Disable() (go.temporal.io/sdk@v1.41.1/internal/common/retry/interceptor.go:140-143). No retries at all there. So at that one call site a single transient blip evicts a healthy client, which is your point exactly.

Second, errors.As(err, &unavailable) misses *serviceerror.NamespaceUnavailable, which convert.go:80-86 returns when the status carries a NamespaceUnavailableFailure detail. So the branch does not even cover the Unavailable family cleanly.

Worth reading alongside the other thread: the deadline branch below it does not match a Temporal-origin deadline at all, so today Unavailable is the only transport-failure branch matching anything Temporal-origin. If we drop both, the predicate falls back to the auth behaviour that already exists on main, and this PR stops doing anything. That is a fine outcome if it's the right one, but I'd rather say it out loud than have it happen by accident.

}
if errors.Is(err, context.DeadlineExceeded) {
return true
}

@carlydf carlydf Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

similarly, I understand that the issue you reported manifested as context.DeadlineExceeded, so including context.DeadlineExceeded as an eviction condition is the main point of this PR.

But I do have misgivings about labeling a context.DeadlineExceeded as a signifier of a "poisoned" connection. Context deadline exceeded could happen due to a very large range of things going on in the server that are unrelated to the client. And, if the SDK is doing something pathologically wrong with a "poisoned" client config, perhaps it would be more appropriate to ask the SDK to return a different error type in that case.

In #328, you call out

ResourceExhausted may represent throttling where client churn does not help.

as a reason for excluding ResourceExhausted, but the same exact reasoning thing can be said about context.DeadlineExceeded.

Can we at least put a comment here explaining honestly why we are including context.DeadlineExceeded error type in the "evictable" errors? It's ok with me if it says something like "saw this error repeatedly until pod restarted and client cache was cleared. not totally sure how the client became unhealthy, but prefer to err on the side of evicting in the case of this error instead of repeating it indefinitely."

I think we are protected from a perpetual and fast evict -> reload client -> context deadline error -> evict loop by the exponential backoff for erroring Reconciles (it could still be perpetual if recreating the client doesnt solve the problem, but at least if would slow over time). That is why I personally am ok with erring on the side of eviction. But I want this PR to be honest about what we do and don't know about why this error happened in the first place.

@rupesh-one rupesh-one Aug 19, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I went looking for the explanation you asked for, this check does not match a Temporal deadline.

The SDK's errorInterceptor is its outermost unary interceptor and rewrites call errors through serviceerror.FromStatus(status.Convert(err)) (go.temporal.io/sdk@v1.41.1/internal/grpc_dialer.go:134, body at :201-208; it passes GrpcMessageTooLargeError through unchanged). codes.DeadlineExceeded becomes *serviceerror.DeadlineExceeded (go.temporal.io/api@v1.62.8/serviceerror/convert.go:77), which implements only Error() and Status() — no Unwrap, no Is. So errors.Is(err, context.DeadlineExceeded) cannot reach the sentinel. The message text survives via st.Message(), which is why the logs read context deadline exceeded and why this looked correct to me. My own tests pass only because they inject the raw sentinel through a stub and never touch gRPC.

This also answers something I had wrong in #328. The deletion path does install the SDK retry config, so I assumed a deadline there had already been retried. It hadn't: codes.DeadlineExceeded is deliberately absent from the SDK's retryable set, with the comment "they are coming from go context and 'context errors are not retriable based on user settings' by gRPC library" (go.temporal.io/sdk@v1.41.1/internal/common/retry/interceptor.go:103-108). So the SDK forwards a deadline unretried, and then the controller's check doesn't match it. Nothing in the stack reacted.

Reproduction on a branch rather than more code here, tests only, based on this PR's head: https://github.com/rupesh-parab-one-app/temporal-worker-controller/blob/5e80872729d8983673771e9808dd79b1993ab422/REPRO.md

It shows it twice: constructing the error the way the interceptor does, where the predicate returns false, and driving a real SDK client over an in-memory bufconn transport with no Temporal server. A real call whose deadline fires returns *serviceerror.DeadlineExceeded with the message context deadline exceeded; an unreachable endpoint returns *serviceerror.Unavailable. Happy to inline the relevant few lines here if you'd rather not follow a fork link.

On your ResourceExhausted parallel: your policy argument stands untouched, and I should be clear I'm not answering it. I'm saying something narrower — that today the question is moot, because the branch is unreachable for the case it was written for. The one deadline it can match is a non-Temporal one, such as a Kubernetes write in executePlan running out the 5-minute reconcile budget (worker_controller.go:113), and evicting the Temporal client there achieves nothing.

One aside on backoff, since you raised it: it isn't present on the cleanup path. Reconcile swallows the handleDeletion error and returns ctrl.Result{RequeueAfter: 10 * time.Second}, nil (worker_controller.go:157-159), so controller-runtime calls Forget and requeues at a flat 10s rather than rate-limiting — which is the retry cadence I described in #328. The main paths do return real errors, so backoff applies there. I'd rather not change those lines in this PR; I'll file it separately.

Where that leaves the comment you asked for: I don't think it's the right change on its own. My suggestion, if you agree, is to match the concrete type in addition to the sentinel — errors.As(err, &*serviceerror.DeadlineExceeded) alongside the existing errors.Is, since swapping one for the other would drop the Kubernetes case — drop Unavailable per your other comment, and then write the honest comment against what's left. The alternative is to drop the deadline case as unproven, but combined with dropping Unavailable that reduces the predicate to the auth behaviour already on main. Happy either way; tell me which and I'll push it.

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.

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

5 participants