Request based sticky session (#860)

* Implement first draft for sticky-session load-balancing.

* Make setting instance cookie opt-in.

* Add tests.

* Make adding request cookie opt-in.

* Add docs and javadocs.

* Add default configuration with blocking discovery client.

* Fix docs after review.
This commit is contained in:
Olga Maciaszek-Sharma
2020-12-07 06:08:06 -06:00
committed by GitHub
parent c3223934f8
commit 0f98419c47
12 changed files with 440 additions and 20 deletions

View File

@@ -98,6 +98,17 @@ public class LoadBalancerClientConfiguration {
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);
}
@Bean
@ConditionalOnBean(ReactiveDiscoveryClient.class)
@ConditionalOnMissingBean
@@ -146,6 +157,17 @@ public class LoadBalancerClientConfiguration {
.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);
}
@Bean
@ConditionalOnBean(DiscoveryClient.class)
@ConditionalOnMissingBean

View File

@@ -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;
}
}

View File

@@ -157,6 +157,20 @@ public final class ServiceInstanceListSupplierBuilder {
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

View File

@@ -30,6 +30,7 @@ import org.springframework.cloud.loadbalancer.core.CachingServiceInstanceListSup
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;
@@ -111,6 +112,19 @@ class LoadBalancerClientConfigurationTests {
});
}
@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 -> {

View File

@@ -0,0 +1,164 @@
/*
* 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.Arrays;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import org.springframework.cloud.client.DefaultServiceInstance;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.ClientRequestContext;
import org.springframework.cloud.client.loadbalancer.DefaultRequest;
import org.springframework.cloud.client.loadbalancer.DefaultRequestContext;
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.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.client.ClientRequest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Tests for {@link RequestBasedStickySessionServiceInstanceListSupplier}.
*
* @author Olga Maciaszek-Sharma
*/
class RequestBasedStickySessionServiceInstanceListSupplierTests {
private final DiscoveryClientServiceInstanceListSupplier delegate = mock(
DiscoveryClientServiceInstanceListSupplier.class);
private final LoadBalancerProperties properties = new LoadBalancerProperties();
private final RequestBasedStickySessionServiceInstanceListSupplier supplier = new RequestBasedStickySessionServiceInstanceListSupplier(
delegate, properties);
private final ClientRequest clientRequest = mock(ClientRequest.class);
private final ServerHttpRequest serverHttpRequest = mock(ServerHttpRequest.class);
private final ServiceInstance first = serviceInstance("test-1");
private final ServiceInstance second = serviceInstance("test-2");
private final ServiceInstance third = serviceInstance("test-3");
@BeforeEach
void setUp() {
when(delegate.get()).thenReturn(Flux.just(Arrays.asList(first, second, third)));
}
@Test
void shouldReturnInstanceBasedOnCookieFromClientRequest() {
HttpHeaders headers = new HttpHeaders();
headers.add(properties.getStickySession().getInstanceIdCookieName(), "test-1");
when(clientRequest.cookies()).thenReturn(headers);
Request<ClientRequestContext> request = new DefaultRequest<>(new ClientRequestContext(clientRequest));
List<ServiceInstance> serviceInstances = supplier.get(request).blockFirst();
assertThat(serviceInstances).hasSize(1);
assertThat(serviceInstances.get(0).getInstanceId()).isEqualTo("test-1");
}
@Test
void shouldReturnAllDelegateInstancesIfInstanceBasedOnCookieFromClientRequestNotFound() {
HttpHeaders headers = new HttpHeaders();
headers.add(properties.getStickySession().getInstanceIdCookieName(), "test-4");
when(clientRequest.cookies()).thenReturn(headers);
Request<ClientRequestContext> request = new DefaultRequest<>(new ClientRequestContext(clientRequest));
List<ServiceInstance> serviceInstances = supplier.get(request).blockFirst();
assertThat(serviceInstances).hasSize(3);
}
@Test
void shouldReturnAllInstancesFromDelegateIfClientRequestHasNoCookie() {
when(clientRequest.cookies()).thenReturn(new HttpHeaders());
Request<ClientRequestContext> request = new DefaultRequest<>(new ClientRequestContext(clientRequest));
List<ServiceInstance> serviceInstances = supplier.get(request).blockFirst();
assertThat(serviceInstances).hasSize(3);
}
@Test
void shouldReturnInstanceBasedOnCookieFromServerHttpRequest() {
MultiValueMap<String, HttpCookie> cookies = new LinkedMultiValueMap<>();
cookies.add(properties.getStickySession().getInstanceIdCookieName(),
new HttpCookie(properties.getStickySession().getInstanceIdCookieName(), "test-1"));
when(serverHttpRequest.getCookies()).thenReturn(cookies);
Request<ServerHttpRequestContext> request = new DefaultRequest<>(
new ServerHttpRequestContext(serverHttpRequest));
List<ServiceInstance> serviceInstances = supplier.get(request).blockFirst();
assertThat(serviceInstances).hasSize(1);
assertThat(serviceInstances.get(0).getInstanceId()).isEqualTo("test-1");
}
@Test
void shouldReturnAllDelegateInstancesIfInstanceBasedOnCookieFromServerHttpRequestNotFound() {
MultiValueMap<String, HttpCookie> cookies = new LinkedMultiValueMap<>();
cookies.add(properties.getStickySession().getInstanceIdCookieName(),
new HttpCookie(properties.getStickySession().getInstanceIdCookieName(), "test-4"));
when(serverHttpRequest.getCookies()).thenReturn(cookies);
Request<ServerHttpRequestContext> request = new DefaultRequest<>(
new ServerHttpRequestContext(serverHttpRequest));
List<ServiceInstance> serviceInstances = supplier.get(request).blockFirst();
assertThat(serviceInstances).hasSize(3);
}
@Test
void shouldReturnAllInstancesFromDelegateIfServerHttpRequestHasNoCookie() {
MultiValueMap<String, HttpCookie> cookies = new LinkedMultiValueMap<>();
when(serverHttpRequest.getCookies()).thenReturn(cookies);
Request<ServerHttpRequestContext> request = new DefaultRequest<>(
new ServerHttpRequestContext(serverHttpRequest));
List<ServiceInstance> serviceInstances = supplier.get(request).blockFirst();
assertThat(serviceInstances).hasSize(3);
}
@Test
void shouldReturnAllInstancesFromDelegateIfNotSupportedRequestContext() {
Request<DefaultRequestContext> request = new DefaultRequest<>(new DefaultRequestContext(clientRequest));
List<ServiceInstance> serviceInstances = supplier.get(request).blockFirst();
assertThat(serviceInstances).hasSize(3);
}
private DefaultServiceInstance serviceInstance(String instanceId) {
return new DefaultServiceInstance(instanceId, "test", "http://test.test", 9080, false);
}
}

View File

@@ -41,23 +41,23 @@ import static org.mockito.Mockito.when;
*/
class ZonePreferenceServiceInstanceListSupplierTests {
private DiscoveryClientServiceInstanceListSupplier delegate = mock(
private final DiscoveryClientServiceInstanceListSupplier delegate = mock(
DiscoveryClientServiceInstanceListSupplier.class);
private LoadBalancerZoneConfig zoneConfig = new LoadBalancerZoneConfig(null);
private final LoadBalancerZoneConfig zoneConfig = new LoadBalancerZoneConfig(null);
private ZonePreferenceServiceInstanceListSupplier supplier = new ZonePreferenceServiceInstanceListSupplier(delegate,
zoneConfig);
private final ZonePreferenceServiceInstanceListSupplier supplier = new ZonePreferenceServiceInstanceListSupplier(
delegate, zoneConfig);
private ServiceInstance first = serviceInstance("test-1", buildZoneMetadata("zone1"));
private final ServiceInstance first = serviceInstance("test-1", buildZoneMetadata("zone1"));
private ServiceInstance second = serviceInstance("test-2", buildZoneMetadata("zone1"));
private final ServiceInstance second = serviceInstance("test-2", buildZoneMetadata("zone1"));
private ServiceInstance third = serviceInstance("test-3", buildZoneMetadata("zone2"));
private final ServiceInstance third = serviceInstance("test-3", buildZoneMetadata("zone2"));
private ServiceInstance fourth = serviceInstance("test-4", buildZoneMetadata("zone3"));
private final ServiceInstance fourth = serviceInstance("test-4", buildZoneMetadata("zone3"));
private ServiceInstance fifth = serviceInstance("test-5", buildZoneMetadata(null));
private final ServiceInstance fifth = serviceInstance("test-5", buildZoneMetadata(null));
@Test
void shouldFilterInstancesByZone() {
@@ -103,7 +103,7 @@ class ZonePreferenceServiceInstanceListSupplierTests {
}
private DefaultServiceInstance serviceInstance(String instanceId, Map<String, String> metadata) {
return new DefaultServiceInstance("test", instanceId, "http://test.test", 9080, false, metadata);
return new DefaultServiceInstance(instanceId, "test", "http://test.test", 9080, false, metadata);
}
private Map<String, String> buildZoneMetadata(String zone) {