Skip to content

feat(controller): surface poller and gate-workflow health as conditions and events#448

Open
wankhede04 wants to merge 7 commits into
temporalio:mainfrom
wankhede04:feat/poller-worker-health-447
Open

feat(controller): surface poller and gate-workflow health as conditions and events#448
wankhede04 wants to merge 7 commits into
temporalio:mainfrom
wankhede04:feat/poller-worker-health-447

Conversation

@wankhede04

Copy link
Copy Markdown
Contributor

What was changed?

  • internal/temporal/worker_deployment.go: extend VersionInfo with PollerHealth map[string]bool / PollerHealthUnknown bool, populated from poller data already fetched via DescribeTaskQueue (no new round trips for the target version's task queues; one additional DescribeVersion call for the current version, since nothing previously fetched its task queues).
  • api/v1alpha1/conditions.go / workerdeployment_types.go: add ConditionWorkersHealthy with reasons ReasonPollersHealthy, ReasonNoActivePollers, ReasonPollerStatusUnknown.
  • internal/controller/workers_healthy.go (new): a pure computePollerHealthCondition function plus syncWorkersHealthyCondition, which sets the condition on the version currently serving production traffic (falling back to the target version before the first rollout completes) and emits a Warning/Normal event only on transition (not every reconcile).
  • internal/controller/genstatus.go: emit a GateWorkflowFailed Warning event the first time a gate/test workflow transitions into Failed/Canceled/Terminated/TimedOut, compared against the previous reconcile's recorded status so it doesn't re-fire every loop.

Why?

Closes #447.

Today HealthySince (api/v1alpha1/workerdeployment_types.go) is derived only from k8s.IsDeploymentHealthy, i.e. Kubernetes Deployment/pod readiness. It has no visibility into whether Temporal is actually receiving polls — a Deployment can be fully Ready while its workers are misconfigured, stuck, or unable to reach Temporal, and today the controller has no way to surface that. Meanwhile internal/temporal/worker_deployment.go's getPollers() already fetches the real poller list via DescribeTaskQueue, but today it's reduced to a single internal bool (AllTaskQueuesHaveUnversionedPoller) used only for a rollout-strategy heuristic — the poller data itself is discarded.

This also directly answers the non-post-GA part of #50: @carlydf's comment there states "We should surface worker healthcheck error and Gate workflow failure in the TemporalWorkerDeployment events for sure," explicitly separating this from the post-GA rollout-acceptance-testing work.

#61 (add URL field to version metadata) is intentionally out of scope here — it's a separately milestoned Post-GA follow-up that this work sets up for but doesn't attempt.

How was this tested?

  • gofmt -l . — clean
  • go vet ./... — clean
  • go build ./... — succeeds
  • go test ./internal/controller/... ./internal/temporal/... ./internal/planner/... ./internal/k8s/... — all pass, including a new table-driven unit test (workers_healthy_test.go) covering computePollerHealthCondition's decision logic (nil/never-checked, all-healthy, one-queue-unhealthy, fetch-error-treated-as-unknown-not-unhealthy, and a confirmed-unhealthy-queue winning over an unrelated fetch error).
  • make test-unit's webhook (Ginkgo) suite could not be run in my environment because helm isn't installed there; confirmed this failure is pre-existing on unmodified main too (unrelated to this change) and is orthogonal to the reconciler/controller logic touched here.

Risks

This is reporting-only: a new condition type, new event emissions, and new struct fields, with no changes to rollout/reconciliation decision logic (internal/planner/planner.go is untouched). The one behavior-affecting addition is a new DescribeVersion call for the current version each reconcile (needed since nothing previously fetched its task queues); a DescribeTaskQueue failure is treated as Unknown, never surfaced as False/unhealthy, to avoid false alarms on transient errors.

…scribeTaskQueue calls

Extend VersionInfo with PollerHealth/PollerHealthUnknown, populated for the
current version (via DescribeVersion) and the target version (reusing task
queues already fetched in GetTestWorkflowStatus), so poller presence data is
no longer discarded after being reduced to AllTaskQueuesHaveUnversionedPoller.
Add ConditionWorkersHealthy plus ReasonPollersHealthy/ReasonNoActivePollers/
ReasonPollerStatusUnknown, so the WorkerDeployment CRD can report whether
workers are actually polling Temporal rather than merely Ready at the
Kubernetes level.
…ns and events

Set ConditionWorkersHealthy from poller health of the version serving
production traffic (current, falling back to target pre-rollout), emitting
a Normal/Warning event only on transition. Also emit a Warning event the
first time a gate/test workflow ends in Failed/Canceled/Terminated/TimedOut.
This is reporting-only: no rollout/reconciliation logic changes.

Closes temporalio#447
@wankhede04
wankhede04 requested review from a team and jlegrone as code owners July 21, 2026 05:21
@CLAassistant

CLAassistant commented Jul 21, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@jaypipes jaypipes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Some good stuff in here, thank you @wankhede04 :) However, I'd love to see this split into two PRs, one that adds the warning event emission for gate workflow failures and another that adds the poller health checking.

Comment thread api/v1alpha1/conditions.go Outdated
// serving production traffic (or, before the first rollout completes, the target
// version) are actively polling Temporal -- as opposed to merely being Ready at
// the Kubernetes level.
ConditionWorkersHealthy = "WorkersHealthy"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it necessary to create a new Condition type for this? How about adding ReasonNoActivePollers and ReasonPollerStatusUnknown but using the existing ConditionReady and adding the poller health check as part of the calculation of ConditionReady status of True for WorkerDeployment?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call — done. Removed ConditionWorkersHealthy entirely. ReasonNoActivePollers and ReasonPollerStatusUnknown are now set directly on the existing ConditionReady (False/Unknown respectively) as part of its calculation when the target version's rollout has otherwise completed (VersionStatusCurrent), instead of a separate condition type. See the updated syncConditions in internal/controller/worker_controller.go.

Comment on lines 19 to +78
@@ -42,6 +57,25 @@ func (r *WorkerDeploymentReconciler) generateStatus(
// Continue without test workflow status
}

// Emit a Warning event the first time a gate/test workflow is observed to have
// ended in a non-successful terminal state. Compare against the previous
// reconcile's recorded status (still on workerDeploy.Status at this point, since
// it hasn't been overwritten yet) so this doesn't re-fire on every loop.
prevStatusByWorkflowID := make(map[string]temporaliov1alpha1.WorkflowExecutionStatus, len(workerDeploy.Status.TargetVersion.TestWorkflows))
for _, wf := range workerDeploy.Status.TargetVersion.TestWorkflows {
prevStatusByWorkflowID[wf.WorkflowID] = wf.Status
}
for _, wf := range testWorkflows {
if !isGateWorkflowTerminalFailure(wf.Status) {
continue
}
if prevStatusByWorkflowID[wf.WorkflowID] == wf.Status {
continue
}
r.Recorder.Eventf(workerDeploy, corev1.EventTypeWarning, ReasonGateWorkflowFailed,
"Gate/test workflow %s for version %s ended with status %s", wf.WorkflowID, targetBuildID, wf.Status)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would support adding this code in a separate PR. It's technically orthogonal to the poller health check work.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, done. Split this out into a standalone PR: #457. This PR (#448) now contains only the poller-health-checking work — isGateWorkflowTerminalFailure and the associated event-emission code have been removed from genstatus.go here.

Comment thread internal/temporal/worker_deployment.go Outdated
Comment on lines +380 to +383
if err != nil { //nolint:revive // TODO(carlydf): consider logging this error
unknown = true
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I suspect it would be wiser to just return the error here instead of continuing on to the next task queue. Considering that the error is most likely either going to be hitting a rate limit or a network partition/connectivity failure, making a call to DescribeTaskQueue directly after receiving either one of those errors is just going to exacerbate things.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, done. computePollerHealth now returns immediately on the first getPollers/DescribeTaskQueue error instead of continuing on to the remaining task queues. Task queues already checked before the error are still returned in the health map, and callers set PollerHealthUnknown=true on a non-nil error, so the 'unknown, not unhealthy' semantics are preserved.

Per review feedback from @jaypipes on PR temporalio#448: this PR should stay
scoped to the poller-health-checking work. The gate/test workflow
terminal-failure Warning event emission (isGateWorkflowTerminalFailure
and the associated event loop in generateStatus, plus
ReasonGateWorkflowFailed) is orthogonal to poller health and will be
proposed in a separate PR instead.
…w condition

Per review feedback from @jaypipes: rather than introducing a new
ConditionWorkersHealthy condition type, reuse the existing
ConditionReady and factor poller health into its calculation.

- Drop ConditionWorkersHealthy from api/v1alpha1/conditions.go.
- ReasonNoActivePollers and ReasonPollerStatusUnknown are now set on
  ConditionReady=False/Unknown (instead of a separate condition) when
  the target version has otherwise completed rollout (Status=Current)
  but its workers are not confirmed to be actively polling Temporal.
  ReasonPollersHealthy remains for the Normal event emitted on
  recovery.
- syncConditions now takes the TemporalWorkerState so it can look up
  PollerHealth/PollerHealthUnknown for the relevant build ID and
  reflect it in Ready before declaring rollout success; Progressing
  and the deprecated RolloutComplete condition are unaffected, since
  those track rollout state only.
- workers_healthy.go now holds only the pure, unit-tested
  computePollerHealthCondition helper; the condition-setting/event
  logic that used to live in syncWorkersHealthyCondition moved into
  syncConditions.
- Updated TestSyncConditions plus added
  NotReadyWhenCurrentVersionHasNoActivePollers and
  ReadyUnknownWhenCurrentVersionPollerStatusUnknown cases.
…Queue error

Per review feedback from @jaypipes: a DescribeTaskQueue failure is
most likely a rate limit or a network partition/connectivity issue, so
continuing on to call DescribeTaskQueue for the remaining task queues
right after one of those errors would only make things worse.

computePollerHealth now returns an error and stops on the first
getPollers failure instead of setting an 'unknown' flag and
continuing the loop. Task queues already checked before the error are
still returned, and callers set PollerHealthUnknown=true on a non-nil
error, preserving the existing 'unknown, not unhealthy' semantics.
@wankhede04

Copy link
Copy Markdown
Contributor Author

Thanks for the review @jaypipes! Addressed all three points:

  1. Split into two PRs — split out the gate/test-workflow-failure event emission into feat(controller): emit a Warning event on gate/test workflow terminal failure #457. This PR (feat(controller): surface poller and gate-workflow health as conditions and events #448) now contains only the poller-health-checking work.
  2. Folded poller health into ConditionReady instead of a new condition type — removed ConditionWorkersHealthy. ReasonNoActivePollers and ReasonPollerStatusUnknown are now set directly on ConditionReady (False/Unknown respectively) as part of its calculation for the Current version status, instead of a separate condition.
  3. Fail fast on DescribeTaskQueue errorscomputePollerHealth now returns immediately on the first error instead of continuing to the next task queue, so a rate-limit/connectivity issue doesn't get compounded by hitting every remaining task queue.

Re-verified gofmt, go vet, go build, golangci-lint (0 issues against main), and the full unit test suite (including updated/added cases in TestSyncConditions) after these changes.

jaypipes added a commit that referenced this pull request Jul 24, 2026
… failure (#457)

## What was changed?

- `internal/controller/genstatus.go`: add
`isGateWorkflowTerminalFailure` and emit a Warning event
(`GateWorkflowFailed`) the first time a gate/test workflow for the
target version is observed to have ended in a non-successful terminal
state (`Failed`, `Canceled`, `Terminated`, `TimedOut`). The previous
reconcile's recorded status is compared against the freshly-fetched
status so the event only fires once per transition, not on every
reconcile loop.
- `internal/controller/util.go`: add the `ReasonGateWorkflowFailed`
event reason constant.

## Why?

Split out of #448 per [review feedback from
@jaypipes](#448 (review)):
*"I'd love to see this split into two PRs, one that adds the warning
event emission for gate workflow failures and another that adds the
poller health checking."* This PR is the gate-workflow-failure half;
#448 now contains only the poller-health-checking work.

Also directly relates to #447 / #50: @carlydf's comment on #50 states
*"We should surface worker healthcheck error and Gate workflow failure
in the TemporalWorkerDeployment events for sure,"* calling out Gate
workflow failure surfacing as in-scope independent of the post-GA
rollout-acceptance-testing work.

## How was this tested?

- `gofmt -l .` — clean
- `go vet ./...` — clean
- `go build ./...` — succeeds
- `make GOLANGCI_LINT_FIX=false GOLANGCI_LINT_BASE_REV=main lint-code` —
0 issues
- `go test ./internal/controller/...` — all pass

## Risks

This is purely additive: one new event-reason constant and one new
Warning event emission, with no changes to rollout/reconciliation
decision logic or existing status fields.

Co-authored-by: Jay Pipes <jay.pipes@temporal.io>
Comment on lines +147 to +151
// ReasonPollersHealthy is used on ConditionReady=True (in place of
// ReasonRolloutComplete) and on the Normal Event emitted when a version's workers
// transition from having no active pollers (or unknown status) back to actively
// polling every known task queue.
ReasonPollersHealthy = "PollersHealthy"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please change this to ReasonActivePollers. The actual health of the poller isn't known :) Just that it's actively polling the server.

Also, I have changed my mind about having this reason affect ConditionReady. Instead, let's make this reason only impact ConditionProgressing=False so that it matches the existing ReasonWaitingForPollers that is set on ConditionProgressing=True.

// Ready at the Kubernetes level while its workers are misconfigured, stuck, or
// unable to reach Temporal; this reason distinguishes that case from a healthy
// rollout.
ReasonNoActivePollers = "NoActivePollers"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm wondering if we need ReasonNoActivePollers at all since we have the existing ReasonWaitingForPollers. thoughts?

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.

Surface Temporal-native poller health as Conditions + Events (extends #50)

3 participants