diff --git a/internal/xds/balancer/cdsbalancer/configbuilder_test.go b/internal/xds/balancer/cdsbalancer/configbuilder_test.go index 282e2a49f6ea..02a36cb9db84 100644 --- a/internal/xds/balancer/cdsbalancer/configbuilder_test.go +++ b/internal/xds/balancer/cdsbalancer/configbuilder_test.go @@ -154,9 +154,8 @@ func (s) TestBuildPriorityConfigJSON(t *testing.T) { EndpointConfig: &xdsresource.EndpointConfig{ EDSUpdate: &xdsresource.EndpointsUpdate{ Drops: []xdsresource.OverloadDropConfig{{ - Category: testDropCategory, - Numerator: testDropOverMillion, - Denominator: million, + Category: testDropCategory, + DropPercentage: xdsresource.FractionalPercent{Numerator: testDropOverMillion, Denominator: million, PPM: testDropOverMillion}, }}, Localities: []xdsresource.Locality{ makeLocality(0, 20, 0, 2), @@ -421,9 +420,8 @@ func (s) TestBuildClusterImplConfigForEDS_PickFirstWeightedShuffling_Disabled(t EndpointConfig: &xdsresource.EndpointConfig{ EDSUpdate: &xdsresource.EndpointsUpdate{ Drops: []xdsresource.OverloadDropConfig{{ - Category: testDropCategory, - Numerator: testDropOverMillion, - Denominator: million, + Category: testDropCategory, + DropPercentage: xdsresource.FractionalPercent{Numerator: testDropOverMillion, Denominator: million, PPM: testDropOverMillion}, }}, Localities: []xdsresource.Locality{ makeLocality(3, 80, 1, 2), @@ -496,9 +494,8 @@ func (s) TestBuildClusterImplConfigForEDS_PickFirstWeightedShuffling_Enabled(t * EndpointConfig: &xdsresource.EndpointConfig{ EDSUpdate: &xdsresource.EndpointsUpdate{ Drops: []xdsresource.OverloadDropConfig{{ - Category: testDropCategory, - Numerator: testDropOverMillion, - Denominator: million, + Category: testDropCategory, + DropPercentage: xdsresource.FractionalPercent{Numerator: testDropOverMillion, Denominator: million, PPM: testDropOverMillion}, }}, Localities: []xdsresource.Locality{ makeLocality(3, 80, 1, 2), diff --git a/internal/xds/balancer/clusterimpl/balancer_test.go b/internal/xds/balancer/clusterimpl/balancer_test.go index 8e118f619ae5..347d938db51e 100644 --- a/internal/xds/balancer/clusterimpl/balancer_test.go +++ b/internal/xds/balancer/clusterimpl/balancer_test.go @@ -327,30 +327,3 @@ func (s) TestPickerUpdatedSynchronouslyOnConfigUpdate(t *testing.T) { t.Fatal("Timed out waiting for client conn update to be completed.") } } - -func (s) TestDropRequestsPerMillion(t *testing.T) { - tests := []struct { - name string - numerator uint32 - denominator uint32 - want uint32 - }{ - {name: "zero", numerator: 0, denominator: million, want: 0}, - {name: "five percent hundred", numerator: 5, denominator: 100, want: 50000}, - // numerator*million overflows a uint32 once numerator reaches 4295 with - // the million denominator, which is only a 0.43% drop. - {name: "point four three percent million", numerator: 4295, denominator: million, want: 4295}, - {name: "one percent million", numerator: 10000, denominator: million, want: 10000}, - {name: "fifty percent million", numerator: 500000, denominator: million, want: 500000}, - {name: "hundred percent million", numerator: million, denominator: million, want: million}, - // A fraction above 100% is capped at a million. - {name: "over hundred percent", numerator: 150, denominator: 100, want: million}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := dropRequestsPerMillion(tt.numerator, tt.denominator); got != tt.want { - t.Errorf("dropRequestsPerMillion(%d, %d) = %d, want %d", tt.numerator, tt.denominator, got, tt.want) - } - }) - } -} diff --git a/internal/xds/balancer/clusterimpl/clusterimpl.go b/internal/xds/balancer/clusterimpl/clusterimpl.go index 36b84c049c95..ac5d0d0ad4a1 100644 --- a/internal/xds/balancer/clusterimpl/clusterimpl.go +++ b/internal/xds/balancer/clusterimpl/clusterimpl.go @@ -150,19 +150,6 @@ type clusterImplBalancer struct { lrsReportEndpointMetrics *xdsresource.LRSReportEndpointMetricsConfig // LRS metrics to propagate. } -// dropRequestsPerMillion scales a drop overload's numerator and denominator to -// a number of requests to drop per million. numerator can be as large as a -// million (a 100% drop), so the multiplication is done in uint64 to avoid -// overflowing a uint32, and the result is capped at a million because a drop -// ratio above 100% is treated as 100%. -func dropRequestsPerMillion(numerator, denominator uint32) uint32 { - rpm := uint64(numerator) * million / uint64(denominator) - if rpm > million { - rpm = million - } - return uint32(rpm) -} - // handleClusterConfigLocked updates the internal state of the balancer with the // new cluster configuration. It returns true if a new picker needs to be // generated as a result of these changes. It must be called with b.mu held. @@ -184,7 +171,7 @@ func (b *clusterImplBalancer) handleClusterConfigLocked(clusterConfig xdsresourc for _, d := range edsUpdate.Drops { newDrops = append(newDrops, DropConfig{ Category: d.Category, - RequestsPerMillion: dropRequestsPerMillion(d.Numerator, d.Denominator), + RequestsPerMillion: d.DropPercentage.PPM, }) } if !slices.Equal(b.dropCategories, newDrops) { diff --git a/internal/xds/httpfilter/fault/fault.go b/internal/xds/httpfilter/fault/fault.go index 58f34863698f..e6f28c2241d5 100644 --- a/internal/xds/httpfilter/fault/fault.go +++ b/internal/xds/httpfilter/fault/fault.go @@ -33,6 +33,7 @@ import ( "google.golang.org/grpc/codes" iresolver "google.golang.org/grpc/internal/resolver" "google.golang.org/grpc/internal/xds/httpfilter" + "google.golang.org/grpc/internal/xds/xdsclient/xdsresource" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" @@ -263,19 +264,12 @@ func parseIntFromMD(header []string) (int, bool) { } func splitPct(fp *tpb.FractionalPercent) (num int, den int) { - if fp == nil { - return 0, 100 + f, err := xdsresource.NewFractionalPercent(fp) + if err != nil { + // An unrecognized denominator is treated as out of 100. + return int(fp.GetNumerator()), 100 } - num = int(fp.GetNumerator()) - switch fp.GetDenominator() { - case tpb.FractionalPercent_HUNDRED: - return num, 100 - case tpb.FractionalPercent_TEN_THOUSAND: - return num, 10 * 1000 - case tpb.FractionalPercent_MILLION: - return num, 1000 * 1000 - } - return num, 100 + return int(f.Numerator), int(f.Denominator) } func grpcFromHTTP(httpStatus int) (codes.Code, bool) { diff --git a/internal/xds/xdsclient/xdsresource/fractional_percent.go b/internal/xds/xdsclient/xdsresource/fractional_percent.go new file mode 100644 index 000000000000..541df1c85af8 --- /dev/null +++ b/internal/xds/xdsclient/xdsresource/fractional_percent.go @@ -0,0 +1,68 @@ +/* + * + * Copyright 2026 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 xdsresource + +import ( + "fmt" + + v3typepb "github.com/envoyproxy/go-control-plane/envoy/type/v3" +) + +// FractionalPercent is the internal representation of the xDS FractionalPercent +// proto. +type FractionalPercent struct { + Numerator uint32 + Denominator uint32 + PPM uint32 // Pre-computed and capped at 1,000,000 (100%). +} + +// NewFractionalPercent converts the given FractionalPercent proto to its +// internal representation. A nil proto is treated as 0 out of 100. An error is +// returned for an unrecognized denominator. +func NewFractionalPercent(fp *v3typepb.FractionalPercent) (FractionalPercent, error) { + if fp == nil { + return FractionalPercent{Numerator: 0, Denominator: 100, PPM: 0}, nil + } + + var den uint32 + switch fp.GetDenominator() { + case v3typepb.FractionalPercent_HUNDRED: + den = 100 + case v3typepb.FractionalPercent_TEN_THOUSAND: + den = 10000 + case v3typepb.FractionalPercent_MILLION: + den = 1000000 + default: + return FractionalPercent{}, fmt.Errorf("unsupported denominator: %v", fp.GetDenominator()) + } + + num := fp.GetNumerator() + // The numerator comes from the control plane, so perform the + // multiplication in uint64 to prevent overflowing a uint32, and cap the + // result at 100%. + ppm := uint64(num) * 1000000 / uint64(den) + if ppm > 1000000 { + ppm = 1000000 + } + + return FractionalPercent{ + Numerator: num, + Denominator: den, + PPM: uint32(ppm), + }, nil +} diff --git a/internal/xds/xdsclient/xdsresource/fractional_percent_test.go b/internal/xds/xdsclient/xdsresource/fractional_percent_test.go new file mode 100644 index 000000000000..bab343d95dd1 --- /dev/null +++ b/internal/xds/xdsclient/xdsresource/fractional_percent_test.go @@ -0,0 +1,90 @@ +/* + * + * Copyright 2026 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 xdsresource + +import ( + "testing" + + v3typepb "github.com/envoyproxy/go-control-plane/envoy/type/v3" +) + +func (s) TestNewFractionalPercent(t *testing.T) { + tests := []struct { + name string + fp *v3typepb.FractionalPercent + want FractionalPercent + wantErr bool + }{ + { + name: "nil is zero out of hundred", + fp: nil, + want: FractionalPercent{Numerator: 0, Denominator: 100, PPM: 0}, + }, + { + name: "five percent hundred", + fp: &v3typepb.FractionalPercent{Numerator: 5, Denominator: v3typepb.FractionalPercent_HUNDRED}, + want: FractionalPercent{Numerator: 5, Denominator: 100, PPM: 50000}, + }, + { + name: "half percent ten thousand", + fp: &v3typepb.FractionalPercent{Numerator: 50, Denominator: v3typepb.FractionalPercent_TEN_THOUSAND}, + want: FractionalPercent{Numerator: 50, Denominator: 10000, PPM: 5000}, + }, + { + name: "fifty percent million", + fp: &v3typepb.FractionalPercent{Numerator: 500000, Denominator: v3typepb.FractionalPercent_MILLION}, + want: FractionalPercent{Numerator: 500000, Denominator: 1000000, PPM: 500000}, + }, + { + // numerator*million overflows a uint32 once the numerator reaches + // 4295 with the million denominator, which is only a 0.43% + // fraction. + name: "point four three percent million", + fp: &v3typepb.FractionalPercent{Numerator: 4295, Denominator: v3typepb.FractionalPercent_MILLION}, + want: FractionalPercent{Numerator: 4295, Denominator: 1000000, PPM: 4295}, + }, + { + // 429497*10000 wraps a uint32 to 2704, so a fraction meant to be + // above 100% would become ~0.27% if computed in uint32. + name: "overflowing numerator hundred capped", + fp: &v3typepb.FractionalPercent{Numerator: 429497, Denominator: v3typepb.FractionalPercent_HUNDRED}, + want: FractionalPercent{Numerator: 429497, Denominator: 100, PPM: 1000000}, + }, + { + name: "over hundred percent capped", + fp: &v3typepb.FractionalPercent{Numerator: 150, Denominator: v3typepb.FractionalPercent_HUNDRED}, + want: FractionalPercent{Numerator: 150, Denominator: 100, PPM: 1000000}, + }, + { + name: "unsupported denominator", + fp: &v3typepb.FractionalPercent{Numerator: 1, Denominator: v3typepb.FractionalPercent_DenominatorType(7)}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewFractionalPercent(tt.fp) + if (err != nil) != tt.wantErr { + t.Fatalf("NewFractionalPercent(%v) returned err %v, wantErr %v", tt.fp, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("NewFractionalPercent(%v) = %+v, want %+v", tt.fp, got, tt.want) + } + }) + } +} diff --git a/internal/xds/xdsclient/xdsresource/type_eds.go b/internal/xds/xdsclient/xdsresource/type_eds.go index 937d2cffe1c8..77a321dd3f5c 100644 --- a/internal/xds/xdsclient/xdsresource/type_eds.go +++ b/internal/xds/xdsclient/xdsresource/type_eds.go @@ -25,9 +25,8 @@ import ( // OverloadDropConfig contains the config to drop overloads. type OverloadDropConfig struct { - Category string - Numerator uint32 - Denominator uint32 + Category string + DropPercentage FractionalPercent } // EndpointHealthStatus represents the health status of an endpoint. diff --git a/internal/xds/xdsclient/xdsresource/unmarshal_eds.go b/internal/xds/xdsclient/xdsresource/unmarshal_eds.go index 3327d6751ee3..ffd9bb7efbd2 100644 --- a/internal/xds/xdsclient/xdsresource/unmarshal_eds.go +++ b/internal/xds/xdsclient/xdsresource/unmarshal_eds.go @@ -25,7 +25,6 @@ import ( v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" v3endpointpb "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3" - v3typepb "github.com/envoyproxy/go-control-plane/envoy/type/v3" "google.golang.org/grpc/experimental/balancer/hostname" "google.golang.org/grpc/internal/envconfig" "google.golang.org/grpc/internal/pretty" @@ -78,25 +77,13 @@ func parseAddress(socketAddress *v3corepb.SocketAddress) string { } func parseDropPolicy(dropPolicy *v3endpointpb.ClusterLoadAssignment_Policy_DropOverload) (OverloadDropConfig, error) { - percentage := dropPolicy.GetDropPercentage() - var ( - numerator = percentage.GetNumerator() - denominator uint32 - ) - switch percentage.GetDenominator() { - case v3typepb.FractionalPercent_HUNDRED: - denominator = 100 - case v3typepb.FractionalPercent_TEN_THOUSAND: - denominator = 10000 - case v3typepb.FractionalPercent_MILLION: - denominator = 1000000 - default: - return OverloadDropConfig{}, fmt.Errorf("EDS response contains a drop policy with unsupported denominator: %v", percentage.GetDenominator()) + fp, err := NewFractionalPercent(dropPolicy.GetDropPercentage()) + if err != nil { + return OverloadDropConfig{}, fmt.Errorf("EDS response contains a drop policy with %v", err) } return OverloadDropConfig{ - Category: dropPolicy.GetCategory(), - Numerator: numerator, - Denominator: denominator, + Category: dropPolicy.GetCategory(), + DropPercentage: fp, }, nil } diff --git a/internal/xds/xdsclient/xdsresource/unmarshal_rds.go b/internal/xds/xdsclient/xdsresource/unmarshal_rds.go index a053db223502..f7ee3ecf89c5 100644 --- a/internal/xds/xdsclient/xdsresource/unmarshal_rds.go +++ b/internal/xds/xdsclient/xdsresource/unmarshal_rds.go @@ -34,7 +34,6 @@ import ( v3routepb "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" v3matcherpb "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3" - v3typepb "github.com/envoyproxy/go-control-plane/envoy/type/v3" ) func unmarshalRouteConfigResource(r *anypb.Any, opts *xdsclient.DecodeOptions) (string, RouteConfigUpdate, error) { @@ -306,16 +305,11 @@ func routesProtoToSlice(routes []*v3routepb.Route, csps map[string]clusterspecif } if fr := match.GetRuntimeFraction(); fr != nil { - d := fr.GetDefaultValue() - n := d.GetNumerator() - switch d.GetDenominator() { - case v3typepb.FractionalPercent_HUNDRED: - n *= 10000 - case v3typepb.FractionalPercent_TEN_THOUSAND: - n *= 100 - case v3typepb.FractionalPercent_MILLION: + fp, err := NewFractionalPercent(fr.GetDefaultValue()) + if err != nil { + return nil, nil, fmt.Errorf("route %+v has an invalid runtime_fraction: %v", r, err) } - route.Fraction = &n + route.Fraction = &fp.PPM } switch r.GetAction().(type) { diff --git a/internal/xds/xdsclient/xdsresource/unmarshal_rds_test.go b/internal/xds/xdsclient/xdsresource/unmarshal_rds_test.go index e8f5045919d9..ac3ddd5b3d08 100644 --- a/internal/xds/xdsclient/xdsresource/unmarshal_rds_test.go +++ b/internal/xds/xdsclient/xdsresource/unmarshal_rds_test.go @@ -1225,6 +1225,43 @@ func (s) TestRoutesProtoToSlice(t *testing.T) { }}, wantErr: false, }, + { + name: "runtime fraction numerator over 100 percent capped", + routes: []*v3routepb.Route{ + { + Match: &v3routepb.RouteMatch{ + PathSpecifier: &v3routepb.RouteMatch_Prefix{Prefix: "/a/"}, + // 429497 * 10000 overflows uint32 and wraps to a tiny + // fraction; the route is meant to always match. + RuntimeFraction: &v3corepb.RuntimeFractionalPercent{ + DefaultValue: &v3typepb.FractionalPercent{ + Numerator: 429497, + Denominator: v3typepb.FractionalPercent_HUNDRED, + }, + }, + }, + Action: &v3routepb.Route_Route{ + Route: &v3routepb.RouteAction{ + ClusterSpecifier: &v3routepb.RouteAction_WeightedClusters{ + WeightedClusters: &v3routepb.WeightedCluster{ + Clusters: []*v3routepb.WeightedCluster_ClusterWeight{ + {Name: "B", Weight: &wrapperspb.UInt32Value{Value: 60}}, + {Name: "A", Weight: &wrapperspb.UInt32Value{Value: 40}}, + }, + }}}}, + }, + }, + wantRoutes: []*Route{{ + Prefix: newStringP("/a/"), + Fraction: newUInt32P(1000000), + WeightedClusters: []WeightedCluster{ + {Name: "B", Weight: 60}, + {Name: "A", Weight: 40}, + }, + ActionType: RouteActionRoute, + }}, + wantErr: false, + }, { name: "query is ignored", routes: []*v3routepb.Route{