Files
tasks/tests/task_queue_test.go
T
Stephan BehnkeandGitHub a0067a2aef Fairness weight override API (#8379)
## What changed?

Adds the ability to set/unset fairness weight overrides by fairness key
via `UpdateTaskQueueConfig` API.

From the API:

> // Fairness weight for a task can come from multiple sources for
    // flexibility. From highest to lowest precedence:
// 1. Weights for a small set of keys can be overridden in task queue
    //    configuration with an API.
    // 2. It can be attached to the workflow/activity in this field.
    // 3. The default weight of 1.0 will be used.
    //
    // Weight values are clamped to the range [0.001, 1000].

## Why?

To allow users to fine-tune their fairness key behavior via the API. 

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

## Potential risks
N/A
2025-10-17 00:37:31 +00:00

937 lines
36 KiB
Go

package tests
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
commandpb "go.temporal.io/api/command/v1"
commonpb "go.temporal.io/api/common/v1"
enumspb "go.temporal.io/api/enums/v1"
taskqueuepb "go.temporal.io/api/taskqueue/v1"
workflowpb "go.temporal.io/api/workflow/v1"
"go.temporal.io/api/workflowservice/v1"
"go.temporal.io/sdk/activity"
sdkclient "go.temporal.io/sdk/client"
"go.temporal.io/sdk/worker"
"go.temporal.io/sdk/workflow"
"go.temporal.io/server/common"
"go.temporal.io/server/common/dynamicconfig"
"go.temporal.io/server/common/payloads"
"go.temporal.io/server/common/testing/taskpoller"
"go.temporal.io/server/common/testing/testvars"
"go.temporal.io/server/tests/testcore"
"google.golang.org/protobuf/types/known/durationpb"
)
type TaskQueueSuite struct {
testcore.FunctionalTestBase
}
func TestTaskQueueSuite(t *testing.T) {
t.Parallel()
suite.Run(t, new(TaskQueueSuite))
}
func (s *TaskQueueSuite) SetupSuite() {
dynamicConfigOverrides := map[dynamicconfig.Key]any{
dynamicconfig.MatchingNumTaskqueueWritePartitions.Key(): 4,
dynamicconfig.MatchingNumTaskqueueReadPartitions.Key(): 4,
}
s.FunctionalTestBase.SetupSuiteWithCluster(testcore.WithDynamicConfigOverrides(dynamicConfigOverrides))
}
// Not using RunTestWithMatchingBehavior because I want to pass different expected drain times for different configurations
func (s *TaskQueueSuite) TestTaskQueueRateLimit() {
s.RunTaskQueueRateLimitTest(1, 1, 12*time.Second, true) // ~0.75s avg
s.RunTaskQueueRateLimitTest(1, 1, 12*time.Second, false) // ~1.1s avg
// Testing multiple partitions with insufficient pollers is too flaky, because token recycling
// depends on a process being available to accept the token, so I'm not testing it
s.RunTaskQueueRateLimitTest(4, 8, 24*time.Second, true) // ~1.6s avg
s.RunTaskQueueRateLimitTest(4, 8, 24*time.Second, false) // ~6s avg
}
func (s *TaskQueueSuite) RunTaskQueueRateLimitTest(nPartitions, nWorkers int, timeToDrain time.Duration, useNewMatching bool) {
s.Run(s.testTaskQueueRateLimitName(nPartitions, nWorkers, useNewMatching), func() { s.taskQueueRateLimitTest(nPartitions, nWorkers, timeToDrain, useNewMatching) })
}
func (s *TaskQueueSuite) taskQueueRateLimitTest(nPartitions, nWorkers int, timeToDrain time.Duration, useNewMatching bool) {
if useNewMatching {
s.OverrideDynamicConfig(dynamicconfig.MatchingUseNewMatcher, true)
}
s.OverrideDynamicConfig(dynamicconfig.MatchingNumTaskqueueReadPartitions, nPartitions)
s.OverrideDynamicConfig(dynamicconfig.MatchingNumTaskqueueWritePartitions, nPartitions)
// exclude the effect of the default forwarding rate limit (10)
s.OverrideDynamicConfig(dynamicconfig.MatchingForwarderMaxRatePerSecond, 1000)
// 30 tasks at 1 task per second is 30 seconds.
// if invalid tasks are NOT using the rate limit, then this should take well below that long.
// task forwarding between task queue partitions is rate-limited by default to 10 rps.
s.OverrideDynamicConfig(dynamicconfig.AdminMatchingNamespaceTaskqueueToPartitionDispatchRate, 1)
s.OverrideDynamicConfig(dynamicconfig.TaskQueueInfoByBuildIdTTL, 0)
const maxBacklog = 30
tv := testvars.New(s.T())
helloRateLimitTest := func(ctx workflow.Context, name string) (string, error) {
return "Hello " + name + " !", nil
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// start workflows to create a backlog
for wfidx := 0; wfidx < maxBacklog; wfidx++ {
_, err := s.SdkClient().ExecuteWorkflow(ctx, sdkclient.StartWorkflowOptions{
TaskQueue: tv.TaskQueue().GetName(),
ID: fmt.Sprintf("wf%d", wfidx),
}, helloRateLimitTest, "Donna")
s.NoError(err)
}
// wait for backlog to be >= maxBacklog
wfBacklogCount := int64(0)
s.Eventually(
func() bool {
wfBacklogCount = s.getBacklogCount(ctx, tv)
return wfBacklogCount >= maxBacklog
},
5*time.Second,
200*time.Millisecond,
)
// terminate all those workflow executions so that all the tasks in the backlog are invalid
var wfList []*workflowpb.WorkflowExecutionInfo
s.Eventually(
func() bool {
listResp, err := s.FrontendClient().ListWorkflowExecutions(ctx, &workflowservice.ListWorkflowExecutionsRequest{
Namespace: s.Namespace().String(),
Query: fmt.Sprintf("TaskQueue = '%s'", tv.TaskQueue().GetName()),
})
s.NoError(err)
wfList = listResp.GetExecutions()
return len(wfList) == maxBacklog
},
5*time.Second,
200*time.Millisecond,
)
for _, exec := range wfList {
_, err := s.FrontendClient().TerminateWorkflowExecution(ctx, &workflowservice.TerminateWorkflowExecutionRequest{
Namespace: s.Namespace().String(),
WorkflowExecution: &commonpb.WorkflowExecution{WorkflowId: exec.GetExecution().GetWorkflowId(), RunId: exec.GetExecution().GetRunId()},
Reason: "test",
Identity: tv.ClientIdentity(),
})
s.NoError(err)
}
// start some workers
workers := make([]worker.Worker, nWorkers)
for i := 0; i < nWorkers; i++ {
workers[i] = worker.New(s.SdkClient(), tv.TaskQueue().GetName(), worker.Options{})
workers[i].RegisterWorkflow(helloRateLimitTest)
err := workers[i].Start()
s.NoError(err)
}
// wait for backlog to be 0
s.Eventually(
func() bool {
wfBacklogCount = s.getBacklogCount(ctx, tv)
return wfBacklogCount == 0
},
timeToDrain,
500*time.Millisecond,
)
}
func (s *TaskQueueSuite) getBacklogCount(ctx context.Context, tv *testvars.TestVars) int64 {
resp, err := s.FrontendClient().DescribeTaskQueue(ctx, &workflowservice.DescribeTaskQueueRequest{
Namespace: s.Namespace().String(),
TaskQueue: tv.TaskQueue(),
ApiMode: enumspb.DESCRIBE_TASK_QUEUE_MODE_ENHANCED,
ReportStats: true,
})
s.NoError(err)
return resp.GetVersionsInfo()[""].GetTypesInfo()[sdkclient.TaskQueueTypeWorkflow].GetStats().GetApproximateBacklogCount()
}
func (s *TaskQueueSuite) testTaskQueueRateLimitName(nPartitions, nWorkers int, useNewMatching bool) string {
ret := fmt.Sprintf("%vPartitions_%vWorkers", nPartitions, nWorkers)
if useNewMatching {
return "NewMatching_" + ret
}
return "OldMatching_" + ret
}
// configureRateLimitAndLaunchWorkflows sets up the test environment to validate task queue API rate limiting behavior.
// - Applies an API-level RPS override on the activity task queue by calling the updateTaskQueueConfig api.
// - Starts an activity worker with a specified worker-side RPS limit.
// - Starts a workflow worker that dispatches activities to the activity queue.
// - Launches a given number of workflows that each execute a single activity.
// - Tracks the time each activity is executed (via runTimes) for test assertions.
// - Returns the context, cancel function, and both workers so the caller can manage their lifecycle.
func (s *TaskQueueSuite) configureRateLimitAndLaunchWorkflows(
activityTaskQueue string,
activityName string,
taskCount int,
workerRPS float64,
drainTimeout time.Duration,
runTimes *[]time.Time,
wg *sync.WaitGroup,
apiRPS float64,
mu *sync.Mutex,
) (ctx context.Context, cancel context.CancelFunc, activityWorker worker.Worker, wfWorker worker.Worker) {
tv := testvars.New(s.T())
// Apply API rate limit on `activityTaskQueue`
_, err := s.FrontendClient().UpdateTaskQueueConfig(context.Background(), &workflowservice.UpdateTaskQueueConfigRequest{
Namespace: s.Namespace().String(),
Identity: tv.ClientIdentity(),
TaskQueue: activityTaskQueue,
TaskQueueType: enumspb.TASK_QUEUE_TYPE_ACTIVITY,
UpdateQueueRateLimit: &workflowservice.UpdateTaskQueueConfigRequest_RateLimitUpdate{
RateLimit: &taskqueuepb.RateLimit{RequestsPerSecond: float32(apiRPS)},
Reason: "Test API override",
},
})
s.NoError(err)
wg.Add(taskCount)
// Track activity run times
activityFunc := func(context.Context) error {
defer wg.Done()
mu.Lock()
*runTimes = append(*runTimes, time.Now())
mu.Unlock()
return nil
}
workflowFn := func(ctx workflow.Context) error {
ao := workflow.ActivityOptions{
// Route activity tasks to a dedicated task queue named `activityTaskQueue`.
// This isolates the test by ensuring that only the dedicated worker polls the queue
TaskQueue: activityTaskQueue,
StartToCloseTimeout: 5 * time.Second,
}
ctx = workflow.WithActivityOptions(ctx, ao)
return workflow.ExecuteActivity(ctx, activityName).Get(ctx, nil)
}
ctx, cancel = context.WithTimeout(context.Background(), drainTimeout)
// Start the activity worker
activityWorker = worker.New(s.SdkClient(), activityTaskQueue, worker.Options{
// Setting rate limit at worker level (this will be ignored in favor of the limit set through the api)
TaskQueueActivitiesPerSecond: workerRPS,
// Setting rate limit to throttle the worker to 4 activities per second
WorkerActivitiesPerSecond: 4,
})
activityWorker.RegisterActivityWithOptions(activityFunc, activity.RegisterOptions{Name: activityName})
s.NoError(activityWorker.Start())
// Start the workflow worker
wfWorker = worker.New(s.SdkClient(), tv.TaskQueue().GetName(), worker.Options{})
wfWorker.RegisterWorkflow(workflowFn)
s.NoError(wfWorker.Start())
// Launch workflows
for i := range taskCount {
_, err := s.SdkClient().ExecuteWorkflow(ctx, sdkclient.StartWorkflowOptions{
TaskQueue: tv.TaskQueue().GetName(),
ID: fmt.Sprintf("wf-%d", i),
}, workflowFn)
s.NoError(err)
}
return ctx, cancel, activityWorker, wfWorker
}
// TestTaskQueueAPIRateLimitOverridesWorkerLimit tests that the API rate limit overrides the worker rate limit.
// It sets the API rate limit on a task queue to 5 RPS and then launches 25 activities.
// Burst = 5 i.e max(int(math.Ceil(effectiveRPSPartitionWise)), r.config.MinTaskThrottlingBurstSize())
// The expected time for all activities to complete is ~ 4 seconds ((25 - 5)/5) +/- 1 second buffer.
// The first five activities should run immediately, and the rest should be throttled to 5 RPS.
// The test verifies that the total time taken for all activities to complete is within the expected range
// To avoid test flakiness, the test uses a buffer of 1 second for the expected total time.
func (s *TaskQueueSuite) TestTaskQueueAPIRateLimitOverridesWorkerLimit() {
const (
apiRPS = 5.0
taskCount = 25
buffer = time.Duration(3.5 * float64(time.Second)) // High Buffer to account for test flakiness
activityTaskQueue = "RateLimitTest"
)
expectedTotal := time.Duration(float64(taskCount-int(apiRPS))/apiRPS) * time.Second
s.OverrideDynamicConfig(dynamicconfig.MatchingNumTaskqueueReadPartitions, 1)
s.OverrideDynamicConfig(dynamicconfig.MatchingNumTaskqueueWritePartitions, 1)
// Set a very low TTL for task queue info cache to ensure the rate limiter stats
// are refreshed frequently, avoiding stale data during the test.
s.OverrideDynamicConfig(dynamicconfig.TaskQueueInfoByBuildIdTTL, 1*time.Millisecond)
var (
mu sync.Mutex
runTimes []time.Time
wg sync.WaitGroup
)
const (
workerRPS = 50.0
// Test typically completes in ~6.2 seconds on average.
// Timeout is set to 10s to reduce flakiness.
drainTimeout = 10 * time.Second
activityName = "trackableActivity"
)
_, cancel, activityWorker, wfWorker := s.configureRateLimitAndLaunchWorkflows(
activityTaskQueue,
activityName,
taskCount,
workerRPS,
drainTimeout,
&runTimes,
&wg,
apiRPS,
&mu,
)
defer cancel()
defer activityWorker.Stop()
defer wfWorker.Stop()
// Wait for all activities to complete
s.True(common.AwaitWaitGroup(&wg, drainTimeout), "timeout waiting for activities to complete")
s.Len(runTimes, taskCount)
totalGap := runTimes[len(runTimes)-1].Sub(runTimes[0])
s.GreaterOrEqual(totalGap, expectedTotal-buffer, "Activity run time too short — API rate limit override not taking effect over the worker rate limit")
s.LessOrEqual(totalGap, expectedTotal+buffer, "Activity run time too long — API rate limit override not enforced as expected")
}
// TestTaskQueueAPIRateLimitZero ensures that when the API rate limit is set to 0,
// no activity tasks are dispatched. Also checks if the rate limit is set to 0,
// then the burst is defaulted to 0 to prevent any initial tasks from executing immediately.
func (s *TaskQueueSuite) TestTaskQueueAPIRateLimitZero() {
const (
apiRPS = 0.0
taskCount = 10
drainTimeout = 3 * time.Second
activityTaskQueue = "RateLimitTestZero"
)
// Override configs
s.OverrideDynamicConfig(dynamicconfig.MatchingNumTaskqueueReadPartitions, 1)
s.OverrideDynamicConfig(dynamicconfig.MatchingNumTaskqueueWritePartitions, 1)
s.OverrideDynamicConfig(dynamicconfig.TaskQueueInfoByBuildIdTTL, 1*time.Millisecond)
var (
mu sync.Mutex
runTimes []time.Time
wg sync.WaitGroup
)
const (
workerRPS = 50.0
activityName = "noopActivity"
)
_, cancel, activityWorker, wfWorker := s.configureRateLimitAndLaunchWorkflows(
activityTaskQueue,
activityName,
taskCount,
workerRPS,
drainTimeout,
&runTimes,
&wg,
apiRPS,
&mu,
)
defer cancel()
defer activityWorker.Stop()
defer wfWorker.Stop()
// Wait for the duration and ensure no tasks executed
s.False(common.AwaitWaitGroup(&wg, drainTimeout), "Some activities unexpectedly completed despite API RPS = 0")
s.Len(runTimes, 0, "No activities should run when API rate limit is 0")
}
func (s *TaskQueueSuite) TestTaskQueueRateLimit_UpdateFromWorkerConfigAndAPI() {
const (
workerSetRPS = 2.0 // Worker rate limit for activities
apiSetRPS = 4.0 // API rate limit for activities set to half of workerSetRPS to test override behavior
taskCount = 12 // Number of tasks to launch
activityTaskQueue = "RateLimitTest_Update"
drainTimeout = 15 * time.Second // 5 second additional buffer to prevent flakiness
buffer = 2 * time.Second
activityName = "timedActivity"
)
s.OverrideDynamicConfig(dynamicconfig.MatchingNumTaskqueueReadPartitions, 1)
s.OverrideDynamicConfig(dynamicconfig.MatchingNumTaskqueueWritePartitions, 1)
s.OverrideDynamicConfig(dynamicconfig.TaskQueueInfoByBuildIdTTL, 1*time.Millisecond)
var (
mu sync.Mutex
runTimes []time.Time
wg sync.WaitGroup
)
wg.Add(taskCount)
// Activity: record run time
activityFunc := func(ctx context.Context) error {
defer wg.Done()
mu.Lock()
runTimes = append(runTimes, time.Now())
mu.Unlock()
return nil
}
// Workflow: calls the activity
workflowFn := func(ctx workflow.Context) error {
ctx = workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
TaskQueue: activityTaskQueue,
StartToCloseTimeout: 5 * time.Second,
})
return workflow.ExecuteActivity(ctx, activityName).Get(ctx, nil)
}
// Start activity worker
activityWorker := worker.New(s.SdkClient(), activityTaskQueue, worker.Options{
TaskQueueActivitiesPerSecond: workerSetRPS, // worker set RPS should take effect initially
})
activityWorker.RegisterActivityWithOptions(activityFunc, activity.RegisterOptions{Name: activityName})
s.NoError(activityWorker.Start())
defer activityWorker.Stop()
// Start workflow worker
tv := testvars.New(s.T())
wfWorker := worker.New(s.SdkClient(), tv.TaskQueue().GetName(), worker.Options{})
wfWorker.RegisterWorkflow(workflowFn)
s.NoError(wfWorker.Start())
defer wfWorker.Stop()
// Launch workflows under workerSetRPS
for i := range taskCount {
_, err := s.SdkClient().ExecuteWorkflow(context.Background(), sdkclient.StartWorkflowOptions{
TaskQueue: tv.TaskQueue().GetName(),
ID: fmt.Sprintf("wf-dynamic-%d", i),
}, workflowFn)
s.NoError(err)
}
s.True(common.AwaitWaitGroup(&wg, drainTimeout), "tasks with dynamic config didn't complete")
s.Len(runTimes, taskCount, "task count mismatch")
// Measure duration with workerSetRPS config
firstGap := runTimes[len(runTimes)-1].Sub(runTimes[0])
// Reset for API override phase
runTimes = nil
wg.Add(taskCount)
// Apply API rate limit override workerSetRPS to set the effective RPS to apiSetRPS
_, err := s.FrontendClient().UpdateTaskQueueConfig(context.Background(), &workflowservice.UpdateTaskQueueConfigRequest{
Namespace: s.Namespace().String(),
Identity: tv.ClientIdentity(),
TaskQueue: activityTaskQueue,
TaskQueueType: enumspb.TASK_QUEUE_TYPE_ACTIVITY,
UpdateQueueRateLimit: &workflowservice.UpdateTaskQueueConfigRequest_RateLimitUpdate{
RateLimit: &taskqueuepb.RateLimit{RequestsPerSecond: float32(apiSetRPS)},
Reason: "test api override",
},
})
s.NoError(err)
require.Eventually(s.T(), func() bool {
describeResp, err := s.FrontendClient().DescribeTaskQueue(context.Background(), &workflowservice.DescribeTaskQueueRequest{
Namespace: s.Namespace().String(),
TaskQueue: &taskqueuepb.TaskQueue{Name: activityTaskQueue},
TaskQueueType: enumspb.TASK_QUEUE_TYPE_ACTIVITY,
ReportConfig: true,
})
if err != nil {
return false
}
cfg := describeResp.GetConfig()
rl := cfg.GetQueueRateLimit().GetRateLimit()
if rl == nil {
s.T().Logf("Rate limit not set in Persistence")
return false
}
if rl.GetRequestsPerSecond() == float32(apiSetRPS) {
s.T().Logf("Rate limit set in Persistence: %v", rl.GetRequestsPerSecond())
return true
}
return false
}, 3*time.Second, 100*time.Millisecond, "DescribeTaskQueue did not reflect override")
// Launch workflows under API override
for i := range taskCount {
_, err := s.SdkClient().ExecuteWorkflow(context.Background(), sdkclient.StartWorkflowOptions{
TaskQueue: tv.TaskQueue().GetName(),
ID: fmt.Sprintf("wf-api-%d", i),
}, workflowFn)
s.NoError(err)
}
s.True(common.AwaitWaitGroup(&wg, drainTimeout), "tasks with API override didn't complete")
s.Len(runTimes, taskCount, "task count mismatch after API override")
// Measure duration with API override
secondGap := runTimes[len(runTimes)-1].Sub(runTimes[0])
s.T().Logf("Completion time for First set of Activity tasks: %v, Second Activity tasks: %v", firstGap, secondGap)
// first gap must be ideally twice as larger as the effective RPS is doubled
// To avoid flakiness, we allow a buffer of 1.5x the first gap along with an additional buffer of 2s
s.Greater(firstGap, time.Duration(1.5*float64(secondGap))-buffer,
"Expected ~2x span at 2 rps vs 4 rps: first=%v (2rps) second=%v (4rps)",
firstGap, secondGap)
}
func (s *TaskQueueSuite) TestWholeQueueLimit_TighterThanPerKeyDefault_IsEnforced() {
const (
wholeQueueRPS = 10.0 // tighter
perKeyRPS = 50.0 // looser than whole queue, should not bind
tasksPerKey = 30
buffer = 3 * time.Second // CI jitter
)
fairnessKeysWithWeight := map[string]float32{"A": 1.0, "B": 1.0, "C": 1.0}
tv := testvars.New(s.T())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// configure task queue
_, err := s.FrontendClient().UpdateTaskQueueConfig(ctx, &workflowservice.UpdateTaskQueueConfigRequest{
Namespace: s.Namespace().String(),
Identity: tv.ClientIdentity(),
TaskQueue: tv.TaskQueue().GetName(),
TaskQueueType: enumspb.TASK_QUEUE_TYPE_ACTIVITY,
UpdateQueueRateLimit: &workflowservice.UpdateTaskQueueConfigRequest_RateLimitUpdate{
RateLimit: &taskqueuepb.RateLimit{RequestsPerSecond: float32(wholeQueueRPS)},
Reason: "test: whole-queue limit",
},
UpdateFairnessKeyRateLimitDefault: &workflowservice.UpdateTaskQueueConfigRequest_RateLimitUpdate{
RateLimit: &taskqueuepb.RateLimit{RequestsPerSecond: float32(perKeyRPS)},
Reason: "test: per-key default",
},
})
s.NoError(err)
_, allTimes := s.runActivitiesWithPriorities(ctx, tv, fairnessKeysWithWeight, tasksPerKey)
// Measure overall throughput after initial burst, which should be limited by wholeQueueRPS.
start := allTimes[0]
end := allTimes[len(allTimes)-1]
expected := time.Duration(float64(tasksPerKey*len(fairnessKeysWithWeight))/wholeQueueRPS) * time.Second
actual := end.Sub(start)
s.T().Logf("Time taken for tasks across fairness keys to drain is %v vs expected %v", actual, expected)
s.GreaterOrEqual(actual, expected-buffer,
"whole-queue RPS violated: actual %v < expected %v (-%v buffer)", actual, expected, buffer)
s.LessOrEqual(actual, expected+buffer,
"too slow overall: actual %v > expected %v (+%v buffer)", actual, expected, buffer)
}
func (s *TaskQueueSuite) TestPerKeyRateLimit_Default_IsEnforcedAcrossThreeKeys() {
const (
perKeyRPS = 5.0
wholeQueueRPS = 1000.0 // tighter
tasksPerKey = 30
buffer = 3 * time.Second // relax for CI jitter
)
fairnessKeysWithWeight := map[string]float32{"A": 1.0, "B": 1.0, "C": 1.0}
tv := testvars.New(s.T())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// configure task queue
_, err := s.FrontendClient().UpdateTaskQueueConfig(ctx, &workflowservice.UpdateTaskQueueConfigRequest{
Namespace: s.Namespace().String(),
Identity: tv.ClientIdentity(),
TaskQueue: tv.TaskQueue().GetName(),
TaskQueueType: enumspb.TASK_QUEUE_TYPE_ACTIVITY,
UpdateQueueRateLimit: &workflowservice.UpdateTaskQueueConfigRequest_RateLimitUpdate{
RateLimit: &taskqueuepb.RateLimit{RequestsPerSecond: float32(wholeQueueRPS)},
Reason: "test: whole-queue limit",
},
UpdateFairnessKeyRateLimitDefault: &workflowservice.UpdateTaskQueueConfigRequest_RateLimitUpdate{
RateLimit: &taskqueuepb.RateLimit{RequestsPerSecond: float32(perKeyRPS)},
Reason: "test: per-key default",
},
})
s.NoError(err)
perKeyTimes, _ := s.runActivitiesWithPriorities(ctx, tv, fairnessKeysWithWeight, tasksPerKey)
for key := range fairnessKeysWithWeight {
times := perKeyTimes[key]
s.Len(times, tasksPerKey, "unexpected count for key %s", key)
start := times[0]
end := times[len(times)-1]
expected := time.Duration(float64(tasksPerKey)/perKeyRPS) * time.Second
actual := end.Sub(start)
s.T().Logf("Time taken for fairness key %s to drain : %v vs expected %v", key, actual, expected)
s.GreaterOrEqual(actual, expected-buffer,
"per-key RPS violated for key %s: actual %v < expected %v (-%v buffer)",
key, actual, expected)
s.LessOrEqual(actual, expected+buffer,
"too slow for key %s: actual %v > expected %v (+%v buffer)", key, actual, expected, buffer)
}
}
func (s *TaskQueueSuite) TestPerKeyRateLimit_WeightOverride_IsEnforcedAcrossThreeKeys() {
const (
perKeyRPS = 5.0 // base per-key limit
wholeQueueRPS = 1000.0 // keep high so only per-key gates
tasksPerKey = 30
buffer = 3 * time.Second
)
// Fairness key overrides take precedence over default.
// Override A and C to default and make B twice as heavy (ie ~2x effective RPS).
fairnessKeysWithWeight := map[string]float32{"A": 6666.0, "B": 0.0, "C": 6666.0} // defaults are opposite of override
fairnessWeightOverrides := map[string]float32{"A": 1.0, "B": 2.0, "C": 1.0}
tv := testvars.New(s.T())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// configure task queue
_, err := s.FrontendClient().UpdateTaskQueueConfig(ctx, &workflowservice.UpdateTaskQueueConfigRequest{
Namespace: s.Namespace().String(),
Identity: tv.ClientIdentity(),
TaskQueue: tv.TaskQueue().GetName(),
TaskQueueType: enumspb.TASK_QUEUE_TYPE_ACTIVITY,
UpdateQueueRateLimit: &workflowservice.UpdateTaskQueueConfigRequest_RateLimitUpdate{
RateLimit: &taskqueuepb.RateLimit{RequestsPerSecond: float32(wholeQueueRPS)},
Reason: "test: whole-queue limit",
},
UpdateFairnessKeyRateLimitDefault: &workflowservice.UpdateTaskQueueConfigRequest_RateLimitUpdate{
RateLimit: &taskqueuepb.RateLimit{RequestsPerSecond: float32(perKeyRPS)},
Reason: "test: per-key default",
},
SetFairnessWeightOverrides: fairnessWeightOverrides,
})
s.NoError(err)
perKeyTimes, _ := s.runActivitiesWithPriorities(ctx, tv, fairnessKeysWithWeight, tasksPerKey)
for key, fairnessWeightOverride := range fairnessWeightOverrides {
times := perKeyTimes[key]
s.Len(times, tasksPerKey, "unexpected count for key %s", key)
start := times[0]
end := times[len(times)-1]
expected := time.Duration(float64(tasksPerKey)/(perKeyRPS*float64(fairnessWeightOverride))) * time.Second
actual := end.Sub(start)
s.T().Logf("Time taken for fairness key %s with weight %v to drain : %v vs expected %v",
key, fairnessWeightOverride, actual, expected)
s.GreaterOrEqual(actual, expected-buffer,
"per-key RPS violated for key %s with weight %v: actual %v < expected %v (-%v buffer)",
key, fairnessWeightOverride, actual, expected)
s.LessOrEqual(actual, expected+buffer,
"too slow for key %s with weight %v: actual %v > expected %v (+%v buffer)",
key, fairnessWeightOverride, actual, expected, buffer)
}
}
// TestUpdateAndDescribeTaskQueueConfig tests the update and describe task queue config functionality.
// It updates the task queue config via the frontend API and then describes the task queue to verify,
// that the updated configuration is reflected correctly.
func (s *TaskQueueSuite) TestUpdateAndDescribeTaskQueueConfig() {
// Enforce a smaller limit for max fairness key weight overrides for this test
s.OverrideDynamicConfig(dynamicconfig.MatchingMaxFairnessKeyWeightOverrides, 5)
// Send update.
tv := testvars.New(s.T())
taskQueueName := tv.TaskQueue().Name
namespace := s.Namespace().String()
taskQueueType := enumspb.TASK_QUEUE_TYPE_ACTIVITY
updateRPS := float32(42)
updateReason := "frontend-update-test"
updateIdentity := "test-identity"
fairnessOverrides := map[string]float32{"k1": 1.0, "k2": 1.5, "k3": 2.0, "k4": 0.5, "k5": 3.0}
updateReq := &workflowservice.UpdateTaskQueueConfigRequest{
Namespace: namespace,
Identity: updateIdentity,
TaskQueue: taskQueueName,
TaskQueueType: taskQueueType,
UpdateQueueRateLimit: &workflowservice.UpdateTaskQueueConfigRequest_RateLimitUpdate{
RateLimit: &taskqueuepb.RateLimit{
RequestsPerSecond: updateRPS,
},
Reason: updateReason,
},
UpdateFairnessKeyRateLimitDefault: &workflowservice.UpdateTaskQueueConfigRequest_RateLimitUpdate{
RateLimit: &taskqueuepb.RateLimit{
RequestsPerSecond: updateRPS,
},
Reason: updateReason,
},
SetFairnessWeightOverrides: fairnessOverrides,
}
updateResp, err := s.FrontendClient().UpdateTaskQueueConfig(testcore.NewContext(), updateReq)
s.NoError(err)
s.NotNil(updateResp)
s.NotNil(updateResp.Config)
s.Equal(updateRPS, updateResp.Config.QueueRateLimit.RateLimit.RequestsPerSecond)
s.Equal(updateReason, updateResp.Config.QueueRateLimit.Metadata.Reason)
s.Equal(updateIdentity, updateResp.Config.QueueRateLimit.Metadata.UpdateIdentity)
s.Equal(updateRPS, updateResp.Config.FairnessKeysRateLimitDefault.RateLimit.RequestsPerSecond)
s.Equal(updateReason, updateResp.Config.FairnessKeysRateLimitDefault.Metadata.Reason)
s.Equal(updateIdentity, updateResp.Config.FairnessKeysRateLimitDefault.Metadata.UpdateIdentity)
s.Equal(fairnessOverrides, updateResp.Config.FairnessWeightOverrides)
// Request describe.
describeReq := &workflowservice.DescribeTaskQueueRequest{
Namespace: namespace,
TaskQueue: &taskqueuepb.TaskQueue{Name: taskQueueName},
TaskQueueType: taskQueueType,
ReportConfig: true,
}
describeResp, err := s.FrontendClient().DescribeTaskQueue(testcore.NewContext(), describeReq)
s.NoError(err)
s.NotNil(describeResp)
s.NotNil(describeResp.Config)
s.NotNil(describeResp.Config.QueueRateLimit)
s.Equal(updateRPS, describeResp.Config.QueueRateLimit.RateLimit.RequestsPerSecond)
s.Equal(updateReason, describeResp.Config.QueueRateLimit.Metadata.Reason)
s.Equal(updateIdentity, updateResp.Config.QueueRateLimit.Metadata.UpdateIdentity)
s.Equal(updateRPS, describeResp.Config.FairnessKeysRateLimitDefault.RateLimit.RequestsPerSecond)
s.Equal(updateReason, describeResp.Config.FairnessKeysRateLimitDefault.Metadata.Reason)
s.Equal(updateIdentity, updateResp.Config.FairnessKeysRateLimitDefault.Metadata.UpdateIdentity)
s.Equal(fairnessOverrides, describeResp.Config.FairnessWeightOverrides)
// Attempt to exceed the maximum allowed fairness weight overrides.
exceedReq := &workflowservice.UpdateTaskQueueConfigRequest{
Namespace: namespace,
Identity: updateIdentity,
TaskQueue: taskQueueName,
TaskQueueType: taskQueueType,
SetFairnessWeightOverrides: map[string]float32{"k6": 1.0}, // Exceeds the limit of 5
}
_, err = s.FrontendClient().UpdateTaskQueueConfig(testcore.NewContext(), exceedReq)
s.Error(err)
s.ErrorContains(err, "fairness weight overrides update rejected")
// Verify no change after rejected update.
describeResp, err = s.FrontendClient().DescribeTaskQueue(testcore.NewContext(), describeReq)
s.NoError(err)
s.Equal(fairnessOverrides, describeResp.Config.FairnessWeightOverrides)
}
func (s *TaskQueueSuite) TestUpdateUnsetAndDescribeTaskQueueConfig() {
tv := testvars.New(s.T())
taskQueueName := tv.TaskQueue().Name
namespace := s.Namespace().String()
taskQueueType := enumspb.TASK_QUEUE_TYPE_ACTIVITY
updateRPS := float32(42)
updateReason := "TestUpdateUnsetAndDescribeTaskQueueConfig"
unsetReasonQueue := "unset queue rate limit"
unsetReasonFairness := "unset fairness key rate limit"
updateIdentity := "test-identity"
// Set rate limit via UpdateTaskQueueConfig api.
updateReq := &workflowservice.UpdateTaskQueueConfigRequest{
Namespace: namespace,
Identity: updateIdentity,
TaskQueue: taskQueueName,
TaskQueueType: taskQueueType,
UpdateQueueRateLimit: &workflowservice.UpdateTaskQueueConfigRequest_RateLimitUpdate{
RateLimit: &taskqueuepb.RateLimit{
RequestsPerSecond: updateRPS,
},
Reason: updateReason,
},
UpdateFairnessKeyRateLimitDefault: &workflowservice.UpdateTaskQueueConfigRequest_RateLimitUpdate{
RateLimit: &taskqueuepb.RateLimit{
RequestsPerSecond: updateRPS,
},
Reason: updateReason,
},
}
updateResp, err := s.FrontendClient().UpdateTaskQueueConfig(testcore.NewContext(), updateReq)
s.NoError(err)
s.NotNil(updateResp)
s.NotNil(updateResp.Config)
s.Equal(updateRPS, updateResp.Config.QueueRateLimit.RateLimit.RequestsPerSecond)
s.Equal(updateReason, updateResp.Config.QueueRateLimit.Metadata.Reason)
s.Equal(updateIdentity, updateResp.Config.QueueRateLimit.Metadata.UpdateIdentity)
s.Equal(updateRPS, updateResp.Config.FairnessKeysRateLimitDefault.RateLimit.RequestsPerSecond)
s.Equal(updateReason, updateResp.Config.FairnessKeysRateLimitDefault.Metadata.Reason)
s.Equal(updateIdentity, updateResp.Config.FairnessKeysRateLimitDefault.Metadata.UpdateIdentity)
// Unset rate limit via UpdateTaskQueueConfig api.
unsetReq := &workflowservice.UpdateTaskQueueConfigRequest{
Namespace: namespace,
Identity: updateIdentity,
TaskQueue: taskQueueName,
TaskQueueType: taskQueueType,
UpdateQueueRateLimit: &workflowservice.UpdateTaskQueueConfigRequest_RateLimitUpdate{
RateLimit: nil,
Reason: unsetReasonQueue,
},
UpdateFairnessKeyRateLimitDefault: &workflowservice.UpdateTaskQueueConfigRequest_RateLimitUpdate{
RateLimit: nil,
Reason: unsetReasonFairness,
},
}
unsetResp, err := s.FrontendClient().UpdateTaskQueueConfig(testcore.NewContext(), unsetReq)
s.NoError(err)
s.NotNil(unsetResp)
s.NotNil(unsetResp.Config)
s.Nil(unsetResp.Config.QueueRateLimit.RateLimit)
s.Equal(unsetReasonQueue, unsetResp.Config.QueueRateLimit.Metadata.Reason)
s.Equal(updateIdentity, updateResp.Config.QueueRateLimit.Metadata.UpdateIdentity)
s.Nil(unsetResp.Config.FairnessKeysRateLimitDefault.RateLimit)
s.Equal(unsetReasonFairness, unsetResp.Config.FairnessKeysRateLimitDefault.Metadata.Reason)
s.Equal(updateIdentity, updateResp.Config.FairnessKeysRateLimitDefault.Metadata.UpdateIdentity)
// Describe the task queue to verify the unset configuration.
describeReq := &workflowservice.DescribeTaskQueueRequest{
Namespace: namespace,
TaskQueue: &taskqueuepb.TaskQueue{Name: taskQueueName},
TaskQueueType: taskQueueType,
ReportConfig: true,
}
describeResp, err := s.FrontendClient().DescribeTaskQueue(testcore.NewContext(), describeReq)
s.NoError(err)
s.NotNil(describeResp)
s.NotNil(describeResp.Config)
s.Nil(describeResp.Config.QueueRateLimit.RateLimit)
s.Equal(unsetReasonQueue, describeResp.Config.QueueRateLimit.Metadata.Reason)
s.Equal(updateIdentity, updateResp.Config.QueueRateLimit.Metadata.UpdateIdentity)
s.Nil(describeResp.Config.FairnessKeysRateLimitDefault.RateLimit)
s.Equal(unsetReasonFairness, describeResp.Config.FairnessKeysRateLimitDefault.Metadata.Reason)
s.Equal(updateIdentity, updateResp.Config.FairnessKeysRateLimitDefault.Metadata.UpdateIdentity)
}
func (s *TaskQueueSuite) runActivitiesWithPriorities(
ctx context.Context,
tv *testvars.TestVars,
fairnessKeysWithWeight map[string]float32,
activitiesPerKey int,
) (map[string][]time.Time, []time.Time) {
fairnessKeys := make([]string, 0, len(fairnessKeysWithWeight))
for k := range fairnessKeysWithWeight {
fairnessKeys = append(fairnessKeys, k)
}
total := len(fairnessKeys) * activitiesPerKey
// Enable fairness
s.OverrideDynamicConfig(dynamicconfig.MatchingEnableFairness, true)
// Single partition for simplicity.
s.OverrideDynamicConfig(dynamicconfig.MatchingNumTaskqueueReadPartitions, 1)
s.OverrideDynamicConfig(dynamicconfig.MatchingNumTaskqueueWritePartitions, 1)
// generate a unique base so IDs don't collide across shards/tests
base := uuid.NewString()
parsePattern := fmt.Sprintf("perkey-wf-%s-%%d", base) // for Sscanf later
// Start workflows (each will schedule one activity tagged with a fairness key).
for i := range total {
wfID := fmt.Sprintf("perkey-wf-%s-%d", base, i)
_, err := s.FrontendClient().StartWorkflowExecution(ctx, &workflowservice.StartWorkflowExecutionRequest{
Namespace: s.Namespace().String(),
WorkflowId: wfID,
WorkflowType: tv.WorkflowType(),
TaskQueue: tv.TaskQueue(),
})
s.NoError(err)
}
// Drain workflow tasks -> schedule activities with Priority.FairnessKey.
wfHandled := 0
for wfHandled < total {
if err := ctx.Err(); err != nil {
s.T().Fatalf("context deadline while draining workflow tasks: handled=%d/%d: %v", wfHandled, total, err)
}
_, err := s.TaskPoller().PollAndHandleWorkflowTask(
tv,
func(task *workflowservice.PollWorkflowTaskQueueResponse) (*workflowservice.RespondWorkflowTaskCompletedRequest, error) {
s.Len(task.History.Events, 3)
var idx int
_, scanErr := fmt.Sscanf(task.WorkflowExecution.WorkflowId, parsePattern, &idx)
s.NoError(scanErr)
key := fairnessKeys[idx%len(fairnessKeys)]
weight := fairnessKeysWithWeight[key]
input, encErr := payloads.Encode(key)
s.NoError(encErr)
cmd := &commandpb.Command{
CommandType: enumspb.COMMAND_TYPE_SCHEDULE_ACTIVITY_TASK,
Attributes: &commandpb.Command_ScheduleActivityTaskCommandAttributes{
ScheduleActivityTaskCommandAttributes: &commandpb.ScheduleActivityTaskCommandAttributes{
ActivityId: fmt.Sprintf("act-%d", idx),
ActivityType: tv.ActivityType(),
TaskQueue: tv.TaskQueue(),
ScheduleToCloseTimeout: durationpb.New(30 * time.Second),
Priority: &commonpb.Priority{FairnessKey: key, FairnessWeight: weight},
Input: input,
},
},
}
return &workflowservice.RespondWorkflowTaskCompletedRequest{Commands: []*commandpb.Command{cmd}}, nil
},
taskpoller.WithContext(ctx),
)
if err == nil {
wfHandled++
}
}
s.Equal(total, wfHandled)
// Drain activity tasks, recording times.
perKeyTimes := make(map[string][]time.Time, len(fairnessKeys))
for _, k := range fairnessKeys {
perKeyTimes[k] = []time.Time{}
}
allTimes := make([]time.Time, 0, total)
actsHandled := 0
for actsHandled < total {
if err := ctx.Err(); err != nil {
s.T().Fatalf("context deadline while draining activity tasks: handled=%d/%d: %v", actsHandled, total, err)
}
_, err := s.TaskPoller().PollAndHandleActivityTask(
tv,
func(task *workflowservice.PollActivityTaskQueueResponse) (*workflowservice.RespondActivityTaskCompletedRequest, error) {
var key string
s.NoError(payloads.Decode(task.Input, &key))
now := time.Now()
perKeyTimes[key] = append(perKeyTimes[key], now)
allTimes = append(allTimes, now)
nothing, encErr := payloads.Encode()
s.NoError(encErr)
return &workflowservice.RespondActivityTaskCompletedRequest{Result: nothing}, nil
},
taskpoller.WithContext(ctx),
)
if err == nil {
actsHandled++
}
}
s.Equal(total, actsHandled)
// perKeyTimes : Used to verify that each key's activities are throttled correctly.
// allTimes : Used to verify the overall throughput of the task queue.
return perKeyTimes, allTimes
}