mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
## What changed? - Matching server propagates partition counts in ephemeral data. - Matching server validates partition counts sent by client, and rejects if they are too far. ## Why? Next part of dynamic partitioning. ## How did you test it? - [ ] built - [ ] run locally and tested manually - [ ] covered by existing tests - [x] added new unit test(s) - [x] added new functional test(s) - covered by future integration tests ## Potential risks No change in behavior at this point, partition scale info is always nil, so nothing will be rejected. The server will start sending grpc trailers though.
2365 lines
88 KiB
Go
2365 lines
88 KiB
Go
package matching
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"math"
|
|
"math/bits"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nexus-rpc/sdk-go/nexus"
|
|
commonpb "go.temporal.io/api/common/v1"
|
|
deploymentpb "go.temporal.io/api/deployment/v1"
|
|
enumspb "go.temporal.io/api/enums/v1"
|
|
"go.temporal.io/api/serviceerror"
|
|
taskqueuepb "go.temporal.io/api/taskqueue/v1"
|
|
"go.temporal.io/api/workflowservice/v1"
|
|
deploymentspb "go.temporal.io/server/api/deployment/v1"
|
|
enumsspb "go.temporal.io/server/api/enums/v1"
|
|
"go.temporal.io/server/api/matchingservice/v1"
|
|
persistencespb "go.temporal.io/server/api/persistence/v1"
|
|
taskqueuespb "go.temporal.io/server/api/taskqueue/v1"
|
|
"go.temporal.io/server/client/matching"
|
|
"go.temporal.io/server/common/backoff"
|
|
"go.temporal.io/server/common/cache"
|
|
"go.temporal.io/server/common/dynamicconfig"
|
|
"go.temporal.io/server/common/future"
|
|
"go.temporal.io/server/common/goro"
|
|
"go.temporal.io/server/common/headers"
|
|
"go.temporal.io/server/common/log"
|
|
"go.temporal.io/server/common/log/tag"
|
|
"go.temporal.io/server/common/metrics"
|
|
"go.temporal.io/server/common/namespace"
|
|
"go.temporal.io/server/common/quotas"
|
|
serviceerrors "go.temporal.io/server/common/serviceerror"
|
|
"go.temporal.io/server/common/softassert"
|
|
"go.temporal.io/server/common/taskqueue"
|
|
"go.temporal.io/server/common/tqid"
|
|
"go.temporal.io/server/common/util"
|
|
"go.temporal.io/server/common/worker_versioning"
|
|
"go.temporal.io/server/service/matching/hooks"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
)
|
|
|
|
var (
|
|
errDefaultQueueNotInit = serviceerror.NewInternal("defaultQueue is not initializaed")
|
|
errPartitionInvalid = serviceerrors.NewStalePartitionCounts("partition is invalid")
|
|
errPartitionDraining = serviceerrors.NewStalePartitionCounts("partition is draining")
|
|
errPartitionCountsStale = serviceerrors.NewStalePartitionCounts("counts are too far off")
|
|
)
|
|
|
|
const (
|
|
defaultTaskDispatchRPS = 100000.0
|
|
defaultTaskDispatchRPSTTL = time.Minute
|
|
)
|
|
|
|
type (
|
|
// Represents a single partition of a (user-level) Task Queue in memory state. Under the hood, each Task Queue
|
|
// partition is made of one or more DB-level queues. There is always a default DB queue. For
|
|
// versioned TQs, there is an additional DB queue for each Build ID.
|
|
// Currently, the liveness of a partition manager is tied to its default queue. More specifically:
|
|
// - If the default queue dies, all the other queues are also stopped and the whole partition is unloaded from
|
|
// matching engine.
|
|
// - Any requests sent to the partition keeps the default queue alive, even if it's served by a versioned queue.
|
|
// - If a versioned queue dies, we only unload that specific queue from the partition.
|
|
// - This behavior is subject to optimizations in the future: for versioned queues, keeping the default queue
|
|
// loaded all the time may be suboptimal.
|
|
taskQueuePartitionManagerImpl struct {
|
|
engine *matchingEngineImpl
|
|
partition tqid.Partition
|
|
ns *namespace.Namespace
|
|
config *taskQueueConfig
|
|
// used for non-sticky versioned queues (one for each version)
|
|
versionedQueues map[PhysicalTaskQueueVersion]physicalTaskQueueManager
|
|
versionedQueuesLock sync.RWMutex // locks mutation of versionedQueues
|
|
userDataManager userDataManager
|
|
logger log.Logger
|
|
throttledLogger log.ThrottledLogger
|
|
matchingClient matchingservice.MatchingServiceClient
|
|
metricsHandler metrics.Handler // namespace/taskqueue tagged metric scope
|
|
// TODO(stephanos): move cache out of partition manager
|
|
cache cache.Cache // non-nil for root-partition
|
|
|
|
taskHooks []hooks.TaskHook
|
|
|
|
goroGroup goro.Group
|
|
|
|
autoEnableRateLimiter quotas.RateLimiter
|
|
fairnessState enumsspb.FairnessState // Set once on initialization and read only after
|
|
defaultQueueFuture *future.FutureImpl[physicalTaskQueueManager]
|
|
initCtx context.Context
|
|
initCancel func()
|
|
|
|
cancelNewMatcherSub func()
|
|
cancelFairnessSub func()
|
|
cancelAutoEnableSub func()
|
|
|
|
// rateLimitManager is used to manage the rate limit for task queues.
|
|
rateLimitManager *rateLimitManager
|
|
|
|
// loadTime tracks when this partition manager was started, used to prevent
|
|
// false positives in the no-recent-poller metric for newly loaded queues
|
|
loadTime time.Time
|
|
}
|
|
)
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) PutCache(key any, value any) {
|
|
pm.cache.Put(key, value)
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) GetCache(key any) any {
|
|
return pm.cache.Get(key)
|
|
}
|
|
|
|
var _ taskQueuePartitionManager = (*taskQueuePartitionManagerImpl)(nil)
|
|
|
|
func newTaskQueuePartitionManager(
|
|
e *matchingEngineImpl,
|
|
ns *namespace.Namespace,
|
|
partition tqid.Partition,
|
|
tqConfig *taskQueueConfig,
|
|
logger log.Logger,
|
|
throttledLogger log.Logger,
|
|
metricsHandler metrics.Handler,
|
|
userDataManager userDataManager,
|
|
) (*taskQueuePartitionManagerImpl, error) {
|
|
rateLimitManager := newRateLimitManager(
|
|
userDataManager,
|
|
tqConfig,
|
|
partition.TaskQueue().TaskType())
|
|
|
|
var taskHooks []hooks.TaskHook
|
|
for _, hookFactory := range e.taskHookFactories {
|
|
taskHook := hookFactory.Create(&hooks.TaskHookFactoryCreateDetails{
|
|
Namespace: ns,
|
|
Partition: partition,
|
|
})
|
|
if taskHook != nil {
|
|
taskHooks = append(taskHooks, taskHook)
|
|
}
|
|
}
|
|
|
|
pm := &taskQueuePartitionManagerImpl{
|
|
engine: e,
|
|
partition: partition,
|
|
ns: ns,
|
|
config: tqConfig,
|
|
logger: logger,
|
|
throttledLogger: throttledLogger,
|
|
matchingClient: e.matchingRawClient,
|
|
metricsHandler: metricsHandler,
|
|
versionedQueues: make(map[PhysicalTaskQueueVersion]physicalTaskQueueManager),
|
|
userDataManager: userDataManager,
|
|
rateLimitManager: rateLimitManager,
|
|
defaultQueueFuture: future.NewFuture[physicalTaskQueueManager](),
|
|
autoEnableRateLimiter: quotas.NewRateLimiter(1.0/60, 1),
|
|
taskHooks: taskHooks,
|
|
}
|
|
pm.initCtx, pm.initCancel = context.WithCancel(context.Background())
|
|
|
|
if pm.partition.IsRoot() {
|
|
pm.cache = cache.New(10000, &cache.Options{
|
|
TTL: max(1, tqConfig.TaskQueueInfoByBuildIdTTL())}, // ensure TTL is never zero (which would disable TTL)
|
|
)
|
|
}
|
|
|
|
return pm, nil
|
|
}
|
|
|
|
// computeEffectiveConfig determines the effective NewMatcher and EnableFairness config values
|
|
// based on fairnessState, autoEnable, and the base dynamic config values.
|
|
func (pm *taskQueuePartitionManagerImpl) computeEffectiveConfig(autoEnable, fairness, newMatcher bool) (effectiveNewMatcher, effectiveEnableFairness bool) {
|
|
effectiveEnableFairness = fairness && pm.partition.SupportsFairness()
|
|
effectiveNewMatcher = newMatcher || fairness
|
|
if !autoEnable {
|
|
return
|
|
}
|
|
|
|
switch pm.fairnessState {
|
|
case enumsspb.FAIRNESS_STATE_UNSPECIFIED:
|
|
// use values from config
|
|
case enumsspb.FAIRNESS_STATE_V0:
|
|
effectiveNewMatcher = false
|
|
effectiveEnableFairness = false
|
|
case enumsspb.FAIRNESS_STATE_V1:
|
|
effectiveNewMatcher = true
|
|
effectiveEnableFairness = false
|
|
case enumsspb.FAIRNESS_STATE_V2:
|
|
effectiveNewMatcher = true
|
|
effectiveEnableFairness = pm.partition.SupportsFairness()
|
|
default:
|
|
pm.logger.Error("unknown fairnessState in user data")
|
|
}
|
|
return
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) initialize() (retErr error) {
|
|
defer pm.initCancel()
|
|
defer func() { pm.defaultQueueFuture.SetIfNotReady(nil, retErr) }()
|
|
|
|
err := pm.userDataManager.WaitUntilInitialized(pm.initCtx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, _, err := pm.getPerTypeUserData()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pm.fairnessState = data.GetFairnessState()
|
|
changeKey := pm.partition.GradualChangeKey()
|
|
|
|
var autoEnable, fairness, newMatcher bool
|
|
autoEnable, pm.cancelAutoEnableSub = pm.config.AutoEnableV2Sub(pm.autoEnableChanged)
|
|
|
|
unloadOnBaseConfigChange := func(bool) {
|
|
if pm.fairnessState == enumsspb.FAIRNESS_STATE_UNSPECIFIED || !pm.config.AutoEnableV2() {
|
|
pm.unloadFromEngine(unloadCauseConfigChange)
|
|
}
|
|
}
|
|
|
|
newMatcher, pm.cancelNewMatcherSub = dynamicconfig.SubscribeGradualChange(
|
|
pm.config.NewMatcherSub, changeKey, unloadOnBaseConfigChange, pm.engine.timeSource)
|
|
fairness, pm.cancelFairnessSub = dynamicconfig.SubscribeGradualChange(
|
|
pm.config.EnableFairnessSub, changeKey, unloadOnBaseConfigChange, pm.engine.timeSource)
|
|
|
|
// Determine initial config values
|
|
pm.config.NewMatcher, pm.config.EnableFairness = pm.computeEffectiveConfig(autoEnable, fairness, newMatcher)
|
|
|
|
defaultQ, err := newPhysicalTaskQueueManager(pm, UnversionedQueueKey(pm.partition))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pm.defaultQueueFuture.Set(defaultQ, nil)
|
|
defaultQ.Start()
|
|
pm.goroGroup.Go(pm.updateEphemeralData)
|
|
pm.goroGroup.Go(pm.emitLogicalBacklogMetrics)
|
|
|
|
// Whenever a root partition is loaded, we need to force all child partitions to load.
|
|
// If there is a backlog of tasks on any child partitions, force loading will ensure
|
|
// that they can forward their tasks the poller which caused the root partition to be
|
|
// loaded. We're in a separate goroutine in initialize() so we can do it here.
|
|
if defaultQ.WaitUntilInitialized(pm.initCtx) == nil {
|
|
pm.ForceLoadAllChildPartitions()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) defaultQueue() physicalTaskQueueManager {
|
|
queue, err := pm.defaultQueueFuture.GetIfReady()
|
|
if err != nil {
|
|
softassert.Fail(pm.logger, "defaultQueue used but not initialized or initialization failed", tag.Error(err))
|
|
}
|
|
return queue
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) Start() {
|
|
pm.loadTime = time.Now()
|
|
pm.engine.updateTaskQueuePartitionGauge(pm.Namespace(), pm.partition, 1)
|
|
pm.userDataManager.Start()
|
|
for _, hook := range pm.taskHooks {
|
|
hook.Start()
|
|
}
|
|
|
|
//nolint:errcheck
|
|
go pm.initialize()
|
|
}
|
|
|
|
// Stop does not unload the partition from matching engine. It is intended to be called by matching engine when
|
|
// unloading the partition. For stopping and unloading a partition call unloadFromEngine instead.
|
|
func (pm *taskQueuePartitionManagerImpl) Stop(unloadCause unloadCause) {
|
|
pm.initCancel()
|
|
queue, err := pm.defaultQueueFuture.Get(context.Background())
|
|
if err == nil {
|
|
queue.Stop(unloadCause)
|
|
pm.emitZeroLogicalBacklogForQueue(queue.QueueKey().Version(), queue)
|
|
}
|
|
|
|
if pm.cancelFairnessSub != nil {
|
|
pm.cancelFairnessSub()
|
|
}
|
|
if pm.cancelNewMatcherSub != nil {
|
|
pm.cancelNewMatcherSub()
|
|
}
|
|
if pm.cancelAutoEnableSub != nil {
|
|
pm.cancelAutoEnableSub()
|
|
}
|
|
|
|
pm.versionedQueuesLock.Lock()
|
|
for version, vq := range pm.versionedQueues {
|
|
vq.Stop(unloadCause)
|
|
pm.emitZeroLogicalBacklogForQueue(version, vq)
|
|
}
|
|
pm.versionedQueuesLock.Unlock()
|
|
|
|
for _, hook := range pm.taskHooks {
|
|
hook.Stop()
|
|
}
|
|
|
|
// Then, stop user data manager to wrap up any reads/writes.
|
|
pm.userDataManager.Stop()
|
|
|
|
// Finally, stop rate limit manager (used by queues and using user data manager).
|
|
pm.rateLimitManager.Stop()
|
|
|
|
pm.engine.updateTaskQueuePartitionGauge(pm.Namespace(), pm.partition, -1)
|
|
|
|
pm.goroGroup.Cancel()
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) checkPartitionCounts(ctx context.Context, forWrite, forwarded bool) error {
|
|
normal, ok := pm.partition.(*tqid.NormalPartition)
|
|
if !ok {
|
|
return nil // only normal partitions do dynamic scaling
|
|
}
|
|
id := normal.PartitionId()
|
|
|
|
// userDataManager must be initialized here already so we can just ask it for scale info
|
|
scaleInfo := pm.userDataManager.PartitionScale()
|
|
|
|
if scaleInfo.GetRead() <= 0 || scaleInfo.GetWrite() <= 0 || scaleInfo.Write > scaleInfo.Read {
|
|
return nil // missing or invalid scale info
|
|
}
|
|
|
|
// always validate partition id based on read/write counts and scale info
|
|
if err := validatePartitionCounts(id, scaleInfo, forWrite); err != nil {
|
|
return err
|
|
}
|
|
|
|
// if client has sent its idea of counts, also validate drift
|
|
clientPC, err := matching.ParsePartitionCountsFromIncomingContext(ctx)
|
|
if err != nil {
|
|
pm.throttledLogger.Info("partition count header parse error", tag.Error(err))
|
|
return nil // just log and skip the check
|
|
} else if !clientPC.Valid() {
|
|
return nil // client didn't send anything or invalid, skip check
|
|
}
|
|
allowedDrift := pm.config.PartitionScaleAllowedDrift()
|
|
return validatePartitionScaleDrift(scaleInfo, forWrite, clientPC, allowedDrift)
|
|
}
|
|
|
|
// validatePartitionCounts checks whether a partition should accept an RPC based on the current
|
|
// scale info. It returns nil if the RPC should be accepted, or an error if it should be
|
|
// rejected. scaleInfo must be valid (positive values).
|
|
func validatePartitionCounts(
|
|
partitionID int,
|
|
scaleInfo *taskqueuespb.PartitionScaleInfo,
|
|
forWrite bool,
|
|
) error {
|
|
switch {
|
|
case partitionID < 0:
|
|
return serviceerror.NewInternal("negative partition id")
|
|
case partitionID >= int(scaleInfo.Read):
|
|
return errPartitionInvalid
|
|
case partitionID >= int(scaleInfo.Write) && forWrite:
|
|
return errPartitionDraining
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// validatePartitionScaleDrift checks whether a partition should accept an RPC based on
|
|
// the client's idea of partition counts. It returns nil if the RPC should be accepted, or an
|
|
// error if it should be rejected. scaleInfo and clientPC must both be valid.
|
|
func validatePartitionScaleDrift(
|
|
scaleInfo *taskqueuespb.PartitionScaleInfo,
|
|
forWrite bool,
|
|
clientPC matching.PartitionCounts,
|
|
allowedDrift dynamicconfig.PartitionScaleAllowedDrift,
|
|
) error {
|
|
var delta int32
|
|
var ratio float32
|
|
if forWrite {
|
|
delta = clientPC.Write - scaleInfo.Write
|
|
ratio = float32(clientPC.Write) / float32(scaleInfo.Write)
|
|
} else {
|
|
delta = clientPC.Read - scaleInfo.Read
|
|
ratio = float32(clientPC.Read) / float32(scaleInfo.Read)
|
|
}
|
|
effectiveRatio := max(1.001, allowedDrift.Ratio)
|
|
if delta >= -allowedDrift.Delta && delta <= allowedDrift.Delta ||
|
|
ratio >= 1/effectiveRatio && ratio <= effectiveRatio {
|
|
return nil
|
|
}
|
|
|
|
// otherwise reject to improve load balancing
|
|
return errPartitionCountsStale
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) sendPartitionCountTrailer(ctx context.Context) {
|
|
// note this sends the trailer even if there is no scale info (i.e. dynamic partition
|
|
// scaling is not enabled). that will instruct clients to fall back to dynamic config.
|
|
scaleInfo := pm.userDataManager.PartitionScale()
|
|
err := matching.PartitionCounts{
|
|
Read: scaleInfo.GetRead(),
|
|
Write: scaleInfo.GetWrite(),
|
|
}.SetTrailer(ctx)
|
|
if err != nil {
|
|
// TODO: this is very noisy in unit tests, figure out how to log it only in non-test
|
|
pm.throttledLogger.Debug("error setting partition count trailer", tag.Error(err))
|
|
}
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) GetRateLimitManager() *rateLimitManager {
|
|
return pm.rateLimitManager
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) Namespace() *namespace.Namespace {
|
|
return pm.ns
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) MarkAlive() {
|
|
dbq := pm.defaultQueue()
|
|
if dbq != nil {
|
|
dbq.MarkAlive()
|
|
}
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) WaitUntilInitialized(ctx context.Context) error {
|
|
queue, err := pm.defaultQueueFuture.Get(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return queue.WaitUntilInitialized(ctx)
|
|
}
|
|
|
|
// autoEnableChanged is called when the AutoEnableV2 dynamic config value changes.
|
|
// It determines the effective config based on the new autoEnable value and fairnessState,
|
|
// and unloads if the effective config differs from the current config.
|
|
func (pm *taskQueuePartitionManagerImpl) autoEnableChanged(en bool) {
|
|
_, err := pm.defaultQueueFuture.Get(context.Background())
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// When fairnessState is UNSPECIFIED, autoEnable changes don't affect the effective config
|
|
if pm.fairnessState == enumsspb.FAIRNESS_STATE_UNSPECIFIED {
|
|
return
|
|
}
|
|
|
|
changeKey := pm.partition.GradualChangeKey()
|
|
now := pm.engine.timeSource.Now()
|
|
|
|
fairnessGC, _ := pm.config.EnableFairnessSub(nil)
|
|
fairness := fairnessGC.Value(changeKey, now)
|
|
|
|
newMatcherGC, _ := pm.config.NewMatcherSub(nil)
|
|
newMatcher := newMatcherGC.Value(changeKey, now)
|
|
|
|
effectiveNewMatcher, effectiveEnableFairness := pm.computeEffectiveConfig(en, fairness, newMatcher)
|
|
|
|
if effectiveNewMatcher != pm.config.NewMatcher || effectiveEnableFairness != pm.config.EnableFairness {
|
|
pm.unloadFromEngine(unloadCauseConfigChange)
|
|
}
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) autoEnableIfNeeded(ctx context.Context, params addTaskParams) {
|
|
if pm.fairnessState != enumsspb.FAIRNESS_STATE_UNSPECIFIED {
|
|
return
|
|
}
|
|
if params.taskInfo.Priority.GetFairnessKey() == "" {
|
|
if params.taskInfo.Priority.GetPriorityKey() == int32(0) {
|
|
return
|
|
}
|
|
// Do not auto enable if we only see priority and we're using new matcher already
|
|
if pm.config.NewMatcher {
|
|
return
|
|
}
|
|
}
|
|
if !pm.Partition().IsRoot() || !pm.Partition().SupportsFairness() || !pm.config.AutoEnableV2() {
|
|
return
|
|
}
|
|
if !pm.autoEnableRateLimiter.Allow() {
|
|
return
|
|
}
|
|
req := &matchingservice.UpdateFairnessStateRequest{
|
|
NamespaceId: pm.Namespace().ID().String(),
|
|
TaskQueue: pm.Partition().RpcName(),
|
|
TaskQueueType: pm.Partition().TaskType(),
|
|
FairnessState: enumsspb.FAIRNESS_STATE_V2,
|
|
}
|
|
_, err := pm.matchingClient.UpdateFairnessState(ctx, req)
|
|
if err != nil {
|
|
pm.logger.Error("could not update userdata for autoenable", tag.Error(err))
|
|
}
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) AddTask(
|
|
ctx context.Context,
|
|
params addTaskParams,
|
|
) (buildId string, syncMatched bool, err error) {
|
|
defer pm.sendPartitionCountTrailer(ctx)
|
|
if err := pm.checkPartitionCounts(ctx, true, params.forwardInfo != nil); err != nil {
|
|
return "", false, err
|
|
}
|
|
|
|
var spoolQueue, syncMatchQueue physicalTaskQueueManager
|
|
directive := params.taskInfo.GetVersionDirective()
|
|
|
|
pm.autoEnableIfNeeded(ctx, params)
|
|
// spoolQueue will be nil iff task is forwarded.
|
|
reredirectTask:
|
|
spoolQueue, syncMatchQueue, _, taskDispatchRevisionNumber, targetVersion, err := pm.getPhysicalQueuesForAdd(ctx, directive, params.forwardInfo, params.taskInfo.GetRunId(), params.taskInfo.GetWorkflowId(), false)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
|
|
syncMatchTask := newInternalTaskForSyncMatch(params.taskInfo, params.forwardInfo, taskDispatchRevisionNumber, targetVersion)
|
|
pm.config.setDefaultPriority(syncMatchTask)
|
|
if spoolQueue != nil && spoolQueue.QueueKey().Version().BuildId() != syncMatchQueue.QueueKey().Version().BuildId() {
|
|
// Task is not forwarded and build ID is different on the two queues -> redirect rule is being applied.
|
|
// Set redirectInfo in the task as it will be needed if we have to forward the task.
|
|
syncMatchTask.redirectInfo = &taskqueuespb.BuildIdRedirectInfo{
|
|
AssignedBuildId: spoolQueue.QueueKey().Version().BuildId(),
|
|
}
|
|
}
|
|
|
|
dbq := pm.defaultQueue()
|
|
if dbq == nil {
|
|
return "", false, errDefaultQueueNotInit
|
|
}
|
|
if dbq != syncMatchQueue {
|
|
// default queue should stay alive even if requests go to other queues
|
|
dbq.MarkAlive()
|
|
}
|
|
|
|
if pm.partition.IsRoot() {
|
|
// Only emit the no-recent-poller metric if BOTH conditions are met:
|
|
// 1. Partition has been loaded for more than noPollerThreshold (2 minutes)
|
|
// 2. No pollers have polled in the last noPollerThreshold (2 minutes)
|
|
// This prevents false positives for newly loaded partitions that haven't had time to receive pollers yet.
|
|
if time.Since(pm.loadTime) > noPollerThreshold && !pm.HasAnyPollerAfter(time.Now().Add(-noPollerThreshold)) {
|
|
pm.metricsHandler.Counter(metrics.NoRecentPollerTasksPerTaskQueueCounter.Name()).Record(1)
|
|
}
|
|
}
|
|
|
|
isActive, err := pm.isActiveInCluster()
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
|
|
behavior := directive.GetBehavior()
|
|
forwarded := params.forwardInfo != nil
|
|
|
|
var outcome syncMatchOutcome
|
|
if isActive {
|
|
outcome, err = syncMatchQueue.TrySyncMatch(ctx, syncMatchTask)
|
|
syncMatched = outcome == syncMatchSuccess
|
|
if syncMatched && !pm.shouldBacklogSyncMatchTaskOnError(err) {
|
|
// Only fire hooks for non-forwarded tasks. Forwarded tasks already had hooks fired
|
|
// on the child partition that originally received the task.
|
|
if !forwarded {
|
|
pm.processTaskAddHooks(ctx, targetVersion, outcome)
|
|
}
|
|
|
|
syncMatchResult := metrics.TaskAddResultSyncMatch
|
|
if err != nil {
|
|
syncMatchResult = taskAddErrResult(err)
|
|
}
|
|
syncMatchQueue.RecordTaskAdd(syncMatchResult, forwarded, behavior)
|
|
|
|
// Build ID is not returned for sync match. The returned build ID is used by History to update
|
|
// mutable state (and visibility) when the first workflow task is spooled.
|
|
// For sync-match case, History has already received the build ID in the Record*TaskStarted call.
|
|
// By omitting the build ID from this response we help History immediately know that no MS update is needed.
|
|
return "", syncMatched, err
|
|
} else if errors.Is(err, errReprocessTask) {
|
|
// We get this if userdata changed while the task was blocked in TrySyncMatch
|
|
// (only for backlog tasks forwarded to root with the new matcher)
|
|
goto reredirectTask
|
|
}
|
|
// other errors are ignored and we try to spool the task
|
|
}
|
|
|
|
if spoolQueue == nil {
|
|
// This means the task is being forwarded. Child partition will persist the task when sync match fails.
|
|
syncMatchQueue.RecordTaskAdd(metrics.TaskAddResultSyncMatchUnavail, forwarded, behavior)
|
|
return "", false, errRemoteSyncMatchFailed
|
|
}
|
|
|
|
var assignedBuildId string
|
|
if directive.GetUseAssignmentRules() != nil {
|
|
// return build ID only if a new one is assigned.
|
|
assignedBuildId = spoolQueue.QueueKey().Version().BuildId()
|
|
}
|
|
|
|
err = spoolQueue.SpoolTask(params.taskInfo)
|
|
if err == nil {
|
|
spoolQueue.RecordTaskAdd(metrics.TaskAddResultBacklog, forwarded, behavior)
|
|
pm.processTaskAddHooks(ctx, targetVersion, outcome)
|
|
} else {
|
|
spoolQueue.RecordTaskAdd(taskAddErrResult(err), forwarded, behavior)
|
|
}
|
|
|
|
return assignedBuildId, false, err
|
|
}
|
|
|
|
func syncMatchOutcomeToHook(outcome syncMatchOutcome) hooks.SyncMatchOutcome {
|
|
switch outcome {
|
|
case syncMatchSuccess:
|
|
return hooks.SyncMatchOutcomeSuccess
|
|
case syncMatchRateLimited:
|
|
return hooks.SyncMatchOutcomeRateLimited
|
|
case syncMatchUnspecified:
|
|
return hooks.SyncMatchOutcomeUnspecified
|
|
default:
|
|
return hooks.SyncMatchOutcomeNotMatched
|
|
}
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) processTaskAddHooks(ctx context.Context, targetVersion *deploymentspb.WorkerDeploymentVersion, outcome syncMatchOutcome) {
|
|
for _, l := range pm.taskHooks {
|
|
hookOutcome := syncMatchOutcomeToHook(outcome)
|
|
l.ProcessTaskAdd(ctx, &hooks.TaskAddHookDetails{
|
|
DeploymentVersion: worker_versioning.ExternalWorkerDeploymentVersionFromVersion(targetVersion),
|
|
IsSyncMatch: hookOutcome == hooks.SyncMatchOutcomeSuccess,
|
|
SyncMatchOutcome: hookOutcome,
|
|
})
|
|
}
|
|
}
|
|
|
|
func taskAddErrResult(err error) string {
|
|
var resourceExhausted *serviceerror.ResourceExhausted
|
|
if errors.As(err, &resourceExhausted) {
|
|
return metrics.TaskAddResultThrottled
|
|
}
|
|
return metrics.TaskAddResultFailure
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) shouldBacklogSyncMatchTaskOnError(err error) bool {
|
|
var resourceExhaustedErr *serviceerror.ResourceExhausted
|
|
if err != nil && errors.As(err, &resourceExhaustedErr) {
|
|
if resourceExhaustedErr.Cause == enumspb.RESOURCE_EXHAUSTED_CAUSE_BUSY_WORKFLOW {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) isActiveInCluster() (bool, error) {
|
|
ns, err := pm.engine.namespaceRegistry.GetNamespaceByID(pm.ns.ID())
|
|
if err == nil {
|
|
return ns.ActiveInCluster(pm.engine.clusterMeta.GetCurrentClusterName()), nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
// PollTask returns a task if there was a match, a boolean that reports whether a versionSet was used for the match, and an error
|
|
func (pm *taskQueuePartitionManagerImpl) PollTask(
|
|
ctx context.Context,
|
|
pollMetadata *pollMetadata,
|
|
) (*internalTask, bool, error) {
|
|
defer pm.sendPartitionCountTrailer(ctx)
|
|
if err := pm.checkPartitionCounts(ctx, false, pollMetadata.forwardedFrom != ""); err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
var err error
|
|
dbq := pm.defaultQueue()
|
|
if dbq == nil {
|
|
return nil, false, errDefaultQueueNotInit
|
|
}
|
|
versionSetUsed := false
|
|
deployment, err := worker_versioning.DeploymentFromCapabilities(pollMetadata.workerVersionCapabilities, pollMetadata.deploymentOptions)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
if deployment != nil {
|
|
if pm.partition.Kind() == enumspb.TASK_QUEUE_KIND_STICKY {
|
|
// TODO: reject poller of old sticky queue if newer version exist
|
|
} else {
|
|
// default queue should stay alive even if requests go to other queues
|
|
dbq.MarkAlive()
|
|
dbq, err = pm.getVersionedQueue(ctx, "", "", deployment, true)
|
|
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
} else if pollMetadata.workerVersionCapabilities.GetUseVersioning() {
|
|
// V1 & V2 versioning
|
|
userData, _, err := pm.userDataManager.GetUserData()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
versioningData := userData.GetData().GetVersioningData()
|
|
buildId := pollMetadata.workerVersionCapabilities.GetBuildId()
|
|
if buildId == "" {
|
|
return nil, false, serviceerror.NewInvalidArgument("build ID must be provided when using worker versioning")
|
|
}
|
|
|
|
if pm.partition.Kind() == enumspb.TASK_QUEUE_KIND_STICKY {
|
|
// In the sticky case we always use the unversioned queue
|
|
// For the old API, we may kick off this worker if there's a newer one.
|
|
oldVersioning, err := checkVersionForStickyPoll(versioningData, pollMetadata.workerVersionCapabilities)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
if !oldVersioning {
|
|
activeRules := getActiveRedirectRules(versioningData.GetRedirectRules())
|
|
terminalBuildId := findTerminalBuildId(buildId, activeRules)
|
|
if terminalBuildId != buildId {
|
|
return nil, false, serviceerror.NewNewerBuildExists(terminalBuildId)
|
|
}
|
|
}
|
|
// We set versionSetUsed to true for all sticky tasks until old versioning is cleaned up.
|
|
// this value is used by matching_engine to decide if it should pass the worker build ID
|
|
// to history in the recordStart call or not. We don't need to pass build ID for sticky
|
|
// tasks as no redirect happen in a sticky queue.
|
|
versionSetUsed = true
|
|
} else {
|
|
// default queue should stay alive even if requests go to other queues
|
|
dbq.MarkAlive()
|
|
|
|
var versionSet string
|
|
if versioningData.GetVersionSets() != nil {
|
|
versionSet, err = pm.getVersionSetForPoll(pollMetadata.workerVersionCapabilities, versioningData)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
|
|
// use version set if found, otherwise assume user is using new API
|
|
if versionSet != "" {
|
|
versionSetUsed = true
|
|
dbq, err = pm.getVersionedQueue(ctx, versionSet, "", nil, true)
|
|
} else {
|
|
activeRules := getActiveRedirectRules(versioningData.GetRedirectRules())
|
|
terminalBuildId := findTerminalBuildId(buildId, activeRules)
|
|
if terminalBuildId != buildId {
|
|
return nil, false, serviceerror.NewNewerBuildExists(terminalBuildId)
|
|
}
|
|
pm.loadUpstreamBuildIds(buildId, activeRules)
|
|
dbq, err = pm.getVersionedQueue(ctx, "", buildId, nil, true)
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
|
|
identity, hasIdentity := ctx.Value(identityKey).(string)
|
|
if hasIdentity && identity != "" {
|
|
dbq.UpdatePollerInfo(pollerIdentity(identity), pollMetadata)
|
|
}
|
|
|
|
// The desired global rate limit for the task queue can come from multiple sources:
|
|
// UpdateTaskQueueConfig API call, poller metadata, or system default.
|
|
// In the case of worker set rate limits at the time of poll task :
|
|
// we update the ratelimiter rps if it has changed from the previous poll.
|
|
// Last poller wins if different pollers provide different values
|
|
// Highest priority is given to the rate limit set by the UpdateTaskQueueConfig api call.
|
|
// Followed by the rate limit set by the poller.
|
|
// UpdateRateLimit implicitly handles whether an update is required or not,
|
|
// based on whether the effectiveRPS has changed.
|
|
pm.rateLimitManager.InjectWorkerRPS(pollMetadata)
|
|
|
|
task, err := dbq.PollTask(ctx, pollMetadata)
|
|
if task != nil {
|
|
task.pollerScalingDecision = dbq.MakePollerScalingDecision(ctx, pollMetadata.localPollStartTime)
|
|
}
|
|
|
|
// Update poller timestamp when poll ends, unless cancelled (e.g., shutdown/disconnect).
|
|
// Skip on cancellation to avoid re-adding entry after RemovePoller was called.
|
|
if hasIdentity && identity != "" && ctx.Err() != context.Canceled {
|
|
dbq.UpdatePollerInfo(pollerIdentity(identity), pollMetadata)
|
|
}
|
|
|
|
return task, versionSetUsed, err
|
|
}
|
|
|
|
// GetPhysicalQueueAdjustedStats retrieves task queue stats for the given physical queue.
|
|
// Returns nil if stats cannot be retrieved or are not available.
|
|
func (pm *taskQueuePartitionManagerImpl) GetPhysicalQueueAdjustedStats(
|
|
ctx context.Context,
|
|
physicalQueue physicalTaskQueueManager,
|
|
) *taskqueuepb.TaskQueueStats {
|
|
// buildID would be empty for either the unversioned queue or when using v3 worker-versioning.
|
|
buildID := physicalQueue.QueueKey().Version().BuildId()
|
|
|
|
// Check if the queue is versioned queue using v3 worker-versioning
|
|
deployment := physicalQueue.QueueKey().Version().Deployment()
|
|
if deployment != nil {
|
|
buildID = worker_versioning.ExternalWorkerDeploymentVersionToString(worker_versioning.ExternalWorkerDeploymentVersionFromDeployment(deployment))
|
|
}
|
|
|
|
partitionInfo, err := pm.Describe(ctx, map[string]bool{buildID: true}, false, true, false, false)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
info, ok := partitionInfo.GetVersionsInfoInternal()[buildID]
|
|
if !ok || info.GetPhysicalTaskQueueInfo().GetTaskQueueStats() == nil {
|
|
return nil
|
|
}
|
|
return info.GetPhysicalTaskQueueInfo().GetTaskQueueStats()
|
|
}
|
|
|
|
// TODO(pri): old matcher cleanup
|
|
func (pm *taskQueuePartitionManagerImpl) ProcessSpooledTask(
|
|
ctx context.Context,
|
|
task *internalTask,
|
|
backlogQueue *PhysicalTaskQueueKey,
|
|
) error {
|
|
taskInfo := task.event.GetData()
|
|
// This task came from taskReader so task.event is always set here.
|
|
directive := taskInfo.GetVersionDirective()
|
|
assignedBuildId := backlogQueue.Version().BuildId()
|
|
if assignedBuildId != "" {
|
|
// construct directive based on the build ID of the spool queue
|
|
directive = worker_versioning.MakeBuildIdDirective(assignedBuildId)
|
|
}
|
|
// Redirect and re-resolve if we're blocked in matcher and user data changes.
|
|
for {
|
|
newBacklogQueue, syncMatchQueue, userDataChanged, taskDispatchRevisionNumber, targetVersion, err := pm.getPhysicalQueuesForAdd(ctx,
|
|
directive,
|
|
nil,
|
|
taskInfo.GetRunId(),
|
|
taskInfo.GetWorkflowId(),
|
|
false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
task.targetWorkerDeploymentVersion = targetVersion
|
|
|
|
// Update the task dispatch revision number on the task since the routingConfig of the partition
|
|
// may have changed after the task was spooled.
|
|
task.taskDispatchRevisionNumber = taskDispatchRevisionNumber
|
|
|
|
// set redirect info if spoolQueue and syncMatchQueue build ids are different
|
|
if assignedBuildId != syncMatchQueue.QueueKey().Version().BuildId() {
|
|
task.redirectInfo = &taskqueuespb.BuildIdRedirectInfo{
|
|
AssignedBuildId: assignedBuildId,
|
|
}
|
|
} else {
|
|
// make sure to reset redirectInfo in case it was set in a previous loop cycle
|
|
task.redirectInfo = nil
|
|
}
|
|
if !backlogQueue.version.Deployment().Equal(newBacklogQueue.QueueKey().version.Deployment()) {
|
|
// Backlog queue has changed, spool to the new queue. This should happen rarely: when
|
|
// activity of pinned workflow was determined independent and sent to the default queue
|
|
// but now at dispatch time, the determination is different because the activity pollers
|
|
// on the pinned deployment have reached server.
|
|
// TODO: before spooling, try to sync-match the task on the new queue
|
|
err = newBacklogQueue.SpoolTask(taskInfo)
|
|
if err != nil {
|
|
// return the error so task_reader retries the outer call
|
|
return err
|
|
}
|
|
// Finish the task because now it is copied to the other backlog. It should be considered
|
|
// invalid because a poller did not receive the task.
|
|
task.finish(nil, false)
|
|
return nil
|
|
}
|
|
err = syncMatchQueue.DispatchSpooledTask(ctx, task, userDataChanged)
|
|
if err != errInterrupted {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) AddSpooledTask(
|
|
ctx context.Context,
|
|
task *internalTask,
|
|
backlogQueue *PhysicalTaskQueueKey,
|
|
) error {
|
|
taskInfo := task.event.GetData()
|
|
// This task came from taskReader so task.event is always set here.
|
|
directive := taskInfo.GetVersionDirective()
|
|
assignedBuildId := backlogQueue.Version().BuildId()
|
|
if assignedBuildId != "" {
|
|
// construct directive based on the build ID of the spool queue
|
|
directive = worker_versioning.MakeBuildIdDirective(assignedBuildId)
|
|
}
|
|
newBacklogQueue, syncMatchQueue, _, taskDispatchRevisionNumber, targetVersion, err := pm.getPhysicalQueuesForAdd(
|
|
ctx,
|
|
directive,
|
|
nil,
|
|
taskInfo.GetRunId(),
|
|
taskInfo.GetWorkflowId(),
|
|
false,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
task.targetWorkerDeploymentVersion = targetVersion
|
|
|
|
// Update the task dispatch revision number on the task since the routingConfig of the partition
|
|
// may have changed after the task was spooled.
|
|
task.taskDispatchRevisionNumber = taskDispatchRevisionNumber
|
|
|
|
// set redirect info if spoolQueue and syncMatchQueue build ids are different (V2 versioning)
|
|
if assignedBuildId != syncMatchQueue.QueueKey().Version().BuildId() {
|
|
task.redirectInfo = &taskqueuespb.BuildIdRedirectInfo{
|
|
AssignedBuildId: assignedBuildId,
|
|
}
|
|
} else {
|
|
// make sure to reset redirectInfo in case it was set in a previous loop cycle
|
|
task.redirectInfo = nil
|
|
}
|
|
// mark if task is being redirected from queue it was read from (V2 or V3 versioning)
|
|
task.redirectedFromBacklog = syncMatchQueue.QueueKey() != backlogQueue
|
|
if !backlogQueue.version.Deployment().Equal(newBacklogQueue.QueueKey().version.Deployment()) {
|
|
// Backlog queue has changed, spool to the new queue. This should happen rarely: when
|
|
// activity of pinned workflow was determined independent and sent to the default queue
|
|
// but now at dispatch time, the determination is different because the activity pollers
|
|
// on the pinned deployment have reached server.
|
|
// TODO: before spooling, try to sync-match the task on the new queue
|
|
err = newBacklogQueue.SpoolTask(taskInfo)
|
|
if err != nil {
|
|
// return the error so task_reader retries the outer call
|
|
return err
|
|
}
|
|
// Finish the task because now it is copied to the other backlog. It should be considered
|
|
// invalid because a poller did not receive the task.
|
|
task.finish(nil, false)
|
|
return nil
|
|
}
|
|
return syncMatchQueue.AddSpooledTaskToMatcher(task)
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) DispatchQueryTask(
|
|
ctx context.Context,
|
|
taskID string,
|
|
request *matchingservice.QueryWorkflowRequest,
|
|
) (*matchingservice.QueryWorkflowResponse, error) {
|
|
// query counts as "write" for partition load balancing
|
|
defer pm.sendPartitionCountTrailer(ctx)
|
|
if err := pm.checkPartitionCounts(ctx, true, request.ForwardInfo != nil); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
task := newInternalQueryTask(taskID, request)
|
|
pm.config.setDefaultPriority(task)
|
|
|
|
reredirectTask:
|
|
_, syncMatchQueue, _, _, _, err := pm.getPhysicalQueuesForAdd(ctx,
|
|
request.VersionDirective,
|
|
// We do not pass forwardInfo because we want the parent partition to make fresh versioning decision. Note that
|
|
// forwarded Query/Nexus task requests do not expire rapidly in contrast to forwarded activity/workflow tasks
|
|
// that only try up to 200ms sync-match. Therefore, to prevent blocking the request on the wrong build ID, its
|
|
// more important to allow the parent partition to make a fresh versioning decision in case the child partition
|
|
// did not have up-to-date User Data when selected a dispatch build ID.
|
|
nil,
|
|
request.GetQueryRequest().GetExecution().GetRunId(),
|
|
request.GetQueryRequest().GetExecution().GetWorkflowId(),
|
|
true,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dbq := pm.defaultQueue()
|
|
if dbq == nil {
|
|
return nil, errDefaultQueueNotInit
|
|
}
|
|
if dbq != syncMatchQueue {
|
|
// default queue should stay alive even if requests go to other queues
|
|
dbq.MarkAlive()
|
|
}
|
|
|
|
res, err := syncMatchQueue.DispatchQueryTask(ctx, task)
|
|
if errors.Is(err, errReprocessTask) {
|
|
// We get this if userdata changed while the task was blocked in DispatchQueryTask
|
|
goto reredirectTask
|
|
}
|
|
return res, err
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) DispatchNexusTask(
|
|
ctx context.Context,
|
|
taskId string,
|
|
request *matchingservice.DispatchNexusTaskRequest,
|
|
) (*matchingservice.DispatchNexusTaskResponse, error) {
|
|
// nexus counts as "write" for partition load balancing
|
|
defer pm.sendPartitionCountTrailer(ctx)
|
|
if err := pm.checkPartitionCounts(ctx, true, request.ForwardInfo != nil); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
deadline, _ := ctx.Deadline() // If not set by user, our client will set a default.
|
|
var opDeadline time.Time
|
|
if header := nexus.Header(request.GetRequest().GetHeader()); header != nil {
|
|
if opTimeoutHeader := header.Get(nexus.HeaderOperationTimeout); opTimeoutHeader != "" {
|
|
opTimeout, err := time.ParseDuration(opTimeoutHeader)
|
|
if err != nil {
|
|
// Operation-Timeout header is not required so don't fail request on parsing errors.
|
|
pm.logger.Warn(fmt.Sprintf("unable to parse %v header: %v", nexus.HeaderOperationTimeout, opTimeoutHeader), tag.Error(err), tag.WorkflowNamespaceID(request.NamespaceId))
|
|
} else {
|
|
opDeadline = time.Now().Add(opTimeout)
|
|
}
|
|
}
|
|
}
|
|
|
|
task := newInternalNexusTask(taskId, deadline, opDeadline, request)
|
|
pm.config.setDefaultPriority(task)
|
|
|
|
reredirectTask:
|
|
_, syncMatchQueue, _, _, _, err := pm.getPhysicalQueuesForAdd(ctx,
|
|
worker_versioning.MakeUseAssignmentRulesDirective(),
|
|
// We do not pass forwardInfo because we want the parent partition to make fresh versioning decision. Note that
|
|
// forwarded Query/Nexus task requests do not expire rapidly in contrast to forwarded activity/workflow tasks
|
|
// that only try up to 200ms sync-match. Therefore, to prevent blocking the request on the wrong build ID, its
|
|
// more important to allow the parent partition to make a fresh versioning decision in case the child partition
|
|
// did not have up-to-date User Data when selected a dispatch build ID.
|
|
nil,
|
|
"",
|
|
"",
|
|
false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dbq := pm.defaultQueue()
|
|
if dbq == nil {
|
|
return nil, errDefaultQueueNotInit
|
|
}
|
|
if dbq != syncMatchQueue {
|
|
// default queue should stay alive even if requests go to other queues
|
|
dbq.MarkAlive()
|
|
}
|
|
|
|
res, err := syncMatchQueue.DispatchNexusTask(ctx, task)
|
|
if errors.Is(err, errReprocessTask) {
|
|
// We get this if userdata changed while the task was blocked in DispatchNexusTask
|
|
goto reredirectTask
|
|
}
|
|
return res, err
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) GetUserDataManager() userDataManager {
|
|
return pm.userDataManager
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) GetConfig() *taskQueueConfig {
|
|
return pm.config
|
|
}
|
|
|
|
// GetAllPollerInfo returns all pollers that polled from this taskqueue in last few minutes
|
|
func (pm *taskQueuePartitionManagerImpl) GetAllPollerInfo() []*taskqueuepb.PollerInfo {
|
|
dbq := pm.defaultQueue()
|
|
var ret []*taskqueuepb.PollerInfo
|
|
if dbq != nil {
|
|
ret = dbq.GetAllPollerInfo()
|
|
}
|
|
pm.versionedQueuesLock.RLock()
|
|
defer pm.versionedQueuesLock.RUnlock()
|
|
for _, vq := range pm.versionedQueues {
|
|
info := vq.GetAllPollerInfo()
|
|
ret = append(ret, info...)
|
|
|
|
// We want DescribeTaskQueue to count for task queue liveness for all loaded versioned
|
|
// queues, and this is the most convenient place to mark liveness, since it's called by
|
|
// LegacyDescribeTaskQueue (and also getPhysicalQueuesForAdd, but that's versioning 1
|
|
// and will be removed soon).
|
|
vq.MarkAlive()
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// RemovePoller eagerly removes a poller from history for graceful shutdown.
|
|
func (pm *taskQueuePartitionManagerImpl) RemovePoller(identity pollerIdentity) {
|
|
if dbq := pm.defaultQueue(); dbq != nil {
|
|
dbq.RemovePoller(identity)
|
|
}
|
|
pm.versionedQueuesLock.RLock()
|
|
defer pm.versionedQueuesLock.RUnlock()
|
|
for _, vq := range pm.versionedQueues {
|
|
vq.RemovePoller(identity)
|
|
}
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) HasAnyPollerAfter(accessTime time.Time) bool {
|
|
dbq := pm.defaultQueue()
|
|
if dbq != nil && dbq.HasPollerAfter(accessTime) {
|
|
return true
|
|
}
|
|
pm.versionedQueuesLock.RLock()
|
|
defer pm.versionedQueuesLock.RUnlock()
|
|
for _, ptqm := range pm.versionedQueues {
|
|
if ptqm.HasPollerAfter(accessTime) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) HasPollerAfter(buildId string, accessTime time.Time) bool {
|
|
if buildId == "" {
|
|
dbq := pm.defaultQueue()
|
|
if dbq != nil && dbq.HasPollerAfter(accessTime) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
pm.versionedQueuesLock.RLock()
|
|
// TODO: support v3 versioning
|
|
vq, ok := pm.versionedQueues[PhysicalTaskQueueVersion{buildId: buildId}]
|
|
pm.versionedQueuesLock.RUnlock()
|
|
if !ok {
|
|
return false
|
|
}
|
|
return vq.HasPollerAfter(accessTime)
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) LegacyDescribeTaskQueue(includeTaskQueueStatus bool) (*matchingservice.DescribeTaskQueueResponse, error) {
|
|
resp := &matchingservice.DescribeTaskQueueResponse{
|
|
DescResponse: &workflowservice.DescribeTaskQueueResponse{
|
|
Pollers: pm.GetAllPollerInfo(),
|
|
},
|
|
}
|
|
dbq := pm.defaultQueue()
|
|
if dbq == nil {
|
|
return nil, errDefaultQueueNotInit
|
|
}
|
|
if includeTaskQueueStatus {
|
|
//nolint:staticcheck // SA1019: [cleanup-wv-3.1]
|
|
resp.DescResponse.TaskQueueStatus = dbq.LegacyDescribeTaskQueue(true).DescResponse.TaskQueueStatus
|
|
}
|
|
if pm.partition.Kind() == enumspb.TASK_QUEUE_KIND_NORMAL {
|
|
perTypeUserData, _, err := pm.getPerTypeUserData()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
current, _, currentUpdateTime, ramping, isRamping, rampingPercentage, _, rampingUpdateTime := worker_versioning.CalculateTaskQueueVersioningInfo(perTypeUserData.GetDeploymentData())
|
|
|
|
info := &taskqueuepb.TaskQueueVersioningInfo{
|
|
//nolint:staticcheck // SA1019: [cleanup-wv-3.1]
|
|
CurrentVersion: worker_versioning.WorkerDeploymentVersionToStringV31(current),
|
|
CurrentDeploymentVersion: worker_versioning.ExternalWorkerDeploymentVersionFromVersion(current),
|
|
UpdateTime: timestamppb.New(currentUpdateTime),
|
|
}
|
|
|
|
if isRamping {
|
|
info.RampingVersionPercentage = rampingPercentage
|
|
// If task queue is ramping to unversioned, ramping will be nil, which converts to "__unversioned__"
|
|
//nolint:staticcheck // SA1019: [cleanup-wv-3.1]
|
|
info.RampingVersion = worker_versioning.WorkerDeploymentVersionToStringV31(ramping)
|
|
info.RampingDeploymentVersion = worker_versioning.ExternalWorkerDeploymentVersionFromVersion(ramping)
|
|
if info.GetUpdateTime().AsTime().Before(rampingUpdateTime) {
|
|
info.UpdateTime = timestamppb.New(rampingUpdateTime)
|
|
}
|
|
}
|
|
|
|
resp.DescResponse.VersioningInfo = info
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// In order to accommodate the changes brought in by versioning-3.1, `buildIDs` will now also accept versionID's that represent worker-deployment versions.
|
|
func (pm *taskQueuePartitionManagerImpl) Describe(
|
|
ctx context.Context,
|
|
buildIds map[string]bool,
|
|
includeAllActive, reportStats, reportPollers, internalTaskQueueStatus bool,
|
|
) (*matchingservice.DescribeTaskQueuePartitionResponse, error) {
|
|
return pm.describe(ctx, buildIds, includeAllActive, reportStats, reportPollers, internalTaskQueueStatus, false)
|
|
}
|
|
|
|
// Describe returns information about physical queues for the requested versions, including
|
|
// pollers, stats (with "versioning attribution", which means the default queue backlog is
|
|
// proportionally attributed to the Current and Ramping versioned backlog counts based on
|
|
// routing config), and internal status. When Describe is called by external clients, each
|
|
// described queue is marked alive to reset its idle timeout and prevent it from being unloaded.
|
|
// When we describe the task queue from inside the partition manager to expose periodic metrics,
|
|
// we pass skipMarkAlive=true to suppress this side effect and avoid preventing idle queue unloading.
|
|
//
|
|
//nolint:revive // cognitive complexity 67 (> max enabled 25) but this is just a renaming of an existing function.
|
|
func (pm *taskQueuePartitionManagerImpl) describe(
|
|
ctx context.Context,
|
|
buildIds map[string]bool,
|
|
includeAllActive, reportStats, reportPollers, internalTaskQueueStatus, skipMarkAlive bool,
|
|
) (*matchingservice.DescribeTaskQueuePartitionResponse, error) {
|
|
pm.versionedQueuesLock.RLock()
|
|
|
|
versions := make(map[PhysicalTaskQueueVersion]bool)
|
|
|
|
// Active means that the physical queue for that version is loaded.
|
|
// An empty string refers to the unversioned queue, which is always loaded.
|
|
// In the future, active will mean that the physical queue for that version has had a task added recently or a recent poller.
|
|
if includeAllActive {
|
|
for k := range pm.versionedQueues {
|
|
versions[k] = true
|
|
}
|
|
}
|
|
|
|
for b := range buildIds {
|
|
if b == "" {
|
|
dbq := pm.defaultQueue()
|
|
if dbq == nil {
|
|
pm.versionedQueuesLock.RUnlock()
|
|
return nil, errDefaultQueueNotInit
|
|
}
|
|
versions[dbq.QueueKey().Version()] = true
|
|
} else {
|
|
found := false
|
|
for k := range pm.versionedQueues {
|
|
// Storing the versioned queue if the buildID is a v2 based buildID or a versionID representing a worker-deployment version (which could be v31 or v32)
|
|
if k.BuildId() == b ||
|
|
worker_versioning.ExternalWorkerDeploymentVersionToString(worker_versioning.ExternalWorkerDeploymentVersionFromDeployment(k.Deployment())) == b ||
|
|
worker_versioning.ExternalWorkerDeploymentVersionToStringV31(worker_versioning.ExternalWorkerDeploymentVersionFromDeployment(k.Deployment())) == b {
|
|
versions[k] = true
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
dv, _ := worker_versioning.WorkerDeploymentVersionFromStringV32(b)
|
|
if dv != nil {
|
|
// Add v3 version.
|
|
versions[PhysicalTaskQueueVersion{
|
|
buildId: dv.BuildId,
|
|
deploymentSeriesName: dv.DeploymentName,
|
|
}] = true
|
|
} else {
|
|
// Still add v2 version because user explicitly asked for the stats, we'd make sure to load the TQ.
|
|
versions[PhysicalTaskQueueVersion{buildId: b}] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pm.versionedQueuesLock.RUnlock()
|
|
|
|
var unversionedStatsByPriority map[int32]*taskqueuepb.TaskQueueStats
|
|
var currentVersion *deploymentspb.WorkerDeploymentVersion
|
|
var rampingVersion *deploymentspb.WorkerDeploymentVersion
|
|
var rampPercentage float32
|
|
var currentExists bool
|
|
var rampingExists bool
|
|
var isRamping bool
|
|
var unversionedCurrentShareByPriority map[int32]*taskqueuepb.TaskQueueStats
|
|
var unversionedRampingShareByPriority map[int32]*taskqueuepb.TaskQueueStats
|
|
|
|
if reportStats {
|
|
// Consider the default/unversioned queue. For current/ramping deployment versions, tasks are backlogged
|
|
// here, so we include this queue's stats if the version to describe is a current/ramping version.
|
|
dbq := pm.defaultQueue()
|
|
if dbq == nil {
|
|
return nil, errDefaultQueueNotInit
|
|
}
|
|
unversionedStatsByPriority = dbq.GetStatsByPriority(true)
|
|
|
|
userData, _, err := pm.GetUserDataManager().GetUserData()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
perType := userData.GetData().GetPerType()[int32(pm.Partition().TaskType())]
|
|
deploymentData := perType.GetDeploymentData()
|
|
|
|
currentVersion, _, _, rampingVersion, isRamping, rampPercentage, _, _ =
|
|
worker_versioning.CalculateTaskQueueVersioningInfo(deploymentData)
|
|
|
|
// Technically, one could have a current version of "unversioned" which shall make currentExists false according
|
|
// to the current logic. However, as of now, the user cannot query the stats of the "unversioned" version so this
|
|
// logic is fine. In other words, this logic is used to only attribute the unversioned backlog to the current version
|
|
// when current version is NOT "unversioned".
|
|
//
|
|
// When the ramping version is "unversioned", isRamping is true which shall make the attribution logic work as expected.
|
|
currentExists = currentVersion != nil
|
|
rampingExists = isRamping && rampPercentage > 0
|
|
|
|
// Split the unversioned queue's stats per priority so TaskQueueStatsByPriorityKey can
|
|
// be adjusted consistently with TaskQueueStats.
|
|
unversionedCurrentShareByPriority = map[int32]*taskqueuepb.TaskQueueStats{}
|
|
unversionedRampingShareByPriority = map[int32]*taskqueuepb.TaskQueueStats{}
|
|
if rampingExists {
|
|
unversionedCurrentShareByPriority, unversionedRampingShareByPriority =
|
|
splitStatsByPriorityByRampPercentage(unversionedStatsByPriority, rampPercentage)
|
|
} else if currentExists {
|
|
// If there exist no ramping version, we attribute the entire unversioned backlog to the current version.
|
|
unversionedCurrentShareByPriority = cloneStatsByPriority(unversionedStatsByPriority)
|
|
}
|
|
}
|
|
|
|
versionsInfo := make(map[string]*taskqueuespb.TaskQueueVersionInfoInternal, len(versions))
|
|
for v := range versions {
|
|
vInfo := &taskqueuespb.TaskQueueVersionInfoInternal{
|
|
PhysicalTaskQueueInfo: &taskqueuespb.PhysicalTaskQueueInfo{},
|
|
}
|
|
|
|
// `getPhysicalQueue` always needs the right buildID passed to function correctly. If the version is a worker-deployment version and an empty buildID is passed,
|
|
// the function returns the default queue which is not what we want.
|
|
// The following assigns buildID to either a v2 based buildID or a buildID part of a worker-deployment version.
|
|
buildID := v.BuildId()
|
|
if v.Deployment() != nil {
|
|
buildID = v.Deployment().BuildId
|
|
}
|
|
|
|
physicalQueue, err := pm.getPhysicalQueue(ctx, buildID, v.Deployment())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if reportPollers {
|
|
vInfo.PhysicalTaskQueueInfo.Pollers = physicalQueue.GetAllPollerInfo()
|
|
}
|
|
if reportStats {
|
|
physicalStatsByPriority := physicalQueue.GetStatsByPriority(true)
|
|
|
|
// Clone the physical queue's stats by priority so we can adjust (either add, subtract) them based on the
|
|
// attribution model defined below.
|
|
adjustedStatsByPriority := cloneStatsByPriority(physicalStatsByPriority)
|
|
|
|
// Attribution model (applied per-priority):
|
|
// - If current and/or ramping deployment versions exist, we first "give away" a portion of the
|
|
// unversioned queue's per-priority stats.
|
|
//
|
|
// Depending on the version described, we have the following options:
|
|
// - For the unversioned version itself, subtract the given-away portion (so we don't double count).
|
|
// - For current/ramping versions, add their share on top of their physical queue stats.
|
|
deploymentVersion := worker_versioning.DeploymentVersionFromDeployment(v.Deployment())
|
|
|
|
isUnversionedDescribe := deploymentVersion == nil
|
|
isCurrentDescribe := deploymentVersion.GetDeploymentName() == currentVersion.GetDeploymentName() &&
|
|
deploymentVersion.GetBuildId() == currentVersion.GetBuildId()
|
|
|
|
// "Ramping to unversioned" is represented by "rampingExists==true AND rampingVersion==nil".
|
|
// In that case, the ramp share should remain attributed to the unversioned queue stats and
|
|
// there is no separate versioned queue to merge that share into.
|
|
isRampingToUnversioned := rampingExists && rampingVersion == nil
|
|
isRampingDescribe := deploymentVersion.GetDeploymentName() == rampingVersion.GetDeploymentName() &&
|
|
deploymentVersion.GetBuildId() == rampingVersion.GetBuildId()
|
|
|
|
if isUnversionedDescribe {
|
|
// Reduce unversioned stats by any shares attributed to versioned queues.
|
|
if currentExists {
|
|
subtractStatsByPriority(adjustedStatsByPriority, unversionedCurrentShareByPriority)
|
|
}
|
|
// Only subtract the ramping share when ramping is to a versioned deployment. If ramping is to
|
|
// unversioned, that share should remain part of the unversioned queue stats.
|
|
if rampingExists && !isRampingToUnversioned {
|
|
subtractStatsByPriority(adjustedStatsByPriority, unversionedRampingShareByPriority)
|
|
}
|
|
} else if isCurrentDescribe && currentExists {
|
|
mergeStatsByPriority(adjustedStatsByPriority, unversionedCurrentShareByPriority)
|
|
} else if isRampingDescribe && rampingExists {
|
|
mergeStatsByPriority(adjustedStatsByPriority, unversionedRampingShareByPriority)
|
|
}
|
|
|
|
vInfo.PhysicalTaskQueueInfo.TaskQueueStatsByPriorityKey = adjustedStatsByPriority
|
|
vInfo.PhysicalTaskQueueInfo.TaskQueueStats = aggregateStats(adjustedStatsByPriority)
|
|
}
|
|
if internalTaskQueueStatus {
|
|
vInfo.PhysicalTaskQueueInfo.InternalTaskQueueStatus = physicalQueue.GetInternalTaskQueueStatus()
|
|
}
|
|
|
|
// The following assigns buildID to either a v2 based buildID or a versionID representing a worker-deployment version.
|
|
// This is required to populate the right value inside the versionsInfo map. If a user is requesting information for a worker-deployment version,
|
|
// the full worker-deployment version string is used as an entry in the versionsInfo map. Moreover, to keep things backwards compatible, users requesting
|
|
// information for non-deployment related builds will only see the buildID as an entry in the versionsInfo map.
|
|
bid := v.BuildId()
|
|
if v.Deployment() != nil {
|
|
bid = worker_versioning.ExternalWorkerDeploymentVersionToString(worker_versioning.ExternalWorkerDeploymentVersionFromDeployment(v.Deployment()))
|
|
}
|
|
versionsInfo[bid] = vInfo
|
|
|
|
if !skipMarkAlive {
|
|
// Skipped by periodic metrics emission to avoid resetting the idle timeout,
|
|
// which would prevent queues from ever being unloaded.
|
|
physicalQueue.MarkAlive()
|
|
}
|
|
}
|
|
|
|
return &matchingservice.DescribeTaskQueuePartitionResponse{
|
|
VersionsInfoInternal: versionsInfo,
|
|
}, nil
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) updateEphemeralData(ctx context.Context) error {
|
|
// for now, this only applies to normal workflow task queues, only with new matcher
|
|
if pm.partition.Kind() != enumspb.TASK_QUEUE_KIND_NORMAL ||
|
|
pm.partition.TaskType() != enumspb.TASK_QUEUE_TYPE_WORKFLOW ||
|
|
!pm.config.NewMatcher {
|
|
return nil
|
|
}
|
|
|
|
var prevBacklogPriority map[PhysicalTaskQueueVersion]int64
|
|
|
|
for {
|
|
interval := pm.config.EphemeralDataUpdateInterval()
|
|
if interval == 0 {
|
|
_ = util.InterruptibleSleep(ctx, time.Minute)
|
|
continue
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
|
|
case <-time.After(backoff.Jitter(interval, 0.05)):
|
|
prevBacklogPriority = pm.updateEphemeralDataIteration(prevBacklogPriority)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) updateEphemeralDataIteration(prevBacklogPriority map[PhysicalTaskQueueVersion]int64) map[PhysicalTaskQueueVersion]int64 {
|
|
negligibleAge := pm.config.BacklogNegligibleAge()
|
|
backlogPriority := make(map[PhysicalTaskQueueVersion]int64)
|
|
|
|
setLevels := func(vk PhysicalTaskQueueVersion, vq physicalTaskQueueManager) {
|
|
var levels int64
|
|
for key, stats := range vq.GetStatsByPriority(false) {
|
|
if key < 64 && stats.ApproximateBacklogAge.AsDuration() > negligibleAge {
|
|
levels = levels | 1<<key
|
|
}
|
|
}
|
|
if levels != 0 {
|
|
backlogPriority[vk] = levels
|
|
}
|
|
}
|
|
|
|
dbq := pm.defaultQueue()
|
|
if dbq == nil {
|
|
return prevBacklogPriority // shouldn't happen, we only start after initialization
|
|
}
|
|
setLevels(PhysicalTaskQueueVersion{}, dbq)
|
|
|
|
pm.versionedQueuesLock.RLock()
|
|
for vk, vq := range pm.versionedQueues {
|
|
if vk.buildId == "" || vk.deploymentSeriesName == "" {
|
|
continue // v3 only
|
|
}
|
|
setLevels(vk, vq)
|
|
}
|
|
pm.versionedQueuesLock.RUnlock()
|
|
|
|
if maps.Equal(backlogPriority, prevBacklogPriority) {
|
|
return prevBacklogPriority
|
|
}
|
|
|
|
pm.userDataManager.LocalBacklogPriorityChanged(backlogPriority)
|
|
return backlogPriority
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) emitLogicalBacklogMetrics(ctx context.Context) error {
|
|
for {
|
|
interval := pm.config.BacklogMetricsEmitInterval()
|
|
if interval == 0 { // disabled
|
|
_ = util.InterruptibleSleep(ctx, time.Minute)
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
continue
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(backoff.Jitter(interval, 0.05)):
|
|
pm.fetchAndEmitLogicalBacklogMetrics(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// fetchAndEmitLogicalBacklogMetrics calls Describe to get attributed backlog stats and emits
|
|
// approximate_backlog_count and approximate_backlog_age_seconds per version. These metrics
|
|
// reflect versioning attribution: for current/ramping versions, a proportional share of the
|
|
// unversioned queue's backlog is added to their count, and the unversioned queue's count is
|
|
// reduced accordingly. This ensures metrics match what DescribeTaskQueue returns.
|
|
func (pm *taskQueuePartitionManagerImpl) fetchAndEmitLogicalBacklogMetrics(ctx context.Context) {
|
|
if !pm.config.BreakdownMetricsByTaskQueue() || !pm.config.BreakdownMetricsByPartition() {
|
|
return
|
|
}
|
|
|
|
buildIds := map[string]bool{"": true} // include unversioned
|
|
resp, err := pm.describe(ctx, buildIds, true, true, false, false, true)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for versionKey, vInfo := range resp.GetVersionsInfoInternal() {
|
|
// When BreakdownMetricsByBuildID is disabled, all versioned queues share the same
|
|
// "__versioned__" tag value. Since gauges overwrite on each Record() call, emitting
|
|
// multiple versions under the same tag would produce non-deterministic results.
|
|
// Skip versioned entries in this case; unversioned attributed metrics are still emitted.
|
|
if versionKey != "" && !pm.config.BreakdownMetricsByBuildID() {
|
|
continue
|
|
}
|
|
|
|
pqInfo := vInfo.GetPhysicalTaskQueueInfo()
|
|
|
|
deploymentName, buildID := parseDeploymentFromVersionKey(versionKey)
|
|
versionHandler := pm.metricsHandler.WithTags(
|
|
metrics.WorkerVersionTag(versionKey, pm.config.BreakdownMetricsByBuildID()),
|
|
metrics.WorkerDeploymentNameTag(deploymentName, pm.config.BreakdownMetricsByBuildID()),
|
|
metrics.WorkerDeploymentBuildIDTag(buildID, pm.config.BreakdownMetricsByBuildID()),
|
|
)
|
|
|
|
// Per-priority backlog count
|
|
for pri, stats := range pqInfo.GetTaskQueueStatsByPriorityKey() {
|
|
metrics.ApproximateBacklogCount.With(versionHandler).Record(
|
|
float64(stats.GetApproximateBacklogCount()),
|
|
metrics.MatchingTaskPriorityTag(pri),
|
|
)
|
|
}
|
|
|
|
// Backlog age (oldest across all priorities)
|
|
age := pqInfo.GetTaskQueueStats().GetApproximateBacklogAge()
|
|
if age != nil && age.AsDuration() > 0 {
|
|
metrics.ApproximateBacklogAgeSeconds.With(versionHandler).Record(age.AsDuration().Seconds())
|
|
} else {
|
|
metrics.ApproximateBacklogAgeSeconds.With(versionHandler).Record(0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// emitZeroLogicalBacklogForQueue zeroes out logical backlog gauges for a single physical queue
|
|
// to prevent stale values after unloading. Called from:
|
|
// - Stop(): after each physical queue is stopped during full partition unload.
|
|
// - unloadPhysicalQueue(): before a versioned queue is removed from the map during individual
|
|
// unload (idle timeout, ownership conflict, init error, or other fatal backlog manager errors).
|
|
//
|
|
// Only zeroes priority keys that actually exist in the queue's own subqueues to avoid creating
|
|
// noisy zero-value series. Note: for current/ramping versions, fetchAndEmitLogicalBacklogMetrics
|
|
// may emit additional priority keys attributed from the default queue via mergeStatsByPriority.
|
|
// Those attributed-only keys are not zeroed here, which could leave stale gauge values for
|
|
// priority keys that existed only through attribution.
|
|
func (pm *taskQueuePartitionManagerImpl) emitZeroLogicalBacklogForQueue(version PhysicalTaskQueueVersion, pq physicalTaskQueueManager) {
|
|
if !pm.config.BreakdownMetricsByTaskQueue() || !pm.config.BreakdownMetricsByPartition() {
|
|
return
|
|
}
|
|
deploymentName, buildID := parseDeploymentFromVersionKey(version.MetricsTagValue())
|
|
handler := pm.metricsHandler.WithTags(
|
|
metrics.WorkerVersionTag(version.MetricsTagValue(), pm.config.BreakdownMetricsByBuildID()),
|
|
metrics.WorkerDeploymentNameTag(deploymentName, pm.config.BreakdownMetricsByBuildID()),
|
|
metrics.WorkerDeploymentBuildIDTag(buildID, pm.config.BreakdownMetricsByBuildID()),
|
|
)
|
|
for pri := range pq.GetStatsByPriority(false) {
|
|
metrics.ApproximateBacklogCount.With(handler).Record(0, metrics.MatchingTaskPriorityTag(pri))
|
|
}
|
|
metrics.ApproximateBacklogAgeSeconds.With(handler).Record(0)
|
|
}
|
|
|
|
// parseDeploymentFromVersionKey extracts the deployment name and build ID from a version key
|
|
// string used as the map key in DescribeTaskQueuePartitionResponse.VersionsInfoInternal.
|
|
// The key format is "deploymentName:buildId" for V3 deployment-based versions, or empty for
|
|
// unversioned queues. Returns empty strings when the delimiter is not found (unversioned or
|
|
// V2 version-set keys).
|
|
func parseDeploymentFromVersionKey(versionKey string) (deploymentName, buildID string) {
|
|
if name, id, found := strings.Cut(versionKey, worker_versioning.WorkerDeploymentVersionDelimiter); found {
|
|
return name, id
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) ephemeralDataChanged(data *taskqueuespb.EphemeralData) {
|
|
// for now, only sticky partitions act on ephemeral data, normal partitions ignore it.
|
|
if pm.partition.Kind() != enumspb.TASK_QUEUE_KIND_STICKY {
|
|
return
|
|
}
|
|
dbq, err := pm.defaultQueueFuture.GetIfReady()
|
|
if err != nil {
|
|
return // not initialized yet
|
|
}
|
|
|
|
// transpose map to more useful form
|
|
updates := make(map[PhysicalTaskQueueVersion]remotePriorityBacklogSet) // version -> {partition id, max level w/backlog}
|
|
|
|
for _, part := range data.GetPartition() {
|
|
for _, verData := range part.GetVersion() {
|
|
versionKey := PhysicalTaskQueueVersion{
|
|
buildId: verData.Version.GetBuildId(),
|
|
deploymentSeriesName: verData.Version.GetDeploymentName(),
|
|
}
|
|
byVersion := util.GetOrSetMap(updates, versionKey)
|
|
levels := uint64(verData.BacklogPriorityLevels)
|
|
for pri := bits.TrailingZeros64(levels); pri != 64; pri = bits.TrailingZeros64(levels) {
|
|
levels &^= 1 << pri
|
|
backlogKey := remotePriorityBacklog{
|
|
partition: part.Partition,
|
|
priority: priorityKey(pri),
|
|
}
|
|
byVersion[backlogKey] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
update := func(key PhysicalTaskQueueVersion, pqm physicalTaskQueueManager) {
|
|
pqm.UpdateRemotePriorityBacklogs(updates[key])
|
|
}
|
|
|
|
if dbq != nil {
|
|
update(PhysicalTaskQueueVersion{}, dbq)
|
|
}
|
|
|
|
pm.versionedQueuesLock.RLock()
|
|
defer pm.versionedQueuesLock.RUnlock()
|
|
|
|
for key, pqm := range pm.versionedQueues {
|
|
update(key, pqm)
|
|
}
|
|
}
|
|
|
|
func cloneTaskQueueStats(in *taskqueuepb.TaskQueueStats) *taskqueuepb.TaskQueueStats {
|
|
if in == nil {
|
|
return &taskqueuepb.TaskQueueStats{ApproximateBacklogAge: durationpb.New(0)}
|
|
}
|
|
age := in.GetApproximateBacklogAge()
|
|
if age == nil {
|
|
age = durationpb.New(0)
|
|
}
|
|
return &taskqueuepb.TaskQueueStats{
|
|
ApproximateBacklogCount: in.GetApproximateBacklogCount(),
|
|
ApproximateBacklogAge: durationpb.New(age.AsDuration()),
|
|
TasksAddRate: in.GetTasksAddRate(),
|
|
TasksDispatchRate: in.GetTasksDispatchRate(),
|
|
}
|
|
}
|
|
|
|
func cloneStatsByPriority(in map[int32]*taskqueuepb.TaskQueueStats) map[int32]*taskqueuepb.TaskQueueStats {
|
|
out := make(map[int32]*taskqueuepb.TaskQueueStats, len(in))
|
|
for pri, s := range in {
|
|
out[pri] = cloneTaskQueueStats(s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// splitTaskQueueStatsByRampPercentage splits a task queue stats record into "current" and "ramping" shares.
|
|
// This split is applied independently per priority bucket (see splitStatsByPriorityByRampPercentage).
|
|
func splitTaskQueueStatsByRampPercentage(
|
|
in *taskqueuepb.TaskQueueStats,
|
|
rampPct float32,
|
|
) (currentShare *taskqueuepb.TaskQueueStats, rampShare *taskqueuepb.TaskQueueStats) {
|
|
if in == nil {
|
|
in = &taskqueuepb.TaskQueueStats{ApproximateBacklogAge: durationpb.New(0)}
|
|
}
|
|
|
|
total := in.GetApproximateBacklogCount()
|
|
// We intentionally bias towards over-attribution ("safe side"):
|
|
// compute both shares with ceil. This can result in currentCount+rampCount > total.
|
|
rampCount := int64(math.Ceil(float64(total) * float64(rampPct) / 100.0))
|
|
currentCount := int64(math.Ceil(float64(total) * float64(100.0-rampPct) / 100.0))
|
|
|
|
// Just being defensive here; should never happen.
|
|
if rampCount < 0 {
|
|
rampCount = 0
|
|
}
|
|
if currentCount < 0 {
|
|
currentCount = 0
|
|
}
|
|
|
|
rampAddRate := in.GetTasksAddRate() * rampPct / 100
|
|
rampDispatchRate := in.GetTasksDispatchRate() * rampPct / 100
|
|
|
|
age := in.GetApproximateBacklogAge()
|
|
if age == nil {
|
|
age = durationpb.New(0)
|
|
}
|
|
|
|
// Rather than splitting the backlog age, we set it to the oldest backlog age for both the current and the ramping shares.
|
|
currentAge := durationpb.New(age.AsDuration())
|
|
rampAge := durationpb.New(age.AsDuration())
|
|
if currentCount == 0 {
|
|
currentAge = durationpb.New(0)
|
|
}
|
|
if rampCount == 0 {
|
|
rampAge = durationpb.New(0)
|
|
}
|
|
|
|
currentShare = &taskqueuepb.TaskQueueStats{
|
|
ApproximateBacklogCount: currentCount,
|
|
ApproximateBacklogAge: currentAge,
|
|
TasksAddRate: in.GetTasksAddRate() - rampAddRate,
|
|
TasksDispatchRate: in.GetTasksDispatchRate() - rampDispatchRate,
|
|
}
|
|
rampShare = &taskqueuepb.TaskQueueStats{
|
|
ApproximateBacklogCount: rampCount,
|
|
ApproximateBacklogAge: rampAge,
|
|
TasksAddRate: rampAddRate,
|
|
TasksDispatchRate: rampDispatchRate,
|
|
}
|
|
return currentShare, rampShare
|
|
}
|
|
|
|
// splitStatsByPriorityByRampPercentage splits each priority bucket independently by ramp percentage.
|
|
// This is required so that TaskQueueStatsByPriorityKey aligns with TaskQueueStats (which is derived from it).
|
|
func splitStatsByPriorityByRampPercentage(
|
|
statsByPriority map[int32]*taskqueuepb.TaskQueueStats,
|
|
rampPct float32,
|
|
) (currentShareByPriority, rampShareByPriority map[int32]*taskqueuepb.TaskQueueStats) {
|
|
currentShareByPriority = make(map[int32]*taskqueuepb.TaskQueueStats, len(statsByPriority))
|
|
rampShareByPriority = make(map[int32]*taskqueuepb.TaskQueueStats, len(statsByPriority))
|
|
for pri, s := range statsByPriority {
|
|
current, ramp := splitTaskQueueStatsByRampPercentage(s, rampPct)
|
|
currentShareByPriority[pri] = current
|
|
rampShareByPriority[pri] = ramp
|
|
}
|
|
return currentShareByPriority, rampShareByPriority
|
|
}
|
|
|
|
func ensureStatsWithAge(stats map[int32]*taskqueuepb.TaskQueueStats, pri int32) {
|
|
if _, ok := stats[pri]; !ok {
|
|
stats[pri] = &taskqueuepb.TaskQueueStats{ApproximateBacklogAge: durationpb.New(0)}
|
|
return
|
|
}
|
|
if stats[pri].GetApproximateBacklogAge() == nil {
|
|
stats[pri].ApproximateBacklogAge = durationpb.New(0)
|
|
}
|
|
}
|
|
|
|
func mergeStatsByPriority(into, from map[int32]*taskqueuepb.TaskQueueStats) {
|
|
if len(from) == 0 {
|
|
return
|
|
}
|
|
for pri, s := range from {
|
|
if s == nil {
|
|
continue
|
|
}
|
|
ensureStatsWithAge(into, pri)
|
|
// MergeStats requires non-nil ApproximateBacklogAge on both inputs.
|
|
s = cloneTaskQueueStats(s)
|
|
taskqueue.MergeStats(into[pri], s)
|
|
}
|
|
}
|
|
|
|
func subtractStats(into *taskqueuepb.TaskQueueStats, sub *taskqueuepb.TaskQueueStats) {
|
|
if into == nil || sub == nil {
|
|
return
|
|
}
|
|
into.ApproximateBacklogCount = max(0, into.GetApproximateBacklogCount()-sub.GetApproximateBacklogCount())
|
|
into.TasksAddRate = max(0, into.GetTasksAddRate()-sub.GetTasksAddRate())
|
|
into.TasksDispatchRate = max(0, into.GetTasksDispatchRate()-sub.GetTasksDispatchRate())
|
|
|
|
// Rather than splitting the backlog age, we set it to the oldest backlog age for both the current and the ramping shares.
|
|
// However, if the backlog count is zero, we set the backlog age to zero.
|
|
if into.GetApproximateBacklogCount() == 0 || into.GetApproximateBacklogAge() == nil {
|
|
into.ApproximateBacklogAge = durationpb.New(0)
|
|
}
|
|
}
|
|
|
|
func subtractStatsByPriority(into, sub map[int32]*taskqueuepb.TaskQueueStats) {
|
|
if len(sub) == 0 || len(into) == 0 {
|
|
return
|
|
}
|
|
for pri, s := range sub {
|
|
if s == nil {
|
|
continue
|
|
}
|
|
existing, ok := into[pri]
|
|
if !ok || existing == nil {
|
|
continue
|
|
}
|
|
subtractStats(existing, s)
|
|
}
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) Partition() tqid.Partition {
|
|
return pm.partition
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) PartitionCount() int {
|
|
if !pm.partition.SupportsPartitions() {
|
|
return 1
|
|
}
|
|
return max(pm.config.NumWritePartitions(), pm.config.NumReadPartitions())
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) LongPollExpirationInterval() time.Duration {
|
|
return pm.config.LongPollExpirationInterval()
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) callerInfoContext(ctx context.Context) context.Context {
|
|
return headers.SetCallerInfo(ctx, headers.NewBackgroundHighCallerInfo(pm.ns.Name().String()))
|
|
}
|
|
|
|
// ForceLoadAllChildPartitions force-loads known child (read) partitions in new goroutines.
|
|
func (pm *taskQueuePartitionManagerImpl) ForceLoadAllChildPartitions() {
|
|
if !pm.partition.IsRoot() {
|
|
return
|
|
}
|
|
|
|
partitions := pm.userDataManager.PartitionScale().GetRead()
|
|
if partitions == 0 {
|
|
partitions = int32(pm.config.NumReadPartitions())
|
|
}
|
|
if partitions <= 1 {
|
|
return
|
|
}
|
|
|
|
// record total-1 as we won't try to load the root partition.
|
|
pm.metricsHandler.Counter(metrics.ForceLoadedTaskQueuePartitions.Name()).Record(int64(partitions) - 1)
|
|
|
|
for id := int32(1); id < partitions; id++ {
|
|
go func() {
|
|
ctx := pm.callerInfoContext(context.Background())
|
|
resp, err := pm.matchingClient.ForceLoadTaskQueuePartition(ctx, &matchingservice.ForceLoadTaskQueuePartitionRequest{
|
|
NamespaceId: pm.partition.NamespaceId(),
|
|
TaskQueuePartition: &taskqueuespb.TaskQueuePartition{
|
|
TaskQueue: pm.partition.TaskQueue().Name(),
|
|
TaskQueueType: pm.partition.TaskQueue().TaskType(),
|
|
PartitionId: &taskqueuespb.TaskQueuePartition_NormalPartitionId{NormalPartitionId: id},
|
|
},
|
|
})
|
|
if err != nil {
|
|
pm.logger.Error("failed to load child partition after root partition was loaded", tag.Error(err))
|
|
return
|
|
}
|
|
|
|
if !resp.WasUnloaded {
|
|
// For the typical TaskQueue with 4 partitions, there is a 1/4 chance
|
|
// that a poller is load balanced to the root partition first.
|
|
// This metric will be used in conjunction with the one called earlier in the function
|
|
// To identify if we are making excessive/unnecessary/wasteful RPCs.
|
|
pm.metricsHandler.Counter(metrics.ForceLoadedTaskQueuePartitionUnnecessarilyCounter.Name()).Record(1)
|
|
}
|
|
|
|
}()
|
|
}
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) unloadPhysicalQueue(unloadedDbq physicalTaskQueueManager, unloadCause unloadCause) {
|
|
version := unloadedDbq.QueueKey().Version()
|
|
|
|
if !version.IsVersioned() {
|
|
dbq := pm.defaultQueue()
|
|
// this is the default queue, unload the whole partition if it is not healthy
|
|
if dbq != nil && dbq == unloadedDbq {
|
|
pm.unloadFromEngine(unloadCause)
|
|
}
|
|
return
|
|
}
|
|
|
|
pm.versionedQueuesLock.Lock()
|
|
foundDbq, ok := pm.versionedQueues[version]
|
|
if ok && foundDbq == unloadedDbq {
|
|
// Zero logical backlog metrics before removing from map to prevent stale gauges.
|
|
pm.emitZeroLogicalBacklogForQueue(version, foundDbq)
|
|
delete(pm.versionedQueues, version)
|
|
}
|
|
pm.versionedQueuesLock.Unlock()
|
|
|
|
unloadedDbq.Stop(unloadCause)
|
|
|
|
// Here we're unloading a versioned queue but not unloading the whole partition. With new
|
|
// matcher, the matcher may be holding tasks that came from other versioned queues
|
|
// (including the default queue). We need to ensure we send those tasks back to get
|
|
// reprocessed (which may end up reloading a new instance of this queue).
|
|
unloadedDbq.ReprocessRedirectedTasksAfterStop()
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) unloadFromEngine(unloadCause unloadCause) {
|
|
pm.engine.unloadTaskQueuePartition(pm, unloadCause)
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) getPhysicalQueue(ctx context.Context, buildId string, deployment *deploymentpb.Deployment) (physicalTaskQueueManager, error) {
|
|
if buildId == "" {
|
|
dbq := pm.defaultQueue()
|
|
if dbq == nil {
|
|
return nil, errDefaultQueueNotInit
|
|
}
|
|
return dbq, nil
|
|
}
|
|
|
|
return pm.getVersionedQueue(ctx, "", buildId, deployment, true)
|
|
}
|
|
|
|
// Pass either versionSet or build ID
|
|
func (pm *taskQueuePartitionManagerImpl) getVersionedQueue(
|
|
ctx context.Context,
|
|
versionSet string,
|
|
buildId string,
|
|
deployment *deploymentpb.Deployment,
|
|
create bool,
|
|
) (physicalTaskQueueManager, error) {
|
|
if !pm.partition.SupportsVersioning() {
|
|
return nil, serviceerror.NewInternal("versioned queues can't be used in partitions that don't support versioning")
|
|
}
|
|
if versionSet == "" && buildId == "" && deployment == nil {
|
|
return nil, serviceerror.NewInternal("deployment or build ID or version set should be given for a versioned queue")
|
|
}
|
|
tqm, err := pm.getVersionedQueueNoWait(versionSet, buildId, deployment, create)
|
|
if err != nil || tqm == nil {
|
|
return nil, err
|
|
}
|
|
if err = tqm.WaitUntilInitialized(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return tqm, nil
|
|
}
|
|
|
|
// Returns physicalTaskQueueManager for a task queue. If not already cached, and create is true, tries
|
|
// to get new range from DB and create one. This does not block for the task queue to be
|
|
// initialized.
|
|
// Pass either versionSet or build ID
|
|
func (pm *taskQueuePartitionManagerImpl) getVersionedQueueNoWait(
|
|
versionSet string,
|
|
buildId string,
|
|
deployment *deploymentpb.Deployment,
|
|
create bool,
|
|
) (physicalTaskQueueManager, error) {
|
|
key := PhysicalTaskQueueVersion{
|
|
versionSet: versionSet,
|
|
buildId: buildId,
|
|
}
|
|
if deployment != nil {
|
|
key.deploymentSeriesName = deployment.GetSeriesName()
|
|
key.buildId = deployment.GetBuildId()
|
|
}
|
|
|
|
pm.versionedQueuesLock.RLock()
|
|
vq, ok := pm.versionedQueues[key]
|
|
pm.versionedQueuesLock.RUnlock()
|
|
if !ok {
|
|
if !create {
|
|
return nil, nil
|
|
}
|
|
|
|
// If it gets here, write lock and check again in case a task queue is created between the two locks
|
|
pm.versionedQueuesLock.Lock()
|
|
vq, ok = pm.versionedQueues[key]
|
|
if !ok {
|
|
var err error
|
|
var dbq *PhysicalTaskQueueKey
|
|
if deployment != nil {
|
|
dbq = DeploymentQueueKey(pm.partition, deployment)
|
|
} else if buildId != "" {
|
|
dbq = BuildIdQueueKey(pm.partition, buildId)
|
|
} else {
|
|
dbq = VersionSetQueueKey(pm.partition, versionSet)
|
|
}
|
|
vq, err = newPhysicalTaskQueueManager(pm, dbq)
|
|
if err != nil {
|
|
pm.versionedQueuesLock.Unlock()
|
|
return nil, err
|
|
}
|
|
pm.versionedQueues[key] = vq
|
|
}
|
|
pm.versionedQueuesLock.Unlock()
|
|
|
|
if !ok {
|
|
vq.Start()
|
|
}
|
|
}
|
|
return vq, nil
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) getVersionSetForPoll(
|
|
caps *commonpb.WorkerVersionCapabilities,
|
|
versioningData *persistencespb.VersioningData,
|
|
) (string, error) {
|
|
primarySetId, demotedSetIds, unknownBuild, err := lookupVersionSetForPoll(versioningData, caps)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if unknownBuild {
|
|
// if the build ID is unknown, we assume user is using the new API
|
|
return "", nil
|
|
}
|
|
pm.loadDemotedSetIds(demotedSetIds)
|
|
|
|
return primarySetId, nil
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) loadDemotedSetIds(demotedSetIds []string) {
|
|
// If we have demoted set ids, we need to load all task queues for them because even though
|
|
// no new tasks will be sent to them, they might have old tasks in the db.
|
|
// Also mark them alive, so that their liveness will be roughly synchronized.
|
|
// TODO: once we know a demoted set id has no more tasks, we can remove it from versioning data
|
|
for _, demotedSetId := range demotedSetIds {
|
|
tqm, _ := pm.getVersionedQueueNoWait(demotedSetId, "", nil, true)
|
|
if tqm != nil {
|
|
tqm.MarkAlive()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) loadUpstreamBuildIds(
|
|
targetBuildId string,
|
|
activeRules []*persistencespb.RedirectRule,
|
|
) {
|
|
// We need to load all physical queues for upstream build IDs because even though
|
|
// no new tasks will be sent to them, they might have old tasks in the db.
|
|
// Also mark them alive, so that their liveness will be roughly synchronized.
|
|
// TODO: make this more efficient so it does not MarkAlive on every poll
|
|
for _, buildId := range getUpstreamBuildIds(targetBuildId, activeRules) {
|
|
tqm, _ := pm.getVersionedQueueNoWait("", buildId, nil, true)
|
|
if tqm != nil {
|
|
tqm.MarkAlive()
|
|
}
|
|
}
|
|
}
|
|
|
|
// getPhysicalQueuesForAdd returns two physical queues, first one for spooling the task, second one for sync-match
|
|
// spoolQueue will be nil iff the task is forwarded (forwardInfo is nil).
|
|
//
|
|
//nolint:revive // cognitive complexity will be reduced once old versioning is deleted [cleanup-old-wv]
|
|
func (pm *taskQueuePartitionManagerImpl) getPhysicalQueuesForAdd(
|
|
ctx context.Context,
|
|
directive *taskqueuespb.TaskVersionDirective,
|
|
forwardInfo *taskqueuespb.TaskForwardInfo,
|
|
runId string,
|
|
workflowId string,
|
|
isQuery bool,
|
|
) (
|
|
spoolQueue physicalTaskQueueManager,
|
|
syncMatchQueue physicalTaskQueueManager,
|
|
userDataChanged <-chan struct{},
|
|
rcRevisionNumber int64,
|
|
targetVersion *deploymentspb.WorkerDeploymentVersion,
|
|
err error,
|
|
) {
|
|
// Note: Revision number mechanics are only involved if the dynamic config, UseRevisionNumberForWorkerVersioning, is enabled.
|
|
// Represents the revision number used by the task and is max(taskDirectiveRevisionNumber, routingConfigRevisionNumber) for the task.
|
|
var taskDispatchRevisionNumber, targetDeploymentRevisionNumber int64
|
|
|
|
wfBehavior := directive.GetBehavior()
|
|
deployment := worker_versioning.DirectiveDeployment(directive)
|
|
|
|
perTypeUserData, userDataChanged, err := pm.getPerTypeUserData()
|
|
if err != nil {
|
|
return nil, nil, nil, 0, nil, err
|
|
}
|
|
deploymentData := perTypeUserData.GetDeploymentData()
|
|
taskDirectiveRevisionNumber := directive.GetRevisionNumber()
|
|
|
|
dbq := pm.defaultQueue()
|
|
if dbq == nil {
|
|
return nil, nil, nil, 0, nil, errDefaultQueueNotInit
|
|
}
|
|
|
|
current, currentRevisionNumber, _, ramping, _, rampingPercentage, rampingRevisionNumber, _ := worker_versioning.CalculateTaskQueueVersioningInfo(deploymentData)
|
|
targetDeploymentVersion, targetDeploymentRevisionNumber := worker_versioning.FindTargetDeploymentVersionAndRevisionNumberForWorkflowID(
|
|
current,
|
|
currentRevisionNumber,
|
|
ramping,
|
|
rampingPercentage,
|
|
rampingRevisionNumber,
|
|
workflowId,
|
|
directive.GetUseRampingVersion(),
|
|
)
|
|
targetDeployment := worker_versioning.DeploymentFromDeploymentVersion(targetDeploymentVersion)
|
|
|
|
if wfBehavior == enumspb.VERSIONING_BEHAVIOR_PINNED {
|
|
if pm.partition.Kind() == enumspb.TASK_QUEUE_KIND_STICKY {
|
|
// TODO (shahab): we can verify the passed deployment matches the last poller's deployment
|
|
return dbq, dbq, userDataChanged, targetDeploymentRevisionNumber, targetDeploymentVersion, nil
|
|
}
|
|
|
|
err = worker_versioning.ValidateDeployment(deployment)
|
|
if err != nil {
|
|
return nil, nil, nil, 0, nil, err
|
|
}
|
|
|
|
// Preventing Query tasks from being dispatched to a drained version with no workers
|
|
if isQuery {
|
|
if err := pm.checkQueryBlackholed(deploymentData, deployment); err != nil {
|
|
return nil, nil, nil, 0, nil, err
|
|
}
|
|
}
|
|
|
|
// We ignore the pinned directive if this is an activity task but the activity task queue is
|
|
// not present in the workflow's pinned deployment. Such activities are considered
|
|
// independent activities and are treated as unpinned, sent to their TQ's current deployment.
|
|
|
|
var isIndependentPinnedActivity bool
|
|
if pm.partition.TaskType() == enumspb.TASK_QUEUE_TYPE_ACTIVITY {
|
|
// We need to check both the deployment data formats to be sure if we can ignore the pinned directive on the activity task.
|
|
if !worker_versioning.HasDeploymentVersion(deploymentData, worker_versioning.DeploymentVersionFromDeployment(deployment)) {
|
|
isIndependentPinnedActivity = true
|
|
}
|
|
}
|
|
|
|
if !isIndependentPinnedActivity {
|
|
pinnedQueue, err := pm.getVersionedQueue(ctx, "", "", deployment, true)
|
|
if err != nil {
|
|
return nil, nil, nil, 0, nil, err
|
|
}
|
|
if forwardInfo == nil {
|
|
// Task is not forwarded, so it can be spooled if sync match fails.
|
|
// Spool queue and sync match queue is the same for pinned workflows.
|
|
return pinnedQueue, pinnedQueue, userDataChanged, targetDeploymentRevisionNumber, targetDeploymentVersion, nil
|
|
} else {
|
|
// Forwarded from child partition - only do sync match.
|
|
return nil, pinnedQueue, userDataChanged, targetDeploymentRevisionNumber, targetDeploymentVersion, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
var targetDeploymentQueue physicalTaskQueueManager
|
|
if directive.GetAssignedBuildId() == "" && targetDeployment != nil {
|
|
if pm.partition.Kind() == enumspb.TASK_QUEUE_KIND_STICKY {
|
|
if !deployment.Equal(targetDeployment) {
|
|
// Current deployment has changed, so the workflow should move to a normal queue to
|
|
// get redirected to the new deployment.
|
|
return nil, nil, nil, 0, nil, serviceerrors.NewStickyWorkerUnavailable()
|
|
}
|
|
|
|
// TODO (shahab): we can verify the passed deployment matches the last poller's deployment
|
|
return dbq, dbq, userDataChanged, 0, targetDeploymentVersion, nil
|
|
}
|
|
|
|
var err error
|
|
targetDeploymentQueue, taskDispatchRevisionNumber, err = pm.chooseTargetQueueByFlag(
|
|
ctx, deployment, targetDeployment, targetDeploymentRevisionNumber, taskDirectiveRevisionNumber,
|
|
)
|
|
if err != nil {
|
|
return nil, nil, nil, 0, nil, err
|
|
}
|
|
targetDeploymentVersion = worker_versioning.DeploymentVersionFromDeployment(targetDeploymentQueue.QueueKey().Version().Deployment())
|
|
|
|
if forwardInfo == nil {
|
|
// Task is not forwarded, so it can be spooled if sync match fails.
|
|
// Unpinned tasks are spooled in default queue
|
|
return dbq, targetDeploymentQueue, userDataChanged, taskDispatchRevisionNumber, targetDeploymentVersion, err
|
|
} else {
|
|
// Forwarded from child partition - only do sync match.
|
|
return nil, targetDeploymentQueue, userDataChanged, taskDispatchRevisionNumber, targetDeploymentVersion, err
|
|
}
|
|
}
|
|
|
|
if forwardInfo != nil {
|
|
// Forwarded from child partition - only do sync match.
|
|
// No need to calculate build ID, just dispatch based on source partition's instructions.
|
|
if forwardInfo.DispatchVersionSet == "" && forwardInfo.DispatchBuildId == "" {
|
|
syncMatchQueue = dbq
|
|
} else {
|
|
syncMatchQueue, err = pm.getVersionedQueue(
|
|
ctx,
|
|
forwardInfo.DispatchVersionSet,
|
|
forwardInfo.DispatchBuildId,
|
|
nil,
|
|
true,
|
|
)
|
|
}
|
|
return nil, syncMatchQueue, nil, taskDispatchRevisionNumber, targetDeploymentVersion, err
|
|
}
|
|
|
|
if directive.GetBuildId() == nil {
|
|
// The task belongs to an unversioned execution. Keep using unversioned. But also return
|
|
// userDataChanged so if current deployment is set, the task redirects to that deployment.
|
|
return dbq, dbq, userDataChanged, taskDispatchRevisionNumber, targetDeploymentVersion, nil
|
|
}
|
|
|
|
userData, userDataChanged, err := pm.userDataManager.GetUserData()
|
|
if err != nil {
|
|
return nil, nil, nil, 0, nil, err
|
|
}
|
|
|
|
data := userData.GetData().GetVersioningData()
|
|
|
|
var buildId, redirectBuildId string
|
|
var versionSet string
|
|
switch dir := directive.GetBuildId().(type) {
|
|
case *taskqueuespb.TaskVersionDirective_UseAssignmentRules:
|
|
// Need to assign build ID. Assignment rules take precedence, fallback to version sets if no matching rule is found
|
|
if len(data.GetAssignmentRules()) > 0 {
|
|
buildId = FindAssignmentBuildId(data.GetAssignmentRules(), runId)
|
|
}
|
|
if buildId == "" {
|
|
versionSet, err = pm.getVersionSetForAdd(directive, data)
|
|
if err != nil {
|
|
return nil, nil, nil, 0, nil, err
|
|
}
|
|
}
|
|
case *taskqueuespb.TaskVersionDirective_AssignedBuildId:
|
|
// Already assigned, need to stay in the same version set or build ID. If TQ has version sets, first try to
|
|
// redirect to the version set based on the build ID. If the build ID does not belong to any version set,
|
|
// assume user wants to use the new API
|
|
if len(data.GetVersionSets()) > 0 {
|
|
versionSet, err = pm.getVersionSetForAdd(directive, data)
|
|
if err != nil {
|
|
return nil, nil, nil, 0, nil, err
|
|
}
|
|
}
|
|
if versionSet == "" {
|
|
buildId = dir.AssignedBuildId
|
|
}
|
|
}
|
|
|
|
redirectBuildId = FindRedirectBuildId(buildId, data.GetRedirectRules())
|
|
|
|
if pm.partition.Kind() == enumspb.TASK_QUEUE_KIND_STICKY {
|
|
// We may kick off this worker if there's a new default build ID in the version set.
|
|
// unknownBuild flag is ignored because we don't have any special logic for it anymore. unknown build can
|
|
// happen in two scenarios:
|
|
// - task queue is switching to the new versioning API and the build ID is not registered in version sets.
|
|
// - task queue is still using the old API but a failover happened before verisoning data fully propagate.
|
|
// the second case is unlikely, and we do not support it anymore considering the old API is deprecated.
|
|
// TODO: [cleanup-old-wv]
|
|
_, err = checkVersionForStickyAdd(data, directive.GetAssignedBuildId())
|
|
if err != nil {
|
|
return nil, nil, nil, 0, nil, err
|
|
}
|
|
if buildId != redirectBuildId {
|
|
// redirect rule added for buildId, kick task back to normal queue
|
|
// TODO (shahab): support V3 in here
|
|
return nil, nil, nil, 0, nil, serviceerrors.NewStickyWorkerUnavailable()
|
|
}
|
|
// sticky queues only use default queue
|
|
return dbq, dbq, userDataChanged, 0, targetDeploymentVersion, nil
|
|
}
|
|
|
|
if versionSet != "" {
|
|
spoolQueue = pm.defaultQueue()
|
|
if spoolQueue == nil {
|
|
return nil, nil, nil, 0, nil, errDefaultQueueNotInit
|
|
}
|
|
syncMatchQueue, err = pm.getVersionedQueue(ctx, versionSet, "", nil, true)
|
|
if err != nil {
|
|
return nil, nil, nil, 0, nil, err
|
|
}
|
|
} else {
|
|
syncMatchQueue, err = pm.getPhysicalQueue(ctx, redirectBuildId, nil)
|
|
if err != nil {
|
|
return nil, nil, nil, 0, nil, err
|
|
}
|
|
// redirect rules are not applied when spooling a task. They'll be applied when dispatching the spool task.
|
|
spoolQueue, err = pm.getPhysicalQueue(ctx, buildId, nil)
|
|
if err != nil {
|
|
return nil, nil, nil, 0, nil, err
|
|
}
|
|
}
|
|
|
|
return spoolQueue, syncMatchQueue, userDataChanged, taskDispatchRevisionNumber, targetDeploymentVersion, err
|
|
}
|
|
|
|
// chooseTargetQueueByFlag picks the target queue and dispatch revision number.
|
|
// If UseRevisionNumberForWorkerVersioning is enabled, it uses the revision
|
|
// comparison; otherwise it always chooses targetDeployment.
|
|
func (pm *taskQueuePartitionManagerImpl) chooseTargetQueueByFlag(
|
|
ctx context.Context,
|
|
taskDeployment *deploymentpb.Deployment,
|
|
targetDeployment *deploymentpb.Deployment,
|
|
targetDeploymentRevisionNumber int64,
|
|
taskDirectiveRevisionNumber int64,
|
|
) (physicalTaskQueueManager, int64, error) {
|
|
|
|
if pm.engine != nil && pm.engine.config.UseRevisionNumberForWorkerVersioning(pm.Namespace().Name().String()) {
|
|
if targetDeployment.GetSeriesName() != taskDeployment.GetSeriesName() || targetDeploymentRevisionNumber >= taskDirectiveRevisionNumber {
|
|
q, err := pm.getVersionedQueue(ctx, "", "", targetDeployment, true)
|
|
return q, targetDeploymentRevisionNumber, err
|
|
}
|
|
q, err := pm.getVersionedQueue(ctx, "", "", taskDeployment, true)
|
|
return q, taskDirectiveRevisionNumber, err
|
|
}
|
|
|
|
// When not using revision number mechanics, always choose the targetDeployment.
|
|
q, err := pm.getVersionedQueue(ctx, "", "", targetDeployment, true)
|
|
return q, targetDeploymentRevisionNumber, err
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) getVersionSetForAdd(directive *taskqueuespb.TaskVersionDirective, data *persistencespb.VersioningData) (string, error) {
|
|
var buildId string
|
|
switch dir := directive.GetBuildId().(type) {
|
|
case *taskqueuespb.TaskVersionDirective_UseAssignmentRules:
|
|
// leave buildId = "", lookupVersionSetForAdd understands that to mean "default"
|
|
case *taskqueuespb.TaskVersionDirective_AssignedBuildId:
|
|
buildId = dir.AssignedBuildId
|
|
default:
|
|
// Unversioned task, leave on unversioned queue.
|
|
return "", nil
|
|
}
|
|
|
|
versionSet, unknownBuild, err := lookupVersionSetForAdd(data, buildId)
|
|
if errors.Is(err, errEmptyVersioningData) {
|
|
// default was requested for an unversioned queue
|
|
return "", nil
|
|
} else if err != nil {
|
|
return "", err
|
|
}
|
|
if unknownBuild {
|
|
// this could happen in two scenarios:
|
|
// - task queue is switching to the new versioning API and the build ID is not registered in version sets.
|
|
// - task queue is still using the old API but a failover happened before verisoning data fully propagate.
|
|
// the second case is unlikely, and we do not support it anymore considering the old API is deprecated.
|
|
return "", nil
|
|
}
|
|
|
|
return versionSet, nil
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) recordUnknownBuildPoll(buildId string) {
|
|
pm.logger.Warn("unknown build ID in poll", tag.BuildId(buildId))
|
|
pm.metricsHandler.Counter(metrics.UnknownBuildPollsCounter.Name()).Record(1)
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) recordUnknownBuildTask(buildId string) {
|
|
pm.logger.Warn("unknown build ID in task", tag.BuildId(buildId))
|
|
pm.metricsHandler.Counter(metrics.UnknownBuildTasksCounter.Name()).Record(1)
|
|
}
|
|
|
|
//nolint:staticcheck // SA1019: [cleanup-wv-3.1]
|
|
func (pm *taskQueuePartitionManagerImpl) checkQueryBlackholed(
|
|
deploymentData *persistencespb.DeploymentData,
|
|
deployment *deploymentpb.Deployment,
|
|
) error {
|
|
|
|
// Only perform this check on the root partition.
|
|
// Forwarding (polls/tasks/queries) moves “up” the partition tree, so the root is the convergence point.
|
|
// Checking non-root partitions can incorrectly conclude “no pollers” simply because the pollers are on
|
|
// a different partition and have not been forwarded here (yet), leading to false blackhole errors.
|
|
if !pm.partition.IsRoot() {
|
|
return nil
|
|
}
|
|
// Check old format
|
|
for _, versionData := range deploymentData.GetVersions() {
|
|
if versionData.GetVersion() != nil && worker_versioning.DeploymentVersionFromDeployment(deployment).Equal(versionData.GetVersion()) {
|
|
if versionData.GetStatus() == enumspb.WORKER_DEPLOYMENT_VERSION_STATUS_DRAINED && len(pm.GetAllPollerInfo()) == 0 {
|
|
versionStr := worker_versioning.ExternalWorkerDeploymentVersionToString(worker_versioning.ExternalWorkerDeploymentVersionFromDeployment(deployment))
|
|
return serviceerror.NewFailedPreconditionf(ErrBlackholedQuery, versionStr, versionStr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check new format
|
|
if workerDeploymentData, ok := deploymentData.GetDeploymentsData()[deployment.GetSeriesName()]; ok {
|
|
if workerDeploymentData.GetVersions() != nil && workerDeploymentData.GetVersions()[deployment.GetBuildId()] != nil &&
|
|
workerDeploymentData.GetVersions()[deployment.GetBuildId()].GetStatus() == enumspb.WORKER_DEPLOYMENT_VERSION_STATUS_DRAINED && len(pm.GetAllPollerInfo()) == 0 {
|
|
return serviceerror.NewFailedPreconditionf(ErrBlackholedQuery, deployment.GetBuildId(), deployment.GetBuildId())
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) getPerTypeUserData() (*persistencespb.TaskQueueTypeUserData, <-chan struct{}, error) {
|
|
userData, userDataChanged, err := pm.userDataManager.GetUserData()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
perType := userData.GetData().GetPerType()[int32(pm.Partition().TaskType())]
|
|
return perType, userDataChanged, nil
|
|
}
|
|
|
|
func (pm *taskQueuePartitionManagerImpl) userDataChanged(to *persistencespb.VersionedTaskQueueUserData) {
|
|
// Update rateLimits if any change is userData.
|
|
pm.rateLimitManager.UserDataChanged()
|
|
|
|
// Do not use defaultQueue() because that treats
|
|
// not being ready as an error, which is expected during bringup here.
|
|
defaultQ, err := pm.defaultQueueFuture.GetIfReady()
|
|
// Initialization error or not ready yet
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
taskType := int32(pm.Partition().TaskType())
|
|
if to.GetData().GetPerType()[taskType].GetFairnessState() != pm.fairnessState {
|
|
pm.logger.Debug("unloading partitionManager due to change in FairnessState")
|
|
pm.unloadFromEngine(unloadCauseConfigChange)
|
|
return
|
|
}
|
|
|
|
// Notify all queues so they can re-evaluate their backlog.
|
|
pm.versionedQueuesLock.RLock()
|
|
for _, vq := range pm.versionedQueues {
|
|
go vq.UserDataChanged()
|
|
}
|
|
pm.versionedQueuesLock.RUnlock()
|
|
|
|
// Do this one in this goroutine.
|
|
defaultQ.UserDataChanged()
|
|
}
|