mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-30 14:56:19 +00:00
## What changed? Add `CONTINUE_AS_NEW_VERSIONING_BEHAVIOR_USE_RAMPING_VERSION` as a `ContinueAsNewInitialVersioningBehavior` in `InheritedAutoUpgradeInfo` and `VersioningInfo`, which if set, forces the first task of an AutoUpgrade workflow to go to the Ramping Version of its task queue instead of basing the Current/Ramping version selection on ramp percentage and workflow id. If there is no Ramping Version at the time of workflow dispatch, the workflow will use the Current Version. This initial behavior comes from the continue-as-new command, and when history sends workflow tasks to matching, is converted into an internally defined `UseRampingVersionInitialTask bool` such that _only_ the first workflow task of an AutoUpgrade workflow will change its Target Version selection. Retries of this workflow will start with the same behavior, but child workflows and future continue-as-new workflows initiated by this workflow will not inherit the initial behavior. ## Why? To enable more fine grained control of upgrade-on-continue-as-new upgrade stages before doing percentage based ramp. To use this, users should set their promotion candidate version to the ramping version with a ramp percentage of zero, and manually signal a certain cohort of workflows to upgrade-on-continue-as-new with this option. ## How did you test it? - [x] built - [ ] run locally and tested manually - [x] covered by existing tests - [x] added new unit test(s) - [x] added new functional test(s) --- > [!NOTE] > **Medium Risk** > Changes workflow-task routing for AutoUpgrade executions by adding a new directive flag that can bypass ramp-percentage hashing for the initial task. Moderate risk because it affects matching/history version selection and ContinueAsNew/retry inheritance semantics. > > **Overview** > Adds a new `use_ramping_version` field to `TaskVersionDirective` and threads it through history → matching so an AutoUpgrade workflow can *force its initial workflow task* to route to the task queue’s ramping deployment version (falling back to current when no ramping version exists), bypassing the normal ramp-percentage/workflow-id hash. > > History now persists and evaluates the ContinueAsNew-requested initial behavior via `MutableState.GetShouldUseRampingVersion()`, ensures it applies only to the first workflow task, propagates it across retries, and explicitly does **not** propagate it to child workflows or subsequent ContinueAsNew hops. Updates tests (unit + `versioning_3_test`) and bumps `go.temporal.io/api` to pick up the new proto/enum support. > > <sup>Reviewed by [Cursor Bugbot]() for commit 923602f0c2432d5aabb7d4d45580c2c42834f52e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
446 lines
16 KiB
Go
446 lines
16 KiB
Go
package queryworkflow
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
commonpb "go.temporal.io/api/common/v1"
|
|
enumspb "go.temporal.io/api/enums/v1"
|
|
querypb "go.temporal.io/api/query/v1"
|
|
"go.temporal.io/api/serviceerror"
|
|
"go.temporal.io/api/workflowservice/v1"
|
|
"go.temporal.io/server/api/historyservice/v1"
|
|
"go.temporal.io/server/api/matchingservice/v1"
|
|
"go.temporal.io/server/common"
|
|
"go.temporal.io/server/common/definition"
|
|
"go.temporal.io/server/common/locks"
|
|
"go.temporal.io/server/common/log/tag"
|
|
"go.temporal.io/server/common/metrics"
|
|
"go.temporal.io/server/common/namespace"
|
|
"go.temporal.io/server/common/primitives/timestamp"
|
|
"go.temporal.io/server/common/rpc"
|
|
"go.temporal.io/server/common/worker_versioning"
|
|
"go.temporal.io/server/service/history/api"
|
|
"go.temporal.io/server/service/history/api/resetstickytaskqueue"
|
|
"go.temporal.io/server/service/history/consts"
|
|
historyi "go.temporal.io/server/service/history/interfaces"
|
|
"go.temporal.io/server/service/history/workflow"
|
|
)
|
|
|
|
// Fail query fast if workflow task keeps failing (attempt >= 3).
|
|
const failQueryWorkflowTaskAttemptCount = 3
|
|
|
|
func Invoke(
|
|
ctx context.Context,
|
|
request *historyservice.QueryWorkflowRequest,
|
|
shardContext historyi.ShardContext,
|
|
workflowConsistencyChecker api.WorkflowConsistencyChecker,
|
|
rawMatchingClient matchingservice.MatchingServiceClient,
|
|
matchingClient matchingservice.MatchingServiceClient,
|
|
) (_ *historyservice.QueryWorkflowResponse, retError error) {
|
|
scope := shardContext.GetMetricsHandler().WithTags(metrics.OperationTag(metrics.HistoryQueryWorkflowScope))
|
|
namespaceID := namespace.ID(request.GetNamespaceId())
|
|
err := api.ValidateNamespaceUUID(namespaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nsEntry, err := shardContext.GetNamespaceRegistry().GetNamespaceByID(namespaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(request.Request.Execution.RunId) == 0 {
|
|
request.Request.Execution.RunId, err = workflowConsistencyChecker.GetCurrentWorkflowRunID(
|
|
ctx,
|
|
request.NamespaceId,
|
|
request.Request.Execution.WorkflowId,
|
|
locks.PriorityHigh,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
workflowKey := definition.NewWorkflowKey(
|
|
request.NamespaceId,
|
|
request.Request.Execution.WorkflowId,
|
|
request.Request.Execution.RunId,
|
|
)
|
|
workflowLease, err := workflowConsistencyChecker.GetWorkflowLease(
|
|
ctx,
|
|
nil,
|
|
workflowKey,
|
|
locks.PriorityHigh,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
// Do not clear mutable state when query failed. Clear mutable state will fail other buffered pending queries.
|
|
// Note: QueryWorkflow should not alter mutable state, so it is safe to ignore error and not clear ms.
|
|
workflowLease.GetReleaseFn()(nil)
|
|
}()
|
|
|
|
// Context metadata is automatically set during mutable state transaction close for operations that mutate state.
|
|
// Since QueryWorkflow is readonly and never closes the transaction, we explicitly call SetContextMetadata
|
|
// here to ensure successful requests have workflow metadata populated in the context.
|
|
workflowLease.GetMutableState().SetContextMetadata(ctx)
|
|
|
|
req := request.GetRequest()
|
|
_, mutableStateStatus := workflowLease.GetMutableState().GetWorkflowStateStatus()
|
|
scope = scope.WithTags(metrics.StringTag("workflow_status", mutableStateStatus.String()))
|
|
if mutableStateStatus != enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING && req.QueryRejectCondition != enumspb.QUERY_REJECT_CONDITION_NONE {
|
|
notOpenReject := req.GetQueryRejectCondition() == enumspb.QUERY_REJECT_CONDITION_NOT_OPEN
|
|
notCompletedCleanlyReject := req.GetQueryRejectCondition() == enumspb.QUERY_REJECT_CONDITION_NOT_COMPLETED_CLEANLY && mutableStateStatus != enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED
|
|
if notOpenReject || notCompletedCleanlyReject {
|
|
return &historyservice.QueryWorkflowResponse{
|
|
Response: &workflowservice.QueryWorkflowResponse{
|
|
QueryRejected: &querypb.QueryRejected{
|
|
Status: mutableStateStatus,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
}
|
|
// If workflow is paused, return query rejected with PAUSED status.
|
|
if mutableStateStatus == enumspb.WORKFLOW_EXECUTION_STATUS_PAUSED {
|
|
return &historyservice.QueryWorkflowResponse{
|
|
Response: &workflowservice.QueryWorkflowResponse{
|
|
QueryRejected: &querypb.QueryRejected{
|
|
Status: enumspb.WORKFLOW_EXECUTION_STATUS_PAUSED,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
mutableState := workflowLease.GetMutableState()
|
|
if !mutableState.IsWorkflowExecutionRunning() && !mutableState.HasCompletedAnyWorkflowTask() {
|
|
// Workflow was closed before WorkflowTaskStarted event. In this case query will fail.
|
|
return nil, consts.ErrWorkflowClosedBeforeWorkflowTaskStarted
|
|
}
|
|
|
|
if !mutableState.HadOrHasWorkflowTask() {
|
|
// Workflow has no workflow task scheduled.
|
|
// This can be due to firstWorkflowTaskBackoff (cron / retry)
|
|
// In this case, check if query can wait.
|
|
queryWillTimeout, err := queryWillTimeoutsBeforeFirstWorkflowTaskStart(ctx, mutableState)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if queryWillTimeout {
|
|
return nil, consts.ErrWorkflowTaskNotScheduled
|
|
}
|
|
}
|
|
|
|
if mutableState.GetExecutionInfo().WorkflowTaskAttempt >= failQueryWorkflowTaskAttemptCount {
|
|
// while workflow task is failing, the query to that workflow will also fail. Failing fast here to prevent wasting
|
|
// resources to load history for a query that will fail.
|
|
shardContext.GetLogger().Info("Fail query fast due to WorkflowTask in failed state.",
|
|
tag.WorkflowNamespace(request.Request.Namespace),
|
|
tag.WorkflowNamespaceID(workflowKey.NamespaceID),
|
|
tag.WorkflowID(workflowKey.WorkflowID),
|
|
tag.WorkflowRunID(workflowKey.RunID))
|
|
return nil, serviceerror.NewWorkflowNotReady("Unable to query workflow due to Workflow Task in failed state.")
|
|
}
|
|
|
|
priority := mutableState.GetExecutionInfo().Priority
|
|
|
|
// There are two ways in which queries get dispatched to workflow worker. First, queries can be dispatched on workflow tasks.
|
|
// These workflow tasks potentially contain new events and queries. The events are treated as coming before the query in time.
|
|
// The second way in which queries are dispatched to workflow worker is directly through matching; in this approach queries can be
|
|
// dispatched to workflow worker immediately even if there are outstanding events that came before the query. The following logic
|
|
// is used to determine if a query can be safely dispatched directly through matching or must be dispatched on a workflow task.
|
|
//
|
|
// Precondition to dispatch query directly to matching is workflow has at least one WorkflowTaskStarted event. Otherwise, sdk would panic.
|
|
if mutableState.HasCompletedAnyWorkflowTask() {
|
|
// There are three cases in which a query can be dispatched directly through matching safely, without violating strong consistency level:
|
|
// 1. the namespace is not active, in this case history is immutable so a query dispatched at any time is consistent
|
|
// 2. the workflow is not running, whenever a workflow is not running dispatching query directly is consistent
|
|
// 3. if there is no pending or started workflow tasks it means no events came before query arrived, so its safe to dispatch directly
|
|
safeToDispatchDirectly := !nsEntry.ActiveInCluster(shardContext.GetClusterMetadata().GetCurrentClusterName()) ||
|
|
!mutableState.IsWorkflowExecutionRunning() ||
|
|
(!mutableState.HasPendingWorkflowTask() && !mutableState.HasStartedWorkflowTask())
|
|
if safeToDispatchDirectly {
|
|
msResp, err := api.MutableStateToGetResponse(mutableState)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
workflowLease.GetReleaseFn()(nil) // release the lock - no access to mutable state beyond this point!
|
|
req.Execution.RunId = msResp.Execution.RunId
|
|
return queryDirectlyThroughMatching(
|
|
ctx,
|
|
msResp,
|
|
nsEntry,
|
|
request.GetNamespaceId(),
|
|
req,
|
|
shardContext,
|
|
workflowConsistencyChecker,
|
|
rawMatchingClient,
|
|
matchingClient,
|
|
scope,
|
|
priority,
|
|
)
|
|
}
|
|
}
|
|
|
|
// If we get here it means query could not be dispatched through matching directly, so it must block
|
|
// until either a result has been obtained on a workflow task response or until it is safe to dispatch directly through matching.
|
|
startTime := time.Now().UTC()
|
|
defer func() { metrics.WorkflowTaskQueryLatency.With(scope).Record(time.Since(startTime)) }()
|
|
|
|
queryReg := mutableState.GetQueryRegistry()
|
|
if len(queryReg.GetBufferedIDs()) >= shardContext.GetConfig().MaxBufferedQueryCount() {
|
|
metrics.QueryBufferExceededCount.With(scope).Record(1)
|
|
return nil, consts.ErrConsistentQueryBufferExceeded
|
|
}
|
|
queryID, completionCh := queryReg.BufferQuery(req.GetQuery())
|
|
defer queryReg.RemoveQuery(queryID)
|
|
|
|
msResp, err := api.MutableStateToGetResponse(mutableState)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
workflowLease.GetReleaseFn()(nil) // release the lock - no access to mutable state beyond this point!
|
|
select {
|
|
case <-completionCh:
|
|
completionState, err := queryReg.GetCompletionState(queryID)
|
|
if err != nil {
|
|
metrics.QueryRegistryInvalidStateCount.With(scope).Record(1)
|
|
return nil, err
|
|
}
|
|
switch completionState.Type {
|
|
case workflow.QueryCompletionTypeSucceeded:
|
|
result := completionState.Result
|
|
switch result.GetResultType() {
|
|
case enumspb.QUERY_RESULT_TYPE_ANSWERED:
|
|
emitWorkflowQueryMetrics(
|
|
scope,
|
|
nsEntry,
|
|
msResp,
|
|
req.GetQuery().GetQueryType(),
|
|
nil,
|
|
)
|
|
return &historyservice.QueryWorkflowResponse{
|
|
Response: &workflowservice.QueryWorkflowResponse{
|
|
QueryResult: result.GetAnswer(),
|
|
},
|
|
}, nil
|
|
case enumspb.QUERY_RESULT_TYPE_FAILED:
|
|
err := serviceerror.NewQueryFailedWithFailure(result.GetErrorMessage(), result.GetFailure())
|
|
emitWorkflowQueryMetrics(
|
|
scope,
|
|
nsEntry,
|
|
msResp,
|
|
req.GetQuery().GetQueryType(),
|
|
err,
|
|
)
|
|
return nil, err
|
|
default:
|
|
metrics.QueryRegistryInvalidStateCount.With(scope).Record(1)
|
|
return nil, consts.ErrQueryEnteredInvalidState
|
|
}
|
|
case workflow.QueryCompletionTypeUnblocked:
|
|
msResp, err := api.GetMutableState(ctx, shardContext, workflowKey, workflowConsistencyChecker)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Execution.RunId = msResp.Execution.RunId
|
|
return queryDirectlyThroughMatching(
|
|
ctx,
|
|
msResp,
|
|
nsEntry,
|
|
request.GetNamespaceId(),
|
|
req,
|
|
shardContext,
|
|
workflowConsistencyChecker,
|
|
rawMatchingClient,
|
|
matchingClient,
|
|
scope,
|
|
priority,
|
|
)
|
|
case workflow.QueryCompletionTypeFailed:
|
|
err = completionState.Err
|
|
emitWorkflowQueryMetrics(
|
|
scope,
|
|
nsEntry,
|
|
msResp,
|
|
req.GetQuery().GetQueryType(),
|
|
err,
|
|
)
|
|
return nil, err
|
|
default:
|
|
metrics.QueryRegistryInvalidStateCount.With(scope).Record(1)
|
|
return nil, consts.ErrQueryEnteredInvalidState
|
|
}
|
|
case <-ctx.Done():
|
|
emitWorkflowQueryMetrics(
|
|
scope,
|
|
nsEntry,
|
|
msResp,
|
|
req.GetQuery().GetQueryType(),
|
|
ctx.Err(),
|
|
)
|
|
metrics.ConsistentQueryTimeoutCount.With(scope).Record(1)
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
func queryWillTimeoutsBeforeFirstWorkflowTaskStart(
|
|
ctx context.Context, mutableState historyi.MutableState,
|
|
) (bool, error) {
|
|
startEvent, err := mutableState.GetStartEvent(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
startAttr := startEvent.GetWorkflowExecutionStartedEventAttributes()
|
|
workflowTaskBackoffDuration := timestamp.DurationValue(startAttr.GetFirstWorkflowTaskBackoff())
|
|
|
|
workflowStart := mutableState.GetExecutionState().StartTime.AsTime().UTC()
|
|
workflowTaskStart := workflowStart.Add(workflowTaskBackoffDuration)
|
|
|
|
deadline, ok := ctx.Deadline()
|
|
if !ok {
|
|
return true, nil
|
|
}
|
|
deadline = deadline.UTC()
|
|
if workflowTaskStart.After(deadline) {
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func queryDirectlyThroughMatching(
|
|
ctx context.Context,
|
|
msResp *historyservice.GetMutableStateResponse,
|
|
nsEntry *namespace.Namespace,
|
|
namespaceID string,
|
|
queryRequest *workflowservice.QueryWorkflowRequest,
|
|
shard historyi.ShardContext,
|
|
workflowConsistencyChecker api.WorkflowConsistencyChecker,
|
|
rawMatchingClient matchingservice.MatchingServiceClient,
|
|
matchingClient matchingservice.MatchingServiceClient,
|
|
metricsHandler metrics.Handler,
|
|
priority *commonpb.Priority,
|
|
) (resp *historyservice.QueryWorkflowResponse, retError error) {
|
|
|
|
startTime := time.Now().UTC()
|
|
defer func() {
|
|
metrics.DirectQueryDispatchLatency.With(metricsHandler).Record(time.Since(startTime))
|
|
emitWorkflowQueryMetrics(
|
|
metricsHandler,
|
|
nsEntry,
|
|
msResp,
|
|
queryRequest.GetQuery().GetQueryType(),
|
|
retError,
|
|
)
|
|
}()
|
|
|
|
directive := worker_versioning.MakeDirectiveForWorkflowTask(
|
|
msResp.GetInheritedBuildId(),
|
|
msResp.GetAssignedBuildId(),
|
|
msResp.GetMostRecentWorkerVersionStamp(),
|
|
msResp.GetPreviousStartedEventId() != common.EmptyEventID,
|
|
workflow.GetEffectiveVersioningBehavior(msResp.GetVersioningInfo()),
|
|
workflow.GetEffectiveDeployment(msResp.GetVersioningInfo()),
|
|
msResp.GetVersioningInfo().GetRevisionNumber(),
|
|
// False for query, because it is not an initial workflow task.
|
|
false,
|
|
)
|
|
|
|
if msResp.GetIsStickyTaskQueueEnabled() &&
|
|
len(msResp.GetStickyTaskQueue().GetName()) != 0 &&
|
|
shard.GetConfig().EnableStickyQuery(queryRequest.GetNamespace()) {
|
|
|
|
stickyMatchingRequest := &matchingservice.QueryWorkflowRequest{
|
|
NamespaceId: namespaceID,
|
|
QueryRequest: queryRequest,
|
|
TaskQueue: msResp.GetStickyTaskQueue(),
|
|
VersionDirective: directive,
|
|
Priority: priority,
|
|
}
|
|
|
|
// using a clean new context in case customer provide a context which has
|
|
// a really short deadline, causing we clear the stickiness
|
|
stickyContext, cancel := rpc.ResetContextTimeout(ctx, timestamp.DurationValue(msResp.GetStickyTaskQueueScheduleToStartTimeout()))
|
|
stickyStartTime := time.Now().UTC()
|
|
matchingResp, err := rawMatchingClient.QueryWorkflow(stickyContext, stickyMatchingRequest)
|
|
metrics.DirectQueryDispatchStickyLatency.With(metricsHandler).Record(time.Since(stickyStartTime))
|
|
cancel()
|
|
if err == nil {
|
|
metrics.DirectQueryDispatchStickySuccessCount.With(metricsHandler).Record(1)
|
|
return &historyservice.QueryWorkflowResponse{
|
|
Response: &workflowservice.QueryWorkflowResponse{
|
|
QueryResult: matchingResp.GetQueryResult(),
|
|
QueryRejected: matchingResp.GetQueryRejected(),
|
|
}}, nil
|
|
}
|
|
if !common.IsContextDeadlineExceededErr(err) && !common.IsContextCanceledErr(err) && !common.IsStickyWorkerUnavailable(err) {
|
|
return nil, err
|
|
}
|
|
if msResp.GetWorkflowStatus() == enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
|
|
resetContext, cancel := rpc.ResetContextTimeout(ctx, 5*time.Second)
|
|
clearStickinessStartTime := time.Now().UTC()
|
|
_, err := resetstickytaskqueue.Invoke(resetContext, &historyservice.ResetStickyTaskQueueRequest{
|
|
NamespaceId: namespaceID,
|
|
Execution: queryRequest.GetExecution(),
|
|
}, shard, workflowConsistencyChecker)
|
|
metrics.DirectQueryDispatchClearStickinessLatency.With(metricsHandler).Record(time.Since(clearStickinessStartTime))
|
|
cancel()
|
|
if err != nil && err != consts.ErrWorkflowCompleted {
|
|
return nil, err
|
|
}
|
|
metrics.DirectQueryDispatchClearStickinessSuccessCount.With(metricsHandler).Record(1)
|
|
}
|
|
}
|
|
|
|
if err := common.IsValidContext(ctx); err != nil {
|
|
metrics.DirectQueryDispatchTimeoutBeforeNonStickyCount.With(metricsHandler).Record(1)
|
|
return nil, err
|
|
}
|
|
|
|
nonStickyMatchingRequest := &matchingservice.QueryWorkflowRequest{
|
|
NamespaceId: namespaceID,
|
|
QueryRequest: queryRequest,
|
|
TaskQueue: msResp.TaskQueue,
|
|
VersionDirective: directive,
|
|
Priority: priority,
|
|
}
|
|
|
|
nonStickyStartTime := time.Now().UTC()
|
|
matchingResp, err := matchingClient.QueryWorkflow(ctx, nonStickyMatchingRequest)
|
|
metrics.DirectQueryDispatchNonStickyLatency.With(metricsHandler).Record(time.Since(nonStickyStartTime))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
metrics.DirectQueryDispatchNonStickySuccessCount.With(metricsHandler).Record(1)
|
|
return &historyservice.QueryWorkflowResponse{
|
|
Response: &workflowservice.QueryWorkflowResponse{
|
|
QueryResult: matchingResp.GetQueryResult(),
|
|
QueryRejected: matchingResp.GetQueryRejected(),
|
|
}}, err
|
|
}
|
|
|
|
func emitWorkflowQueryMetrics(
|
|
metricsHandler metrics.Handler,
|
|
nsEntry *namespace.Namespace,
|
|
msResp *historyservice.GetMutableStateResponse,
|
|
queryType string,
|
|
err error,
|
|
) {
|
|
commonTags := []metrics.Tag{
|
|
metrics.OperationTag(metrics.HistoryQueryWorkflowScope),
|
|
metrics.NamespaceTag(nsEntry.Name().String()),
|
|
metrics.VersioningBehaviorTag(workflow.GetEffectiveVersioningBehavior(msResp.GetVersioningInfo())),
|
|
metrics.WorkflowStatusTag(msResp.GetWorkflowStatus().String()),
|
|
metrics.QueryTypeTag(queryType),
|
|
}
|
|
|
|
if err == nil {
|
|
metrics.WorkflowQuerySuccessCount.With(metricsHandler).Record(1, commonTags...)
|
|
} else if common.IsContextDeadlineExceededErr(err) {
|
|
metrics.WorkflowQueryTimeoutCount.With(metricsHandler).Record(1, commonTags...)
|
|
} else {
|
|
metrics.WorkflowQueryFailureCount.With(metricsHandler).Record(1, commonTags...)
|
|
}
|
|
}
|