Skip to content

Commit

Permalink
Retry when Spring tests fails to start a Server with a random port …
Browse files Browse the repository at this point in the history
…obtained in advance (#4395)

Motivation:

If a random port (0) for internal services or managed services is
specified in Spring configuration, the Spring integration module
allocate available ports early and use them to configure additional
service later.
https://github.com/line/armeria/blob/948763acc7911c951e37dbd35132edd253ea3934/spring/boot2-actuator-autoconfigure/src/main/java/com/linecorp/armeria/spring/actuate/ArmeriaSpringActuatorAutoConfiguration.java#L258-L260
This is not a problem in production mode.
However, in testing situations, many test servers are started simultaneously 
with a random port and the available ports can be acquired by them. #4391 #4329

Modifications:

- Add `RetryableArmeriaServerGracefulShutdownLifecycle` to retry a
server with a backoff at most N times.

Result:

- This is not a perfect answer for the failure but it might reduce the
  number of failures.
- Fixes #4391 #4329
  • Loading branch information
ikhoon committed Sep 8, 2022
1 parent 712cb0d commit 8e96b96
Show file tree
Hide file tree
Showing 5 changed files with 182 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ public Server armeriaServer(
* Wrap {@link Server} with {@link SmartLifecycle}.
*/
@Bean
@ConditionalOnMissingBean(SmartLifecycle.class)
public SmartLifecycle armeriaServerGracefulShutdownLifecycle(Server server) {
return new ArmeriaServerGracefulShutdownLifecycle(server);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import org.junit.runner.RunWith;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.annotation.Bean;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
Expand All @@ -46,6 +47,11 @@ static class TestConfiguration {
ArmeriaServerConfigurator maxNumConnectionsConfigurator() {
return builder -> builder.maxNumConnections(16);
}

@Bean
SmartLifecycle smartLifecycle(Server server) {
return new RetryableArmeriaServerGracefulShutdownLifecycle(server, 8);
}
}

@Inject
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
import org.junit.runner.RunWith;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
Expand All @@ -50,7 +52,13 @@ public class LocalArmeriaPortHttpsTest {

@SpringBootApplication
@Import(ArmeriaOkServiceConfiguration.class)
static class TestConfiguration {}
static class TestConfiguration {

@Bean
SmartLifecycle smartLifecycle(Server server) {
return new RetryableArmeriaServerGracefulShutdownLifecycle(server, 8);
}
}

private static final ClientFactory clientFactory =
ClientFactory.builder()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright 2022 LINE Corporation
*
* LINE Corporation licenses this file to you 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 com.linecorp.armeria.spring;

import java.util.concurrent.TimeUnit;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.SmartLifecycle;

import com.google.common.util.concurrent.Uninterruptibles;

import com.linecorp.armeria.client.retry.Backoff;
import com.linecorp.armeria.common.util.Exceptions;
import com.linecorp.armeria.server.Server;

/**
* A {@link SmartLifecycle} which retries to start the {@link Server} up to {@code maxAttempts}.
* This is useful for testing that needs to bind a server to a random port number obtained in advance.
*/
final class RetryableArmeriaServerGracefulShutdownLifecycle implements SmartLifecycle {

private static final Logger logger =
LoggerFactory.getLogger(RetryableArmeriaServerGracefulShutdownLifecycle.class);

private final SmartLifecycle delegate;
private final int maxAttempts;
private final Backoff backoff;

RetryableArmeriaServerGracefulShutdownLifecycle(Server server, int maxAttempts) {
delegate = new ArmeriaServerGracefulShutdownLifecycle(server);
this.maxAttempts = maxAttempts;
backoff = Backoff.ofDefault();
}

@Override
public void start() {
Throwable caughtException = null;
for (int i = 1; i <= maxAttempts; i++) {
try {
delegate.start();
return;
} catch (Exception ex) {
caughtException = Exceptions.peel(ex);
if (i < maxAttempts) {
final long delayMillis = backoff.nextDelayMillis(i);
logger.debug("{}; retrying in {} ms (attempts so far: {})",
ex.getMessage(), delayMillis, i, ex);
Uninterruptibles.sleepUninterruptibly(delayMillis, TimeUnit.MILLISECONDS);
}
}
}

assert caughtException != null;
Exceptions.throwUnsafely(caughtException);
}

@Override
public void stop() {
delegate.stop();
}

@Override
public void stop(Runnable callback) {
delegate.stop(callback);
}

@Override
public boolean isAutoStartup() {
return delegate.isAutoStartup();
}

@Override
public int getPhase() {
return delegate.getPhase();
}

@Override
public boolean isRunning() {
return delegate.isRunning();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright 2022 LINE Corporation
*
* LINE Corporation licenses this file to you 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 com.linecorp.armeria.spring;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.awaitility.Awaitility.await;

import java.io.IOException;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;

import org.junit.Test;
import org.springframework.context.SmartLifecycle;

import com.linecorp.armeria.common.CommonPools;
import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.server.Server;

public class RetryableArmeriaServerGracefulShutdownLifecycleTest {

@Test
public void retryStarting() throws InterruptedException {
final Server dummyServer = Server.builder()
.service("/", (ctx, req) -> HttpResponse.of("OK"))
.build();
dummyServer.start().join();

final Server failingServer = Server.builder().port(dummyServer.activePort())
.service("/", (ctx, req) -> HttpResponse.of("OK"))
.build();

// Make sure that `failingServer` is unable to start because of the port collision.
assertThatThrownBy(() -> failingServer.start().join())
.isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(IOException.class)
.hasMessageContaining("Address already in use");

final SmartLifecycle lifecycle =
new RetryableArmeriaServerGracefulShutdownLifecycle(failingServer, 100);
final AtomicBoolean success = new AtomicBoolean();

final CountDownLatch latch = new CountDownLatch(1);
CommonPools.blockingTaskExecutor().execute(() -> {
latch.countDown();
lifecycle.start();
success.set(true);
});

latch.await();
// Wait for the retry to work
Thread.sleep(1000);
dummyServer.stop().join();
await().untilTrue(success);
}
}

0 comments on commit 8e96b96

Please sign in to comment.