Skip to content
14 changes: 14 additions & 0 deletions internal/envconfig/envconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ var (
// setting the environment variable "GRPC_EXPERIMENTAL_ENABLE_NEW_PICK_FIRST"
// to "false".
NewPickFirstEnabled = boolFromEnv("GRPC_EXPERIMENTAL_ENABLE_NEW_PICK_FIRST", true)

// XDSEndpointHashKeyBackwardCompat disables parsing of the endpoint hash
// key from EDS LbEndpoint metadata. We can disable this behavior by setting
// the environment variable "GRPC_XDS_ENDPOINT_HASH_KEY_BACKWARD_COMPAT" to
// "true". When the implementation of A76 is stable, we will flip the default
// value to false in a subsequent release. A final release will remove this
// environment variable, enabling the new behavior unconditionally.
XDSEndpointHashKeyBackwardCompat = boolFromEnv("GRPC_XDS_ENDPOINT_HASH_KEY_BACKWARD_COMPAT", true)

// RingHashSetRequestHashKey is set if the ring hash balancer can get the
// request hash header by setting the "request_hash_header" field, according
// to gRFC A76. It can be enabled by setting the environment variable
// "GRPC_EXPERIMENTAL_RING_HASH_SET_REQUEST_HASH_KEY" to "true".
RingHashSetRequestHashKey = boolFromEnv("GRPC_EXPERIMENTAL_RING_HASH_SET_REQUEST_HASH_KEY", false)
)

func boolFromEnv(envVar string, def bool) bool {
Expand Down
26 changes: 19 additions & 7 deletions internal/metadata/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,11 @@ func hasNotPrintable(msg string) bool {
return false
}

// ValidatePair validate a key-value pair with the following rules (the pseudo-header will be skipped) :
//
// - key must contain one or more characters.
// - the characters in the key must be contained in [0-9 a-z _ - .].
// - if the key ends with a "-bin" suffix, no validation of the corresponding value is performed.
// - the characters in the every value must be printable (in [%x20-%x7E]).
func ValidatePair(key string, vals ...string) error {
// ValidateKey validates a key with the following rules (pseudo-headers are
// skipped):
// - the key must contain one or more characters.
// - the characters in the key must be in [0-9 a-z _ - .].
func ValidateKey(key string) error {
// key should not be empty
if key == "" {
return fmt.Errorf("there is an empty key in the header")
Expand All @@ -119,6 +117,20 @@ func ValidatePair(key string, vals ...string) error {
return fmt.Errorf("header key %q contains illegal characters not in [0-9a-z-_.]", key)
}
}
return nil
}

// ValidatePair validates a key-value pair with the following rules
// (pseudo-header are skipped):
// - the key must contain one or more characters.
// - the characters in the key must be in [0-9 a-z _ - .].
// - if the key ends with a "-bin" suffix, no validation of the corresponding
// value is performed.
// - the characters in every value must be printable (in [%x20-%x7E]).
func ValidatePair(key string, vals ...string) error {
if err := ValidateKey(key); err != nil {
return err
}
if strings.HasSuffix(key, "-bin") {
return nil
}
Expand Down
14 changes: 14 additions & 0 deletions internal/testutils/xds/e2e/clientresources.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"net"
"strconv"

"google.golang.org/protobuf/types/known/structpb"

"github.com/envoyproxy/go-control-plane/pkg/wellknown"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
Expand Down Expand Up @@ -649,6 +651,9 @@
HealthStatus v3corepb.HealthStatus
// Weight sets the backend weight. Defaults to 1.
Weight uint32
// Metadata sets the LB endpoint metadata (envoy.lb FilterMetadata field).
// See https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/core/v3/base.proto#envoy-v3-api-msg-config-core-v3-metadata
Metadata map[string]any
}

// EndpointOptions contains options to configure an Endpoint (or
Expand Down Expand Up @@ -708,6 +713,10 @@
},
}
}
metadata, err := structpb.NewStruct(b.Metadata)
if err != nil {
panic(err)

Check warning on line 718 in internal/testutils/xds/e2e/clientresources.go

View check run for this annotation

Codecov / codecov/patch

internal/testutils/xds/e2e/clientresources.go#L718

Added line #L718 was not covered by tests
}
lbEndpoints = append(lbEndpoints, &v3endpointpb.LbEndpoint{
HostIdentifier: &v3endpointpb.LbEndpoint_Endpoint{Endpoint: &v3endpointpb.Endpoint{
Address: &v3corepb.Address{Address: &v3corepb.Address_SocketAddress{
Expand All @@ -721,6 +730,11 @@
}},
HealthStatus: b.HealthStatus,
LoadBalancingWeight: &wrapperspb.UInt32Value{Value: b.Weight},
Metadata: &v3corepb.Metadata{
FilterMetadata: map[string]*structpb.Struct{
"envoy.lb": metadata,
},
},
})
}

Expand Down
59 changes: 59 additions & 0 deletions resolver/ringhash/attr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
*
* Copyright 2025 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

// Package ringhash implements resolver related functions for the ring_hash
// load balancing policy.
package ringhash

import (
"google.golang.org/grpc/resolver"
)

type hashKeyType string

// hashKeyKey is the key to store the ring hash key attribute in
// a resolver.Endpoint attribute.
const hashKeyKey = hashKeyType("hash_key")

// SetEndpointHashKey sets the hash key for this endpoint. Combined with the
// ring_hash load balancing policy, it allows placing the endpoint on the ring
// based on an arbitrary string instead of the IP address.
//
// # Experimental
//
// Notice: This API is EXPERIMENTAL and may be changed or removed in a
// later release.
func SetEndpointHashKey(ep resolver.Endpoint, hashKey string) resolver.Endpoint {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: We can remove Endpoint from the function name since the function argument type conveys this information.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: s/hash key attribute of addr/hash key attribute of endpoint/

if hashKey == "" {
return ep
}
ep.Attributes = ep.Attributes.WithValue(hashKeyKey, hashKey)
return ep
}

// GetEndpointHashKey returns the hash key attribute of addr. If this attribute
// is not set, it returns the empty string.
//
// # Experimental
//
// Notice: This API is EXPERIMENTAL and may be changed or removed in a
// later release.
func GetEndpointHashKey(ep resolver.Endpoint) string {
hashKey, _ := ep.Attributes.Value(hashKeyKey).(string)
return hashKey
}
2 changes: 2 additions & 0 deletions xds/internal/balancer/clusterresolver/configbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"google.golang.org/grpc/internal/hierarchy"
internalserviceconfig "google.golang.org/grpc/internal/serviceconfig"
"google.golang.org/grpc/resolver"
"google.golang.org/grpc/resolver/ringhash"
"google.golang.org/grpc/xds/internal"
"google.golang.org/grpc/xds/internal/balancer/clusterimpl"
"google.golang.org/grpc/xds/internal/balancer/outlierdetection"
Expand Down Expand Up @@ -284,6 +285,7 @@ func priorityLocalitiesToClusterImpl(localities []xdsresource.Locality, priority
ew = endpoint.Weight
}
resolverEndpoint = weight.Set(resolverEndpoint, weight.EndpointInfo{Weight: lw * ew})
resolverEndpoint = ringhash.SetEndpointHashKey(resolverEndpoint, endpoint.HashKey)
retEndpoints = append(retEndpoints, resolverEndpoint)
}
}
Expand Down
19 changes: 17 additions & 2 deletions xds/internal/balancer/ringhash/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,20 @@ package ringhash
import (
"encoding/json"
"fmt"
"strings"

"google.golang.org/grpc/internal/envconfig"
"google.golang.org/grpc/internal/metadata"
"google.golang.org/grpc/serviceconfig"
)

// LBConfig is the balancer config for ring_hash balancer.
type LBConfig struct {
serviceconfig.LoadBalancingConfig `json:"-"`

MinRingSize uint64 `json:"minRingSize,omitempty"`
MaxRingSize uint64 `json:"maxRingSize,omitempty"`
MinRingSize uint64 `json:"minRingSize,omitempty"`
MaxRingSize uint64 `json:"maxRingSize,omitempty"`
RequestHashHeader string `json:"request_hash_header,omitempty"`
}

const (
Expand Down Expand Up @@ -66,5 +69,17 @@ func parseConfig(c json.RawMessage) (*LBConfig, error) {
if cfg.MaxRingSize > envconfig.RingHashCap {
cfg.MaxRingSize = envconfig.RingHashCap
}
if !envconfig.RingHashSetRequestHashKey {
cfg.RequestHashHeader = ""
}
if cfg.RequestHashHeader != "" {
// See rules in https://github.com/grpc/proposal/blob/master/A76-ring-hash-improvements.md#explicitly-setting-the-request-hash-key
if err := metadata.ValidateKey(cfg.RequestHashHeader); err != nil {
return nil, fmt.Errorf("invalid request_metadata_key %q: %s", cfg.RequestHashHeader, err)
}
if strings.HasSuffix(cfg.RequestHashHeader, "-bin") {
return nil, fmt.Errorf("invalid request_metadata_key %q: key must not end with \"-bin\"", cfg.RequestHashHeader)
}
}
return &cfg, nil
}
25 changes: 25 additions & 0 deletions xds/internal/balancer/ringhash/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import (
)

func (s) TestParseConfig(t *testing.T) {
oldEnvConfig := envconfig.RingHashSetRequestHashKey
defer func() { envconfig.RingHashSetRequestHashKey = oldEnvConfig }()
envconfig.RingHashSetRequestHashKey = true

tests := []struct {
name string
js string
Expand Down Expand Up @@ -94,6 +98,27 @@ func (s) TestParseConfig(t *testing.T) {
want: nil,
wantErr: true,
},
{
name: "request metadata key set",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there should be a test case for when there are multiple header values from the gRFC A76

If the header contains multiple values, then values are joined with a comma , character before hashing.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The behavior joining headers with a coma is for header values. Using multiple headers is not supported. I added a picker test case for this, and took the opportunity to make the picker test a bit more robust by distributing endpoints on the ring, and removing unnecessary setting of the request hash in existing tests.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have a test for the case where the env var is set to false and the JSON config contains a value for the request_hash_header and we check that the parsed internal representation does not have the corresponding field set?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The behavior joining headers with a coma is for header values. Using multiple headers is not supported. I added a picker test case for this

Ack. Thanks for adding this test.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have a test for the case where the env var is set to false and the JSON config contains a value for the request_hash_header and we check that the parsed internal representation does not have the corresponding field set?

Added.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Since we have multiple env vars in the context of ring_hash config parsing, could we please rename the field something more specific. Maybe requestHeaderEnvVar?

js: `{"request_hash_header": "x-foo"}`,
want: &LBConfig{
MinRingSize: defaultMinSize,
MaxRingSize: defaultMaxSize,
RequestHashHeader: "x-foo",
},
},
{
name: "invalid request hash header",
js: `{"request_hash_header": "!invalid"}`,
want: nil,
wantErr: true,
},
{
name: "binary request hash header",
js: `{"request_hash_header": "header-with-bin"}`,
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
Loading
Loading