Conversation
A WorkerDeploymentVersion that was superseded before its version became Current was marked as Inactive by Temporal Worker Controller before this fix was committed in TWC v1.10.0/v1.7.1. Would you mind installing TWC v1.10.0 and verifying that you no longer see the behaviour this PR addresses? |
Absolutely, will report back! |
Replace the inactive-retirement fork with the upstream image containing #554, as requested for the upstream verification in temporalio/temporal-worker-controller#577. Keep controller/CRD charts, identity recovery, application workers, gates and rollout settings unchanged. Update the release contract and verification runbook. This switches the controller baseline; it does not claim #577's never-promoted Inactive retirement case is resolved or perform a production rollout.
|
Thanks for taking a look, @jaypipes! I asked GPT Astra to run the follow-up tests and leave this reply on my behalf. I'm completely fine with your decision on whether this needs an upstream change, and whether you want to use or adapt this patch. I just wanted to share what the retest found in case you're still curious. The test used the official v1.10.1 controller image and Temporal 1.32.0 in a separate local Kubernetes cluster with real worker Pods. It started a worker but left it waiting for permission to receive normal jobs (
Here's the standalone reproduction, sample worker, setup commands, and test details: The original cluster test was run successfully; the Gist packaging hasn't had a separate run from scratch yet. It uses public dependencies and doesn't need access to my cluster or application repositories. Could this be a separate case from the one #554 fixes? Either way, thanks for reviewing it! |
jaypipes
left a comment
There was a problem hiding this comment.
@mitchross thanks very much for this PR! Left a few inline suggestions for you, but nothing major :)
|
|
||
| Stop sending pinned version overrides to a version being retired. Visibility is eventually | ||
| consistent, so this check cannot exclude concurrent workflow starts or override changes. | ||
| Temporal's drained status has the [same limitation for newly pinned overrides](https://typescript.temporal.io/api/interfaces/proto.temporal.api.deployment.v1.IWorkerDeploymentVersionInfo#drainageinfo). |
There was a problem hiding this comment.
Remove the above last paragraph. It's too much sausagemaking I'm afraid and will likely just confuse readers.
| - **scaledownDelay**: How long to wait after a version has been Drained before scaling pods to zero | ||
| - **deleteDelay**: How long to wait after a version has been Drained before deleting the Kubernetes `Deployment` | ||
|
|
||
| Versions superseded before ever becoming Current or Ramping remain Inactive in Temporal; |
There was a problem hiding this comment.
Let's convert this into a callout...
| Versions superseded before ever becoming Current or Ramping remain Inactive in Temporal; | |
| > **NOTE**: Versions superseded before ever becoming Current or Ramping remain Inactive in Temporal; |
| do not apply to these unused versions. API failures or active pollers defer deletion and are | ||
| retried on subsequent reconciliations. |
There was a problem hiding this comment.
| do not apply to these unused versions. API failures or active pollers defer deletion and are | |
| retried on subsequent reconciliations. | |
| do not apply to these unused versions. |
(removed the last line because it's just sausagemaking and not particularly important to convey to the reader)
| // Visibility can contain either the legacy dot separator or the newer colon form. | ||
| legacyVersion := strings.ReplaceAll(p.WorkerDeploymentName+"."+buildID, "'", "''") | ||
| version := strings.ReplaceAll(p.WorkerDeploymentName+":"+buildID, "'", "''") | ||
| count, err := temporalClient.CountWorkflow(ctx, &workflowservice.CountWorkflowExecutionsRequest{ | ||
| Query: fmt.Sprintf("TemporalWorkerDeploymentVersion IN ('%s', '%s') AND TemporalWorkflowVersioningBehavior = 'Pinned' AND ExecutionStatus = 'Running'", legacyVersion, version), | ||
| }) | ||
| if err != nil || count == nil || count.Count != 0 { |
There was a problem hiding this comment.
Please make a little helper function getOpenPinnedWorkflowExecutions() to encapsulate this query.
| // DeleteVersion does not check pinned execution visibility for Inactive versions, | ||
| // which can receive workflows through VersioningOverride. Check visibility first. |
There was a problem hiding this comment.
| // DeleteVersion does not check pinned execution visibility for Inactive versions, | |
| // which can receive workflows through VersioningOverride. Check visibility first. | |
| // Because DeleteVersion does not check to see if there are open workflows using | |
| // pinned execution for Inactive versions, we query the Temporal visibility service | |
| // for open pinned workflows before deleting Inactive versions. |
| legacyVersion := strings.ReplaceAll(p.WorkerDeploymentName+"."+buildID, "'", "''") | ||
| version := strings.ReplaceAll(p.WorkerDeploymentName+":"+buildID, "'", "''") | ||
| count, err := temporalClient.CountWorkflow(ctx, &workflowservice.CountWorkflowExecutionsRequest{ | ||
| Query: fmt.Sprintf("TemporalWorkerDeploymentVersion IN ('%s', '%s') AND TemporalWorkflowVersioningBehavior = 'Pinned' AND ExecutionStatus = 'Running'", legacyVersion, version), |
There was a problem hiding this comment.
Might be a little easier to read if you create a qs variable first and pass that:
qs := fmt.Sprintf(
"TemporalWorkerDeploymentVersion IN ('%s', '%s') "+
"AND TemporalWorkflowVersioningBehavior = 'Pinned' "+
"AND ExecutionStatus = 'Running'",
legacyVersion, version,
)
req := &workflowservice.CountWorkflowExecutionsRequest{Query: qs}
count, err := temporalClient.CountWorkflow(ctx, req)
...| ) { | ||
| identity := getControllerIdentity() | ||
| markedForDeletion := make([]*appsv1.Deployment, 0, len(p.DeleteDeployments)) | ||
| retainedInactiveBuilds := make(map[string]bool) |
There was a problem hiding this comment.
Unless I'm mistaken, you don't need the above retainedInactiveBuilds variable. The continue on line 734 will cause the Deployment to not be added to the markedForDeletion slice on line 755...
Extract the pinned-workflow visibility query, clarify its formatting and safety comment, and distinguish confirmed running workflows from failed visibility checks in logs. Shorten the concepts documentation into a note. Rename retainedInactiveBuilds to retainedResourceBuilds and explain its purpose: skipping a Deployment does not filter the separate worker-resource deletion list. Preserve the existing protection for retained versions. Validation: go test ./... and go vet ./... passed. Server-backed integration tests passed for normal drained retirement and inactive retirement with and without a pinned workflow. Existing unit cases verify rendered ConfigMaps survive visibility failures, pinned workflows, and rejected deletion.
|
@mitchross hey, just FYI, the TWC maintainers all hang out on the |
Thanks for the heads up. Getting some practice in at home as we start to ramp up Temporal in a cluster at $JOB. I think I hit on all the comments. In full transparency I leveraged AI, GPT 6. I purposely used the temporal skills , and temporal MCP docs server to ensure adherence. |
| if slices.ContainsFunc(workerDeploy.Status.DeprecatedVersions, func(v *temporaliov1alpha1.DeprecatedWorkerDeploymentVersion) bool { | ||
| return v.BuildID == buildID && v.Status == temporaliov1alpha1.VersionStatusInactive | ||
| }) { | ||
| retainedResourceBuilds[buildID] = true |
There was a problem hiding this comment.
Not sure if you caught my earlier comment about this, but I don't believe you need the retainedResourceBuilds variable at all. Pretty sure the existing markedForDeletion variable and behaviour is all that is needed.
Nice! :)
Totally cool. Thanks for the transparency, it is much appreciated! |
Remove retainedResourceBuilds as requested in review. Use markedForDeletion to identify rejected Inactive Deployment deletions and preserve their worker resources without maintaining a separate map. Keep independent resource deletions, including orphan cleanup and drained autoscaler sunset, unchanged. Extend the existing regression cases to check orphan cleanup alongside retained Inactive versions. Report the actual observed state when the inactive-retirement integration test times out. Validation: unit tests, go vet, import formatting, and the three targeted server-backed retirement integration scenarios passed locally.
| // Keep resources belonging to inactive Deployments whose deletion was refused. | ||
| // Other resource deletions, such as orphan cleanup, remain in the plan. | ||
| for _, d := range p.DeleteDeployments { | ||
| if slices.Contains(markedForDeletion, d) { | ||
| continue | ||
| } | ||
| buildID, ok := d.Labels[k8s.BuildIDLabel] | ||
| if !ok { | ||
| continue | ||
| } | ||
| if !slices.ContainsFunc(workerDeploy.Status.DeprecatedVersions, func(v *temporaliov1alpha1.DeprecatedWorkerDeploymentVersion) bool { | ||
| return v.BuildID == buildID && v.Status == temporaliov1alpha1.VersionStatusInactive | ||
| }) { | ||
| continue | ||
| } | ||
| p.DeleteWorkerResources = slices.DeleteFunc(p.DeleteWorkerResources, func(ref planner.WorkerResourceRef) bool { | ||
| return ref.BuildID == buildID | ||
| }) | ||
| } |
There was a problem hiding this comment.
@mitchross I think maybe Claude is getting confused :) I don't believe this block of code is needed. Inactive build IDs that have open workflows are not added to the markedForDeletion slice (line 753) because of the continue statements on lines 728 and 732.
Remove the additional worker-resource filtering block as requested in review. Keep the existing markedForDeletion handling for Deployments. Validation: go vet ./... and git diff --check passed. go test ./... fails four existing TestExecutePlan_InactiveVersionDeletion cases with a missing ConfigMap: running pinned workflow, visibility failure, missing response, and rejected server deletion. Test expectations are unchanged.
Combine upstream per-version DeleteVersion retry backoff with the existing Inactive-version visibility check and its helper. Preserve the reviewer's requested removal of the worker-resource filtering block. Validation: go vet ./..., import formatting, and git diff --check pass. go test ./... reports the same four existing ConfigMap-retention failures; all other tests, including the upstream deletion-backoff tests, pass.
jaypipes
left a comment
There was a problem hiding this comment.
muy bueno, thank you so much @mitchross! :)
A rollout superseded before its version becomes Current or Ramping stays Inactive forever. The controller scales its Deployment to zero, but only Drained and NotRegistered versions enter deletion, leaking both the Kubernetes Deployment and Temporal version record. This is the Inactive case discussed in #498.
Extend the existing controller deletion path:
No CRD or new retirement clock is introduced. The existing sunset delays are defined for Drained versions. Unused Inactive versions are removed after the checks above succeed.
The documented operational limit is explicit: stop sending pinned overrides to versions being retired. Visibility is eventually consistent and the count is not atomic with deletion. Temporal's existing Drained status also does not account for newly pinned overrides. This change does not claim to provide a server-side exclusion against concurrent workflow starts or override updates.
Fixes #596
Validation:
go test ./... -count=1with envtest assets and Helm dependencies passes.The complete
TestIntegrationsuite passes (475 seconds), covering Manual, AllAtOnce, Progressive, rollback, version limits, resource templates, and deletion. No production deployment has occurred.