chore: harden lifecycle, health checks and CI
CI / Backend tests (Maven / Java 21) (push) Failing after 7m9s

- add PR trigger and Maven cache in CI

- centralize shutdown cleanup in Main

- make HealthMonitor failure counter thread-safe

- fix UTF-8 response length handling in health endpoint

- improve Influx username null-safety

- reduce expected modbus read failures from ERROR to WARN
This commit is contained in:
root committed 2026-02-12 22:50:37 +01:00
1 parent e345063720
commit dd739e16c0
7 files changed
+36 -36

No files matched your search

+5 -5
View File
@@ -4,6 +4,9 @@ on:
push: push:
branches: branches:
- "**" - "**"
pull_request:
branches:
- "**"
jobs: jobs:
backend: backend:
@@ -23,10 +26,7 @@ jobs:
uses: actions/setup-java@v5 uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
java-version: '21' java-version: "21"
- name: Set up Maven cache: maven
uses: stCarolas/setup-maven@v5
with:
maven-version: 3.9.6
- name: Run Maven tests (parallel) - name: Run Maven tests (parallel)
run: mvn -B -T 1C test run: mvn -B -T 1C test
+13 -4
View File
@@ -41,12 +41,14 @@ public class Main {
final var useMock = config.getUseMock() != null && config.getUseMock(); final var useMock = config.getUseMock() != null && config.getUseMock();
ModbusService service; ModbusService service;
ModbusConnection modbusConnection = null;
if (useMock) { if (useMock) {
service = new ModbusService(new home.automation.modbus.MockRepository(registryMappings)); service = new ModbusService(new home.automation.modbus.MockRepository(registryMappings));
} else { } else {
modbusConnection = new ModbusConnection(jsonFileBasedConfiguration);
service = new ModbusService( service = new ModbusService(
new ModbusRtuTcpRepository( new ModbusRtuTcpRepository(
new ModbusConnection(jsonFileBasedConfiguration), modbusConnection,
registryMappings)); registryMappings));
} }
@@ -57,12 +59,21 @@ public class Main {
healthCheckServer.start(); healthCheckServer.start();
// Shutdown hook for graceful cleanup // Shutdown hook for graceful cleanup
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
ScheduledExecutorService healthScheduler = Executors.newSingleThreadScheduledExecutor();
Runtime.getRuntime().addShutdownHook(new Thread(() -> { Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOGGER.info("Shutdown signal received, cleaning up resources..."); LOGGER.info("Shutdown signal received, cleaning up resources...");
try { try {
scheduler.shutdownNow();
healthScheduler.shutdownNow();
healthCheckServer.close(); healthCheckServer.close();
if (modbusConnection != null) {
modbusConnection.close();
}
connection.close();
} catch (Exception e) { } catch (Exception e) {
LOGGER.error("Error closing health check server", e); LOGGER.error("Error during shutdown cleanup", e);
} }
})); }));
@@ -70,7 +81,6 @@ public class Main {
? 300 ? 300
: config.getInfluxSendIntervalSeconds(); : config.getInfluxSendIntervalSeconds();
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate( scheduler.scheduleAtFixedRate(
new InfluxMetricModbusSender(connection.getConnection(), pollingService), new InfluxMetricModbusSender(connection.getConnection(), pollingService),
0, 0,
@@ -78,7 +88,6 @@ public class Main {
TimeUnit.SECONDS); TimeUnit.SECONDS);
// Monitor health and trigger restart if needed // Monitor health and trigger restart if needed
ScheduledExecutorService healthScheduler = Executors.newSingleThreadScheduledExecutor();
healthScheduler.scheduleAtFixedRate(() -> { healthScheduler.scheduleAtFixedRate(() -> {
if (healthMonitor.hasExceededFailureThreshold()) { if (healthMonitor.hasExceededFailureThreshold()) {
LOGGER.error("Health monitor threshold exceeded ({} consecutive failures). Initiating shutdown for restart.", LOGGER.error("Health monitor threshold exceeded ({} consecutive failures). Initiating shutdown for restart.",
@@ -36,22 +36,24 @@ public class HealthCheckServer implements AutoCloseable {
final HealthStatus status = healthMonitor.getHealthStatus(); final HealthStatus status = healthMonitor.getHealthStatus();
final HealthResponse response = HealthResponse.fromHealthStatus(status); final HealthResponse response = HealthResponse.fromHealthStatus(status);
final String jsonResponse = response.toJson(); final String jsonResponse = response.toJson();
final byte[] body = jsonResponse.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set(CONTENT_TYPE, CONTENT_TYPE_JSON); exchange.getResponseHeaders().set(CONTENT_TYPE, CONTENT_TYPE_JSON);
exchange.sendResponseHeaders(response.getHttpStatusCode(), jsonResponse.length()); exchange.sendResponseHeaders(response.getHttpStatusCode(), body.length);
try (OutputStream os = exchange.getResponseBody()) { try (OutputStream os = exchange.getResponseBody()) {
os.write(jsonResponse.getBytes(StandardCharsets.UTF_8)); os.write(body);
} }
} catch (Exception e) { } catch (Exception e) {
LOGGER.error("Error handling health check", e); LOGGER.error("Error handling health check", e);
final HealthResponse errorResponse = HealthResponse.error("Internal error"); final HealthResponse errorResponse = HealthResponse.error("Internal error");
final String jsonResponse = errorResponse.toJson(); final String jsonResponse = errorResponse.toJson();
final byte[] body = jsonResponse.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set(CONTENT_TYPE, CONTENT_TYPE_JSON); exchange.getResponseHeaders().set(CONTENT_TYPE, CONTENT_TYPE_JSON);
exchange.sendResponseHeaders(errorResponse.getHttpStatusCode(), jsonResponse.length()); exchange.sendResponseHeaders(errorResponse.getHttpStatusCode(), body.length);
try (OutputStream os = exchange.getResponseBody()) { try (OutputStream os = exchange.getResponseBody()) {
os.write(jsonResponse.getBytes(StandardCharsets.UTF_8)); os.write(body);
} }
} }
} }
@@ -5,11 +5,12 @@ import static home.automation.health.HealthState.*;
import java.time.Instant; import java.time.Instant;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class HealthMonitor { public class HealthMonitor {
private final List<HealthIndicator> indicators; private final List<HealthIndicator> indicators;
private int consecutiveFailures = 0; private final AtomicInteger consecutiveFailures = new AtomicInteger(0);
private final int failureThreshold; private final int failureThreshold;
public HealthMonitor(List<HealthIndicator> indicators) { public HealthMonitor(List<HealthIndicator> indicators) {
@@ -33,9 +34,9 @@ public class HealthMonitor {
// Track consecutive failures // Track consecutive failures
if (overallState != UP) { if (overallState != UP) {
consecutiveFailures++; consecutiveFailures.incrementAndGet();
} else { } else {
consecutiveFailures = 0; consecutiveFailures.set(0);
} }
final Instant lastSuccessfulRead = componentHealth.stream() final Instant lastSuccessfulRead = componentHealth.stream()
@@ -53,18 +54,18 @@ public class HealthMonitor {
.map(ComponentHealth::getComponent) .map(ComponentHealth::getComponent)
.collect(Collectors.joining(", ")); .collect(Collectors.joining(", "));
message = String.format("Unhealthy components: %s (consecutive failures: %d/%d)", affected, message = String.format("Unhealthy components: %s (consecutive failures: %d/%d)", affected,
consecutiveFailures, failureThreshold); consecutiveFailures.get(), failureThreshold);
} }
return new HealthStatus(overallState, lastSuccessfulRead, message, componentHealth); return new HealthStatus(overallState, lastSuccessfulRead, message, componentHealth);
} }
public boolean hasExceededFailureThreshold() { public boolean hasExceededFailureThreshold() {
return consecutiveFailures >= failureThreshold; return consecutiveFailures.get() >= failureThreshold;
} }
public int getConsecutiveFailures() { public int getConsecutiveFailures() {
return consecutiveFailures; return consecutiveFailures.get();
} }
private static int statePriority(HealthState state) { private static int statePriority(HealthState state) {
@@ -11,21 +11,15 @@ public class InfluxConnection implements AutoCloseable {
private final InfluxDB connection; private final InfluxDB connection;
public InfluxConnection(InfluxDBConfiguration configuration) { public InfluxConnection(InfluxDBConfiguration configuration) {
if (configuration.getUsername().isBlank()) { final String username = configuration.getUsername();
if (username == null || username.isBlank()) {
this.connection = InfluxDBFactory.connect(configuration.getAddress()); this.connection = InfluxDBFactory.connect(configuration.getAddress());
} else { } else {
this.connection = InfluxDBFactory.connect( this.connection = InfluxDBFactory.connect(
configuration.getAddress(), configuration.getAddress(),
configuration.getUsername(), username,
configuration.getPassword()); configuration.getPassword());
} }
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
this.close();
} catch (Exception e) {
LOGGER.error("Error closing InfluxDB connection during shutdown", e);
}
}));
} }
public InfluxDB getConnection() { public InfluxDB getConnection() {
@@ -18,13 +18,6 @@ public class ModbusConnection implements Connection, AutoCloseable {
public ModbusConnection(ConfigurationStrategy config) { public ModbusConnection(ConfigurationStrategy config) {
this.config = config; this.config = config;
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
this.close();
} catch (Exception e) {
LOGGER.warn("Error closing Modbus connection during shutdown: {}", e.getMessage());
}
}));
} }
private Pair<InetAddress, Integer> getPortAndAddressFromConfig(ConfigurationStrategy config) { private Pair<InetAddress, Integer> getPortAndAddressFromConfig(ConfigurationStrategy config) {
@@ -145,7 +145,8 @@ public class ModbusRtuTcpRepository implements Repository {
LOGGER.info("Registry {}: calculated value = {}", register, value); LOGGER.info("Registry {}: calculated value = {}", register, value);
return new Registry(register, mapping.getDesc(), value); return new Registry(register, mapping.getDesc(), value);
} catch (Exception e) { } catch (Exception e) {
LOGGER.error("Cannot get registry {} due to: ", register, e); LOGGER.warn("Cannot get registry {}: {}", register, e.getMessage());
LOGGER.debug("Cannot get registry {} stack trace", register, e);
throw new RuntimeException("Cannot get registry " + register, e); throw new RuntimeException("Cannot get registry " + register, e);
} }
} }