diff --git a/core/src/main/java/io/grpc/internal/AutoConfiguredLoadBalancerFactory.java b/core/src/main/java/io/grpc/internal/AutoConfiguredLoadBalancerFactory.java index b52a1c2a5a..e1f6f5d4fa 100644 --- a/core/src/main/java/io/grpc/internal/AutoConfiguredLoadBalancerFactory.java +++ b/core/src/main/java/io/grpc/internal/AutoConfiguredLoadBalancerFactory.java @@ -164,7 +164,7 @@ final class AutoConfiguredLoadBalancerFactory extends LoadBalancer.Factory { } String serviceConfigChoiceBalancingPolicy = - ServiceConfigUtil.getLoadBalancingPolicy(config); + ServiceConfigUtil.getLoadBalancingPolicyFromServiceConfig(config); // Check for an explicitly present lb choice if (serviceConfigChoiceBalancingPolicy != null) { diff --git a/core/src/main/java/io/grpc/internal/DnsNameResolver.java b/core/src/main/java/io/grpc/internal/DnsNameResolver.java index 04aa26d58a..cfa652cc7f 100644 --- a/core/src/main/java/io/grpc/internal/DnsNameResolver.java +++ b/core/src/main/java/io/grpc/internal/DnsNameResolver.java @@ -35,9 +35,12 @@ import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Map.Entry; import java.util.Random; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.logging.Level; import java.util.logging.Logger; @@ -63,6 +66,22 @@ final class DnsNameResolver extends NameResolver { private static final boolean JNDI_AVAILABLE = jndiAvailable(); + private static final String SERVICE_CONFIG_CHOICE_CLIENT_LANGUAGE_KEY = "clientLanguage"; + private static final String SERVICE_CONFIG_CHOICE_PERCENTAGE_KEY = "percentage"; + private static final String SERVICE_CONFIG_CHOICE_CLIENT_HOSTNAME_KEY = "clientHostname"; + private static final String SERVICE_CONFIG_CHOICE_SERVICE_CONFIG_KEY = "serviceConfig"; + + // From https://github.com/grpc/proposal/blob/master/A2-service-configs-in-dns.md + static final String SERVICE_CONFIG_PREFIX = "_grpc_config="; + private static final Set SERVICE_CONFIG_CHOICE_KEYS = + Collections.unmodifiableSet( + new HashSet( + Arrays.asList( + SERVICE_CONFIG_CHOICE_CLIENT_LANGUAGE_KEY, + SERVICE_CONFIG_CHOICE_PERCENTAGE_KEY, + SERVICE_CONFIG_CHOICE_CLIENT_HOSTNAME_KEY, + SERVICE_CONFIG_CHOICE_SERVICE_CONFIG_KEY))); + // From https://github.com/grpc/proposal/blob/master/A2-service-configs-in-dns.md private static final String SERVICE_CONFIG_NAME_PREFIX = "_grpc_config."; // From https://github.com/grpc/proposal/blob/master/A5-grpclb-in-dns.md @@ -196,11 +215,10 @@ final class DnsNameResolver extends NameResolver { Map serviceConfig = null; try { for (Map possibleConfig : - ServiceConfigUtil.parseTxtResults(resolvedInetAddrs.txtRecords)) { + parseTxtResults(resolvedInetAddrs.txtRecords)) { try { serviceConfig = - ServiceConfigUtil.maybeChooseServiceConfig( - possibleConfig, random, getLocalHostname()); + maybeChooseServiceConfig(possibleConfig, random, getLocalHostname()); } catch (RuntimeException e) { logger.log(Level.WARNING, "Bad service config choice " + possibleConfig, e); } @@ -265,6 +283,120 @@ final class DnsNameResolver extends NameResolver { this.delegateResolver = delegateResolver; } + @SuppressWarnings("unchecked") + @VisibleForTesting + static List> parseTxtResults(List txtRecords) { + List> serviceConfigs = new ArrayList>(); + for (String txtRecord : txtRecords) { + if (txtRecord.startsWith(SERVICE_CONFIG_PREFIX)) { + List> choices; + try { + Object rawChoices = JsonParser.parse(txtRecord.substring(SERVICE_CONFIG_PREFIX.length())); + if (!(rawChoices instanceof List)) { + throw new IOException("wrong type " + rawChoices); + } + List listChoices = (List) rawChoices; + for (Object obj : listChoices) { + if (!(obj instanceof Map)) { + throw new IOException("wrong element type " + rawChoices); + } + } + choices = (List>) (List) listChoices; + } catch (IOException e) { + logger.log(Level.WARNING, "Bad service config: " + txtRecord, e); + continue; + } + serviceConfigs.addAll(choices); + } else { + logger.log(Level.FINE, "Ignoring non service config {0}", new Object[]{txtRecord}); + } + } + return serviceConfigs; + } + + @Nullable + private static final Double getPercentageFromChoice( + Map serviceConfigChoice) { + if (!serviceConfigChoice.containsKey(SERVICE_CONFIG_CHOICE_PERCENTAGE_KEY)) { + return null; + } + return ServiceConfigUtil.getDouble(serviceConfigChoice, SERVICE_CONFIG_CHOICE_PERCENTAGE_KEY); + } + + @Nullable + private static final List getClientLanguagesFromChoice( + Map serviceConfigChoice) { + if (!serviceConfigChoice.containsKey(SERVICE_CONFIG_CHOICE_CLIENT_LANGUAGE_KEY)) { + return null; + } + return ServiceConfigUtil.checkStringList( + ServiceConfigUtil.getList(serviceConfigChoice, SERVICE_CONFIG_CHOICE_CLIENT_LANGUAGE_KEY)); + } + + @Nullable + private static final List getHostnamesFromChoice( + Map serviceConfigChoice) { + if (!serviceConfigChoice.containsKey(SERVICE_CONFIG_CHOICE_CLIENT_HOSTNAME_KEY)) { + return null; + } + return ServiceConfigUtil.checkStringList( + ServiceConfigUtil.getList(serviceConfigChoice, SERVICE_CONFIG_CHOICE_CLIENT_HOSTNAME_KEY)); + } + + /** + * Determines if a given Service Config choice applies, and if so, returns it. + * + * @see + * Service Config in DNS + * @param choice The service config choice. + * @return The service config object or {@code null} if this choice does not apply. + */ + @Nullable + @SuppressWarnings("BetaApi") // Verify isn't all that beta + @VisibleForTesting + static Map maybeChooseServiceConfig( + Map choice, Random random, String hostname) { + for (Entry entry : choice.entrySet()) { + Verify.verify(SERVICE_CONFIG_CHOICE_KEYS.contains(entry.getKey()), "Bad key: %s", entry); + } + + List clientLanguages = getClientLanguagesFromChoice(choice); + if (clientLanguages != null && clientLanguages.size() != 0) { + boolean javaPresent = false; + for (String lang : clientLanguages) { + if ("java".equalsIgnoreCase(lang)) { + javaPresent = true; + break; + } + } + if (!javaPresent) { + return null; + } + } + Double percentage = getPercentageFromChoice(choice); + if (percentage != null) { + int pct = percentage.intValue(); + Verify.verify(pct >= 0 && pct <= 100, "Bad percentage: %s", percentage); + if (random.nextInt(100) >= pct) { + return null; + } + } + List clientHostnames = getHostnamesFromChoice(choice); + if (clientHostnames != null && clientHostnames.size() != 0) { + boolean hostnamePresent = false; + for (String clientHostname : clientHostnames) { + if (clientHostname.equals(hostname)) { + hostnamePresent = true; + break; + } + } + if (!hostnamePresent) { + return null; + } + } + return ServiceConfigUtil.getObject(choice, SERVICE_CONFIG_CHOICE_SERVICE_CONFIG_KEY); + } + /** * Returns whether the JNDI DNS resolver is available. This is accomplished by looking up a * particular class. It is believed to be the default (only?) DNS resolver that will actually be diff --git a/core/src/main/java/io/grpc/internal/ManagedChannelImpl.java b/core/src/main/java/io/grpc/internal/ManagedChannelImpl.java index a9fb34ade2..593ca193e8 100644 --- a/core/src/main/java/io/grpc/internal/ManagedChannelImpl.java +++ b/core/src/main/java/io/grpc/internal/ManagedChannelImpl.java @@ -64,6 +64,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; @@ -134,6 +135,8 @@ final class ManagedChannelImpl extends ManagedChannel implements Instrumented serviceConfig = + config.get(GrpcAttributes.NAME_RESOLVER_SERVICE_CONFIG); + try { + serviceConfigInterceptor.handleUpdate(serviceConfig); if (retryEnabled) { retryPolicies = getRetryPolicies(config); throttle = getThrottle(config); @@ -1223,7 +1231,7 @@ final class ManagedChannelImpl extends ManagedChannel implements Instrumented> serviceMethodMap + = new AtomicReference>(); + @VisibleForTesting + final AtomicReference> serviceMap + = new AtomicReference>(); + + ServiceConfigInterceptor() {} + + void handleUpdate(Map serviceConfig) { + Map newServiceMethodConfigs = new HashMap(); + Map newServiceConfigs = new HashMap(); + + // Try and do as much validation here before we swap out the existing configuration. In case + // the input is invalid, we don't want to lose the existing configuration. + + List> methodConfigs = + ServiceConfigUtil.getMethodConfigFromServiceConfig(serviceConfig); + if (methodConfigs == null) { + logger.log(Level.FINE, "No method configs found, skipping"); + return; + } + + for (Map methodConfig : methodConfigs) { + MethodInfo info = new MethodInfo(methodConfig); + + List> nameList = + ServiceConfigUtil.getNameListFromMethodConfig(methodConfig); + + checkArgument( + nameList != null && !nameList.isEmpty(), "no names in method config %s", methodConfig); + for (Map name : nameList) { + String serviceName = ServiceConfigUtil.getServiceFromName(name); + checkArgument(!Strings.isNullOrEmpty(serviceName), "missing service name"); + String methodName = ServiceConfigUtil.getMethodFromName(name); + if (Strings.isNullOrEmpty(methodName)) { + // Service scoped config + checkArgument( + !newServiceConfigs.containsKey(serviceName), "Duplicate service %s", serviceName); + newServiceConfigs.put(serviceName, info); + } else { + // Method scoped config + String fullMethodName = MethodDescriptor.generateFullMethodName(serviceName, methodName); + checkArgument( + !newServiceMethodConfigs.containsKey(fullMethodName), + "Duplicate method name %s", + fullMethodName); + newServiceMethodConfigs.put(fullMethodName, info); + } + } + } + + // Okay, service config is good, swap it. + serviceMethodMap.set(Collections.unmodifiableMap(newServiceMethodConfigs)); + serviceMap.set(Collections.unmodifiableMap(newServiceConfigs)); + } + + /** + * Equivalent of MethodConfig from a ServiceConfig. + */ + static final class MethodInfo { + final Long timeoutNanos; + final Boolean waitForReady; + final Integer maxInboundMessageSize; + final Integer maxOutboundMessageSize; + final RetryPolicy retryPolicy; + + MethodInfo(Map methodConfig) { + timeoutNanos = ServiceConfigUtil.getTimeoutFromMethodConfig(methodConfig); + waitForReady = ServiceConfigUtil.getWaitForReadyFromMethodConfig(methodConfig); + maxInboundMessageSize = + ServiceConfigUtil.getMaxResponseMessageBytesFromMethodConfig(methodConfig); + if (maxInboundMessageSize != null) { + checkArgument( + maxInboundMessageSize >= 0, + "maxInboundMessageSize %s exceeds bounds", maxInboundMessageSize); + } + maxOutboundMessageSize = + ServiceConfigUtil.getMaxRequestMessageBytesFromMethodConfig(methodConfig); + if (maxOutboundMessageSize != null) { + checkArgument( + maxOutboundMessageSize >= 0, + "maxOutboundMessageSize %s exceeds bounds", maxOutboundMessageSize); + } + + Map policy = ServiceConfigUtil.getRetryPolicyFromMethodConfig(methodConfig); + retryPolicy = policy == null ? null : new RetryPolicy(policy); + } + + @Override + public int hashCode() { + return Objects.hashCode( + timeoutNanos, waitForReady, maxInboundMessageSize, maxOutboundMessageSize); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof MethodInfo)) { + return false; + } + MethodInfo that = (MethodInfo) other; + return Objects.equal(this.timeoutNanos, that.timeoutNanos) + && Objects.equal(this.waitForReady, that.waitForReady) + && Objects.equal(this.maxInboundMessageSize, that.maxInboundMessageSize) + && Objects.equal(this.maxOutboundMessageSize, that.maxOutboundMessageSize); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("timeoutNanos", timeoutNanos) + .add("waitForReady", waitForReady) + .add("maxInboundMessageSize", maxInboundMessageSize) + .add("maxOutboundMessageSize", maxOutboundMessageSize) + .toString(); + } + + static final class RetryPolicy { + final int maxAttempts; + final long initialBackoffNanos; + final long maxBackoffNanos; + final double backoffMultiplier; + final Set retryableStatusCodes; + + RetryPolicy(Map retryPolicy) { + maxAttempts = checkNotNull( + ServiceConfigUtil.getMaxAttemptsFromRetryPolicy(retryPolicy), + "maxAttempts cannot be empty"); + checkArgument(maxAttempts >= 2, "maxAttempts must be greater than 1: %s", maxAttempts); + + initialBackoffNanos = checkNotNull( + ServiceConfigUtil.getInitialBackoffNanosFromRetryPolicy(retryPolicy), + "initialBackoff cannot be empty"); + checkArgument( + initialBackoffNanos > 0, + "initialBackoffNanos must be greater than 0: %s", + initialBackoffNanos); + + maxBackoffNanos = checkNotNull( + ServiceConfigUtil.getMaxBackoffNanosFromRetryPolicy(retryPolicy), + "maxBackoff cannot be empty"); + checkArgument( + maxBackoffNanos > 0, "maxBackoff must be greater than 0: %s", maxBackoffNanos); + + backoffMultiplier = checkNotNull( + ServiceConfigUtil.getBackoffMultiplierFromRetryPolicy(retryPolicy), + "backoffMultiplier cannot be empty"); + checkArgument( + backoffMultiplier > 0, + "backoffMultiplier must be greater than 0: %s", + backoffMultiplier); + + List rawCodes = + ServiceConfigUtil.getRetryableStatusCodesFromRetryPolicy(retryPolicy); + checkNotNull(rawCodes, "rawCodes must be present"); + checkArgument(!rawCodes.isEmpty(), "rawCodes can't be empty"); + EnumSet codes = EnumSet.noneOf(Code.class); + // service config doesn't say if duplicates are allowed, so just accept them. + for (String rawCode : rawCodes) { + codes.add(Code.valueOf(rawCode)); + } + retryableStatusCodes = Collections.unmodifiableSet(codes); + } + + @VisibleForTesting + RetryPolicy( + int maxAttempts, + long initialBackoffNanos, + long maxBackoffNanos, + double backoffMultiplier, + Set retryableStatusCodes) { + this.maxAttempts = maxAttempts; + this.initialBackoffNanos = initialBackoffNanos; + this.maxBackoffNanos = maxBackoffNanos; + this.backoffMultiplier = backoffMultiplier; + this.retryableStatusCodes = retryableStatusCodes; + } + + @Override + public int hashCode() { + return Objects.hashCode( + maxAttempts, + initialBackoffNanos, + maxBackoffNanos, + backoffMultiplier, + retryableStatusCodes); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof RetryPolicy)) { + return false; + } + RetryPolicy that = (RetryPolicy) other; + return Objects.equal(this.maxAttempts, that.maxAttempts) + && Objects.equal(this.initialBackoffNanos, that.initialBackoffNanos) + && Objects.equal(this.maxBackoffNanos, that.maxBackoffNanos) + && Objects.equal(this.backoffMultiplier, that.backoffMultiplier) + && Objects.equal(this.retryableStatusCodes, that.retryableStatusCodes); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("maxAttempts", maxAttempts) + .add("initialBackoffNanos", initialBackoffNanos) + .add("maxBackoffNanos", maxBackoffNanos) + .add("backoffMultiplier", backoffMultiplier) + .add("retryableStatusCodes", retryableStatusCodes) + .toString(); + } + + } + } + + static final CallOptions.Key RETRY_POLICY_KEY = + CallOptions.Key.of("internal-retry-policy", null); + + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + Map localServiceMethodMap = serviceMethodMap.get(); + MethodInfo info = null; + if (localServiceMethodMap != null) { + info = localServiceMethodMap.get(method.getFullMethodName()); + } + if (info == null) { + Map localServiceMap = serviceMap.get(); + if (localServiceMap != null) { + info = localServiceMap.get( + MethodDescriptor.extractFullServiceName(method.getFullMethodName())); + } + } + if (info == null) { + return next.newCall(method, callOptions); + } + + if (info.timeoutNanos != null) { + Deadline newDeadline = Deadline.after(info.timeoutNanos, TimeUnit.NANOSECONDS); + Deadline existingDeadline = callOptions.getDeadline(); + // If the new deadline is sooner than the existing deadline, swap them. + if (existingDeadline == null || newDeadline.compareTo(existingDeadline) < 0) { + callOptions = callOptions.withDeadline(newDeadline); + } + } + if (info.waitForReady != null) { + callOptions = + info.waitForReady ? callOptions.withWaitForReady() : callOptions.withoutWaitForReady(); + } + if (info.maxInboundMessageSize != null) { + Integer existingLimit = callOptions.getMaxInboundMessageSize(); + if (existingLimit != null) { + callOptions = callOptions.withMaxInboundMessageSize( + Math.min(existingLimit, info.maxInboundMessageSize)); + } else { + callOptions = callOptions.withMaxInboundMessageSize(info.maxInboundMessageSize); + } + } + if (info.maxOutboundMessageSize != null) { + Integer existingLimit = callOptions.getMaxOutboundMessageSize(); + if (existingLimit != null) { + callOptions = callOptions.withMaxOutboundMessageSize( + Math.min(existingLimit, info.maxOutboundMessageSize)); + } else { + callOptions = callOptions.withMaxOutboundMessageSize(info.maxOutboundMessageSize); + } + } + if (info.retryPolicy != null) { + callOptions = callOptions.withOption(RETRY_POLICY_KEY, info.retryPolicy); + } + + return next.newCall(method, callOptions); + } +} diff --git a/core/src/main/java/io/grpc/internal/ServiceConfigUtil.java b/core/src/main/java/io/grpc/internal/ServiceConfigUtil.java index 36accfbe95..d5f3c6aec5 100644 --- a/core/src/main/java/io/grpc/internal/ServiceConfigUtil.java +++ b/core/src/main/java/io/grpc/internal/ServiceConfigUtil.java @@ -18,26 +18,20 @@ package io.grpc.internal; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; +import static com.google.common.math.LongMath.checkedAdd; -import com.google.common.base.Verify; import io.grpc.MethodDescriptor; import io.grpc.Status.Code; import io.grpc.internal.RetriableStream.RetryPolicies; import io.grpc.internal.RetriableStream.RetryPolicy; import io.grpc.internal.RetriableStream.Throttle; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; +import java.text.ParseException; import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.Locale; import java.util.Map; -import java.util.Map.Entry; -import java.util.Random; import java.util.Set; -import java.util.logging.Level; +import java.util.concurrent.TimeUnit; import java.util.logging.Logger; import javax.annotation.Nullable; @@ -48,24 +42,29 @@ final class ServiceConfigUtil { private static final Logger logger = Logger.getLogger(ServiceConfigUtil.class.getName()); - // From https://github.com/grpc/proposal/blob/master/A2-service-configs-in-dns.md - static final String SERVICE_CONFIG_PREFIX = "_grpc_config="; - private static final Set SERVICE_CONFIG_CHOICE_KEYS = - Collections.unmodifiableSet( - new HashSet( - Arrays.asList("clientLanguage", "percentage", "clientHostname", "serviceConfig"))); + private static final String SERVICE_CONFIG_METHOD_CONFIG_KEY = "methodConfig"; + private static final String SERVICE_CONFIG_LOAD_BALANCING_POLICY_key = "loadBalancingPolicy"; + private static final String METHOD_CONFIG_NAME_KEY = "name"; + private static final String METHOD_CONFIG_TIMEOUT_KEY = "timeout"; + private static final String METHOD_CONFIG_WAIT_FOR_READY_KEY = "waitForReady"; + private static final String METHOD_CONFIG_MAX_REQUEST_MESSAGE_BYTES_KEY = + "maxRequestMessageBytes"; + private static final String METHOD_CONFIG_MAX_RESPONSE_MESSAGE_BYTES_KEY = + "maxResponseMessageBytes"; + private static final String METHOD_CONFIG_RETRY_POLICY_KEY = "retryPolicy"; + private static final String NAME_SERVICE_KEY = "service"; + private static final String NAME_METHOD_KEY = "method"; + private static final String RETRY_POLICY_MAX_ATTEMPTS_KEY = "maxAttempts"; + private static final String RETRY_POLICY_INITIAL_BACKOFF_KEY = "initialBackoff"; + private static final String RETRY_POLICY_MAX_BACKOFF_KEY = "maxBackoff"; + private static final String RETRY_POLICY_BACKOFF_MULTIPLIER_KEY = "backoffMultiplier"; + private static final String RETRY_POLICY_RETRYABLE_STATUS_CODES_KEY = "retryableStatusCodes"; + + private static final long DURATION_SECONDS_MIN = -315576000000L; + private static final long DURATION_SECONDS_MAX = 315576000000L; private ServiceConfigUtil() {} - @Nullable - static String getLoadBalancingPolicy(Map serviceConfig) { - String key = "loadBalancingPolicy"; - if (serviceConfig.containsKey(key)) { - return getString(serviceConfig, key); - } - return null; - } - /** * Gets retry policies from the service config. * @@ -94,10 +93,10 @@ final class ServiceConfigUtil { "maxBackoff": string, // Long decimal with "s" appended "backoffMultiplier": number "retryableStatusCodes": [] - } - } - ] - } + } + } + ] + } */ if (serviceConfig.containsKey("methodConfig")) { @@ -204,98 +203,152 @@ final class ServiceConfigUtil { return new Throttle(maxTokens, tokenRatio); } - /** - * Determines if a given Service Config choice applies, and if so, returns it. - * - * @see - * Service Config in DNS - * @param choice The service config choice. - * @return The service config object or {@code null} if this choice does not apply. - */ @Nullable - @SuppressWarnings("BetaApi") // Verify isn't all that beta - static Map maybeChooseServiceConfig( - Map choice, Random random, String hostname) { - for (Entry entry : choice.entrySet()) { - Verify.verify(SERVICE_CONFIG_CHOICE_KEYS.contains(entry.getKey()), "Bad key: %s", entry); + static Integer getMaxAttemptsFromRetryPolicy(Map retryPolicy) { + if (!retryPolicy.containsKey(RETRY_POLICY_MAX_ATTEMPTS_KEY)) { + return null; } - if (choice.containsKey("clientLanguage")) { - List clientLanguages = getList(choice, "clientLanguage"); - if (!clientLanguages.isEmpty()) { - boolean javaPresent = false; - for (int i = 0; i < clientLanguages.size(); i++) { - String lang = getString(clientLanguages, i).toLowerCase(Locale.ROOT); - if ("java".equals(lang)) { - javaPresent = true; - break; - } - } - if (!javaPresent) { - return null; - } - } - } - if (choice.containsKey("percentage")) { - int pct = getDouble(choice, "percentage").intValue(); - Verify.verify(pct >= 0 && pct <= 100, "Bad percentage", choice.get("percentage")); - if (random.nextInt(100) >= pct) { - return null; - } - } - if (choice.containsKey("clientHostname")) { - List clientHostnames = getList(choice, "clientHostname"); - if (!clientHostnames.isEmpty()) { - boolean hostnamePresent = false; - for (int i = 0; i < clientHostnames.size(); i++) { - if (getString(clientHostnames, i).equals(hostname)) { - hostnamePresent = true; - break; - } - } - if (!hostnamePresent) { - return null; - } - } - } - return getObject(choice, "serviceConfig"); + return getDouble(retryPolicy, RETRY_POLICY_MAX_ATTEMPTS_KEY).intValue(); } - @SuppressWarnings("unchecked") - static List> parseTxtResults(List txtRecords) { - List> serviceConfigs = new ArrayList>(); - - for (String txtRecord : txtRecords) { - if (txtRecord.startsWith(SERVICE_CONFIG_PREFIX)) { - List> choices; - try { - Object rawChoices = JsonParser.parse(txtRecord.substring(SERVICE_CONFIG_PREFIX.length())); - if (!(rawChoices instanceof List)) { - throw new IOException("wrong type" + rawChoices); - } - List listChoices = (List) rawChoices; - for (Object obj : listChoices) { - if (!(obj instanceof Map)) { - throw new IOException("wrong element type" + rawChoices); - } - } - choices = (List>) (List) listChoices; - } catch (IOException e) { - logger.log(Level.WARNING, "Bad service config: " + txtRecord, e); - continue; - } - serviceConfigs.addAll(choices); - } else { - logger.log(Level.FINE, "Ignoring non service config {0}", new Object[]{txtRecord}); - } + @Nullable + static Long getInitialBackoffNanosFromRetryPolicy(Map retryPolicy) { + if (!retryPolicy.containsKey(RETRY_POLICY_INITIAL_BACKOFF_KEY)) { + return null; } - return serviceConfigs; + String rawInitialBackoff = getString(retryPolicy, RETRY_POLICY_INITIAL_BACKOFF_KEY); + try { + return parseDuration(rawInitialBackoff); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } + + @Nullable + static Long getMaxBackoffNanosFromRetryPolicy(Map retryPolicy) { + if (!retryPolicy.containsKey(RETRY_POLICY_MAX_BACKOFF_KEY)) { + return null; + } + String rawMaxBackoff = getString(retryPolicy, RETRY_POLICY_MAX_BACKOFF_KEY); + try { + return parseDuration(rawMaxBackoff); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } + + @Nullable + static Double getBackoffMultiplierFromRetryPolicy(Map retryPolicy) { + if (!retryPolicy.containsKey(RETRY_POLICY_BACKOFF_MULTIPLIER_KEY)) { + return null; + } + return getDouble(retryPolicy, RETRY_POLICY_BACKOFF_MULTIPLIER_KEY); + } + + @Nullable + static List getRetryableStatusCodesFromRetryPolicy(Map retryPolicy) { + if (!retryPolicy.containsKey(RETRY_POLICY_RETRYABLE_STATUS_CODES_KEY)) { + return null; + } + return checkStringList(getList(retryPolicy, RETRY_POLICY_RETRYABLE_STATUS_CODES_KEY)); + } + + @Nullable + static String getServiceFromName(Map name) { + if (!name.containsKey(NAME_SERVICE_KEY)) { + return null; + } + return getString(name, NAME_SERVICE_KEY); + } + + @Nullable + static String getMethodFromName(Map name) { + if (!name.containsKey(NAME_METHOD_KEY)) { + return null; + } + return getString(name, NAME_METHOD_KEY); + } + + @Nullable + static Map getRetryPolicyFromMethodConfig(Map methodConfig) { + if (!methodConfig.containsKey(METHOD_CONFIG_RETRY_POLICY_KEY)) { + return null; + } + return getObject(methodConfig, METHOD_CONFIG_RETRY_POLICY_KEY); + } + + @Nullable + static List> getNameListFromMethodConfig(Map methodConfig) { + if (!methodConfig.containsKey(METHOD_CONFIG_NAME_KEY)) { + return null; + } + return checkObjectList(getList(methodConfig, METHOD_CONFIG_NAME_KEY)); + } + + /** + * Returns the number of nanoseconds of timeout for the given method config. + * + * @return duration nanoseconds, or {@code null} if it isn't present. + */ + @Nullable + static Long getTimeoutFromMethodConfig(Map methodConfig) { + if (!methodConfig.containsKey(METHOD_CONFIG_TIMEOUT_KEY)) { + return null; + } + String rawTimeout = getString(methodConfig, METHOD_CONFIG_TIMEOUT_KEY); + try { + return parseDuration(rawTimeout); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } + + @Nullable + static Boolean getWaitForReadyFromMethodConfig(Map methodConfig) { + if (!methodConfig.containsKey(METHOD_CONFIG_WAIT_FOR_READY_KEY)) { + return null; + } + return getBoolean(methodConfig, METHOD_CONFIG_WAIT_FOR_READY_KEY); + } + + @Nullable + static Integer getMaxRequestMessageBytesFromMethodConfig(Map methodConfig) { + if (!methodConfig.containsKey(METHOD_CONFIG_MAX_REQUEST_MESSAGE_BYTES_KEY)) { + return null; + } + return getDouble(methodConfig, METHOD_CONFIG_MAX_REQUEST_MESSAGE_BYTES_KEY).intValue(); + } + + @Nullable + static Integer getMaxResponseMessageBytesFromMethodConfig(Map methodConfig) { + if (!methodConfig.containsKey(METHOD_CONFIG_MAX_RESPONSE_MESSAGE_BYTES_KEY)) { + return null; + } + return getDouble(methodConfig, METHOD_CONFIG_MAX_RESPONSE_MESSAGE_BYTES_KEY).intValue(); + } + + @Nullable + static List> getMethodConfigFromServiceConfig( + Map serviceConfig) { + if (!serviceConfig.containsKey(SERVICE_CONFIG_METHOD_CONFIG_KEY)) { + return null; + } + return checkObjectList(getList(serviceConfig, SERVICE_CONFIG_METHOD_CONFIG_KEY)); + } + + @Nullable + static String getLoadBalancingPolicyFromServiceConfig(Map serviceConfig) { + if (!serviceConfig.containsKey(SERVICE_CONFIG_LOAD_BALANCING_POLICY_key)) { + return null; + } + return getString(serviceConfig, SERVICE_CONFIG_LOAD_BALANCING_POLICY_key); } /** * Gets a list from an object for the given key. */ @SuppressWarnings("unchecked") - private static List getList(Map obj, String key) { + static List getList(Map obj, String key) { assert obj.containsKey(key); Object value = checkNotNull(obj.get(key), "no such key %s", key); if (value instanceof List) { @@ -309,7 +362,7 @@ final class ServiceConfigUtil { * Gets an object from an object for the given key. */ @SuppressWarnings("unchecked") - private static Map getObject(Map obj, String key) { + static Map getObject(Map obj, String key) { assert obj.containsKey(key); Object value = checkNotNull(obj.get(key), "no such key %s", key); if (value instanceof Map) { @@ -337,7 +390,7 @@ final class ServiceConfigUtil { * Gets a double from an object for the given key. */ @SuppressWarnings("unchecked") - private static Double getDouble(Map obj, String key) { + static Double getDouble(Map obj, String key) { assert obj.containsKey(key); Object value = checkNotNull(obj.get(key), "no such key %s", key); if (value instanceof Double) { @@ -351,7 +404,7 @@ final class ServiceConfigUtil { * Gets a string from an object for the given key. */ @SuppressWarnings("unchecked") - private static String getString(Map obj, String key) { + static String getString(Map obj, String key) { assert obj.containsKey(key); Object value = checkNotNull(obj.get(key), "no such key %s", key); if (value instanceof String) { @@ -365,7 +418,7 @@ final class ServiceConfigUtil { * Gets a string from an object for the given index. */ @SuppressWarnings("unchecked") - private static String getString(List list, int i) { + static String getString(List list, int i) { assert i >= 0 && i < list.size(); Object value = checkNotNull(list.get(i), "idx %s in %s is null", i, list); if (value instanceof String) { @@ -374,4 +427,170 @@ final class ServiceConfigUtil { throw new ClassCastException( String.format("value %s for idx %d in %s is not String", value, i, list)); } + + /** + * Gets a boolean from an object for the given key. + */ + static Boolean getBoolean(Map obj, String key) { + assert obj.containsKey(key); + Object value = checkNotNull(obj.get(key), "no such key %s", key); + if (value instanceof Boolean) { + return (Boolean) value; + } + throw new ClassCastException( + String.format("value %s for key %s in %s is not Boolean", value, key, obj)); + } + + @SuppressWarnings("unchecked") + private static List> checkObjectList(List rawList) { + for (int i = 0; i < rawList.size(); i++) { + if (!(rawList.get(i) instanceof Map)) { + throw new ClassCastException( + String.format("value %s for idx %d in %s is not object", rawList.get(i), i, rawList)); + } + } + return (List>) (List) rawList; + } + + @SuppressWarnings("unchecked") + static List checkStringList(List rawList) { + for (int i = 0; i < rawList.size(); i++) { + if (!(rawList.get(i) instanceof String)) { + throw new ClassCastException( + String.format("value %s for idx %d in %s is not string", rawList.get(i), i, rawList)); + } + } + return (List) (List) rawList; + } + + /** + * Parse from a string to produce a duration. Copy of + * {@link com.google.protobuf.util.Durations#parse}. + * + * @return A Duration parsed from the string. + * @throws ParseException if parsing fails. + */ + private static long parseDuration(String value) throws ParseException { + // Must ended with "s". + if (value.isEmpty() || value.charAt(value.length() - 1) != 's') { + throw new ParseException("Invalid duration string: " + value, 0); + } + boolean negative = false; + if (value.charAt(0) == '-') { + negative = true; + value = value.substring(1); + } + String secondValue = value.substring(0, value.length() - 1); + String nanoValue = ""; + int pointPosition = secondValue.indexOf('.'); + if (pointPosition != -1) { + nanoValue = secondValue.substring(pointPosition + 1); + secondValue = secondValue.substring(0, pointPosition); + } + long seconds = Long.parseLong(secondValue); + int nanos = nanoValue.isEmpty() ? 0 : parseNanos(nanoValue); + if (seconds < 0) { + throw new ParseException("Invalid duration string: " + value, 0); + } + if (negative) { + seconds = -seconds; + nanos = -nanos; + } + try { + return normalizedDuration(seconds, nanos); + } catch (IllegalArgumentException e) { + throw new ParseException("Duration value is out of range.", 0); + } + } + + /** + * Copy of {@link com.google.protobuf.util.Timestamps#parseNanos}. + */ + private static int parseNanos(String value) throws ParseException { + int result = 0; + for (int i = 0; i < 9; ++i) { + result = result * 10; + if (i < value.length()) { + if (value.charAt(i) < '0' || value.charAt(i) > '9') { + throw new ParseException("Invalid nanoseconds.", 0); + } + result += value.charAt(i) - '0'; + } + } + return result; + } + + private static final long NANOS_PER_SECOND = TimeUnit.SECONDS.toNanos(1); + + /** + * Copy of {@link com.google.protobuf.util.Durations#normalizedDuration}. + */ + @SuppressWarnings("NarrowingCompoundAssignment") + private static long normalizedDuration(long seconds, int nanos) { + if (nanos <= -NANOS_PER_SECOND || nanos >= NANOS_PER_SECOND) { + seconds = checkedAdd(seconds, nanos / NANOS_PER_SECOND); + nanos %= NANOS_PER_SECOND; + } + if (seconds > 0 && nanos < 0) { + nanos += NANOS_PER_SECOND; // no overflow since nanos is negative (and we're adding) + seconds--; // no overflow since seconds is positive (and we're decrementing) + } + if (seconds < 0 && nanos > 0) { + nanos -= NANOS_PER_SECOND; // no overflow since nanos is positive (and we're subtracting) + seconds++; // no overflow since seconds is negative (and we're incrementing) + } + if (!durationIsValid(seconds, nanos)) { + throw new IllegalArgumentException(String.format( + "Duration is not valid. See proto definition for valid values. " + + "Seconds (%s) must be in range [-315,576,000,000, +315,576,000,000]. " + + "Nanos (%s) must be in range [-999,999,999, +999,999,999]. " + + "Nanos must have the same sign as seconds", seconds, nanos)); + } + return saturatedAdd(TimeUnit.SECONDS.toNanos(seconds), nanos); + } + + /** + * Returns true if the given number of seconds and nanos is a valid {@code Duration}. The {@code + * seconds} value must be in the range [-315,576,000,000, +315,576,000,000]. The {@code nanos} + * value must be in the range [-999,999,999, +999,999,999]. + * + *

Note: Durations less than one second are represented with a 0 {@code seconds} field + * and a positive or negative {@code nanos} field. For durations of one second or more, a non-zero + * value for the {@code nanos} field must be of the same sign as the {@code seconds} field. + * + *

Copy of {@link com.google.protobuf.util.Duration#isValid}.

+ */ + private static boolean durationIsValid(long seconds, int nanos) { + if (seconds < DURATION_SECONDS_MIN || seconds > DURATION_SECONDS_MAX) { + return false; + } + if (nanos < -999999999L || nanos >= NANOS_PER_SECOND) { + return false; + } + if (seconds < 0 || nanos < 0) { + if (seconds > 0 || nanos > 0) { + return false; + } + } + return true; + } + + /** + * Returns the sum of {@code a} and {@code b} unless it would overflow or underflow in which case + * {@code Long.MAX_VALUE} or {@code Long.MIN_VALUE} is returned, respectively. + * + *

Copy of {@link com.google.common.math.LongMath#saturatedAdd}.

+ * + */ + @SuppressWarnings("ShortCircuitBoolean") + private static long saturatedAdd(long a, long b) { + long naiveSum = a + b; + if ((a ^ b) < 0 | (a ^ naiveSum) >= 0) { + // If a and b have different signs or a has the same sign as the result then there was no + // overflow, return. + return naiveSum; + } + // we did over/under flow, if the sign is negative we should return MAX otherwise MIN + return Long.MAX_VALUE + ((naiveSum >>> (Long.SIZE - 1)) ^ 1); + } } diff --git a/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java b/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java index bbecf9dd61..8d7296d5b5 100644 --- a/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java +++ b/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java @@ -19,6 +19,7 @@ package io.grpc.internal; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -45,15 +46,19 @@ import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Queue; +import java.util.Random; import java.util.concurrent.ExecutorService; import org.junit.After; import org.junit.Assume; import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.ExpectedException; import org.junit.rules.Timeout; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -68,6 +73,11 @@ public class DnsNameResolverTest { @Rule public final Timeout globalTimeout = Timeout.seconds(10); + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + private final Map serviceConfig = new LinkedHashMap(); + private static final int DEFAULT_PORT = 887; private static final Attributes NAME_RESOLVER_PARAMS = Attributes.newBuilder().set(NameResolver.Factory.PARAMS_DEFAULT_PORT, DEFAULT_PORT).build(); @@ -281,6 +291,239 @@ public class DnsNameResolverTest { assertEquals("blah\\blah", DnsNameResolver.unquote("\"blah\\\\blah\"")); } + @Test + public void maybeChooseServiceConfig_failsOnMisspelling() { + Map bad = new LinkedHashMap(); + bad.put("parcentage", 1.0); + thrown.expectMessage("Bad key"); + + DnsNameResolver.maybeChooseServiceConfig(bad, new Random(), "host"); + } + + @Test + public void maybeChooseServiceConfig_clientLanguageMatchesJava() { + Map choice = new LinkedHashMap(); + List langs = new ArrayList(); + langs.add("java"); + choice.put("clientLanguage", langs); + choice.put("serviceConfig", serviceConfig); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_clientLanguageDoesntMatchGo() { + Map choice = new LinkedHashMap(); + List langs = new ArrayList(); + langs.add("go"); + choice.put("clientLanguage", langs); + choice.put("serviceConfig", serviceConfig); + + assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_clientLanguageCaseInsensitive() { + Map choice = new LinkedHashMap(); + List langs = new ArrayList(); + langs.add("JAVA"); + choice.put("clientLanguage", langs); + choice.put("serviceConfig", serviceConfig); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_clientLanguageMatchesEmtpy() { + Map choice = new LinkedHashMap(); + List langs = new ArrayList(); + choice.put("clientLanguage", langs); + choice.put("serviceConfig", serviceConfig); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_clientLanguageMatchesMulti() { + Map choice = new LinkedHashMap(); + List langs = new ArrayList(); + langs.add("go"); + langs.add("java"); + choice.put("clientLanguage", langs); + choice.put("serviceConfig", serviceConfig); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_percentageZeroAlwaysFails() { + Map choice = new LinkedHashMap(); + choice.put("percentage", 0D); + choice.put("serviceConfig", serviceConfig); + + assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_percentageHundredAlwaysSucceeds() { + Map choice = new LinkedHashMap(); + choice.put("percentage", 100D); + choice.put("serviceConfig", serviceConfig); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_percentageAboveMatches50() { + Map choice = new LinkedHashMap(); + choice.put("percentage", 50D); + choice.put("serviceConfig", serviceConfig); + + Random r = new Random() { + @Override + public int nextInt(int bound) { + return 49; + } + }; + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, r, "host")); + } + + @Test + public void maybeChooseServiceConfig_percentageAtFails50() { + Map choice = new LinkedHashMap(); + choice.put("percentage", 50D); + choice.put("serviceConfig", serviceConfig); + + Random r = new Random() { + @Override + public int nextInt(int bound) { + return 50; + } + }; + + assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, r, "host")); + } + + @Test + public void maybeChooseServiceConfig_percentageAboveMatches99() { + Map choice = new LinkedHashMap(); + choice.put("percentage", 99D); + choice.put("serviceConfig", serviceConfig); + + Random r = new Random() { + @Override + public int nextInt(int bound) { + return 98; + } + }; + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, r, "host")); + } + + @Test + public void maybeChooseServiceConfig_percentageAtFails99() { + Map choice = new LinkedHashMap(); + choice.put("percentage", 99D); + choice.put("serviceConfig", serviceConfig); + + Random r = new Random() { + @Override + public int nextInt(int bound) { + return 99; + } + }; + + assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, r, "host")); + } + + @Test + public void maybeChooseServiceConfig_percentageAboveMatches1() { + Map choice = new LinkedHashMap(); + choice.put("percentage", 1D); + choice.put("serviceConfig", serviceConfig); + + Random r = new Random() { + @Override + public int nextInt(int bound) { + return 0; + } + }; + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, r, "host")); + } + + @Test + public void maybeChooseServiceConfig_percentageAtFails1() { + Map choice = new LinkedHashMap(); + choice.put("percentage", 1D); + choice.put("serviceConfig", serviceConfig); + + Random r = new Random() { + @Override + public int nextInt(int bound) { + return 1; + } + }; + + assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, r, "host")); + } + + @Test + public void maybeChooseServiceConfig_hostnameMatches() { + Map choice = new LinkedHashMap(); + List hosts = new ArrayList(); + hosts.add("localhost"); + choice.put("clientHostname", hosts); + choice.put("serviceConfig", serviceConfig); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "localhost")); + } + + @Test + public void maybeChooseServiceConfig_hostnameDoesntMatch() { + Map choice = new LinkedHashMap(); + List hosts = new ArrayList(); + hosts.add("localhorse"); + choice.put("clientHostname", hosts); + choice.put("serviceConfig", serviceConfig); + + assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "localhost")); + } + + @Test + public void maybeChooseServiceConfig_clientLanguageCaseSensitive() { + Map choice = new LinkedHashMap(); + List hosts = new ArrayList(); + hosts.add("LOCALHOST"); + choice.put("clientHostname", hosts); + choice.put("serviceConfig", serviceConfig); + + assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "localhost")); + } + + @Test + public void maybeChooseServiceConfig_hostnameMatchesEmtpy() { + Map choice = new LinkedHashMap(); + List hosts = new ArrayList(); + choice.put("clientHostname", hosts); + choice.put("serviceConfig", serviceConfig); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_hostnameMatchesMulti() { + Map choice = new LinkedHashMap(); + List hosts = new ArrayList(); + hosts.add("localhorse"); + hosts.add("localhost"); + choice.put("clientHostname", hosts); + choice.put("serviceConfig", serviceConfig); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "localhost")); + } + private void testInvalidUri(URI uri) { try { provider.newNameResolver(uri, NAME_RESOLVER_PARAMS); diff --git a/core/src/test/java/io/grpc/internal/ServiceConfigInterceptorTest.java b/core/src/test/java/io/grpc/internal/ServiceConfigInterceptorTest.java new file mode 100644 index 0000000000..d4888167fd --- /dev/null +++ b/core/src/test/java/io/grpc/internal/ServiceConfigInterceptorTest.java @@ -0,0 +1,415 @@ +/* + * Copyright 2018, gRPC Authors All rights reserved. + * + * 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 io.grpc.internal; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.verify; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.Deadline; +import io.grpc.MethodDescriptor; +import io.grpc.MethodDescriptor.MethodType; +import io.grpc.internal.ServiceConfigInterceptor.MethodInfo; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** + * Unit tests for {@link ServiceConfigInterceptor}. + */ +@RunWith(JUnit4.class) +public class ServiceConfigInterceptorTest { + + @Rule public final ExpectedException thrown = ExpectedException.none(); + + @Mock private Channel channel; + @Captor private ArgumentCaptor callOptionsCap; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + } + + private final ServiceConfigInterceptor interceptor = new ServiceConfigInterceptor(); + + private final String fullMethodName = + MethodDescriptor.generateFullMethodName("service", "method"); + private final MethodDescriptor methodDescriptor = + MethodDescriptor.newBuilder(new NoopMarshaller(), new NoopMarshaller()) + .setType(MethodType.UNARY) + .setFullMethodName(fullMethodName) + .build(); + + + + private static final class JsonObj extends HashMap { + private JsonObj(Object ... kv) { + for (int i = 0; i < kv.length; i += 2) { + put((String) kv[i], kv[i + 1]); + } + } + } + + private static final class JsonList extends ArrayList { + private JsonList(Object ... values) { + addAll(Arrays.asList(values)); + } + } + + @Test + public void withWaitForReady() { + JsonObj name = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name), "waitForReady", true); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + interceptor.handleUpdate(serviceConfig); + + interceptor.interceptCall(methodDescriptor, CallOptions.DEFAULT.withoutWaitForReady(), channel); + + verify(channel).newCall(eq(methodDescriptor), callOptionsCap.capture()); + assertThat(callOptionsCap.getValue().isWaitForReady()).isTrue(); + } + + @Test + public void withMaxRequestSize() { + JsonObj name = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name), "maxRequestMessageBytes", 1d); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + interceptor.handleUpdate(serviceConfig); + + interceptor.interceptCall(methodDescriptor, CallOptions.DEFAULT, channel); + + verify(channel).newCall(eq(methodDescriptor), callOptionsCap.capture()); + assertThat(callOptionsCap.getValue().getMaxOutboundMessageSize()).isEqualTo(1); + } + + @Test + public void withMaxRequestSize_pickSmallerExisting() { + JsonObj name = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name), "maxRequestMessageBytes", 10d); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + interceptor.handleUpdate(serviceConfig); + + interceptor.interceptCall( + methodDescriptor, CallOptions.DEFAULT.withMaxOutboundMessageSize(5), channel); + + verify(channel).newCall(eq(methodDescriptor), callOptionsCap.capture()); + assertThat(callOptionsCap.getValue().getMaxOutboundMessageSize()).isEqualTo(5); + } + + @Test + public void withMaxRequestSize_pickSmallerNew() { + JsonObj name = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name), "maxRequestMessageBytes", 5d); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + interceptor.handleUpdate(serviceConfig); + + interceptor.interceptCall( + methodDescriptor, CallOptions.DEFAULT.withMaxOutboundMessageSize(10), channel); + + verify(channel).newCall(eq(methodDescriptor), callOptionsCap.capture()); + assertThat(callOptionsCap.getValue().getMaxOutboundMessageSize()).isEqualTo(5); + } + + @Test + public void withMaxResponseSize() { + JsonObj name = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name), "maxResponseMessageBytes", 1d); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + interceptor.handleUpdate(serviceConfig); + + interceptor.interceptCall(methodDescriptor, CallOptions.DEFAULT, channel); + + verify(channel).newCall(eq(methodDescriptor), callOptionsCap.capture()); + assertThat(callOptionsCap.getValue().getMaxInboundMessageSize()).isEqualTo(1); + } + + @Test + public void withMaxResponseSize_pickSmallerExisting() { + JsonObj name = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name), "maxResponseMessageBytes", 5d); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + interceptor.handleUpdate(serviceConfig); + + interceptor.interceptCall( + methodDescriptor, CallOptions.DEFAULT.withMaxInboundMessageSize(10), channel); + + verify(channel).newCall(eq(methodDescriptor), callOptionsCap.capture()); + assertThat(callOptionsCap.getValue().getMaxInboundMessageSize()).isEqualTo(5); + } + + @Test + public void withMaxResponseSize_pickSmallerNew() { + JsonObj name = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name), "maxResponseMessageBytes", 10d); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + interceptor.handleUpdate(serviceConfig); + + interceptor.interceptCall( + methodDescriptor, CallOptions.DEFAULT.withMaxInboundMessageSize(5), channel); + + verify(channel).newCall(eq(methodDescriptor), callOptionsCap.capture()); + assertThat(callOptionsCap.getValue().getMaxInboundMessageSize()).isEqualTo(5); + } + + @Test + public void withoutWaitForReady() { + JsonObj name = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name), "waitForReady", false); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + interceptor.handleUpdate(serviceConfig); + + interceptor.interceptCall(methodDescriptor, CallOptions.DEFAULT.withWaitForReady(), channel); + + verify(channel).newCall(eq(methodDescriptor), callOptionsCap.capture()); + assertThat(callOptionsCap.getValue().isWaitForReady()).isFalse(); + } + + @Test + public void fullMethodMatched() { + // Put in service that matches, but has no deadline. It should be lower priority + JsonObj name1 = new JsonObj("service", "service"); + JsonObj methodConfig1 = new JsonObj("name", new JsonList(name1)); + + JsonObj name2 = new JsonObj("service", "service", "method", "method"); + JsonObj methodConfig2 = new JsonObj("name", new JsonList(name2), "timeout", "1s"); + + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig1, methodConfig2)); + + interceptor.handleUpdate(serviceConfig); + + interceptor.interceptCall(methodDescriptor, CallOptions.DEFAULT, channel); + + verify(channel).newCall(eq(methodDescriptor), callOptionsCap.capture()); + assertThat(callOptionsCap.getValue().getDeadline()).isNotNull(); + } + + @Test + public void nearerDeadlineKept_existing() { + JsonObj name = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name), "timeout", "100000s"); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + interceptor.handleUpdate(serviceConfig); + + Deadline existingDeadline = Deadline.after(1000, TimeUnit.NANOSECONDS); + interceptor.interceptCall( + methodDescriptor, CallOptions.DEFAULT.withDeadline(existingDeadline), channel); + + verify(channel).newCall(eq(methodDescriptor), callOptionsCap.capture()); + assertThat(callOptionsCap.getValue().getDeadline()).isEqualTo(existingDeadline); + } + + @Test + public void nearerDeadlineKept_new() { + // TODO(carl-mastrangelo): the deadlines are very large because they change over time. + // This should be fixed, and is tracked in https://github.com/grpc/grpc-java/issues/2531 + JsonObj name = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name), "timeout", "1s"); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + interceptor.handleUpdate(serviceConfig); + + Deadline existingDeadline = Deadline.after(1234567890, TimeUnit.NANOSECONDS); + interceptor.interceptCall( + methodDescriptor, CallOptions.DEFAULT.withDeadline(existingDeadline), channel); + + verify(channel).newCall(eq(methodDescriptor), callOptionsCap.capture()); + assertThat(callOptionsCap.getValue().getDeadline()).isNotEqualTo(existingDeadline); + } + + + @Test + public void handleUpdate_failsOnMissingServiceName() { + JsonObj name = new JsonObj("method", "method"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name)); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("missing service"); + + interceptor.handleUpdate(serviceConfig); + } + + + @Test + public void handleUpdate_failsOnDuplicateMethod() { + JsonObj name1 = new JsonObj("service", "service", "method", "method"); + JsonObj name2 = new JsonObj("service", "service", "method", "method"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name1, name2)); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Duplicate method"); + + interceptor.handleUpdate(serviceConfig); + } + + @Test + public void handleUpdate_failsOnEmptyName() { + JsonObj methodConfig = new JsonObj(); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("no names in method config"); + + interceptor.handleUpdate(serviceConfig); + } + + @Test + public void handleUpdate_failsOnDuplicateService() { + JsonObj name1 = new JsonObj("service", "service"); + JsonObj name2 = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name1, name2)); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Duplicate service"); + + interceptor.handleUpdate(serviceConfig); + } + + @Test + public void handleUpdate_failsOnDuplicateServiceMultipleConfig() { + JsonObj name1 = new JsonObj("service", "service"); + JsonObj name2 = new JsonObj("service", "service"); + JsonObj methodConfig1 = new JsonObj("name", new JsonList(name1)); + JsonObj methodConfig2 = new JsonObj("name", new JsonList(name2)); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig1, methodConfig2)); + + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Duplicate service"); + + interceptor.handleUpdate(serviceConfig); + } + + @Test + public void handleUpdate_replaceExistingConfig() { + JsonObj name1 = new JsonObj("service", "service"); + JsonObj methodConfig1 = new JsonObj("name", new JsonList(name1)); + JsonObj serviceConfig1 = new JsonObj("methodConfig", new JsonList(methodConfig1)); + + JsonObj name2 = new JsonObj("service", "service", "method", "method"); + JsonObj methodConfig2 = new JsonObj("name", new JsonList(name2)); + JsonObj serviceConfig2 = new JsonObj("methodConfig", new JsonList(methodConfig2)); + + interceptor.handleUpdate(serviceConfig1); + + assertThat(interceptor.serviceMap.get()).isNotEmpty(); + assertThat(interceptor.serviceMethodMap.get()).isEmpty(); + + interceptor.handleUpdate(serviceConfig2); + + assertThat(interceptor.serviceMap.get()).isEmpty(); + assertThat(interceptor.serviceMethodMap.get()).isNotEmpty(); + } + + @Test + public void handleUpdate_matchNames() { + JsonObj name1 = new JsonObj("service", "service2"); + JsonObj name2 = new JsonObj("service", "service", "method", "method"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name1, name2)); + JsonObj serviceConfig = new JsonObj("methodConfig", new JsonList(methodConfig)); + + interceptor.handleUpdate(serviceConfig); + + assertThat(interceptor.serviceMethodMap.get()) + .containsExactly( + methodDescriptor.getFullMethodName(), + new MethodInfo(methodConfig)); + assertThat(interceptor.serviceMap.get()) + .containsExactly("service2", new MethodInfo(methodConfig)); + } + + + @Test + public void methodInfo_validateDeadline() { + JsonObj name = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name), "timeout", "10000000000000000s"); + + thrown.expectMessage("Duration value is out of range"); + + new MethodInfo(methodConfig); + } + + @Test + public void methodInfo_saturateDeadline() { + JsonObj name = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name), "timeout", "315576000000s"); + + MethodInfo info = new MethodInfo(methodConfig); + + assertThat(info.timeoutNanos).isEqualTo(Long.MAX_VALUE); + } + + + @Test + public void methodInfo_badMaxRequestSize() { + JsonObj name = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name), "maxRequestMessageBytes", -1d); + + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("exceeds bounds"); + + new MethodInfo(methodConfig); + } + + @Test + public void methodInfo_badMaxResponseSize() { + JsonObj name = new JsonObj("service", "service"); + JsonObj methodConfig = new JsonObj("name", new JsonList(name), "maxResponseMessageBytes", -1d); + + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("exceeds bounds"); + + new MethodInfo(methodConfig); + } + + private static final class NoopMarshaller implements MethodDescriptor.Marshaller { + + @Override + public InputStream stream(Void value) { + return null; + } + + @Override + public Void parse(InputStream stream) { + return null; + } + } +} diff --git a/core/src/test/java/io/grpc/internal/ServiceConfigUtilTest.java b/core/src/test/java/io/grpc/internal/ServiceConfigUtilTest.java deleted file mode 100644 index aadf987d51..0000000000 --- a/core/src/test/java/io/grpc/internal/ServiceConfigUtilTest.java +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright 2018, gRPC Authors All rights reserved. - * - * 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 io.grpc.internal; - -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Random; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -/** - * Unit tests for {@link ServiceConfigUtil}. - */ -@RunWith(JUnit4.class) -public class ServiceConfigUtilTest { - - @Rule - public final ExpectedException thrown = ExpectedException.none(); - - private final Map serviceConfig = new LinkedHashMap(); - - @Test - public void maybeChooseServiceConfig_failsOnMisspelling() { - Map bad = new LinkedHashMap(); - bad.put("parcentage", 1.0); - thrown.expectMessage("Bad key"); - - ServiceConfigUtil.maybeChooseServiceConfig(bad, new Random(), "host"); - } - - @Test - public void maybeChooseServiceConfig_clientLanguageMatchesJava() { - Map choice = new LinkedHashMap(); - List langs = new ArrayList(); - langs.add("java"); - choice.put("clientLanguage", langs); - choice.put("serviceConfig", serviceConfig); - - assertNotNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, new Random(), "host")); - } - - @Test - public void maybeChooseServiceConfig_clientLanguageDoesntMatchGo() { - Map choice = new LinkedHashMap(); - List langs = new ArrayList(); - langs.add("go"); - choice.put("clientLanguage", langs); - choice.put("serviceConfig", serviceConfig); - - assertNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, new Random(), "host")); - } - - @Test - public void maybeChooseServiceConfig_clientLanguageCaseInsensitive() { - Map choice = new LinkedHashMap(); - List langs = new ArrayList(); - langs.add("JAVA"); - choice.put("clientLanguage", langs); - choice.put("serviceConfig", serviceConfig); - - assertNotNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, new Random(), "host")); - } - - @Test - public void maybeChooseServiceConfig_clientLanguageMatchesEmtpy() { - Map choice = new LinkedHashMap(); - List langs = new ArrayList(); - choice.put("clientLanguage", langs); - choice.put("serviceConfig", serviceConfig); - - assertNotNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, new Random(), "host")); - } - - @Test - public void maybeChooseServiceConfig_clientLanguageMatchesMulti() { - Map choice = new LinkedHashMap(); - List langs = new ArrayList(); - langs.add("go"); - langs.add("java"); - choice.put("clientLanguage", langs); - choice.put("serviceConfig", serviceConfig); - - assertNotNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, new Random(), "host")); - } - - @Test - public void maybeChooseServiceConfig_percentageZeroAlwaysFails() { - Map choice = new LinkedHashMap(); - choice.put("percentage", 0D); - choice.put("serviceConfig", serviceConfig); - - assertNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, new Random(), "host")); - } - - @Test - public void maybeChooseServiceConfig_percentageHundredAlwaysSucceeds() { - Map choice = new LinkedHashMap(); - choice.put("percentage", 100D); - choice.put("serviceConfig", serviceConfig); - - assertNotNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, new Random(), "host")); - } - - @Test - public void maybeChooseServiceConfig_percentageAboveMatches50() { - Map choice = new LinkedHashMap(); - choice.put("percentage", 50D); - choice.put("serviceConfig", serviceConfig); - - Random r = new Random() { - @Override - public int nextInt(int bound) { - return 49; - } - }; - - assertNotNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, r, "host")); - } - - @Test - public void maybeChooseServiceConfig_percentageAtFails50() { - Map choice = new LinkedHashMap(); - choice.put("percentage", 50D); - choice.put("serviceConfig", serviceConfig); - - Random r = new Random() { - @Override - public int nextInt(int bound) { - return 50; - } - }; - - assertNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, r, "host")); - } - - @Test - public void maybeChooseServiceConfig_percentageAboveMatches99() { - Map choice = new LinkedHashMap(); - choice.put("percentage", 99D); - choice.put("serviceConfig", serviceConfig); - - Random r = new Random() { - @Override - public int nextInt(int bound) { - return 98; - } - }; - - assertNotNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, r, "host")); - } - - @Test - public void maybeChooseServiceConfig_percentageAtFails99() { - Map choice = new LinkedHashMap(); - choice.put("percentage", 99D); - choice.put("serviceConfig", serviceConfig); - - Random r = new Random() { - @Override - public int nextInt(int bound) { - return 99; - } - }; - - assertNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, r, "host")); - } - - @Test - public void maybeChooseServiceConfig_percentageAboveMatches1() { - Map choice = new LinkedHashMap(); - choice.put("percentage", 1D); - choice.put("serviceConfig", serviceConfig); - - Random r = new Random() { - @Override - public int nextInt(int bound) { - return 0; - } - }; - - assertNotNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, r, "host")); - } - - @Test - public void maybeChooseServiceConfig_percentageAtFails1() { - Map choice = new LinkedHashMap(); - choice.put("percentage", 1D); - choice.put("serviceConfig", serviceConfig); - - Random r = new Random() { - @Override - public int nextInt(int bound) { - return 1; - } - }; - - assertNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, r, "host")); - } - - @Test - public void maybeChooseServiceConfig_hostnameMatches() { - Map choice = new LinkedHashMap(); - List hosts = new ArrayList(); - hosts.add("localhost"); - choice.put("clientHostname", hosts); - choice.put("serviceConfig", serviceConfig); - - assertNotNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, new Random(), "localhost")); - } - - @Test - public void maybeChooseServiceConfig_hostnameDoesntMatch() { - Map choice = new LinkedHashMap(); - List hosts = new ArrayList(); - hosts.add("localhorse"); - choice.put("clientHostname", hosts); - choice.put("serviceConfig", serviceConfig); - - assertNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, new Random(), "localhost")); - } - - @Test - public void maybeChooseServiceConfig_clientLanguageCaseSensitive() { - Map choice = new LinkedHashMap(); - List hosts = new ArrayList(); - hosts.add("LOCALHOST"); - choice.put("clientHostname", hosts); - choice.put("serviceConfig", serviceConfig); - - assertNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, new Random(), "localhost")); - } - - @Test - public void maybeChooseServiceConfig_hostnameMatchesEmtpy() { - Map choice = new LinkedHashMap(); - List hosts = new ArrayList(); - choice.put("clientHostname", hosts); - choice.put("serviceConfig", serviceConfig); - - assertNotNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, new Random(), "host")); - } - - @Test - public void maybeChooseServiceConfig_hostnameMatchesMulti() { - Map choice = new LinkedHashMap(); - List hosts = new ArrayList(); - hosts.add("localhorse"); - hosts.add("localhost"); - choice.put("clientHostname", hosts); - choice.put("serviceConfig", serviceConfig); - - assertNotNull(ServiceConfigUtil.maybeChooseServiceConfig(choice, new Random(), "localhost")); - } -}