xref: /aosp_15_r20/external/grpc-grpc-java/xds/src/main/java/io/grpc/xds/LoadBalancerConfigFactory.java (revision e07d83d3ffcef9ecfc9f7f475418ec639ff0e5fe)
1 /*
2  * Copyright 2022 The gRPC Authors
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *     http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package io.grpc.xds;
18 
19 import com.google.common.collect.ImmutableList;
20 import com.google.common.collect.ImmutableMap;
21 import com.google.common.collect.Iterables;
22 import com.google.protobuf.Any;
23 import com.google.protobuf.InvalidProtocolBufferException;
24 import com.google.protobuf.Struct;
25 import com.google.protobuf.util.Durations;
26 import com.google.protobuf.util.JsonFormat;
27 import io.envoyproxy.envoy.config.cluster.v3.Cluster;
28 import io.envoyproxy.envoy.config.cluster.v3.Cluster.LeastRequestLbConfig;
29 import io.envoyproxy.envoy.config.cluster.v3.Cluster.RingHashLbConfig;
30 import io.envoyproxy.envoy.config.cluster.v3.LoadBalancingPolicy;
31 import io.envoyproxy.envoy.config.cluster.v3.LoadBalancingPolicy.Policy;
32 import io.envoyproxy.envoy.extensions.load_balancing_policies.client_side_weighted_round_robin.v3.ClientSideWeightedRoundRobin;
33 import io.envoyproxy.envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest;
34 import io.envoyproxy.envoy.extensions.load_balancing_policies.pick_first.v3.PickFirst;
35 import io.envoyproxy.envoy.extensions.load_balancing_policies.ring_hash.v3.RingHash;
36 import io.envoyproxy.envoy.extensions.load_balancing_policies.round_robin.v3.RoundRobin;
37 import io.envoyproxy.envoy.extensions.load_balancing_policies.wrr_locality.v3.WrrLocality;
38 import io.grpc.InternalLogId;
39 import io.grpc.LoadBalancerRegistry;
40 import io.grpc.internal.JsonParser;
41 import io.grpc.xds.LoadBalancerConfigFactory.LoadBalancingPolicyConverter.MaxRecursionReachedException;
42 import io.grpc.xds.XdsClientImpl.ResourceInvalidException;
43 import io.grpc.xds.XdsLogger.XdsLogLevel;
44 import java.io.IOException;
45 import java.util.Map;
46 
47 /**
48  * Creates service config JSON  load balancer config objects for a given xDS Cluster message.
49  * Supports both the "legacy" configuration style and the new, more advanced one that utilizes the
50  * xDS "typed extension" mechanism.
51  *
52  * <p>Legacy configuration is done by setting the lb_policy enum field and any supporting
53  * configuration fields needed by the particular policy.
54  *
55  * <p>The new approach is to set the load_balancing_policy field that contains both the policy
56  * selection as well as any supporting configuration data. Providing a list of acceptable policies
57  * is also supported. Note that if this field is used, it will override any configuration set using
58  * the legacy approach. The new configuration approach is explained in detail in the <a href="
59  * https://github.com/grpc/proposal/blob/master/A52-xds-custom-lb-policies.md">Custom LB Policies
60  * gRFC</a>
61  */
62 class LoadBalancerConfigFactory {
63 
64   private static final XdsLogger logger = XdsLogger.withLogId(
65       InternalLogId.allocate("xds-client-lbconfig-factory", null));
66 
67   static final String ROUND_ROBIN_FIELD_NAME = "round_robin";
68 
69   static final String RING_HASH_FIELD_NAME = "ring_hash_experimental";
70   static final String MIN_RING_SIZE_FIELD_NAME = "minRingSize";
71   static final String MAX_RING_SIZE_FIELD_NAME = "maxRingSize";
72 
73   static final String LEAST_REQUEST_FIELD_NAME = "least_request_experimental";
74   static final String CHOICE_COUNT_FIELD_NAME = "choiceCount";
75 
76   static final String WRR_LOCALITY_FIELD_NAME = "wrr_locality_experimental";
77   static final String CHILD_POLICY_FIELD = "childPolicy";
78 
79   static final String BLACK_OUT_PERIOD = "blackoutPeriod";
80 
81   static final String WEIGHT_EXPIRATION_PERIOD = "weightExpirationPeriod";
82 
83   static final String OOB_REPORTING_PERIOD = "oobReportingPeriod";
84 
85   static final String ENABLE_OOB_LOAD_REPORT = "enableOobLoadReport";
86 
87   static final String WEIGHT_UPDATE_PERIOD = "weightUpdatePeriod";
88 
89   static final String PICK_FIRST_FIELD_NAME = "pick_first";
90   static final String SHUFFLE_ADDRESS_LIST_FIELD_NAME = "shuffleAddressList";
91 
92   static final String ERROR_UTILIZATION_PENALTY = "errorUtilizationPenalty";
93 
94   /**
95    * Factory method for creating a new {link LoadBalancerConfigConverter} for a given xDS {@link
96    * Cluster}.
97    *
98    * @throws ResourceInvalidException If the {@link Cluster} has an invalid LB configuration.
99    */
newConfig(Cluster cluster, boolean enableLeastRequest, boolean enableWrr, boolean enablePickFirst)100   static ImmutableMap<String, ?> newConfig(Cluster cluster, boolean enableLeastRequest,
101       boolean enableWrr, boolean enablePickFirst)
102       throws ResourceInvalidException {
103     // The new load_balancing_policy will always be used if it is set, but for backward
104     // compatibility we will fall back to using the old lb_policy field if the new field is not set.
105     if (cluster.hasLoadBalancingPolicy()) {
106       try {
107         return LoadBalancingPolicyConverter.convertToServiceConfig(cluster.getLoadBalancingPolicy(),
108             0, enableWrr, enablePickFirst);
109       } catch (MaxRecursionReachedException e) {
110         throw new ResourceInvalidException("Maximum LB config recursion depth reached", e);
111       }
112     } else {
113       return LegacyLoadBalancingPolicyConverter.convertToServiceConfig(cluster, enableLeastRequest);
114     }
115   }
116 
117   /**
118    * Builds a service config JSON object for the ring_hash load balancer config based on the given
119    * config values.
120    */
buildRingHashConfig(Long minRingSize, Long maxRingSize)121   private static ImmutableMap<String, ?> buildRingHashConfig(Long minRingSize, Long maxRingSize) {
122     ImmutableMap.Builder<String, Object> configBuilder = ImmutableMap.builder();
123     if (minRingSize != null) {
124       configBuilder.put(MIN_RING_SIZE_FIELD_NAME, minRingSize.doubleValue());
125     }
126     if (maxRingSize != null) {
127       configBuilder.put(MAX_RING_SIZE_FIELD_NAME, maxRingSize.doubleValue());
128     }
129     return ImmutableMap.of(RING_HASH_FIELD_NAME, configBuilder.buildOrThrow());
130   }
131 
132   /**
133    * Builds a service config JSON object for the weighted_round_robin load balancer config based on
134    * the given config values.
135    */
buildWrrConfig(String blackoutPeriod, String weightExpirationPeriod, String oobReportingPeriod, Boolean enableOobLoadReport, String weightUpdatePeriod, Float errorUtilizationPenalty)136   private static ImmutableMap<String, ?> buildWrrConfig(String blackoutPeriod,
137                                                         String weightExpirationPeriod,
138                                                         String oobReportingPeriod,
139                                                         Boolean enableOobLoadReport,
140                                                         String weightUpdatePeriod,
141                                                         Float errorUtilizationPenalty) {
142     ImmutableMap.Builder<String, Object> configBuilder = ImmutableMap.builder();
143     if (blackoutPeriod != null) {
144       configBuilder.put(BLACK_OUT_PERIOD, blackoutPeriod);
145     }
146     if (weightExpirationPeriod != null) {
147       configBuilder.put(WEIGHT_EXPIRATION_PERIOD, weightExpirationPeriod);
148     }
149     if (oobReportingPeriod != null) {
150       configBuilder.put(OOB_REPORTING_PERIOD, oobReportingPeriod);
151     }
152     if (enableOobLoadReport != null) {
153       configBuilder.put(ENABLE_OOB_LOAD_REPORT, enableOobLoadReport);
154     }
155     if (weightUpdatePeriod != null) {
156       configBuilder.put(WEIGHT_UPDATE_PERIOD, weightUpdatePeriod);
157     }
158     if (errorUtilizationPenalty != null) {
159       configBuilder.put(ERROR_UTILIZATION_PENALTY, errorUtilizationPenalty);
160     }
161     return ImmutableMap.of(WeightedRoundRobinLoadBalancerProvider.SCHEME,
162         configBuilder.buildOrThrow());
163   }
164 
165   /**
166    * Builds a service config JSON object for the least_request load balancer config based on the
167    * given config values.
168    */
buildLeastRequestConfig(Integer choiceCount)169   private static ImmutableMap<String, ?> buildLeastRequestConfig(Integer choiceCount) {
170     ImmutableMap.Builder<String, Object> configBuilder = ImmutableMap.builder();
171     if (choiceCount != null) {
172       configBuilder.put(CHOICE_COUNT_FIELD_NAME, choiceCount.doubleValue());
173     }
174     return ImmutableMap.of(LEAST_REQUEST_FIELD_NAME, configBuilder.buildOrThrow());
175   }
176 
177   /**
178    * Builds a service config JSON wrr_locality by wrapping another policy config.
179    */
buildWrrLocalityConfig( ImmutableMap<String, ?> childConfig)180   private static ImmutableMap<String, ?> buildWrrLocalityConfig(
181       ImmutableMap<String, ?> childConfig) {
182     return ImmutableMap.<String, Object>builder().put(WRR_LOCALITY_FIELD_NAME,
183         ImmutableMap.of(CHILD_POLICY_FIELD, ImmutableList.of(childConfig))).buildOrThrow();
184   }
185 
186   /**
187    * Builds an empty service config JSON config object for round robin (it is not configurable).
188    */
buildRoundRobinConfig()189   private static ImmutableMap<String, ?> buildRoundRobinConfig() {
190     return ImmutableMap.of(ROUND_ROBIN_FIELD_NAME, ImmutableMap.of());
191   }
192 
193   /**
194    * Builds a service config JSON object for the pick_first load balancer config based on the
195    * given config values.
196    */
buildPickFirstConfig(boolean shuffleAddressList)197   private static ImmutableMap<String, ?> buildPickFirstConfig(boolean shuffleAddressList) {
198     ImmutableMap.Builder<String, Object> configBuilder = ImmutableMap.builder();
199     configBuilder.put(SHUFFLE_ADDRESS_LIST_FIELD_NAME, shuffleAddressList);
200     return ImmutableMap.of(PICK_FIRST_FIELD_NAME, configBuilder.buildOrThrow());
201   }
202 
203   /**
204    * Responsible for converting from a {@code envoy.config.cluster.v3.LoadBalancingPolicy} proto
205    * message to a gRPC service config format.
206    */
207   static class LoadBalancingPolicyConverter {
208 
209     private static final int MAX_RECURSION = 16;
210 
211     /**
212      * Converts a {@link LoadBalancingPolicy} object to a service config JSON object.
213      */
convertToServiceConfig( LoadBalancingPolicy loadBalancingPolicy, int recursionDepth, boolean enableWrr, boolean enablePickFirst)214     private static ImmutableMap<String, ?> convertToServiceConfig(
215         LoadBalancingPolicy loadBalancingPolicy, int recursionDepth, boolean enableWrr,
216         boolean enablePickFirst)
217         throws ResourceInvalidException, MaxRecursionReachedException {
218       if (recursionDepth > MAX_RECURSION) {
219         throw new MaxRecursionReachedException();
220       }
221       ImmutableMap<String, ?> serviceConfig = null;
222 
223       for (Policy policy : loadBalancingPolicy.getPoliciesList()) {
224         Any typedConfig = policy.getTypedExtensionConfig().getTypedConfig();
225         try {
226           if (typedConfig.is(RingHash.class)) {
227             serviceConfig = convertRingHashConfig(typedConfig.unpack(RingHash.class));
228           } else if (typedConfig.is(WrrLocality.class)) {
229             serviceConfig = convertWrrLocalityConfig(typedConfig.unpack(WrrLocality.class),
230                 recursionDepth, enableWrr, enablePickFirst);
231           } else if (typedConfig.is(RoundRobin.class)) {
232             serviceConfig = convertRoundRobinConfig();
233           } else if (typedConfig.is(LeastRequest.class)) {
234             serviceConfig = convertLeastRequestConfig(typedConfig.unpack(LeastRequest.class));
235           } else if (typedConfig.is(ClientSideWeightedRoundRobin.class)) {
236             if (enableWrr) {
237               serviceConfig = convertWeightedRoundRobinConfig(
238                   typedConfig.unpack(ClientSideWeightedRoundRobin.class));
239             }
240           } else if (typedConfig.is(PickFirst.class)) {
241             if (enablePickFirst) {
242               serviceConfig = convertPickFirstConfig(typedConfig.unpack(PickFirst.class));
243             }
244           } else if (typedConfig.is(com.github.xds.type.v3.TypedStruct.class)) {
245             serviceConfig = convertCustomConfig(
246                 typedConfig.unpack(com.github.xds.type.v3.TypedStruct.class));
247           } else if (typedConfig.is(com.github.udpa.udpa.type.v1.TypedStruct.class)) {
248             serviceConfig = convertCustomConfig(
249                 typedConfig.unpack(com.github.udpa.udpa.type.v1.TypedStruct.class));
250           }
251 
252           // TODO: support least_request once it is added to the envoy protos.
253         } catch (InvalidProtocolBufferException e) {
254           throw new ResourceInvalidException(
255               "Unable to unpack typedConfig for: " + typedConfig.getTypeUrl(), e);
256         }
257         // The service config is expected to have a single root entry, where the name of that entry
258         // is the name of the policy. A Load balancer with this name must exist in the registry.
259         if (serviceConfig == null || LoadBalancerRegistry.getDefaultRegistry()
260             .getProvider(Iterables.getOnlyElement(serviceConfig.keySet())) == null) {
261           logger.log(XdsLogLevel.WARNING, "Policy {0} not found in the LB registry, skipping",
262               typedConfig.getTypeUrl());
263           continue;
264         } else {
265           return serviceConfig;
266         }
267       }
268 
269       // If we could not find a Policy that we could both convert as well as find a provider for
270       // then we have an invalid LB policy configuration.
271       throw new ResourceInvalidException("Invalid LoadBalancingPolicy: " + loadBalancingPolicy);
272     }
273 
274     /**
275      * Converts a ring_hash {@link Any} configuration to service config format.
276      */
convertRingHashConfig(RingHash ringHash)277     private static ImmutableMap<String, ?> convertRingHashConfig(RingHash ringHash)
278         throws ResourceInvalidException {
279       // The hash function needs to be validated here as it is not exposed in the returned
280       // configuration for later validation.
281       if (RingHash.HashFunction.XX_HASH != ringHash.getHashFunction()) {
282         throw new ResourceInvalidException(
283             "Invalid ring hash function: " + ringHash.getHashFunction());
284       }
285 
286       return buildRingHashConfig(
287           ringHash.hasMinimumRingSize() ? ringHash.getMinimumRingSize().getValue() : null,
288           ringHash.hasMaximumRingSize() ? ringHash.getMaximumRingSize().getValue() : null);
289     }
290 
convertWeightedRoundRobinConfig( ClientSideWeightedRoundRobin wrr)291     private static ImmutableMap<String, ?> convertWeightedRoundRobinConfig(
292             ClientSideWeightedRoundRobin wrr) throws ResourceInvalidException {
293       try {
294         return buildWrrConfig(
295             wrr.hasBlackoutPeriod() ? Durations.toString(wrr.getBlackoutPeriod()) : null,
296             wrr.hasWeightExpirationPeriod()
297                 ? Durations.toString(wrr.getWeightExpirationPeriod()) : null,
298             wrr.hasOobReportingPeriod() ? Durations.toString(wrr.getOobReportingPeriod()) : null,
299             wrr.hasEnableOobLoadReport() ? wrr.getEnableOobLoadReport().getValue() : null,
300             wrr.hasWeightUpdatePeriod() ? Durations.toString(wrr.getWeightUpdatePeriod()) : null,
301             wrr.hasErrorUtilizationPenalty() ? wrr.getErrorUtilizationPenalty().getValue() : null);
302       } catch (IllegalArgumentException ex) {
303         throw new ResourceInvalidException("Invalid duration in weighted round robin config: "
304             + ex.getMessage());
305       }
306     }
307 
308     /**
309      * Converts a wrr_locality {@link Any} configuration to service config format.
310      */
convertWrrLocalityConfig(WrrLocality wrrLocality, int recursionDepth, boolean enableWrr, boolean enablePickFirst)311     private static ImmutableMap<String, ?> convertWrrLocalityConfig(WrrLocality wrrLocality,
312         int recursionDepth, boolean enableWrr, boolean enablePickFirst)
313         throws ResourceInvalidException,
314         MaxRecursionReachedException {
315       return buildWrrLocalityConfig(
316           convertToServiceConfig(wrrLocality.getEndpointPickingPolicy(),
317               recursionDepth + 1, enableWrr, enablePickFirst));
318     }
319 
320     /**
321      * "Converts" a round_robin configuration to service config format.
322      */
convertRoundRobinConfig()323     private static ImmutableMap<String, ?> convertRoundRobinConfig() {
324       return buildRoundRobinConfig();
325     }
326 
327     /**
328      * "Converts" a pick_first configuration to service config format.
329      */
convertPickFirstConfig(PickFirst pickFirst)330     private static ImmutableMap<String, ?> convertPickFirstConfig(PickFirst pickFirst) {
331       return buildPickFirstConfig(pickFirst.getShuffleAddressList());
332     }
333 
334     /**
335      * Converts a least_request {@link Any} configuration to service config format.
336      */
convertLeastRequestConfig(LeastRequest leastRequest)337     private static ImmutableMap<String, ?> convertLeastRequestConfig(LeastRequest leastRequest)
338         throws ResourceInvalidException {
339       return buildLeastRequestConfig(
340           leastRequest.hasChoiceCount() ? leastRequest.getChoiceCount().getValue() : null);
341     }
342 
343     /**
344      * Converts a custom TypedStruct LB config to service config format.
345      */
346     @SuppressWarnings("unchecked")
convertCustomConfig( com.github.xds.type.v3.TypedStruct configTypedStruct)347     private static ImmutableMap<String, ?> convertCustomConfig(
348         com.github.xds.type.v3.TypedStruct configTypedStruct)
349         throws ResourceInvalidException {
350       return ImmutableMap.of(parseCustomConfigTypeName(configTypedStruct.getTypeUrl()),
351           (Map<String, ?>) parseCustomConfigJson(configTypedStruct.getValue()));
352     }
353 
354     /**
355      * Converts a custom UDPA (legacy) TypedStruct LB config to service config format.
356      */
357     @SuppressWarnings("unchecked")
convertCustomConfig( com.github.udpa.udpa.type.v1.TypedStruct configTypedStruct)358     private static ImmutableMap<String, ?> convertCustomConfig(
359         com.github.udpa.udpa.type.v1.TypedStruct configTypedStruct)
360         throws ResourceInvalidException {
361       return ImmutableMap.of(parseCustomConfigTypeName(configTypedStruct.getTypeUrl()),
362           (Map<String, ?>) parseCustomConfigJson(configTypedStruct.getValue()));
363     }
364 
365     /**
366      * Print the config Struct into JSON and then parse that into our internal representation.
367      */
parseCustomConfigJson(Struct configStruct)368     private static Object parseCustomConfigJson(Struct configStruct)
369         throws ResourceInvalidException {
370       Object rawJsonConfig = null;
371       try {
372         rawJsonConfig = JsonParser.parse(JsonFormat.printer().print(configStruct));
373       } catch (IOException e) {
374         throw new ResourceInvalidException("Unable to parse custom LB config JSON", e);
375       }
376 
377       if (!(rawJsonConfig instanceof Map)) {
378         throw new ResourceInvalidException("Custom LB config does not contain a JSON object");
379       }
380       return rawJsonConfig;
381     }
382 
383 
parseCustomConfigTypeName(String customConfigTypeName)384     private static String parseCustomConfigTypeName(String customConfigTypeName) {
385       if (customConfigTypeName.contains("/")) {
386         customConfigTypeName = customConfigTypeName.substring(
387             customConfigTypeName.lastIndexOf("/") + 1);
388       }
389       return customConfigTypeName;
390     }
391 
392     // Used to signal that the LB config goes too deep.
393     static class MaxRecursionReachedException extends Exception {
394       static final long serialVersionUID = 1L;
395     }
396   }
397 
398   /**
399    * Builds a JSON LB configuration based on the old style of using the xDS Cluster proto message.
400    * The lb_policy field is used to select the policy and configuration is extracted from various
401    * policy specific fields in Cluster.
402    */
403   static class LegacyLoadBalancingPolicyConverter {
404 
405     /**
406      * Factory method for creating a new {link LoadBalancerConfigConverter} for a given xDS {@link
407      * Cluster}.
408      *
409      * @throws ResourceInvalidException If the {@link Cluster} has an invalid LB configuration.
410      */
convertToServiceConfig(Cluster cluster, boolean enableLeastRequest)411     static ImmutableMap<String, ?> convertToServiceConfig(Cluster cluster,
412         boolean enableLeastRequest) throws ResourceInvalidException {
413       switch (cluster.getLbPolicy()) {
414         case RING_HASH:
415           return convertRingHashConfig(cluster);
416         case ROUND_ROBIN:
417           return buildWrrLocalityConfig(buildRoundRobinConfig());
418         case LEAST_REQUEST:
419           if (enableLeastRequest) {
420             return buildWrrLocalityConfig(convertLeastRequestConfig(cluster));
421           }
422           break;
423         default:
424       }
425       throw new ResourceInvalidException(
426           "Cluster " + cluster.getName() + ": unsupported lb policy: " + cluster.getLbPolicy());
427     }
428 
429     /**
430      * Creates a new ring_hash service config JSON object based on the old {@link RingHashLbConfig}
431      * config message.
432      */
convertRingHashConfig(Cluster cluster)433     private static ImmutableMap<String, ?> convertRingHashConfig(Cluster cluster)
434         throws ResourceInvalidException {
435       RingHashLbConfig lbConfig = cluster.getRingHashLbConfig();
436 
437       // The hash function needs to be validated here as it is not exposed in the returned
438       // configuration for later validation.
439       if (lbConfig.getHashFunction() != RingHashLbConfig.HashFunction.XX_HASH) {
440         throw new ResourceInvalidException(
441             "Cluster " + cluster.getName() + ": invalid ring hash function: " + lbConfig);
442       }
443 
444       return buildRingHashConfig(
445           lbConfig.hasMinimumRingSize() ? (Long) lbConfig.getMinimumRingSize().getValue() : null,
446           lbConfig.hasMaximumRingSize() ? (Long) lbConfig.getMaximumRingSize().getValue() : null);
447     }
448 
449     /**
450      * Creates a new least_request service config JSON object based on the old {@link
451      * LeastRequestLbConfig} config message.
452      */
convertLeastRequestConfig(Cluster cluster)453     private static ImmutableMap<String, ?> convertLeastRequestConfig(Cluster cluster) {
454       LeastRequestLbConfig lbConfig = cluster.getLeastRequestLbConfig();
455       return buildLeastRequestConfig(
456           lbConfig.hasChoiceCount() ? (Integer) lbConfig.getChoiceCount().getValue() : null);
457     }
458   }
459 }
460