diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java index 5973cd8383..67341b9da2 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java @@ -36,6 +36,7 @@ import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.boot.convert.DurationUnit; import org.springframework.boot.web.server.Compression; import org.springframework.boot.web.server.Http2; +import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.Ssl; import org.springframework.boot.web.servlet.server.Encoding; import org.springframework.boot.web.servlet.server.Jsp; @@ -114,6 +115,9 @@ public class ServerProperties { @NestedConfigurationProperty private final Http2 http2 = new Http2(); + @NestedConfigurationProperty + private final Shutdown shutdown = new Shutdown(); + private final Servlet servlet = new Servlet(); private final Tomcat tomcat = new Tomcat(); @@ -199,6 +203,10 @@ public class ServerProperties { return this.http2; } + public Shutdown getShutdown() { + return this.shutdown; + } + public Servlet getServlet() { return this.servlet; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryCustomizer.java index c597ce099e..8d15e4db4a 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryCustomizer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryCustomizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -52,6 +52,7 @@ public class ReactiveWebServerFactoryCustomizer map.from(this.serverProperties::getSsl).to(factory::setSsl); map.from(this.serverProperties::getCompression).to(factory::setCompression); map.from(this.serverProperties::getHttp2).to(factory::setHttp2); + map.from(this.serverProperties.getShutdown()).to(factory::setShutdown); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizer.java index e05f921910..1c232f5006 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -60,6 +60,7 @@ public class ServletWebServerFactoryCustomizer map.from(this.serverProperties::getHttp2).to(factory::setHttp2); map.from(this.serverProperties::getServerHeader).to(factory::setServerHeader); map.from(this.serverProperties.getServlet()::getContextParameters).to(factory::setInitParameters); + map.from(this.serverProperties.getShutdown()).to(factory::setShutdown); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryCustomizerTests.java index e3c676f4fd..c806b58ca9 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryCustomizerTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryCustomizerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -17,14 +17,18 @@ package org.springframework.boot.autoconfigure.web.reactive; import java.net.InetAddress; +import java.time.Duration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.web.reactive.server.ConfigurableReactiveWebServerFactory; +import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.Ssl; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -71,4 +75,14 @@ class ReactiveWebServerFactoryCustomizerTests { verify(factory).setSsl(ssl); } + @Test + void whenGracePeriodPropertyIsSetThenGracePeriodIsCustomized() { + this.properties.getShutdown().setGracePeriod(Duration.ofSeconds(30)); + ConfigurableReactiveWebServerFactory factory = mock(ConfigurableReactiveWebServerFactory.class); + this.customizer.customize(factory); + ArgumentCaptor shutdownCaptor = ArgumentCaptor.forClass(Shutdown.class); + verify(factory).setShutdown(shutdownCaptor.capture()); + assertThat(shutdownCaptor.getValue().getGracePeriod()).isEqualTo(Duration.ofSeconds(30)); + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizerTests.java index f142f60610..ef6d3e0bf2 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizerTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizerTests.java @@ -17,6 +17,7 @@ package org.springframework.boot.autoconfigure.web.servlet; import java.io.File; +import java.time.Duration; import java.util.HashMap; import java.util.Map; @@ -29,6 +30,7 @@ import org.springframework.boot.context.properties.bind.Bindable; import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.context.properties.source.ConfigurationPropertySource; import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.Ssl; import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory; import org.springframework.boot.web.servlet.server.Jsp; @@ -154,6 +156,18 @@ class ServletWebServerFactoryCustomizerTests { assertThat(sessionCaptor.getValue().getStoreDir()).isEqualTo(new File("myfolder")); } + @Test + void whenGracePeriodPropertyIsSetThenGracePeriodIsCustomized() { + Map map = new HashMap<>(); + map.put("server.shutdown.grace-period", "30s"); + bindProperties(map); + ConfigurableServletWebServerFactory factory = mock(ConfigurableServletWebServerFactory.class); + this.customizer.customize(factory); + ArgumentCaptor shutdownCaptor = ArgumentCaptor.forClass(Shutdown.class); + verify(factory).setShutdown(shutdownCaptor.capture()); + assertThat(shutdownCaptor.getValue().getGracePeriod()).isEqualTo(Duration.ofSeconds(30)); + } + private void bindProperties(Map map) { ConfigurationPropertySource source = new MapConfigurationPropertySource(map); new Binder(source).bind("server", Bindable.ofInstance(this.properties)); diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/spring-boot-features.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/spring-boot-features.adoc index 1f5191db4b..743b88d52c 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/spring-boot-features.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/spring-boot-features.adoc @@ -2960,6 +2960,26 @@ You can learn more about the resource configuration on the client side in the << +[[boot-features-graceful-shutdown]] +== Graceful shutdown +Graceful shutdown is supported with all four embedded web servers (Jetty, Reactor Netty, Tomcat, and Undertow) and with both reactive and Servlet-based web applications. +When enabled, shutdown of the application will include a grace period of configurable duration. +During this grace period, existing requests will be allowed to complete but no new requests will be permitted. +The exact way in which new requests are not permitted varies depending on the web server that is being used. +Jetty, Reactor Netty, and Tomcat will stop accepting requests at the network layer. +Undertow will accept requests but respond immediately with a service unavailable (503) response. + +Graceful shutdown occurs as one of the first steps during application close processing and before any beans have been destroyed. +This ensures that the beans are available for use by any processing that occurs while in-flight requests are being allowed to complete. +To enable graceful shutdown, configure the configprop:server.shutdown.grace-period[] property, as shown in the following example: + +[source,properties,indent=0,configprops] +---- +server.shutdown.grace-period=30s +---- + + + [[boot-features-rsocket]] == RSocket https://rsocket.io[RSocket] is a binary protocol for use on byte stream transports. diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyGracefulShutdown.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyGracefulShutdown.java new file mode 100644 index 0000000000..52c0f813fd --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyGracefulShutdown.java @@ -0,0 +1,87 @@ +/* + * 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.boot.web.embedded.jetty; + +import java.time.Duration; +import java.util.function.Supplier; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.eclipse.jetty.server.Connector; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; + +import org.springframework.boot.web.server.GracefulShutdown; + +/** + * {@link GracefulShutdown} for Jetty. + * + * @author Andy Wilkinson + */ +class JettyGracefulShutdown implements GracefulShutdown { + + private static final Log logger = LogFactory.getLog(JettyGracefulShutdown.class); + + private final Server server; + + private final Supplier activeRequests; + + private final Duration period; + + private volatile boolean shuttingDown = false; + + JettyGracefulShutdown(Server server, Supplier activeRequests, Duration period) { + this.server = server; + this.activeRequests = activeRequests; + this.period = period; + } + + @Override + public boolean shutDownGracefully() { + logger.info("Commencing graceful shutdown, allowing up to " + this.period.getSeconds() + + "s for active requests to complete"); + for (Connector connector : this.server.getConnectors()) { + ((ServerConnector) connector).setAccepting(false); + } + this.shuttingDown = true; + long end = System.currentTimeMillis() + this.period.toMillis(); + while (System.currentTimeMillis() < end && (this.activeRequests.get() > 0)) { + try { + Thread.sleep(100); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + this.shuttingDown = false; + long activeRequests = this.activeRequests.get(); + if (activeRequests == 0) { + logger.info("Graceful shutdown complete"); + return true; + } + if (logger.isInfoEnabled()) { + logger.info("Grace period elaped with " + activeRequests + " request(s) still active"); + } + return activeRequests == 0; + } + + @Override + public boolean isShuttingDown() { + return this.shuttingDown; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java index df883ab348..58485d543e 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -103,7 +103,7 @@ public class JettyReactiveWebServerFactory extends AbstractReactiveWebServerFact public WebServer getWebServer(HttpHandler httpHandler) { JettyHttpHandlerAdapter servlet = new JettyHttpHandlerAdapter(httpHandler); Server server = createJettyServer(servlet); - return new JettyWebServer(server, getPort() >= 0); + return new JettyWebServer(server, getPort() >= 0, getShutdown().getGracePeriod()); } @Override diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java index 214370a673..211d943fb4 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java @@ -398,7 +398,7 @@ public class JettyServletWebServerFactory extends AbstractServletWebServerFactor * @return a new {@link JettyWebServer} instance */ protected JettyWebServer getJettyWebServer(Server server) { - return new JettyWebServer(server, getPort() >= 0); + return new JettyWebServer(server, getPort() >= 0, getShutdown().getGracePeriod()); } @Override diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyWebServer.java index d174f2fc56..20d1c3c17d 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyWebServer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyWebServer.java @@ -18,6 +18,7 @@ package org.springframework.boot.web.embedded.jetty; import java.io.IOException; import java.net.BindException; +import java.time.Duration; import java.util.Arrays; import java.util.List; import java.util.Objects; @@ -32,8 +33,11 @@ import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.handler.ContextHandler; import org.eclipse.jetty.server.handler.HandlerCollection; import org.eclipse.jetty.server.handler.HandlerWrapper; +import org.eclipse.jetty.server.handler.StatisticsHandler; import org.eclipse.jetty.util.component.AbstractLifeCycle; +import org.springframework.boot.web.server.GracefulShutdown; +import org.springframework.boot.web.server.ImmediateGracefulShutdown; import org.springframework.boot.web.server.PortInUseException; import org.springframework.boot.web.server.WebServer; import org.springframework.boot.web.server.WebServerException; @@ -63,6 +67,8 @@ public class JettyWebServer implements WebServer { private final boolean autoStart; + private final GracefulShutdown gracefulShutdown; + private Connector[] connectors; private volatile boolean started; @@ -81,9 +87,31 @@ public class JettyWebServer implements WebServer { * @param autoStart if auto-starting the server */ public JettyWebServer(Server server, boolean autoStart) { + this(server, autoStart, null); + } + + /** + * Create a new {@link JettyWebServer} instance. + * @param server the underlying Jetty server + * @param autoStart if auto-starting the server + * @param shutdownGracePeriod grace period to use when shutting down + * @since 2.3.0 + */ + public JettyWebServer(Server server, boolean autoStart, Duration shutdownGracePeriod) { this.autoStart = autoStart; Assert.notNull(server, "Jetty Server must not be null"); this.server = server; + GracefulShutdown gracefulShutdown = null; + if (shutdownGracePeriod != null) { + StatisticsHandler handler = new StatisticsHandler(); + handler.setHandler(server.getHandler()); + server.setHandler(handler); + gracefulShutdown = new JettyGracefulShutdown(server, handler::getRequestsActive, shutdownGracePeriod); + } + else { + gracefulShutdown = new ImmediateGracefulShutdown(); + } + this.gracefulShutdown = gracefulShutdown; initialize(); } @@ -261,6 +289,15 @@ public class JettyWebServer implements WebServer { return 0; } + @Override + public boolean shutDownGracefully() { + return this.gracefulShutdown.shutDownGracefully(); + } + + boolean inGracefulShutdown() { + return this.gracefulShutdown.isShuttingDown(); + } + /** * Returns access to the underlying Jetty Server. * @return the Jetty server diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyGracefulShutdown.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyGracefulShutdown.java new file mode 100644 index 0000000000..40c5182549 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyGracefulShutdown.java @@ -0,0 +1,116 @@ +/* + * 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.boot.web.embedded.netty; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BiFunction; +import java.util.function.Supplier; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; +import reactor.netty.DisposableServer; +import reactor.netty.http.server.HttpServerRequest; +import reactor.netty.http.server.HttpServerResponse; + +import org.springframework.boot.web.server.GracefulShutdown; +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; + +/** + * {@link GracefulShutdown} for a Reactor Netty {@link DisposableServer}. + * + * @author Andy Wilkinson + */ +final class NettyGracefulShutdown implements GracefulShutdown { + + private static final Log logger = LogFactory.getLog(NettyGracefulShutdown.class); + + private final Supplier disposableServer; + + private final Duration lifecycleTimeout; + + private final Duration period; + + private final AtomicLong activeRequests = new AtomicLong(); + + private volatile boolean shuttingDown; + + NettyGracefulShutdown(Supplier disposableServer, Duration lifecycleTimeout, Duration period) { + this.disposableServer = disposableServer; + this.lifecycleTimeout = lifecycleTimeout; + this.period = period; + } + + @Override + public boolean shutDownGracefully() { + logger.info("Commencing graceful shutdown, allowing up to " + this.period.getSeconds() + + "s for active requests to complete"); + DisposableServer server = this.disposableServer.get(); + if (server == null) { + return false; + } + if (this.lifecycleTimeout != null) { + server.disposeNow(this.lifecycleTimeout); + } + else { + server.disposeNow(); + } + this.shuttingDown = true; + long end = System.currentTimeMillis() + this.period.toMillis(); + try { + while (this.activeRequests.get() > 0 && System.currentTimeMillis() < end) { + try { + Thread.sleep(50); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + break; + } + } + long activeRequests = this.activeRequests.get(); + if (activeRequests == 0) { + logger.info("Graceful shutdown complete"); + return true; + } + if (logger.isInfoEnabled()) { + logger.info("Grace period elaped with " + activeRequests + " request(s) still active"); + } + return false; + } + finally { + this.shuttingDown = false; + } + } + + @Override + public boolean isShuttingDown() { + return this.shuttingDown; + } + + BiFunction> wrapHandler( + ReactorHttpHandlerAdapter handlerAdapter) { + if (this.period == null) { + return handlerAdapter; + } + return (request, response) -> { + this.activeRequests.incrementAndGet(); + return handlerAdapter.apply(request, response).doOnTerminate(() -> this.activeRequests.decrementAndGet()); + }; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java index 6a88a1a65f..0bc774f026 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -31,6 +31,7 @@ import reactor.netty.resources.LoopResources; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory; import org.springframework.boot.web.reactive.server.ReactiveWebServerFactory; +import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.WebServer; import org.springframework.http.client.reactive.ReactorResourceFactory; import org.springframework.http.server.reactive.HttpHandler; @@ -55,6 +56,8 @@ public class NettyReactiveWebServerFactory extends AbstractReactiveWebServerFact private ReactorResourceFactory resourceFactory; + private Shutdown shutdown; + public NettyReactiveWebServerFactory() { } @@ -66,7 +69,8 @@ public class NettyReactiveWebServerFactory extends AbstractReactiveWebServerFact public WebServer getWebServer(HttpHandler httpHandler) { HttpServer httpServer = createHttpServer(); ReactorHttpHandlerAdapter handlerAdapter = new ReactorHttpHandlerAdapter(httpHandler); - NettyWebServer webServer = new NettyWebServer(httpServer, handlerAdapter, this.lifecycleTimeout); + NettyWebServer webServer = new NettyWebServer(httpServer, handlerAdapter, this.lifecycleTimeout, + (this.shutdown == null) ? null : this.shutdown.getGracePeriod()); webServer.setRouteProviders(this.routeProviders); return webServer; } @@ -136,6 +140,16 @@ public class NettyReactiveWebServerFactory extends AbstractReactiveWebServerFact this.resourceFactory = resourceFactory; } + @Override + public void setShutdown(Shutdown shutdown) { + this.shutdown = shutdown; + } + + @Override + public Shutdown getShutdown() { + return this.shutdown; + } + private HttpServer createHttpServer() { HttpServer server = HttpServer.create(); if (this.resourceFactory != null) { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java index 6e4b500aec..7e7f31ff15 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -19,16 +19,21 @@ package org.springframework.boot.web.embedded.netty; import java.time.Duration; import java.util.Collections; import java.util.List; +import java.util.function.BiFunction; import java.util.function.Predicate; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; import reactor.netty.ChannelBindException; import reactor.netty.DisposableServer; import reactor.netty.http.server.HttpServer; import reactor.netty.http.server.HttpServerRequest; +import reactor.netty.http.server.HttpServerResponse; import reactor.netty.http.server.HttpServerRoutes; +import org.springframework.boot.web.server.GracefulShutdown; +import org.springframework.boot.web.server.ImmediateGracefulShutdown; import org.springframework.boot.web.server.PortInUseException; import org.springframework.boot.web.server.WebServer; import org.springframework.boot.web.server.WebServerException; @@ -53,20 +58,44 @@ public class NettyWebServer implements WebServer { private final HttpServer httpServer; - private final ReactorHttpHandlerAdapter handlerAdapter; + private final BiFunction> handler; private final Duration lifecycleTimeout; + private final GracefulShutdown shutdown; + private List routeProviders = Collections.emptyList(); - private DisposableServer disposableServer; + private volatile DisposableServer disposableServer; public NettyWebServer(HttpServer httpServer, ReactorHttpHandlerAdapter handlerAdapter, Duration lifecycleTimeout) { + this(httpServer, handlerAdapter, lifecycleTimeout, null); + } + + /** + * Creates a {@code NettyWebServer}. + * @param httpServer the Reactor Netty HTTP server + * @param handlerAdapter the Spring WebFlux handler adapter + * @param lifecycleTimeout lifecycle timeout + * @param shutdownGracePeriod grace period for handler for graceful shutdown + * @since 2.3.0 + */ + public NettyWebServer(HttpServer httpServer, ReactorHttpHandlerAdapter handlerAdapter, Duration lifecycleTimeout, + Duration shutdownGracePeriod) { Assert.notNull(httpServer, "HttpServer must not be null"); Assert.notNull(handlerAdapter, "HandlerAdapter must not be null"); this.httpServer = httpServer; - this.handlerAdapter = handlerAdapter; this.lifecycleTimeout = lifecycleTimeout; + if (shutdownGracePeriod != null) { + NettyGracefulShutdown gracefulShutdown = new NettyGracefulShutdown(() -> this.disposableServer, + lifecycleTimeout, shutdownGracePeriod); + this.handler = gracefulShutdown.wrapHandler(handlerAdapter); + this.shutdown = gracefulShutdown; + } + else { + this.handler = handlerAdapter; + this.shutdown = new ImmediateGracefulShutdown(); + } } public void setRouteProviders(List routeProviders) { @@ -91,10 +120,19 @@ public class NettyWebServer implements WebServer { } } + @Override + public boolean shutDownGracefully() { + return this.shutdown.shutDownGracefully(); + } + + boolean inGracefulShutdown() { + return this.shutdown.isShuttingDown(); + } + private DisposableServer startHttpServer() { HttpServer server = this.httpServer; if (this.routeProviders.isEmpty()) { - server = server.handle(this.handlerAdapter); + server = server.handle(this.handler); } else { server = server.route(this::applyRouteProviders); @@ -109,7 +147,7 @@ public class NettyWebServer implements WebServer { for (NettyRouteProvider provider : this.routeProviders) { routes = provider.apply(routes); } - routes.route(ALWAYS, this.handlerAdapter); + routes.route(ALWAYS, this.handler); } private ChannelBindException findBindException(Exception ex) { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatGracefulShutdown.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatGracefulShutdown.java new file mode 100644 index 0000000000..84e2288247 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatGracefulShutdown.java @@ -0,0 +1,126 @@ +/* + * 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.boot.web.embedded.tomcat; + +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import org.apache.catalina.Container; +import org.apache.catalina.Service; +import org.apache.catalina.connector.Connector; +import org.apache.catalina.core.StandardWrapper; +import org.apache.catalina.startup.Tomcat; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.boot.web.server.GracefulShutdown; +import org.springframework.util.ReflectionUtils; + +/** + * {@link GracefulShutdown} for {@link Tomcat}. + * + * @author Andy Wilkinson + */ +class TomcatGracefulShutdown implements GracefulShutdown { + + private static final Log logger = LogFactory.getLog(TomcatGracefulShutdown.class); + + private final Tomcat tomcat; + + private final Duration period; + + private volatile boolean shuttingDown = false; + + TomcatGracefulShutdown(Tomcat tomcat, Duration period) { + this.tomcat = tomcat; + this.period = period; + } + + @Override + public boolean shutDownGracefully() { + logger.info("Commencing graceful shutdown, allowing up to " + this.period.getSeconds() + + "s for active requests to complete"); + List connectors = getConnectors(); + for (Connector connector : connectors) { + connector.pause(); + connector.getProtocolHandler().closeServerSocketGraceful(); + } + this.shuttingDown = true; + try { + long end = System.currentTimeMillis() + this.period.toMillis(); + for (Container host : this.tomcat.getEngine().findChildren()) { + for (Container context : host.findChildren()) { + while (active(context)) { + if (System.currentTimeMillis() > end) { + logger.info("Grace period elaped with one or more requests still active"); + return false; + } + Thread.sleep(50); + } + } + } + + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + finally { + this.shuttingDown = false; + } + logger.info("Graceful shutdown complete"); + return true; + } + + private boolean active(Container context) { + try { + Field field = ReflectionUtils.findField(context.getClass(), "inProgressAsyncCount"); + field.setAccessible(true); + AtomicLong inProgressAsyncCount = (AtomicLong) field.get(context); + if (inProgressAsyncCount.get() > 0) { + return true; + } + for (Container wrapper : context.findChildren()) { + if (((StandardWrapper) wrapper).getCountAllocated() > 0) { + return true; + } + } + return false; + } + catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + private List getConnectors() { + List connectors = new ArrayList<>(); + for (Service service : this.tomcat.getServer().findServices()) { + for (Connector connector : service.findConnectors()) { + connectors.add(connector); + } + } + return connectors; + } + + @Override + public boolean isShuttingDown() { + return this.shuttingDown; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java index 01a63a70de..4e22f32a59 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -131,7 +131,7 @@ public class TomcatReactiveWebServerFactory extends AbstractReactiveWebServerFac } TomcatHttpHandlerAdapter servlet = new TomcatHttpHandlerAdapter(httpHandler); prepareContext(tomcat.getHost(), servlet); - return new TomcatWebServer(tomcat, getPort() >= 0); + return getTomcatWebServer(tomcat); } private void configureEngine(Engine engine) { @@ -413,7 +413,7 @@ public class TomcatReactiveWebServerFactory extends AbstractReactiveWebServerFac * @return a new {@link TomcatWebServer} instance */ protected TomcatWebServer getTomcatWebServer(Tomcat tomcat) { - return new TomcatWebServer(tomcat, getPort() >= 0); + return new TomcatWebServer(tomcat, getPort() >= 0, getShutdown().getGracePeriod()); } /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java index c83cc58b51..93b7f193cf 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -435,7 +435,7 @@ public class TomcatServletWebServerFactory extends AbstractServletWebServerFacto * @return a new {@link TomcatWebServer} instance */ protected TomcatWebServer getTomcatWebServer(Tomcat tomcat) { - return new TomcatWebServer(tomcat, getPort() >= 0); + return new TomcatWebServer(tomcat, getPort() >= 0, getShutdown().getGracePeriod()); } @Override diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatWebServer.java index 9aabbadb3c..8b38cf2982 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatWebServer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatWebServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -17,6 +17,7 @@ package org.springframework.boot.web.embedded.tomcat; import java.net.BindException; +import java.time.Duration; import java.util.Arrays; import java.util.HashMap; import java.util.Map; @@ -38,6 +39,8 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.apache.naming.ContextBindings; +import org.springframework.boot.web.server.GracefulShutdown; +import org.springframework.boot.web.server.ImmediateGracefulShutdown; import org.springframework.boot.web.server.PortInUseException; import org.springframework.boot.web.server.WebServer; import org.springframework.boot.web.server.WebServerException; @@ -66,6 +69,8 @@ public class TomcatWebServer implements WebServer { private final boolean autoStart; + private final GracefulShutdown gracefulShutdown; + private volatile boolean started; /** @@ -82,9 +87,22 @@ public class TomcatWebServer implements WebServer { * @param autoStart if the server should be started */ public TomcatWebServer(Tomcat tomcat, boolean autoStart) { + this(tomcat, autoStart, null); + } + + /** + * Create a new {@link TomcatWebServer} instance. + * @param tomcat the underlying Tomcat server + * @param autoStart if the server should be started + * @param shutdownGracePeriod grace period to use when shutting down + * @since 2.3.0 + */ + public TomcatWebServer(Tomcat tomcat, boolean autoStart, Duration shutdownGracePeriod) { Assert.notNull(tomcat, "Tomcat Server must not be null"); this.tomcat = tomcat; this.autoStart = autoStart; + this.gracefulShutdown = (shutdownGracePeriod != null) ? new TomcatGracefulShutdown(tomcat, shutdownGracePeriod) + : new ImmediateGracefulShutdown(); initialize(); } @@ -374,4 +392,13 @@ public class TomcatWebServer implements WebServer { return this.tomcat; } + @Override + public boolean shutDownGracefully() { + return this.gracefulShutdown.shutDownGracefully(); + } + + boolean inGracefulShutdown() { + return this.gracefulShutdown.isShuttingDown(); + } + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowGracefulShutdown.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowGracefulShutdown.java new file mode 100644 index 0000000000..7e4b2f20b6 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowGracefulShutdown.java @@ -0,0 +1,76 @@ +/* + * 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.boot.web.embedded.undertow; + +import java.time.Duration; + +import io.undertow.server.handlers.GracefulShutdownHandler; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.boot.web.server.GracefulShutdown; + +/** + * {@link GracefulShutdown} for Undertow. + * + * @author Andy Wilkinson + */ +class UndertowGracefulShutdown implements GracefulShutdown { + + private static final Log logger = LogFactory.getLog(UndertowGracefulShutdown.class); + + private final GracefulShutdownHandler gracefulShutdownHandler; + + private final Duration period; + + private volatile boolean shuttingDown; + + UndertowGracefulShutdown(GracefulShutdownHandler gracefulShutdownHandler, Duration period) { + this.gracefulShutdownHandler = gracefulShutdownHandler; + this.period = period; + } + + @Override + public boolean shutDownGracefully() { + logger.info("Commencing graceful shutdown, allowing up to " + this.period.getSeconds() + + "s for active requests to complete"); + this.gracefulShutdownHandler.shutdown(); + this.shuttingDown = true; + boolean graceful = false; + try { + graceful = this.gracefulShutdownHandler.awaitShutdown(this.period.toMillis()); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + finally { + this.shuttingDown = false; + } + if (graceful) { + logger.info("Graceful shutdown complete"); + return true; + } + logger.info("Grace period elaped with one or more requests still active"); + return graceful; + } + + @Override + public boolean isShuttingDown() { + return this.shuttingDown; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactory.java index 08a33832f7..88fbea5b52 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -19,6 +19,7 @@ package org.springframework.boot.web.embedded.undertow; import java.io.Closeable; import java.io.File; import java.io.IOException; +import java.time.Duration; import java.util.Arrays; import java.util.Collection; import java.util.LinkedHashSet; @@ -29,6 +30,7 @@ import io.undertow.Handlers; import io.undertow.Undertow; import io.undertow.UndertowOptions; import io.undertow.server.HttpHandler; +import io.undertow.server.handlers.GracefulShutdownHandler; import io.undertow.server.handlers.accesslog.AccessLogHandler; import io.undertow.server.handlers.accesslog.DefaultAccessLogReceiver; import org.xnio.OptionMap; @@ -38,6 +40,8 @@ import org.xnio.XnioWorker; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory; import org.springframework.boot.web.reactive.server.ReactiveWebServerFactory; +import org.springframework.boot.web.server.GracefulShutdown; +import org.springframework.boot.web.server.ImmediateGracefulShutdown; import org.springframework.boot.web.server.WebServer; import org.springframework.http.server.reactive.UndertowHttpHandlerAdapter; import org.springframework.util.Assert; @@ -93,8 +97,29 @@ public class UndertowReactiveWebServerFactory extends AbstractReactiveWebServerF @Override public WebServer getWebServer(org.springframework.http.server.reactive.HttpHandler httpHandler) { Undertow.Builder builder = createBuilder(getPort()); - Closeable closeable = configureHandler(builder, httpHandler); - return new UndertowWebServer(builder, getPort() >= 0, closeable); + HttpHandler handler = new UndertowHttpHandlerAdapter(httpHandler); + if (this.useForwardHeaders) { + handler = Handlers.proxyPeerAddress(handler); + } + handler = UndertowCompressionConfigurer.configureCompression(getCompression(), handler); + Closeable closeable = null; + GracefulShutdown gracefulShutdown = null; + if (isAccessLogEnabled()) { + AccessLogHandlerConfiguration accessLogHandlerConfiguration = configureAccessLogHandler(builder, handler); + closeable = accessLogHandlerConfiguration.closeable; + handler = accessLogHandlerConfiguration.accessLogHandler; + } + GracefulShutdownHandler gracefulShutdownHandler = Handlers.gracefulShutdown(handler); + Duration gracePeriod = getShutdown().getGracePeriod(); + if (gracePeriod != null) { + gracefulShutdown = new UndertowGracefulShutdown(gracefulShutdownHandler, gracePeriod); + handler = gracefulShutdownHandler; + } + else { + gracefulShutdown = new ImmediateGracefulShutdown(); + } + builder.setHandler(handler); + return new UndertowWebServer(builder, getPort() >= 0, closeable, gracefulShutdown); } private Undertow.Builder createBuilder(int port) { @@ -123,24 +148,7 @@ public class UndertowReactiveWebServerFactory extends AbstractReactiveWebServerF return builder; } - private Closeable configureHandler(Undertow.Builder builder, - org.springframework.http.server.reactive.HttpHandler httpHandler) { - HttpHandler handler = new UndertowHttpHandlerAdapter(httpHandler); - if (this.useForwardHeaders) { - handler = Handlers.proxyPeerAddress(handler); - } - handler = UndertowCompressionConfigurer.configureCompression(getCompression(), handler); - Closeable closeable = null; - if (isAccessLogEnabled()) { - closeable = configureAccessLogHandler(builder, handler); - } - else { - builder.setHandler(handler); - } - return closeable; - } - - private Closeable configureAccessLogHandler(Undertow.Builder builder, HttpHandler handler) { + private AccessLogHandlerConfiguration configureAccessLogHandler(Undertow.Builder builder, HttpHandler handler) { try { createAccessLogDirectoryIfNecessary(); XnioWorker worker = createWorker(); @@ -148,9 +156,9 @@ public class UndertowReactiveWebServerFactory extends AbstractReactiveWebServerF DefaultAccessLogReceiver accessLogReceiver = new DefaultAccessLogReceiver(worker, this.accessLogDirectory, prefix, this.accessLogSuffix, this.accessLogRotate); String formatString = ((this.accessLogPattern != null) ? this.accessLogPattern : "common"); - builder.setHandler( - new AccessLogHandler(handler, accessLogReceiver, formatString, Undertow.class.getClassLoader())); - return () -> { + AccessLogHandler accessLogHandler = new AccessLogHandler(handler, accessLogReceiver, formatString, + Undertow.class.getClassLoader()); + return new AccessLogHandlerConfiguration(accessLogHandler, () -> { try { accessLogReceiver.close(); worker.shutdown(); @@ -162,7 +170,7 @@ public class UndertowReactiveWebServerFactory extends AbstractReactiveWebServerF catch (InterruptedException ex) { Thread.currentThread().interrupt(); } - }; + }); } catch (IOException ex) { throw new IllegalStateException("Failed to create AccessLogHandler", ex); @@ -289,4 +297,17 @@ public class UndertowReactiveWebServerFactory extends AbstractReactiveWebServerF this.builderCustomizers.addAll(Arrays.asList(customizers)); } + private static final class AccessLogHandlerConfiguration { + + private final AccessLogHandler accessLogHandler; + + private final Closeable closeable; + + private AccessLogHandlerConfiguration(AccessLogHandler accessLogHandler, Closeable closeable) { + this.accessLogHandler = accessLogHandler; + this.closeable = closeable; + } + + } + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServer.java index 4ddf009a6d..2b893e231b 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -20,6 +20,7 @@ import java.lang.reflect.Field; import java.net.BindException; import java.net.InetSocketAddress; import java.net.SocketAddress; +import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -29,12 +30,15 @@ import io.undertow.Handlers; import io.undertow.Undertow; import io.undertow.Undertow.Builder; import io.undertow.server.HttpHandler; +import io.undertow.server.handlers.GracefulShutdownHandler; import io.undertow.servlet.api.DeploymentManager; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.xnio.channels.BoundChannel; import org.springframework.boot.web.server.Compression; +import org.springframework.boot.web.server.GracefulShutdown; +import org.springframework.boot.web.server.ImmediateGracefulShutdown; import org.springframework.boot.web.server.PortInUseException; import org.springframework.boot.web.server.WebServer; import org.springframework.boot.web.server.WebServerException; @@ -74,10 +78,14 @@ public class UndertowServletWebServer implements WebServer { private final String serverHeader; + private final Duration shutdownGracePeriod; + private Undertow undertow; private volatile boolean started = false; + private volatile GracefulShutdown gracefulShutdown; + /** * Create a new {@link UndertowServletWebServer} instance. * @param builder the builder @@ -117,6 +125,25 @@ public class UndertowServletWebServer implements WebServer { */ public UndertowServletWebServer(Builder builder, DeploymentManager manager, String contextPath, boolean useForwardHeaders, boolean autoStart, Compression compression, String serverHeader) { + this(builder, manager, contextPath, useForwardHeaders, autoStart, compression, serverHeader, null); + } + + /** + * Create a new {@link UndertowServletWebServer} instance. + * @param builder the builder + * @param manager the deployment manager + * @param contextPath the root context path + * @param useForwardHeaders if x-forward headers should be used + * @param autoStart if the server should be started + * @param compression compression configuration + * @param serverHeader string to be used in HTTP header + * @param shutdownGracePeriod the period to wait for activity to cease when shutting + * down the server gracefully + * @since 2.3.0 + */ + public UndertowServletWebServer(Builder builder, DeploymentManager manager, String contextPath, + boolean useForwardHeaders, boolean autoStart, Compression compression, String serverHeader, + Duration shutdownGracePeriod) { this.builder = builder; this.manager = manager; this.contextPath = contextPath; @@ -124,6 +151,7 @@ public class UndertowServletWebServer implements WebServer { this.autoStart = autoStart; this.compression = compression; this.serverHeader = serverHeader; + this.shutdownGracePeriod = shutdownGracePeriod; } @Override @@ -200,6 +228,14 @@ public class UndertowServletWebServer implements WebServer { if (StringUtils.hasText(this.serverHeader)) { httpHandler = Handlers.header(httpHandler, "Server", this.serverHeader); } + if (this.shutdownGracePeriod != null) { + GracefulShutdownHandler gracefulShutdownHandler = Handlers.gracefulShutdown(httpHandler); + this.gracefulShutdown = new UndertowGracefulShutdown(gracefulShutdownHandler, this.shutdownGracePeriod); + httpHandler = gracefulShutdownHandler; + } + else { + this.gracefulShutdown = new ImmediateGracefulShutdown(); + } this.builder.setHandler(httpHandler); return this.builder.build(); } @@ -314,6 +350,15 @@ public class UndertowServletWebServer implements WebServer { return ports.get(0).getNumber(); } + @Override + public boolean shutDownGracefully() { + return this.gracefulShutdown.shutDownGracefully(); + } + + boolean inGracefulShutdown() { + return this.gracefulShutdown.isShuttingDown(); + } + /** * An active Undertow port. */ diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactory.java index 8dc3b442e4..161c2d1f52 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -449,7 +449,7 @@ public class UndertowServletWebServerFactory extends AbstractServletWebServerFac */ protected UndertowServletWebServer getUndertowWebServer(Builder builder, DeploymentManager manager, int port) { return new UndertowServletWebServer(builder, manager, getContextPath(), isUseForwardHeaders(), port >= 0, - getCompression(), getServerHeader()); + getCompression(), getServerHeader(), getShutdown().getGracePeriod()); } @Override diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServer.java index e8027d0903..ef68ba10b2 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -29,6 +29,8 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.xnio.channels.BoundChannel; +import org.springframework.boot.web.server.GracefulShutdown; +import org.springframework.boot.web.server.ImmediateGracefulShutdown; import org.springframework.boot.web.server.PortInUseException; import org.springframework.boot.web.server.WebServer; import org.springframework.boot.web.server.WebServerException; @@ -59,6 +61,8 @@ public class UndertowWebServer implements WebServer { private final Closeable closeable; + private final GracefulShutdown gracefulShutdown; + private Undertow undertow; private volatile boolean started = false; @@ -80,9 +84,23 @@ public class UndertowWebServer implements WebServer { * @since 2.0.4 */ public UndertowWebServer(Undertow.Builder builder, boolean autoStart, Closeable closeable) { + this(builder, autoStart, closeable, new ImmediateGracefulShutdown()); + } + + /** + * Create a new {@link UndertowWebServer} instance. + * @param builder the builder + * @param autoStart if the server should be started + * @param closeable called when the server is stopped + * @param gracefulShutdown handler for graceful shutdown + * @since 2.3.0 + */ + public UndertowWebServer(Undertow.Builder builder, boolean autoStart, Closeable closeable, + GracefulShutdown gracefulShutdown) { this.builder = builder; this.autoStart = autoStart; this.closeable = closeable; + this.gracefulShutdown = gracefulShutdown; } @Override @@ -245,6 +263,15 @@ public class UndertowWebServer implements WebServer { return ports.get(0).getNumber(); } + @Override + public boolean shutDownGracefully() { + return (this.gracefulShutdown != null) ? this.gracefulShutdown.shutDownGracefully() : false; + } + + boolean inGracefulShutdown() { + return (this.gracefulShutdown != null) ? this.gracefulShutdown.isShuttingDown() : false; + } + /** * An active Undertow port. */ diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/context/ReactiveWebServerApplicationContext.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/context/ReactiveWebServerApplicationContext.java index 172a8d9ab1..4d243969b9 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/context/ReactiveWebServerApplicationContext.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/context/ReactiveWebServerApplicationContext.java @@ -146,6 +146,15 @@ public class ReactiveWebServerApplicationContext extends GenericReactiveWebAppli return getBeanFactory().getBean(beanNames[0], HttpHandler.class); } + @Override + protected void doClose() { + WebServer webServer = getWebServer(); + if (webServer != null) { + webServer.shutDownGracefully(); + } + super.doClose(); + } + @Override protected void onClose() { super.onClose(); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java index 2b136064ed..d9fa662e1c 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -55,6 +55,8 @@ public abstract class AbstractConfigurableWebServerFactory implements Configurab private String serverHeader; + private Shutdown shutdown = new Shutdown(); + /** * Create a new {@link AbstractConfigurableWebServerFactory} instance. */ @@ -162,6 +164,20 @@ public abstract class AbstractConfigurableWebServerFactory implements Configurab this.serverHeader = serverHeader; } + @Override + public void setShutdown(Shutdown shutdown) { + this.shutdown = shutdown; + } + + /** + * Returns the shutdown configuration that will be applied to the server. + * @return the shutdown configuration + * @since 2.3.0 + */ + public Shutdown getShutdown() { + return this.shutdown; + } + /** * Return the absolute temp dir for given web server. * @param prefix server name diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/ConfigurableWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/ConfigurableWebServerFactory.java index d9890298f5..b124b4aa40 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/ConfigurableWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/ConfigurableWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -80,4 +80,13 @@ public interface ConfigurableWebServerFactory extends WebServerFactory, ErrorPag */ void setServerHeader(String serverHeader); + /** + * Sets the shutdown configuration that will be applied to the server. + * @param shutdown the shutdown configuration + * @since 2.3.0 + */ + default void setShutdown(Shutdown shutdown) { + + } + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/GracefulShutdown.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/GracefulShutdown.java new file mode 100644 index 0000000000..f7aeed2208 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/GracefulShutdown.java @@ -0,0 +1,42 @@ +/* + * 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.boot.web.server; + +/** + * Handles graceful shutdown of a {@link WebServer}. + * + * @author Andy Wilkinson + * @since 2.3.0 + */ +public interface GracefulShutdown { + + /** + * Shuts down the {@link WebServer}, returning {@code true} if activity ceased during + * the grace period, otherwise {@code false}. + * @return {@code true} if activity ceased during the grace period, otherwise + * {@code false} + */ + boolean shutDownGracefully(); + + /** + * Returns whether the handler is in the process of gracefully shutting down the web + * server. + * @return {@code true} is graceful shutdown is in progress, otherwise {@code false}. + */ + boolean isShuttingDown(); + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/ImmediateGracefulShutdown.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/ImmediateGracefulShutdown.java new file mode 100644 index 0000000000..c5a31f7974 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/ImmediateGracefulShutdown.java @@ -0,0 +1,37 @@ +/* + * 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.boot.web.server; + +/** + * A {@link GracefulShutdown} that returns immediately with no grace period. + * + * @author Andy Wilkinson + * @since 2.3.0 + */ +public class ImmediateGracefulShutdown implements GracefulShutdown { + + @Override + public boolean shutDownGracefully() { + return false; + } + + @Override + public boolean isShuttingDown() { + return false; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/Shutdown.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/Shutdown.java new file mode 100644 index 0000000000..6451708fda --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/Shutdown.java @@ -0,0 +1,43 @@ +/* + * 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.boot.web.server; + +import java.time.Duration; + +/** + * Configuration for shutting down a {@link WebServer}. + * + * @author Andy Wilkinson + * @since 2.3.0 + */ +public class Shutdown { + + /** + * Time to wait for web activity to cease before shutting down the application. By + * default, shutdown will proceed immediately. + */ + private Duration gracePeriod; + + public Duration getGracePeriod() { + return this.gracePeriod; + } + + public void setGracePeriod(Duration period) { + this.gracePeriod = period; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServer.java index 479d70633e..eab3311072 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -47,4 +47,15 @@ public interface WebServer { */ int getPort(); + /** + * Gracefully shuts down the web server by preventing the handling of new requests and + * waiting for a configurable period for there to be no active requests. + * @return {@code true} if graceful shutdown completed within the period, otherwise + * {@code false} + * @since 2.3.0 + */ + default boolean shutDownGracefully() { + return false; + } + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContext.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContext.java index 6f00ae7ee4..797b212351 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContext.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -166,6 +166,15 @@ public class ServletWebServerApplicationContext extends GenericWebApplicationCon } } + @Override + protected void doClose() { + WebServer webServer = this.webServer; + if (webServer != null) { + webServer.shutDownGracefully(); + } + super.doClose(); + } + @Override protected void onClose() { super.onClose(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/AbstractJettyServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/AbstractJettyServletWebServerFactoryTests.java index 7032c59b3f..7abd215229 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/AbstractJettyServletWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/AbstractJettyServletWebServerFactoryTests.java @@ -91,6 +91,11 @@ abstract class AbstractJettyServletWebServerFactoryTests extends AbstractServlet this.handleExceptionCausedByBlockedPortOnPrimaryConnector(ex, blockedPort); } + @Override + protected boolean inGracefulShutdown() { + return ((JettyWebServer) this.webServer).inGracefulShutdown(); + } + @Test void contextPathIsLoggedOnStartupWhenCompressionIsEnabled(CapturedOutput output) { AbstractServletWebServerFactory factory = getFactory(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactoryTests.java index 4317adb281..e4870b5d5a 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -16,8 +16,14 @@ package org.springframework.boot.web.embedded.jetty; +import java.io.IOException; import java.net.InetAddress; +import java.time.Duration; import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.eclipse.jetty.server.Connector; import org.eclipse.jetty.server.Server; @@ -26,11 +32,13 @@ import org.junit.jupiter.api.Test; import org.mockito.InOrder; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactoryTests; +import org.springframework.boot.web.server.Shutdown; import org.springframework.http.client.reactive.JettyResourceFactory; import org.springframework.http.server.reactive.HttpHandler; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -110,4 +118,38 @@ class JettyReactiveWebServerFactoryTests extends AbstractReactiveWebServerFactor assertThat(connector.getScheduler()).isEqualTo(resourceFactory.getScheduler()); } + @Test + void whenServerIsShuttingDownGracefullyThenNewConnectionsCannotBeMade() throws Exception { + JettyReactiveWebServerFactory factory = getFactory(); + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(5)); + factory.setShutdown(shutdown); + BlockingHandler blockingHandler = new BlockingHandler(); + this.webServer = factory.getWebServer(blockingHandler); + this.webServer.start(); + getWebClient().build().get().retrieve().toBodilessEntity().subscribe(); + blockingHandler.awaitQueue(); + Future shutdownResult = initiateGracefulShutdown(); + // We need to make two requests as Jetty accepts one additional request after a + // connector has been told to stop accepting requests + CountDownLatch responseLatch = new CountDownLatch(1); + AtomicReference errorReference = new AtomicReference<>(); + getWebClient().build().get().retrieve().toBodilessEntity().doOnSuccess((response) -> responseLatch.countDown()) + .doOnError(errorReference::set).subscribe(); + getWebClient().build().get().retrieve().toBodilessEntity().doOnSuccess((response) -> responseLatch.countDown()) + .doOnError(errorReference::set).subscribe(); + assertThat(shutdownResult.get()).isEqualTo(false); + blockingHandler.completeOne(); + blockingHandler.completeOne(); + responseLatch.await(5, TimeUnit.SECONDS); + this.webServer.stop(); + Throwable error = await().atMost(Duration.ofSeconds(5)).until(errorReference::get, (ex) -> ex != null); + assertThat(error).isInstanceOf(IOException.class); + } + + @Override + protected boolean inGracefulShutdown() { + return ((JettyWebServer) this.webServer).inGracefulShutdown(); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactoryTests.java index ea794ec061..033702b075 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactoryTests.java @@ -21,12 +21,17 @@ import java.time.Duration; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.List; +import java.util.concurrent.Future; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.servlet.ServletContextEvent; import javax.servlet.ServletContextListener; +import javax.servlet.ServletRegistration.Dynamic; +import org.apache.http.HttpResponse; +import org.apache.http.conn.HttpHostConnectException; import org.eclipse.jetty.server.Connector; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Server; @@ -44,8 +49,10 @@ import org.eclipse.jetty.webapp.WebAppContext; import org.junit.jupiter.api.Test; import org.mockito.InOrder; +import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.Ssl; import org.springframework.boot.web.server.WebServerException; +import org.springframework.boot.web.servlet.server.AbstractServletWebServerFactory; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -169,6 +176,34 @@ class JettyServletWebServerFactoryTests extends AbstractJettyServletWebServerFac assertThat(connectionFactory.getSslContextFactory().getIncludeProtocols()).containsExactly("TLSv1.1"); } + @Test + void whenServerIsShuttingDownGracefullyThenNewConnectionsCannotBeMade() throws Exception { + AbstractServletWebServerFactory factory = getFactory(); + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(5)); + factory.setShutdown(shutdown); + BlockingServlet blockingServlet = new BlockingServlet(); + this.webServer = factory.getWebServer((context) -> { + Dynamic registration = context.addServlet("blockingServlet", blockingServlet); + registration.addMapping("/blocking"); + registration.setAsyncSupported(true); + }); + this.webServer.start(); + Future request = initiateGetRequest("/blocking"); + blockingServlet.awaitQueue(); + Future shutdownResult = initiateGracefulShutdown(); + // Jetty accepts one additional request after a connector has been told to stop + // accepting requests + Future unconnectableRequest1 = initiateGetRequest("/"); + Future unconnectableRequest2 = initiateGetRequest("/"); + assertThat(shutdownResult.get()).isEqualTo(false); + blockingServlet.admitOne(); + assertThat(request.get()).isInstanceOf(HttpResponse.class); + this.webServer.stop(); + List results = Arrays.asList(unconnectableRequest1.get(), unconnectableRequest2.get()); + assertThat(results).anySatisfy((result) -> assertThat(result).isInstanceOf(HttpHostConnectException.class)); + } + private Ssl getSslSettings(String... enabledProtocols) { Ssl ssl = new Ssl(); ssl.setKeyStore("src/test/resources/test.jks"); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java index 7fd98645d5..7180a29a07 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java @@ -16,8 +16,11 @@ package org.springframework.boot.web.embedded.netty; +import java.net.ConnectException; import java.time.Duration; import java.util.Arrays; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.mockito.InOrder; @@ -28,6 +31,7 @@ import reactor.test.StepVerifier; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactoryTests; import org.springframework.boot.web.server.PortInUseException; +import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.Ssl; import org.springframework.http.MediaType; import org.springframework.http.client.reactive.ReactorClientHttpConnector; @@ -99,6 +103,27 @@ class NettyReactiveWebServerFactoryTests extends AbstractReactiveWebServerFactor StepVerifier.create(result).expectNext("Hello World").verifyComplete(); } + @Test + void whenServerIsShuttingDownGracefullyThenNewConnectionsCannotBeMade() throws Exception { + NettyReactiveWebServerFactory factory = getFactory(); + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(5)); + factory.setShutdown(shutdown); + BlockingHandler blockingHandler = new BlockingHandler(); + this.webServer = factory.getWebServer(blockingHandler); + this.webServer.start(); + WebClient webClient = getWebClient().build(); + webClient.get().retrieve().toBodilessEntity().subscribe(); + blockingHandler.awaitQueue(); + Future shutdownResult = initiateGracefulShutdown(); + AtomicReference errorReference = new AtomicReference<>(); + webClient.get().retrieve().toBodilessEntity().doOnError(errorReference::set).subscribe(); + assertThat(shutdownResult.get()).isEqualTo(false); + blockingHandler.completeOne(); + this.webServer.stop(); + assertThat(errorReference.get()).hasCauseInstanceOf(ConnectException.class); + } + protected Mono testSslWithAlias(String alias) { String keyStore = "classpath:test.jks"; String keyPassword = "password"; @@ -117,4 +142,9 @@ class NettyReactiveWebServerFactoryTests extends AbstractReactiveWebServerFactor .exchange().flatMap((response) -> response.bodyToMono(String.class)); } + @Override + protected boolean inGracefulShutdown() { + return ((NettyWebServer) this.webServer).inGracefulShutdown(); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactoryTests.java index 33ab93d465..5b452e8d5a 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactoryTests.java @@ -17,10 +17,14 @@ package org.springframework.boot.web.embedded.tomcat; import java.io.IOException; +import java.net.ConnectException; import java.net.InetSocketAddress; import java.net.ServerSocket; +import java.time.Duration; import java.util.Arrays; import java.util.Map; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; import org.apache.catalina.Context; import org.apache.catalina.LifecycleEvent; @@ -41,10 +45,12 @@ import org.mockito.InOrder; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactoryTests; import org.springframework.boot.web.server.PortInUseException; +import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.Ssl; import org.springframework.boot.web.server.WebServerException; import org.springframework.http.server.reactive.HttpHandler; import org.springframework.util.SocketUtils; +import org.springframework.web.reactive.function.client.WebClient; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -256,6 +262,27 @@ class TomcatReactiveWebServerFactoryTests extends AbstractReactiveWebServerFacto .isInstanceOf(WebServerException.class); } + @Test + void whenServerIsShuttingDownGracefullyThenNewConnectionsCannotBeMade() throws Exception { + TomcatReactiveWebServerFactory factory = getFactory(); + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(5)); + factory.setShutdown(shutdown); + BlockingHandler blockingHandler = new BlockingHandler(); + this.webServer = factory.getWebServer(blockingHandler); + this.webServer.start(); + WebClient webClient = getWebClient().build(); + webClient.get().retrieve().toBodilessEntity().subscribe(); + blockingHandler.awaitQueue(); + Future shutdownResult = initiateGracefulShutdown(); + AtomicReference errorReference = new AtomicReference<>(); + webClient.get().retrieve().toBodilessEntity().doOnError(errorReference::set).subscribe(); + assertThat(shutdownResult.get()).isEqualTo(false); + blockingHandler.completeOne(); + this.webServer.stop(); + assertThat(errorReference.get()).hasCauseInstanceOf(ConnectException.class); + } + private void doWithBlockedPort(BlockedPortAction action) throws IOException { int port = SocketUtils.findAvailableTcpPort(40000); ServerSocket serverSocket = new ServerSocket(); @@ -280,6 +307,11 @@ class TomcatReactiveWebServerFactoryTests extends AbstractReactiveWebServerFacto assertThat(((PortInUseException) ex).getPort()).isEqualTo(blockedPort); } + @Override + protected boolean inGracefulShutdown() { + return ((TomcatWebServer) this.webServer).inGracefulShutdown(); + } + interface BlockedPortAction { void run(int port); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java index c35b590c7e..6c27655eb1 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java @@ -26,6 +26,7 @@ import java.util.Arrays; import java.util.HashMap; import java.util.Locale; import java.util.Map; +import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicReference; import javax.naming.InitialContext; @@ -55,6 +56,8 @@ import org.apache.catalina.util.CharsetMapper; import org.apache.catalina.valves.RemoteIpValve; import org.apache.coyote.ProtocolHandler; import org.apache.coyote.http11.AbstractHttp11Protocol; +import org.apache.http.HttpResponse; +import org.apache.http.conn.HttpHostConnectException; import org.apache.jasper.servlet.JspServlet; import org.apache.tomcat.JarScanFilter; import org.apache.tomcat.JarScanType; @@ -66,6 +69,7 @@ import org.mockito.InOrder; import org.springframework.boot.testsupport.system.CapturedOutput; import org.springframework.boot.testsupport.web.servlet.ExampleServlet; import org.springframework.boot.web.server.PortInUseException; +import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.Ssl; import org.springframework.boot.web.server.WebServerException; import org.springframework.boot.web.servlet.ServletRegistrationBean; @@ -557,6 +561,30 @@ class TomcatServletWebServerFactoryTests extends AbstractServletWebServerFactory assertThatThrownBy(() -> factory.getWebServer(registration).start()).isInstanceOf(WebServerException.class); } + @Test + void whenServerIsShuttingDownGracefullyThenNewConnectionsCannotBeMade() throws Exception { + AbstractServletWebServerFactory factory = getFactory(); + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(5)); + factory.setShutdown(shutdown); + BlockingServlet blockingServlet = new BlockingServlet(); + this.webServer = factory.getWebServer((context) -> { + Dynamic registration = context.addServlet("blockingServlet", blockingServlet); + registration.addMapping("/blocking"); + registration.setAsyncSupported(true); + }); + this.webServer.start(); + Future request = initiateGetRequest("/blocking"); + blockingServlet.awaitQueue(); + Future shutdownResult = initiateGracefulShutdown(); + Future unconnectableRequest = initiateGetRequest("/"); + assertThat(shutdownResult.get()).isEqualTo(false); + blockingServlet.admitOne(); + assertThat(request.get()).isInstanceOf(HttpResponse.class); + this.webServer.stop(); + assertThat(unconnectableRequest.get()).isInstanceOf(HttpHostConnectException.class); + } + @Override protected JspServlet getJspServlet() throws ServletException { Tomcat tomcat = ((TomcatWebServer) this.webServer).getTomcat(); @@ -610,4 +638,9 @@ class TomcatServletWebServerFactoryTests extends AbstractServletWebServerFactory assertThat(((ConnectorStartFailedException) ex).getPort()).isEqualTo(blockedPort); } + @Override + protected boolean inGracefulShutdown() { + return ((TomcatWebServer) this.webServer).inGracefulShutdown(); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactoryTests.java index b3b93ae078..465aa6fe2f 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -21,6 +21,8 @@ import java.io.IOException; import java.net.URISyntaxException; import java.time.Duration; import java.util.Arrays; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; import io.undertow.Undertow; import org.awaitility.Awaitility; @@ -30,10 +32,12 @@ import org.mockito.InOrder; import reactor.core.publisher.Mono; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactoryTests; +import org.springframework.boot.web.server.Shutdown; import org.springframework.http.MediaType; import org.springframework.http.server.reactive.HttpHandler; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException.ServiceUnavailable; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -105,6 +109,32 @@ class UndertowReactiveWebServerFactoryTests extends AbstractReactiveWebServerFac testAccessLog("my_access.", "logz", "my_access.logz"); } + @Test + void whenServerIsShuttingDownGracefullyThenNewConnectionsAreRejectedWithServiceUnavailable() throws Exception { + UndertowReactiveWebServerFactory factory = getFactory(); + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(5)); + factory.setShutdown(shutdown); + BlockingHandler blockingHandler = new BlockingHandler(); + this.webServer = factory.getWebServer(blockingHandler); + this.webServer.start(); + WebClient webClient = getWebClient().build(); + webClient.get().retrieve().toBodilessEntity().subscribe(); + blockingHandler.awaitQueue(); + Future shutdownResult = initiateGracefulShutdown(); + AtomicReference errorReference = new AtomicReference<>(); + webClient.get().retrieve().toBodilessEntity().doOnError(errorReference::set).subscribe(); + assertThat(shutdownResult.get()).isEqualTo(false); + blockingHandler.completeOne(); + this.webServer.stop(); + assertThat(errorReference.get()).isInstanceOf(ServiceUnavailable.class); + } + + @Override + protected boolean inGracefulShutdown() { + return ((UndertowWebServer) this.webServer).inGracefulShutdown(); + } + private void testAccessLog(String prefix, String suffix, String expectedFile) throws IOException, URISyntaxException, InterruptedException { UndertowReactiveWebServerFactory factory = getFactory(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactoryTests.java index 199ae78392..298562a719 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * 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. @@ -25,15 +25,18 @@ import java.time.Duration; import java.util.Arrays; import java.util.Locale; import java.util.Map; +import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLException; import javax.net.ssl.SSLHandshakeException; +import javax.servlet.ServletRegistration.Dynamic; import io.undertow.Undertow; import io.undertow.Undertow.Builder; import io.undertow.servlet.api.DeploymentInfo; import io.undertow.servlet.api.ServletContainer; +import org.apache.http.HttpResponse; import org.apache.jasper.servlet.JspServlet; import org.awaitility.Awaitility; import org.junit.jupiter.api.Test; @@ -42,6 +45,7 @@ import org.mockito.InOrder; import org.springframework.boot.testsupport.web.servlet.ExampleServlet; import org.springframework.boot.web.server.ErrorPage; import org.springframework.boot.web.server.PortInUseException; +import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.servlet.ServletRegistrationBean; import org.springframework.boot.web.servlet.server.AbstractServletWebServerFactory; import org.springframework.boot.web.servlet.server.AbstractServletWebServerFactoryTests; @@ -173,6 +177,33 @@ class UndertowServletWebServerFactoryTests extends AbstractServletWebServerFacto testAccessLog("my_access.", "logz", "my_access.logz"); } + @Test + void whenServerIsShuttingDownGracefullyThenRequestsAreRejectedWithServiceUnavailable() throws Exception { + AbstractServletWebServerFactory factory = getFactory(); + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(5)); + factory.setShutdown(shutdown); + BlockingServlet blockingServlet = new BlockingServlet(); + this.webServer = factory.getWebServer((context) -> { + Dynamic registration = context.addServlet("blockingServlet", blockingServlet); + registration.addMapping("/blocking"); + registration.setAsyncSupported(true); + }); + this.webServer.start(); + Future request = initiateGetRequest("/blocking"); + blockingServlet.awaitQueue(); + Future shutdownResult = initiateGracefulShutdown(); + Future rejectedRequest = initiateGetRequest("/"); + assertThat(shutdownResult.get()).isEqualTo(false); + blockingServlet.admitOne(); + assertThat(request.get()).isInstanceOf(HttpResponse.class); + this.webServer.stop(); + Object requestResult = rejectedRequest.get(); + assertThat(requestResult).isInstanceOf(HttpResponse.class); + assertThat(((HttpResponse) requestResult).getStatusLine().getStatusCode()) + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE.value()); + } + private void testAccessLog(String prefix, String suffix, String expectedFile) throws IOException, URISyntaxException, InterruptedException { UndertowServletWebServerFactory factory = getFactory(); @@ -278,4 +309,9 @@ class UndertowServletWebServerFactoryTests extends AbstractServletWebServerFacto this.handleExceptionCausedByBlockedPortOnPrimaryConnector(ex, blockedPort); } + @Override + protected boolean inGracefulShutdown() { + return ((UndertowServletWebServer) this.webServer).inGracefulShutdown(); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java index 2f0947eb4c..b2d288fa49 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java @@ -23,7 +23,15 @@ import java.nio.charset.StandardCharsets; import java.security.KeyStore; import java.time.Duration; import java.util.Arrays; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.RunnableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLException; @@ -38,11 +46,14 @@ import io.netty.handler.ssl.util.InsecureTrustManagerFactory; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoProcessor; import reactor.netty.NettyPipeline; import reactor.netty.http.client.HttpClient; import reactor.test.StepVerifier; +import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory; import org.springframework.boot.web.server.Compression; +import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.Ssl; import org.springframework.boot.web.server.WebServer; import org.springframework.core.io.buffer.DataBuffer; @@ -53,6 +64,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.http.client.reactive.ReactorResourceFactory; import org.springframework.http.server.reactive.HttpHandler; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; @@ -333,6 +345,78 @@ public abstract class AbstractReactiveWebServerFactoryTests { .hasMessageContaining("Could not load key store 'null'"); } + @Test + void whenThereAreNoInFlightRequestsShutDownGracefullyReturnsTrueBeforePeriodElapses() throws Exception { + AbstractReactiveWebServerFactory factory = getFactory(); + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(30)); + factory.setShutdown(shutdown); + this.webServer = factory.getWebServer(new EchoHandler()); + this.webServer.start(); + long start = System.currentTimeMillis(); + assertThat(this.webServer.shutDownGracefully()).isTrue(); + long end = System.currentTimeMillis(); + assertThat(end - start).isLessThanOrEqualTo(30000); + } + + @Test + void whenARequestRemainsInFlightThenShutDownGracefullyReturnsFalseAfterPeriodElapses() throws Exception { + AbstractReactiveWebServerFactory factory = getFactory(); + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(5)); + factory.setShutdown(shutdown); + BlockingHandler blockingHandler = new BlockingHandler(); + this.webServer = factory.getWebServer(blockingHandler); + this.webServer.start(); + Mono> request = getWebClient().build().get().retrieve().toBodilessEntity(); + AtomicReference> responseReference = new AtomicReference<>(); + CountDownLatch responseLatch = new CountDownLatch(1); + request.subscribe((response) -> { + responseReference.set(response); + responseLatch.countDown(); + }); + blockingHandler.awaitQueue(); + long start = System.currentTimeMillis(); + assertThat(this.webServer.shutDownGracefully()).isFalse(); + long end = System.currentTimeMillis(); + assertThat(end - start).isGreaterThanOrEqualTo(5000); + assertThat(responseReference.get()).isNull(); + blockingHandler.completeOne(); + assertThat(responseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void whenARequestCompletesDuringGracePeriodThenShutDownGracefullyReturnsTrueBeforePeriodElapses() throws Exception { + AbstractReactiveWebServerFactory factory = getFactory(); + if (factory instanceof NettyReactiveWebServerFactory) { + ReactorResourceFactory resourceFactory = new ReactorResourceFactory(); + resourceFactory.afterPropertiesSet(); + ((NettyReactiveWebServerFactory) factory).setResourceFactory(resourceFactory); + } + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(30)); + factory.setShutdown(shutdown); + BlockingHandler blockingHandler = new BlockingHandler(); + this.webServer = factory.getWebServer(blockingHandler); + this.webServer.start(); + Mono> request = getWebClient().build().get().retrieve().toBodilessEntity(); + AtomicReference> responseReference = new AtomicReference<>(); + CountDownLatch responseLatch = new CountDownLatch(1); + request.subscribe((response) -> { + responseReference.set(response); + responseLatch.countDown(); + }); + blockingHandler.awaitQueue(); + long start = System.currentTimeMillis(); + Future shutdownResult = initiateGracefulShutdown(); + assertThat(responseLatch.getCount()).isEqualTo(1); + blockingHandler.completeOne(); + assertThat(shutdownResult.get()).isTrue(); + long end = System.currentTimeMillis(); + assertThat(end - start).isLessThanOrEqualTo(30000); + assertThat(responseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + } + protected WebClient prepareCompressionTest() { Compression compression = new Compression(); compression.setEnabled(true); @@ -385,6 +469,26 @@ public abstract class AbstractReactiveWebServerFactoryTests { throw new IllegalStateException("Action was not successful in 10 attempts", lastFailure); } + protected Future initiateGracefulShutdown() { + RunnableFuture future = new FutureTask(() -> this.webServer.shutDownGracefully()); + new Thread(future).start(); + awaitInGracefulShutdown(); + return future; + } + + protected void awaitInGracefulShutdown() { + while (!this.inGracefulShutdown()) { + try { + Thread.sleep(100); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + } + + protected abstract boolean inGracefulShutdown(); + protected static class EchoHandler implements HttpHandler { public EchoHandler() { @@ -398,6 +502,40 @@ public abstract class AbstractReactiveWebServerFactoryTests { } + protected static class BlockingHandler implements HttpHandler { + + private final BlockingQueue> monoProcessors = new ArrayBlockingQueue<>(10); + + public BlockingHandler() { + + } + + @Override + public Mono handle(ServerHttpRequest request, ServerHttpResponse response) { + MonoProcessor completion = MonoProcessor.create(); + this.monoProcessors.add(completion); + return completion.then(Mono.empty()); + } + + public void completeOne() { + try { + MonoProcessor processor = this.monoProcessors.take(); + System.out.println("Completing " + processor); + processor.onComplete(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + + public void awaitQueue() throws InterruptedException { + while (this.monoProcessors.isEmpty()) { + Thread.sleep(100); + } + } + + } + static class CompressionDetectionHandler extends ChannelInboundHandlerAdapter { @Override diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java index 66a3da1e5f..a78be6e166 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java @@ -44,7 +44,15 @@ import java.util.EnumSet; import java.util.HashMap; import java.util.Locale; import java.util.Map; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.BrokenBarrierException; import java.util.concurrent.Callable; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.RunnableFuture; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; @@ -52,6 +60,7 @@ import java.util.zip.GZIPInputStream; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; +import javax.servlet.AsyncContext; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; @@ -60,6 +69,7 @@ import javax.servlet.ServletContext; import javax.servlet.ServletContextEvent; import javax.servlet.ServletContextListener; import javax.servlet.ServletException; +import javax.servlet.ServletRegistration.Dynamic; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.SessionCookieConfig; @@ -69,8 +79,10 @@ import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import org.apache.catalina.webresources.TomcatURLStreamHandlerFactory; +import org.apache.http.HttpResponse; import org.apache.http.client.HttpClient; import org.apache.http.client.entity.InputStreamFactory; +import org.apache.http.client.methods.HttpGet; import org.apache.http.client.protocol.HttpClientContext; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; import org.apache.http.conn.ssl.TrustSelfSignedStrategy; @@ -99,6 +111,7 @@ import org.springframework.boot.testsupport.web.servlet.ExampleServlet; import org.springframework.boot.web.server.Compression; import org.springframework.boot.web.server.ErrorPage; import org.springframework.boot.web.server.MimeMappings; +import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.Ssl; import org.springframework.boot.web.server.Ssl.ClientAuth; import org.springframework.boot.web.server.SslStoreProvider; @@ -1002,6 +1015,155 @@ public abstract class AbstractServletWebServerFactoryTests { .satisfies(this::wrapsFailingServletException); } + @Test + void whenThereAreNoInFlightRequestsShutDownGracefullyReturnsTrueBeforePeriodElapses() throws Exception { + AbstractServletWebServerFactory factory = getFactory(); + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(30)); + factory.setShutdown(shutdown); + this.webServer = factory.getWebServer(); + this.webServer.start(); + long start = System.currentTimeMillis(); + assertThat(this.webServer.shutDownGracefully()).isTrue(); + long end = System.currentTimeMillis(); + assertThat(end - start).isLessThanOrEqualTo(30000); + } + + @Test + void whenARequestRemainsInFlightThenShutDownGracefullyReturnsFalseAfterPeriodElapses() throws Exception { + AbstractServletWebServerFactory factory = getFactory(); + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(5)); + factory.setShutdown(shutdown); + BlockingServlet blockingServlet = new BlockingServlet(); + this.webServer = factory.getWebServer((context) -> { + Dynamic registration = context.addServlet("blockingServlet", blockingServlet); + registration.addMapping("/blocking"); + }); + this.webServer.start(); + Future request = initiateGetRequest("/blocking"); + blockingServlet.awaitQueue(); + long start = System.currentTimeMillis(); + assertThat(this.webServer.shutDownGracefully()).isFalse(); + long end = System.currentTimeMillis(); + assertThat(end - start).isGreaterThanOrEqualTo(5000); + assertThat(request.isDone()).isFalse(); + blockingServlet.admitOne(); + assertThat(request.get()).isInstanceOf(HttpResponse.class); + } + + @Test + void whenARequestCompletesAndTheConnectionIsClosedDuringGracePeriodThenShutDownGracefullyReturnsTrueBeforePeriodElapses() + throws Exception { + AbstractServletWebServerFactory factory = getFactory(); + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(30)); + factory.setShutdown(shutdown); + BlockingServlet blockingServlet = new BlockingServlet(); + this.webServer = factory.getWebServer((context) -> { + Dynamic registration = context.addServlet("blockingServlet", blockingServlet); + registration.addMapping("/blocking"); + registration.setAsyncSupported(true); + }); + this.webServer.start(); + Future request = initiateGetRequest("/blocking"); + blockingServlet.awaitQueue(); + long start = System.currentTimeMillis(); + Future shutdownResult = initiateGracefulShutdown(); + blockingServlet.admitOne(); + assertThat(shutdownResult.get()).isTrue(); + long end = System.currentTimeMillis(); + assertThat(end - start).isLessThanOrEqualTo(30000); + assertThat(request.get()).isInstanceOf(HttpResponse.class); + } + + @Test + void whenAnAsyncRequestRemainsInFlightThenShutDownGracefullyReturnsFalseAfterPeriodElapses() throws Exception { + AbstractServletWebServerFactory factory = getFactory(); + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(5)); + factory.setShutdown(shutdown); + BlockingAsyncServlet blockingAsyncServlet = new BlockingAsyncServlet(); + this.webServer = factory.getWebServer((context) -> { + Dynamic registration = context.addServlet("blockingServlet", blockingAsyncServlet); + registration.addMapping("/blockingAsync"); + registration.setAsyncSupported(true); + }); + this.webServer.start(); + Future request = initiateGetRequest("/blockingAsync"); + blockingAsyncServlet.awaitQueue(); + long start = System.currentTimeMillis(); + assertThat(this.webServer.shutDownGracefully()).isFalse(); + long end = System.currentTimeMillis(); + assertThat(end - start).isGreaterThanOrEqualTo(5000); + assertThat(request.isDone()).isFalse(); + blockingAsyncServlet.admitOne(); + assertThat(request.get()).isInstanceOf(HttpResponse.class); + } + + @Test + void whenAnAsyncRequestCompletesAndTheConnectionIsClosedDuringGracePeriodThenShutDownGracefullyReturnsTrueBeforePeriodElapses() + throws Exception { + AbstractServletWebServerFactory factory = getFactory(); + Shutdown shutdown = new Shutdown(); + shutdown.setGracePeriod(Duration.ofSeconds(30)); + factory.setShutdown(shutdown); + BlockingAsyncServlet blockingAsyncServlet = new BlockingAsyncServlet(); + this.webServer = factory.getWebServer((context) -> { + Dynamic registration = context.addServlet("blockingServlet", blockingAsyncServlet); + registration.addMapping("/blockingAsync"); + registration.setAsyncSupported(true); + }); + this.webServer.start(); + Future request = initiateGetRequest("/blockingAsync"); + blockingAsyncServlet.awaitQueue(); + long start = System.currentTimeMillis(); + Future shutdownResult = initiateGracefulShutdown(); + blockingAsyncServlet.admitOne(); + assertThat(shutdownResult.get()).isTrue(); + long end = System.currentTimeMillis(); + assertThat(end - start).isLessThanOrEqualTo(30000); + assertThat(request.get(30, TimeUnit.SECONDS)).isInstanceOf(HttpResponse.class); + } + + protected Future initiateGracefulShutdown() { + RunnableFuture future = new FutureTask(() -> this.webServer.shutDownGracefully()); + new Thread(future).start(); + awaitInGracefulShutdown(); + return future; + } + + protected Future initiateGetRequest(String path) { + return initiateGetRequest(HttpClients.createDefault(), path); + } + + protected Future initiateGetRequest(HttpClient httpClient, String path) { + RunnableFuture getRequest = new FutureTask<>(() -> { + try { + HttpResponse response = httpClient + .execute(new HttpGet("http://localhost:" + this.webServer.getPort() + path)); + response.getEntity().getContent().close(); + return response; + } + catch (Exception ex) { + return ex; + } + }); + new Thread(getRequest, "GET " + path).start(); + return getRequest; + } + + protected void awaitInGracefulShutdown() { + while (!this.inGracefulShutdown()) { + try { + Thread.sleep(100); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + } + private void wrapsFailingServletException(WebServerException ex) { Throwable cause = ex.getCause(); while (cause != null) { @@ -1151,6 +1313,8 @@ public abstract class AbstractServletWebServerFactoryTests { protected abstract org.apache.jasper.servlet.JspServlet getJspServlet() throws Exception; + protected abstract boolean inGracefulShutdown(); + protected ServletContextInitializer exampleServletRegistration() { return new ServletRegistrationBean<>(new ExampleServlet(), "/hello"); } @@ -1315,4 +1479,96 @@ public abstract class AbstractServletWebServerFactoryTests { } + protected static class BlockingServlet extends HttpServlet { + + private final BlockingQueue barriers = new ArrayBlockingQueue<>(10); + + public BlockingServlet() { + + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + CyclicBarrier barrier = new CyclicBarrier(2); + this.barriers.add(barrier); + try { + barrier.await(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + catch (BrokenBarrierException ex) { + throw new ServletException(ex); + } + } + + public void admitOne() { + try { + this.barriers.take().await(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + catch (BrokenBarrierException ex) { + throw new RuntimeException(ex); + } + } + + public void awaitQueue() throws InterruptedException { + while (this.barriers.isEmpty()) { + Thread.sleep(100); + } + } + + public void awaitQueue(int size) throws InterruptedException { + while (this.barriers.size() < size) { + Thread.sleep(100); + } + } + + } + + static class BlockingAsyncServlet extends HttpServlet { + + private final BlockingQueue barriers = new ArrayBlockingQueue<>(10); + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + CyclicBarrier barrier = new CyclicBarrier(2); + this.barriers.add(barrier); + AsyncContext async = req.startAsync(); + new Thread(() -> { + try { + barrier.await(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + catch (BrokenBarrierException ex) { + + } + async.complete(); + }).start(); + } + + private void admitOne() { + try { + this.barriers.take().await(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + catch (BrokenBarrierException ex) { + throw new RuntimeException(ex); + } + } + + private void awaitQueue() throws InterruptedException { + while (this.barriers.isEmpty()) { + Thread.sleep(100); + } + } + + } + }