Files
tasks/service/frontend/fx_test.go
9a4bf2319d Prevent frontend burst value going below 1 (#6730)
## What changed?
Prevent frontend burst value going below 0 in NewNamespaceRateBurst()

## Why?
If the rate value is too small, the burst value can become 0. This will
block all requests. This can happen for rate limiters with small global
RPS values(eg. namespaceReplicationInducingAPIs rate limiter) in a
cluster with large number of frontend instances.

## How did you test it?
Existing tests.

## Potential risks

## Documentation

## Is hotfix candidate?
No

---------

Co-authored-by: David Reiss <[email protected]>
2024-10-31 15:30:52 -07:00

848 lines
32 KiB
Go

// The MIT License
//
// Copyright (c) 2020 Temporal Technologies Inc. All rights reserved.
//
// Copyright (c) 2020 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
package frontend
import (
"context"
"errors"
"net"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/api/serviceerror"
"go.temporal.io/api/workflowservice/v1"
"go.temporal.io/server/common/log"
"go.temporal.io/server/common/membership"
"go.temporal.io/server/common/metrics"
"go.temporal.io/server/common/metrics/metricstest"
"go.temporal.io/server/common/namespace"
"go.temporal.io/server/common/primitives"
"go.temporal.io/server/common/rpc"
"go.temporal.io/server/internal/nettest"
"go.uber.org/mock/gomock"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
type rateLimitInterceptorTestCase struct {
// name of the test case
name string
// t is the test object
t *testing.T
// globalRPSLimit is the global RPS limit for all frontend hosts
globalRPSLimit int
// perInstanceRPSLimit is the RPS limit for each frontend host
perInstanceRPSLimit int
// operatorRPSRatio is the ratio of the global RPS limit that is reserved for operator requests
operatorRPSRatio float64
// expectRateLimit is true if the interceptor should return a rate limit error
expectRateLimit bool
// numRequests is the number of requests to send to the interceptor
numRequests int
// serviceResolver is used to determine the number of frontend hosts for the global rate limiter
serviceResolver membership.ServiceResolver
// configure is a function that can be used to override the default test case values
configure func(tc *rateLimitInterceptorTestCase)
}
type namespaceRateLimitInterceptorTestCase struct {
// name of the test case
name string
// numRequests is the number of requests to send to the interceptor
numRequests int
// numVisibility is the number of visibility requests to send to the interceptor
numVisibilityRequests int
// numReplicationInducingRequests is the number of replication inducing requests to send to the interceptor
numReplicationInducingRequests int
// expectRateLimit is true if the interceptor should return a rate limit error
expectRateLimit bool
// frontendServiceCount is the number of frontend services returned by ServiceResolver to rate limiter
frontendServiceCount int
// Rate limiter config values
globalNamespaceRPS int
maxNamespaceRPSPerInstance int
maxNamespaceBurstRatioPerInstance float64
globalNamespaceVisibilityRPS int
maxNamespaceVisibilityRPSPerInstance int
maxNamespaceVisibilityBurstRatioPerInstance float64
globalNamespaceNamespaceReplicationInducingAPIsRPS int
maxNamespaceNamespaceReplicationInducingAPIsRPSPerInstance int
maxNamespaceNamespaceReplicationInducingAPIsBurstRatioPerInstance float64
}
type rateLimitMetricsTestcase struct {
// name of the test case
name string
// frontendServiceCount is the number of frontend services returned by ServiceResolver to rate limiter
frontendServiceCount int
// Rate limiter config values
rps int
globalRPS int
expectedHostLimit int
}
func TestRateLimitInterceptorProvider(t *testing.T) {
t.Parallel()
// The burst limit is 2 * the rps limit, so this is 8, which is < the number of requests. The interceptor should
// rate limit one of the last two requests because we exceeded the burst limit, and there is no delay between the
// last two requests.
lowPerInstanceRPSLimit := 4
// The burst limit is 2 * the rps limit, so this is 10, which is >= the number of requests. The interceptor should
// not rate limit any of the requests because we never exceed the burst limit.
highPerInstanceRPSLimit := 5
// The number of hosts is 10, so if 4 is too low of an RPS limit per-instance, then 40 is too low of a global RPS
// limit.
numHosts := 10
lowGlobalRPSLimit := lowPerInstanceRPSLimit * numHosts
highGlobalRPSLimit := highPerInstanceRPSLimit * numHosts
operatorRPSRatio := 0.2
testCases := []rateLimitInterceptorTestCase{
{
name: "both rate limits hit",
configure: func(tc *rateLimitInterceptorTestCase) {
tc.globalRPSLimit = lowGlobalRPSLimit
tc.perInstanceRPSLimit = lowPerInstanceRPSLimit
tc.operatorRPSRatio = operatorRPSRatio
tc.expectRateLimit = true
},
},
{
name: "global rate limit hit",
configure: func(tc *rateLimitInterceptorTestCase) {
tc.globalRPSLimit = lowGlobalRPSLimit
tc.perInstanceRPSLimit = highPerInstanceRPSLimit
tc.operatorRPSRatio = operatorRPSRatio
tc.expectRateLimit = true
},
},
{
name: "per instance rate limit hit but ignored because global rate limit is not hit",
configure: func(tc *rateLimitInterceptorTestCase) {
tc.globalRPSLimit = highGlobalRPSLimit
tc.perInstanceRPSLimit = lowPerInstanceRPSLimit
tc.operatorRPSRatio = operatorRPSRatio
tc.expectRateLimit = false
},
},
{
name: "neither rate limit hit",
configure: func(tc *rateLimitInterceptorTestCase) {
tc.globalRPSLimit = highGlobalRPSLimit
tc.perInstanceRPSLimit = highPerInstanceRPSLimit
tc.operatorRPSRatio = operatorRPSRatio
tc.expectRateLimit = false
},
},
{
name: "global rate limit not configured and per instance rate limit not hit",
configure: func(tc *rateLimitInterceptorTestCase) {
tc.globalRPSLimit = 0
tc.perInstanceRPSLimit = highPerInstanceRPSLimit
tc.operatorRPSRatio = operatorRPSRatio
tc.expectRateLimit = false
},
},
{
name: "global rate limit not configured and per instance rate limit is hit",
configure: func(tc *rateLimitInterceptorTestCase) {
tc.globalRPSLimit = 0
tc.perInstanceRPSLimit = lowPerInstanceRPSLimit
tc.operatorRPSRatio = operatorRPSRatio
tc.expectRateLimit = true
},
},
{
name: "global rate limit not configured and zero per-instance rate limit",
configure: func(tc *rateLimitInterceptorTestCase) {
tc.globalRPSLimit = 0
tc.perInstanceRPSLimit = 0
tc.operatorRPSRatio = operatorRPSRatio
tc.expectRateLimit = true
},
},
{
name: "nil service resolver causes global RPS limit to be ignored",
configure: func(tc *rateLimitInterceptorTestCase) {
tc.globalRPSLimit = lowPerInstanceRPSLimit
tc.perInstanceRPSLimit = highPerInstanceRPSLimit
tc.operatorRPSRatio = operatorRPSRatio
tc.expectRateLimit = false
tc.serviceResolver = nil
},
},
{
name: "no hosts causes global RPS limit to be ignored",
configure: func(tc *rateLimitInterceptorTestCase) {
tc.globalRPSLimit = lowPerInstanceRPSLimit
tc.perInstanceRPSLimit = highPerInstanceRPSLimit
tc.operatorRPSRatio = operatorRPSRatio
tc.expectRateLimit = false
serviceResolver := membership.NewMockServiceResolver(gomock.NewController(tc.t))
serviceResolver.EXPECT().AvailableMemberCount().Return(0).AnyTimes()
tc.serviceResolver = serviceResolver
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.numRequests = 10
tc.t = t
{
// Create a mock service resolver which returns the number of frontend hosts.
// This may be overridden by the test case.
ctrl := gomock.NewController(t)
serviceResolver := membership.NewMockServiceResolver(ctrl)
serviceResolver.EXPECT().AvailableMemberCount().Return(numHosts).AnyTimes()
tc.serviceResolver = serviceResolver
}
tc.configure(&tc)
// Create a rate limit interceptor which uses the per-instance and global RPS limits from the test case.
rateLimitInterceptor := RateLimitInterceptorProvider(&Config{
RPS: func() int {
return tc.perInstanceRPSLimit
},
GlobalRPS: func() int {
return tc.globalRPSLimit
},
NamespaceReplicationInducingAPIsRPS: func() int {
// this is not used in this test
return 0
},
OperatorRPSRatio: func() float64 {
return tc.operatorRPSRatio
},
}, tc.serviceResolver, metrics.NoopMetricsHandler, log.NewTestLogger())
// Create a gRPC server for the fake workflow service.
svc := &testSvc{}
server := grpc.NewServer(grpc.ChainUnaryInterceptor(
rpc.ServiceErrorInterceptor,
rpc.NewFrontendServiceErrorInterceptor(log.NewTestLogger()),
rateLimitInterceptor.Intercept,
))
workflowservice.RegisterWorkflowServiceServer(server, svc)
pipe := nettest.NewPipe()
var wg sync.WaitGroup
defer wg.Wait()
wg.Add(1)
listener := nettest.NewListener(pipe)
go func() {
defer wg.Done()
_ = server.Serve(listener)
}()
// Create a gRPC client to the fake workflow service.
dialer := grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return pipe.Connect(ctx.Done())
})
transportCredentials := grpc.WithTransportCredentials(insecure.NewCredentials())
conn, err := grpc.NewClient("localhost", dialer, transportCredentials)
require.NoError(t, err)
conn.Connect()
defer server.Stop()
client := workflowservice.NewWorkflowServiceClient(conn)
var header metadata.MD
// Generate load by sending a number of requests to the server.
for i := 0; i < tc.numRequests; i++ {
_, err = client.StartWorkflowExecution(
context.Background(),
&workflowservice.StartWorkflowExecutionRequest{},
grpc.Header(&header),
)
if err != nil {
break
}
}
// Check if the rate limit is hit.
if tc.expectRateLimit {
assert.ErrorContains(t, err, "rate limit exceeded")
s := status.Convert(err)
var resourceExhausted *serviceerror.ResourceExhausted
errors.As(serviceerror.FromStatus(s), &resourceExhausted)
assert.Equal(t, enumspb.RESOURCE_EXHAUSTED_CAUSE_RPS_LIMIT, resourceExhausted.Cause)
assert.Equal(t, enumspb.RESOURCE_EXHAUSTED_SCOPE_SYSTEM, resourceExhausted.Scope)
assert.Len(t, header.Get(rpc.ResourceExhaustedCauseHeader), 1)
assert.Equal(t, enumspb.RESOURCE_EXHAUSTED_CAUSE_RPS_LIMIT.String(), header.Get(rpc.ResourceExhaustedCauseHeader)[0])
assert.Len(t, header.Get(rpc.ResourceExhaustedScopeHeader), 1)
assert.Equal(t, enumspb.RESOURCE_EXHAUSTED_SCOPE_SYSTEM.String(), header.Get(rpc.ResourceExhaustedScopeHeader)[0])
} else {
assert.NoError(t, err)
}
})
}
}
// testSvc is a fake workflow service.
type testSvc struct {
workflowservice.UnimplementedWorkflowServiceServer
}
// StartWorkflowExecution is a fake implementation of the StartWorkflowExecution gRPC method which does nothing.
func (t *testSvc) StartWorkflowExecution(
context.Context,
*workflowservice.StartWorkflowExecutionRequest,
) (*workflowservice.StartWorkflowExecutionResponse, error) {
return &workflowservice.StartWorkflowExecutionResponse{}, nil
}
// ListWorkflowExecution is a fake implementation of the ListWorkflowExecution gRPC method which does nothing.
func (t *testSvc) ListWorkflowExecutions(
context.Context,
*workflowservice.ListWorkflowExecutionsRequest,
) (*workflowservice.ListWorkflowExecutionsResponse, error) {
return &workflowservice.ListWorkflowExecutionsResponse{}, nil
}
// RegisterNamespace is a fake implementation of the RegisterNamespace gRPC method which does nothing.
func (t *testSvc) RegisterNamespace(
context.Context,
*workflowservice.RegisterNamespaceRequest,
) (*workflowservice.RegisterNamespaceResponse, error) {
return &workflowservice.RegisterNamespaceResponse{}, nil
}
func TestNamespaceRateLimitInterceptorProvider(t *testing.T) {
t.Parallel()
testCases := []namespaceRateLimitInterceptorTestCase{
{
name: "namespace rate allow when burst ratio is 1",
maxNamespaceRPSPerInstance: 10,
maxNamespaceBurstRatioPerInstance: 1,
numRequests: 10,
expectRateLimit: false,
},
{
name: "namespace rate hit when burst ratio is 1",
maxNamespaceRPSPerInstance: 10,
maxNamespaceBurstRatioPerInstance: 1,
numRequests: 11,
expectRateLimit: true,
},
{
name: "namespace burst allow when burst ratio is 1.5",
maxNamespaceRPSPerInstance: 10,
maxNamespaceBurstRatioPerInstance: 1.5,
numRequests: 15,
expectRateLimit: false,
},
{
name: "namespace burst hit when burst ratio is 1.5",
maxNamespaceRPSPerInstance: 10,
maxNamespaceBurstRatioPerInstance: 1.5,
numRequests: 16,
expectRateLimit: true,
},
{
name: "namespace burst allow when burst ratio is 0.5",
maxNamespaceRPSPerInstance: 10,
maxNamespaceBurstRatioPerInstance: 0.5,
numRequests: 5,
expectRateLimit: false,
},
{
name: "namespace burst hit when burst ratio is 0.5",
maxNamespaceRPSPerInstance: 10,
maxNamespaceBurstRatioPerInstance: 0.5,
numRequests: 6,
expectRateLimit: true,
},
{
name: "namespace burst allow when burst ratio is 1 and global limit is set",
frontendServiceCount: 1,
globalNamespaceRPS: 10,
maxNamespaceRPSPerInstance: 5,
maxNamespaceBurstRatioPerInstance: 1,
numRequests: 10,
expectRateLimit: false,
},
{
name: "namespace burst hit when burst ratio is 1 and global limit is set",
frontendServiceCount: 1,
globalNamespaceRPS: 5,
maxNamespaceRPSPerInstance: 10,
maxNamespaceBurstRatioPerInstance: 1,
numRequests: 6,
expectRateLimit: true,
},
{
name: "namespace burst allow when burst ratio is 1.5 and global limit is set",
frontendServiceCount: 1,
globalNamespaceRPS: 10,
maxNamespaceRPSPerInstance: 5,
maxNamespaceBurstRatioPerInstance: 1.5,
numRequests: 15,
expectRateLimit: false,
},
{
name: "namespace burst hit when burst ratio is 1.5 and global limit is set",
frontendServiceCount: 1,
globalNamespaceRPS: 5,
maxNamespaceRPSPerInstance: 10,
maxNamespaceBurstRatioPerInstance: 1.5,
numRequests: 9,
expectRateLimit: true,
},
{
name: "visibility rate allow when burst ratio is 1",
maxNamespaceVisibilityRPSPerInstance: 10,
maxNamespaceVisibilityBurstRatioPerInstance: 1,
numVisibilityRequests: 10,
expectRateLimit: false,
},
{
name: "visibility rate hit when burst ratio is 1",
maxNamespaceVisibilityRPSPerInstance: 10,
maxNamespaceVisibilityBurstRatioPerInstance: 1,
numVisibilityRequests: 11,
expectRateLimit: true,
},
{
name: "visibility burst allow when burst ratio is 1.5",
maxNamespaceVisibilityRPSPerInstance: 10,
maxNamespaceVisibilityBurstRatioPerInstance: 1.5,
numVisibilityRequests: 15,
expectRateLimit: false,
},
{
name: "visibility burst hit when burst ratio is 1.5",
maxNamespaceVisibilityRPSPerInstance: 10,
maxNamespaceVisibilityBurstRatioPerInstance: 1.5,
numVisibilityRequests: 16,
expectRateLimit: true,
},
{
name: "visibility burst allow when burst ratio is 0.5",
maxNamespaceVisibilityRPSPerInstance: 10,
maxNamespaceVisibilityBurstRatioPerInstance: 0.5,
numVisibilityRequests: 5,
expectRateLimit: false,
},
{
name: "visibility burst hit when burst ratio is 0.5",
maxNamespaceVisibilityRPSPerInstance: 10,
maxNamespaceVisibilityBurstRatioPerInstance: 0.5,
numVisibilityRequests: 6,
expectRateLimit: true,
},
{
name: "visibility burst allow when burst ratio is 1 and global limit is set",
frontendServiceCount: 1,
globalNamespaceVisibilityRPS: 10,
maxNamespaceVisibilityRPSPerInstance: 5,
maxNamespaceVisibilityBurstRatioPerInstance: 1,
numVisibilityRequests: 10,
expectRateLimit: false,
},
{
name: "visibility burst hit when burst ratio is 1 and global limit is set",
frontendServiceCount: 1,
globalNamespaceVisibilityRPS: 5,
maxNamespaceVisibilityRPSPerInstance: 10,
maxNamespaceVisibilityBurstRatioPerInstance: 1,
numVisibilityRequests: 6,
expectRateLimit: true,
},
{
name: "visibility burst allow when burst ratio is 1.5 and global limit is set",
frontendServiceCount: 1,
globalNamespaceVisibilityRPS: 10,
maxNamespaceVisibilityRPSPerInstance: 5,
maxNamespaceVisibilityBurstRatioPerInstance: 1.5,
numVisibilityRequests: 15,
expectRateLimit: false,
},
{
name: "visibility burst hit when burst ratio is 1.5 and global limit is set",
frontendServiceCount: 1,
globalNamespaceVisibilityRPS: 5,
maxNamespaceVisibilityRPSPerInstance: 10,
maxNamespaceVisibilityBurstRatioPerInstance: 1.5,
numVisibilityRequests: 9,
expectRateLimit: true,
},
{
name: "replication inducing op rate allow when burst ratio is 1",
maxNamespaceNamespaceReplicationInducingAPIsRPSPerInstance: 10,
maxNamespaceNamespaceReplicationInducingAPIsBurstRatioPerInstance: 1,
numReplicationInducingRequests: 10,
expectRateLimit: false,
},
{
name: "replication inducing op rate hit when burst ratio is 1",
maxNamespaceNamespaceReplicationInducingAPIsRPSPerInstance: 10,
maxNamespaceNamespaceReplicationInducingAPIsBurstRatioPerInstance: 1,
numReplicationInducingRequests: 11,
expectRateLimit: true,
},
{
name: "replication inducing op burst allow when burst ratio is 1.5",
maxNamespaceNamespaceReplicationInducingAPIsRPSPerInstance: 10,
maxNamespaceNamespaceReplicationInducingAPIsBurstRatioPerInstance: 1.5,
numReplicationInducingRequests: 15,
expectRateLimit: false,
},
{
name: "replication inducing op burst hit when burst ratio is 1.5",
maxNamespaceNamespaceReplicationInducingAPIsRPSPerInstance: 10,
maxNamespaceNamespaceReplicationInducingAPIsBurstRatioPerInstance: 1.5,
numReplicationInducingRequests: 16,
expectRateLimit: true,
},
{
name: "replication inducing op burst allow when burst ratio is 0.5",
maxNamespaceNamespaceReplicationInducingAPIsRPSPerInstance: 10,
maxNamespaceNamespaceReplicationInducingAPIsBurstRatioPerInstance: 0.5,
numReplicationInducingRequests: 5,
expectRateLimit: false,
},
{
name: "replication inducing op burst hit when burst ratio is 0.5",
maxNamespaceNamespaceReplicationInducingAPIsRPSPerInstance: 10,
maxNamespaceNamespaceReplicationInducingAPIsBurstRatioPerInstance: 0.5,
numReplicationInducingRequests: 6,
expectRateLimit: true,
},
{
name: "replication inducing op burst allow when burst ratio is 1 and global limit is set",
frontendServiceCount: 1,
globalNamespaceNamespaceReplicationInducingAPIsRPS: 10,
maxNamespaceNamespaceReplicationInducingAPIsRPSPerInstance: 5,
maxNamespaceNamespaceReplicationInducingAPIsBurstRatioPerInstance: 1,
numReplicationInducingRequests: 10,
expectRateLimit: false,
},
{
name: "replication inducing op burst hit when burst ratio is 1 and global limit is set",
frontendServiceCount: 1,
globalNamespaceNamespaceReplicationInducingAPIsRPS: 5,
maxNamespaceNamespaceReplicationInducingAPIsRPSPerInstance: 10,
maxNamespaceNamespaceReplicationInducingAPIsBurstRatioPerInstance: 1,
numReplicationInducingRequests: 6,
expectRateLimit: true,
},
{
name: "replication inducing op burst allow when burst ratio is 1.5 and global limit is set",
frontendServiceCount: 1,
globalNamespaceNamespaceReplicationInducingAPIsRPS: 10,
maxNamespaceNamespaceReplicationInducingAPIsRPSPerInstance: 5,
maxNamespaceNamespaceReplicationInducingAPIsBurstRatioPerInstance: 1.5,
numReplicationInducingRequests: 15,
expectRateLimit: false,
},
{
name: "replication inducing op burst hit when burst ratio is 1.5 and global limit is set",
frontendServiceCount: 1,
globalNamespaceNamespaceReplicationInducingAPIsRPS: 5,
maxNamespaceNamespaceReplicationInducingAPIsRPSPerInstance: 10,
maxNamespaceNamespaceReplicationInducingAPIsBurstRatioPerInstance: 1.5,
numReplicationInducingRequests: 9,
expectRateLimit: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
namespaceName := "test-namespace"
mockRegistry := namespace.NewMockRegistry(gomock.NewController(t))
mockRegistry.EXPECT().GetNamespace(namespace.Name(namespaceName)).Return(&namespace.Namespace{}, nil).AnyTimes()
serviceResolver := membership.NewMockServiceResolver(gomock.NewController(t))
serviceResolver.EXPECT().AvailableMemberCount().Return(tc.frontendServiceCount).AnyTimes()
config := getTestConfig(tc)
// Create a rate limit interceptor.
rateLimitInterceptor := NamespaceRateLimitInterceptorProvider(
primitives.FrontendService,
&config,
mockRegistry,
serviceResolver,
log.NewTestLogger(),
)
// Create a gRPC server for the fake workflow service.
svc := &testSvc{}
server := grpc.NewServer(grpc.ChainUnaryInterceptor(
rpc.ServiceErrorInterceptor,
rpc.NewFrontendServiceErrorInterceptor(log.NewTestLogger()),
rateLimitInterceptor.Intercept,
))
workflowservice.RegisterWorkflowServiceServer(server, svc)
pipe := nettest.NewPipe()
var wg sync.WaitGroup
defer wg.Wait()
wg.Add(1)
listener := nettest.NewListener(pipe)
go func() {
defer wg.Done()
_ = server.Serve(listener)
}()
// Create a gRPC client to the fake workflow service.
dialer := grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return pipe.Connect(ctx.Done())
})
transportCredentials := grpc.WithTransportCredentials(insecure.NewCredentials())
conn, err := grpc.NewClient("localhost", dialer, transportCredentials)
require.NoError(t, err)
defer server.Stop()
client := workflowservice.NewWorkflowServiceClient(conn)
var header metadata.MD
defer func() {
// Check if the rate limit is hit.
if tc.expectRateLimit {
assert.ErrorContains(t, err, "rate limit exceeded")
s := status.Convert(err)
var resourceExhausted *serviceerror.ResourceExhausted
errors.As(serviceerror.FromStatus(s), &resourceExhausted)
assert.Equal(t, enumspb.RESOURCE_EXHAUSTED_CAUSE_RPS_LIMIT, resourceExhausted.Cause)
assert.Equal(t, enumspb.RESOURCE_EXHAUSTED_SCOPE_NAMESPACE, resourceExhausted.Scope)
assert.Len(t, header.Get(rpc.ResourceExhaustedCauseHeader), 1)
assert.Equal(t, enumspb.RESOURCE_EXHAUSTED_CAUSE_RPS_LIMIT.String(), header.Get(rpc.ResourceExhaustedCauseHeader)[0])
assert.Len(t, header.Get(rpc.ResourceExhaustedScopeHeader), 1)
assert.Equal(t, enumspb.RESOURCE_EXHAUSTED_SCOPE_NAMESPACE.String(), header.Get(rpc.ResourceExhaustedScopeHeader)[0])
} else {
assert.NoError(t, err)
}
}()
// Generate load by sending a number of requests to the server.
for i := 0; i < tc.numRequests; i++ {
_, err = client.StartWorkflowExecution(
context.Background(),
&workflowservice.StartWorkflowExecutionRequest{
Namespace: namespaceName,
},
grpc.Header(&header),
)
if err != nil {
return
}
}
for i := 0; i < tc.numVisibilityRequests; i++ {
_, err = client.ListWorkflowExecutions(
context.Background(),
&workflowservice.ListWorkflowExecutionsRequest{
Namespace: namespaceName,
},
grpc.Header(&header),
)
if err != nil {
return
}
}
for i := 0; i < tc.numReplicationInducingRequests; i++ {
_, err = client.RegisterNamespace(
context.Background(),
&workflowservice.RegisterNamespaceRequest{
Namespace: namespaceName,
},
grpc.Header(&header),
)
if err != nil {
return
}
}
})
}
}
func getTestConfig(tc namespaceRateLimitInterceptorTestCase) Config {
return Config{
OperatorRPSRatio: func() float64 {
return 0.20
},
GlobalNamespaceRPS: func(namespace string) int {
return getOrDefaultLimit(tc.globalNamespaceRPS)
},
GlobalNamespaceVisibilityRPS: func(namespace string) int {
return getOrDefaultLimit(tc.globalNamespaceVisibilityRPS)
},
GlobalNamespaceNamespaceReplicationInducingAPIsRPS: func(namespace string) int {
return getOrDefaultLimit(tc.globalNamespaceNamespaceReplicationInducingAPIsRPS)
},
MaxNamespaceRPSPerInstance: func(namespace string) int {
return getOrDefaultLimit(tc.maxNamespaceRPSPerInstance)
},
MaxNamespaceBurstRatioPerInstance: func(namespace string) float64 {
return tc.maxNamespaceBurstRatioPerInstance
},
MaxNamespaceVisibilityRPSPerInstance: func(namespace string) int {
return getOrDefaultLimit(tc.maxNamespaceVisibilityRPSPerInstance)
},
MaxNamespaceVisibilityBurstRatioPerInstance: func(namespace string) float64 {
return getOrDefaultLimit(tc.maxNamespaceVisibilityBurstRatioPerInstance)
},
MaxNamespaceNamespaceReplicationInducingAPIsRPSPerInstance: func(namespace string) int {
return getOrDefaultLimit(tc.maxNamespaceNamespaceReplicationInducingAPIsRPSPerInstance)
},
MaxNamespaceNamespaceReplicationInducingAPIsBurstRatioPerInstance: func(namespace string) float64 {
return getOrDefaultLimit(tc.maxNamespaceNamespaceReplicationInducingAPIsBurstRatioPerInstance)
},
}
}
func TestNamespaceRateLimitMetrics(t *testing.T) {
testCases := []rateLimitMetricsTestcase{
{
name: "global limit is emitted when set",
rps: 100,
globalRPS: 10,
frontendServiceCount: 5,
expectedHostLimit: 2, // This should be globalRPS/frontendServiceCount
},
{
name: "host limit emitted when global limit is not set",
rps: 10,
globalRPS: -1,
frontendServiceCount: 0,
expectedHostLimit: 10,
},
{
name: "host limit emitted when frontend service count is not set",
rps: 10,
globalRPS: 100,
frontendServiceCount: 0,
expectedHostLimit: 10,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
testNS := "test_namespace"
mockRegistry := namespace.NewMockRegistry(gomock.NewController(t))
mockRegistry.EXPECT().GetNamespace(namespace.Name(testNS)).Return(&namespace.Namespace{}, nil).AnyTimes()
serviceResolver := membership.NewMockServiceResolver(gomock.NewController(t))
serviceResolver.EXPECT().AvailableMemberCount().Return(tc.frontendServiceCount).AnyTimes()
metricsHandler := metricstest.NewCaptureHandler()
capture := metricsHandler.StartCapture()
config := &Config{
RPS: func() int {
return getOrDefaultLimit(tc.rps)
},
GlobalRPS: func() int {
return getOrDefaultLimit(tc.globalRPS)
},
// Values below this are not used in this test. But they are required to initialize rate limiter.
OperatorRPSRatio: func() float64 {
return 0.2
},
NamespaceReplicationInducingAPIsRPS: func() int {
return 1
},
}
// Create a rate limit interceptor which uses the per-instance and global RPS limits from the test case.
rateLimitInterceptor := RateLimitInterceptorProvider(config, serviceResolver, metricsHandler, log.NewTestLogger())
// Create a gRPC server for the fake workflow service.
svc := &testSvc{}
server := grpc.NewServer(grpc.ChainUnaryInterceptor(
rpc.ServiceErrorInterceptor,
rpc.NewFrontendServiceErrorInterceptor(log.NewTestLogger()),
rateLimitInterceptor.Intercept,
))
workflowservice.RegisterWorkflowServiceServer(server, svc)
pipe := nettest.NewPipe()
var wg sync.WaitGroup
defer wg.Wait()
wg.Add(1)
listener := nettest.NewListener(pipe)
go func() {
defer wg.Done()
_ = server.Serve(listener)
}()
// Create a gRPC client to the fake workflow service.
dialer := grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return pipe.Connect(ctx.Done())
})
transportCredentials := grpc.WithTransportCredentials(insecure.NewCredentials())
conn, err := grpc.NewClient("localhost", dialer, transportCredentials)
require.NoError(t, err)
defer server.Stop()
client := workflowservice.NewWorkflowServiceClient(conn)
_, _ = client.StartWorkflowExecution(
context.Background(),
&workflowservice.StartWorkflowExecutionRequest{
Namespace: testNS,
},
)
// Check if limits are emitted by metrics handler
snapshot := capture.Snapshot()
for _, limit := range snapshot[metrics.HostRPSLimit.Name()] {
assert.Equal(t, float64(tc.expectedHostLimit), limit.Value)
}
})
}
}
func getOrDefaultLimit[T int | float64](limit T) T {
if limit == 0 {
return 100
}
return limit
}