]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-10104 async webhooks with full in-memory implementation
authorSébastien Lesaint <sebastien.lesaint@sonarsource.com>
Tue, 21 Nov 2017 15:00:19 +0000 (16:00 +0100)
committerSébastien Lesaint <sebastien.lesaint@sonarsource.com>
Fri, 24 Nov 2017 08:23:58 +0000 (09:23 +0100)
17 files changed:
server/sonar-ce/src/main/java/org/sonar/ce/container/ComputeEngineContainerImpl.java
server/sonar-ce/src/test/java/org/sonar/ce/container/ComputeEngineContainerImplTest.java
server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecution.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecutionExecutorService.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecutionExecutorServiceImpl.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecutionImpl.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecutionModule.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/async/package-info.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/computation/task/projectanalysis/container/ProjectAnalysisTaskContainerPopulator.java
server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel3.java
server/sonar-server/src/main/java/org/sonar/server/util/AbstractStoppableExecutorService.java
server/sonar-server/src/main/java/org/sonar/server/webhook/WebHooksImpl.java
server/sonar-server/src/test/java/org/sonar/server/async/AsyncExecutionExecutorServiceImplTest.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/async/AsyncExecutionImplTest.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/webhook/AsynchronousWebHooksImplTest.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/webhook/SynchronousWebHooksImplTest.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/webhook/WebHooksImplTest.java [deleted file]

index 9bcaaad2909647b427e1d5043976dff8c6fbfc1b..febc63da76f87aaccb8ee9402450af0e43d52663 100644 (file)
@@ -78,6 +78,7 @@ import org.sonar.process.NetworkUtilsImpl;
 import org.sonar.process.ProcessProperties;
 import org.sonar.process.Props;
 import org.sonar.process.logging.LogbackHelper;
+import org.sonar.server.async.AsyncExecutionModule;
 import org.sonar.server.component.ComponentFinder;
 import org.sonar.server.component.index.ComponentIndexer;
 import org.sonar.server.computation.task.projectanalysis.ProjectAnalysisTaskModule;
@@ -153,6 +154,7 @@ import org.sonar.server.user.index.UserIndexer;
 import org.sonar.server.util.OkHttpClientProvider;
 import org.sonar.server.view.index.ViewIndex;
 import org.sonar.server.view.index.ViewIndexer;
+import org.sonar.server.webhook.WebhookModule;
 import org.sonarqube.ws.Rules;
 
 import static java.util.Objects.requireNonNull;
@@ -309,7 +311,8 @@ public class ComputeEngineContainerImpl implements ComputeEngineContainer {
       ServerIdManager.class,
       UriReader.class,
       ServerImpl.class,
-      DefaultOrganizationProviderImpl.class);
+      DefaultOrganizationProviderImpl.class,
+      AsyncExecutionModule.class);
   }
 
   private static void populateLevel4(ComponentContainer container, Props props) {
@@ -422,6 +425,9 @@ public class ComputeEngineContainerImpl implements ComputeEngineContainer {
       InternalPropertiesImpl.class,
       ProjectConfigurationFactory.class,
 
+      // webhooks
+      WebhookModule.class,
+
       // cleaning
       CeCleaningModule.class);
 
index 8c94f57d8e81d7944ad18f841013f3cc3ab40029..0793ae11024faf9d8f1fc7f2eae9bed6da878ecc 100644 (file)
@@ -91,7 +91,7 @@ public class ComputeEngineContainerImplTest {
     assertThat(picoContainer.getComponentAdapters())
       .hasSize(
         CONTAINER_ITSELF
-          + 76 // level 4
+          + 77 // level 4
           + 6 // content of CeConfigurationModule
           + 4 // content of CeQueueModule
           + 4 // content of CeHttpModule
@@ -100,16 +100,18 @@ public class ComputeEngineContainerImplTest {
           + 7 // content of CeTaskProcessorModule
           + 4 // content of ReportAnalysisFailureNotificationModule
           + 3 // CeCleaningModule + its content
+          + 4 // WebhookModule
           + 1 // CeDistributedInformation
     );
     assertThat(picoContainer.getParent().getComponentAdapters()).hasSize(
       CONTAINER_ITSELF
-        + 5 // level 3
+        + 6 // level 3
+        + 2 // AsyncExecutionModule
     );
     assertThat(picoContainer.getParent().getParent().getComponentAdapters()).hasSize(
       CONTAINER_ITSELF
-        + 13 // MigrationConfigurationModule
         + 17 // level 2
+        + 13 // MigrationConfigurationModule
     );
     assertThat(picoContainer.getParent().getParent().getParent().getComponentAdapters()).hasSize(
       COMPONENTS_IN_LEVEL_1_AT_CONSTRUCTION
diff --git a/server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecution.java b/server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecution.java
new file mode 100644 (file)
index 0000000..f98d582
--- /dev/null
@@ -0,0 +1,31 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.async;
+
+public interface AsyncExecution {
+  /**
+   * Add the specified {@link Runnable} in queue for asynchronous processing.
+   *
+   * This method returns instantly and {@code r} is executed in another thread.
+   *
+   * @throws NullPointerException if r is {@code null}
+   */
+  void addToQueue(Runnable r);
+}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecutionExecutorService.java b/server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecutionExecutorService.java
new file mode 100644 (file)
index 0000000..bade997
--- /dev/null
@@ -0,0 +1,24 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.async;
+
+public interface AsyncExecutionExecutorService {
+  void addToQueue(Runnable r);
+}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecutionExecutorServiceImpl.java b/server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecutionExecutorServiceImpl.java
new file mode 100644 (file)
index 0000000..60c87fc
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.async;
+
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+import org.sonar.server.util.AbstractStoppableExecutorService;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+public class AsyncExecutionExecutorServiceImpl
+  extends AbstractStoppableExecutorService<ExecutorService>
+  implements AsyncExecutionExecutorService {
+  private static final Logger LOG = Loggers.get(AsyncExecutionExecutorServiceImpl.class);
+
+  private static final int MIN_THREAD_COUNT = 1;
+  private static final int MAX_THREAD_COUNT = 10;
+  private static final int MAX_QUEUE_SIZE = Integer.MAX_VALUE;
+  private static final long KEEP_ALIVE_TIME_IN_MILLISECONDS = 0L;
+
+  public AsyncExecutionExecutorServiceImpl() {
+    super(
+      new ThreadPoolExecutor(
+        MIN_THREAD_COUNT, MAX_THREAD_COUNT,
+        KEEP_ALIVE_TIME_IN_MILLISECONDS, MILLISECONDS,
+        new LinkedBlockingQueue<>(MAX_QUEUE_SIZE),
+        new ThreadFactoryBuilder()
+          .setDaemon(false)
+          .setNameFormat("SQ_async-%d")
+          .setUncaughtExceptionHandler(((t, e) -> LOG.error("Thread " + t + " failed unexpectedly", e)))
+          .build()));
+  }
+
+  @Override
+  public void addToQueue(Runnable r) {
+    this.submit(r);
+  }
+}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecutionImpl.java b/server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecutionImpl.java
new file mode 100644 (file)
index 0000000..d730bc3
--- /dev/null
@@ -0,0 +1,46 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.async;
+
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+
+import static java.util.Objects.requireNonNull;
+
+public class AsyncExecutionImpl implements AsyncExecution {
+  private static final Logger LOG = Loggers.get(AsyncExecutionImpl.class);
+  private final AsyncExecutionExecutorService executorService;
+
+  public AsyncExecutionImpl(AsyncExecutionExecutorService executorService) {
+    this.executorService = executorService;
+  }
+
+  @Override
+  public void addToQueue(Runnable r) {
+    requireNonNull(r);
+    executorService.addToQueue(() -> {
+      try {
+        r.run();
+      } catch (Exception e) {
+        LOG.error("Asynchronous task failed", e);
+      }
+    });
+  }
+}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecutionModule.java b/server/sonar-server/src/main/java/org/sonar/server/async/AsyncExecutionModule.java
new file mode 100644 (file)
index 0000000..1e5673c
--- /dev/null
@@ -0,0 +1,31 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.async;
+
+import org.sonar.core.platform.Module;
+
+public class AsyncExecutionModule extends Module {
+  @Override
+  protected void configureModule() {
+    add(
+      AsyncExecutionExecutorServiceImpl.class,
+      AsyncExecutionImpl.class);
+  }
+}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/async/package-info.java b/server/sonar-server/src/main/java/org/sonar/server/async/package-info.java
new file mode 100644 (file)
index 0000000..0934fef
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.server.async;
+
+import javax.annotation.ParametersAreNonnullByDefault;
index 6c49120776fd52733887bd2213796029ee4ce9e1..88fc743e11e9764dbe5a9a63cebf8170d8334254 100644 (file)
@@ -122,7 +122,6 @@ import org.sonar.server.computation.task.step.ComputationStepExecutor;
 import org.sonar.server.computation.task.step.ComputationSteps;
 import org.sonar.server.computation.taskprocessor.MutableTaskResultHolderImpl;
 import org.sonar.server.view.index.ViewIndex;
-import org.sonar.server.webhook.WebhookModule;
 
 public final class ProjectAnalysisTaskContainerPopulator implements ContainerPopulator<TaskContainer> {
   private static final ReportAnalysisComponentProvider[] NO_REPORT_ANALYSIS_COMPONENT_PROVIDERS = new ReportAnalysisComponentProvider[0];
@@ -272,7 +271,6 @@ public final class ProjectAnalysisTaskContainerPopulator implements ContainerPop
       SmallChangesetQualityGateSpecialCase.class,
 
       // webhooks
-      WebhookModule.class,
       WebhookPostTask.class);
   }
 
index d7c3a3753b6c6bdcf6d3fa24444445a2ec1e65f9..9c4ab3de7205f2ee674989220147a2d26458f1ef 100644 (file)
@@ -21,6 +21,7 @@ package org.sonar.server.platform.platformlevel;
 
 import org.sonar.api.utils.UriReader;
 import org.sonar.core.util.DefaultHttpDownloader;
+import org.sonar.server.async.AsyncExecutionModule;
 import org.sonar.server.organization.DefaultOrganizationProviderImpl;
 import org.sonar.server.organization.OrganizationFlagsImpl;
 import org.sonar.server.platform.ServerIdManager;
@@ -47,6 +48,7 @@ public class PlatformLevel3 extends PlatformLevel {
       UriReader.class,
       DefaultHttpDownloader.class,
       DefaultOrganizationProviderImpl.class,
-      OrganizationFlagsImpl.class);
+      OrganizationFlagsImpl.class,
+      AsyncExecutionModule.class);
   }
 }
index 5a926a55ca5d513cdd0a65a72774def71ed9fd6a..a3e331846fcef5a792bf5609c824d2ba2096cbf6 100644 (file)
@@ -36,10 +36,10 @@ import static java.lang.String.format;
  * Abstract implementation of StoppableExecutorService that implements the
  * stop() method and delegates all methods to the provided ExecutorService instance.
  */
-public abstract class AbstractStoppableExecutorService<T extends ExecutorService> implements StoppableExecutorService {
-  protected final T delegate;
+public abstract class AbstractStoppableExecutorService<D extends ExecutorService> implements StoppableExecutorService {
+  protected final D delegate;
 
-  public AbstractStoppableExecutorService(T delegate) {
+  public AbstractStoppableExecutorService(D delegate) {
     this.delegate = delegate;
   }
 
index b839d8c43fe45a5263a3c79fcf7c5057fb349cdc..8554484fe5b67dd5ad2b6fda0b8a9dcbd15b66e7 100644 (file)
@@ -30,6 +30,7 @@ import org.sonar.api.utils.log.Logger;
 import org.sonar.api.utils.log.Loggers;
 import org.sonar.core.config.WebhookProperties;
 import org.sonar.core.util.stream.MoreCollectors;
+import org.sonar.server.async.AsyncExecution;
 
 import static java.lang.String.format;
 import static org.sonar.core.config.WebhookProperties.MAX_WEBHOOKS_PER_TYPE;
@@ -41,10 +42,12 @@ public class WebHooksImpl implements WebHooks {
 
   private final WebhookCaller caller;
   private final WebhookDeliveryStorage deliveryStorage;
+  private final AsyncExecution asyncExecution;
 
-  public WebHooksImpl(WebhookCaller caller, WebhookDeliveryStorage deliveryStorage) {
+  public WebHooksImpl(WebhookCaller caller, WebhookDeliveryStorage deliveryStorage, AsyncExecution asyncExecution) {
     this.caller = caller;
     this.deliveryStorage = deliveryStorage;
+    this.asyncExecution = asyncExecution;
   }
 
   @Override
@@ -88,12 +91,12 @@ public class WebHooksImpl implements WebHooks {
     }
 
     WebhookPayload payload = payloadSupplier.get();
-    webhooks.forEach(webhook -> {
+    webhooks.forEach(webhook -> asyncExecution.addToQueue(() -> {
       WebhookDelivery delivery = caller.call(webhook, payload);
       log(delivery);
       deliveryStorage.persist(delivery);
-    });
-    deliveryStorage.purge(analysis.getProjectUuid());
+    }));
+    asyncExecution.addToQueue(() -> deliveryStorage.purge(analysis.getProjectUuid()));
   }
 
   private static void log(WebhookDelivery delivery) {
diff --git a/server/sonar-server/src/test/java/org/sonar/server/async/AsyncExecutionExecutorServiceImplTest.java b/server/sonar-server/src/test/java/org/sonar/server/async/AsyncExecutionExecutorServiceImplTest.java
new file mode 100644 (file)
index 0000000..f8e0976
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.async;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class AsyncExecutionExecutorServiceImplTest {
+  private AsyncExecutionExecutorServiceImpl underTest = new AsyncExecutionExecutorServiceImpl();
+
+  @Test
+  public void submit_executes_runnable_in_another_thread() {
+    try (SlowRunnable slowRunnable = new SlowRunnable()) {
+      underTest.submit(slowRunnable);
+      assertThat(slowRunnable.executed).isFalse();
+    }
+  }
+
+  private static final class SlowRunnable implements Runnable, AutoCloseable {
+    private final CountDownLatch latch = new CountDownLatch(1);
+    private volatile boolean executed = false;
+
+    @Override
+    public void run() {
+      try {
+        latch.await(30, TimeUnit.SECONDS);
+      } catch (InterruptedException e) {
+        // ignore
+      }
+      executed = true;
+    }
+
+    @Override
+    public void close() {
+      latch.countDown();
+    }
+  }
+}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/async/AsyncExecutionImplTest.java b/server/sonar-server/src/test/java/org/sonar/server/async/AsyncExecutionImplTest.java
new file mode 100644 (file)
index 0000000..f98001c
--- /dev/null
@@ -0,0 +1,68 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.async;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.sonar.api.utils.log.LogTester;
+import org.sonar.api.utils.log.LoggerLevel;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class AsyncExecutionImplTest {
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+  @Rule
+  public LogTester logTester = new LogTester();
+
+  private AsyncExecutionExecutorService synchronousExecutorService = Runnable::run;
+  private AsyncExecutionImpl underTest = new AsyncExecutionImpl(synchronousExecutorService);
+
+  @Test
+  public void addToQueue_fails_with_NPE_if_Runnable_is_null() {
+    expectedException.expect(NullPointerException.class);
+
+    underTest.addToQueue(null);
+  }
+
+  @Test
+  public void addToQueue_submits_runnable_to_executorService_which_does_not_fail_if_Runnable_argument_throws_exception() {
+    underTest.addToQueue(() -> {
+      throw new RuntimeException("Faking an exception thrown by Runnable argument");
+    });
+
+    assertThat(logTester.logs()).hasSize(1);
+    assertThat(logTester.logs(LoggerLevel.ERROR)).containsOnly("Asynchronous task failed");
+  }
+
+  @Test
+  public void addToQueue_submits_runnable_that_fails_if_Runnable_argument_throws_Error() {
+    Error expected = new Error("Faking an exception thrown by Runnable argument");
+    Runnable runnable = () -> {
+      throw expected;
+    };
+
+    expectedException.expect(Error.class);
+    expectedException.expectMessage(expected.getMessage());
+
+    underTest.addToQueue(runnable);
+  }
+}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/webhook/AsynchronousWebHooksImplTest.java b/server/sonar-server/src/test/java/org/sonar/server/webhook/AsynchronousWebHooksImplTest.java
new file mode 100644 (file)
index 0000000..5e0bfa2
--- /dev/null
@@ -0,0 +1,85 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.webhook;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+import org.sonar.api.config.internal.MapSettings;
+import org.sonar.server.async.AsyncExecution;
+
+import static java.util.Objects.requireNonNull;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+
+public class AsynchronousWebHooksImplTest {
+  private static final long NOW = 1_500_000_000_000L;
+  private static final String PROJECT_UUID = "P1_UUID";
+
+  private final MapSettings settings = new MapSettings();
+  private final TestWebhookCaller caller = new TestWebhookCaller();
+  private final WebhookDeliveryStorage deliveryStorage = mock(WebhookDeliveryStorage.class);
+  private final WebhookPayload mock = mock(WebhookPayload.class);
+  private final RecordingAsyncExecution asyncExecution = new RecordingAsyncExecution();
+
+  private final WebHooksImpl underTest = new WebHooksImpl(caller, deliveryStorage, asyncExecution);
+
+  @Test
+  public void send_global_webhooks() {
+    settings.setProperty("sonar.webhooks.global", "1,2");
+    settings.setProperty("sonar.webhooks.global.1.name", "First");
+    settings.setProperty("sonar.webhooks.global.1.url", "http://url1");
+    settings.setProperty("sonar.webhooks.global.2.name", "Second");
+    settings.setProperty("sonar.webhooks.global.2.url", "http://url2");
+    caller.enqueueSuccess(NOW, 200, 1_234);
+    caller.enqueueFailure(NOW, new IOException("Fail to connect"));
+
+    underTest.sendProjectAnalysisUpdate(settings.asConfig(), new WebHooks.Analysis(PROJECT_UUID, "1", "#1"), () -> mock);
+
+    assertThat(caller.countSent()).isZero();
+    verifyZeroInteractions(deliveryStorage);
+
+    asyncExecution.executeRecorded();
+
+    assertThat(caller.countSent()).isEqualTo(2);
+    verify(deliveryStorage, times(2)).persist(any(WebhookDelivery.class));
+    verify(deliveryStorage).purge(PROJECT_UUID);
+  }
+
+  private static class RecordingAsyncExecution implements AsyncExecution {
+    private final List<Runnable> runnableList = new ArrayList<>();
+
+    @Override
+    public void addToQueue(Runnable r) {
+      runnableList.add(requireNonNull(r));
+    }
+
+    public void executeRecorded() {
+      ArrayList<Runnable> runnables = new ArrayList<>(runnableList);
+      runnableList.clear();
+      runnables.forEach(Runnable::run);
+    }
+  }
+}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/webhook/SynchronousWebHooksImplTest.java b/server/sonar-server/src/test/java/org/sonar/server/webhook/SynchronousWebHooksImplTest.java
new file mode 100644 (file)
index 0000000..17fc6de
--- /dev/null
@@ -0,0 +1,177 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.webhook;
+
+import java.io.IOException;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.api.config.internal.MapSettings;
+import org.sonar.api.utils.log.LogTester;
+import org.sonar.api.utils.log.LoggerLevel;
+import org.sonar.server.async.AsyncExecution;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+
+public class SynchronousWebHooksImplTest {
+
+  private static final long NOW = 1_500_000_000_000L;
+  private static final String PROJECT_UUID = "P1_UUID";
+
+  @Rule
+  public LogTester logTester = new LogTester();
+
+  private final MapSettings settings = new MapSettings();
+  private final TestWebhookCaller caller = new TestWebhookCaller();
+  private final WebhookDeliveryStorage deliveryStorage = mock(WebhookDeliveryStorage.class);
+  private final WebhookPayload mock = mock(WebhookPayload.class);
+  private final AsyncExecution synchronousAsyncExecution = Runnable::run;
+  private final WebHooksImpl underTest = new WebHooksImpl(caller, deliveryStorage, synchronousAsyncExecution);
+
+  @Test
+  public void isEnabled_returns_false_if_no_webHoolds() {
+    assertThat(underTest.isEnabled(settings.asConfig())).isFalse();
+  }
+
+  @Test
+  public void isEnabled_returns_true_if_one_valid_global_webhook() {
+    settings.setProperty("sonar.webhooks.global", "1");
+    settings.setProperty("sonar.webhooks.global.1.name", "First");
+    settings.setProperty("sonar.webhooks.global.1.url", "http://url1");
+
+    assertThat(underTest.isEnabled(settings.asConfig())).isTrue();
+  }
+
+  @Test
+  public void isEnabled_returns_false_if_only_one_global_webhook_without_url() {
+    settings.setProperty("sonar.webhooks.global", "1");
+    settings.setProperty("sonar.webhooks.global.1.name", "First");
+
+    assertThat(underTest.isEnabled(settings.asConfig())).isFalse();
+  }
+
+  @Test
+  public void isEnabled_returns_false_if_only_one_global_webhook_without_name() {
+    settings.setProperty("sonar.webhooks.global", "1");
+    settings.setProperty("sonar.webhooks.global.1.url", "http://url1");
+
+    assertThat(underTest.isEnabled(settings.asConfig())).isFalse();
+  }
+
+  @Test
+  public void isEnabled_returns_true_if_one_valid_project_webhook() {
+    settings.setProperty("sonar.webhooks.project", "1");
+    settings.setProperty("sonar.webhooks.project.1.name", "First");
+    settings.setProperty("sonar.webhooks.project.1.url", "http://url1");
+
+    assertThat(underTest.isEnabled(settings.asConfig())).isTrue();
+  }
+
+  @Test
+  public void isEnabled_returns_false_if_only_one_project_webhook_without_url() {
+    settings.setProperty("sonar.webhooks.project", "1");
+    settings.setProperty("sonar.webhooks.project.1.name", "First");
+
+    assertThat(underTest.isEnabled(settings.asConfig())).isFalse();
+  }
+
+  @Test
+  public void isEnabled_returns_false_if_only_one_project_webhook_without_name() {
+    settings.setProperty("sonar.webhooks.project", "1");
+    settings.setProperty("sonar.webhooks.project.1.url", "http://url1");
+
+    assertThat(underTest.isEnabled(settings.asConfig())).isFalse();
+  }
+
+  @Test
+  public void do_nothing_if_no_webhooks() {
+    underTest.sendProjectAnalysisUpdate(settings.asConfig(), new WebHooks.Analysis(PROJECT_UUID, "1", "#1"), () -> mock);
+
+    assertThat(caller.countSent()).isEqualTo(0);
+    assertThat(logTester.logs(LoggerLevel.DEBUG)).isEmpty();
+    verifyZeroInteractions(deliveryStorage);
+  }
+
+  @Test
+  public void send_global_webhooks() {
+    settings.setProperty("sonar.webhooks.global", "1,2");
+    settings.setProperty("sonar.webhooks.global.1.name", "First");
+    settings.setProperty("sonar.webhooks.global.1.url", "http://url1");
+    settings.setProperty("sonar.webhooks.global.2.name", "Second");
+    settings.setProperty("sonar.webhooks.global.2.url", "http://url2");
+    caller.enqueueSuccess(NOW, 200, 1_234);
+    caller.enqueueFailure(NOW, new IOException("Fail to connect"));
+
+    underTest.sendProjectAnalysisUpdate(settings.asConfig(), new WebHooks.Analysis(PROJECT_UUID, "1", "#1"), () -> mock);
+
+    assertThat(caller.countSent()).isEqualTo(2);
+    assertThat(logTester.logs(LoggerLevel.DEBUG)).contains("Sent webhook 'First' | url=http://url1 | time=1234ms | status=200");
+    assertThat(logTester.logs(LoggerLevel.DEBUG)).contains("Failed to send webhook 'Second' | url=http://url2 | message=Fail to connect");
+    verify(deliveryStorage, times(2)).persist(any(WebhookDelivery.class));
+    verify(deliveryStorage).purge(PROJECT_UUID);
+  }
+
+  @Test
+  public void send_project_webhooks() {
+    settings.setProperty("sonar.webhooks.project", "1");
+    settings.setProperty("sonar.webhooks.project.1.name", "First");
+    settings.setProperty("sonar.webhooks.project.1.url", "http://url1");
+    caller.enqueueSuccess(NOW, 200, 1_234);
+
+    underTest.sendProjectAnalysisUpdate(settings.asConfig(), new WebHooks.Analysis(PROJECT_UUID, "1", "#1"), () -> mock);
+
+    assertThat(caller.countSent()).isEqualTo(1);
+    assertThat(logTester.logs(LoggerLevel.DEBUG)).contains("Sent webhook 'First' | url=http://url1 | time=1234ms | status=200");
+    verify(deliveryStorage).persist(any(WebhookDelivery.class));
+    verify(deliveryStorage).purge(PROJECT_UUID);
+  }
+
+  @Test
+  public void process_only_the_10_first_global_webhooks() {
+    testMaxWebhooks("sonar.webhooks.global");
+  }
+
+  @Test
+  public void process_only_the_10_first_project_webhooks() {
+    testMaxWebhooks("sonar.webhooks.project");
+  }
+
+  private void testMaxWebhooks(String property) {
+    IntStream.range(1, 15)
+      .forEach(i -> {
+        settings.setProperty(property + "." + i + ".name", "First");
+        settings.setProperty(property + "." + i + ".url", "http://url");
+        caller.enqueueSuccess(NOW, 200, 1_234);
+      });
+    settings.setProperty(property, IntStream.range(1, 15).mapToObj(String::valueOf).collect(Collectors.joining(",")));
+
+    underTest.sendProjectAnalysisUpdate(settings.asConfig(), new WebHooks.Analysis(PROJECT_UUID, "1", "#1"), () -> mock);
+
+    assertThat(caller.countSent()).isEqualTo(10);
+    assertThat(logTester.logs(LoggerLevel.DEBUG).stream().filter(log -> log.contains("Sent"))).hasSize(10);
+  }
+
+}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/webhook/WebHooksImplTest.java b/server/sonar-server/src/test/java/org/sonar/server/webhook/WebHooksImplTest.java
deleted file mode 100644 (file)
index abc72c3..0000000
+++ /dev/null
@@ -1,175 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-package org.sonar.server.webhook;
-
-import java.io.IOException;
-import java.util.stream.Collectors;
-import java.util.stream.IntStream;
-import org.junit.Rule;
-import org.junit.Test;
-import org.sonar.api.config.internal.MapSettings;
-import org.sonar.api.utils.log.LogTester;
-import org.sonar.api.utils.log.LoggerLevel;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Matchers.any;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
-
-public class WebHooksImplTest {
-
-  private static final long NOW = 1_500_000_000_000L;
-  private static final String PROJECT_UUID = "P1_UUID";
-
-  @Rule
-  public LogTester logTester = new LogTester();
-
-  private final MapSettings settings = new MapSettings();
-  private final TestWebhookCaller caller = new TestWebhookCaller();
-  private final WebhookDeliveryStorage deliveryStorage = mock(WebhookDeliveryStorage.class);
-  private final WebhookPayload mock = mock(WebhookPayload.class);
-  private final WebHooksImpl underTest = new WebHooksImpl(caller, deliveryStorage);
-
-  @Test
-  public void isEnabled_returns_false_if_no_webHoolds() {
-    assertThat(underTest.isEnabled(settings.asConfig())).isFalse();
-  }
-
-  @Test
-  public void isEnabled_returns_true_if_one_valid_global_webhook() {
-    settings.setProperty("sonar.webhooks.global", "1");
-    settings.setProperty("sonar.webhooks.global.1.name", "First");
-    settings.setProperty("sonar.webhooks.global.1.url", "http://url1");
-
-    assertThat(underTest.isEnabled(settings.asConfig())).isTrue();
-  }
-
-  @Test
-  public void isEnabled_returns_false_if_only_one_global_webhook_without_url() {
-    settings.setProperty("sonar.webhooks.global", "1");
-    settings.setProperty("sonar.webhooks.global.1.name", "First");
-
-    assertThat(underTest.isEnabled(settings.asConfig())).isFalse();
-  }
-
-  @Test
-  public void isEnabled_returns_false_if_only_one_global_webhook_without_name() {
-    settings.setProperty("sonar.webhooks.global", "1");
-    settings.setProperty("sonar.webhooks.global.1.url", "http://url1");
-
-    assertThat(underTest.isEnabled(settings.asConfig())).isFalse();
-  }
-
-  @Test
-  public void isEnabled_returns_true_if_one_valid_project_webhook() {
-    settings.setProperty("sonar.webhooks.project", "1");
-    settings.setProperty("sonar.webhooks.project.1.name", "First");
-    settings.setProperty("sonar.webhooks.project.1.url", "http://url1");
-
-    assertThat(underTest.isEnabled(settings.asConfig())).isTrue();
-  }
-
-  @Test
-  public void isEnabled_returns_false_if_only_one_project_webhook_without_url() {
-    settings.setProperty("sonar.webhooks.project", "1");
-    settings.setProperty("sonar.webhooks.project.1.name", "First");
-
-    assertThat(underTest.isEnabled(settings.asConfig())).isFalse();
-  }
-
-  @Test
-  public void isEnabled_returns_false_if_only_one_project_webhook_without_name() {
-    settings.setProperty("sonar.webhooks.project", "1");
-    settings.setProperty("sonar.webhooks.project.1.url", "http://url1");
-
-    assertThat(underTest.isEnabled(settings.asConfig())).isFalse();
-  }
-
-  @Test
-  public void do_nothing_if_no_webhooks() {
-    underTest.sendProjectAnalysisUpdate(settings.asConfig(), new WebHooks.Analysis(PROJECT_UUID, "1", "#1"), () -> mock);
-
-    assertThat(caller.countSent()).isEqualTo(0);
-    assertThat(logTester.logs(LoggerLevel.DEBUG)).isEmpty();
-    verifyZeroInteractions(deliveryStorage);
-  }
-
-  @Test
-  public void send_global_webhooks() {
-    settings.setProperty("sonar.webhooks.global", "1,2");
-    settings.setProperty("sonar.webhooks.global.1.name", "First");
-    settings.setProperty("sonar.webhooks.global.1.url", "http://url1");
-    settings.setProperty("sonar.webhooks.global.2.name", "Second");
-    settings.setProperty("sonar.webhooks.global.2.url", "http://url2");
-    caller.enqueueSuccess(NOW, 200, 1_234);
-    caller.enqueueFailure(NOW, new IOException("Fail to connect"));
-
-    underTest.sendProjectAnalysisUpdate(settings.asConfig(), new WebHooks.Analysis(PROJECT_UUID, "1", "#1"), () -> mock);
-
-    assertThat(caller.countSent()).isEqualTo(2);
-    assertThat(logTester.logs(LoggerLevel.DEBUG)).contains("Sent webhook 'First' | url=http://url1 | time=1234ms | status=200");
-    assertThat(logTester.logs(LoggerLevel.DEBUG)).contains("Failed to send webhook 'Second' | url=http://url2 | message=Fail to connect");
-    verify(deliveryStorage, times(2)).persist(any(WebhookDelivery.class));
-    verify(deliveryStorage).purge(PROJECT_UUID);
-  }
-
-  @Test
-  public void send_project_webhooks() {
-    settings.setProperty("sonar.webhooks.project", "1");
-    settings.setProperty("sonar.webhooks.project.1.name", "First");
-    settings.setProperty("sonar.webhooks.project.1.url", "http://url1");
-    caller.enqueueSuccess(NOW, 200, 1_234);
-
-    underTest.sendProjectAnalysisUpdate(settings.asConfig(), new WebHooks.Analysis(PROJECT_UUID, "1", "#1"), () -> mock);
-
-    assertThat(caller.countSent()).isEqualTo(1);
-    assertThat(logTester.logs(LoggerLevel.DEBUG)).contains("Sent webhook 'First' | url=http://url1 | time=1234ms | status=200");
-    verify(deliveryStorage).persist(any(WebhookDelivery.class));
-    verify(deliveryStorage).purge(PROJECT_UUID);
-  }
-
-  @Test
-  public void process_only_the_10_first_global_webhooks() {
-    testMaxWebhooks("sonar.webhooks.global");
-  }
-
-  @Test
-  public void process_only_the_10_first_project_webhooks() {
-    testMaxWebhooks("sonar.webhooks.project");
-  }
-
-  private void testMaxWebhooks(String property) {
-    IntStream.range(1, 15)
-      .forEach(i -> {
-        settings.setProperty(property + "." + i + ".name", "First");
-        settings.setProperty(property + "." + i + ".url", "http://url");
-        caller.enqueueSuccess(NOW, 200, 1_234);
-      });
-    settings.setProperty(property, IntStream.range(1, 15).mapToObj(String::valueOf).collect(Collectors.joining(",")));
-
-    underTest.sendProjectAnalysisUpdate(settings.asConfig(), new WebHooks.Analysis(PROJECT_UUID, "1", "#1"), () -> mock);
-
-    assertThat(caller.countSent()).isEqualTo(10);
-    assertThat(logTester.logs(LoggerLevel.DEBUG).stream().filter(log -> log.contains("Sent"))).hasSize(10);
-  }
-
-}