diff --git a/scm-webapp/src/main/java/sonia/scm/admin/ReleaseFeedParser.java b/scm-webapp/src/main/java/sonia/scm/admin/ReleaseFeedParser.java index 128699e0d4..4c54a96708 100644 --- a/scm-webapp/src/main/java/sonia/scm/admin/ReleaseFeedParser.java +++ b/scm-webapp/src/main/java/sonia/scm/admin/ReleaseFeedParser.java @@ -31,25 +31,71 @@ import sonia.scm.net.ahc.AdvancedHttpClient; import sonia.scm.version.Version; import javax.inject.Inject; +import javax.inject.Singleton; import java.io.IOException; import java.util.Comparator; import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +@Singleton public class ReleaseFeedParser { + public static final int DEFAULT_TIMEOUT_IN_MILLIS = 1000; + private static final Logger LOG = LoggerFactory.getLogger(ReleaseFeedParser.class); private final AdvancedHttpClient client; + private final ExecutorService executorService; + private final long timeoutInMillis; + private Future> updateInfoFuture; @Inject public ReleaseFeedParser(AdvancedHttpClient client) { + this(client, DEFAULT_TIMEOUT_IN_MILLIS); + } + + public ReleaseFeedParser(AdvancedHttpClient client, long timeoutInMillis) { this.client = client; + this.timeoutInMillis = timeoutInMillis; + this.executorService = Executors.newSingleThreadExecutor(); } Optional findLatestRelease(String url) { - LOG.info("Search for newer versions of SCM-Manager"); - Optional latestRelease = parseLatestReleaseFromRssFeed(url); - return latestRelease.map(release -> new UpdateInfo(release.getTitle(), release.getLink())); + Future> currentUpdateInfoFuture; + boolean updateInfoFutureCreated = false; + try { + synchronized (this) { + currentUpdateInfoFuture = this.updateInfoFuture; + if (currentUpdateInfoFuture == null) { + currentUpdateInfoFuture = submitRequest(url); + this.updateInfoFuture = currentUpdateInfoFuture; + updateInfoFutureCreated = true; + } + } + try { + return currentUpdateInfoFuture.get(timeoutInMillis, TimeUnit.MILLISECONDS); + } catch (Exception e) { + LOG.error("Could not read release feed", e); + return Optional.empty(); + } + } finally { + if (updateInfoFutureCreated) { + synchronized (this) { + this.updateInfoFuture = null; + } + } + } + } + + private Future> submitRequest(String url) { + return executorService.submit(() -> { + LOG.info("Search for newer versions of SCM-Manager"); + Optional latestRelease = parseLatestReleaseFromRssFeed(url); + return latestRelease.map(release -> new UpdateInfo(release.getTitle(), release.getLink())); + }); } private Optional parseLatestReleaseFromRssFeed(String url) { diff --git a/scm-webapp/src/test/java/sonia/scm/admin/ReleaseFeedParserTest.java b/scm-webapp/src/test/java/sonia/scm/admin/ReleaseFeedParserTest.java index 1c58697da7..590cd905ac 100644 --- a/scm-webapp/src/test/java/sonia/scm/admin/ReleaseFeedParserTest.java +++ b/scm-webapp/src/test/java/sonia/scm/admin/ReleaseFeedParserTest.java @@ -25,10 +25,10 @@ package sonia.scm.admin; import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Answers; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.net.ahc.AdvancedHttpClient; @@ -36,6 +36,11 @@ import sonia.scm.net.ahc.AdvancedHttpClient; import java.io.IOException; import java.util.Date; import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.Semaphore; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; @@ -46,20 +51,62 @@ class ReleaseFeedParserTest { @Mock(answer = Answers.RETURNS_DEEP_STUBS) AdvancedHttpClient client; - @InjectMocks ReleaseFeedParser releaseFeedParser; + @BeforeEach + void createSut() { + releaseFeedParser = new ReleaseFeedParser(client, 100); + } + @Test void shouldFindLatestRelease() throws IOException { String url = "https://www.scm-manager.org/download/rss.xml"; when(client.get(url).request().contentFromXml(ReleaseFeedDto.class)).thenReturn(createReleaseFeedDto()); - Optional release = releaseFeedParser.findLatestRelease(url); + Optional update = releaseFeedParser.findLatestRelease(url); - assertThat(release).isPresent(); - assertThat(release.get().getLatestVersion()).isEqualTo("3"); - assertThat(release.get().getLink()).isEqualTo("download-3"); + assertThat(update).isPresent(); + assertThat(update.get().getLatestVersion()).isEqualTo("3"); + assertThat(update.get().getLink()).isEqualTo("download-3"); + } + + @Test + void shouldHandleTimeout() throws IOException { + String url = "https://www.scm-manager.org/download/rss.xml"; + + Semaphore waitWithResultUntilTimeout = new Semaphore(0); + + when(client.get(url).request().contentFromXml(ReleaseFeedDto.class)).thenAnswer(invocation -> { + waitWithResultUntilTimeout.acquire(); + return createReleaseFeedDto(); + }); + + Optional update = releaseFeedParser.findLatestRelease(url); + + waitWithResultUntilTimeout.release(); + + assertThat(update).isEmpty(); + } + + @Test + void shouldNotQueryInParallel() throws IOException, ExecutionException, InterruptedException { + String url = "https://www.scm-manager.org/download/rss.xml"; + + Semaphore waitWithResultUntilBothTriggered = new Semaphore(0); + + when(client.get(url).request().contentFromXml(ReleaseFeedDto.class)).thenAnswer(invocation -> { + waitWithResultUntilBothTriggered.acquire(); + return createReleaseFeedDto(); + }); + + final ExecutorService executorService = Executors.newFixedThreadPool(2); + Future> update1 = executorService.submit(() -> releaseFeedParser.findLatestRelease(url)); + Future> update2 = executorService.submit(() -> releaseFeedParser.findLatestRelease(url)); + + waitWithResultUntilBothTriggered.release(2); + + assertThat(update1.get()).containsSame(update2.get().get()); } private ReleaseFeedDto createReleaseFeedDto() {