Skip to content

Commit 98c1c60

Browse files
Check external data sources in a separate health check category (#8198)
## Rationale The default health check should fail only when the LabKey database is unreachable, while external data sources stay visible through a separate category. Today an unreachable external data source makes the default check return 503, so load balancers and probes pull or restart a server that is otherwise working, and neither action can fix an external database. ## Changes - The default health check covers only the LabKey database. - External data sources are checked under a new `externalDataSources` category, for monitoring that wants an explicit signal; it also reports data sources that failed to connect. Their connection failure/reconnect logging now happens only when that category (or `all`) is polled. - Callers requesting `categories=all` see external data source failures as before. ## Tasks - [x] Claude Code Review - [x] Manual Testing - not needed - [x] Test Automation
1 parent d87a97b commit 98c1c60

4 files changed

Lines changed: 166 additions & 26 deletions

File tree

‎api/src/org/labkey/api/data/DbScope.java‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import org.apache.logging.log4j.Logger;
2626
import org.jetbrains.annotations.NotNull;
2727
import org.jetbrains.annotations.Nullable;
28+
import org.jetbrains.annotations.TestOnly;
2829
import org.junit.Assert;
2930
import org.junit.Assume;
3031
import org.junit.Test;
@@ -1856,6 +1857,17 @@ public static void addScope(String dsName, LabKeyDataSource dataSource)
18561857
}
18571858
}
18581859

1860+
@TestOnly
1861+
public static void removeScope(String dsName)
1862+
{
1863+
synchronized (_scopeLoaders)
1864+
{
1865+
_scopeLoaders.remove(dsName);
1866+
}
1867+
1868+
_dataSourceFailures.remove(dsName);
1869+
}
1870+
18591871
public boolean isRds()
18601872
{
18611873
return _rds;
@@ -2214,6 +2226,17 @@ public static DbScope getDbScope(String dsName)
22142226
.toList();
22152227
}
22162228

2229+
/**
2230+
* Returns the names of data sources whose connection attempt failed, which {@link #getDbScopes()} omits
2231+
*/
2232+
public static @NotNull Set<String> getFailedDataSourceNames()
2233+
{
2234+
return getLoaders().stream()
2235+
.filter(DbScopeLoader::isFailed)
2236+
.map(DbScopeLoader::getDsName)
2237+
.collect(Collectors.toCollection(LinkedHashSet::new));
2238+
}
2239+
22172240
/**
22182241
* Clear out the DbScopeLoaders that previously failed to connect to their data source. Next call to getDbScopes()
22192242
* will attempt to retry those failed connections.

‎core/api-src/org/labkey/api/admin/HealthCheckRegistry.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public class HealthCheckRegistry
3737

3838
public static final String DEFAULT_CATEGORY = "default";
3939
public static final String TRIAL_INSTANCES_CATEGORY = "trial";
40+
public static final String EXTERNAL_DATA_SOURCES_CATEGORY = "externalDataSources";
4041

4142
private final Map<String, Map<String, HealthCheck>> _healthCheckCategories = new HashMap<>();
4243
private static final HealthCheckRegistry _instance = new HealthCheckRegistry();

‎core/src/org/labkey/core/CoreModule.java‎

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -591,41 +591,65 @@ private void deleteTempFiles()
591591
}
592592
}
593593

594+
private static boolean isConnected(DbScope dbScope, Set<String> failedDataSources)
595+
{
596+
boolean dbConnected;
597+
try (Connection conn = dbScope.getConnection())
598+
{
599+
dbConnected = conn != null;
600+
}
601+
// Some failures come as ConfigurationException, not SQLException. Cast a wide net to ensure
602+
// we return a 200 saying we're not healthy instead of a 500
603+
catch (Exception e)
604+
{
605+
if (failedDataSources.add(dbScope.getDataSourceName()))
606+
LOG.warn("Failed to get connection for data source {}", dbScope.getDataSourceName(), e);
607+
dbConnected = false;
608+
}
609+
610+
if (dbConnected && failedDataSources.remove(dbScope.getDataSourceName()))
611+
LOG.info("Reconnected to data source {}", dbScope.getDataSourceName());
612+
613+
return dbConnected;
614+
}
615+
594616
private void registerHealthChecks()
595617
{
596618
// Data sources that were unreachable on the previous check, so we can log transitions instead of every poll
597619
Set<String> failedDataSources = ConcurrentHashMap.newKeySet();
598620

599-
HealthCheckRegistry.get().registerHealthCheck("database", HealthCheckRegistry.DEFAULT_CATEGORY, () ->
600-
{
601-
Map<String, Object> healthValues = new HashMap<>();
602-
boolean allConnected = true;
603-
for (DbScope dbScope : DbScope.getDbScopes())
604-
{
605-
boolean dbConnected;
606-
try (Connection conn = dbScope.getConnection())
607-
{
608-
dbConnected = conn != null;
609-
}
610-
// Some failures come as ConfigurationException, not SQLException. Cast a wide net to ensure
611-
// we return a 200 saying we're not healthy instead of a 500
612-
catch (Exception e)
613-
{
614-
if (failedDataSources.add(dbScope.getDataSourceName()))
615-
LOG.warn("Failed to get connection for data source {}", dbScope.getDataSourceName(), e);
616-
dbConnected = false;
617-
}
621+
// Default-category failures take the server out of rotation, so check only the LabKey scope; getDbScopes() would
622+
// also connect to external data sources, blocking until an unreachable one times out
623+
HealthCheckRegistry.get().registerHealthCheck("database", HealthCheckRegistry.DEFAULT_CATEGORY, () -> {
624+
DbScope labkeyScope = DbScope.getLabKeyScope();
625+
boolean dbConnected = isConnected(labkeyScope, failedDataSources);
626+
Map<String, Object> healthValues = new HashMap<>();
627+
healthValues.put(labkeyScope.getDatabaseName(), dbConnected);
628+
return new HealthCheck.Result(dbConnected, healthValues);
629+
});
618630

619-
if (dbConnected && failedDataSources.remove(dbScope.getDataSourceName()))
620-
LOG.info("Reconnected to data source {}", dbScope.getDataSourceName());
631+
HealthCheckRegistry.get().registerHealthCheck("database", HealthCheckRegistry.EXTERNAL_DATA_SOURCES_CATEGORY, () -> {
632+
Map<String, Object> healthValues = new HashMap<>();
633+
boolean allConnected = true;
634+
for (DbScope dbScope : DbScope.getDbScopes())
635+
{
636+
if (dbScope.isLabKeyScope())
637+
continue;
621638

622-
healthValues.put(dbScope.getDatabaseName(), dbConnected);
623-
allConnected &= dbConnected;
624-
}
639+
boolean dbConnected = isConnected(dbScope, failedDataSources);
640+
healthValues.put(dbScope.getDatabaseName(), dbConnected);
641+
allConnected &= dbConnected;
642+
}
625643

626-
return new HealthCheck.Result(allConnected, healthValues);
644+
// getDbScopes() omits data sources that failed to connect, and those have no database name
645+
for (String dsName : DbScope.getFailedDataSourceNames())
646+
{
647+
healthValues.put(dsName, false);
648+
allConnected = false;
627649
}
628-
);
650+
651+
return new HealthCheck.Result(allConnected, healthValues);
652+
});
629653

630654
HealthCheckRegistry.get().registerHealthCheck("modules", HealthCheckRegistry.TRIAL_INSTANCES_CATEGORY, () -> {
631655
Map<String, Throwable> failures = ModuleLoader.getInstance().getModuleFailures();
@@ -1484,6 +1508,7 @@ public TabDisplayMode getTabDisplayMode()
14841508
DavController.MoveActionContainerScopingTestCase.class,
14851509
EmailServiceImpl.TestCase.class,
14861510
FilesSiteSettingsAction.TestCase.class,
1511+
HealthCheckTestCase.class,
14871512
LoggerController.TestCase.class,
14881513
LoggingTestCase.class,
14891514
LoginController.TestCase.class,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright (c) 2026 LabKey Corporation
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.labkey.core;
17+
18+
import org.apache.logging.log4j.Level;
19+
import org.apache.logging.log4j.LogManager;
20+
import org.apache.logging.log4j.core.config.Configurator;
21+
import org.junit.Assert;
22+
import org.junit.Test;
23+
import org.labkey.api.admin.HealthCheck;
24+
import org.labkey.api.admin.HealthCheckRegistry;
25+
import org.labkey.api.data.DbScope;
26+
27+
import javax.sql.DataSource;
28+
import java.util.HashSet;
29+
import java.util.List;
30+
import java.util.Map;
31+
import java.util.Set;
32+
33+
public class HealthCheckTestCase extends Assert
34+
{
35+
private static final String UNREACHABLE_DS = "healthCheckTestDataSource";
36+
private static final String SCOPE_LOADER_LOGGER = "org.labkey.api.data.DbScopeLoader";
37+
38+
private static Map<String, Object> databaseDetails(String category)
39+
{
40+
HealthCheck.Result result = (HealthCheck.Result) HealthCheckRegistry.get().checkHealth(category).getDetails().get("database");
41+
return result.getDetails();
42+
}
43+
44+
@Test
45+
public void testDefaultCoversOnlyLabKeyScope()
46+
{
47+
assertEquals(Set.of(DbScope.getLabKeyScope().getDatabaseName()), databaseDetails(HealthCheckRegistry.DEFAULT_CATEGORY).keySet());
48+
}
49+
50+
@Test
51+
public void testExternalCoversOtherScopes()
52+
{
53+
Set<String> expected = new HashSet<>();
54+
DbScope.getDbScopes().stream()
55+
.filter(scope -> !scope.isLabKeyScope())
56+
.forEach(scope -> expected.add(scope.getDatabaseName()));
57+
expected.addAll(DbScope.getFailedDataSourceNames());
58+
assertEquals(expected, databaseDetails(HealthCheckRegistry.EXTERNAL_DATA_SOURCES_CATEGORY).keySet());
59+
}
60+
61+
@Test
62+
public void testUnreachableExternalDataSource() throws Exception
63+
{
64+
// Core doesn't compile against tomcat-dbcp, but it's the only pool LabKeyDataSource accepts
65+
Class<?> poolClass = Class.forName("org.apache.tomcat.dbcp.dbcp2.BasicDataSource");
66+
DataSource ds = (DataSource) poolClass.getConstructor().newInstance();
67+
poolClass.getMethod("setDriverClassName", String.class).invoke(ds, "org.postgresql.Driver");
68+
// Port 1 refuses immediately, so the connection attempt fails fast instead of waiting out a timeout
69+
poolClass.getMethod("setUrl", String.class).invoke(ds, "jdbc:postgresql://127.0.0.1:1/healthchecktest");
70+
DbScope.addScope(UNREACHABLE_DS, new DbScope.LabKeyDataSource(ds, UNREACHABLE_DS));
71+
72+
// The loader logs the expected connection failure as an error, which the test harness would report as a server error
73+
Level previousLevel = LogManager.getLogger(SCOPE_LOADER_LOGGER).getLevel();
74+
Configurator.setLevel(SCOPE_LOADER_LOGGER, Level.OFF);
75+
try
76+
{
77+
assertTrue(HealthCheckRegistry.get().checkHealth(HealthCheckRegistry.DEFAULT_CATEGORY).isHealthy());
78+
assertFalse("Default check connected to an external data source", DbScope.getFailedDataSourceNames().contains(UNREACHABLE_DS));
79+
80+
assertFalse(HealthCheckRegistry.get().checkHealth(HealthCheckRegistry.EXTERNAL_DATA_SOURCES_CATEGORY).isHealthy());
81+
assertEquals(false, databaseDetails(HealthCheckRegistry.EXTERNAL_DATA_SOURCES_CATEGORY).get(UNREACHABLE_DS));
82+
83+
assertFalse(HealthCheckRegistry.get().checkHealth(List.of("all")).isHealthy());
84+
}
85+
finally
86+
{
87+
Configurator.setLevel(SCOPE_LOADER_LOGGER, previousLevel);
88+
DbScope.removeScope(UNREACHABLE_DS);
89+
}
90+
}
91+
}

0 commit comments

Comments
 (0)