Starup Report added and StatisticsService rolling avgs optimized to O(n)

This commit is contained in:
Niklas Aumueller 2026-03-06 18:16:43 +01:00
parent 6478e046b4
commit c7e9bce1d3
6 changed files with 391 additions and 30 deletions

View File

@ -98,8 +98,15 @@ public class ApplicationInitializer {
);
context.setProcessManagerService(new ProcessManagerService(config));
ProcessManagerService.StartupReport startupReport =
context.getProcessManagerService().startProcesses();
if (startupReport.hasFailures()) {
context.getProcessManagerService().shutdown();
throw new IllegalStateException(startupReport.buildFailureMessage());
}
return context;
}

View File

@ -107,7 +107,7 @@ public class DataPersistenceService {
String query = """
SELECT value, timestamp
FROM binary_event
ORDER BY timestamp DESC
ORDER BY id DESC
LIMIT ?
""";
try (Connection c = DriverManager.getConnection(DB_URL); PreparedStatement ps = c.prepareStatement(query)) {

View File

@ -13,6 +13,205 @@ import java.util.Properties;
*/
public class ProcessManagerService {
/**
* Represents startup status for a single managed process component.
*/
public static final class ProcessStartStatus {
private final String componentName;
private final boolean enabled;
private final boolean started;
private final String errorMessage;
/**
* Creates an immutable process start status instance.
*
* @param componentName logical component name
* @param enabled whether component startup was enabled
* @param started whether startup completed without launch error
* @param errorMessage optional startup error details
*/
private ProcessStartStatus(
String componentName,
boolean enabled,
boolean started,
String errorMessage
) {
this.componentName = componentName;
this.enabled = enabled;
this.started = started;
this.errorMessage = errorMessage;
}
/**
* Creates a disabled status for a component.
*
* @param componentName logical component name
* @return disabled status instance
*/
static ProcessStartStatus disabled(String componentName) {
return new ProcessStartStatus(componentName, false, false, null);
}
/**
* Creates a successful startup status for a component.
*
* @param componentName logical component name
* @return successful status instance
*/
static ProcessStartStatus started(String componentName) {
return new ProcessStartStatus(componentName, true, true, null);
}
/**
* Creates a failed startup status for a component.
*
* @param componentName logical component name
* @param errorMessage startup failure details
* @return failure status instance
*/
static ProcessStartStatus failed(String componentName, String errorMessage) {
return new ProcessStartStatus(componentName, true, false, errorMessage);
}
/**
* Returns whether startup failed for an enabled component.
*
* @return {@code true} when startup failed
*/
public boolean hasFailed() {
return enabled && !started;
}
/**
* Returns logical component name.
*
* @return component name
*/
public String getComponentName() {
return componentName;
}
/**
* Returns whether startup was enabled.
*
* @return enabled flag
*/
public boolean isEnabled() {
return enabled;
}
/**
* Returns whether startup completed without launch error.
*
* @return started flag
*/
public boolean isStarted() {
return started;
}
/**
* Returns startup error details when available.
*
* @return error details or {@code null}
*/
public String getErrorMessage() {
return errorMessage;
}
}
/**
* Aggregates startup status for all managed process components.
*/
public static final class StartupReport {
private final ProcessStartStatus mqttSimulator;
private final ProcessStartStatus unreal;
/**
* Creates a startup report.
*
* @param mqttSimulator startup status of MQTT simulator component
* @param unreal startup status of Unreal launcher component
*/
StartupReport(
ProcessStartStatus mqttSimulator,
ProcessStartStatus unreal
) {
this.mqttSimulator = Objects.requireNonNull(mqttSimulator);
this.unreal = Objects.requireNonNull(unreal);
}
/**
* Returns startup status of MQTT simulator component.
*
* @return MQTT simulator startup status
*/
public ProcessStartStatus getMqttSimulator() {
return mqttSimulator;
}
/**
* Returns startup status of Unreal launcher component.
*
* @return Unreal startup status
*/
public ProcessStartStatus getUnreal() {
return unreal;
}
/**
* Returns whether at least one enabled process failed to launch.
*
* @return {@code true} when startup has failures
*/
public boolean hasFailures() {
return mqttSimulator.hasFailed() || unreal.hasFailed();
}
/**
* Builds a compact startup failure message suitable for exceptions.
*
* @return failure message
*/
public String buildFailureMessage() {
StringBuilder message =
new StringBuilder("External process startup failed:");
appendFailure(message, mqttSimulator);
appendFailure(message, unreal);
return message.toString();
}
/**
* Appends one failed component summary to a message builder.
*
* @param message target message builder
* @param status component startup status
*/
private static void appendFailure(
StringBuilder message,
ProcessStartStatus status
) {
if (!status.hasFailed()) {
return;
}
message.append(System.lineSeparator())
.append("- ")
.append(status.getComponentName())
.append(": ");
String error = status.getErrorMessage();
if (error == null || error.isBlank()) {
message.append("unknown startup error");
} else {
message.append(error);
}
}
}
@FunctionalInterface
interface ProcessLauncher {
Process launch(ProcessBuilder processBuilder) throws IOException;
@ -78,23 +277,31 @@ public class ProcessManagerService {
/**
* Starts optional helper processes according to configuration flags.
*
* @return startup status report for all managed components
*/
public void startProcesses() {
public StartupReport startProcesses() {
startPythonIfEnabled();
startUnrealIfEnabled();
ProcessStartStatus mqttSimulator = startPythonIfEnabled();
ProcessStartStatus unreal = startUnrealIfEnabled();
return new StartupReport(mqttSimulator, unreal);
}
/**
* Starts the Python MQTT simulator if enabled.
*
* @return startup status for the simulator component
*/
private void startPythonIfEnabled() {
private ProcessStartStatus startPythonIfEnabled() {
boolean enabled = Boolean.parseBoolean(
config.getProperty("mqtt_sim.enabled", "false")
);
if (!enabled) return;
if (!enabled) {
return ProcessStartStatus.disabled("mqtt_sim");
}
try {
String script = cleanConfigValue(config.getProperty("mqtt_sim.script"));
@ -107,25 +314,39 @@ public class ProcessManagerService {
pythonProcess = processLauncher.launch(pb);
Logger.info("PROCESS", "Mqtt Simulator gestartet");
} catch (IOException e) {
return ProcessStartStatus.started("mqtt_sim");
} catch (Exception e) {
Logger.error("PROCESS", "Mqtt Simulator Start fehlgeschlagen", e);
return ProcessStartStatus.failed(
"mqtt_sim",
exceptionMessage(e)
);
}
}
/**
* Starts Unreal-related processes if enabled.
*
* @return startup status for the Unreal component
*/
private void startUnrealIfEnabled() {
private ProcessStartStatus startUnrealIfEnabled() {
boolean enabled = Boolean.parseBoolean(config.getProperty("unreal.enabled", "false"));
if (!enabled) return;
if (!enabled) {
return ProcessStartStatus.disabled("unreal");
}
try {
startUnrealEngine();
return ProcessStartStatus.started("unreal");
} catch (IOException e) {
} catch (Exception e) {
Logger.error("PROCESS", "Unreal Start fehlgeschlagen", e);
return ProcessStartStatus.failed(
"unreal",
exceptionMessage(e)
);
}
}
@ -273,6 +494,23 @@ public class ProcessManagerService {
return trimmed;
}
/**
* Returns a compact message for startup exception reporting.
*
* @param exception startup exception
* @return compact exception details
*/
private static String exceptionMessage(Exception exception) {
String type = exception.getClass().getSimpleName();
String message = exception.getMessage();
if (message == null || message.isBlank()) {
return type;
}
return type + ": " + message;
}
/**
* Passes optional simulator settings from app config to the Python process environment.
*

View File

@ -5,7 +5,6 @@ import vassistent.model.RatioPoint;
import vassistent.util.Logger;
import java.sql.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@ -31,6 +30,10 @@ public class StatisticsService {
* @return ratio in range {@code [0, 1]} or {@code 0.0} when unavailable
*/
public double getRatio(int lastN) {
if (lastN <= 0) {
return 0.0;
}
String query = """
SELECT AVG(value)
FROM (
@ -58,30 +61,40 @@ public class StatisticsService {
/**
* Computes rolling averages for chart rendering.
*
* @param windowSize number of previous entries considered for each point
* The rolling window includes the current entry and up to {@code windowSize - 1}
* previous entries.
*
* @param windowSize number of entries considered per rolling average window
* @return chronological list of ratio points
*/
public List<RatioPoint> getLastNAverages(int windowSize) {
if (windowSize <= 0) {
return List.of();
}
List<DatabaseEntry> entries = persistenceService.getLastEntries(windowSize + 1);
List<RatioPoint> result = new ArrayList<>();
if (entries.isEmpty())
if (entries.isEmpty()) {
return result;
for (int i = 0; i < entries.size(); i++) {
int start = Math.max(0, i - windowSize);
double sum = 0;
int count = 0;
for (int j = start; j < i; j++) {
sum += entries.get(j).getValue();
count++;
}
double avg = count == 0 ? 0 : sum / count;
double rollingSum = 0.0;
int windowStart = 0;
for (int i = 0; i < entries.size(); i++) {
rollingSum += entries.get(i).getValue();
if (i - windowStart + 1 > windowSize) {
rollingSum -= entries.get(windowStart).getValue();
windowStart++;
}
int windowCount = i - windowStart + 1;
double avg = rollingSum / windowCount;
result.add(new RatioPoint(
entries.get(i).getTimestamp(),

View File

@ -14,6 +14,7 @@ import java.util.List;
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ -92,11 +93,14 @@ class ProcessManagerServiceTest {
config.setProperty("unreal.enabled", "false");
ProcessManagerService service = createService();
service.startProcesses();
ProcessManagerService.StartupReport report = service.startProcesses();
assertTrue(launchedCommands.isEmpty());
assertNull(service.getPythonProcess());
assertNull(service.getUnrealProcess());
assertFalse(report.hasFailures());
assertFalse(report.getMqttSimulator().isEnabled());
assertFalse(report.getUnreal().isEnabled());
}
/**
@ -110,11 +114,14 @@ class ProcessManagerServiceTest {
config.setProperty("unreal.enabled", "false");
ProcessManagerService service = createService();
service.startProcesses();
ProcessManagerService.StartupReport report = service.startProcesses();
assertEquals(1, launchedCommands.size());
assertEquals(List.of("python", "sim.py"), launchedCommands.get(0));
assertSame(process, service.getPythonProcess());
assertFalse(report.hasFailures());
assertTrue(report.getMqttSimulator().isStarted());
assertFalse(report.getUnreal().isEnabled());
}
/**
@ -128,7 +135,7 @@ class ProcessManagerServiceTest {
config.setProperty("unreal.signalling_server.script", "start_signal.bat");
ProcessManagerService service = createService();
service.startProcesses();
ProcessManagerService.StartupReport report = service.startProcesses();
assertEquals(1, launchedCommands.size());
assertEquals(
@ -140,6 +147,9 @@ class ProcessManagerServiceTest {
.noneMatch(command -> !command.isEmpty() && "cmd".equalsIgnoreCase(command.get(0)))
);
assertSame(process, service.getUnrealProcess());
assertFalse(report.hasFailures());
assertTrue(report.getUnreal().isStarted());
assertFalse(report.getMqttSimulator().isEnabled());
}
/**
@ -157,7 +167,7 @@ class ProcessManagerServiceTest {
config.setProperty("unreal.signalling_server.script", "start_signal.bat");
ProcessManagerService service = createService();
service.startProcesses();
ProcessManagerService.StartupReport report = service.startProcesses();
service.shutdown();
long taskkillCount = launchedCommands.stream()
@ -165,6 +175,7 @@ class ProcessManagerServiceTest {
.count();
assertEquals(2, taskkillCount);
assertFalse(report.hasFailures());
}
/**
@ -198,4 +209,61 @@ class ProcessManagerServiceTest {
assertTrue(launchedCommands.stream().anyMatch(command -> command.contains("777")));
assertTrue(launchedCommands.stream().anyMatch(command -> command.contains("888")));
}
/**
* Verifies a failed simulator launch is reported as startup failure.
*/
@Test
void startProcesses_mqttLaunchFails_reportsFailure() {
config.setProperty("mqtt_sim.enabled", "true");
config.setProperty("mqtt_sim.script", "sim.py");
config.setProperty("python.path", "python");
config.setProperty("unreal.enabled", "false");
ProcessManagerService.ProcessLauncher failingLauncher = processBuilder -> {
throw new java.io.IOException("python not found");
};
ProcessManagerService service = new ProcessManagerService(
config,
failingLauncher,
tempDir.resolve("unreal.pid").toString(),
tempDir.resolve("signalling.pid").toString()
);
ProcessManagerService.StartupReport report = service.startProcesses();
assertTrue(report.hasFailures());
assertTrue(report.getMqttSimulator().hasFailed());
assertTrue(report.buildFailureMessage().contains("mqtt_sim"));
assertTrue(report.buildFailureMessage().contains("python not found"));
}
/**
* Verifies a failed Unreal launch is reported as startup failure.
*/
@Test
void startProcesses_unrealLaunchFails_reportsFailure() {
config.setProperty("mqtt_sim.enabled", "false");
config.setProperty("unreal.enabled", "true");
config.setProperty("unreal.executable", "avatar.ps1");
ProcessManagerService.ProcessLauncher failingLauncher = processBuilder -> {
throw new java.io.IOException("powershell unavailable");
};
ProcessManagerService service = new ProcessManagerService(
config,
failingLauncher,
tempDir.resolve("unreal.pid").toString(),
tempDir.resolve("signalling.pid").toString()
);
ProcessManagerService.StartupReport report = service.startProcesses();
assertTrue(report.hasFailures());
assertTrue(report.getUnreal().hasFailed());
assertTrue(report.buildFailureMessage().contains("unreal"));
assertTrue(report.buildFailureMessage().contains("powershell unavailable"));
}
}

View File

@ -14,6 +14,7 @@ import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
@ -55,8 +56,8 @@ class StatisticsServiceTest {
List<RatioPoint> result = unitService.getLastNAverages(2);
assertEquals(3, result.size());
assertEquals(0.0, result.get(0).getRatio(), 0.0001);
assertEquals(1.0, result.get(1).getRatio(), 0.0001);
assertEquals(1.0, result.get(0).getRatio(), 0.0001);
assertEquals(0.5, result.get(1).getRatio(), 0.0001);
assertEquals(0.5, result.get(2).getRatio(), 0.0001);
}
@ -72,6 +73,16 @@ class StatisticsServiceTest {
assertTrue(result.isEmpty());
}
/**
* Verifies that non-positive window sizes yield an empty result.
*/
@Test
void getLastNAverages_withNonPositiveWindow_returnsEmptyList() {
List<RatioPoint> result = unitService.getLastNAverages(0);
assertTrue(result.isEmpty());
}
/**
* Verifies average ratio over newest values using real SQLite persistence.
*
@ -107,4 +118,28 @@ class StatisticsServiceTest {
assertEquals(0.0, ratio, 0.0001);
}
/**
* Verifies that the newest rolling average matches {@link StatisticsService#getRatio(int)}
* for the same window size.
*/
@Test
void getLastNAverages_lastPointMatchesGetRatio() {
String dbUrl = "jdbc:sqlite:" + tempDir.resolve("ratio-rolling.db").toAbsolutePath();
DataPersistenceService integrationPersistence = new DataPersistenceService(dbUrl);
StatisticsService integrationService = new StatisticsService(integrationPersistence);
integrationPersistence.store(1);
integrationPersistence.store(0);
integrationPersistence.store(1);
integrationPersistence.store(1);
int window = 3;
List<RatioPoint> rolling = integrationService.getLastNAverages(window);
double ratio = integrationService.getRatio(window);
assertFalse(rolling.isEmpty());
assertEquals(ratio, rolling.get(rolling.size() - 1).getRatio(), 0.0001);
}
}