Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/src/main/asciidoc/_configprops.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
|spring.cloud.loadbalancer.retry.retryable-status-codes | | A {@link Set} of status codes that should trigger a retry.
|spring.cloud.loadbalancer.ribbon.enabled | `true` | Causes `RibbonLoadBalancerClient` to be used by default.
|spring.cloud.loadbalancer.service-discovery.timeout | | String representation of Duration of the timeout for calls to service discovery.
|spring.cloud.loadbalancer.sticky-session.add-service-instance-cookie | `false` | Indicates whether a cookie with the newly selected instance should be added by SC LoadBalancer.
|spring.cloud.loadbalancer.sticky-session.instance-id-cookie-name | `sc-lb-instance-id` | The name of the cookie holding the preferred instance id.
|spring.cloud.loadbalancer.zone | | Spring Cloud LoadBalancer zone.
|spring.cloud.refresh.enabled | `true` | Enables autoconfiguration for the refresh scope and associated features.
|spring.cloud.refresh.extra-refreshable | `true` | Additional class names for beans to post process into refresh scope.
Expand Down
26 changes: 26 additions & 0 deletions docs/src/main/asciidoc/spring-cloud-commons.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,32 @@ public class CustomLoadBalancerConfiguration {

WARNING: `HealthCheckServiceInstanceListSupplier` has its own caching mechanism based on Reactor Flux `replay()`. Therefore, if it's being used, you may want to skip wrapping that supplier with `CachingServiceInstanceListSupplier`.

=== Request-based Sticky Session for LoadBalancer

It is possible to set up the LoadBalancer in such a way that it will prefer the instance with `instanceId` provided in a request cookie. We currently support this if the request is being passed to the LoadBalancer either via the `ClientRequestContext` or `ServerHttpRequestContext`, which are being used by the SC LoadBalancer exchange filter functions and filters.
Copy link
Contributor

Choose a reason for hiding this comment

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

Change to:

You can set up the LoadBalancer in such a way that it prefers the instance with instanceId provided in a request cookie. We currently support this if the request is being passed to the LoadBalancer through either ClientRequestContext or ServerHttpRequestContext, which are used by the SC LoadBalancer exchange filter functions and filters.

Copy link
Contributor

Choose a reason for hiding this comment

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

As a rule, you can nearly always remove "will" and use the indefinite present (also called the simple present). Doing so makes the doc more consistent with our other docs and often makes the sentences shorter.


For that, you will need to use the `RequestBasedStickySessionServiceInstanceListSupplier`. It can be configured either by setting the value of `spring.cloud.loadbalancer.configurations` to `request-based-sticky-session` or by providing your own `ServiceInstanceListSupplier` bean, for example:
Copy link
Contributor

Choose a reason for hiding this comment

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

Change to:

For that, you need to use the RequestBasedStickySessionServiceInstanceListSupplier. You can configure it either by setting the value of spring.cloud.loadbalancer.configurations to request-based-sticky-session or by providing your own ServiceInstanceListSupplier bean -- for example:


[[health-check-based-custom-loadbalancer-configuration]]
[source,java,indent=0]
----
public class CustomLoadBalancerConfiguration {

@Bean
public ServiceInstanceListSupplier discoveryClientServiceInstanceListSupplier(
ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder()
.withDiscoveryClient()
.withRequestBasedStickySession()
.build(context);
}
}
----

For that functionality, it will be useful to have the selected service instance (that can be different from the one in the original request cookie in case that one was not available) to be updated before sending the request forward. In order to do that set the value of `spring.cloud.loadbalancer.sticky-session.add-service-instance-cookie` to `true`.
Copy link
Contributor

Choose a reason for hiding this comment

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

For that functionality, it is useful to have the selected service instance (which can be different from the one in the original request cookie if that one is not available) to be updated before sending the request forward. To do that, set the value of spring.cloud.loadbalancer.sticky-session.add-service-instance-cookie to true.

Choose a reason for hiding this comment

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

Hi @Buzzardo
Do we need to add spring.cloud.loadbalancer.sticky-session.add-service-instance-cookie in cloud gateway or in service which needs sticky session configuration?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

in GW, so that the selected instance is passed or in the LB client, if the instance is selected there directly (without routing)


By default, the name of the cookie is `sc-lb-instance-id`. It can be modified by changing the value of `spring.cloud.loadbalancer.instance-id-cookie-name` property.
Copy link
Contributor

Choose a reason for hiding this comment

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

Change to:

By default, the name of the cookie is sc-lb-instance-id. You can modify it by changing the value of the spring.cloud.loadbalancer.instance-id-cookie-name property.


[[spring-cloud-loadbalancer-hints]]
=== Spring Cloud LoadBalancer Hints

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import java.net.URI;
import java.util.Map;

import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;

Expand All @@ -40,10 +42,17 @@ static String getHint(String serviceId, Map<String, String> hints) {
return hintPropertyValue != null ? hintPropertyValue : defaultHint;
}

static ClientRequest buildClientRequest(ClientRequest request, URI uri) {
return ClientRequest.create(request.method(), uri).headers(headers -> headers.addAll(request.headers()))
.cookies(cookies -> cookies.addAll(request.cookies()))
.attributes(attributes -> attributes.putAll(request.attributes())).body(request.body()).build();
static ClientRequest buildClientRequest(ClientRequest request, ServiceInstance serviceInstance,
String instanceIdCookieName, boolean addServiceInstanceCookie) {
URI originalUrl = request.url();
return ClientRequest.create(request.method(), LoadBalancerUriTools.reconstructURI(serviceInstance, originalUrl))
.headers(headers -> headers.addAll(request.headers())).cookies(cookies -> {
cookies.addAll(request.cookies());
if (!(instanceIdCookieName == null || instanceIdCookieName.length() == 0)
&& addServiceInstanceCookie) {
cookies.add(instanceIdCookieName, serviceInstance.getInstanceId());
}
}).attributes(attributes -> attributes.putAll(request.attributes())).body(request.body()).build();
}

static String serviceInstanceUnavailableMessage(String serviceId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ public class LoadBalancerProperties {
*/
private Retry retry = new Retry();

/**
* Properties for LoadBalancer sticky-session.
*/
private StickySession stickySession = new StickySession();

public HealthCheck getHealthCheck() {
return healthCheck;
}
Expand All @@ -77,6 +82,45 @@ public void setRetry(Retry retry) {
this.retry = retry;
}

public StickySession getStickySession() {
return stickySession;
}

public void setStickySession(StickySession stickySession) {
this.stickySession = stickySession;
}

public static class StickySession {

/**
* The name of the cookie holding the preferred instance id.
*/
private String instanceIdCookieName = "sc-lb-instance-id";

/**
* Indicates whether a cookie with the newly selected instance should be added by
* SC LoadBalancer.
*/
private boolean addServiceInstanceCookie = false;

public String getInstanceIdCookieName() {
return instanceIdCookieName;
}

public void setInstanceIdCookieName(String instanceIdCookieName) {
this.instanceIdCookieName = instanceIdCookieName;
}

public boolean isAddServiceInstanceCookie() {
return addServiceInstanceCookie;
}

public void setAddServiceInstanceCookie(boolean addServiceInstanceCookie) {
this.addServiceInstanceCookie = addServiceInstanceCookie;
}

}

public static class HealthCheck {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;

import static org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools.reconstructURI;
import static org.springframework.cloud.client.loadbalancer.reactive.ExchangeFilterFunctionUtils.buildClientRequest;
import static org.springframework.cloud.client.loadbalancer.reactive.ExchangeFilterFunctionUtils.getHint;
import static org.springframework.cloud.client.loadbalancer.reactive.ExchangeFilterFunctionUtils.serviceInstanceUnavailableMessage;
Expand Down Expand Up @@ -101,7 +100,10 @@ public Mono<ClientResponse> filter(ClientRequest clientRequest, ExchangeFunction
LOG.debug(String.format("LoadBalancer has retrieved the instance for service %s: %s", serviceId,
instance.getUri()));
}
ClientRequest newRequest = buildClientRequest(clientRequest, reconstructURI(instance, originalUrl));
LoadBalancerProperties.StickySession stickySessionProperties = properties.getStickySession();
ClientRequest newRequest = buildClientRequest(clientRequest, instance,
stickySessionProperties.getInstanceIdCookieName(),
stickySessionProperties.isAddServiceInstanceCookie());
return next.exchange(newRequest)
.doOnError(throwable -> supportedLifecycleProcessors.forEach(
lifecycle -> lifecycle.onComplete(new CompletionContext<ClientResponse, ServiceInstance>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;

import static org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools.reconstructURI;
import static org.springframework.cloud.client.loadbalancer.reactive.ExchangeFilterFunctionUtils.buildClientRequest;
import static org.springframework.cloud.client.loadbalancer.reactive.ExchangeFilterFunctionUtils.getHint;
import static org.springframework.cloud.client.loadbalancer.reactive.ExchangeFilterFunctionUtils.serviceInstanceUnavailableMessage;
Expand Down Expand Up @@ -123,14 +122,17 @@ public Mono<ClientResponse> filter(ClientRequest clientRequest, ExchangeFunction
LOG.debug(String.format("LoadBalancer has retrieved the instance for service %s: %s", serviceId,
instance.getUri()));
}
ClientRequest newRequest = buildClientRequest(clientRequest, reconstructURI(instance, originalUrl));
LoadBalancerProperties.StickySession stickySessionProperties = properties.getStickySession();
ClientRequest newRequest = buildClientRequest(clientRequest, instance,
stickySessionProperties.getInstanceIdCookieName(),
stickySessionProperties.isAddServiceInstanceCookie());
return next.exchange(newRequest)
.doOnError(throwable -> supportedLifecycleProcessors.forEach(
lifecycle -> lifecycle.onComplete(new CompletionContext<ClientResponse, ServiceInstance>(
CompletionContext.Status.FAILED, throwable, lbResponse))))
.doOnSuccess(clientResponse -> supportedLifecycleProcessors.forEach(
lifecycle -> lifecycle.onComplete(new CompletionContext<ClientResponse, ServiceInstance>(
CompletionContext.Status.SUCCESS, lbResponse, clientResponse))))
lifecycle -> lifecycle.onComplete(new CompletionContext<>(CompletionContext.Status.SUCCESS,
lbResponse, clientResponse))))
.map(clientResponse -> {
loadBalancerRetryContext.setClientResponse(clientResponse);
if (shouldRetrySameServiceInstance(loadBalancerRetryContext)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ public ServiceInstanceListSupplier healthCheckDiscoveryClientServiceInstanceList
return ServiceInstanceListSupplier.builder().withDiscoveryClient().withHealthChecks().build(context);
}

@Bean
@ConditionalOnBean(ReactiveDiscoveryClient.class)
@ConditionalOnMissingBean
@ConditionalOnProperty(value = "spring.cloud.loadbalancer.configurations",
havingValue = "request-based-sticky-session")
public ServiceInstanceListSupplier requestBasedStickySessionDiscoveryClientServiceInstanceListSupplier(
ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder().withDiscoveryClient().withRequestBasedStickySession()
.build(context);
}

}

@Configuration(proxyBeanMethods = false)
Expand Down Expand Up @@ -135,6 +146,17 @@ public ServiceInstanceListSupplier healthCheckDiscoveryClientServiceInstanceList
.build(context);
}

@Bean
@ConditionalOnBean(DiscoveryClient.class)
@ConditionalOnMissingBean
@ConditionalOnProperty(value = "spring.cloud.loadbalancer.configurations",
havingValue = "request-based-sticky-session")
public ServiceInstanceListSupplier requestBasedStickySessionDiscoveryClientServiceInstanceListSupplier(
ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder().withBlockingDiscoveryClient().withRequestBasedStickySession()
.build(context);
}

}

@Configuration(proxyBeanMethods = false)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* Copyright 2012-2020 the original author or 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
*
* https://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 org.springframework.cloud.loadbalancer.core;

import java.util.Collections;
import java.util.List;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Flux;

import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.ClientRequestContext;
import org.springframework.cloud.client.loadbalancer.Request;
import org.springframework.cloud.client.loadbalancer.ServerHttpRequestContext;
import org.springframework.cloud.client.loadbalancer.reactive.LoadBalancerProperties;
import org.springframework.http.HttpCookie;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.web.reactive.function.client.ClientRequest;

/**
* A session cookie based implementation of {@link ServiceInstanceListSupplier} that gives
* preference to the instance with an id specified in a request cookie.
*
* @author Olga Maciaszek-Sharma
* @since 3.0.0
*/
public class RequestBasedStickySessionServiceInstanceListSupplier extends DelegatingServiceInstanceListSupplier {

private static final Log LOG = LogFactory.getLog(RequestBasedStickySessionServiceInstanceListSupplier.class);

private final LoadBalancerProperties properties;

public RequestBasedStickySessionServiceInstanceListSupplier(ServiceInstanceListSupplier delegate,
LoadBalancerProperties properties) {
super(delegate);
this.properties = properties;
}

@Override
public String getServiceId() {
return delegate.getServiceId();
}

@Override
public Flux<List<ServiceInstance>> get() {
return delegate.get();
}

@SuppressWarnings("rawtypes")
@Override
public Flux<List<ServiceInstance>> get(Request request) {
String instanceIdCookieName = properties.getStickySession().getInstanceIdCookieName();
Object context = request.getContext();
if ((context instanceof ClientRequestContext)) {
ClientRequest originalRequest = ((ClientRequestContext) context).getClientRequest();
// We expect there to be one value in this cookie
String cookie = originalRequest.cookies().getFirst(instanceIdCookieName);
if (cookie != null) {
return get().map(serviceInstances -> selectInstance(serviceInstances, cookie));
}
if (LOG.isDebugEnabled()) {
LOG.debug("Cookie not found. Returning all instances returned by delegate.");
}
return get();
}
if ((context instanceof ServerHttpRequestContext)) {
ServerHttpRequest originalRequest = ((ServerHttpRequestContext) context).getClientRequest();
HttpCookie cookie = originalRequest.getCookies().getFirst(instanceIdCookieName);
if (cookie != null) {
return get().map(serviceInstances -> selectInstance(serviceInstances, cookie.getValue()));
}
if (LOG.isDebugEnabled()) {
LOG.debug("Cookie not found. Returning all instances returned by delegate.");
}
return get();
}
if (LOG.isDebugEnabled()) {
LOG.debug("Searching for instances based on cookie not supported for ClientRequestContext type."
+ " Returning all instances returned by delegate.");
}
// If no cookie is available, we return all the instances provided by the
// delegate.
return get();
}

private List<ServiceInstance> selectInstance(List<ServiceInstance> serviceInstances, String cookie) {
for (ServiceInstance serviceInstance : serviceInstances) {
if (cookie.equals(serviceInstance.getInstanceId())) {
if (LOG.isDebugEnabled()) {
LOG.debug(String.format("Returning the service instance: %s. Found for cookie: %s",
serviceInstance.toString(), cookie));
}
return Collections.singletonList(serviceInstance);
}
}
// If the instances cannot be found based on the cookie,
// we return all the instances provided by the delegate.
if (LOG.isDebugEnabled()) {
LOG.debug(String.format(
"Service instance for cookie: %s not found. Returning all instances returned by delegate.",
cookie));
}
return serviceInstances;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,20 @@ public ServiceInstanceListSupplierBuilder withZonePreference() {
return this;
}

/**
* Adds a {@link RequestBasedStickySessionServiceInstanceListSupplier} to the
* {@link ServiceInstanceListSupplier} hierarchy.
* @return the {@link ServiceInstanceListSupplierBuilder} object
*/
public ServiceInstanceListSupplierBuilder withRequestBasedStickySession() {
DelegateCreator creator = (context, delegate) -> {
LoadBalancerProperties properties = context.getBean(LoadBalancerProperties.class);
return new RequestBasedStickySessionServiceInstanceListSupplier(delegate, properties);
};
this.creators.add(creator);
return this;
}

/**
* If {@link LoadBalancerCacheManager} is available in the context, wraps created
* {@link ServiceInstanceListSupplier} hierarchy with a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import org.springframework.cloud.loadbalancer.core.DelegatingServiceInstanceListSupplier;
import org.springframework.cloud.loadbalancer.core.DiscoveryClientServiceInstanceListSupplier;
import org.springframework.cloud.loadbalancer.core.HealthCheckServiceInstanceListSupplier;
import org.springframework.cloud.loadbalancer.core.RequestBasedStickySessionServiceInstanceListSupplier;
import org.springframework.cloud.loadbalancer.core.RetryAwareServiceInstanceListSupplier;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.cloud.loadbalancer.core.ZonePreferenceServiceInstanceListSupplier;
Expand Down Expand Up @@ -111,6 +112,19 @@ void shouldInstantiateHealthCheckServiceInstanceListSupplier() {
});
}

@Test
void shouldInstantiateRequestBasedStickySessionServiceInstanceListSupplierTests() {
reactiveDiscoveryClientRunner.withUserConfiguration(TestConfig.class)
.withPropertyValues("spring.cloud.loadbalancer.configurations=request-based-sticky-session")
.run(context -> {
ServiceInstanceListSupplier supplier = context.getBean(ServiceInstanceListSupplier.class);
then(supplier).isInstanceOf(RequestBasedStickySessionServiceInstanceListSupplier.class);
ServiceInstanceListSupplier delegate = ((DelegatingServiceInstanceListSupplier) supplier)
.getDelegate();
then(delegate).isInstanceOf(DiscoveryClientServiceInstanceListSupplier.class);
});
}

@Test
void shouldInstantiateDefaultBlockingServiceInstanceListSupplierWhenConfigurationsPropertyNotSet() {
blockingDiscoveryClientRunner.run(context -> {
Expand Down
Loading