Starup Report added and StatisticsService rolling avgs optimized to O(n)
This commit is contained in:
parent
6478e046b4
commit
c7e9bce1d3
@ -98,7 +98,14 @@ public class ApplicationInitializer {
|
||||
);
|
||||
|
||||
context.setProcessManagerService(new ProcessManagerService(config));
|
||||
context.getProcessManagerService().startProcesses();
|
||||
|
||||
ProcessManagerService.StartupReport startupReport =
|
||||
context.getProcessManagerService().startProcesses();
|
||||
|
||||
if (startupReport.hasFailures()) {
|
||||
context.getProcessManagerService().shutdown();
|
||||
throw new IllegalStateException(startupReport.buildFailureMessage());
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
double rollingSum = 0.0;
|
||||
int windowStart = 0;
|
||||
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
int start = Math.max(0, i - windowSize);
|
||||
rollingSum += entries.get(i).getValue();
|
||||
|
||||
double sum = 0;
|
||||
int count = 0;
|
||||
|
||||
for (int j = start; j < i; j++) {
|
||||
sum += entries.get(j).getValue();
|
||||
count++;
|
||||
if (i - windowStart + 1 > windowSize) {
|
||||
rollingSum -= entries.get(windowStart).getValue();
|
||||
windowStart++;
|
||||
}
|
||||
|
||||
double avg = count == 0 ? 0 : sum / count;
|
||||
int windowCount = i - windowStart + 1;
|
||||
double avg = rollingSum / windowCount;
|
||||
|
||||
|
||||
result.add(new RatioPoint(
|
||||
entries.get(i).getTimestamp(),
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user