Files
1e31c76ed1 Versioning cherry-pick for OSS v1.30.2 (#9543)
## What changed?
Cherry-pick versioning PRs
- #9168
  - Cache for system protection
- #9262 
  - Cache for system protection
- #9239 
- Critical PR to enable sending `TargetVersionChanged` flag for
Upgrade-on-CaN feature
- #9147 
- Tracks version drainage properly when version receives workflows via
`VersioningOverride`. Needed for automated worker controllers to
correctly scale versioned workers that received workflows via
`VersioningOverride`.
- #9300 
- Needed for `approximate_backlog_count` metric to track Current and
Ramping version tasks correctly
- #9316 
- Needed for `approximate_backlog_count` metric to track Current and
Ramping version tasks correctly
- #8957 
- Contains minor metric improvement. Included because it adds a test
harness that is used in the two metrics PRs above
- #9250 
  - Bug fix of task rescheduling edge case during AutoUpgrade Transition

## Why?
For OSS v1.30.2

## How did you test it?
- [x] built
- [ ] run locally and tested manually
- [x] covered by existing tests
- [ ] added new unit test(s)
- [ ] added new functional test(s)





---

> [!NOTE]
> **Medium Risk**
> Touches history/matching worker-versioning paths, adding new caches
and changing workflow task/start handling and backlog metric emission;
incorrect caching or signaling could affect dispatch/upgrade behavior
and observability.
> 
> **Overview**
> Adds new worker-versioning protections and upgrade signaling: workflow
task started events now persist a
`workflow_task_target_worker_deployment_version_changed` flag (and emit
a new `workflow_target_version_changed_count` metric) under a new
`EnableSendTargetVersionChanged` dynamic config.
> 
> Introduces two new caches with metrics and dynamic config knobs: a
`RoutingInfoCache` to avoid repeated `GetTaskQueueUserData` lookups
during activity start/transition logic, and a `ReactivationSignalCache`
plus `EnableVersionReactivationSignals` to dedupe and asynchronously
send “reactivation” signals when workflows are pinned (via
start/signal-with-start/reset/update-options) to potentially
drained/inactive worker versions.
> 
> Extends matching backlog metrics to support version-attributed
reporting by adding `BacklogMetricsEmitInterval` and switching queue DB
emission to *physical* backlog gauges (`physical_approximate_backlog_*`)
when attribution is enabled, while keeping legacy gauges when disabled.
> 
> Adds frontend scaffolding for a new visibility RPC `CountSchedules`
(client plumbing, interception/metadata/quota wiring) but leaves the
frontend handler unimplemented, and bumps `go.temporal.io/api` to
`v1.62.2`.
> 
> <sup>Written by [Cursor
Bugbot]() for commit
cb8ae14c86666654c51dd66ae91cb225722e1ba7. This will update automatically
on new commits. Configure
[here]().</sup>

---------

Co-authored-by: Shahab Tajik <[email protected]>
Co-authored-by: Shivam <[email protected]>
2026-03-17 17:19:47 -05:00

429 lines
17 KiB
Go

package workerdeployment
import (
"context"
"errors"
"fmt"
"strings"
"time"
commonpb "go.temporal.io/api/common/v1"
deploymentpb "go.temporal.io/api/deployment/v1"
enumspb "go.temporal.io/api/enums/v1"
failurepb "go.temporal.io/api/failure/v1"
"go.temporal.io/api/serviceerror"
taskqueuepb "go.temporal.io/api/taskqueue/v1"
updatepb "go.temporal.io/api/update/v1"
"go.temporal.io/api/workflowservice/v1"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"
deploymentspb "go.temporal.io/server/api/deployment/v1"
"go.temporal.io/server/api/historyservice/v1"
"go.temporal.io/server/common/backoff"
"go.temporal.io/server/common/namespace"
"go.temporal.io/server/common/payload"
"go.temporal.io/server/common/primitives"
"go.temporal.io/server/common/sdk"
"go.temporal.io/server/common/searchattribute"
"go.temporal.io/server/common/searchattribute/sadefs"
"go.temporal.io/server/common/worker_versioning"
"go.temporal.io/server/service/history/api"
"go.temporal.io/server/service/history/consts"
update2 "go.temporal.io/server/service/history/workflow/update"
)
const (
// Workflow types
WorkerDeploymentVersionWorkflowType = "temporal-sys-worker-deployment-version-workflow"
WorkerDeploymentWorkflowType = "temporal-sys-worker-deployment-workflow"
// Namespace division
WorkerDeploymentNamespaceDivision = "TemporalWorkerDeployment"
// Updates
RegisterWorkerInDeploymentVersion = "register-task-queue-worker" // for Worker Deployment Version wf
SyncVersionState = "sync-version-state" // for Worker Deployment Version wfs
UpdateVersionMetadata = "update-version-metadata" // for Worker Deployment Version wfs
RegisterWorkerInWorkerDeployment = "register-worker-in-deployment" // for Worker Deployment wfs
SetCurrentVersion = "set-current-version" // for Worker Deployment wfs
SetRampingVersion = "set-ramping-version" // for Worker Deployment wfs
DeleteVersion = "delete-version" // for WorkerDeployment wfs
DeleteDeployment = "delete-deployment" // for WorkerDeployment wfs
SetManagerIdentity = "set-manager-identity" // for WorkerDeployment wfs
serverDeleteVersionIdentity = "try-delete-for-add-version" // identity of the worker-deployment workflow when it tries to delete a version on the event that the addition
// of a version exceeds the max number of versions allowed in a worker-deployment (defaultMaxVersions)
// Signals
ForceCANSignalName = "force-continue-as-new" // for Worker Deployment Version _and_ Worker Deployment wfs
SyncDrainageSignalName = "sync-drainage-status"
TerminateDrainageSignal = "terminate-drainage"
SyncVersionSummarySignal = "sync-version-summary"
PropagationCompleteSignal = "propagation-complete"
ReactivateVersionSignalName = "reactivate-version" // for Worker Deployment Version wfs
// Queries
QueryDescribeVersion = "describe-version" // for Worker Deployment Version wf
QueryDescribeDeployment = "describe-deployment" // for Worker Deployment wf
// Memos
WorkerDeploymentMemoField = "WorkerDeploymentMemo" // for Worker Deployment wf
// Application error names for rejected updates
errNoChangeType = "errNoChange"
errTooManyVersions = "errTooManyVersions"
errTooManyDeployments = "errTooManyDeployments"
errVersionAlreadyExistsType = "errVersionAlreadyExists"
errMaxTaskQueuesInVersionType = "errMaxTaskQueuesInVersion"
errVersionNotFound = "Version not found in deployment"
errDeploymentDeleted = "worker deployment deleted" // returned in the race condition that the deployment is deleted but the workflow is not yet closed.
errVersionDeleted = "worker deployment version deleted" // returned in the race condition that the deployment version is deleted but the workflow is not yet closed.
errLongHistory = "errLongHistory" // update is not accepted until CaN happens. client should retry
errVersionIsDraining = "errVersionIsDraining"
errVersionHasPollers = "errVersionHasPollersSuffix"
errFailedPrecondition = "FailedPrecondition"
ErrVersionIsDraining = "version '%s' cannot be deleted since it is draining"
ErrVersionHasPollers = "version '%s' cannot be deleted since it has active pollers"
ErrVersionIsCurrentOrRamping = "version '%s' cannot be deleted since it is current or ramping"
ErrRampingVersionDoesNotHaveAllTaskQueues = "proposed ramping version '%s' is missing active task queues from the current version; these would become unversioned if it is set as the ramping version"
ErrCurrentVersionDoesNotHaveAllTaskQueues = "proposed current version '%s' is missing active task queues from the current version; these would become unversioned if it is set as the current version"
ErrManagerIdentityMismatch = "ManagerIdentity '%s' is set and does not match user identity '%s'; to proceed, set your own identity as the ManagerIdentity, remove the ManagerIdentity, or wait for the other client to do so"
ErrWorkerDeploymentNotFound = "no Worker Deployment found with name '%s'; does your Worker Deployment have pollers?"
ErrWorkerDeploymentVersionNotFound = "build ID '%s' not found in Worker Deployment '%s'"
ErrTooManyRequests = "too many requests issued to Worker Deployment '%s'. Please try again later"
)
var (
WorkerDeploymentVisibilityBaseListQuery = fmt.Sprintf(
"%s = '%s' AND %s = '%s' AND %s = '%s'",
sadefs.WorkflowType,
WorkerDeploymentWorkflowType,
sadefs.TemporalNamespaceDivision,
WorkerDeploymentNamespaceDivision,
sadefs.ExecutionStatus,
enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING.String(),
)
)
var (
defaultActivityOptions = workflow.ActivityOptions{
StartToCloseTimeout: 1 * time.Minute,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: 100 * time.Millisecond,
MaximumAttempts: 5,
},
}
propagationActivityOptions = workflow.ActivityOptions{
StartToCloseTimeout: 1 * time.Minute,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: 100 * time.Millisecond,
// unlimited attempts
},
}
)
// validateVersionWfParams is a helper that verifies if the fields used for generating
// Worker Deployment Version related workflowID's are valid
func validateVersionWfParams(fieldName string, field string, maxIDLengthLimit int) error {
return worker_versioning.ValidateDeploymentVersionFields(fieldName, field, maxIDLengthLimit)
}
func GetDeploymentNameFromWorkflowID(workflowID string) string {
_, deploymentName, _ := strings.Cut(workflowID, worker_versioning.WorkerDeploymentVersionDelimiter)
return deploymentName
}
// GenerateDeploymentWorkflowID is a helper that generates a system accepted
// workflowID which are used in our Worker Deployment workflows
func GenerateDeploymentWorkflowID(deploymentName string) string {
return worker_versioning.WorkerDeploymentWorkflowIDPrefix + worker_versioning.WorkerDeploymentVersionDelimiter + deploymentName
}
// GenerateVersionWorkflowID is a helper that generates a system accepted
// workflowID which are used in our Worker Deployment Version workflows
func GenerateVersionWorkflowID(deploymentName string, buildID string) string {
versionString := worker_versioning.ExternalWorkerDeploymentVersionToString(&deploymentpb.WorkerDeploymentVersion{
DeploymentName: deploymentName,
BuildId: buildID,
})
return worker_versioning.WorkerDeploymentVersionWorkflowIDPrefix + worker_versioning.WorkerDeploymentVersionDelimiter + versionString
}
func DecodeWorkerDeploymentMemo(memo *commonpb.Memo) (*deploymentspb.WorkerDeploymentWorkflowMemo, error) {
if memo == nil || memo.Fields == nil {
return nil, errors.New("decoding WorkerDeploymentMemo failed: Memo or it's fields are nil")
}
var workerDeploymentWorkflowMemo deploymentspb.WorkerDeploymentWorkflowMemo
err := sdk.LenientFromPayloadProtoConverter(memo.Fields[WorkerDeploymentMemoField], &workerDeploymentWorkflowMemo)
if err != nil {
return nil, err
}
return &workerDeploymentWorkflowMemo, nil
}
func getSafeDurationConfig(ctx workflow.Context, id string, unsafeGetter func() time.Duration, defaultValue time.Duration) (time.Duration, error) {
get := func(_ workflow.Context) interface{} {
return unsafeGetter()
}
var value time.Duration
if err := workflow.MutableSideEffect(ctx, id, get, durationEq).Get(&value); err != nil {
return defaultValue, err
}
return value, nil
}
func durationEq(a, b any) bool {
return a == b
}
func isFailedPreconditionOrNotFound(err error) bool {
var failedPreconditionError *serviceerror.FailedPrecondition
var notFound *serviceerror.NotFound
return errors.As(err, &failedPreconditionError) || errors.As(err, &notFound)
}
// update updates an already existing deployment version/deployment workflow.
func updateWorkflow(
ctx context.Context,
historyClient historyservice.HistoryServiceClient,
namespaceEntry *namespace.Namespace,
workflowID string,
updateRequest *updatepb.Request,
) (*updatepb.Outcome, error) {
updateReq := &historyservice.UpdateWorkflowExecutionRequest{
NamespaceId: namespaceEntry.ID().String(),
Request: &workflowservice.UpdateWorkflowExecutionRequest{
Namespace: namespaceEntry.Name().String(),
WorkflowExecution: &commonpb.WorkflowExecution{
WorkflowId: workflowID,
},
Request: updateRequest,
WaitPolicy: &updatepb.WaitPolicy{LifecycleStage: enumspb.UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_COMPLETED},
},
}
var outcome *updatepb.Outcome
err := backoff.ThrottleRetryContext(ctx, func(ctx context.Context) error {
// historyClient retries internally on retryable rpc errors, we just have to retry on
// successful but un-completed responses.
res, err := historyClient.UpdateWorkflowExecution(ctx, updateReq)
if err != nil {
return err
}
if err := convertUpdateFailure(res.GetResponse()); err != nil {
return err
}
outcome = res.GetResponse().GetOutcome()
return nil
}, retryPolicy, isRetryableUpdateError)
return outcome, err
}
// extractApplicationErrorOrInternal extract application error from update failure preserving error type and retriability.
// If the failure is no-nil but not an application error, it returns an internal error.
func extractApplicationErrorOrInternal(failure *failurepb.Failure) error {
if failure != nil {
if af := failure.GetApplicationFailureInfo(); af != nil {
if af.GetNonRetryable() {
return temporal.NewNonRetryableApplicationError(failure.GetMessage(), af.GetType(), nil)
}
return temporal.NewApplicationError(failure.GetMessage(), af.GetType(), nil)
}
return serviceerror.NewInternal(failure.Message)
}
return nil
}
func updateWorkflowWithStart(
ctx context.Context,
historyClient historyservice.HistoryServiceClient,
namespaceEntry *namespace.Namespace,
workflowType string,
workflowID string,
memo *commonpb.Memo,
input *commonpb.Payloads,
updateRequest *updatepb.Request,
identity string,
requestID string,
) (*updatepb.Outcome, error) {
// Start workflow execution, if it hasn't already
startReq := makeStartRequest(requestID, workflowID, identity, workflowType, namespaceEntry, memo, input)
updateReq := &workflowservice.UpdateWorkflowExecutionRequest{
Namespace: namespaceEntry.Name().String(),
WorkflowExecution: &commonpb.WorkflowExecution{
WorkflowId: workflowID,
},
Request: updateRequest,
WaitPolicy: &updatepb.WaitPolicy{LifecycleStage: enumspb.UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_COMPLETED},
}
// This is an atomic operation; if one operation fails, both will.
multiOpReq := &historyservice.ExecuteMultiOperationRequest{
NamespaceId: namespaceEntry.ID().String(),
WorkflowId: workflowID,
Operations: []*historyservice.ExecuteMultiOperationRequest_Operation{
{
Operation: &historyservice.ExecuteMultiOperationRequest_Operation_StartWorkflow{
StartWorkflow: &historyservice.StartWorkflowExecutionRequest{
NamespaceId: namespaceEntry.ID().String(),
StartRequest: startReq,
},
},
},
{
Operation: &historyservice.ExecuteMultiOperationRequest_Operation_UpdateWorkflow{
UpdateWorkflow: &historyservice.UpdateWorkflowExecutionRequest{
NamespaceId: namespaceEntry.ID().String(),
Request: updateReq,
},
},
},
},
}
var outcome *updatepb.Outcome
err := backoff.ThrottleRetryContext(ctx, func(ctx context.Context) error {
// historyClient retries internally on retryable rpc errors, we just have to retry on
// successful but un-completed responses.
res, err := historyClient.ExecuteMultiOperation(ctx, multiOpReq)
if err != nil {
return err
}
// we should get exactly one of each of these
var startRes *historyservice.StartWorkflowExecutionResponse
var updateRes *workflowservice.UpdateWorkflowExecutionResponse
for _, response := range res.Responses {
if sr := response.GetStartWorkflow(); sr != nil {
startRes = sr
} else if ur := response.GetUpdateWorkflow().GetResponse(); ur != nil {
updateRes = ur
}
}
if startRes == nil {
return serviceerror.NewInternal("failed to start deployment workflow")
}
if err := convertUpdateFailure(updateRes); err != nil {
return err
}
outcome = updateRes.GetOutcome()
return nil
}, retryPolicy, isRetryableUpdateError)
return outcome, err
}
func convertUpdateFailure(updateRes *workflowservice.UpdateWorkflowExecutionResponse) error {
if updateRes == nil {
return serviceerror.NewInternal("failed to update deployment workflow")
}
if updateRes.Stage != enumspb.UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_COMPLETED {
// update not completed, try again
return errUpdateInProgress
}
if failure := updateRes.GetOutcome().GetFailure(); failure != nil {
if failure.GetApplicationFailureInfo().GetType() == errLongHistory {
// Retryable
return errWorkflowHistoryTooLong
} else if failure.GetApplicationFailureInfo().GetType() == errVersionDeleted {
// Non-retryable
return serviceerror.NewNotFoundf("Worker Deployment Version not found")
} else if failure.GetApplicationFailureInfo().GetType() == errDeploymentDeleted {
// Non-retryable
return serviceerror.NewNotFoundf("Worker Deployment not found")
} else if failure.GetApplicationFailureInfo().GetType() == errFailedPrecondition {
return serviceerror.NewFailedPrecondition(failure.GetMessage())
}
// we let caller handle other update failures
} else if updateRes.GetOutcome().GetSuccess() == nil {
return serviceerror.NewInternal("outcome missing success and failure")
}
return nil
}
func isRetryableQueryError(err error) bool {
var internalErr *serviceerror.Internal
return api.IsRetryableError(err) && !errors.As(err, &internalErr)
}
func isRetryableUpdateError(err error) bool {
if errors.Is(err, errUpdateInProgress) || errors.Is(err, errWorkflowHistoryTooLong) ||
err.Error() == consts.ErrWorkflowClosing.Error() || err.Error() == update2.AbortedByServerErr.Error() {
return true
}
var errResourceExhausted *serviceerror.ResourceExhausted
if errors.As(err, &errResourceExhausted) &&
(errResourceExhausted.Cause == enumspb.RESOURCE_EXHAUSTED_CAUSE_CONCURRENT_LIMIT ||
errResourceExhausted.Cause == enumspb.RESOURCE_EXHAUSTED_CAUSE_BUSY_WORKFLOW) {
// We're hitting the max concurrent update limit for the wf. Retrying will eventually succeed.
return true
}
var errWfNotReady *serviceerror.WorkflowNotReady
if errors.As(err, &errWfNotReady) {
// Update edge cases, can retry.
return true
}
// All updates that are admitted as the workflow is closing due to CaN are considered retryable.
// The ErrWorkflowClosing and ResourceExhausted could be nested.
var errMultiOps *serviceerror.MultiOperationExecution
if errors.As(err, &errMultiOps) {
for _, e := range errMultiOps.OperationErrors() {
if e == nil {
continue
}
if errors.As(e, &errResourceExhausted) &&
(errResourceExhausted.Cause == enumspb.RESOURCE_EXHAUSTED_CAUSE_CONCURRENT_LIMIT ||
errResourceExhausted.Cause == enumspb.RESOURCE_EXHAUSTED_CAUSE_BUSY_WORKFLOW) {
// We're hitting the max concurrent update limit for the wf. Retrying will eventually succeed.
return true
}
if e.Error() == consts.ErrWorkflowClosing.Error() || e.Error() == update2.AbortedByServerErr.Error() {
return true
}
}
}
return false
}
func makeStartRequest(
requestID, workflowID, identity, workflowType string,
namespaceEntry *namespace.Namespace,
memo *commonpb.Memo,
input *commonpb.Payloads,
) *workflowservice.StartWorkflowExecutionRequest {
return &workflowservice.StartWorkflowExecutionRequest{
RequestId: requestID,
Namespace: namespaceEntry.Name().String(),
WorkflowId: workflowID,
WorkflowType: &commonpb.WorkflowType{Name: workflowType},
TaskQueue: &taskqueuepb.TaskQueue{Name: primitives.PerNSWorkerTaskQueue},
Input: input,
WorkflowIdReusePolicy: enumspb.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE,
WorkflowIdConflictPolicy: enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING,
SearchAttributes: buildSearchAttributes(),
Memo: memo,
Identity: identity,
}
}
func buildSearchAttributes() *commonpb.SearchAttributes {
sa := &commonpb.SearchAttributes{}
searchattribute.AddSearchAttribute(&sa, sadefs.TemporalNamespaceDivision, payload.EncodeString(WorkerDeploymentNamespaceDivision))
return sa
}