This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new 729ffe8f6 [UNOMI-1025] Restore the healthcheck provider timeout (#931)
729ffe8f6 is described below
commit 729ffe8f6dfd3e601aa6a90fa3f48393da560bfe
Author: Jerome Blanchard <[email protected]>
AuthorDate: Thu Oct 29 11:20:58 2026 +0100
[UNOMI-1025] Restore the healthcheck provider timeout (#931)
Co-authored-by: Serge Huber <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../unomi/healthcheck/HealthCheckService.java | 49 +++++--
.../provider/ElasticSearchHealthCheckProvider.java | 9 +-
.../provider/OpenSearchHealthCheckProvider.java | 9 +-
.../unomi/healthcheck/HealthCheckServiceTest.java | 157 +++++++++++++++++++++
4 files changed, 213 insertions(+), 11 deletions(-)
diff --git
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
index 5a6f181ba..9d13c4052 100644
---
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
+++
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
@@ -30,6 +30,13 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
import static org.apache.unomi.healthcheck.HealthCheckConfig.CONFIG_AUTH_REALM;
@@ -42,7 +49,10 @@ public class HealthCheckService {
private static final Logger LOGGER =
LoggerFactory.getLogger(HealthCheckService.class.getName());
- private final List<HealthCheckProvider> providers = new ArrayList<>();
+ private final List<HealthCheckProvider> providers = new
CopyOnWriteArrayList<>();
+ private final AtomicInteger threadCount = new AtomicInteger();
+ private final ExecutorService executor = Executors.newCachedThreadPool(
+ r -> new Thread(r, "unomi-healthcheck-" +
threadCount.incrementAndGet()));
private final Object cacheLock = new Object();
private volatile long cacheTimestamp = 0L;
private volatile List<HealthCheckResponse> healthCache =
Collections.emptyList();
@@ -68,7 +78,7 @@ public class HealthCheckService {
}
@Reference(service = HealthCheckConfig.class, policy =
ReferencePolicy.DYNAMIC, updated = "setConfig")
- private void setConfig(HealthCheckConfig config) throws ServletException,
NamespaceException {
+ protected void setConfig(HealthCheckConfig config) throws
ServletException, NamespaceException {
this.config = config;
if (httpService == null ) {
LOGGER.info("Healthcheck config with {} entrie(s) did not update
the service as not fully started yet.", config.getSize());
@@ -103,6 +113,7 @@ public class HealthCheckService {
httpService.unregister("/health/check");
registered = false;
}
+ executor.shutdownNow();
}
@Reference(service = HealthCheckProvider.class, cardinality =
ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC, unbind =
"unbind")
@@ -130,7 +141,7 @@ public class HealthCheckService {
}
} else if (shouldRefreshCache()) {
synchronized (cacheLock) {
- if (!busy) {
+ if (isCacheExpired()) {
busy = true;
try {
refreshCache();
@@ -144,21 +155,41 @@ public class HealthCheckService {
}
private boolean shouldRefreshCache() {
- return !busy && (System.currentTimeMillis() - cacheTimestamp) > 1000;
+ return !busy && isCacheExpired();
+ }
+
+ private boolean isCacheExpired() {
+ return (System.currentTimeMillis() - cacheTimestamp) > 1000;
}
private void refreshCache() {
try {
List<HealthCheckResponse> health = new ArrayList<>();
health.add(HealthCheckResponse.live("karaf"));
- for (HealthCheckProvider provider : providers.stream()
+ List<HealthCheckProvider> enabledProviders = providers.stream()
.filter(p ->
config.getEnabledProviders().contains(p.name()))
- .toList()) {
+ .toList();
+ List<Future<HealthCheckResponse>> futures = new ArrayList<>();
+ for (HealthCheckProvider provider : enabledProviders) {
+ futures.add(executor.submit(provider::execute));
+ }
+ long deadline = System.nanoTime() +
TimeUnit.MILLISECONDS.toNanos(config.getTimeout());
+ for (int i = 0; i < enabledProviders.size(); i++) {
+ HealthCheckProvider provider = enabledProviders.get(i);
+ Future<HealthCheckResponse> future = futures.get(i);
try {
- HealthCheckResponse response = provider.execute();
- health.add(response);
+ health.add(future.get(Math.max(0, deadline -
System.nanoTime()), TimeUnit.NANOSECONDS));
+ } catch (TimeoutException e) {
+ future.cancel(true);
+ LOGGER.warn("Health check {} timed out after {} ms",
provider.name(), config.getTimeout());
+ health.add(provider.timeout());
+ } catch (InterruptedException e) {
+ futures.forEach(f -> f.cancel(true));
+ Thread.currentThread().interrupt();
+ LOGGER.warn("Health check refresh interrupted, keeping the
previous results");
+ return;
} catch (Exception e) {
- LOGGER.error("Error while executing health check", e);
+ LOGGER.error("Error while executing health check {}",
provider.name(), e);
health.add(provider.timeout());
}
}
diff --git
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/ElasticSearchHealthCheckProvider.java
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/ElasticSearchHealthCheckProvider.java
index b64ad50a4..708e3b467 100644
---
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/ElasticSearchHealthCheckProvider.java
+++
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/ElasticSearchHealthCheckProvider.java
@@ -24,6 +24,7 @@ import org.apache.http.HttpEntity;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
+import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.BasicCredentialsProvider;
@@ -120,7 +121,13 @@ public class ElasticSearchHealthCheckProvider implements
HealthCheckProvider {
config.get(HealthCheckConfig.CONFIG_ES_ADDRESSES).split(",")[0].trim()).concat("/_cluster/health");
CloseableHttpResponse response = null;
try {
- response = httpClient.execute(new HttpGet(url));
+ HttpGet request = new HttpGet(url);
+ request.setConfig(RequestConfig.custom()
+ .setConnectTimeout(config.getTimeout())
+ .setConnectionRequestTimeout(config.getTimeout())
+ .setSocketTimeout(config.getTimeout())
+ .build());
+ response = httpClient.execute(request);
if (response != null &&
response.getStatusLine().getStatusCode() == 200) {
builder.up();
HttpEntity entity = response.getEntity();
diff --git
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/OpenSearchHealthCheckProvider.java
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/OpenSearchHealthCheckProvider.java
index 7e3b21461..53f04f606 100644
---
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/OpenSearchHealthCheckProvider.java
+++
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/OpenSearchHealthCheckProvider.java
@@ -24,6 +24,7 @@ import org.apache.http.HttpEntity;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
+import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.BasicCredentialsProvider;
@@ -120,7 +121,13 @@ public class OpenSearchHealthCheckProvider implements
HealthCheckProvider {
config.get(HealthCheckConfig.CONFIG_OS_ADDRESSES).split(",")[0].trim()).concat("/_cluster/health");
CloseableHttpResponse response = null;
try {
- response = httpClient.execute(new HttpGet(url));
+ HttpGet request = new HttpGet(url);
+ request.setConfig(RequestConfig.custom()
+ .setConnectTimeout(config.getTimeout())
+ .setConnectionRequestTimeout(config.getTimeout())
+ .setSocketTimeout(config.getTimeout())
+ .build());
+ response = httpClient.execute(request);
if (response != null &&
response.getStatusLine().getStatusCode() == 200) {
builder.up();
HttpEntity entity = response.getEntity();
diff --git
a/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
new file mode 100644
index 000000000..c4ebdfc9b
--- /dev/null
+++
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF 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
+ *
+ * http://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.apache.unomi.healthcheck;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class HealthCheckServiceTest {
+
+ private static final int TIMEOUT = 200;
+
+ private final CountDownLatch neverReleased = new CountDownLatch(1);
+ private HealthCheckService service;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ HealthCheckConfig config = new HealthCheckConfig();
+ config.setEnabledProviders(List.of("fast", "blocked"));
+ config.setTimeout(TIMEOUT);
+ service = new HealthCheckService();
+ service.setConfig(config);
+ }
+
+ @AfterEach
+ void tearDown() {
+ service.deactivate();
+ }
+
+ @Test
+ void blockedProviderTimesOutAndOtherProvidersAnswer() {
+ service.bind(new TestProvider("fast", () ->
HealthCheckResponse.live("fast")));
+ service.bind(new TestProvider("blocked", this::waitForever));
+
+ List<HealthCheckResponse> health =
assertTimeoutPreemptively(Duration.ofMillis(10L * TIMEOUT), service::check);
+
+ assertEquals(3, health.size());
+ assertTrue(response(health, "karaf").isLive());
+ assertTrue(response(health, "fast").isLive());
+ HealthCheckResponse blocked = response(health, "blocked");
+ assertTrue(blocked.isError());
+ assertEquals("timeout", blocked.getData().get("error.cause"));
+ }
+
+ @Test
+ void secondCallWithinOneSecondReturnsTheCachedResults() {
+ TestProvider fast = new TestProvider("fast", () ->
HealthCheckResponse.live("fast"));
+ service.bind(fast);
+
+ List<HealthCheckResponse> first = service.check();
+ List<HealthCheckResponse> second = service.check();
+
+ assertEquals(1, fast.calls.get());
+ assertEquals(first, second);
+ }
+
+ @Test
+ void concurrentCallsOnAnExpiredCacheRefreshOnlyOnce() throws Exception {
+ TestProvider slow = new TestProvider("fast", () -> {
+ try {
+ Thread.sleep(50);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ return HealthCheckResponse.live("fast");
+ });
+ service.bind(slow);
+ service.check();
+ Thread.sleep(1100);
+
+ int threads = 20;
+ CountDownLatch start = new CountDownLatch(1);
+ ExecutorService callers = Executors.newFixedThreadPool(threads);
+ try {
+ List<Future<?>> calls = new ArrayList<>();
+ for (int i = 0; i < threads; i++) {
+ calls.add(callers.submit(() -> {
+ start.await();
+ return service.check();
+ }));
+ }
+ start.countDown();
+ for (Future<?> call : calls) {
+ call.get(5, TimeUnit.SECONDS);
+ }
+ } finally {
+ callers.shutdownNow();
+ }
+
+ assertEquals(2, slow.calls.get());
+ }
+
+ private HealthCheckResponse waitForever() {
+ try {
+ neverReleased.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ return HealthCheckResponse.live("blocked");
+ }
+
+ private static HealthCheckResponse response(List<HealthCheckResponse>
health, String name) {
+ return health.stream().filter(r ->
r.getName().equals(name)).findFirst().orElseThrow();
+ }
+
+ private static class TestProvider implements HealthCheckProvider {
+
+ private final String name;
+ private final Supplier<HealthCheckResponse> execution;
+ private final AtomicInteger calls = new AtomicInteger();
+
+ TestProvider(String name, Supplier<HealthCheckResponse> execution) {
+ this.name = name;
+ this.execution = execution;
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public HealthCheckResponse execute() {
+ calls.incrementAndGet();
+ return execution.get();
+ }
+ }
+}