Skip to content

Commit a31f476

Browse files
cbsandeep10claude
andauthored
Add TaskQueueRateLimitFractionProvider interface to scale task queue rate limits (#10827)
## What changed? Introduces a `TaskQueueRateLimitFractionProvider` interface in the matching service that applies a fractional multiplier to the task queue rate limit. The default implementation returns `1.0` (no change in behavior). The interface can be overridden via `fx.Decorate` to supply a custom fraction per namespace and task queue. The provider is stored on `*Config` and bound as a closure (`RateLimitFraction func() float64`) inside `newTaskQueueConfig`. The `rateLimitManager` calls it directly in `computeEffectiveRPSAndSourceLocked`. The `[0.0, 1.0]` range is enforced at the provider layer via `NewTaskQueueRateLimitFractionProvider`. Note: `adminNsRate` / `adminTqRate` are per-partition system caps and are intentionally not scaled by the fraction. ## Why? Provides an extension point for deployments that need to scale task queue rate limits by a fractional value per namespace/task queue, without changing rate limit logic. ## How did you test it? - [x] built - [ ] run locally and tested manually - [x] covered by existing tests - [x] added new unit tests for clamping behavior ## Potential risks The fraction defaults to `1.0` so existing behavior is unchanged. A misconfigured implementation returning `0.0` would effectively pause task dispatch — operators overriding this interface should ensure correctness of the returned fraction. --------- Signed-off-by: Sandeep Balaji <sandeep.balaji@temporal.io> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 40d5f20 commit a31f476

7 files changed

Lines changed: 232 additions & 7 deletions

service/matching/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ type (
9696
TaskQueueInfoByBuildIdTTL dynamicconfig.DurationPropertyFnWithTaskQueueFilter
9797
PriorityLevels dynamicconfig.IntPropertyFnWithTaskQueueFilter
9898

99+
RateLimitFractionProvider TaskQueueRateLimitFractionProvider
100+
99101
RateLimiterRefreshInterval time.Duration
100102
FairnessKeyRateLimitCacheSize dynamicconfig.IntPropertyFnWithTaskQueueFilter
101103
MaxFairnessKeyWeightOverrides dynamicconfig.IntPropertyFnWithTaskQueueFilter
@@ -214,6 +216,7 @@ type (
214216
MaxVersionsInTaskQueue func() int
215217

216218
// Rate limiting
219+
RateLimitFraction func() float64
217220
RateLimiterRefreshInterval time.Duration
218221
FairnessKeyRateLimitCacheSize func() int
219222
MaxFairnessKeyWeightOverrides func() int
@@ -387,6 +390,8 @@ func NewConfig(
387390
PartitionScaleManagerSettings: dynamicconfig.MatchingPartitionScaleManager.Get(dc),
388391

389392
LogAllReqErrors: dynamicconfig.LogAllReqErrors.Get(dc),
393+
394+
RateLimitFractionProvider: defaultTaskQueueRateLimitFractionProvider,
390395
}
391396
}
392397

@@ -535,6 +540,9 @@ func newTaskQueueConfig(tq *tqid.TaskQueue, config *Config, ns namespace.Name) *
535540
TaskQueueInfoByBuildIdTTL: func() time.Duration {
536541
return config.TaskQueueInfoByBuildIdTTL(ns.String(), taskQueueName, taskType)
537542
},
543+
RateLimitFraction: func() float64 {
544+
return config.RateLimitFractionProvider.GetRateLimitFraction(ns, taskQueueName, taskType)
545+
},
538546
RateLimiterRefreshInterval: config.RateLimiterRefreshInterval,
539547
FairnessKeyRateLimitCacheSize: func() int {
540548
return config.FairnessKeyRateLimitCacheSize(ns.String(), taskQueueName, taskType)

service/matching/fx.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ var Module = fx.Options(
5151
fx.Provide(ServerProvider),
5252
fx.Provide(NewService),
5353
fx.Provide(simplePartitionScalerFactoryProvider),
54+
fx.Provide(taskQueueRateLimitFractionProviderProvider),
5455
fx.Invoke(ServiceLifetimeHooks),
5556
)
5657

@@ -61,8 +62,11 @@ func ServerProvider(grpcServerOptions []grpc.ServerOption) *grpc.Server {
6162
func ConfigProvider(
6263
dc *dynamicconfig.Collection,
6364
persistenceConfig config.Persistence,
65+
rateLimitFractionProvider TaskQueueRateLimitFractionProvider,
6466
) *Config {
65-
return NewConfig(dc)
67+
cfg := NewConfig(dc)
68+
cfg.RateLimitFractionProvider = rateLimitFractionProvider
69+
return cfg
6670
}
6771

6872
func ServiceErrorInterceptorProvider(

service/matching/matching_engine_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,8 @@ func newMatchingEngine(
245245
taskManager: taskMgr,
246246
fairTaskManager: fairTaskMgr,
247247
historyClient: mockHistoryClient,
248-
partitions: make(map[tqid.PartitionKey]taskQueuePartitionManager),
248+
249+
partitions: make(map[tqid.PartitionKey]taskQueuePartitionManager),
249250
gaugeMetrics: gaugeMetrics{
250251
loadedTaskQueueFamilyCount: make(map[taskQueueCounterKey]int),
251252
loadedTaskQueueCount: make(map[taskQueueCounterKey]int),
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package matching
2+
3+
import (
4+
enumspb "go.temporal.io/api/enums/v1"
5+
"go.temporal.io/server/common/namespace"
6+
)
7+
8+
// TaskQueueRateLimitFractionProvider returns the fraction of the rate limit for a task queue.
9+
// The returned fraction is always clamped to [0.0, 1.0].
10+
type TaskQueueRateLimitFractionProvider interface {
11+
GetRateLimitFraction(nsName namespace.Name, tqName string, tqType enumspb.TaskQueueType) float64
12+
}
13+
14+
const (
15+
minRateLimitFraction = 0.0
16+
maxRateLimitFraction = 1.0
17+
defaultRateLimitFraction = maxRateLimitFraction
18+
)
19+
20+
// TaskQueueRateLimitFractionProviderFunc is a function that implements TaskQueueRateLimitFractionProvider.
21+
type TaskQueueRateLimitFractionProviderFunc func(nsName namespace.Name, tqName string, tqType enumspb.TaskQueueType) float64
22+
23+
func (f TaskQueueRateLimitFractionProviderFunc) GetRateLimitFraction(nsName namespace.Name, tqName string, tqType enumspb.TaskQueueType) float64 {
24+
return f(nsName, tqName, tqType)
25+
}
26+
27+
// NewTaskQueueRateLimitFractionProvider wraps inner and enforces [0.0, 1.0] on every call.
28+
func NewTaskQueueRateLimitFractionProvider(inner TaskQueueRateLimitFractionProvider) TaskQueueRateLimitFractionProvider {
29+
return TaskQueueRateLimitFractionProviderFunc(func(nsName namespace.Name, tqName string, tqType enumspb.TaskQueueType) float64 {
30+
return max(min(inner.GetRateLimitFraction(nsName, tqName, tqType), maxRateLimitFraction), minRateLimitFraction)
31+
})
32+
}
33+
34+
type unitRateLimitFractionProvider struct{}
35+
36+
func (p *unitRateLimitFractionProvider) GetRateLimitFraction(_ namespace.Name, _ string, _ enumspb.TaskQueueType) float64 {
37+
return defaultRateLimitFraction
38+
}
39+
40+
var defaultTaskQueueRateLimitFractionProvider = NewTaskQueueRateLimitFractionProvider(&unitRateLimitFractionProvider{})
41+
42+
func taskQueueRateLimitFractionProviderProvider() TaskQueueRateLimitFractionProvider {
43+
return defaultTaskQueueRateLimitFractionProvider
44+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package matching
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
enumspb "go.temporal.io/api/enums/v1"
8+
"go.temporal.io/server/common/namespace"
9+
)
10+
11+
func TestNewTaskQueueRateLimitFractionProvider_ClampedToMax(t *testing.T) {
12+
inner := TaskQueueRateLimitFractionProviderFunc(func(_ namespace.Name, _ string, _ enumspb.TaskQueueType) float64 { return 2.0 })
13+
p := NewTaskQueueRateLimitFractionProvider(inner)
14+
require.InEpsilon(t, maxRateLimitFraction, p.GetRateLimitFraction("ns", "tq", enumspb.TASK_QUEUE_TYPE_WORKFLOW), 0)
15+
}
16+
17+
func TestNewTaskQueueRateLimitFractionProvider_ClampedToMin(t *testing.T) {
18+
inner := TaskQueueRateLimitFractionProviderFunc(func(_ namespace.Name, _ string, _ enumspb.TaskQueueType) float64 { return -0.5 })
19+
p := NewTaskQueueRateLimitFractionProvider(inner)
20+
require.InDelta(t, minRateLimitFraction, p.GetRateLimitFraction("ns", "tq", enumspb.TASK_QUEUE_TYPE_WORKFLOW), 0)
21+
}
22+
23+
func TestNewTaskQueueRateLimitFractionProvider_PassthroughInRange(t *testing.T) {
24+
inner := TaskQueueRateLimitFractionProviderFunc(func(_ namespace.Name, _ string, _ enumspb.TaskQueueType) float64 { return 0.5 })
25+
p := NewTaskQueueRateLimitFractionProvider(inner)
26+
require.InEpsilon(t, 0.5, p.GetRateLimitFraction("ns", "tq", enumspb.TASK_QUEUE_TYPE_WORKFLOW), 0)
27+
}

service/matching/ratelimit_manager.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ const (
6363
)
6464

6565
// Create a new rate limit manager for the task queue partition.
66-
func newRateLimitManager(userDataManager userDataManager,
66+
func newRateLimitManager(
67+
userDataManager userDataManager,
6768
config *taskQueueConfig,
6869
taskQueueType enumspb.TaskQueueType,
6970
) *rateLimitManager {
@@ -143,12 +144,13 @@ func (r *rateLimitManager) computeEffectiveRPSAndSourceLocked() {
143144
r.adminTqRate,
144145
)
145146
r.systemRPS = systemRPS
147+
fraction := r.config.RateLimitFraction()
146148
switch {
147149
case r.apiConfigRPS != nil:
148-
effectiveRPS = *r.apiConfigRPS / float64(r.numReadPartitions)
150+
effectiveRPS = *r.apiConfigRPS * fraction / float64(r.numReadPartitions)
149151
rateLimitSource = enumspb.RATE_LIMIT_SOURCE_API
150152
case r.workerRPS != nil:
151-
effectiveRPS = *r.workerRPS / float64(r.numReadPartitions)
153+
effectiveRPS = *r.workerRPS * fraction / float64(r.numReadPartitions)
152154
rateLimitSource = enumspb.RATE_LIMIT_SOURCE_WORKER
153155
}
154156

@@ -235,8 +237,10 @@ func (r *rateLimitManager) trySetRPSFromUserDataLocked() {
235237
if fairnessKeyRateLimitDefault.GetRateLimit() == nil {
236238
r.fairnessKeyRateLimitDefault = nil
237239
} else {
238-
// Maintain the fairnessKeyRateLimitDefault as per-partition rate.
239-
val := float64(fairnessKeyRateLimitDefault.GetRateLimit().GetRequestsPerSecond()) / float64(r.numReadPartitions)
240+
// Maintain the fairnessKeyRateLimitDefault as per-partition rate, scaled by the same
241+
// fraction applied to the whole-queue effectiveRPS.
242+
fraction := r.config.RateLimitFraction()
243+
val := float64(fairnessKeyRateLimitDefault.GetRateLimit().GetRequestsPerSecond()) * fraction / float64(r.numReadPartitions)
240244
r.fairnessKeyRateLimitDefault = &val
241245
}
242246
fairnessWeightOverrides := config.GetFairnessWeightOverrides()

service/matching/ratelimit_manager_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import (
77
"github.com/stretchr/testify/require"
88
"github.com/stretchr/testify/suite"
99
enumspb "go.temporal.io/api/enums/v1"
10+
taskqueuepb "go.temporal.io/api/taskqueue/v1"
11+
persistencespb "go.temporal.io/server/api/persistence/v1"
1012
"go.temporal.io/server/common/dynamicconfig"
13+
"go.temporal.io/server/common/namespace"
1114
"go.temporal.io/server/common/tqid"
1215
)
1316

@@ -89,3 +92,137 @@ func (r *rateLimitManager) SetFairnessKeyRateLimitDefaultForTesting(rps float64,
8992
defer r.mu.Unlock()
9093
r.fairnessKeyRateLimitDefault = &rps
9194
}
95+
96+
func (r *rateLimitManager) SetAPIConfigRPSForTesting(rps float64) {
97+
r.mu.Lock()
98+
defer r.mu.Unlock()
99+
r.apiConfigRPS = &rps
100+
r.computeEffectiveRPSAndSourceLocked()
101+
}
102+
103+
func (r *rateLimitManager) SetWorkerRPSForTesting(rps float64) {
104+
r.mu.Lock()
105+
defer r.mu.Unlock()
106+
r.workerRPS = &rps
107+
r.computeEffectiveRPSAndSourceLocked()
108+
}
109+
110+
// GetFairnessKeyRateLimitDefaultForTesting returns the stored per-partition fairness-key rate and
111+
// whether it is set. It is the value stored after fraction and partition scaling.
112+
func (r *rateLimitManager) GetFairnessKeyRateLimitDefaultForTesting() (float64, bool) {
113+
r.mu.Lock()
114+
defer r.mu.Unlock()
115+
if r.fairnessKeyRateLimitDefault == nil {
116+
return 0, false
117+
}
118+
return *r.fairnessKeyRateLimitDefault, true
119+
}
120+
121+
func (r *rateLimitManager) GetNumReadPartitionsForTesting() int {
122+
r.mu.Lock()
123+
defer r.mu.Unlock()
124+
return r.numReadPartitions
125+
}
126+
127+
// newConfigWithFraction returns a Config whose RateLimitFractionProvider always returns the given fraction.
128+
func newConfigWithFraction(fraction float64) *Config {
129+
cfg := NewConfig(dynamicconfig.NewNoopCollection())
130+
cfg.RateLimitFractionProvider = TaskQueueRateLimitFractionProviderFunc(
131+
func(_ namespace.Name, _ string, _ enumspb.TaskQueueType) float64 {
132+
return fraction
133+
},
134+
)
135+
return cfg
136+
}
137+
138+
// TestFractionScaling_ApiConfigRPS verifies that apiConfigRPS is scaled by the fraction
139+
// before being divided by the number of read partitions.
140+
func (s *RateLimitManagerSuite) TestFractionScaling_ApiConfigRPS() {
141+
cfg := newConfigWithFraction(0.5)
142+
config := newTaskQueueConfig(
143+
tqid.UnsafeTaskQueueFamily("test-ns", "test-tq").TaskQueue(enumspb.TASK_QUEUE_TYPE_WORKFLOW),
144+
cfg, "test-ns",
145+
)
146+
rlm := newRateLimitManager(&mockUserDataManager{}, config, enumspb.TASK_QUEUE_TYPE_WORKFLOW)
147+
defer rlm.Stop()
148+
149+
rlm.SetAPIConfigRPSForTesting(100.0)
150+
151+
rps, source := rlm.GetEffectiveRPSAndSource()
152+
// effectiveRPS = apiRPS * fraction / numPartitions; GetEffectiveRPSAndSource multiplies back by numPartitions → apiRPS * fraction
153+
s.InEpsilon(50.0, rps, 1e-9)
154+
s.Equal(enumspb.RATE_LIMIT_SOURCE_API, source)
155+
}
156+
157+
// TestFractionScaling_WorkerRPS verifies that workerRPS is scaled by the fraction.
158+
func (s *RateLimitManagerSuite) TestFractionScaling_WorkerRPS() {
159+
cfg := newConfigWithFraction(0.5)
160+
config := newTaskQueueConfig(
161+
tqid.UnsafeTaskQueueFamily("test-ns", "test-tq").TaskQueue(enumspb.TASK_QUEUE_TYPE_WORKFLOW),
162+
cfg, "test-ns",
163+
)
164+
rlm := newRateLimitManager(&mockUserDataManager{}, config, enumspb.TASK_QUEUE_TYPE_WORKFLOW)
165+
defer rlm.Stop()
166+
167+
rlm.SetWorkerRPSForTesting(100.0)
168+
169+
rps, source := rlm.GetEffectiveRPSAndSource()
170+
s.InEpsilon(50.0, rps, 1e-9)
171+
s.Equal(enumspb.RATE_LIMIT_SOURCE_WORKER, source)
172+
}
173+
174+
// TestFractionScaling_FairnessKeyRateLimitDefault verifies that the per-key fairness rate default
175+
// is also scaled by the fraction (Fix 3 coverage).
176+
func (s *RateLimitManagerSuite) TestFractionScaling_FairnessKeyRateLimitDefault() {
177+
const fraction = 0.5
178+
const fairnessKeyRPS = float32(100)
179+
cfg := newConfigWithFraction(fraction)
180+
config := newTaskQueueConfig(
181+
tqid.UnsafeTaskQueueFamily("test-ns", "test-tq").TaskQueue(enumspb.TASK_QUEUE_TYPE_WORKFLOW),
182+
cfg, "test-ns",
183+
)
184+
udm := &mockUserDataManager{
185+
data: &persistencespb.VersionedTaskQueueUserData{
186+
Data: &persistencespb.TaskQueueUserData{
187+
PerType: map[int32]*persistencespb.TaskQueueTypeUserData{
188+
int32(enumspb.TASK_QUEUE_TYPE_WORKFLOW): {
189+
Config: &taskqueuepb.TaskQueueConfig{
190+
FairnessKeysRateLimitDefault: &taskqueuepb.RateLimitConfig{
191+
RateLimit: &taskqueuepb.RateLimit{RequestsPerSecond: fairnessKeyRPS},
192+
},
193+
},
194+
},
195+
},
196+
},
197+
},
198+
}
199+
rlm := newRateLimitManager(udm, config, enumspb.TASK_QUEUE_TYPE_WORKFLOW)
200+
defer rlm.Stop()
201+
202+
rlm.UserDataChanged()
203+
204+
got, ok := rlm.GetFairnessKeyRateLimitDefaultForTesting()
205+
s.True(ok)
206+
// val = fairnessKeyRPS * fraction / numPartitions
207+
numPartitions := rlm.GetNumReadPartitionsForTesting()
208+
want := float64(fairnessKeyRPS) * fraction / float64(numPartitions)
209+
s.InEpsilon(want, got, 1e-9)
210+
}
211+
212+
// TestFractionScaling_ZeroFraction verifies that a zero fraction (fully drained) reduces
213+
// the effective RPS to zero even when an API rate limit is set.
214+
func (s *RateLimitManagerSuite) TestFractionScaling_ZeroFraction() {
215+
cfg := newConfigWithFraction(0.0)
216+
config := newTaskQueueConfig(
217+
tqid.UnsafeTaskQueueFamily("test-ns", "test-tq").TaskQueue(enumspb.TASK_QUEUE_TYPE_WORKFLOW),
218+
cfg, "test-ns",
219+
)
220+
rlm := newRateLimitManager(&mockUserDataManager{}, config, enumspb.TASK_QUEUE_TYPE_WORKFLOW)
221+
defer rlm.Stop()
222+
223+
rlm.SetAPIConfigRPSForTesting(100.0)
224+
225+
rps, source := rlm.GetEffectiveRPSAndSource()
226+
s.InDelta(0.0, rps, 0.0)
227+
s.Equal(enumspb.RATE_LIMIT_SOURCE_API, source)
228+
}

0 commit comments

Comments
 (0)