feat: add GRPC_MAX_CONCURRENT_STREAMS to cap concurrent gRPC streams#1189
Open
hoangcn95 wants to merge 1 commit into
Open
feat: add GRPC_MAX_CONCURRENT_STREAMS to cap concurrent gRPC streams#1189hoangcn95 wants to merge 1 commit into
hoangcn95 wants to merge 1 commit into
Conversation
Signed-off-by: Peter Nguyen <petnguyen@axon.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This continues the goroutine-buildup / OOM hardening work from #987 and #1018 by adding a configurable cap on concurrent gRPC streams per connection.
Current Behavior
The gRPC server is constructed in
src/server/server_impl.gofrom agrpcOptionsslice that sets keepalive parameters, unary/stream interceptors, and (optionally) TLS credentials. It never setsgrpc.MaxConcurrentStreams. grpc-go's server therefore falls back to its internal default, which ismath.MaxUint32(server.go:190in grpc-go v1.81.1), effectively unlimited concurrent streams per HTTP/2 connection.Problem
ShouldRateLimitcall) is handled by its own goroutine for the lifetime of the request.GOMEMLIMIT, and the process OOMs.This is problematic because:
REDIS_POOL_SIZElooks like it should bound concurrency, but it only bounds the number of pooled Redis connections. It does not limit how many gRPC handler goroutines can be in flight waiting for a pool slot or a Redis response.Real-world impact: during a Redis stall on CA1 (2026-07-13), two rate limit pods OOMed as a result of this unbounded goroutine growth.
Proposed Solution
Add a new environment variable that maps directly to
grpc.MaxConcurrentStreams:GRPC_MAX_CONCURRENT_STREAMSuint3200disables the cap and preserves the grpc-go default (unlimited).Implementation notes:
src/settings/settings.go: addsGrpcMaxConcurrentStreams uint32(envconfig tagGRPC_MAX_CONCURRENT_STREAMS, default"0"), next to the existingGrpcMaxConnectionAge/GrpcMaxConnectionAgeGracesettings.src/server/server_impl.go: adds a small helper,maxConcurrentStreamsOptions(maxStreams uint32) []grpc.ServerOption, that returns[]grpc.ServerOption{grpc.MaxConcurrentStreams(maxStreams)}whenmaxStreams > 0, andnilotherwise. The result is appended togrpcOptionsbeforegrpc.NewServer(grpcOptions...)is called, so the cap is a no-op unless explicitly configured.Per-connection semantics: this is a per-HTTP/2-connection cap, not a global one. The aggregate concurrency a pod can serve is roughly
(number of active connections) x GRPC_MAX_CONCURRENT_STREAMS. Operators tuning this value should account for how many client connections they expect (e.g. Envoy's connection pooling behavior) when picking a cap.Test Result
Added unit tests covering both new pieces of logic:
src/settings/settings_test.go:TestGrpcMaxConcurrentStreams_DefaultassertsGrpcMaxConcurrentStreamsdefaults to0when the env var is unset, andTestGrpcMaxConcurrentStreams_Configuredasserts it parsesGRPC_MAX_CONCURRENT_STREAMS=100intouint32(100).src/server/server_impl_internal_test.go(packageserver, internal test):TestMaxConcurrentStreamsOptions_DisabledassertsmaxConcurrentStreamsOptions(0)returns no options (preserving current unlimited behavior), andTestMaxConcurrentStreamsOptions_EnabledassertsmaxConcurrentStreamsOptions(100)returns exactly onegrpc.ServerOption. This directly exercises the branch that is appended intogrpcOptions, rather than only asserting thatNewServerdoesn't error.All affected packages pass with
-race:Load test (recommended for reviewers)
Not run as part of this PR. Recommended validation before merge/rollout:
toxiproxyin front of the Redis backend to inject latency/stalls, and drive load withk6orghzagainst the gRPC listener on:8081.:6070/debug/pprof/goroutinebefore, during, and after the induced stall, with and withoutGRPC_MAX_CONCURRENT_STREAMSset, to confirm goroutine count plateaus instead of growing unbounded once the cap is active.Changes
src/settings/settings.go: addGrpcMaxConcurrentStreams uint32setting (GRPC_MAX_CONCURRENT_STREAMS, default0).src/server/server_impl.go: addmaxConcurrentStreamsOptionshelper and wire it intogrpcOptionsbeforegrpc.NewServer(...).src/settings/settings_test.go: default/configured tests for the new setting.src/server/server_impl_internal_test.go: unit tests for the new helper's branch behavior.README.md: documentGRPC_MAX_CONCURRENT_STREAMSnext to the existingGRPC_MAX_CONNECTION_AGE/GRPC_MAX_CONNECTION_AGE_GRACEdocs, including per-connection semantics.Related